From e70c4a90f31e19f54232afc8a2596e41282abe13 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Wed, 22 Jul 2026 18:52:03 +0100 Subject: [PATCH] file viewer, docs, ws: add image/media preview path, projects doc, ws wiring Show file gains image and video display for capable agents. Docs add projects.md and update index. Wire ws file-watch in project-board. Minor fs tool and CLAUDE.md updates. --- CLAUDE.md | 26 +++++++--- crates/skald-core/src/tools/fs/mod.rs | 6 +-- crates/skald-core/src/tools/show_file.rs | 66 +++++++++++++++++++----- docs/index.md | 8 ++- docs/projects.md | 54 +++++++++++++++++++ src/frontend/api/files.rs | 31 +++++++++++ src/frontend/api/ws.rs | 2 + web/components/projects/project-board.js | 13 +++-- 8 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 docs/projects.md diff --git a/CLAUDE.md b/CLAUDE.md index 87e742e..ce18eed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: | `crates/skald-core/src/approval/` | Approval rules engine | | `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer | | `crates/skald-core/src/elicitation/` | `ElicitationManager` + bridge: MCP server-initiated input (`elicitation/create`), surfaced in the Inbox; secrets never logged/persisted | -| `crates/skald-core/src/inbox.rs` | `Inbox`: unified façade for pending approvals + clarifications + elicitations (wraps ApprovalManager, ClarificationManager, ElicitationManager) | +| `crates/skald-core/src/inbox.rs` | `Inbox`: unified façade for pending approvals + clarifications + elicitations (wraps ApprovalManager, ClarificationManager, ElicitationManager). The managers already emit the `*Requested`/`*Resolved` lifecycle events on the per-user bus; `ws.rs` forwards them to every connected client of that user regardless of `source`, so the web UI updates live (see `sidebar.js` row) | | `crates/skald-core/src/llm/` | LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider *types* are runtime data, not code: `providers/declared.rs` loads `providers.yaml` at boot (see Config); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. **Retriability** (`llm_call.rs::is_retriable_llm_error`) keys on the real HTTP status via `llm_client::http_status` (a structured `LlmError { status }` from the client, else a `reqwest::Error` in the chain), **not** a substring of the message — a model id/token count containing "404"/"401" no longer mis-classifies; 401/403/404/422 don't retry, 400/429/5xx/network do | | `crates/skald-core/src/transcribe/` | Transcription providers | | `crates/skald-core/src/image_generate/` | Image generation providers | @@ -105,14 +105,14 @@ Two rules keep the boundary real, and both are enforced by the compiler: The schema is split into two buckets (§5.1), and the split is the point: -- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key. -- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. +- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key. +- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.) Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid). -**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. Two keys crossed and were fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model) and `project_tickets.job_id` (fixed by moving `projects`/`project_tickets` into the owner bucket). +**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. One key crossed and was fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model). -**Memory namespace (blueprint §5).** `memory_docs` (accessor `db/memory_docs.rs` — `get`/`upsert`/`list`/`search`(FTS)/`delete`) backs a virtual note store surfaced through the fs-tools, **not** the disk. Two sibling roots (not the blueprint's nested `memory/{userid}` + `memory/shared`): `user-memory/…` routes to the caller's own pool (`ToolContext::pool`), `shared-memory/…` to the system pool (a singleton captured in `fs::register_all`). `tools/fs/classify_memory()` decides on the raw first path component (a `..` in the tail clamps inside the store, never escapes to disk); `read_file`/`write_file`/`list_files`/`edit_file`/`insert_at_line`/`replace_lines`/`search_file` override `run_with` to route memory paths (each extracting a pure transform shared with its on-disk `execute`) and leave every other path on disk. Approval (seeded in `seed_fs_path_rules`): `user-memory/*` is `@fs_any allow` (private, frictionless); `shared-memory/*` is `@fs_read allow` + `@fs_write require` — reads free, **writes need approval** so the agent can't silently push one person's data into shared memory. `grep_files` stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, `memory_search` (`tools/fs/memory_search.rs`), over the `memory_docs` FTS index — allowed by a path-less rule (it takes `query`, not `path`). +**Memory namespace (blueprint §5).** `memory_docs` (accessor `db/memory_docs.rs` — `get`/`upsert`/`list`/`search`(FTS)/`delete`) backs a virtual note store surfaced through the fs-tools, **not** the disk. Two sibling roots (not the blueprint's nested `memory/{userid}` + `memory/shared`): `user-memory/…` routes to the caller's own pool (`ToolContext::pool`), `shared-memory/…` to the system pool (a singleton captured in `fs::register_all`). `tools/fs/classify_memory()` decides on the raw first path component (a `..` in the tail clamps inside the store, never escapes to disk); `read_file`/`write_file`/`list_files`/`edit_file`/`insert_at_line`/`replace_lines`/`search_file` override `run_with` to route memory paths (each extracting a pure transform shared with its on-disk `execute`) and leave every other path on disk. The HTTP surface routes them the same way: `GET /api/file` classifies **before** `resolve_view_path` and serves the note from `memory_docs` (caller's pool / system pool), so the file viewer opens `user-memory/…` and `shared-memory/…` like any file, and `show_file_to_user` accepts memory paths too (existence-checked on the right pool). Approval (seeded in `seed_fs_path_rules`): `user-memory/*` is `@fs_any allow` (private, frictionless); `shared-memory/*` is `@fs_read allow` + `@fs_write require` — reads free, **writes need approval** so the agent can't silently push one person's data into shared memory. `grep_files` stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, `memory_search` (`tools/fs/memory_search.rs`), over the `memory_docs` FTS index — allowed by a path-less rule (it takes `query`, not `path`). **Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `assistant` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`. @@ -133,6 +133,7 @@ The agent sees **one namespace**, routed on the first path component. The choke | `user-memory/…` | SQLite `ctx.pool` (`{userid}.db`) | `classify_memory` → `memory_docs` | | `shared-memory/…` | SQLite `system.db` | `classify_memory` → `memory_docs` | | `shared/{X}/…` | host `{WD}/shared/{X}` (if a member) | `UserFs::host_base_and_tail` | +| `projects/{O}/{S}/…` | host `{WD}/projects/{owner_userid}/{S}` (if a member) | `UserFs::host_base_and_tail` | | `~/…`, relative | host `{WD}/homes/{userid}` | `UserFs::host_base_and_tail` | Two views, **one storage**: the fs-tools run **host-side** in the Skald process on `{WD}/homes/{userid}` + `{WD}/shared/{X}`; `execute_cmd` runs **inside the container** (`docker exec -w skald-{userid} sh -c …`, via `ExecuteCmd::run_with`) on the same paths bind-mounted (`homes/{userid}`→`/root`, `shared/{X}`→`/root/shared/{X}`, read-only when `can_write=0`). A file written in the container appears to the host fs-tools and vice versa. @@ -141,6 +142,16 @@ Two views, **one storage**: the fs-tools run **host-side** in the Skald process The threading: `UserContext.fs` (built by `container::build_user_fs` at login, snapshotting shared memberships) → `ChatSessionManager` → `ChatSessionHandler.fs` → `ToolContext.fs`. **Admin CRUD is wired** (`src/frontend/api/shared_folders.rs` — `GET/POST /api/shared-folders`, `PATCH/DELETE /api/shared-folders/{id}`, `POST`/`DELETE .../members[/{user_id}]`; UI `shared-folders.js`): a create/describe/delete + per-member `can_write` surface, and each mutation calls a best-effort `remount(user)` that rebuilds the affected user's fs + container mounts **in place** — so a membership change lands without a re-login (blueprint §6's "admin CRUD" + "membership refresh without re-login" TODOs, now closed; it still settles at next login/boot if the live remount fails). `execute_cmd` /stop is robust: the command runs under `setsid -w` in its own process-group (leader pid recorded in a container pidfile), and a `KillReaper` drop-guard reaps that group on /stop **or** timeout via a detached `docker exec` that walks `/proc` and kills members by **positive pid** (the container's dash mishandles `kill -`); the pidfile is passed positionally (`$1`), and the container's `--init` (tini) reaps the killed processes so no zombies accumulate. **Per-user MCP connectors now run inside this container** (§7) — the container infra enabled it; see the MCP connectors section. +## Projects + +A **project** is a shareable, self-service workspace: a folder at `{WD}/projects/{owner_userid}/{slug}` plus membership in the registry. `projects` (accessor `db/projects.rs` — slug is immutable, `UNIQUE(owner_user_id, slug)`) + `project_members` (junction with `can_write`; the owner is always a write-member, so a private project = one member). Sharing is **not** admin-gated: the owner and any write-member can add/remove/re-grant members and edit metadata; only the owner can delete. Each membership mutation remounts the affected users' containers in place (`Skald::refresh_user_mounts`). The mount appears in the agent namespace as `projects/{owner_username}/{slug}` (host keys on the stable userid, agent path on the username) — read-only members get a read-only bind mount in the container. + +**API** (`src/frontend/api/projects.rs`): `GET/POST /api/projects`, `GET/PUT/DELETE /api/projects/{id}`, `POST /api/projects/{id}/members`, `DELETE .../members/{user_id}`, `POST /api/projects/{id}/session`. `ProjectDetail` carries `root_path` — the agent path of the folder, computed server-side (owner username ≠ `owner_name`, which may be a display name) — the explorer's root. A `project-{id}` chat source provisions the `project-coordinator` agent with a project `RunContext` (`provisioning_for_source` → `skald_core::projects::build_project_run_context`: `project_root` + a system block with name/description/folder/members); every member keeps their **own private** `project-{id}` session — only the folder is shared. + +**UI** (`web/components/projects/`): `index.js` (`` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`` — the detail page: header with **Open chat**, then a **Files / Sharing** tab bar using the `.project-tab-bar` styles in `css/projects/board.css`), `project-files.js` (`` — the explorer). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat). + +**The explorer** (`project-files.js`): one directory at a time via `GET /api/files/dir?path=…` (new endpoint in `src/frontend/api/files.rs`: immediate children with `name/path/is_dir/size/created_at/modified_at`, dirs-first; same `resolve_view_path` scoping as `/api/file`). Breadcrumb rooted at the project (`/` = `root_path`); file click → `window.openFile` (existing viewer); folder click → navigate. **Live**: it subscribes the open directory on the existing `/api/file/watch` socket (`web/lib/file-watcher.js` singleton — `notify` NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to `can_write` members and ride the existing `/api/file` endpoints — `POST` gained `dir:true` (mkdir), `DELETE` handles directories (`remove_dir_all`), and binary upload is the new `POST /api/file/upload?path=…` (raw body, 256 MiB `DefaultBodyLimit`). **Server-side write gate**: all `/api/file` write handlers now call `UserFs::can_write_to(agent_path)` (core-api) — home → true, `shared/`/`projects/` → the membership's `can_write`, `docs/` → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes). + ## MCP connectors (blueprint §7/§14/§15) MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema stays neutral, §0.1). The old single owner table `mcp_servers`, the agent-facing `register_mcp`/`delete_mcp` tools, and the `mcp` kinds of `list_items`/`toggle_item` are **gone**. Connectors are now admin-curated and user-activated through the Connectors UI/API — never written by the agent, which closes the §14 RCE vector (prompt-injection → agent writes+registers a local script → arbitrary code on the box). @@ -257,7 +268,7 @@ Create `agents//meta.json` and `agents//AGENT.md`. The agent is discover ## Documentation -`docs/` is **not developer documentation** — it's written for the in-app LLM, not for a human reading the repo, and is mounted read-only into every user's container at `~/docs/` (see the Filesystem & containers section: `docs_host` on `UserFs`, `DOCS_DIR` in `container/mod.rs`). It explains the software's UX (plugins, and eventually agents/connectors/memory/roles/…) in plain terms, in English, so the assistant can help a non-technical user configure things instead of guessing. `docs/index.md` is the entry point; `docs/plugins/.md` covers each built-in plugin. The three `type: chat` agents (`assistant`, `kid`, `project-coordinator`) are told in their `AGENT.md` to read `docs/index.md` when a user asks how the software works. Keep it in sync when plugins or major UX-facing behavior change — it goes stale like any other doc. +`docs/` is **not developer documentation** — it's written for the in-app LLM, not for a human reading the repo, and is mounted read-only into every user's container at `~/docs/` (see the Filesystem & containers section: `docs_host` on `UserFs`, `DOCS_DIR` in `container/mod.rs`). It explains the software's UX (plugins, and eventually agents/connectors/memory/roles/…) in plain terms, in English, so the assistant can help a non-technical user configure things instead of guessing. `docs/index.md` is the entry point (general index of feature pages); `docs/plugins/.md` covers each built-in plugin. The three `type: chat` agents (`assistant`, `kid`, `project-coordinator`) are told in their `AGENT.md` to read `docs/index.md` when a user asks how the software works. **Standing rule: every change that impacts the UX must update `docs/` in the same change** — a new/renamed feature page plus the `docs/index.md` index entry. It goes stale like any other doc, except users actually see this one. ## Config @@ -294,7 +305,7 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ | `copilot.js` | `` | The chat surface (`_wsSource='web'`): full/dock roving layout, welcome hero empty state, privacy chip, composer with model pill, slash-command autocomplete | | `shared/chat-page.js` | `` | Mobile chat (`_wsSource='mobile'`) | | `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page | -| `sidebar.js` | `` | Nav sidebar; role-driven (`ui_mode`); polls `/api/inbox` every 10 s for badge | +| `sidebar.js` | `` | Nav sidebar; role-driven (`ui_mode`); inbox badge is **live** — the chat WS forwards the inbox lifecycle events (`approval_requested/resolved`, `clarification_*`, `elicitation_*`) regardless of `source`, `chat-session.js` re-dispatches them as the `inbox-changed` window event, and the sidebar (+ `agent-inbox.js`) refreshes on it; a 60 s poll remains as fallback | | `topbar.js` | `` | Top nav bar; per-user avatar color hashed from the username | | `dashboard-page.js` | `` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide | | `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile | @@ -310,6 +321,7 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ | `plugin-detail.js` | `` | `#plugin-detail?id=` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) | | `plugin-page-host.js` | `` | Host for plugin-contributed pages (`#plugin//`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `shared-folders.js` | `` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context | +| `projects/` | `` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section | | `connector-detail.js` | `` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable + per-user access grants | | `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch | | `llm-providers.js` | `` | LLM provider management | diff --git a/crates/skald-core/src/tools/fs/mod.rs b/crates/skald-core/src/tools/fs/mod.rs index 2c1a778..375032c 100644 --- a/crates/skald-core/src/tools/fs/mod.rs +++ b/crates/skald-core/src/tools/fs/mod.rs @@ -52,7 +52,7 @@ pub const USER_MEMORY_ROOT: &str = "user-memory"; pub const SHARED_MEMORY_ROOT: &str = "shared-memory"; /// Which memory store a path resolves to. -pub(crate) enum MemScope { +pub enum MemScope { /// `user-memory/…` → the caller's own pool (`ToolContext::pool`). User, /// `shared-memory/…` → the shared system pool. @@ -61,7 +61,7 @@ pub(crate) enum MemScope { /// A path that falls inside the virtual memory namespace: the store it belongs to /// and the note key **relative to that store's root** (the root prefix stripped). -pub(crate) struct MemRef { +pub struct MemRef { pub scope: MemScope, pub rel: String, } @@ -75,7 +75,7 @@ pub(crate) struct MemRef { /// disk path. The tail is then normalized (resolving `.`/`..`) and clamped at the /// store root, so a memory path stays within its store and an absolute path is /// always disk. -pub(crate) fn classify_memory(user_path: &str) -> Option { +pub fn classify_memory(user_path: &str) -> Option { let mut parts = user_path.trim_start_matches("./").splitn(2, ['/', '\\']); let scope = match parts.next()? { USER_MEMORY_ROOT => MemScope::User, diff --git a/crates/skald-core/src/tools/show_file.rs b/crates/skald-core/src/tools/show_file.rs index 61d6103..1822fdc 100644 --- a/crates/skald-core/src/tools/show_file.rs +++ b/crates/skald-core/src/tools/show_file.rs @@ -1,17 +1,20 @@ use std::sync::Arc; use serde_json::{Value, json}; +use sqlx::SqlitePool; use core_api::user_fs::SharedFs; use crate::chat_hub::ChatHub; +use crate::db::memory_docs; use crate::events::{GlobalEvent, ServerEvent}; use crate::session::handler::{InterfaceTool, ToolFuture}; use crate::tools::fs; use crate::tools::tool_names::SHOW_FILE_TO_USER; -/// Build a `show_file_to_user` InterfaceTool bound to a `ChatHub`, a source and the -/// caller's [`SharedFs`] (their per-user filesystem view). +/// Build a `show_file_to_user` InterfaceTool bound to a `ChatHub`, a source, the +/// caller's [`SharedFs`] (their per-user filesystem view) and the two memory +/// pools (the caller's own + the shared system one). /// /// Injected only for SPA clients (web copilot + mobile) at the WebSocket entry /// point, so Telegram — which has its own `send_attachment` — never sees it. @@ -19,11 +22,20 @@ use crate::tools::tool_names::SHOW_FILE_TO_USER; /// The path is resolved through the caller's own workspace (`resolve_view_path`): /// `~/…`, `shared/{X}/…`, `projects/{O}/{S}/…`, a bare relative path, or a /// container-absolute `/root/…` — anything outside the container view is refused. +/// A path under a memory root (`user-memory/…`, `shared-memory/…`) is a virtual +/// note instead: it is looked up in `memory_docs` on the matching pool — the +/// viewer's `GET /api/file` applies the same routing, so it round-trips. /// It then emits a `ServerEvent::OpenFile` carrying the **canonical agent path**, so -/// the file-viewer page fetches the same file back through `/api/file` (which applies -/// the identical per-user resolution). The frontend renders every kind in the viewer -/// (HTML live in an origin-isolated iframe; LaTeX compiled to PDF server-side). -pub fn make_tool(hub: Arc, source: String, fs: SharedFs) -> InterfaceTool { +/// the file-viewer page fetches the same file back through `/api/file`. The +/// frontend renders every kind in the viewer (HTML live in an origin-isolated +/// iframe; LaTeX compiled to PDF server-side). +pub fn make_tool( + hub: Arc, + source: String, + fs: SharedFs, + user_pool: SqlitePool, + shared_pool: SqlitePool, +) -> InterfaceTool { let definition = json!({ "type": "function", "function": { @@ -34,7 +46,8 @@ pub fn make_tool(hub: Arc, source: String, fs: SharedFs) -> InterfaceTo to PDF automatically on the server). HTML files open in a \ new browser tab. Use this to surface a file you created or \ found so the user can look at it directly. One file per call. \ - The file must already exist on disk. \ + The file must already exist on disk — or as a memory note \ + (`user-memory/…`, `shared-memory/…`). \ IMPORTANT for LaTeX: always pass the `.tex` source, never a \ pre-built `.pdf` of a document you have the `.tex` for. The \ `.tex` is compiled on the server and the view live-reloads \ @@ -49,8 +62,9 @@ pub fn make_tool(hub: Arc, source: String, fs: SharedFs) -> InterfaceTo "type": "string", "description": "Path of the file to show, in your own workspace: relative to your \ home (e.g. `report.md` or `~/report.md`), a `shared//…` or \ - `projects///…` path, or an absolute container path \ - (`/root/…`). Paths outside your workspace are refused." + `projects///…` path, a memory note \ + (`user-memory/…`, `shared-memory/…`), or an absolute container \ + path (`/root/…`). Paths outside your workspace are refused." } }, "required": ["path"] @@ -59,14 +73,42 @@ pub fn make_tool(hub: Arc, source: String, fs: SharedFs) -> InterfaceTo }); let handler = Arc::new(move |args: Value| -> ToolFuture { - let hub = Arc::clone(&hub); - let source = source.clone(); - let fs = fs.clone(); + let hub = Arc::clone(&hub); + let source = source.clone(); + let fs = fs.clone(); + let user_pool = user_pool.clone(); + let shared_pool = shared_pool.clone(); Box::pin(async move { let path = args["path"] .as_str() .ok_or_else(|| anyhow::anyhow!("show_file_to_user: missing required parameter 'path'"))?; + // Virtual memory namespace → a `memory_docs` note, not a disk file. + // The viewer serves it through the same routing (see GET /api/file), + // so confirming existence is all that's needed here. + if let Some(mem) = fs::classify_memory(path) { + if mem.rel.is_empty() { + anyhow::bail!("show_file_to_user: '{path}' is a memory folder, not a file"); + } + let (pool, root) = match mem.scope { + fs::MemScope::User => (&user_pool, fs::USER_MEMORY_ROOT), + fs::MemScope::Shared => (&shared_pool, fs::SHARED_MEMORY_ROOT), + }; + let exists = memory_docs::get(pool, &mem.rel).await + .map_err(|e| anyhow::anyhow!("show_file_to_user: {e}"))? + .is_some(); + if !exists { + anyhow::bail!("show_file_to_user: file not found: {path}"); + } + let display = format!("{root}/{}", mem.rel); + hub.emit(GlobalEvent { + source: Some(source), + session_id: None, + event: ServerEvent::OpenFile { path: display.clone() }, + }); + return Ok(format!("Opened {display} in the user's viewer.")); + } + // Resolve against the caller's workspace snapshot: gives the host path to // stat and the canonical agent path the viewer will fetch back. let user_fs = fs.load(); diff --git a/docs/index.md b/docs/index.md index 1b04cb9..f7750a7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,13 @@ This folder is written for **you, the assistant**, not for the human directly. I Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance. -This index will grow over time. Right now it covers plugins; more sections (agents, connectors, memory, security groups, shared folders, projects…) will be added later. +This index will grow over time. Right now it covers projects and plugins; more sections (agents, connectors, memory, security groups, shared folders…) will be added later. + +## Features + +| Document | What it covers | +| --- | --- | +| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing | ## Plugins diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..f5fb241 --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,54 @@ +# Projects + +A **project** is a shared workspace: a folder on the server plus its own chat with the assistant. Members of the group can work on the same files — directly, or by asking the assistant in the project chat — without giving anyone access to their private home folder. + +Examples: a household budget, a holiday plan, a shared recipe collection, a small work document base. + +## Creating a project + +1. Open **Projects** in the sidebar. +2. Click **New Project**, give it a name and an optional description, save. + +You become the project's owner. Only you can delete the project; everything else (editing the description, sharing) can also be done by members you grant read & write access. + +## The project page + +Opening a project shows its page, with two tabs (the current tab is part of the address, so you can bookmark or share the link): + +- **Files** — the project's file explorer (see below). +- **Sharing** — who can access the project. + +The header also has an **Open chat** button: it opens the project's conversation with the assistant. The assistant already knows the project folder and works directly inside it — creating documents, searching, summarizing. Each member has their **own private** conversation about the project; only the files are shared. + +## The Files tab + +A file explorer rooted at the project folder: + +- The **breadcrumb** on top shows where you are, relative to the project root (`/`, then `/folder`, `/folder/subfolder`). Click any segment to jump back. +- Files and folders are listed as a table: icon, name, creation date, last-modified date, size. +- Click a **file** to open it in the file viewer (Markdown rendered, images, PDFs, text…). +- Click a **folder** to navigate into it. +- The listing **updates by itself**: if another member or the assistant creates, renames or deletes a file while you're looking at a folder, the change appears within a second — no refresh needed. + +If you have write access you can also, from the toolbar or each row: + +- **New folder** — create a subfolder in the current location. +- **Upload** — send files from your device into the current folder (or just drag & drop them onto the list). +- **Rename** and **Delete** — from the icons on each row. Deleting a folder removes everything inside it, after a confirmation. + +Read-only members see the same explorer and can open every file, but the write actions are hidden (and refused by the server anyway). + +## The Sharing tab + +Lists every member with their access level. The owner and any read & write member can: + +- **Add a member** — pick a person and their access: *Read* (browse and open files only) or *Read & write* (can also create, edit, delete and share). +- **Change access** or **remove** a member (the owner can't be removed). + +Access changes apply immediately — no need for the other person to log out. + +## Notes + +- A private project is simply a project with one member (you). Share it later whenever you want. +- Renaming a project does not move its folder, so links and the assistant's context keep working. +- Deleting a project removes its folder for everyone — there is no undo. diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index 79424f4..dbd86df 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use core_api::user_fs::UserFs; +use skald_core::db::memory_docs; use skald_core::skald::Skald; use skald_core::latex::CompileError; use skald_core::tools::fs as fs_tools; @@ -141,6 +142,12 @@ pub struct FileQuery { /// `application/pdf`. Compilation failures yield `422 Unprocessable Entity` /// with the textual `latexmk` log in the body, so the caller can fall back to /// showing the raw source. +/// +/// A path under a virtual memory root (`user-memory/…`, `shared-memory/…`) is +/// served from the `memory_docs` table — the caller's own pool for the private +/// root, the system pool for the shared one — exactly like the fs-tools route +/// them (see [`fs_tools::classify_memory`]). Raw content only: no LaTeX +/// compilation (notes are not on disk). pub async fn get_file( State(state): State>, Extension(auth): Extension, @@ -150,6 +157,30 @@ pub async fn get_file( Ok(c) => c, Err(e) => return e.into_response(), }; + + // Virtual memory namespace → SQLite, not disk. + if let Some(mem) = fs_tools::classify_memory(&q.path) { + let pool = match mem.scope { + fs_tools::MemScope::User => Arc::clone(&ctx.pool), + fs_tools::MemScope::Shared => state.db().clone(), + }; + return match memory_docs::get(&pool, &mem.rel).await { + Ok(Some(doc)) => { + let mut response = doc.content.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type_for(&q.path)), + ); + if q.force_download { + set_attachment(&mut response, &basename(&q.path)); + } + response + } + Ok(None) => (StatusCode::NOT_FOUND, format!("File not found: {}", q.path)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + } + let user_fs = ctx.fs.load(); let abs = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) { Ok((abs, _)) => abs, diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index 5d162e2..7538ea9 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -412,6 +412,8 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, Arc::clone(&chat_hub), source.clone(), ctx.fs.clone(), + ctx.pool.as_ref().clone(), + skald.db().as_ref().clone(), ), ], ..Default::default() diff --git a/web/components/projects/project-board.js b/web/components/projects/project-board.js index 37846f1..8aded50 100644 --- a/web/components/projects/project-board.js +++ b/web/components/projects/project-board.js @@ -222,17 +222,16 @@ export class ProjectBoardSection extends LightElement { _renderTabs() { const tab = (id, icon, label) => html` - + `; return html` - + `; }