From 178a38357e9eb1ccfa12c617338343d418165dd2 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Fri, 10 Jul 2026 16:48:51 +0100 Subject: [PATCH] feat(users): UserManager with per-user SQLCipher, and extract skald-core crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes developed together in one session; they share the same module structure (db/mod.rs, the core lib root) and only compile together, so they land as one commit. ## UserManager + per-user encryption (§9/§11) New `users::UserManager`: owns the system.db pool plus a map `userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM until restart and dropping it re-locks (§9). Knows nothing about cookies. New `crypto` module: envelope encryption. A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. The AEAD tag is the password verifier — one derivation both authenticates and yields the key, so encrypted users store no second hash. Cleartext users store the Argon2id output directly, compared in constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore (256 MiB per derivation). - SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so the binary stays self-contained. - Schema split into `create_registry_tables` (instance-wide, no user key) and `create_owner_tables` (one owner's content, identical in every file). No FK in the owner bucket may reach the registry — enforced by a standalone test. Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing key); moved `projects`/`project_tickets` into the owner bucket. - Provisioning invariant: the file is written before the row, deleted after it, so a crash leaves an orphan file, never a user without a database. `open_db` never creates: a missing file is an error, not a silent empty database. Not consumed yet: no login, call sites still use the shared system.db pool. ## Extract crates/skald-core The headless core moves out of `src/` into its own crate; `skald` (server) and the coming `skald-setup` are shells around it. Two dependencies on the shell were inverted rather than dragged along, so the core names neither Tauri nor any concrete plugin: - `Plugin::tools(self: Arc)` — plugins contribute tools through this hook (sibling of `http_router`), so the core no longer downcasts to `MobileConnectorPlugin`. - `tools::restart::set_restart_handler` — the desktop shell installs its teardown-and-respawn; the core defaults to the supervisor exit code. The core loses its `desktop` feature. - `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core only emits tracing events. All 79 core tests pass; the binary boots and serves in a clean directory, and the mobile-connector tools still register through the new hook. --- CLAUDE.md | 88 +- Cargo.lock | 122 ++- Cargo.toml | 44 +- crates/core-api/src/plugin.rs | 13 + crates/plugin-mobile-connector/src/lib.rs | 7 + crates/skald-core/Cargo.toml | 82 ++ {src/core => crates/skald-core/src}/agents.rs | 2 +- .../skald-core/src}/approval/mod.rs | 34 +- crates/skald-core/src/boot.rs | 53 ++ .../skald-core/src}/chat_event_bus.rs | 0 .../skald-core/src}/chat_hub/inbox.rs | 0 .../skald-core/src}/chat_hub/mod.rs | 24 +- .../skald-core/src}/chatbot/logging.rs | 2 +- .../skald-core/src}/chatbot/mod.rs | 0 .../skald-core/src}/clarification/mod.rs | 4 +- .../skald-core/src}/command/mod.rs | 0 .../skald-core/src}/compactor.rs | 14 +- {src/core => crates/skald-core/src}/config.rs | 0 .../skald-core/src}/config_store.rs | 0 .../skald-core/src}/cron/mod.rs | 34 +- crates/skald-core/src/crypto/mod.rs | 363 ++++++++ .../skald-core/src}/db/approval_rules.rs | 2 +- .../skald-core/src}/db/chat_history.rs | 9 - .../skald-core/src}/db/chat_llm_tools.rs | 0 .../skald-core/src}/db/chat_sessions.rs | 0 .../skald-core/src}/db/chat_sessions_stack.rs | 2 +- .../skald-core/src}/db/chat_summaries.rs | 0 .../skald-core/src}/db/config.rs | 0 .../skald-core/src}/db/job_runs.rs | 0 .../skald-core/src}/db/known_tools.rs | 4 +- .../src}/db/llm_requests/cleanup.rs | 2 +- .../skald-core/src}/db/llm_requests/mod.rs | 2 +- .../skald-core/src}/db/mcp_events.rs | 0 .../skald-core/src}/db/mcp_servers.rs | 0 {src/core => crates/skald-core/src}/db/mod.rs | 821 +++++++++++------- .../skald-core/src}/db/plugins.rs | 0 .../skald-core/src}/db/project_tickets.rs | 0 .../skald-core/src}/db/projects.rs | 0 .../skald-core/src}/db/scheduled_jobs.rs | 0 .../skald-core/src}/db/scratchpad.rs | 0 .../skald-core/src}/db/session_mcp_grants.rs | 0 .../skald-core/src}/db/sources.rs | 0 .../skald-core/src}/db/stack_mcp_grants.rs | 0 .../src}/db/tool_permission_groups.rs | 0 .../skald-core/src}/db/users.rs | 14 +- .../skald-core/src}/elicitation/mod.rs | 6 +- {src/core => crates/skald-core/src}/events.rs | 0 .../skald-core/src}/image_generate/db.rs | 0 .../skald-core/src}/image_generate/manager.rs | 12 +- .../skald-core/src}/image_generate/mod.rs | 0 .../src}/image_generate/openrouter_image.rs | 0 {src/core => crates/skald-core/src}/inbox.rs | 8 +- .../skald-core/src}/latex/compiler.rs | 40 +- .../skald-core/src}/latex/mod.rs | 0 .../mod.rs => crates/skald-core/src/lib.rs | 10 + {src/core => crates/skald-core/src}/llm/db.rs | 14 +- .../skald-core/src}/llm/manager.rs | 8 +- .../core => crates/skald-core/src}/llm/mod.rs | 4 +- .../src}/llm/providers/anthropic.rs | 8 +- .../skald-core/src}/llm/providers/deepseek.rs | 8 +- .../src}/llm/providers/lm_studio.rs | 8 +- .../skald-core/src}/llm/providers/mod.rs | 2 +- .../skald-core/src}/llm/providers/ollama.rs | 8 +- .../skald-core/src}/llm/providers/openai.rs | 24 +- .../src}/llm/providers/openrouter.rs | 32 +- .../skald-core/src}/llm/providers/zai.rs | 8 +- .../skald-core/src}/location/mod.rs | 0 .../skald-core/src}/mcp/logs.rs | 0 .../core => crates/skald-core/src}/mcp/mod.rs | 20 +- .../skald-core/src}/memory/mod.rs | 2 +- .../skald-core/src}/notification.rs | 0 .../skald-core/src}/pending_registry.rs | 0 .../skald-core/src}/plugin/mod.rs | 21 +- .../skald-core/src}/projects/mod.rs | 4 +- .../skald-core/src}/projects/tickets.rs | 6 +- .../skald-core/src}/provider/mod.rs | 0 .../skald-core/src}/run_context/mod.rs | 20 +- .../core => crates/skald-core/src}/secrets.rs | 0 .../skald-core/src}/service_manager.rs | 2 +- .../src}/session/handler/agent_dispatch.rs | 12 +- .../src}/session/handler/approval.rs | 4 +- .../skald-core/src}/session/handler/config.rs | 14 +- .../src}/session/handler/dispatch.rs | 4 +- .../src}/session/handler/emitter.rs | 2 +- .../skald-core/src}/session/handler/gate.rs | 8 +- .../src}/session/handler/interface_tools.rs | 10 +- .../src}/session/handler/llm_call.rs | 4 +- .../src}/session/handler/llm_loop.rs | 18 +- .../src}/session/handler/message_builder.rs | 18 +- .../src}/session/handler/messages.rs | 0 .../skald-core/src}/session/handler/mod.rs | 32 +- .../src}/session/handler/outcome.rs | 8 +- .../skald-core/src}/session/handler/resume.rs | 10 +- .../skald-core/src}/session/manager.rs | 26 +- .../skald-core/src}/session/mod.rs | 0 .../skald-core/src}/skald/accessors.rs | 70 +- .../skald-core/src}/skald/bundles.rs | 123 +-- .../skald-core/src}/skald/mod.rs | 3 + .../skald-core/src}/skald/runtime.rs | 13 +- .../skald-core/src}/skald/supervisor.rs | 0 .../skald-core/src}/skald/wiring.rs | 6 +- .../core => crates/skald-core/src}/tic/mod.rs | 16 +- .../skald-core/src}/tool_catalog.rs | 6 +- .../skald-core/src}/tool_discovery.rs | 8 +- .../skald-core/src}/tools/activate_tools.rs | 14 +- .../skald-core/src}/tools/ast_outline.rs | 6 +- .../skald-core/src}/tools/configure_plugin.rs | 6 +- .../skald-core/src}/tools/cron_jobs.rs | 10 +- .../skald-core/src}/tools/exec.rs | 6 +- .../skald-core/src}/tools/fs/edit_file.rs | 4 +- .../skald-core/src}/tools/fs/grep_files.rs | 4 +- .../src}/tools/fs/insert_at_line.rs | 4 +- .../skald-core/src}/tools/fs/list_files.rs | 4 +- .../skald-core/src}/tools/fs/mod.rs | 2 +- .../skald-core/src}/tools/fs/read_file.rs | 4 +- .../skald-core/src}/tools/fs/replace_lines.rs | 4 +- .../skald-core/src}/tools/fs/search_file.rs | 4 +- .../skald-core/src}/tools/fs/write_file.rs | 4 +- .../skald-core/src}/tools/image_generate.rs | 4 +- .../skald-core/src}/tools/list_items.rs | 12 +- .../skald-core/src}/tools/list_secrets.rs | 6 +- .../skald-core/src}/tools/mod.rs | 0 .../skald-core/src}/tools/notify.rs | 8 +- .../src}/tools/read_notification.rs | 0 .../skald-core/src}/tools/register_mcp.rs | 10 +- crates/skald-core/src/tools/restart.rs | 68 ++ .../skald-core/src}/tools/set_secret.rs | 6 +- .../skald-core/src}/tools/show_file.rs | 10 +- .../skald-core/src}/tools/toggle_item.rs | 10 +- .../skald-core/src}/tools/tool_names.rs | 0 .../skald-core/src}/transcribe/db.rs | 0 .../skald-core/src}/transcribe/manager.rs | 8 +- .../skald-core/src}/transcribe/mod.rs | 0 .../src}/transcribe/openai_audio.rs | 0 {src/core => crates/skald-core/src}/tts/db.rs | 0 .../skald-core/src}/tts/manager.rs | 8 +- .../core => crates/skald-core/src}/tts/mod.rs | 0 .../skald-core/src}/tts/openai_tts.rs | 0 crates/skald-core/src/users/mod.rs | 697 +++++++++++++++ src/boot.rs | 98 --- src/boot_format.rs | 56 ++ src/config.rs | 8 +- src/core/tools/restart.rs | 63 -- src/desktop/mod.rs | 19 +- src/frontend/api/agents.rs | 14 +- src/frontend/api/approval.rs | 10 +- src/frontend/api/commands.rs | 2 +- src/frontend/api/config.rs | 2 +- src/frontend/api/cron.rs | 6 +- src/frontend/api/dev.rs | 2 +- src/frontend/api/file_watch.rs | 4 +- src/frontend/api/files.rs | 8 +- src/frontend/api/image_generate_models.rs | 4 +- src/frontend/api/images.rs | 2 +- src/frontend/api/inbox.rs | 2 +- src/frontend/api/llm.rs | 8 +- src/frontend/api/mcp.rs | 2 +- src/frontend/api/mcp_media.rs | 4 +- src/frontend/api/mod.rs | 2 +- src/frontend/api/plugins.rs | 2 +- src/frontend/api/projects.rs | 10 +- src/frontend/api/run_context.rs | 4 +- src/frontend/api/sessions.rs | 12 +- src/frontend/api/stats.rs | 2 +- src/frontend/api/transcribe_audio.rs | 2 +- src/frontend/api/transcribe_models.rs | 4 +- src/frontend/api/tts_models.rs | 4 +- src/frontend/api/uploads.rs | 4 +- src/frontend/api/ws.rs | 18 +- src/frontend/api/ws_session.rs | 2 +- src/frontend/mod.rs | 4 +- src/frontend/server.rs | 2 +- src/main.rs | 15 +- 173 files changed, 2650 insertions(+), 1106 deletions(-) create mode 100644 crates/skald-core/Cargo.toml rename {src/core => crates/skald-core/src}/agents.rs (99%) rename {src/core => crates/skald-core/src}/approval/mod.rs (97%) create mode 100644 crates/skald-core/src/boot.rs rename {src/core => crates/skald-core/src}/chat_event_bus.rs (100%) rename {src/core => crates/skald-core/src}/chat_hub/inbox.rs (100%) rename {src/core => crates/skald-core/src}/chat_hub/mod.rs (97%) rename {src/core => crates/skald-core/src}/chatbot/logging.rs (99%) rename {src/core => crates/skald-core/src}/chatbot/mod.rs (100%) rename {src/core => crates/skald-core/src}/clarification/mod.rs (97%) rename {src/core => crates/skald-core/src}/command/mod.rs (100%) rename {src/core => crates/skald-core/src}/compactor.rs (98%) rename {src/core => crates/skald-core/src}/config.rs (100%) rename {src/core => crates/skald-core/src}/config_store.rs (100%) rename {src/core => crates/skald-core/src}/cron/mod.rs (95%) create mode 100644 crates/skald-core/src/crypto/mod.rs rename {src/core => crates/skald-core/src}/db/approval_rules.rs (97%) rename {src/core => crates/skald-core/src}/db/chat_history.rs (97%) rename {src/core => crates/skald-core/src}/db/chat_llm_tools.rs (100%) rename {src/core => crates/skald-core/src}/db/chat_sessions.rs (100%) rename {src/core => crates/skald-core/src}/db/chat_sessions_stack.rs (98%) rename {src/core => crates/skald-core/src}/db/chat_summaries.rs (100%) rename {src/core => crates/skald-core/src}/db/config.rs (100%) rename {src/core => crates/skald-core/src}/db/job_runs.rs (100%) rename {src/core => crates/skald-core/src}/db/known_tools.rs (96%) rename {src/core => crates/skald-core/src}/db/llm_requests/cleanup.rs (98%) rename {src/core => crates/skald-core/src}/db/llm_requests/mod.rs (98%) rename {src/core => crates/skald-core/src}/db/mcp_events.rs (100%) rename {src/core => crates/skald-core/src}/db/mcp_servers.rs (100%) rename {src/core => crates/skald-core/src}/db/mod.rs (61%) rename {src/core => crates/skald-core/src}/db/plugins.rs (100%) rename {src/core => crates/skald-core/src}/db/project_tickets.rs (100%) rename {src/core => crates/skald-core/src}/db/projects.rs (100%) rename {src/core => crates/skald-core/src}/db/scheduled_jobs.rs (100%) rename {src/core => crates/skald-core/src}/db/scratchpad.rs (100%) rename {src/core => crates/skald-core/src}/db/session_mcp_grants.rs (100%) rename {src/core => crates/skald-core/src}/db/sources.rs (100%) rename {src/core => crates/skald-core/src}/db/stack_mcp_grants.rs (100%) rename {src/core => crates/skald-core/src}/db/tool_permission_groups.rs (100%) rename {src/core => crates/skald-core/src}/db/users.rs (97%) rename {src/core => crates/skald-core/src}/elicitation/mod.rs (98%) rename {src/core => crates/skald-core/src}/events.rs (100%) rename {src/core => crates/skald-core/src}/image_generate/db.rs (100%) rename {src/core => crates/skald-core/src}/image_generate/manager.rs (96%) rename {src/core => crates/skald-core/src}/image_generate/mod.rs (100%) rename {src/core => crates/skald-core/src}/image_generate/openrouter_image.rs (100%) rename {src/core => crates/skald-core/src}/inbox.rs (95%) rename {src/core => crates/skald-core/src}/latex/compiler.rs (95%) rename {src/core => crates/skald-core/src}/latex/mod.rs (100%) rename src/core/mod.rs => crates/skald-core/src/lib.rs (60%) rename {src/core => crates/skald-core/src}/llm/db.rs (94%) rename {src/core => crates/skald-core/src}/llm/manager.rs (99%) rename {src/core => crates/skald-core/src}/llm/mod.rs (97%) rename {src/core => crates/skald-core/src}/llm/providers/anthropic.rs (93%) rename {src/core => crates/skald-core/src}/llm/providers/deepseek.rs (94%) rename {src/core => crates/skald-core/src}/llm/providers/lm_studio.rs (91%) rename {src/core => crates/skald-core/src}/llm/providers/mod.rs (97%) rename {src/core => crates/skald-core/src}/llm/providers/ollama.rs (94%) rename {src/core => crates/skald-core/src}/llm/providers/openai.rs (81%) rename {src/core => crates/skald-core/src}/llm/providers/openrouter.rs (89%) rename {src/core => crates/skald-core/src}/llm/providers/zai.rs (94%) rename {src/core => crates/skald-core/src}/location/mod.rs (100%) rename {src/core => crates/skald-core/src}/mcp/logs.rs (100%) rename {src/core => crates/skald-core/src}/mcp/mod.rs (95%) rename {src/core => crates/skald-core/src}/memory/mod.rs (99%) rename {src/core => crates/skald-core/src}/notification.rs (100%) rename {src/core => crates/skald-core/src}/pending_registry.rs (100%) rename {src/core => crates/skald-core/src}/plugin/mod.rs (95%) rename {src/core => crates/skald-core/src}/projects/mod.rs (97%) rename {src/core => crates/skald-core/src}/projects/tickets.rs (97%) rename {src/core => crates/skald-core/src}/provider/mod.rs (100%) rename {src/core => crates/skald-core/src}/run_context/mod.rs (94%) rename {src/core => crates/skald-core/src}/secrets.rs (100%) rename {src/core => crates/skald-core/src}/service_manager.rs (88%) rename {src/core => crates/skald-core/src}/session/handler/agent_dispatch.rs (97%) rename {src/core => crates/skald-core/src}/session/handler/approval.rs (97%) rename {src/core => crates/skald-core/src}/session/handler/config.rs (94%) rename {src/core => crates/skald-core/src}/session/handler/dispatch.rs (98%) rename {src/core => crates/skald-core/src}/session/handler/emitter.rs (99%) rename {src/core => crates/skald-core/src}/session/handler/gate.rs (97%) rename {src/core => crates/skald-core/src}/session/handler/interface_tools.rs (96%) rename {src/core => crates/skald-core/src}/session/handler/llm_call.rs (98%) rename {src/core => crates/skald-core/src}/session/handler/llm_loop.rs (97%) rename {src/core => crates/skald-core/src}/session/handler/message_builder.rs (97%) rename {src/core => crates/skald-core/src}/session/handler/messages.rs (100%) rename {src/core => crates/skald-core/src}/session/handler/mod.rs (97%) rename {src/core => crates/skald-core/src}/session/handler/outcome.rs (94%) rename {src/core => crates/skald-core/src}/session/handler/resume.rs (98%) rename {src/core => crates/skald-core/src}/session/manager.rs (91%) rename {src/core => crates/skald-core/src}/session/mod.rs (100%) rename {src/core => crates/skald-core/src}/skald/accessors.rs (65%) rename {src/core => crates/skald-core/src}/skald/bundles.rs (77%) rename {src/core => crates/skald-core/src}/skald/mod.rs (95%) rename {src/core => crates/skald-core/src}/skald/runtime.rs (82%) rename {src/core => crates/skald-core/src}/skald/supervisor.rs (100%) rename {src/core => crates/skald-core/src}/skald/wiring.rs (96%) rename {src/core => crates/skald-core/src}/tic/mod.rs (95%) rename {src/core => crates/skald-core/src}/tool_catalog.rs (96%) rename {src/core => crates/skald-core/src}/tool_discovery.rs (95%) rename {src/core => crates/skald-core/src}/tools/activate_tools.rs (91%) rename {src/core => crates/skald-core/src}/tools/ast_outline.rs (98%) rename {src/core => crates/skald-core/src}/tools/configure_plugin.rs (92%) rename {src/core => crates/skald-core/src}/tools/cron_jobs.rs (96%) rename {src/core => crates/skald-core/src}/tools/exec.rs (96%) rename {src/core => crates/skald-core/src}/tools/fs/edit_file.rs (96%) rename {src/core => crates/skald-core/src}/tools/fs/grep_files.rs (98%) rename {src/core => crates/skald-core/src}/tools/fs/insert_at_line.rs (93%) rename {src/core => crates/skald-core/src}/tools/fs/list_files.rs (94%) rename {src/core => crates/skald-core/src}/tools/fs/mod.rs (99%) rename {src/core => crates/skald-core/src}/tools/fs/read_file.rs (94%) rename {src/core => crates/skald-core/src}/tools/fs/replace_lines.rs (94%) rename {src/core => crates/skald-core/src}/tools/fs/search_file.rs (94%) rename {src/core => crates/skald-core/src}/tools/fs/write_file.rs (91%) rename {src/core => crates/skald-core/src}/tools/image_generate.rs (95%) rename {src/core => crates/skald-core/src}/tools/list_items.rs (94%) rename {src/core => crates/skald-core/src}/tools/list_secrets.rs (92%) rename {src/core => crates/skald-core/src}/tools/mod.rs (100%) rename {src/core => crates/skald-core/src}/tools/notify.rs (94%) rename {src/core => crates/skald-core/src}/tools/read_notification.rs (100%) rename {src/core => crates/skald-core/src}/tools/register_mcp.rs (94%) create mode 100644 crates/skald-core/src/tools/restart.rs rename {src/core => crates/skald-core/src}/tools/set_secret.rs (90%) rename {src/core => crates/skald-core/src}/tools/show_file.rs (93%) rename {src/core => crates/skald-core/src}/tools/toggle_item.rs (94%) rename {src/core => crates/skald-core/src}/tools/tool_names.rs (100%) rename {src/core => crates/skald-core/src}/transcribe/db.rs (100%) rename {src/core => crates/skald-core/src}/transcribe/manager.rs (98%) rename {src/core => crates/skald-core/src}/transcribe/mod.rs (100%) rename {src/core => crates/skald-core/src}/transcribe/openai_audio.rs (100%) rename {src/core => crates/skald-core/src}/tts/db.rs (100%) rename {src/core => crates/skald-core/src}/tts/manager.rs (98%) rename {src/core => crates/skald-core/src}/tts/mod.rs (100%) rename {src/core => crates/skald-core/src}/tts/openai_tts.rs (100%) create mode 100644 crates/skald-core/src/users/mod.rs delete mode 100644 src/boot.rs create mode 100644 src/boot_format.rs delete mode 100644 src/core/tools/restart.rs diff --git a/CLAUDE.md b/CLAUDE.md index 29dc1b3..4ce6be7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,39 +33,58 @@ Domain words are allowed only in seed data, preset labels, UI copy and positioni ### Current state -Single-user Skald plus a `users` table (`src/core/db/users.rs`). Next step is `UserManager` (§11). The multi-user split of the database has not started; `Runtime` still carries one shared `Arc`. +`UserManager` (§11) exists and works — `crates/skald-core/src/users/mod.rs`, with real per-user SQLCipher encryption (§4). It is **not consumed yet**: there is no login, and `Runtime` still hands every call site the one shared `Arc` on `system.db`, so chats still land in that file's owner tables. The next step is migrating those call sites to `pool_of`, and only then deciding where the owner-without-a-user lives (see blueprint §19). Direction of travel, decided but not yet executed: strip the **power-user surface** (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a **binary-first** layout — the app is built once and run from a compiled binary, not executed from its own source tree. +## Workspace layout + +The application core is the `skald-core` crate; the binaries are **shells** around it. + +| Crate | Role | +| ---- | ---- | +| `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no Tauri, no HTTP server, and **no concrete plugin crate** — `PluginManager` only ever sees `Arc` from `core-api` | +| `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, the Tauri `desktop/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new` | +| `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types | + +Two rules keep the boundary real, and both are enforced by the compiler: + +- **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`. +- **The core never learns about the process shell.** The `restart` tool defaults to the supervisor protocol (`exit(-1)`); a shell with different needs installs `tools::restart::set_restart_handler` at startup. The Tauri shell installs teardown-and-respawn there. This is why `skald-core` has no `desktop` feature. + +`skald_core::boot` emits curated startup lines on the `boot` tracing target; each shell decides how to render them (`src/boot_format.rs` here). The core says what happened, never how it looks. + ## Key modules | Path | Role | | ---- | ---- | | `src/main.rs` | Thin entry point: tracing → `Skald::new` → `WebFrontend::start` → shutdown. Branches on the `desktop` feature: under `--features desktop` enters `desktop::run()` (Tauri event loop) instead of blocking on a tokio runtime. Exposes `run_backend()` / `shutdown_backend()` shared by both entry points | -| `src/desktop/mod.rs` | Tauri shell — **only compiled under `--features desktop`**. Builds the system-tray icon + menu (`Open` / `Quit`), creates the main `WebviewWindow` (URL = `http://127.0.0.1:{config.port}`), spawns the backend on Tauri's shared tokio runtime, handles graceful shutdown. Holds the `OnceLock` that the `restart` tool reads from. See [docs/desktop.md](docs/desktop.md) | -| `src/core/skald/` | `Skald` — headless application core. `mod.rs` (struct + staged `new()` / `shutdown()`), `runtime.rs` (cross-cutting `Runtime` context), `bundles.rs` (8 domain bundles + `build()`), `wiring.rs` (`wire()` + `spawn_background()`), `supervisor.rs` (`TaskSupervisor`), `accessors.rs` (per-manager accessor facade — the API surface the frontend uses) | -| `src/core/session/handler/` | Core LLM loop — `mod.rs`, `llm_loop.rs` (`run_agent_turn`), `agent_dispatch.rs`, `dispatcher.rs`, `approval.rs`, `resume.rs`, `messages.rs`, `config.rs`, `interface_tools.rs` | -| `src/core/session/manager.rs` | Creates/retrieves `ChatSessionHandler` per session | -| `src/core/chat_hub/` | `ChatHub`: broadcast events to all connected WS clients | -| `src/core/chat_event_bus.rs` | Global async bus for cross-session events | -| `src/core/agents.rs` | Discovers agents from `agents/*/`, loads meta + system prompt | -| `src/core/tools/` | Built-in tools: `exec`, `restart`, `list_agents`, `fs/*`, `notify`, `ast_outline`, `image_generate`, MCP tools, plugin tools, cron tools | -| `src/core/tool_catalog.rs` | `ToolCatalog`: unified tool listing façade (wraps ToolRegistry + McpManager) | -| `src/core/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend | -| `src/core/db/` | sqlx SQLite — see below | +| `src/desktop/mod.rs` | Tauri shell — **only compiled under `--features desktop`**. Builds the system-tray icon + menu (`Open` / `Quit`), creates the main `WebviewWindow` (URL = `http://127.0.0.1:{config.port}`), spawns the backend on Tauri's shared tokio runtime, handles graceful shutdown. Holds the `OnceLock`, and installs the core's restart handler. See [docs/desktop.md](docs/desktop.md) | +| `crates/skald-core/src/skald/` | `Skald` — headless application core. `mod.rs` (struct + staged `new()` / `shutdown()`), `runtime.rs` (cross-cutting `Runtime` context), `bundles.rs` (8 domain bundles + `build()`), `wiring.rs` (`wire()` + `spawn_background()`), `supervisor.rs` (`TaskSupervisor`), `accessors.rs` (per-manager accessor facade — the API surface the frontend uses) | +| `crates/skald-core/src/session/handler/` | Core LLM loop — `mod.rs`, `llm_loop.rs` (`run_agent_turn`), `agent_dispatch.rs`, `dispatcher.rs`, `approval.rs`, `resume.rs`, `messages.rs`, `config.rs`, `interface_tools.rs` | +| `crates/skald-core/src/session/manager.rs` | Creates/retrieves `ChatSessionHandler` per session | +| `crates/skald-core/src/chat_hub/` | `ChatHub`: broadcast events to all connected WS clients | +| `crates/skald-core/src/chat_event_bus.rs` | Global async bus for cross-session events | +| `crates/skald-core/src/agents.rs` | Discovers agents from `agents/*/`, loads meta + system prompt | +| `crates/skald-core/src/tools/` | Built-in tools: `exec`, `restart`, `list_agents`, `fs/*`, `notify`, `ast_outline`, `image_generate`, MCP tools, plugin tools, cron tools | +| `crates/skald-core/src/tool_catalog.rs` | `ToolCatalog`: unified tool listing façade (wraps ToolRegistry + McpManager) | +| `crates/skald-core/src/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend | +| `crates/skald-core/src/db/` | sqlx SQLite — see below | +| `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it | +| `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) | | `src/config.rs` | Loads `config.yml`; LLM clients, strength/use_cases, data root. Also hosts `bootstrap_data_dir()` — under the `desktop` feature, relocates the process cwd to a per-user data dir when running inside a `.app` bundle (no-op in dev mode and headless mode) | -| `src/core/mcp/` | MCP client manager (connects to external MCP servers) | -| `src/core/plugin/` | Plugin system: discovery, enable/disable, tool registration | -| `src/core/cron/` | Scheduled job runner | -| `src/core/compactor.rs` | Context compaction (summarises history when token budget exceeded) | -| `src/core/approval/` | Approval rules engine | -| `src/core/clarification/` | `ClarificationManager`: background-session question/answer | -| `src/core/elicitation/` | `ElicitationManager` + bridge: MCP server-initiated input (`elicitation/create`), surfaced in the Inbox; secrets never logged/persisted | -| `src/core/inbox.rs` | `Inbox`: unified façade for pending approvals + clarifications + elicitations (wraps ApprovalManager, ClarificationManager, ElicitationManager) | -| `src/core/llm/` | LLM client abstraction (OpenAI-compat, Anthropic, Ollama…) | -| `src/core/transcribe/` | Transcription providers | -| `src/core/image_generate/` | Image generation providers | -| `src/core/memory/` | Agent memory tools | +| `crates/skald-core/src/mcp/` | MCP client manager (connects to external MCP servers) | +| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration | +| `crates/skald-core/src/cron/` | Scheduled job runner | +| `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded) | +| `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/llm/` | LLM client abstraction (OpenAI-compat, Anthropic, Ollama…) | +| `crates/skald-core/src/transcribe/` | Transcription providers | +| `crates/skald-core/src/image_generate/` | Image generation providers | +| `crates/skald-core/src/memory/` | Agent memory tools | | `src/frontend/mod.rs` | `WebFrontend`: wires router_factory, starts plugins, runs Axum | | `src/frontend/server.rs` | Axum router, static file serving | | `src/frontend/api/` | HTTP + WebSocket handlers — `State>` | @@ -73,11 +92,18 @@ Direction of travel, decided but not yet executed: strip the **power-user surfac ## DB tables (sqlx SQLite) -One file, `database/system.db` — the path is a constant (`core::db::SYSTEM_DB_PATH`), **not** configurable. `init_pool` creates the directory; SQLite only creates the file. Per-user `database/{userid}.db` files are the next step (§5.1). +`database/system.db` — the path is a constant (`core::db::SYSTEM_DB_PATH`), **not** configurable. `init_system_pool` creates the directory; SQLite only creates the file. Per-user files are `database/{userid}.db`, created by `UserManager::register_user` and encrypted with SQLCipher. -`users`, `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `llm_requests`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `scheduled_jobs`, `job_runs`, `mcp_servers`, `mcp_events`, `plugins`, `approval_rules`, `tool_permission_groups`, `sources`, `secrets`, `config`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `known_tools`, `projects`, `project_tickets` +The schema is split into two buckets (§5.1), and the split is the point: -`users` (`src/core/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` has no foreign key yet: sqlx enables `PRAGMA foreign_keys`, so referencing the not-yet-existing `roles` table would fail every insert. +- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`. +- **`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_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`. + +**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). + +`system.db` currently gets **both** bucket functions, because nothing has migrated to per-user pools yet. That is transitional. + +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` has no foreign key yet: sqlx enables `PRAGMA foreign_keys`, so referencing the not-yet-existing `roles` table would fail every insert. ## Sub-agent system @@ -102,18 +128,18 @@ The rule engine `ApprovalManager::check` returns `Allow`/`Deny`/`Require` per to Resolution is **source-agnostic**: the WS + Inbox paths resolve by `request_id`; the inline chat card resolves by the durable `tool_call_id` via `POST /api/tools/:tool_call_id/resolve` (`resolve_tool` in `src/frontend/api/sessions.rs`), which derives the owning session from the tool call's own stack row — never a hardcoded source. Live pending cards fire the `oneshot`; post-restart they execute directly on the owning session. See `docs/approval/`. -**Tool visibility in the Security-groups UI** (`GET /api/approval/tools`): tools injected outside the `ToolRegistry` (interface/plugin/provider tools) would otherwise be un-configurable. `ToolCatalog::list_all()` covers registry tools + a static `synthetic_tools()` list of core interface tools; everything else is captured by `src/core/tool_discovery.rs` (`ToolDiscovery`), which taps `all_tool_defs()` in `llm_loop.rs` each round and upserts every offered tool into the `known_tools` table (in-memory seen-set guard → background DB write). `list_tools` merges `known_tools` (deduped, `category: "dynamic"`) so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names. +**Tool visibility in the Security-groups UI** (`GET /api/approval/tools`): tools injected outside the `ToolRegistry` (interface/plugin/provider tools) would otherwise be un-configurable. `ToolCatalog::list_all()` covers registry tools + a static `synthetic_tools()` list of core interface tools; everything else is captured by `crates/skald-core/src/tool_discovery.rs` (`ToolDiscovery`), which taps `all_tool_defs()` in `llm_loop.rs` each round and upserts every offered tool into the `known_tools` table (in-memory seen-set guard → background DB write). `list_tools` merges `known_tools` (deduped, `category: "dynamic"`) so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names. ## Restart `restart` **no longer rebuilds anything** — neither mode compiles. -- **Headless** (default): `restart` calls `libc::_exit(-1)` (= exit code 255); `run.sh` re-executes the same binary *by path*. -- **Desktop** (`--features desktop`): Tauri cleanup + respawn of the bundled binary + `exit(0)`. +- **Headless** (default): no handler installed, so `restart` calls `libc::_exit(-1)` (= exit code 255); `run.sh` re-executes the same binary *by path*. +- **Desktop** (`--features desktop`): the Tauri shell installs a handler via `tools::restart::set_restart_handler` — cleanup + respawn of the bundled binary + `exit(0)`. The core does not know Tauri exists. Use it to pick up `config.yml` / database changes, which are only read at startup. To load new **code**: `./build.sh`, then restart — the supervisor picks up the new binary on the next loop, since `build.sh` installs it with an atomic rename. -> `restart`'s tool description in `src/core/tools/restart.rs` still tells the model it triggers a `cargo build`. That is stale and must be fixed, along with `run.bat` (still `cargo run`). +> `run.bat` is still stale (`cargo run`) and must be fixed. ## Build & run diff --git a/Cargo.lock b/Cargo.lock index 387325b..f684d8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,18 @@ dependencies = [ "object", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -2990,11 +3002,12 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] @@ -3620,6 +3633,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3696,6 +3731,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4299,7 +4345,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -5396,22 +5442,16 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64 0.22.1", "chrono", - "chrono-tz", "core-api", - "cron", "dirs 5.0.1", "futures", - "glob", "honcho-client", - "iana-time-zone", "indexmap 2.14.0", "libc", "llm-client", "mcp-client", "notify", - "os_info", "plugin-comfyui", "plugin-elevenlabs", "plugin-honcho", @@ -5421,18 +5461,13 @@ dependencies = [ "plugin-transcribe-whisper-local", "plugin-tts-kokoro", "plugin-tts-orpheus-3b", - "proc-macro2", - "quote", - "rand 0.10.1", - "regex", "reqwest 0.13.4", "rustls", "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "skald-core", "sqlx", - "syn 2.0.117", "tauri", "tauri-build", "tokio", @@ -5442,6 +5477,47 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", +] + +[[package]] +name = "skald-core" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "chrono-tz", + "core-api", + "cron", + "futures", + "glob", + "honcho-client", + "iana-time-zone", + "indexmap 2.14.0", + "libc", + "libsqlite3-sys", + "llm-client", + "mcp-client", + "notify", + "os_info", + "proc-macro2", + "quote", + "rand 0.10.1", + "regex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "subtle", + "syn 2.0.117", + "tokio", + "tokio-util", + "tracing", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", @@ -5459,6 +5535,8 @@ dependencies = [ "tree-sitter-swift", "tree-sitter-typescript", "tree-sitter-yaml", + "uuid", + "zeroize", ] [[package]] @@ -6389,7 +6467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7721,9 +7799,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -8903,18 +8981,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 54d7ca9..693bb60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ ".", + "crates/skald-core", "crates/honcho-client", "crates/llm-client", "crates/core-api", @@ -45,6 +46,8 @@ embedded-tailscale = ["plugin-tailscale-remote/remote-tailscale"] tauri-build = { version = "2", optional = true , features = [] } [dependencies] +skald-core = { path = "crates/skald-core" } + axum = { version = "0.8", features = ["ws", "multipart"] } tokio = { version = "1.52.3", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } @@ -57,10 +60,14 @@ anyhow = "1" sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "json", "multipart"] } # rustls is pinned as a direct dependency solely to select the crypto provider: -# `ring` instead of the default `aws-lc-rs`. This keeps TLS off OpenSSL/aws-lc -# (no libssl link, no cmake/NASM build), which is what makes a fully static musl -# binary practical. Every reqwest client uses `rustls-no-provider`, so exactly -# one process-wide provider is installed in main() before any TLS handshake. +# `ring` instead of the default `aws-lc-rs`, avoiding aws-lc's cmake/NASM build. +# Every reqwest client uses `rustls-no-provider`, so exactly one process-wide +# provider is installed in main() before any TLS handshake. +# +# TLS therefore never touches OpenSSL. libcrypto is nonetheless in the tree now, +# vendored and statically linked for SQLCipher (see `libsqlite3-sys` in +# crates/skald-core) — so the binary stays self-contained, but "no OpenSSL +# anywhere" is no longer true. rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } async-trait = "0.1" serde_json = "1" @@ -69,36 +76,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -chrono-tz = "0.10" -iana-time-zone = "0.1" -os_info = "3" -cron = "0.16" -rand = "0.10.1" -syn = { version = "2", features = ["full"] } -quote = "1" -proc-macro2 = { version = "1", features = ["span-locations"] } -tree-sitter = "0.26" -tree-sitter-python = "0.25" -tree-sitter-javascript = "0.25" -tree-sitter-typescript = "0.23" -tree-sitter-go = "0.25" -tree-sitter-java = "0.23" -tree-sitter-c = "0.24" -tree-sitter-cpp = "0.23" -tree-sitter-swift = "0.7" -tree-sitter-lua = "0.5" -tree-sitter-ruby = "0.23" -tree-sitter-bash = "0.25" -tree-sitter-elixir = "0.3" -tree-sitter-json = "0.24" -tree-sitter-yaml = "0.7" -tree-sitter-html = "0.23" -tree-sitter-css = "0.25" -regex = "1" -glob = "0.3" libc = "0.2" -base64 = "0.22" -sha2 = "0.10" notify = "8" honcho-client = { path = "crates/honcho-client" } llm-client = { path = "crates/llm-client" } diff --git a/crates/core-api/src/plugin.rs b/crates/core-api/src/plugin.rs index ea9fdd4..807df45 100644 --- a/crates/core-api/src/plugin.rs +++ b/crates/core-api/src/plugin.rs @@ -87,6 +87,19 @@ pub trait Plugin: Send + Sync { /// unaffected. fn http_router(&self) -> Option { None } + /// Tools this plugin contributes to the registry — the sibling of + /// [`Plugin::http_router`]. + /// + /// The receiver is `Arc` because the tools a plugin builds usually + /// call back into it, so it must hand them its own handle. Without this + /// hook the core has to name concrete plugin crates in order to downcast + /// them, and ends up depending on every plugin in the tree. + /// + /// Called once while the tool registry is built, *before* the plugin's + /// runloop starts: the tools must tolerate being invoked while their plugin + /// is stopped. Default: no tools. + fn tools(self: Arc) -> Vec> { Vec::new() } + /// Returns a [`Memory`] backend if this plugin provides one. fn memory(&self) -> Option> { None } diff --git a/crates/plugin-mobile-connector/src/lib.rs b/crates/plugin-mobile-connector/src/lib.rs index 187ec6d..6f0ebd4 100644 --- a/crates/plugin-mobile-connector/src/lib.rs +++ b/crates/plugin-mobile-connector/src/lib.rs @@ -316,6 +316,13 @@ impl Plugin for MobileConnectorPlugin { Some(router::build(Arc::clone(&self.inner))) } + /// Control tools (plugin.md §11). They close over the plugin itself as a + /// `RelayAgent` and call into it lazily, so building them before the runloop + /// starts is fine — they fail gracefully while it is stopped. + fn tools(self: Arc) -> Vec> { + crate::tools::mobile_tools(self) + } + fn as_any(&self) -> &dyn std::any::Any { self } fn as_arc_any(self: Arc) -> Arc { self } } diff --git a/crates/skald-core/Cargo.toml b/crates/skald-core/Cargo.toml new file mode 100644 index 0000000..ab00853 --- /dev/null +++ b/crates/skald-core/Cargo.toml @@ -0,0 +1,82 @@ +[package] +name = "skald-core" +version = "0.1.0" +edition = "2024" + +# The headless application core: database + crypto + identity, the LLM stack, +# tools, MCP, plugins-as-a-registry, sessions. +# +# Deliberately knows nothing about the process shell around it. No Tauri, no +# concrete plugin crates (it only ever sees `Arc` from `core-api`), +# no `axum` server — `skald` and `skald-setup` are both consumers. + +[dependencies] +axum = { version = "0.8", features = ["ws", "multipart"] } +tokio = { version = "1.52.3", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } +futures = "0.3" +serde = { version = "1", features = ["derive"] } +anyhow = "1" +sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] } +# Per-user database encryption. `sqlx-sqlite` already builds `libsqlite3-sys`; +# naming it here only adds a feature, and Cargo's feature unification turns the +# one SQLite the process links into SQLCipher. A database opened without +# `PRAGMA key` still behaves as plain SQLite, so `system.db` is unaffected. +# `-vendored-openssl` compiles libcrypto from source instead of linking the +# system one, so the binary stays self-contained (see b08a13c). It costs a C +# build (perl + make) — the trade the blueprint §4 accepts explicitly. +# +# Pinned below 0.38 on purpose: `sqlx-sqlite` 0.9 requires ">=0.30.1, <0.38.0". +# A newer release would resolve to a *second* copy of the crate, and the +# SQLCipher feature would then apply to a SQLite that sqlx never links. 0.37.0 +# is the newest version that still unifies. +libsqlite3-sys = { version = "0.37", features = ["bundled-sqlcipher-vendored-openssl"] } +# 0.6 is still a release candidate; stay on the stable line. +argon2 = "0.5" +# Held at 0.10 to match `skald-relay-common`, so the binary links one AES-GCM +# rather than two. Bumping to 0.11 means bumping the mobile E2E code with it. +aes-gcm = "0.10" +zeroize = { version = "1", features = ["derive"] } +subtle = "2" +uuid = { version = "1", features = ["v4"] } +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "json", "multipart"] } +async-trait = "0.1" +serde_json = "1" +indexmap = { version = "2", features = ["serde"] } +tracing = "0.1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +chrono-tz = "0.10" +iana-time-zone = "0.1" +os_info = "3" +cron = "0.16" +rand = "0.10.1" +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = { version = "1", features = ["span-locations"] } +tree-sitter = "0.26" +tree-sitter-python = "0.25" +tree-sitter-javascript = "0.25" +tree-sitter-typescript = "0.23" +tree-sitter-go = "0.25" +tree-sitter-java = "0.23" +tree-sitter-c = "0.24" +tree-sitter-cpp = "0.23" +tree-sitter-swift = "0.7" +tree-sitter-lua = "0.5" +tree-sitter-ruby = "0.23" +tree-sitter-bash = "0.25" +tree-sitter-elixir = "0.3" +tree-sitter-json = "0.24" +tree-sitter-yaml = "0.7" +tree-sitter-html = "0.23" +tree-sitter-css = "0.25" +regex = "1" +glob = "0.3" +libc = "0.2" +base64 = "0.22" +sha2 = "0.10" +notify = "8" +honcho-client = { path = "../honcho-client" } +llm-client = { path = "../llm-client" } +core-api = { path = "../core-api" } +mcp-client = { path = "../mcp-client" } diff --git a/src/core/agents.rs b/crates/skald-core/src/agents.rs similarity index 99% rename from src/core/agents.rs rename to crates/skald-core/src/agents.rs index 60f9bfe..62c513b 100644 --- a/src/core/agents.rs +++ b/crates/skald-core/src/agents.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tracing::{debug, trace, warn}; -use crate::config::LlmStrength; +use core_api::provider::LlmStrength; const AGENTS_DIR: &str = "agents"; diff --git a/src/core/approval/mod.rs b/crates/skald-core/src/approval/mod.rs similarity index 97% rename from src/core/approval/mod.rs rename to crates/skald-core/src/approval/mod.rs index 80c0478..cdd39b8 100644 --- a/src/core/approval/mod.rs +++ b/crates/skald-core/src/approval/mod.rs @@ -46,11 +46,11 @@ use sqlx::SqlitePool; use tokio::sync::{broadcast, Mutex, oneshot}; use tracing::{debug, error, info, warn}; -use crate::core::pending_registry::PendingRegistry; -use crate::core::session::handler::ApprovalDecision; -use crate::core::tools::tool_names as tn; -use crate::core::tools::ToolCategory; -use crate::core::events::{GlobalEvent, ServerEvent}; +use crate::pending_registry::PendingRegistry; +use crate::session::handler::ApprovalDecision; +use crate::tools::tool_names as tn; +use crate::tools::ToolCategory; +use crate::events::{GlobalEvent, ServerEvent}; // ── Public types ────────────────────────────────────────────────────────────── @@ -437,7 +437,7 @@ impl ApprovalManager { args: &Value, group_id: Option<&str>, ) -> GateResult { - let rules = match crate::core::db::approval_rules::list_for_group(&self.db, group_id).await { + let rules = match crate::db::approval_rules::list_for_group(&self.db, group_id).await { Ok(r) => r, Err(e) => { // Fail closed: this is a default-closed security gate (see module docs). @@ -754,7 +754,7 @@ impl ApprovalManager { group_id: &str, tool_name: &str, ) -> Option { - let rules = crate::core::db::approval_rules::list_for_group(&self.db, Some(group_id)) + let rules = crate::db::approval_rules::list_for_group(&self.db, Some(group_id)) .await .unwrap_or_default(); for rule in &rules { @@ -768,7 +768,7 @@ impl ApprovalManager { // ── Rule management ─────────────────────────────────────────────────────── pub async fn list_rules(&self) -> Result> { - crate::core::db::approval_rules::list(&self.db).await + crate::db::approval_rules::list(&self.db).await } pub async fn add_rule(&self, r: NewApprovalRule) -> Result { @@ -776,11 +776,11 @@ impl ApprovalManager { let group = r.group_id.as_deref().unwrap_or("default"); self.ensure_no_conflicting_catch_all(group, &r.action, None).await?; } - crate::core::db::approval_rules::insert(&self.db, r).await + crate::db::approval_rules::insert(&self.db, r).await } pub async fn delete_rule(&self, id: i64) -> Result<()> { - crate::core::db::approval_rules::delete(&self.db, id).await + crate::db::approval_rules::delete(&self.db, id).await } pub async fn update_rule(&self, id: i64, r: NewApprovalRule) -> Result<()> { @@ -788,7 +788,7 @@ impl ApprovalManager { let group = r.group_id.as_deref().unwrap_or("default"); self.ensure_no_conflicting_catch_all(group, &r.action, Some(id)).await?; } - crate::core::db::approval_rules::update(&self.db, id, r).await + crate::db::approval_rules::update(&self.db, id, r).await } /// Rejects creating/updating a `*` catch-all when the target group already has a @@ -916,11 +916,11 @@ fn rule_matches(rule: &ApprovalRule, agent_id: &str, source: &str, tool_name: &s /// These back the "File System" permission panel, where each path row is one rule row. pub(crate) fn tool_pattern_matches(pattern: &str, tool_name: &str) -> bool { match pattern { - "@fs_read" => crate::core::tools::is_file_read_tool(tool_name), - "@fs_write" => crate::core::tools::is_file_write_tool(tool_name), + "@fs_read" => crate::tools::is_file_read_tool(tool_name), + "@fs_write" => crate::tools::is_file_write_tool(tool_name), "@fs_any" => { - crate::core::tools::is_file_read_tool(tool_name) - || crate::core::tools::is_file_write_tool(tool_name) + crate::tools::is_file_read_tool(tool_name) + || crate::tools::is_file_write_tool(tool_name) } _ => pattern_matches(pattern, tool_name), } @@ -981,7 +981,7 @@ fn mcp_server_from_tool_name(name: &str) -> Option { pub(crate) fn normalize_path(path: &str) -> String { let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd); - let canon = crate::core::tools::fs::canonicalize_for_policy(path, &cwd); + let canon = crate::tools::fs::canonicalize_for_policy(path, &cwd); if let Ok(rel) = canon.strip_prefix(&cwd) { return rel.to_string_lossy().into_owned(); } @@ -1059,7 +1059,7 @@ mod tests { let path = std::env::temp_dir().join(format!("skald_fs_test_{}.db", std::process::id())); let path_str = path.to_string_lossy().to_string(); let _ = std::fs::remove_file(&path); - let pool = crate::core::db::init_pool(&path_str).await.expect("init_pool"); + let pool = crate::db::init_system_pool(&path_str).await.expect("init_system_pool"); let db = Arc::new(pool); // The `default` permission group is a FK target for approval_rules.group_id. diff --git a/crates/skald-core/src/boot.rs b/crates/skald-core/src/boot.rs new file mode 100644 index 0000000..917175d --- /dev/null +++ b/crates/skald-core/src/boot.rs @@ -0,0 +1,53 @@ +//! Curated, human-readable bootstrap progress printed to **stdout**. +//! +//! At runtime stdout is silent — only the file log (`logs/skald.log`) records +//! events. During startup, though, it is useful to see at a glance how the app +//! is configured and how it is coming up. These helpers emit a small, ordered +//! set of lines on the dedicated `boot` tracing target. +//! +//! Rendering belongs to whoever owns the process: each shell installs a stdout +//! layer filtered on [`TARGET`] and formats the lines as it likes (`skald`'s +//! `BootFormat` strips timestamps and paints failures red). The core only says +//! *what* happened, never how it looks — which is also why nothing here depends +//! on `tracing-subscriber`. +//! +//! The same lines land in the log file (they pass the normal `EnvFilter`), so +//! they double as a high-level startup trace. Glyphs are baked into the message +//! on purpose, so the file keeps the same readable shape. + +use std::fmt; + +use tracing::{info, warn}; + +/// Tracing target for curated bootstrap lines shown on stdout. +pub const TARGET: &str = "boot"; + +/// Top-level title (no glyph), e.g. `skald v0.5 — starting`. +pub fn title(msg: impl fmt::Display) { + info!(target: TARGET, "{}", msg); +} + +/// A phase header, e.g. `› Plugins — 6 active, 1 failed`. +pub fn section(msg: impl fmt::Display) { + info!(target: TARGET, "› {}", msg); +} + +/// A successful item under a phase. +pub fn ok(msg: impl fmt::Display) { + info!(target: TARGET, " ✓ {}", msg); +} + +/// An item that exists but is inactive (e.g. a disabled plugin). +pub fn off(msg: impl fmt::Display) { + info!(target: TARGET, " ○ {}", msg); +} + +/// A failed item (rendered in red on stdout; logged at WARN in the file). +pub fn fail(msg: impl fmt::Display) { + warn!(target: TARGET, " ✗ {}", msg); +} + +/// The final "app is up" line, e.g. `✅ Ready — http://localhost:8080`. +pub fn ready(msg: impl fmt::Display) { + info!(target: TARGET, "✅ {}", msg); +} diff --git a/src/core/chat_event_bus.rs b/crates/skald-core/src/chat_event_bus.rs similarity index 100% rename from src/core/chat_event_bus.rs rename to crates/skald-core/src/chat_event_bus.rs diff --git a/src/core/chat_hub/inbox.rs b/crates/skald-core/src/chat_hub/inbox.rs similarity index 100% rename from src/core/chat_hub/inbox.rs rename to crates/skald-core/src/chat_hub/inbox.rs diff --git a/src/core/chat_hub/mod.rs b/crates/skald-core/src/chat_hub/mod.rs similarity index 97% rename from src/core/chat_hub/mod.rs rename to crates/skald-core/src/chat_hub/mod.rs index 4f32ab2..3f6c42e 100644 --- a/src/core/chat_hub/mod.rs +++ b/crates/skald-core/src/chat_hub/mod.rs @@ -12,14 +12,14 @@ use tracing::{error, info, warn}; mod inbox; use inbox::{QueuedMessage, SourceInbox, build_unit, drain_leading_user}; -use crate::core::approval::ApprovalManager; -use crate::core::cron::TaskManager; -use crate::core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, config, sources}; -use crate::core::events::{GlobalEvent, ServerEvent}; -use crate::core::notification::Notification; -use crate::core::session::handler::{ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput}; -use crate::core::session::manager::ChatSessionManager; -use crate::core::tools::tool_names as tn; +use crate::approval::ApprovalManager; +use crate::cron::TaskManager; +use crate::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, config, sources}; +use crate::events::{GlobalEvent, ServerEvent}; +use crate::notification::Notification; +use crate::session::handler::{ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput}; +use crate::session::manager::ChatSessionManager; +use crate::tools::tool_names as tn; pub use core_api::chat_hub::{ChatHubApi, ModelCommandOutcome, SendMessageOptions}; @@ -196,7 +196,7 @@ impl ChatHub { let mut interface_tools = opts.interface_tools; if let Some(task_mgr) = self.task_mgr.get() { interface_tools.push( - crate::core::tools::cron_jobs::build_execute_task_interface_tool( + crate::tools::cron_jobs::build_execute_task_interface_tool( Arc::clone(task_mgr), session_id, run_context_json, @@ -245,7 +245,7 @@ impl ChatHub { &self, source_id: &str, agent_id: &str, - run_context: Option<&crate::core::run_context::RunContext>, + run_context: Option<&crate::run_context::RunContext>, reset: bool, ) -> anyhow::Result { // A reset discards the current session; drop any messages queued for it. @@ -372,7 +372,7 @@ impl ChatHub { let mut tools = Vec::new(); if let Some(task_mgr) = self.task_mgr.get() { let run_context_json = handler.run_context_json().await; - tools.push(crate::core::tools::cron_jobs::build_execute_task_interface_tool( + tools.push(crate::tools::cron_jobs::build_execute_task_interface_tool( Arc::clone(task_mgr), session_id, run_context_json, @@ -402,7 +402,7 @@ impl ChatHub { /// The next LLM turn will start with no MCP servers activated. pub async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()> { let session_id = self.get_or_create_session(source_id, "main").await?; - crate::core::db::session_mcp_grants::revoke_all(&self.db, session_id).await?; + crate::db::session_mcp_grants::revoke_all(&self.db, session_id).await?; info!(source_id, session_id, "ChatHub: MCP grants reset"); Ok(()) } diff --git a/src/core/chatbot/logging.rs b/crates/skald-core/src/chatbot/logging.rs similarity index 99% rename from src/core/chatbot/logging.rs rename to crates/skald-core/src/chatbot/logging.rs index ecf1728..cf227f3 100644 --- a/src/core/chatbot/logging.rs +++ b/crates/skald-core/src/chatbot/logging.rs @@ -15,7 +15,7 @@ use serde_json::Value; use sqlx::SqlitePool; use tracing::warn; -use crate::core::db::llm_requests; +use crate::db::llm_requests; use super::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message}; diff --git a/src/core/chatbot/mod.rs b/crates/skald-core/src/chatbot/mod.rs similarity index 100% rename from src/core/chatbot/mod.rs rename to crates/skald-core/src/chatbot/mod.rs diff --git a/src/core/clarification/mod.rs b/crates/skald-core/src/clarification/mod.rs similarity index 97% rename from src/core/clarification/mod.rs rename to crates/skald-core/src/clarification/mod.rs index dba6161..2cdd40e 100644 --- a/src/core/clarification/mod.rs +++ b/crates/skald-core/src/clarification/mod.rs @@ -6,8 +6,8 @@ use serde::Serialize; use tokio::sync::{broadcast, oneshot}; use tracing::info; -use crate::core::events::{GlobalEvent, ServerEvent}; -use crate::core::pending_registry::PendingRegistry; +use crate::events::{GlobalEvent, ServerEvent}; +use crate::pending_registry::PendingRegistry; #[derive(Debug, Clone, Serialize)] pub struct PendingClarificationInfo { diff --git a/src/core/command/mod.rs b/crates/skald-core/src/command/mod.rs similarity index 100% rename from src/core/command/mod.rs rename to crates/skald-core/src/command/mod.rs diff --git a/src/core/compactor.rs b/crates/skald-core/src/compactor.rs similarity index 98% rename from src/core/compactor.rs rename to crates/skald-core/src/compactor.rs index 6823dea..e50ef2a 100644 --- a/src/core/compactor.rs +++ b/crates/skald-core/src/compactor.rs @@ -49,11 +49,11 @@ use serde_json::json; use sqlx::SqlitePool; use tracing::{debug, info, warn}; -use crate::core::chat_event_bus::{ChatEventBus, CompactionEvent}; -use crate::core::chatbot::ChatOptions; -use crate::core::config::CompactionConfig; -use crate::core::db::{chat_history, chat_llm_tools, chat_summaries}; -use crate::core::llm::LlmManager; +use crate::chat_event_bus::{ChatEventBus, CompactionEvent}; +use crate::chatbot::ChatOptions; +use crate::config::CompactionConfig; +use crate::db::{chat_history, chat_llm_tools, chat_summaries}; +use crate::llm::LlmManager; // ── Compaction constants (ported from Hermes context_compressor.py) ────────── // @@ -322,8 +322,8 @@ impl ContextCompactor { })?; let summary_text = match turn { - crate::core::chatbot::LlmTurn::Message(resp) => resp.content, - crate::core::chatbot::LlmTurn::ToolCalls { content, .. } => { + crate::chatbot::LlmTurn::Message(resp) => resp.content, + crate::chatbot::LlmTurn::ToolCalls { content, .. } => { warn!(stack_id, "compactor: unexpected tool calls in summary response, using content"); content } diff --git a/src/core/config.rs b/crates/skald-core/src/config.rs similarity index 100% rename from src/core/config.rs rename to crates/skald-core/src/config.rs diff --git a/src/core/config_store.rs b/crates/skald-core/src/config_store.rs similarity index 100% rename from src/core/config_store.rs rename to crates/skald-core/src/config_store.rs diff --git a/src/core/cron/mod.rs b/crates/skald-core/src/cron/mod.rs similarity index 95% rename from src/core/cron/mod.rs rename to crates/skald-core/src/cron/mod.rs index 58d9c7c..eaf67d3 100644 --- a/src/core/cron/mod.rs +++ b/crates/skald-core/src/cron/mod.rs @@ -12,10 +12,10 @@ use tracing::{error, info}; use core_api::system_bus::{SystemEvent, SystemEventBus}; -use crate::core::chat_hub::ChatHub; -use crate::core::db::chat_sessions; -use crate::core::db::scheduled_jobs::{self, ScheduledJob}; -use crate::core::session::manager::ChatSessionManager; +use crate::chat_hub::ChatHub; +use crate::db::chat_sessions; +use crate::db::scheduled_jobs::{self, ScheduledJob}; +use crate::session::manager::ChatSessionManager; pub struct TaskManager { pool: Arc, @@ -183,7 +183,7 @@ impl TaskManager { if agent_id.trim().is_empty() { anyhow::bail!("agent_id is required — specify which task agent runs this task (no default)"); } - crate::core::agents::load_task_meta(agent_id)?; + crate::agents::load_task_meta(agent_id)?; Ok(()) } @@ -449,7 +449,7 @@ async fn run_job( "cron" => { if let Some(hub) = hub { let outcome = final_response.as_deref().unwrap_or("(no output)"); - hub.notify(crate::core::notification::Notification { + hub.notify(crate::notification::Notification { source: "cron".into(), event_type: "cron_result".into(), summary: format!( @@ -496,7 +496,7 @@ async fn run_job( }); if let Some(hub) = hub { - hub.notify(crate::core::notification::Notification { + hub.notify(crate::notification::Notification { source: "cron".into(), event_type: "cron_error".into(), summary: format!( @@ -525,14 +525,14 @@ async fn inject_async_result( result: &str, ) { // Resolve source_id from the parent session row. - let source_id = match crate::core::db::chat_sessions::find_by_id(pool, parent_session_id).await { + let source_id = match crate::db::chat_sessions::find_by_id(pool, parent_session_id).await { Ok(Some(s)) => s.source, Ok(None) => { error!("inject_async_result: session {parent_session_id} not found"); return; } Err(e) => { error!("inject_async_result: DB error: {e}"); return; } }; // Get the active stack for the parent session. - let stack = match crate::core::db::chat_sessions_stack::active_for_session(pool, parent_session_id).await { + let stack = match crate::db::chat_sessions_stack::active_for_session(pool, parent_session_id).await { Ok(Some(s)) => s, Ok(None) => { error!("inject_async_result: no active stack for session {parent_session_id}"); return; } Err(e) => { error!("inject_async_result: stack lookup failed: {e}"); return; } @@ -544,8 +544,8 @@ async fn inject_async_result( Let me process the result via task_completed.", task_title, ); - let assistant_id = match crate::core::db::chat_history::append( - pool, stack.id, &crate::core::db::chat_history::Role::Assistant, + let assistant_id = match crate::db::chat_history::append( + pool, stack.id, &crate::db::chat_history::Role::Assistant, "", true, Some(&reasoning), ).await { Ok(id) => id, @@ -559,7 +559,7 @@ async fn inject_async_result( "result": result, })).unwrap_or_else(|_| "{}".to_string()); - let tool_call_id = match crate::core::db::chat_llm_tools::append( + let tool_call_id = match crate::db::chat_llm_tools::append( pool, assistant_id, "task_completed", &serde_json::json!({"task_id": task_id}).to_string(), ).await { @@ -567,7 +567,7 @@ async fn inject_async_result( Err(e) => { error!("inject_async_result: append tool call failed: {e}"); return; } }; - if let Err(e) = crate::core::db::chat_llm_tools::complete(pool, tool_call_id, &result_json, "string").await { + if let Err(e) = crate::db::chat_llm_tools::complete(pool, tool_call_id, &result_json, "string").await { error!("inject_async_result: complete tool call failed: {e}"); return; } @@ -580,15 +580,15 @@ async fn inject_async_result( /// Builds the `execute_subtask` InterfaceTool injected into background sessions. /// Background tasks can only run synchronous sub-tasks — no cron or async. -fn build_execute_subtask_tool(task_mgr: Arc, run_context: Option) -> crate::core::session::handler::InterfaceTool { - use crate::core::session::handler::{InterfaceTool, ToolFuture}; +fn build_execute_subtask_tool(task_mgr: Arc, run_context: Option) -> crate::session::handler::InterfaceTool { + use crate::session::handler::{InterfaceTool, ToolFuture}; use serde_json::json; InterfaceTool { definition: json!({ "type": "function", "function": { - "name": crate::core::tools::tool_names::EXECUTE_SUBTASK, + "name": crate::tools::tool_names::EXECUTE_SUBTASK, "description": "Run a synchronous sub-task and return its result. Blocks until the sub-task completes.", "parameters": { "type": "object", @@ -633,7 +633,7 @@ async fn record_job_run( response: Option<&str>, error: Option<&str>, ) -> Result<()> { - crate::core::db::job_runs::insert( + crate::db::job_runs::insert( pool, job_id, Some(session_id), started_at, completed_at, duration_ms, status, response, error, diff --git a/crates/skald-core/src/crypto/mod.rs b/crates/skald-core/src/crypto/mod.rs new file mode 100644 index 0000000..a9cbd56 --- /dev/null +++ b/crates/skald-core/src/crypto/mod.rs @@ -0,0 +1,363 @@ +//! Envelope encryption for the per-user databases (blueprint §4 / §5.1). +//! +//! A user's database is encrypted by SQLCipher under a random 256-bit **DEK**. +//! The DEK is never stored in the clear: `users.database_password` holds it +//! sealed with AES-256-GCM under a **KEK** derived from the password with +//! Argon2id. +//! +//! That seal *is* the password verifier. Opening it either yields the DEK — in +//! which case the password was right — or fails the AEAD tag, which means a +//! wrong password, cleanly distinct from a corrupt file. Storing a second hash +//! of the same password beside the seal would only hand an offline attacker an +//! easier target than the seal itself, so encrypted users have no +//! `password_hash` at all. +//! +//! Cleartext users have no database key to bind a verifier to, so they store the +//! Argon2id output directly and it is compared in constant time. Its +//! crackability is harmless: their database is readable by the box owner by +//! design. +//! +//! Nothing here invents a construction — it composes Argon2id, AES-256-GCM and a +//! CSPRNG. Changing the password re-seals the same DEK; the database itself is +//! never re-encrypted. + +use std::fmt::{self, Write as _}; +use std::sync::LazyLock; + +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use anyhow::{Context, Result, anyhow, bail}; +use argon2::{Algorithm, Argon2, Params, Version}; +use rand::Rng as _; +use serde::{Deserialize, Serialize}; +use subtle::ConstantTimeEq; +use tokio::sync::Semaphore; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// 256-bit keys throughout: AES-256-GCM for the seal, raw SQLCipher key for the +/// database. +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; +/// Sealed layout: `nonce ‖ ciphertext ‖ tag`. +const SEALED_LEN: usize = NONCE_LEN + KEY_LEN + TAG_LEN; + +pub const SALT_LEN: usize = 16; + +/// The only algorithm we accept. Stored per row so it can change later without a +/// migration, but a row asking for anything else is a bug, not a fallback. +const ALGO: &str = "argon2id"; + +/// Argon2id at 256 MiB is a memory bomb if it runs unbounded: every concurrent +/// login would allocate its own arena. Two at a time keeps a login responsive +/// while capping the peak at ~2× `KdfParams::m`. +/// +/// Deliberately a module-level invariant rather than a caller's responsibility — +/// a forgotten permit is a memory incident, not a style problem. +static KDF_GATE: LazyLock = LazyLock::new(|| Semaphore::new(2)); + +// ── KDF parameters ──────────────────────────────────────────────────────────── + +/// Serialized into `users.kdf_params`. Not secret: calibrated on the box when the +/// user is created, and kept per row so raising the cost later needs no migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KdfParams { + pub algo: String, + /// Memory cost, in KiB. + pub m: u32, + /// Time cost (passes). + pub t: u32, + /// Parallelism (lanes). + pub p: u32, +} + +impl Default for KdfParams { + /// 256 MiB, ~1s on the weakest box we target. + /// + /// Between the OWASP baseline (64 MiB) and the 512 MiB–1 GiB of §5.1: four + /// times the cost for an attacker with a GPU, while two concurrent logins + /// peak at 512 MiB rather than 2 GiB and never reach swap. + fn default() -> Self { + Self { algo: ALGO.to_string(), m: 262_144, t: 3, p: 1 } + } +} + +impl KdfParams { + pub fn to_json(&self) -> Result { + serde_json::to_string(self).context("serializing kdf_params") + } + + pub fn from_json(s: &str) -> Result { + let p: Self = serde_json::from_str(s).context("parsing kdf_params")?; + if p.algo != ALGO { + bail!("unsupported kdf algorithm: {}", p.algo); + } + Ok(p) + } + + /// Cheap parameters, tests only: the real ones cost ~1s and 256 MiB per + /// derivation, which a test suite pays on every register and every login. + #[cfg(test)] + pub fn fast() -> Self { + Self { algo: ALGO.to_string(), m: 64, t: 1, p: 1 } + } +} + +// ── Keys ────────────────────────────────────────────────────────────────────── + +/// Data Encryption Key: the raw SQLCipher key for one user's database. Random, +/// never derived from the password, so changing the password does not re-encrypt +/// anything. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Dek([u8; KEY_LEN]); + +/// Key Encryption Key: `Argon2id(password, salt)`. Seals the [`Dek`], and for a +/// cleartext user its raw bytes *are* the stored verifier. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Kek([u8; KEY_LEN]); + +impl Dek { + pub fn random() -> Self { + let mut k = [0u8; KEY_LEN]; + rand::rng().fill_bytes(&mut k); + Self(k) + } + + /// The `PRAGMA key` value for a raw 256-bit key. + /// + /// The double quotes are **part of the value**: sqlx pastes it verbatim into + /// `PRAGMA key = {value};`, and SQLCipher only treats the argument as a raw + /// key when it parses as the blob literal `x'…'`. Given anything else it + /// runs its own KDF over the bytes instead, silently deriving a *different* + /// key from our hex digits — and the database would open, just not the one + /// we meant. + /// + /// The returned string is key material. It must never be logged, and it + /// outlives this call inside the pool's connect options. + pub fn to_pragma(&self) -> String { + // `"x'` + 64 hex digits + `'"` + let mut s = String::with_capacity(5 + 2 * KEY_LEN); + s.push_str("\"x'"); + for b in self.0 { + let _ = write!(s, "{b:02x}"); + } + s.push_str("'\""); + s + } +} + +impl Kek { + /// The verifier stored in `users.password_hash` for a cleartext user. + pub fn as_verifier(&self) -> &[u8] { + &self.0 + } +} + +// Hand-written so a stray `{:?}` — a tracing span, an error context, a panic — +// cannot print key material. Same reasoning as `db::users::Credentials`. +impl fmt::Debug for Dek { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Dek()") + } +} + +impl fmt::Debug for Kek { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Kek()") + } +} + +pub fn random_salt() -> Vec { + let mut s = vec![0u8; SALT_LEN]; + rand::rng().fill_bytes(&mut s); + s +} + +// ── Derivation ──────────────────────────────────────────────────────────────── + +/// `Argon2id(password, salt)` → 256-bit key. +/// +/// Runs on a blocking thread — it burns a core and 256 MiB for about a second, +/// which would stall a tokio worker — and behind [`KDF_GATE`]. +pub async fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result { + let _permit = KDF_GATE.acquire().await.context("kdf gate closed")?; + + let mut password = password.to_owned(); + let salt = salt.to_vec(); + let params = params.clone(); + + let out = tokio::task::spawn_blocking(move || { + let result = derive_blocking(password.as_bytes(), &salt, ¶ms); + password.zeroize(); + result + }) + .await + .context("argon2 task panicked")?; + + out +} + +fn derive_blocking(password: &[u8], salt: &[u8], params: &KdfParams) -> Result { + if params.algo != ALGO { + bail!("unsupported kdf algorithm: {}", params.algo); + } + let p = Params::new(params.m, params.t, params.p, Some(KEY_LEN)) + .map_err(|e| anyhow!("invalid argon2 params: {e}"))?; + let mut key = [0u8; KEY_LEN]; + Argon2::new(Algorithm::Argon2id, Version::V0x13, p) + .hash_password_into(password, salt, &mut key) + .map_err(|e| anyhow!("argon2 derivation failed: {e}"))?; + Ok(Kek(key)) +} + +/// Constant-time comparison of a freshly derived key against a stored verifier. +/// `subtle` returns "not equal" for a length mismatch rather than short-circuiting. +pub fn verify(derived: &Kek, stored: &[u8]) -> bool { + derived.0.ct_eq(stored).into() +} + +// ── Envelope ────────────────────────────────────────────────────────────────── + +/// Why a seal did not open. The distinction is the whole point: a failed tag is +/// an authentication answer, a malformed blob is a broken row. +#[derive(Debug, PartialEq, Eq)] +pub enum KeyError { + /// The AEAD tag did not verify. This *is* the wrong-password signal. + WrongPassword, + Malformed(&'static str), +} + +impl fmt::Display for KeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyError::WrongPassword => f.write_str("wrong password"), + KeyError::Malformed(w) => write!(f, "malformed wrapped key: {w}"), + } + } +} + +impl std::error::Error for KeyError {} + +/// Seals `dek` under `kek`. Output is `nonce ‖ ciphertext ‖ tag`, stored as-is in +/// `users.database_password`. +pub fn wrap_dek(kek: &Kek, dek: &Dek) -> Result> { + let cipher = Aes256Gcm::new_from_slice(&kek.0).map_err(|e| anyhow!("bad kek length: {e}"))?; + + let mut nonce = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce); + + let sealed = cipher + .encrypt(Nonce::from_slice(&nonce), dek.0.as_slice()) + .map_err(|_| anyhow!("AEAD seal failed"))?; + + let mut out = Vec::with_capacity(SEALED_LEN); + out.extend_from_slice(&nonce); + out.extend_from_slice(&sealed); + Ok(out) +} + +/// Opens a seal produced by [`wrap_dek`]. A failed tag returns +/// [`KeyError::WrongPassword`] — one Argon2id pass answers "is the password +/// right" *and* hands back the key. +pub fn unwrap_dek(kek: &Kek, sealed: &[u8]) -> Result { + if sealed.len() != SEALED_LEN { + return Err(KeyError::Malformed("unexpected length")); + } + let (nonce, body) = sealed.split_at(NONCE_LEN); + + let cipher = + Aes256Gcm::new_from_slice(&kek.0).map_err(|_| KeyError::Malformed("bad kek length"))?; + + let mut plain = cipher + .decrypt(Nonce::from_slice(nonce), body) + .map_err(|_| KeyError::WrongPassword)?; + + if plain.len() != KEY_LEN { + plain.zeroize(); + return Err(KeyError::Malformed("unexpected plaintext length")); + } + + let mut dek = [0u8; KEY_LEN]; + dek.copy_from_slice(&plain); + plain.zeroize(); + Ok(Dek(dek)) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn kek(password: &str, salt: &[u8]) -> Kek { + derive_kek(password, salt, &KdfParams::fast()).await.unwrap() + } + + #[tokio::test] + async fn seal_round_trips_and_the_tag_is_the_password_check() { + let salt = random_salt(); + let dek = Dek::random(); + let sealed = wrap_dek(&kek("correct horse", &salt).await, &dek).unwrap(); + assert_eq!(sealed.len(), SEALED_LEN); + + let opened = unwrap_dek(&kek("correct horse", &salt).await, &sealed).unwrap(); + assert_eq!(opened.0, dek.0, "the same DEK must come back out"); + + // The whole auth story: a wrong password is a failed AEAD tag, and it is + // reported as such — not as a corrupt blob. + let err = unwrap_dek(&kek("wrong horse", &salt).await, &sealed).unwrap_err(); + assert_eq!(err, KeyError::WrongPassword); + } + + #[tokio::test] + async fn a_truncated_seal_is_malformed_not_a_wrong_password() { + let salt = random_salt(); + let sealed = wrap_dek(&kek("pw", &salt).await, &Dek::random()).unwrap(); + let err = unwrap_dek(&kek("pw", &salt).await, &sealed[..SEALED_LEN - 1]).unwrap_err(); + assert!(matches!(err, KeyError::Malformed(_)), "got {err:?}"); + } + + #[tokio::test] + async fn the_salt_separates_identical_passwords() { + let a = kek("same", &random_salt()).await; + let b = kek("same", &random_salt()).await; + assert_ne!(a.0, b.0, "a per-user salt must defuse rainbow tables"); + } + + #[tokio::test] + async fn verify_accepts_the_right_password_and_rejects_a_short_verifier() { + let salt = random_salt(); + let stored = kek("pw", &salt).await; + assert!(verify(&kek("pw", &salt).await, stored.as_verifier())); + assert!(!verify(&kek("nope", &salt).await, stored.as_verifier())); + // A length mismatch must not panic, and must not compare equal. + assert!(!verify(&kek("pw", &salt).await, &stored.as_verifier()[..16])); + } + + #[test] + fn pragma_is_a_quoted_blob_literal() { + let dek = Dek([0xAB; KEY_LEN]); + let p = dek.to_pragma(); + assert!(p.starts_with("\"x'") && p.ends_with("'\""), "got {p}"); + assert_eq!(p.len(), 5 + 2 * KEY_LEN, "\"x' + 64 hex digits + '\""); + assert!(p.contains("abab"), "lowercase hex"); + } + + #[test] + fn debug_never_prints_key_material() { + let dek = Dek([0xDE; KEY_LEN]); + let kek = Kek([0xAD; KEY_LEN]); + assert_eq!(format!("{dek:?}"), "Dek()"); + assert_eq!(format!("{kek:?}"), "Kek()"); + assert!(!format!("{dek:?}").contains("dede"), "no raw key bytes"); + assert!(!format!("{kek:?}").contains("adad"), "no raw key bytes"); + } + + #[test] + fn kdf_params_round_trip_and_reject_foreign_algorithms() { + let p = KdfParams::default(); + assert_eq!(p.m, 262_144); + assert_eq!(KdfParams::from_json(&p.to_json().unwrap()).unwrap(), p); + + let pbkdf2 = r#"{"algo":"pbkdf2","m":1,"t":1,"p":1}"#; + assert!(KdfParams::from_json(pbkdf2).is_err(), "only argon2id is accepted"); + } +} diff --git a/src/core/db/approval_rules.rs b/crates/skald-core/src/db/approval_rules.rs similarity index 97% rename from src/core/db/approval_rules.rs rename to crates/skald-core/src/db/approval_rules.rs index a253df7..85a8cd5 100644 --- a/src/core/db/approval_rules.rs +++ b/crates/skald-core/src/db/approval_rules.rs @@ -1,7 +1,7 @@ use anyhow::Result; use sqlx::SqlitePool; -use crate::core::approval::{ApprovalRule, NewApprovalRule, RuleAction}; +use crate::approval::{ApprovalRule, NewApprovalRule, RuleAction}; type RawRow = (i64, Option, Option, String, Option, String, Option, i64, Option); diff --git a/src/core/db/chat_history.rs b/crates/skald-core/src/db/chat_history.rs similarity index 97% rename from src/core/db/chat_history.rs rename to crates/skald-core/src/db/chat_history.rs index 0086987..5b391d8 100644 --- a/src/core/db/chat_history.rs +++ b/crates/skald-core/src/db/chat_history.rs @@ -191,15 +191,6 @@ pub async fn for_stack_all( rows.into_iter().map(row_to_message).collect() } -pub async fn set_model_db_id(pool: &SqlitePool, id: i64, model_db_id: i64) -> anyhow::Result<()> { - sqlx::query("UPDATE chat_history SET model_db_id = ? WHERE id = ?") - .bind(model_db_id) - .bind(id) - .execute(pool) - .await?; - Ok(()) -} - /// Ok messages for a stack frame whose id is strictly greater than `after_id`, /// ordered chronologically. Used by `build_openai_messages` when a compaction /// summary exists: only the "raw" messages after the summary boundary are loaded. diff --git a/src/core/db/chat_llm_tools.rs b/crates/skald-core/src/db/chat_llm_tools.rs similarity index 100% rename from src/core/db/chat_llm_tools.rs rename to crates/skald-core/src/db/chat_llm_tools.rs diff --git a/src/core/db/chat_sessions.rs b/crates/skald-core/src/db/chat_sessions.rs similarity index 100% rename from src/core/db/chat_sessions.rs rename to crates/skald-core/src/db/chat_sessions.rs diff --git a/src/core/db/chat_sessions_stack.rs b/crates/skald-core/src/db/chat_sessions_stack.rs similarity index 98% rename from src/core/db/chat_sessions_stack.rs rename to crates/skald-core/src/db/chat_sessions_stack.rs index 4f0db0a..e493b44 100644 --- a/src/core/db/chat_sessions_stack.rs +++ b/crates/skald-core/src/db/chat_sessions_stack.rs @@ -164,7 +164,7 @@ mod tests { #[tokio::test] async fn active_all_reflects_parallel_siblings() { let path = temp_db_path("stack-parallel"); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); let sid = 1; // `session_id` has a FK to chat_sessions (sqlx enables foreign_keys). sqlx::query("INSERT INTO chat_sessions (id) VALUES (?)") diff --git a/src/core/db/chat_summaries.rs b/crates/skald-core/src/db/chat_summaries.rs similarity index 100% rename from src/core/db/chat_summaries.rs rename to crates/skald-core/src/db/chat_summaries.rs diff --git a/src/core/db/config.rs b/crates/skald-core/src/db/config.rs similarity index 100% rename from src/core/db/config.rs rename to crates/skald-core/src/db/config.rs diff --git a/src/core/db/job_runs.rs b/crates/skald-core/src/db/job_runs.rs similarity index 100% rename from src/core/db/job_runs.rs rename to crates/skald-core/src/db/job_runs.rs diff --git a/src/core/db/known_tools.rs b/crates/skald-core/src/db/known_tools.rs similarity index 96% rename from src/core/db/known_tools.rs rename to crates/skald-core/src/db/known_tools.rs index 094a476..3d522e3 100644 --- a/src/core/db/known_tools.rs +++ b/crates/skald-core/src/db/known_tools.rs @@ -1,5 +1,5 @@ //! `known_tools` — every tool ever offered to the LLM, captured at injection -//! time by [`crate::core::tool_discovery::ToolDiscovery`]. +//! time by [`crate::tool_discovery::ToolDiscovery`]. //! //! This is the drift-proof half of tool visibility: instead of maintaining a //! parallel list of "all tools", we record what is actually assembled into the @@ -77,7 +77,7 @@ mod tests { #[tokio::test] async fn upsert_is_idempotent_and_updates_metadata() { let path = temp_db_path("known-tools"); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); upsert(&pool, "send_voice_message", "v1", Some(r#"{"type":"object"}"#)).await.unwrap(); upsert(&pool, "send_voice_message", "v2", None).await.unwrap(); diff --git a/src/core/db/llm_requests/cleanup.rs b/crates/skald-core/src/db/llm_requests/cleanup.rs similarity index 98% rename from src/core/db/llm_requests/cleanup.rs rename to crates/skald-core/src/db/llm_requests/cleanup.rs index 28615e2..192527f 100644 --- a/src/core/db/llm_requests/cleanup.rs +++ b/crates/skald-core/src/db/llm_requests/cleanup.rs @@ -13,7 +13,7 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; -use crate::core::config::LlmRequestsLogConfig; +use crate::config::LlmRequestsLogConfig; /// Spawns the retention/cleanup loop for the `llm_requests` table. /// diff --git a/src/core/db/llm_requests/mod.rs b/crates/skald-core/src/db/llm_requests/mod.rs similarity index 98% rename from src/core/db/llm_requests/mod.rs rename to crates/skald-core/src/db/llm_requests/mod.rs index d0516c3..2d33b70 100644 --- a/src/core/db/llm_requests/mod.rs +++ b/crates/skald-core/src/db/llm_requests/mod.rs @@ -1,7 +1,7 @@ //! DB operations for the `llm_requests` table. //! //! Every `chat_with_tools` call is logged here by the -//! [`crate::core::chatbot::logging::LoggingChatbotClient`] wrapper. +//! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper. //! Rows are retained for `llm.request_log.retention_days` days (default 14). use anyhow::Result; diff --git a/src/core/db/mcp_events.rs b/crates/skald-core/src/db/mcp_events.rs similarity index 100% rename from src/core/db/mcp_events.rs rename to crates/skald-core/src/db/mcp_events.rs diff --git a/src/core/db/mcp_servers.rs b/crates/skald-core/src/db/mcp_servers.rs similarity index 100% rename from src/core/db/mcp_servers.rs rename to crates/skald-core/src/db/mcp_servers.rs diff --git a/src/core/db/mod.rs b/crates/skald-core/src/db/mod.rs similarity index 61% rename from src/core/db/mod.rs rename to crates/skald-core/src/db/mod.rs index 7a5fa68..ed7b2f3 100644 --- a/src/core/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -21,147 +21,138 @@ pub mod stack_mcp_grants; pub mod tool_permission_groups; pub mod users; -use anyhow::{Context, Result}; -use sqlx::{SqlitePool, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}}; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; -/// System database: instance-wide state, shared by every user. -/// -/// Fixed path — not configurable. Sibling `database/{userid}.db` files hold -/// per-user content; keeping them in one directory makes backup, export and -/// per-user erasure a matter of files rather than tables. +use anyhow::{Context, Result}; +use sqlx::{SqlitePool, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}}; + +use crate::crypto::Dek; + +/// Every database file lives here, so backup, export and per-user erasure are a +/// matter of files rather than tables. +pub const DATABASE_DIR: &str = "database"; + +/// System database: instance-wide state, shared by every user. Fixed path — not +/// configurable. pub const SYSTEM_DB_PATH: &str = "database/system.db"; -pub async fn init_pool(path: &str) -> Result { +/// `{dir}/{userid}.db`. Keyed by the opaque user id and never the username, so a +/// rename never has to touch the file. `dir` is a parameter rather than the +/// [`DATABASE_DIR`] constant so nothing depends on the process's working +/// directory — tests in particular. +pub fn user_db_path(dir: &Path, user_id: &str) -> PathBuf { + dir.join(format!("{user_id}.db")) +} + +/// The `-wal` and `-shm` sidecars SQLite keeps beside a database in WAL mode. +/// Erasing a user means erasing these too. +pub fn user_db_sidecars(path: &Path) -> [PathBuf; 2] { + let ext = |suffix: &str| { + let mut p = path.to_path_buf().into_os_string(); + p.push(suffix); + PathBuf::from(p) + }; + [ext("-wal"), ext("-shm")] +} + +fn ensure_parent(path: &Path) -> Result<()> { // `create_if_missing` creates the file, never its parent directory. - if let Some(parent) = std::path::Path::new(path).parent() + if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create database directory {}", parent.display()))?; } - let opts = SqliteConnectOptions::from_str(path)? - .create_if_missing(true) - // WAL lets readers run alongside a single writer, and `busy_timeout` - // makes a writer *wait* for the lock instead of failing immediately with - // SQLITE_BUSY ("database is locked"). Without these, concurrent writers — - // e.g. the mobile-connector persisting its E2E `send_counter` while the - // chat loop / cron write history — abort mid-operation, which silently - // drops outbound mobile messages (inbox_update never reaches the device). - .journal_mode(SqliteJournalMode::Wal) + Ok(()) +} + +fn tuned(opts: SqliteConnectOptions) -> SqliteConnectOptions { + // WAL lets readers run alongside a single writer, and `busy_timeout` makes a + // writer *wait* for the lock instead of failing immediately with SQLITE_BUSY + // ("database is locked"). Without these, concurrent writers — e.g. the + // mobile-connector persisting its E2E `send_counter` while the chat loop / + // cron write history — abort mid-operation, which silently drops outbound + // mobile messages (inbox_update never reaches the device). + opts.journal_mode(SqliteJournalMode::Wal) .synchronous(SqliteSynchronous::Normal) - .busy_timeout(Duration::from_secs(5)); + .busy_timeout(Duration::from_secs(5)) +} + +/// Connect options for one user's database. +/// +/// When `key` is present the pool is a SQLCipher pool: sqlx puts `PRAGMA key` +/// ahead of every other pragma, so the page cipher is armed before `journal_mode` +/// touches the file. Without a key the same code opens an ordinary SQLite file — +/// which is exactly what a cleartext user gets. +/// +/// The returned value carries the raw key. **Never** `{:?}` it: `SqliteConnectOptions` +/// prints its pragmas, so a debug format anywhere near this would write the DEK, +/// in hex, into the logs. +fn user_options(path: &Path, key: Option<&Dek>, create: bool) -> SqliteConnectOptions { + let opts = SqliteConnectOptions::new().filename(path).create_if_missing(create); + let opts = match key { + Some(dek) => opts.pragma("key", dek.to_pragma()), + None => opts, + }; + tuned(opts) +} + +/// Forces a page read so a wrong key fails here, at open time, rather than at the +/// first unrelated query. SQLCipher answers "file is not a database" when the key +/// does not decrypt the header. +async fn probe(pool: &SqlitePool) -> Result<()> { + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM sqlite_master") + .fetch_one(pool) + .await + .context("database did not open — wrong key, or not a database")?; + Ok(()) +} + +/// The system database: registry tables, plus — for now — the owner tables. +/// +/// Owner tables live here **transitionally**. Nothing has been migrated to +/// per-user pools yet, so sessions, history and jobs still land in `system.db`. +/// When the call sites move to `UserManager::pool_of`, this second call goes +/// away and the owner-without-a-user gets a file of its own. +pub async fn init_system_pool(path: &str) -> Result { + ensure_parent(Path::new(path))?; + let opts = tuned(SqliteConnectOptions::from_str(path)?.create_if_missing(true)); let pool = SqlitePool::connect_with(opts).await?; - create_tables(&pool).await?; + create_registry_tables(&pool).await?; + create_owner_tables(&pool).await?; crate::boot::section("Database initialised".to_string()); Ok(pool) } -async fn create_tables(pool: &SqlitePool) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS chat_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT, - source TEXT NOT NULL DEFAULT 'web', - agent_id TEXT NOT NULL DEFAULT 'main', - is_interactive INTEGER NOT NULL DEFAULT 1, - is_ephemeral INTEGER NOT NULL DEFAULT 0, - run_context TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; +/// Provisions `database/{userid}.db` and lays down the owner schema. +/// +/// Only this function may create a user's database. Login goes through +/// [`open_user_pool`], which refuses to create anything: a missing file there is +/// data loss, and creating a fresh empty one under the right password would hide +/// it instead of reporting it. +pub async fn create_user_pool(path: &Path, key: Option<&Dek>) -> Result { + ensure_parent(path)?; + let pool = SqlitePool::connect_with(user_options(path, key, true)).await?; + probe(&pool).await?; + create_owner_tables(&pool).await?; + Ok(pool) +} - sqlx::query( - "CREATE TABLE IF NOT EXISTS chat_sessions_stack ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL REFERENCES chat_sessions(id), - agent_id TEXT NOT NULL DEFAULT 'main', - agent_prompt TEXT, - depth INTEGER NOT NULL DEFAULT 0, - parent_tool_call_id INTEGER, - terminated_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; +/// Opens an existing user database. Never creates one — see [`create_user_pool`]. +pub async fn open_user_pool(path: &Path, key: Option<&Dek>) -> Result { + let pool = SqlitePool::connect_with(user_options(path, key, false)).await?; + probe(&pool).await?; + Ok(pool) +} - sqlx::query( - "CREATE TABLE IF NOT EXISTS chat_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_stack_id INTEGER NOT NULL REFERENCES chat_sessions_stack(id), - role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'agent')), - content TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'ok' CHECK(status IN ('ok', 'failed')), - input_tokens INTEGER, - output_tokens INTEGER, - duration_ms INTEGER, - model_db_id INTEGER REFERENCES llm_models(id), - is_synthetic INTEGER NOT NULL DEFAULT 0, - reasoning_content TEXT, - cost REAL, - metadata TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS chat_llm_tools ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL REFERENCES chat_history(id), - name TEXT NOT NULL, - arguments TEXT, - result TEXT, - status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')), - result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')), - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_history_stack ON chat_history(session_stack_id)", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_tools_message ON chat_llm_tools(message_id)", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS mcp_servers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - transport TEXT NOT NULL DEFAULT 'stdio', - command TEXT, - args_json TEXT, - env_json TEXT, - url TEXT, - api_key TEXT, - description TEXT, - friendly_name TEXT, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; +// ── Registry tables ─────────────────────────────────────────────────────────── +// +// Instance-wide, readable without any user key: the directory you must open +// before you know who exists. Nothing here is scoped to one user. +async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { sqlx::query( "CREATE TABLE IF NOT EXISTS llm_providers ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -205,82 +196,6 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; - sqlx::query( - "CREATE TABLE IF NOT EXISTS scheduled_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - cron TEXT NOT NULL, - prompt TEXT NOT NULL, - agent_id TEXT NOT NULL DEFAULT 'main', - session_id INTEGER REFERENCES chat_sessions(id), - enabled INTEGER NOT NULL DEFAULT 1, - last_run_at TEXT, - next_run_at TEXT, - single_run INTEGER NOT NULL DEFAULT 0, - running_session_id INTEGER, - kind TEXT NOT NULL DEFAULT 'cron', - parent_session_id INTEGER REFERENCES chat_sessions(id), - run_context TEXT, - running_since TEXT, - origin_ref TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS plugins ( - id TEXT PRIMARY KEY, - enabled INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS session_scratchpad ( - session_id INTEGER NOT NULL REFERENCES chat_sessions(id), - key TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (session_id, key) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS tool_permission_groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS approval_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - agent_id TEXT, - source TEXT, - tool_pattern TEXT NOT NULL, - action TEXT NOT NULL DEFAULT 'require' - CHECK(action IN ('require', 'allow', 'deny')), - note TEXT, - priority INTEGER NOT NULL DEFAULT 100, - path_pattern TEXT, - group_id TEXT REFERENCES tool_permission_groups(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - sqlx::query( "CREATE TABLE IF NOT EXISTS transcribe_models ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -332,10 +247,40 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .await?; sqlx::query( - "CREATE TABLE IF NOT EXISTS sources ( - id TEXT PRIMARY KEY, - active_session_id INTEGER REFERENCES chat_sessions(id), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + "CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS tool_permission_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS approval_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT, + source TEXT, + tool_pattern TEXT NOT NULL, + action TEXT NOT NULL DEFAULT 'require' + CHECK(action IN ('require', 'allow', 'deny')), + note TEXT, + priority INTEGER NOT NULL DEFAULT 100, + path_pattern TEXT, + group_id TEXT REFERENCES tool_permission_groups(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) )", ) .execute(pool) @@ -351,63 +296,26 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + // Every tool ever offered to the LLM, recorded by `ToolDiscovery` at + // injection time. Lets the approval / Security-groups UI list and gate tools + // that are injected dynamically outside the `ToolRegistry` (interface tools, + // plugin tools, provider tools). sqlx::query( - "CREATE TABLE IF NOT EXISTS secrets ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS mcp_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source TEXT NOT NULL, - method TEXT NOT NULL, - payload TEXT NOT NULL, - processed INTEGER NOT NULL DEFAULT 0, - processed_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_mcp_events_pending - ON mcp_events (processed, created_at) - WHERE processed = 0", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS session_mcp_grants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - mcp_name TEXT NOT NULL, - granted_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(session_id, mcp_name) - )", - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS stack_mcp_grants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - stack_id INTEGER NOT NULL, - mcp_name TEXT NOT NULL, - granted_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(stack_id, mcp_name) + "CREATE TABLE IF NOT EXISTS known_tools ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + schema TEXT, + first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), + last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')) )", ) .execute(pool) .await?; + // Spend/telemetry metadata. Stays in the registry so cost charts run without + // decrypting anything: the admin sees how much, when and which model — never + // what was said. `session_id` / `stack_id` are bare integers, not foreign + // keys, precisely because the rows they point at live in another file. sqlx::query( "CREATE TABLE IF NOT EXISTS llm_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -437,6 +345,140 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + // User directory + auth material. Read before every login, so it lives in + // the registry — which means it must never hold anything that derives a + // user's key: `database_password` is the DEK sealed under a key derived + // from the password, useless without it. + // + // `role_id` has no `REFERENCES roles(id)` yet: sqlx turns on + // `PRAGMA foreign_keys`, so pointing at a table that does not exist would + // make every INSERT fail. The constraint lands with the `roles` table. + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT, + role_id TEXT NOT NULL, + encrypted INTEGER NOT NULL, + kdf_params TEXT, + kdf_salt BLOB, + database_password BLOB, + password_hash BLOB, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK ( + (encrypted = 1 AND database_password IS NOT NULL AND password_hash IS NULL) + OR (encrypted = 0 AND database_password IS NULL) + ) + )", + ) + .execute(pool) + .await?; + + Ok(()) +} + +// ── Owner tables ────────────────────────────────────────────────────────────── +// +// One owner's content. The schema is identical in every file that has it — only +// the file says who owns the rows — so this runs verbatim against `system.db` +// and against each `database/{userid}.db`. +// +// **No foreign key here 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` would succeed and every `INSERT` +// would fail. `create_owner_tables_stand_alone` in the tests guards this by +// running the schema against a file that has nothing else in it. + +pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS chat_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + source TEXT NOT NULL DEFAULT 'web', + agent_id TEXT NOT NULL DEFAULT 'main', + is_interactive INTEGER NOT NULL DEFAULT 1, + is_ephemeral INTEGER NOT NULL DEFAULT 0, + run_context TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS chat_sessions_stack ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES chat_sessions(id), + agent_id TEXT NOT NULL DEFAULT 'main', + agent_prompt TEXT, + depth INTEGER NOT NULL DEFAULT 0, + parent_tool_call_id INTEGER, + terminated_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + // `model_db_id` used to point at `llm_models(id)`. It was write-only — never + // selected, never joined — and it was the one key that crossed into the + // registry. Which model answered is already recorded in + // `llm_requests.model_name`. + sqlx::query( + "CREATE TABLE IF NOT EXISTS chat_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_stack_id INTEGER NOT NULL REFERENCES chat_sessions_stack(id), + role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'agent')), + content TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'ok' CHECK(status IN ('ok', 'failed')), + input_tokens INTEGER, + output_tokens INTEGER, + duration_ms INTEGER, + is_synthetic INTEGER NOT NULL DEFAULT 0, + reasoning_content TEXT, + cost REAL, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS chat_llm_tools ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES chat_history(id), + name TEXT NOT NULL, + arguments TEXT, + result TEXT, + status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')), + result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_history_stack ON chat_history(session_stack_id)", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_tools_message ON chat_llm_tools(message_id)", + ) + .execute(pool) + .await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS chat_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -456,6 +498,66 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS session_scratchpad ( + session_id INTEGER NOT NULL REFERENCES chat_sessions(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (session_id, key) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS session_mcp_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + mcp_name TEXT NOT NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(session_id, mcp_name) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS stack_mcp_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stack_id INTEGER NOT NULL, + mcp_name TEXT NOT NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(stack_id, mcp_name) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + cron TEXT NOT NULL, + prompt TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT 'main', + session_id INTEGER REFERENCES chat_sessions(id), + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + next_run_at TEXT, + single_run INTEGER NOT NULL DEFAULT 0, + running_session_id INTEGER, + kind TEXT NOT NULL DEFAULT 'cron', + parent_session_id INTEGER REFERENCES chat_sessions(id), + run_context TEXT, + running_since TEXT, + origin_ref TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS job_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -481,6 +583,68 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS mcp_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + transport TEXT NOT NULL DEFAULT 'stdio', + command TEXT, + args_json TEXT, + env_json TEXT, + url TEXT, + api_key TEXT, + description TEXT, + friendly_name TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS mcp_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + method TEXT NOT NULL, + payload TEXT NOT NULL, + processed INTEGER NOT NULL DEFAULT 0, + processed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_mcp_events_pending + ON mcp_events (processed, created_at) + WHERE processed = 0", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + active_session_id INTEGER REFERENCES chat_sessions(id), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS secrets ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -516,52 +680,111 @@ async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; - // Every tool ever offered to the LLM, recorded by `ToolDiscovery` at - // injection time. Lets the approval / Security-groups UI list and gate tools - // that are injected dynamically outside the `ToolRegistry` (interface tools, - // plugin tools, provider tools). See docs/approval + docs/tools. - sqlx::query( - "CREATE TABLE IF NOT EXISTS known_tools ( - name TEXT PRIMARY KEY, - description TEXT NOT NULL DEFAULT '', - schema TEXT, - first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), - last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')) - )", - ) - .execute(pool) - .await?; - - // User directory + auth material. Read before every login, so it lives in - // the system DB — which means it must never hold anything that derives a - // user's key: `database_password` is the DEK sealed under a key derived - // from the password, useless without it. - // - // `role_id` has no `REFERENCES roles(id)` yet: sqlx turns on - // `PRAGMA foreign_keys`, so pointing at a table that does not exist would - // make every INSERT fail. The constraint lands with the `roles` table. - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - display_name TEXT, - role_id TEXT NOT NULL, - encrypted INTEGER NOT NULL, - kdf_params TEXT, - kdf_salt BLOB, - database_password BLOB, - password_hash BLOB, - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - CHECK ( - (encrypted = 1 AND database_password IS NOT NULL AND password_hash IS NULL) - OR (encrypted = 0 AND database_password IS NULL) - ) - )", - ) - .execute(pool) - .await?; - Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let mut p = std::env::temp_dir(); + p.push(format!("skald-db-{tag}-{}-{nanos}", std::process::id())); + p + } + + /// The guardrail the file split would have given for free. + /// + /// `create_owner_tables` runs here against a database that holds *nothing + /// else* — no `llm_models`, no `users`. Every table then takes a row. Any + /// foreign key that reaches into a registry table passes `CREATE TABLE` and + /// dies on the `INSERT`, right here, instead of lying dormant until a real + /// user logs in and writes to their own file. + #[tokio::test] + async fn owner_tables_stand_alone_with_foreign_keys_on() { + let dir = temp_dir("owner-standalone"); + let path = dir.join("owner.db"); + let pool = create_user_pool(&path, None).await.unwrap(); + + let (fk,): (i64,) = sqlx::query_as("PRAGMA foreign_keys") + .fetch_one(&pool).await.unwrap(); + assert_eq!(fk, 1, "the guardrail is meaningless without FK enforcement"); + + let one = |q: &'static str| sqlx::query(q).execute(&pool); + + one("INSERT INTO chat_sessions (id, title) VALUES (1, 't')").await.unwrap(); + one("INSERT INTO chat_sessions_stack (id, session_id) VALUES (1, 1)").await.unwrap(); + one("INSERT INTO chat_history (id, session_stack_id, role, content) VALUES (1, 1, 'user', 'hi')") + .await.unwrap(); + one("INSERT INTO chat_llm_tools (message_id, name) VALUES (1, 'exec')").await.unwrap(); + one("INSERT INTO chat_summaries (stack_id, content, covers_up_to_message_id) VALUES (1, 's', 1)") + .await.unwrap(); + one("INSERT INTO session_scratchpad (session_id, key, value) VALUES (1, 'k', 'v')").await.unwrap(); + one("INSERT INTO session_mcp_grants (session_id, mcp_name) VALUES (1, 'm')").await.unwrap(); + one("INSERT INTO stack_mcp_grants (stack_id, mcp_name) VALUES (1, 'm')").await.unwrap(); + one("INSERT INTO scheduled_jobs (id, title, cron, prompt, session_id) VALUES (1, 't', '* * * * *', 'p', 1)") + .await.unwrap(); + one("INSERT INTO job_runs (job_id, started_at, status) VALUES (1, 'now', 'completed')").await.unwrap(); + one("INSERT INTO mcp_servers (name) VALUES ('srv')").await.unwrap(); + one("INSERT INTO mcp_events (source, method, payload) VALUES ('s', 'm', '{}')").await.unwrap(); + one("INSERT INTO sources (id, active_session_id) VALUES ('web', 1)").await.unwrap(); + one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap(); + one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap(); + one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap(); + + pool.close().await; + let _ = std::fs::remove_dir_all(&dir); + } + + /// A cleartext user's database is an ordinary SQLite file; an encrypted one + /// is unreadable without the key, and does not even carry SQLite's header. + #[tokio::test] + async fn an_encrypted_database_is_opaque_without_its_key() { + let dir = temp_dir("opaque"); + let clear_path = dir.join("clear.db"); + let enc_path = dir.join("enc.db"); + + let clear = create_user_pool(&clear_path, None).await.unwrap(); + clear.close().await; + + let dek = Dek::random(); + let enc = create_user_pool(&enc_path, Some(&dek)).await.unwrap(); + sqlx::query("INSERT INTO chat_sessions (title) VALUES ('secret')") + .execute(&enc).await.unwrap(); + enc.close().await; + + let magic = b"SQLite format 3\0"; + assert_eq!(&std::fs::read(&clear_path).unwrap()[..16], magic); + assert_ne!(&std::fs::read(&enc_path).unwrap()[..16], magic, + "an encrypted file must not advertise itself as SQLite"); + + assert!(open_user_pool(&enc_path, None).await.is_err(), "no key must not open it"); + assert!(open_user_pool(&enc_path, Some(&Dek::random())).await.is_err(), "wrong key must not open it"); + + // ...and the right key still does, with the row intact. + let reopened = open_user_pool(&enc_path, Some(&dek)).await.unwrap(); + let (title,): (String,) = sqlx::query_as("SELECT title FROM chat_sessions") + .fetch_one(&reopened).await.unwrap(); + assert_eq!(title, "secret"); + reopened.close().await; + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Login must never conjure a database. A missing file is data loss and has + /// to be reported, not papered over with a fresh empty one. + #[tokio::test] + async fn open_never_creates_a_database() { + let dir = temp_dir("no-create"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("ghost.db"); + + assert!(open_user_pool(&path, None).await.is_err()); + assert!(open_user_pool(&path, Some(&Dek::random())).await.is_err()); + assert!(!path.exists(), "open_user_pool must not have created the file"); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/core/db/plugins.rs b/crates/skald-core/src/db/plugins.rs similarity index 100% rename from src/core/db/plugins.rs rename to crates/skald-core/src/db/plugins.rs diff --git a/src/core/db/project_tickets.rs b/crates/skald-core/src/db/project_tickets.rs similarity index 100% rename from src/core/db/project_tickets.rs rename to crates/skald-core/src/db/project_tickets.rs diff --git a/src/core/db/projects.rs b/crates/skald-core/src/db/projects.rs similarity index 100% rename from src/core/db/projects.rs rename to crates/skald-core/src/db/projects.rs diff --git a/src/core/db/scheduled_jobs.rs b/crates/skald-core/src/db/scheduled_jobs.rs similarity index 100% rename from src/core/db/scheduled_jobs.rs rename to crates/skald-core/src/db/scheduled_jobs.rs diff --git a/src/core/db/scratchpad.rs b/crates/skald-core/src/db/scratchpad.rs similarity index 100% rename from src/core/db/scratchpad.rs rename to crates/skald-core/src/db/scratchpad.rs diff --git a/src/core/db/session_mcp_grants.rs b/crates/skald-core/src/db/session_mcp_grants.rs similarity index 100% rename from src/core/db/session_mcp_grants.rs rename to crates/skald-core/src/db/session_mcp_grants.rs diff --git a/src/core/db/sources.rs b/crates/skald-core/src/db/sources.rs similarity index 100% rename from src/core/db/sources.rs rename to crates/skald-core/src/db/sources.rs diff --git a/src/core/db/stack_mcp_grants.rs b/crates/skald-core/src/db/stack_mcp_grants.rs similarity index 100% rename from src/core/db/stack_mcp_grants.rs rename to crates/skald-core/src/db/stack_mcp_grants.rs diff --git a/src/core/db/tool_permission_groups.rs b/crates/skald-core/src/db/tool_permission_groups.rs similarity index 100% rename from src/core/db/tool_permission_groups.rs rename to crates/skald-core/src/db/tool_permission_groups.rs diff --git a/src/core/db/users.rs b/crates/skald-core/src/db/users.rs similarity index 97% rename from src/core/db/users.rs rename to crates/skald-core/src/db/users.rs index 6d5fcf9..4d08dda 100644 --- a/src/core/db/users.rs +++ b/crates/skald-core/src/db/users.rs @@ -358,7 +358,7 @@ pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> { mod tests { use super::*; - /// Nested on purpose: also covers `init_pool` creating the parent directory. + /// Nested on purpose: also covers `init_system_pool` creating the parent directory. fn temp_db_path(tag: &str) -> String { let mut p = std::env::temp_dir(); let nanos = std::time::SystemTime::now() @@ -392,11 +392,11 @@ mod tests { } #[tokio::test] - async fn init_pool_creates_the_database_directory() { + async fn init_system_pool_creates_the_database_directory() { let path = temp_db_path("users-mkdir"); assert!(!std::path::Path::new(&path).parent().unwrap().exists()); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); assert!(std::path::Path::new(&path).exists(), "system.db must exist under a fresh database/"); pool.close().await; @@ -406,7 +406,7 @@ mod tests { #[tokio::test] async fn encrypted_user_round_trips_and_keeps_the_wrapped_dek() { let path = temp_db_path("users-enc"); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); insert(&pool, "u-1", "ada", Some("Ada"), "admin", &encrypted()).await.unwrap(); @@ -429,7 +429,7 @@ mod tests { #[tokio::test] async fn cleartext_user_round_trips_with_and_without_a_verifier() { let path = temp_db_path("users-clear"); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap(); insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap(); @@ -455,7 +455,7 @@ mod tests { #[tokio::test] async fn set_credentials_rewraps_and_migrates() { let path = temp_db_path("users-rewrap"); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); insert(&pool, "u-1", "ada", None, "admin", &encrypted()).await.unwrap(); @@ -485,7 +485,7 @@ mod tests { #[tokio::test] async fn check_constraint_rejects_impossible_rows() { let path = temp_db_path("users-check"); - let pool = crate::core::db::init_pool(&path).await.unwrap(); + let pool = crate::db::init_system_pool(&path).await.unwrap(); // encrypted without a wrapped DEK let err = sqlx::query( diff --git a/src/core/elicitation/mod.rs b/crates/skald-core/src/elicitation/mod.rs similarity index 98% rename from src/core/elicitation/mod.rs rename to crates/skald-core/src/elicitation/mod.rs index 3efdbd2..ec9b25c 100644 --- a/src/core/elicitation/mod.rs +++ b/crates/skald-core/src/elicitation/mod.rs @@ -7,7 +7,7 @@ //! secret it carries) flows straight back to the server's stdin — it is **never** //! logged, broadcast in an event, or written to the DB. //! -//! Mirrors [`crate::core::clarification`], but with the `accept`/`decline`/`cancel` +//! Mirrors [`crate::clarification`], but with the `accept`/`decline`/`cancel` //! outcome and a `sensitive` flag that elicitation needs and clarification lacks. use std::sync::Arc; @@ -23,8 +23,8 @@ use tracing::{debug, info}; use mcp_client::{ElicitationAction, ElicitationHandler, ElicitationReply, ElicitationRequest}; -use crate::core::events::{GlobalEvent, ServerEvent}; -use crate::core::pending_registry::PendingRegistry; +use crate::events::{GlobalEvent, ServerEvent}; +use crate::pending_registry::PendingRegistry; /// How long the user has to answer an elicitation before we reply `cancel`. /// Independent of any secret-cache TTL the MCP server keeps in its own RAM. diff --git a/src/core/events.rs b/crates/skald-core/src/events.rs similarity index 100% rename from src/core/events.rs rename to crates/skald-core/src/events.rs diff --git a/src/core/image_generate/db.rs b/crates/skald-core/src/image_generate/db.rs similarity index 100% rename from src/core/image_generate/db.rs rename to crates/skald-core/src/image_generate/db.rs diff --git a/src/core/image_generate/manager.rs b/crates/skald-core/src/image_generate/manager.rs similarity index 96% rename from src/core/image_generate/manager.rs rename to crates/skald-core/src/image_generate/manager.rs index e36d3e7..f40853a 100644 --- a/src/core/image_generate/manager.rs +++ b/crates/skald-core/src/image_generate/manager.rs @@ -21,10 +21,10 @@ use tracing::{info, warn}; use core_api::image_generate::ImageGenerateRegistry; -use crate::core::llm::LlmProviderRecord; -use crate::core::llm::db as llm_db; -use crate::core::provider::ProviderRegistry; -use crate::core::tools::Tool; +use crate::llm::LlmProviderRecord; +use crate::llm::db as llm_db; +use crate::provider::ProviderRegistry; +use crate::tools::Tool; use super::{ImageGenerate, ImageGenerateInfo, ImageGenerateModelInfo, ImageGenerateModelRecord}; use super::db as image_db; @@ -231,8 +231,8 @@ impl ImageGeneratorManager { } drop(state); vec![ - Arc::new(crate::core::tools::image_generate::ImageGenerateProvidersList { mgr: Arc::clone(&self) }) as Arc, - Arc::new(crate::core::tools::image_generate::ImageGenerateTool { mgr: Arc::clone(&self) }) as Arc, + Arc::new(crate::tools::image_generate::ImageGenerateProvidersList { mgr: Arc::clone(&self) }) as Arc, + Arc::new(crate::tools::image_generate::ImageGenerateTool { mgr: Arc::clone(&self) }) as Arc, ] } diff --git a/src/core/image_generate/mod.rs b/crates/skald-core/src/image_generate/mod.rs similarity index 100% rename from src/core/image_generate/mod.rs rename to crates/skald-core/src/image_generate/mod.rs diff --git a/src/core/image_generate/openrouter_image.rs b/crates/skald-core/src/image_generate/openrouter_image.rs similarity index 100% rename from src/core/image_generate/openrouter_image.rs rename to crates/skald-core/src/image_generate/openrouter_image.rs diff --git a/src/core/inbox.rs b/crates/skald-core/src/inbox.rs similarity index 95% rename from src/core/inbox.rs rename to crates/skald-core/src/inbox.rs index c6ff5f8..759eaed 100644 --- a/src/core/inbox.rs +++ b/crates/skald-core/src/inbox.rs @@ -10,10 +10,10 @@ use core_api::inbox::{ }; use core_api::tool::ToolDescriptionLength; -use crate::core::approval::{ApprovalManager, PendingApprovalInfo}; -use crate::core::clarification::{ClarificationManager, PendingClarificationInfo}; -use crate::core::elicitation::{ElicitationManager, ElicitationOutcome, PendingElicitationInfo}; -use crate::core::tools::ToolRegistry; +use crate::approval::{ApprovalManager, PendingApprovalInfo}; +use crate::clarification::{ClarificationManager, PendingClarificationInfo}; +use crate::elicitation::{ElicitationManager, ElicitationOutcome, PendingElicitationInfo}; +use crate::tools::ToolRegistry; #[derive(Serialize)] pub struct InboxItems { diff --git a/src/core/latex/compiler.rs b/crates/skald-core/src/latex/compiler.rs similarity index 95% rename from src/core/latex/compiler.rs rename to crates/skald-core/src/latex/compiler.rs index c8e8fc6..9a4d50c 100644 --- a/src/core/latex/compiler.rs +++ b/crates/skald-core/src/latex/compiler.rs @@ -597,29 +597,43 @@ OUTPUT hello.log } } + /// Two real files to hash. These used to be `file!()` and `Cargo.toml`, + /// which quietly stopped resolving when this module moved into its own + /// crate: `file!()` is relative to the workspace root while a test's working + /// directory is the package root. Owning the fixtures keeps the test about + /// hashing. + fn hash_fixture(tag: &str) -> (PathBuf, PathBuf, PathBuf) { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("skald-hash-{tag}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.tex"), dir.join("b.tex")); + std::fs::write(&a, b"\\documentclass{article}").unwrap(); + std::fs::write(&b, b"\\usepackage{amsmath}").unwrap(); + (dir, a, b) + } + #[tokio::test] async fn composite_hash_is_deterministic_for_same_contents() { - // Use the test file itself as a stand-in dependency: it exists on disk - // and its contents are stable for the duration of the test. - let me = Path::new(file!()); - let deps_a = vec![me.to_path_buf()]; - let deps_b = vec![me.to_path_buf()]; + let (dir, a, _b) = hash_fixture("deterministic"); + let deps = vec![a]; assert_eq!( - composite_hash_of(&deps_a).await.unwrap(), - composite_hash_of(&deps_b).await.unwrap() + composite_hash_of(&deps).await.unwrap(), + composite_hash_of(&deps).await.unwrap() ); + let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn composite_hash_is_order_independent() { - let me = Path::new(file!()); - let cargo = Path::new("Cargo.toml"); - let a = vec![me.to_path_buf(), cargo.to_path_buf()]; - let b = vec![cargo.to_path_buf(), me.to_path_buf()]; + let (dir, a, b) = hash_fixture("order"); + let forwards = vec![a.clone(), b.clone()]; + let backwards = vec![b, a]; assert_eq!( - composite_hash_of(&a).await.unwrap(), - composite_hash_of(&b).await.unwrap() + composite_hash_of(&forwards).await.unwrap(), + composite_hash_of(&backwards).await.unwrap() ); + let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] diff --git a/src/core/latex/mod.rs b/crates/skald-core/src/latex/mod.rs similarity index 100% rename from src/core/latex/mod.rs rename to crates/skald-core/src/latex/mod.rs diff --git a/src/core/mod.rs b/crates/skald-core/src/lib.rs similarity index 60% rename from src/core/mod.rs rename to crates/skald-core/src/lib.rs index fadcf4a..884644a 100644 --- a/src/core/mod.rs +++ b/crates/skald-core/src/lib.rs @@ -1,3 +1,11 @@ +//! The headless Skald core: storage, identity, LLM stack, tools, sessions. +//! +//! Nothing here knows what runs it. The process shell — HTTP server, desktop +//! webview, setup wizard — lives in the crates that depend on this one. Concrete +//! plugins are never named: `plugin::PluginManager` only ever sees +//! `Arc`, constructed by the consumer and handed to `Skald::new`. + +pub mod boot; pub mod config; pub mod config_store; pub mod skald; @@ -9,6 +17,7 @@ pub mod chatbot; pub mod clarification; pub mod command; pub mod compactor; +pub mod crypto; pub mod elicitation; pub mod cron; pub mod db; @@ -35,3 +44,4 @@ pub mod tool_discovery; pub mod tools; pub mod transcribe; pub mod tts; +pub mod users; diff --git a/src/core/llm/db.rs b/crates/skald-core/src/llm/db.rs similarity index 94% rename from src/core/llm/db.rs rename to crates/skald-core/src/llm/db.rs index 7891fc8..a92d1e5 100644 --- a/src/core/llm/db.rs +++ b/crates/skald-core/src/llm/db.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use sqlx::SqlitePool; -use crate::config::LlmStrength; +use core_api::provider::LlmStrength; use super::{LlmModelRecord, LlmProviderRecord}; // ── Provider rows ───────────────────────────────────────────────────────────── @@ -124,11 +124,13 @@ pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result let extra_params = r.extra_params.as_ref().map(|v| v.to_string()); let capabilities = serde_json::to_string(&r.capabilities)?; let reasoning = r.reasoning.as_ref().map(|v| v.to_string()); - // A model row is never hard-deleted (llm_requests.model_db_id references it), - // only soft-deleted via `removed_at`. The unique identity is `name` (also the - // resolution key), so re-adding a previously removed model with the same alias - // would collide with the lingering soft-deleted row. Upsert on `name` so that - // existing row is revived (removed_at cleared) and every field overwritten. + // A model row is never hard-deleted, only soft-deleted via `removed_at`: + // history and telemetry name it, and `llm_requests` keeps only the model's + // name, so the row is what maps that name back to a provider. The unique + // identity is `name` (also the resolution key), so re-adding a previously + // removed model with the same alias would collide with the lingering + // soft-deleted row. Upsert on `name` so that existing row is revived + // (removed_at cleared) and every field overwritten. let id = sqlx::query_scalar::<_, i64>( "INSERT INTO llm_models (provider_id, model_id, name, strength, scope, is_default, priority, extra_params, context_length, max_output_tokens, knowledge_cutoff, capabilities, reasoning) diff --git a/src/core/llm/manager.rs b/crates/skald-core/src/llm/manager.rs similarity index 99% rename from src/core/llm/manager.rs rename to crates/skald-core/src/llm/manager.rs index 8c86ac6..9315465 100644 --- a/src/core/llm/manager.rs +++ b/crates/skald-core/src/llm/manager.rs @@ -8,10 +8,10 @@ use sqlx::SqlitePool; use tokio::sync::RwLock; use tracing::{info, warn}; -use crate::core::chatbot::ChatbotClient; -use crate::core::chatbot::logging::{LoggingChatbotClient, LogSaveFlags}; -use crate::config::LlmStrength; -use crate::core::provider::{ApiProvider, ProviderRegistry, ReasoningMode}; +use crate::chatbot::ChatbotClient; +use crate::chatbot::logging::{LoggingChatbotClient, LogSaveFlags}; +use core_api::provider::LlmStrength; +use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode}; use super::providers::RemoteLlmModelInfo; use super::{ClientStatus, LlmEntry, LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord}; diff --git a/src/core/llm/mod.rs b/crates/skald-core/src/llm/mod.rs similarity index 97% rename from src/core/llm/mod.rs rename to crates/skald-core/src/llm/mod.rs index fe82fee..006af25 100644 --- a/src/core/llm/mod.rs +++ b/crates/skald-core/src/llm/mod.rs @@ -4,8 +4,8 @@ pub mod providers; use std::sync::Arc; -use crate::core::chatbot::ChatbotClient; -use crate::core::provider::ServiceType; +use crate::chatbot::ChatbotClient; +use crate::provider::ServiceType; pub use core_api::provider::{LlmProviderRecord, LlmModelRecord, LlmStrength, ReasoningMode}; pub use manager::{LlmManager, sort_models_for_agent}; diff --git a/src/core/llm/providers/anthropic.rs b/crates/skald-core/src/llm/providers/anthropic.rs similarity index 93% rename from src/core/llm/providers/anthropic.rs rename to crates/skald-core/src/llm/providers/anthropic.rs index 655c8ea..ceb716c 100644 --- a/src/core/llm/providers/anthropic.rs +++ b/crates/skald-core/src/llm/providers/anthropic.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; -use crate::core::chatbot::anthropic::AnthropicClient; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; +use crate::chatbot::anthropic::AnthropicClient; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; pub struct AnthropicProvider { http: reqwest::Client, diff --git a/src/core/llm/providers/deepseek.rs b/crates/skald-core/src/llm/providers/deepseek.rs similarity index 94% rename from src/core/llm/providers/deepseek.rs rename to crates/skald-core/src/llm/providers/deepseek.rs index 4664c08..953a2f3 100644 --- a/src/core/llm/providers/deepseek.rs +++ b/crates/skald-core/src/llm/providers/deepseek.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; -use crate::core::chatbot::openai::OpenAiClient; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; +use crate::chatbot::openai::OpenAiClient; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; pub struct DeepSeekProvider { http: reqwest::Client, diff --git a/src/core/llm/providers/lm_studio.rs b/crates/skald-core/src/llm/providers/lm_studio.rs similarity index 91% rename from src/core/llm/providers/lm_studio.rs rename to crates/skald-core/src/llm/providers/lm_studio.rs index 3d9509a..9881d86 100644 --- a/src/core/llm/providers/lm_studio.rs +++ b/crates/skald-core/src/llm/providers/lm_studio.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; -use crate::core::chatbot::lm_studio::LmStudioClient; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::RemoteLlmModelInfo; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType}; +use crate::chatbot::lm_studio::LmStudioClient; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::RemoteLlmModelInfo; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType}; pub struct LmStudioProvider { http: reqwest::Client, diff --git a/src/core/llm/providers/mod.rs b/crates/skald-core/src/llm/providers/mod.rs similarity index 97% rename from src/core/llm/providers/mod.rs rename to crates/skald-core/src/llm/providers/mod.rs index aab66c8..666fb29 100644 --- a/src/core/llm/providers/mod.rs +++ b/crates/skald-core/src/llm/providers/mod.rs @@ -7,7 +7,7 @@ pub mod openrouter; pub mod zai; // Re-export so existing code that uses `providers::ServiceType` / `providers::RemoteLlmModelInfo` keeps working. -pub use crate::core::provider::ServiceType; +pub use crate::provider::ServiceType; pub use core_api::provider::RemoteLlmModelInfo; use core_api::provider::{ApiProvider, LlmModelRecord}; diff --git a/src/core/llm/providers/ollama.rs b/crates/skald-core/src/llm/providers/ollama.rs similarity index 94% rename from src/core/llm/providers/ollama.rs rename to crates/skald-core/src/llm/providers/ollama.rs index dbcae57..ffdfdd1 100644 --- a/src/core/llm/providers/ollama.rs +++ b/crates/skald-core/src/llm/providers/ollama.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; -use crate::core::chatbot::ollama::OllamaClient; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::RemoteLlmModelInfo; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType}; +use crate::chatbot::ollama::OllamaClient; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::RemoteLlmModelInfo; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType}; pub struct OllamaProvider { http: reqwest::Client, diff --git a/src/core/llm/providers/openai.rs b/crates/skald-core/src/llm/providers/openai.rs similarity index 81% rename from src/core/llm/providers/openai.rs rename to crates/skald-core/src/llm/providers/openai.rs index 37bff6c..924a2f3 100644 --- a/src/core/llm/providers/openai.rs +++ b/crates/skald-core/src/llm/providers/openai.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use anyhow::{Context, Result}; -use crate::core::chatbot::openai::OpenAiClient; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; -use crate::core::transcribe::TranscribeModelRecord; -use crate::core::transcribe::openai_audio::OpenAiAudioTranscriber; -use crate::core::tts::TtsModelRecord; -use crate::core::tts::openai_tts::OpenAiTtsSynthesiser; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; +use crate::chatbot::openai::OpenAiClient; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; +use crate::transcribe::TranscribeModelRecord; +use crate::transcribe::openai_audio::OpenAiAudioTranscriber; +use crate::tts::TtsModelRecord; +use crate::tts::openai_tts::OpenAiTtsSynthesiser; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; pub struct OpenAiProvider; @@ -58,7 +58,7 @@ impl ApiProvider for OpenAiProvider { })()) } - fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option>> { + fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option>> { Some((|| { let base_url = record.base_url.clone() .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); @@ -67,11 +67,11 @@ impl ApiProvider for OpenAiProvider { Ok(Arc::new(OpenAiTtsSynthesiser::new( &model.name, base_url, api_key, &model.model_id, model.voice_id.clone(), model.instructions.clone(), model.response_format.clone(), - )) as Arc) + )) as Arc) })()) } - fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option>> { + fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option>> { Some((|| { let base_url = record.base_url.clone() .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); @@ -79,7 +79,7 @@ impl ApiProvider for OpenAiProvider { .with_context(|| format!("provider '{}': api_key required for open_ai", record.name))?; Ok(Arc::new(OpenAiAudioTranscriber::new( &model.name, base_url, api_key, &model.model_id, model.language.clone(), - )) as Arc) + )) as Arc) })()) } diff --git a/src/core/llm/providers/openrouter.rs b/crates/skald-core/src/llm/providers/openrouter.rs similarity index 89% rename from src/core/llm/providers/openrouter.rs rename to crates/skald-core/src/llm/providers/openrouter.rs index 2967dfc..b6a5349 100644 --- a/src/core/llm/providers/openrouter.rs +++ b/crates/skald-core/src/llm/providers/openrouter.rs @@ -2,16 +2,16 @@ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; -use crate::core::chatbot::openai::OpenAiClient; -use crate::core::image_generate::ImageGenerateModelRecord; -use crate::core::image_generate::openrouter_image::OpenRouterImageGenerator; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; -use crate::core::transcribe::TranscribeModelRecord; -use crate::core::transcribe::openai_audio::OpenAiAudioTranscriber; -use crate::core::tts::TtsModelRecord; -use crate::core::tts::openai_tts::OpenAiTtsSynthesiser; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; +use crate::chatbot::openai::OpenAiClient; +use crate::image_generate::ImageGenerateModelRecord; +use crate::image_generate::openrouter_image::OpenRouterImageGenerator; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; +use crate::transcribe::TranscribeModelRecord; +use crate::transcribe::openai_audio::OpenAiAudioTranscriber; +use crate::tts::TtsModelRecord; +use crate::tts::openai_tts::OpenAiTtsSynthesiser; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; pub struct OpenRouterProvider { http: reqwest::Client, @@ -163,7 +163,7 @@ impl ApiProvider for OpenRouterProvider { })()) } - fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option>> { + fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option>> { Some((|| { let base_url = record.base_url.clone() .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()); @@ -172,11 +172,11 @@ impl ApiProvider for OpenRouterProvider { Ok(Arc::new(OpenAiTtsSynthesiser::new( &model.name, base_url, api_key, &model.model_id, model.voice_id.clone(), model.instructions.clone(), model.response_format.clone(), - )) as Arc) + )) as Arc) })()) } - fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option>> { + fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option>> { Some((|| { let base_url = record.base_url.clone() .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()); @@ -184,11 +184,11 @@ impl ApiProvider for OpenRouterProvider { .with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?; Ok(Arc::new(OpenAiAudioTranscriber::new( &model.name, base_url, api_key, &model.model_id, model.language.clone(), - )) as Arc) + )) as Arc) })()) } - fn build_image_generator(&self, record: &LlmProviderRecord, model: &ImageGenerateModelRecord) -> Option>> { + fn build_image_generator(&self, record: &LlmProviderRecord, model: &ImageGenerateModelRecord) -> Option>> { Some((|| { let base_url = record.base_url.clone() .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()); @@ -196,7 +196,7 @@ impl ApiProvider for OpenRouterProvider { .with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?; Ok(Arc::new(OpenRouterImageGenerator::new( &model.name, base_url, api_key, &model.model_id, - )) as Arc) + )) as Arc) })()) } diff --git a/src/core/llm/providers/zai.rs b/crates/skald-core/src/llm/providers/zai.rs similarity index 94% rename from src/core/llm/providers/zai.rs rename to crates/skald-core/src/llm/providers/zai.rs index a170155..f67178f 100644 --- a/src/core/llm/providers/zai.rs +++ b/crates/skald-core/src/llm/providers/zai.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use anyhow::{Context, Result}; -use crate::core::chatbot::openai::OpenAiClient; -use crate::core::llm::{LlmModelRecord, LlmProviderRecord}; -use crate::core::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; -use crate::core::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; +use crate::chatbot::openai::OpenAiClient; +use crate::llm::{LlmModelRecord, LlmProviderRecord}; +use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning}; +use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType}; /// Z.AI (Zhipu AI) — OpenAI-compatible GLM API. /// diff --git a/src/core/location/mod.rs b/crates/skald-core/src/location/mod.rs similarity index 100% rename from src/core/location/mod.rs rename to crates/skald-core/src/location/mod.rs diff --git a/src/core/mcp/logs.rs b/crates/skald-core/src/mcp/logs.rs similarity index 100% rename from src/core/mcp/logs.rs rename to crates/skald-core/src/mcp/logs.rs diff --git a/src/core/mcp/mod.rs b/crates/skald-core/src/mcp/mod.rs similarity index 95% rename from src/core/mcp/mod.rs rename to crates/skald-core/src/mcp/mod.rs index 8c3c0b1..9fda435 100644 --- a/src/core/mcp/mod.rs +++ b/crates/skald-core/src/mcp/mod.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; -use crate::core::tools::ToolResult; +use crate::tools::ToolResult; pub use mcp_client::{ ElicitationHandler, @@ -105,7 +105,7 @@ impl McpManager { Some((source, payload)) => { let method = payload["method"].as_str().unwrap_or("unknown").to_string(); let params = serde_json::to_string(&payload["params"]).unwrap_or_else(|_| "{}".to_string()); - match crate::core::db::mcp_events::insert(&pool, &source, &method, ¶ms).await { + match crate::db::mcp_events::insert(&pool, &source, &method, ¶ms).await { Ok(id) => info!("mcp_event stored: id={id} source={source} method={method}"), Err(e) => warn!("mcp_events insert failed (source={source} method={method}): {e}"), } @@ -116,7 +116,7 @@ impl McpManager { } } - fn cfg_from_row(row: &crate::core::db::mcp_servers::McpServerRow) -> McpServerConfig { + fn cfg_from_row(row: &crate::db::mcp_servers::McpServerRow) -> McpServerConfig { McpServerConfig { name: row.name.clone(), transport: match row.transport.as_str() { @@ -155,7 +155,7 @@ impl McpManager { } pub async fn initialize(&self) { - let rows = match crate::core::db::mcp_servers::all_enabled(&self.pool).await { + let rows = match crate::db::mcp_servers::all_enabled(&self.pool).await { Ok(r) => r, Err(e) => { warn!("McpManager::initialize: failed to read DB: {e}"); return; } }; @@ -218,12 +218,12 @@ impl McpManager { } } - pub async fn register(&self, p: crate::core::db::mcp_servers::UpsertParams<'_>) -> Result> { + pub async fn register(&self, p: crate::db::mcp_servers::UpsertParams<'_>) -> Result> { let name = p.name.to_string(); - crate::core::db::mcp_servers::upsert(&self.pool, p).await?; + crate::db::mcp_servers::upsert(&self.pool, p).await?; - let rows = crate::core::db::mcp_servers::all_enabled(&self.pool).await?; + let rows = crate::db::mcp_servers::all_enabled(&self.pool).await?; let row = rows.into_iter().find(|r| r.name == name) .ok_or_else(|| anyhow::anyhow!("register: server '{}' not found after upsert", name))?; let cfg = Self::cfg_from_row(&row); @@ -251,7 +251,7 @@ impl McpManager { } pub async fn unregister(&self, name: &str) -> Result<()> { - crate::core::db::mcp_servers::delete(&self.pool, name).await?; + crate::db::mcp_servers::delete(&self.pool, name).await?; self.servers.write().unwrap().remove(name); self.errors.write().unwrap().remove(name); self.descriptions.write().unwrap().remove(name); @@ -259,11 +259,11 @@ impl McpManager { } pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> { - crate::core::db::mcp_servers::set_enabled(&self.pool, name, enabled).await + crate::db::mcp_servers::set_enabled(&self.pool, name, enabled).await } pub async fn list(&self) -> Result> { - let rows = crate::core::db::mcp_servers::all(&self.pool).await?; + let rows = crate::db::mcp_servers::all(&self.pool).await?; let servers = self.servers.read().unwrap(); let errors = self.errors.read().unwrap(); diff --git a/src/core/memory/mod.rs b/crates/skald-core/src/memory/mod.rs similarity index 99% rename from src/core/memory/mod.rs rename to crates/skald-core/src/memory/mod.rs index 99b3612..fdc133f 100644 --- a/src/core/memory/mod.rs +++ b/crates/skald-core/src/memory/mod.rs @@ -24,7 +24,7 @@ use tracing::{error, info}; pub use core_api::memory::Memory; -use crate::core::tools::Tool; +use crate::tools::Tool; // ── MemoryManager ───────────────────────────────────────────────────────────── diff --git a/src/core/notification.rs b/crates/skald-core/src/notification.rs similarity index 100% rename from src/core/notification.rs rename to crates/skald-core/src/notification.rs diff --git a/src/core/pending_registry.rs b/crates/skald-core/src/pending_registry.rs similarity index 100% rename from src/core/pending_registry.rs rename to crates/skald-core/src/pending_registry.rs diff --git a/src/core/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs similarity index 95% rename from src/core/plugin/mod.rs rename to crates/skald-core/src/plugin/mod.rs index ff5c6c1..a5ad228 100644 --- a/src/core/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -1,8 +1,8 @@ +// The manager only ever handles `Arc`. Naming a concrete plugin crate +// here would make the core depend on every plugin — and, through +// `plugin-transcribe-whisper-local`, on a C build — for no gain: the consumer +// constructs the plugin list and passes it to `Skald::new`. pub use core_api::plugin::{Plugin, PluginContext, RouterFactory}; -pub use plugin_comfyui::ComfyUIPlugin; -#[cfg(feature = "whisper-local")] -pub use plugin_transcribe_whisper_local::WhisperLocalPlugin; -pub use plugin_telegram_bot::TelegramPlugin; use std::collections::HashMap; use std::sync::{Arc, OnceLock}; @@ -19,8 +19,8 @@ use tokio::sync::Mutex; use tokio::time::timeout; use tracing::{error, info, warn}; -use crate::core::db::plugins as db; -use crate::core::skald::Skald; +use crate::db::plugins as db; +use crate::skald::Skald; // ── Public plugin info (returned by list_items tool and REST API) ───────────── @@ -238,7 +238,7 @@ impl PluginManager { /// Toggle only the enabled flag, keeping existing config. pub async fn toggle(&self, id: &str, enabled: bool) -> Result<()> { let row = db::get(&self.db, id).await? - .unwrap_or_else(|| crate::core::db::plugins::PluginRow { + .unwrap_or_else(|| crate::db::plugins::PluginRow { id: id.to_string(), enabled, config: "{}".to_string(), @@ -329,6 +329,13 @@ impl PluginManager { Ok(out) } + /// Every registered plugin, enabled or not. Lets the core ask each one for + /// its contributions (`Plugin::tools`, `Plugin::http_router`) without ever + /// naming a concrete plugin type. + pub fn all(&self) -> &[Arc] { + &self.plugins + } + pub fn get_plugin_typed(&self, id: &str) -> Option> { self.plugins.iter() .find(|p| p.id() == id) diff --git a/src/core/projects/mod.rs b/crates/skald-core/src/projects/mod.rs similarity index 97% rename from src/core/projects/mod.rs rename to crates/skald-core/src/projects/mod.rs index 98d8b5e..fcb2909 100644 --- a/src/core/projects/mod.rs +++ b/crates/skald-core/src/projects/mod.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use anyhow::Result; use sqlx::SqlitePool; -use crate::core::db::projects::{self, Project}; -use crate::core::run_context::RunContext; +use crate::db::projects::{self, Project}; +use crate::run_context::RunContext; pub struct ProjectManager { db: Arc, diff --git a/src/core/projects/tickets.rs b/crates/skald-core/src/projects/tickets.rs similarity index 97% rename from src/core/projects/tickets.rs rename to crates/skald-core/src/projects/tickets.rs index 7326789..b10d946 100644 --- a/src/core/projects/tickets.rs +++ b/crates/skald-core/src/projects/tickets.rs @@ -7,9 +7,9 @@ use tracing::warn; use core_api::system_bus::{SystemEvent, SystemEventBus}; -use crate::core::cron::TaskManager; -use crate::core::db::{project_tickets, project_tickets::ProjectTicket, projects}; -use crate::core::run_context::RunContext; +use crate::cron::TaskManager; +use crate::db::{project_tickets, project_tickets::ProjectTicket, projects}; +use crate::run_context::RunContext; pub struct ProjectTicketManager { db: Arc, diff --git a/src/core/provider/mod.rs b/crates/skald-core/src/provider/mod.rs similarity index 100% rename from src/core/provider/mod.rs rename to crates/skald-core/src/provider/mod.rs diff --git a/src/core/run_context/mod.rs b/crates/skald-core/src/run_context/mod.rs similarity index 94% rename from src/core/run_context/mod.rs rename to crates/skald-core/src/run_context/mod.rs index bdad022..ec8b3d0 100644 --- a/src/core/run_context/mod.rs +++ b/crates/skald-core/src/run_context/mod.rs @@ -6,9 +6,9 @@ use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use tracing::info; -pub use crate::core::db::tool_permission_groups::ToolPermissionGroup; -use crate::core::approval::{ApprovalManager, RuleAction}; -use crate::core::tools::fs::{canonicalize_for_policy, path_under}; +pub use crate::db::tool_permission_groups::ToolPermissionGroup; +use crate::approval::{ApprovalManager, RuleAction}; +use crate::tools::fs::{canonicalize_for_policy, path_under}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RunContext { @@ -113,7 +113,7 @@ impl RunContextManager { /// Seeds the built-in "default" permission group and migrates legacy rules. /// Safe to call at every startup (idempotent). pub async fn seed_defaults(&self) -> Result<()> { - crate::core::db::tool_permission_groups::insert_or_ignore( + crate::db::tool_permission_groups::insert_or_ignore( &self.db, "default", "Default", Some("Built-in default permission group"), ).await?; @@ -133,11 +133,11 @@ impl RunContextManager { // ── ToolPermissionGroup CRUD ─────────────────────────────────────────────── pub async fn list_groups(&self) -> Result> { - crate::core::db::tool_permission_groups::list(&self.db).await + crate::db::tool_permission_groups::list(&self.db).await } pub async fn get_group(&self, id: &str) -> Result> { - crate::core::db::tool_permission_groups::get(&self.db, id).await + crate::db::tool_permission_groups::get(&self.db, id).await } pub async fn create_group( @@ -149,7 +149,7 @@ impl RunContextManager { if id == "default" { bail!("cannot create a permission group with reserved id 'default'"); } - crate::core::db::tool_permission_groups::insert(&self.db, id, name, description).await + crate::db::tool_permission_groups::insert(&self.db, id, name, description).await } pub async fn update_group( @@ -158,14 +158,14 @@ impl RunContextManager { name: &str, description: Option<&str>, ) -> Result { - crate::core::db::tool_permission_groups::update(&self.db, id, name, description).await + crate::db::tool_permission_groups::update(&self.db, id, name, description).await } pub async fn delete_group(&self, id: &str) -> Result { if id == "default" { bail!("cannot delete the built-in 'default' permission group"); } - crate::core::db::tool_permission_groups::delete(&self.db, id).await + crate::db::tool_permission_groups::delete(&self.db, id).await } /// Duplicates a permission group and all its rules atomically. @@ -178,7 +178,7 @@ impl RunContextManager { if new_id == "default" { bail!("cannot create a permission group with reserved id 'default'"); } - let source = crate::core::db::tool_permission_groups::get(&self.db, source_id).await? + let source = crate::db::tool_permission_groups::get(&self.db, source_id).await? .ok_or_else(|| anyhow::anyhow!("source group '{source_id}' not found"))?; let mut tx = self.db.begin().await?; diff --git a/src/core/secrets.rs b/crates/skald-core/src/secrets.rs similarity index 100% rename from src/core/secrets.rs rename to crates/skald-core/src/secrets.rs diff --git a/src/core/service_manager.rs b/crates/skald-core/src/service_manager.rs similarity index 88% rename from src/core/service_manager.rs rename to crates/skald-core/src/service_manager.rs index 5be61e6..3eab278 100644 --- a/src/core/service_manager.rs +++ b/crates/skald-core/src/service_manager.rs @@ -1,4 +1,4 @@ -use crate::core::provider::ServiceType; +use crate::provider::ServiceType; /// Light umbrella trait shared by all model managers. /// Enables grouping managers generically (e.g. models-hub routing, diagnostics) diff --git a/src/core/session/handler/agent_dispatch.rs b/crates/skald-core/src/session/handler/agent_dispatch.rs similarity index 97% rename from src/core/session/handler/agent_dispatch.rs rename to crates/skald-core/src/session/handler/agent_dispatch.rs index 481f2fb..64de518 100644 --- a/src/core/session/handler/agent_dispatch.rs +++ b/crates/skald-core/src/session/handler/agent_dispatch.rs @@ -6,8 +6,8 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::info; -use crate::core::db::{chat_history, chat_llm_tools, chat_sessions_stack, scratchpad, stack_mcp_grants}; -use crate::core::events::ServerEvent; +use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack, scratchpad, stack_mcp_grants}; +use crate::events::ServerEvent; use super::{ChatSessionHandler, MAX_AGENT_DEPTH, TurnOutcome}; use super::emitter::TurnEmitter; @@ -41,7 +41,7 @@ impl ChatSessionHandler { // Only `task` agents are dispatchable: this rejects `chat` (e.g. `main`, // `project-coordinator`) and `system` (e.g. `tic`) agents, and surfaces a // not-found error for unknown ids — all in one gate. - let target_meta = crate::core::agents::load_task_meta(target_id) + let target_meta = crate::agents::load_task_meta(target_id) .map_err(|e| anyhow::anyhow!("dispatch_sub_agent: {e}"))?; let parent_frame = chat_sessions_stack::find_by_id(pool, parent_stack_id).await? @@ -91,7 +91,7 @@ impl ChatSessionHandler { { let group_id = self.tool_group_id().await; let gid = group_id.as_deref().unwrap_or("default"); - let group_rules = crate::core::db::approval_rules::list_for_group( + let group_rules = crate::db::approval_rules::list_for_group( pool, Some(gid), ).await.unwrap_or_default(); child_config.base_tool_defs.retain(|def| { @@ -107,7 +107,7 @@ impl ChatSessionHandler { let mcp_clone = Arc::clone(&self.mcp); let grants_clone = Arc::clone(&active_mcp_grants); - let activate_tool = crate::core::tools::activate_tools::ActivateTools { + let activate_tool = crate::tools::activate_tools::ActivateTools { pool: pool_clone, session_id, stack_id: Some(stack_id), @@ -118,7 +118,7 @@ impl ChatSessionHandler { child_config.interface_tools.push(InterfaceTool { definition: activate_tools_tool_def(), handler: Arc::new(move |args| -> ToolFuture { - use crate::core::tools::Tool as _; + use crate::tools::Tool as _; let tool = Arc::clone(&activate_tool); Box::pin(async move { tokio::task::spawn_blocking(move || tool.execute(args)) diff --git a/src/core/session/handler/approval.rs b/crates/skald-core/src/session/handler/approval.rs similarity index 97% rename from src/core/session/handler/approval.rs rename to crates/skald-core/src/session/handler/approval.rs index 3eb95e7..8505051 100644 --- a/src/core/session/handler/approval.rs +++ b/crates/skald-core/src/session/handler/approval.rs @@ -3,7 +3,7 @@ use tracing::debug; use super::ChatSessionHandler; use super::emitter::TurnEmitter; -use crate::core::tools::{is_file_write_tool, tool_names as tn}; +use crate::tools::{is_file_write_tool, tool_names as tn}; impl ChatSessionHandler { /// Emits the appropriate frontend approval event for the given tool call. @@ -55,7 +55,7 @@ impl ChatSessionHandler { /// Reads the current content of a file from disk (for diff generation in PendingWrite events). pub(super) async fn read_current_content(&self, path: &str) -> Option { - let abs = crate::core::tools::fs::resolve(path).ok()?; + let abs = crate::tools::fs::resolve(path).ok()?; tokio::fs::read_to_string(&abs).await.ok() } diff --git a/src/core/session/handler/config.rs b/crates/skald-core/src/session/handler/config.rs similarity index 94% rename from src/core/session/handler/config.rs rename to crates/skald-core/src/session/handler/config.rs index 4e0d0bb..d0314fb 100644 --- a/src/core/session/handler/config.rs +++ b/crates/skald-core/src/session/handler/config.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, RwLock}; use serde_json::Value; -use crate::core::tools::tool_names as tn; +use crate::tools::tool_names as tn; use super::{ChatSessionHandler, update_scratchpad_tool_def, write_todos_tool_def}; use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture}; @@ -47,7 +47,7 @@ impl ChatSessionHandler { mut interface_tools: Vec, system_substitutions: HashMap, ) -> anyhow::Result { - let meta = crate::core::agents::load_meta(&self.agent_id).ok(); + let meta = crate::agents::load_meta(&self.agent_id).ok(); let (key, _) = self.llm_manager.resolve( client_name.as_deref(), meta.as_ref().and_then(|m| m.scope.as_deref()), @@ -68,7 +68,7 @@ impl ChatSessionHandler { // Inbox alone. let is_system = meta .as_ref() - .map(|m| m.agent_type == crate::core::agents::AgentType::System) + .map(|m| m.agent_type == crate::agents::AgentType::System) .unwrap_or(false); if !is_system { base_tool_defs.push(super::ask_user_clarification_tool_def()); @@ -114,7 +114,7 @@ impl ChatSessionHandler { { let group_id = self.tool_group_id().await; let gid = group_id.as_deref().unwrap_or("default"); - let group_rules = crate::core::db::approval_rules::list_for_group( + let group_rules = crate::db::approval_rules::list_for_group( &self.db, Some(gid), ).await.unwrap_or_default(); let visible = |def: &Value| { @@ -130,7 +130,7 @@ impl ChatSessionHandler { // Load persisted session grants from DB (MCP server names and/or the reserved // `config` keyword), then inject `activate_tools` so the LLM can activate // additional groups on demand. - let persisted = crate::core::db::session_mcp_grants::list_for_session( + let persisted = crate::db::session_mcp_grants::list_for_session( &self.db, self.session_id, ).await.unwrap_or_default(); @@ -143,7 +143,7 @@ impl ChatSessionHandler { let mcp_clone = Arc::clone(&self.mcp); let grants_clone = Arc::clone(&active_mcp_grants); - let activate_tool = crate::core::tools::activate_tools::ActivateTools { + let activate_tool = crate::tools::activate_tools::ActivateTools { pool: pool_clone, session_id, stack_id: None, @@ -155,7 +155,7 @@ impl ChatSessionHandler { interface_tools.push(InterfaceTool { definition: activate_tools_tool_def(), handler: Arc::new(move |args| -> ToolFuture { - use crate::core::tools::Tool as _; + use crate::tools::Tool as _; let tool = Arc::clone(&activate_tool); Box::pin(async move { tokio::task::spawn_blocking(move || tool.execute(args)) diff --git a/src/core/session/handler/dispatch.rs b/crates/skald-core/src/session/handler/dispatch.rs similarity index 98% rename from src/core/session/handler/dispatch.rs rename to crates/skald-core/src/session/handler/dispatch.rs index ca57f2c..a182aab 100644 --- a/src/core/session/handler/dispatch.rs +++ b/crates/skald-core/src/session/handler/dispatch.rs @@ -10,8 +10,8 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::warn; -use crate::core::events::ServerEvent; -use crate::core::tools::{drive_execution, tool_names as tn, ExecutionOutcome, ToolResult}; +use crate::events::ServerEvent; +use crate::tools::{drive_execution, tool_names as tn, ExecutionOutcome, ToolResult}; use super::ChatSessionHandler; use super::interface_tools::AgentRunConfig; diff --git a/src/core/session/handler/emitter.rs b/crates/skald-core/src/session/handler/emitter.rs similarity index 99% rename from src/core/session/handler/emitter.rs rename to crates/skald-core/src/session/handler/emitter.rs index 1ee25c2..7427c1a 100644 --- a/src/core/session/handler/emitter.rs +++ b/crates/skald-core/src/session/handler/emitter.rs @@ -16,7 +16,7 @@ use tokio::sync::mpsc; use core_api::message_meta::Attachment; -use crate::core::events::ServerEvent; +use crate::events::ServerEvent; /// Borrows the per-turn event sender and emits typed [`ServerEvent`]s. pub(super) struct TurnEmitter<'a> { diff --git a/src/core/session/handler/gate.rs b/crates/skald-core/src/session/handler/gate.rs similarity index 97% rename from src/core/session/handler/gate.rs rename to crates/skald-core/src/session/handler/gate.rs index 4bbd48c..9e5e954 100644 --- a/src/core/session/handler/gate.rs +++ b/crates/skald-core/src/session/handler/gate.rs @@ -11,10 +11,10 @@ use std::sync::atomic::Ordering; use serde_json::Value; use tracing::{info, warn}; -use crate::core::approval::GateResult; -use crate::core::db::chat_llm_tools; -use crate::core::run_context::RunContext; -use crate::core::tools::{is_file_read_tool, is_file_write_tool}; +use crate::approval::GateResult; +use crate::db::chat_llm_tools; +use crate::run_context::RunContext; +use crate::tools::{is_file_read_tool, is_file_write_tool}; use super::{ApprovalDecision, ChatSessionHandler}; use super::emitter::TurnEmitter; diff --git a/src/core/session/handler/interface_tools.rs b/crates/skald-core/src/session/handler/interface_tools.rs similarity index 96% rename from src/core/session/handler/interface_tools.rs rename to crates/skald-core/src/session/handler/interface_tools.rs index 15d0336..d7058cc 100644 --- a/src/core/session/handler/interface_tools.rs +++ b/crates/skald-core/src/session/handler/interface_tools.rs @@ -3,9 +3,9 @@ use std::sync::{Arc, RwLock}; use serde_json::Value; -use crate::core::mcp::McpManager; -use crate::core::tools::Tool; -use crate::core::tools::tool_names as tn; +use crate::mcp::McpManager; +use crate::tools::Tool; +use crate::tools::tool_names as tn; pub use core_api::interface_tool::{InterfaceTool, ToolFuture}; @@ -89,7 +89,7 @@ impl AgentRunConfig { // MCP servers: include tools for the granted server names. let servers: Vec = granted.iter() - .filter(|n| n.as_str() != crate::core::tools::tool_names::CONFIG_GROUP) + .filter(|n| n.as_str() != crate::tools::tool_names::CONFIG_GROUP) .cloned() .collect(); if !servers.is_empty() { @@ -101,7 +101,7 @@ impl AgentRunConfig { } // `config` group: include the built-in Config-category tools on demand. - if granted.contains(crate::core::tools::tool_names::CONFIG_GROUP) { + if granted.contains(crate::tools::tool_names::CONFIG_GROUP) { defs.extend(self.config_tool_defs.iter().cloned()); } diff --git a/src/core/session/handler/llm_call.rs b/crates/skald-core/src/session/handler/llm_call.rs similarity index 98% rename from src/core/session/handler/llm_call.rs rename to crates/skald-core/src/session/handler/llm_call.rs index a03904c..07db028 100644 --- a/src/core/session/handler/llm_call.rs +++ b/crates/skald-core/src/session/handler/llm_call.rs @@ -12,8 +12,8 @@ use serde_json::Value; use tokio_util::sync::CancellationToken; use tracing::{error, warn}; -use crate::core::chatbot::{ChatOptions, LlmTurn}; -use crate::core::llm::{LlmEntry, LlmStrength}; +use crate::chatbot::{ChatOptions, LlmTurn}; +use crate::llm::{LlmEntry, LlmStrength}; use super::ChatSessionHandler; use super::emitter::TurnEmitter; diff --git a/src/core/session/handler/llm_loop.rs b/crates/skald-core/src/session/handler/llm_loop.rs similarity index 97% rename from src/core/session/handler/llm_loop.rs rename to crates/skald-core/src/session/handler/llm_loop.rs index 8bcaedc..18d7b7c 100644 --- a/src/core/session/handler/llm_loop.rs +++ b/crates/skald-core/src/session/handler/llm_loop.rs @@ -3,12 +3,12 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{debug, info, trace}; -use crate::core::tools::tool_names as tn; -use crate::core::chat_event_bus::ToolCallEvent; -use crate::core::chatbot::{LlmTurn, ToolCall}; -use crate::core::db::{chat_history, chat_llm_tools}; -use crate::core::events::ServerEvent; -use crate::core::tools::{ +use crate::tools::tool_names as tn; +use crate::chat_event_bus::ToolCallEvent; +use crate::chatbot::{LlmTurn, ToolCall}; +use crate::db::{chat_history, chat_llm_tools}; +use crate::events::ServerEvent; +use crate::tools::{ ExecutionOutcome, SimpleExecution, ToolDescriptionLength, ToolExecution, ToolResult, }; use futures::stream::{self, StreamExt}; @@ -67,7 +67,7 @@ impl ChatSessionHandler { .ok_or_else(|| anyhow::anyhow!("LLM client '{}' not found", cur_name))?; // Scope/strength needed for fallback re-selection. - let meta = crate::core::agents::load_meta(&config.agent_id).ok(); + let meta = crate::agents::load_meta(&config.agent_id).ok(); let req_scope = meta.as_ref().and_then(|m| m.scope.as_deref()).map(str::to_string); let req_strength = meta.as_ref().and_then(|m| m.strength); @@ -144,7 +144,6 @@ impl ChatSessionHandler { pool, stack_id, &chat_history::Role::Assistant, &resp.content, false, resp.reasoning_content.as_deref(), ).await?; - chat_history::set_model_db_id(pool, message_id, cur_llm.model_db_id).await?; if let (Some(i), Some(o)) = (resp.input_tokens, resp.output_tokens) { chat_history::set_usage(pool, message_id, i, o, 0, resp.cost).await?; } @@ -163,7 +162,6 @@ impl ChatSessionHandler { pool, stack_id, &chat_history::Role::Assistant, &assistant_text, false, reasoning_content.as_deref(), ).await?; - chat_history::set_model_db_id(pool, message_id, cur_llm.model_db_id).await?; if let (Some(i), Some(o)) = (input_tokens, output_tokens) { chat_history::set_usage(pool, message_id, i, o, 0, cost).await?; } @@ -418,7 +416,7 @@ impl ChatSessionHandler { return Some(tool.run(args)); } // MCP tools (`server::tool`). Clone the Arc so the work future is 'static. - if let Some((srv, mcp_tool)) = crate::core::mcp::parse_mcp_tool_name(name) { + if let Some((srv, mcp_tool)) = crate::mcp::parse_mcp_tool_name(name) { let mcp = std::sync::Arc::clone(&self.mcp); let srv = srv.to_string(); let mcp_tool = mcp_tool.to_string(); diff --git a/src/core/session/handler/message_builder.rs b/crates/skald-core/src/session/handler/message_builder.rs similarity index 97% rename from src/core/session/handler/message_builder.rs rename to crates/skald-core/src/session/handler/message_builder.rs index 68a046b..95fc810 100644 --- a/src/core/session/handler/message_builder.rs +++ b/crates/skald-core/src/session/handler/message_builder.rs @@ -4,11 +4,11 @@ use std::sync::Arc; use serde_json::{Value, json}; use sqlx::SqlitePool; -use crate::core::compactor::{ContextCompactor, SUMMARY_PREFIX}; -use crate::core::config::DatetimeConfig; -use crate::core::db::{chat_history, chat_llm_tools, chat_summaries}; -use crate::core::mcp::McpManager; -use crate::core::tools::tool_names as tn; +use crate::compactor::{ContextCompactor, SUMMARY_PREFIX}; +use crate::config::DatetimeConfig; +use crate::db::{chat_history, chat_llm_tools, chat_summaries}; +use crate::mcp::McpManager; +use crate::tools::tool_names as tn; /// Registry of installed skills, relative to Skald's process cwd. Injected into agents /// that have `inject_skills` enabled (the default). @@ -84,9 +84,9 @@ impl MessageBuilder { let pool = &*self.pool; // ── 1. Static system message ────────────────────────────────────────── - let mut static_content = crate::core::agents::load_prompt(agent_id)?; + let mut static_content = crate::agents::load_prompt(agent_id)?; - let meta = crate::core::agents::load_meta(agent_id)?; + let meta = crate::agents::load_meta(agent_id)?; if !meta.inject_memory.is_empty() { static_content.push_str( "\n\n---\nThe following memory files have been loaded automatically. \ @@ -154,7 +154,7 @@ impl MessageBuilder { let mut out = vec![static_msg]; // ── 2. Scratchpad system message (before conversation) ──────────────── - let scratch = crate::core::db::scratchpad::for_session(pool, self.session_id).await?; + let scratch = crate::db::scratchpad::for_session(pool, self.session_id).await?; if !scratch.is_empty() { let mut s = String::from( "\n \ @@ -409,7 +409,7 @@ impl MessageBuilder { let wd = self.working_directory.clone() .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); let expanded = mem_path.replace("$WD", &wd.display().to_string()); - let abs = crate::core::tools::fs::resolve(&expanded) + let abs = crate::tools::fs::resolve(&expanded) .unwrap_or_else(|_| std::path::PathBuf::from(&expanded)); let display = match abs.strip_prefix(&wd) { Ok(rel) => rel.to_string_lossy().into_owned(), diff --git a/src/core/session/handler/messages.rs b/crates/skald-core/src/session/handler/messages.rs similarity index 100% rename from src/core/session/handler/messages.rs rename to crates/skald-core/src/session/handler/messages.rs diff --git a/src/core/session/handler/mod.rs b/crates/skald-core/src/session/handler/mod.rs similarity index 97% rename from src/core/session/handler/mod.rs rename to crates/skald-core/src/session/handler/mod.rs index e3b346c..19dcace 100644 --- a/src/core/session/handler/mod.rs +++ b/crates/skald-core/src/session/handler/mod.rs @@ -10,22 +10,22 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, trace, warn}; -use crate::core::approval::ApprovalManager; -use crate::core::run_context::RunContext; -use crate::core::tools::tool_names as tn; -use crate::core::chat_event_bus::{ChatEvent, ChatEventBus, ChatEventRole}; -use crate::core::clarification::ClarificationManager; -use crate::core::compactor::ContextCompactor; -use crate::core::config::DatetimeConfig; -use crate::core::db::{chat_history, chat_sessions_stack}; -use crate::core::events::ServerEvent; +use crate::approval::ApprovalManager; +use crate::run_context::RunContext; +use crate::tools::tool_names as tn; +use crate::chat_event_bus::{ChatEvent, ChatEventBus, ChatEventRole}; +use crate::clarification::ClarificationManager; +use crate::compactor::ContextCompactor; +use crate::config::DatetimeConfig; +use crate::db::{chat_history, chat_sessions_stack}; +use crate::events::ServerEvent; use core_api::message_meta::MessageMetadata; -use crate::core::llm::LlmManager; -use crate::core::mcp::McpManager; -use crate::core::image_generate::ImageGeneratorManager; -use crate::core::memory::MemoryManager; -use crate::core::tool_discovery::ToolDiscovery; -use crate::core::tools::ToolRegistry; +use crate::llm::LlmManager; +use crate::mcp::McpManager; +use crate::image_generate::ImageGeneratorManager; +use crate::memory::MemoryManager; +use crate::tool_discovery::ToolDiscovery; +use crate::tools::ToolRegistry; mod approval; mod agent_dispatch; @@ -102,7 +102,7 @@ pub(super) enum TurnOutcome { output_tokens: Option, truncated: bool, /// All tool calls executed during this turn, across all rounds. - tool_calls: Vec, + tool_calls: Vec, }, Cancelled, Exhausted, diff --git a/src/core/session/handler/outcome.rs b/crates/skald-core/src/session/handler/outcome.rs similarity index 94% rename from src/core/session/handler/outcome.rs rename to crates/skald-core/src/session/handler/outcome.rs index 50b6f76..a6becdc 100644 --- a/src/core/session/handler/outcome.rs +++ b/crates/skald-core/src/session/handler/outcome.rs @@ -8,9 +8,9 @@ use serde_json::Value; use tracing::{debug, info, warn}; -use crate::core::chat_event_bus::ToolCallEvent; -use crate::core::db::chat_llm_tools; -use crate::core::tools::{is_file_write_tool, ExecutionOutcome}; +use crate::chat_event_bus::ToolCallEvent; +use crate::db::chat_llm_tools; +use crate::tools::{is_file_write_tool, ExecutionOutcome}; use super::ChatSessionHandler; use super::emitter::TurnEmitter; @@ -52,7 +52,7 @@ impl ChatSessionHandler { if is_file_write_tool(tool_name) && let Some(p) = args["path"].as_str() { - em.file_changed(crate::core::approval::normalize_path(p)).await; + em.file_changed(crate::approval::normalize_path(p)).await; } acc.push(ToolCallEvent { name: tool_name.to_string(), diff --git a/src/core/session/handler/resume.rs b/crates/skald-core/src/session/handler/resume.rs similarity index 98% rename from src/core/session/handler/resume.rs rename to crates/skald-core/src/session/handler/resume.rs index 993bed7..d1dcbc7 100644 --- a/src/core/session/handler/resume.rs +++ b/crates/skald-core/src/session/handler/resume.rs @@ -3,9 +3,9 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; -use crate::core::db::{chat_history, chat_llm_tools, chat_sessions_stack}; -use crate::core::events::ServerEvent; -use crate::core::tools::{ToolDescriptionLength, ToolResult, tool_names as tn}; +use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack}; +use crate::events::ServerEvent; +use crate::tools::{ToolDescriptionLength, ToolResult, tool_names as tn}; use super::{ChatSessionHandler, TurnOutcome}; use super::emitter::TurnEmitter; @@ -18,7 +18,7 @@ impl ChatSessionHandler { /// Used by the REST `resolve` endpoint and by `resume_pending_tools`. /// Does NOT update the DB — caller is responsible for `complete` / `fail`. pub async fn execute_tool(&self, name: &str, args: Value) -> anyhow::Result { - if let Some((srv, mcp_tool)) = crate::core::mcp::parse_mcp_tool_name(name) { + if let Some((srv, mcp_tool)) = crate::mcp::parse_mcp_tool_name(name) { return self.mcp.call(srv, mcp_tool, args).await; } self.tools.dispatch(name, args).await.map(ToolResult::Text) @@ -365,7 +365,7 @@ fn shallowest_parallel_depth(active: &[chat_sessions_stack::SessionStack]) -> Op #[cfg(test)] mod tests { use super::shallowest_parallel_depth; - use crate::core::db::chat_sessions_stack::SessionStack; + use crate::db::chat_sessions_stack::SessionStack; fn frame(id: i64, depth: i64, parent: Option) -> SessionStack { SessionStack { id, agent_id: "agent".into(), depth, parent_tool_call_id: parent } diff --git a/src/core/session/manager.rs b/crates/skald-core/src/session/manager.rs similarity index 91% rename from src/core/session/manager.rs rename to crates/skald-core/src/session/manager.rs index 0ac5cec..f8f4078 100644 --- a/src/core/session/manager.rs +++ b/crates/skald-core/src/session/manager.rs @@ -4,19 +4,19 @@ use std::sync::Arc; use sqlx::SqlitePool; use tokio::sync::Mutex; -use crate::core::approval::ApprovalManager; -use crate::core::chat_event_bus::ChatEventBus; -use crate::core::clarification::ClarificationManager; -use crate::core::compactor::ContextCompactor; -use crate::core::config::DatetimeConfig; -use crate::core::db::{chat_sessions, chat_sessions_stack}; -use crate::core::llm::LlmManager; -use crate::core::mcp::McpManager; -use crate::core::image_generate::ImageGeneratorManager; -use crate::core::memory::MemoryManager; -use crate::core::run_context::{RunContext, RunContextManager}; -use crate::core::tool_discovery::ToolDiscovery; -use crate::core::tools::ToolRegistry; +use crate::approval::ApprovalManager; +use crate::chat_event_bus::ChatEventBus; +use crate::clarification::ClarificationManager; +use crate::compactor::ContextCompactor; +use crate::config::DatetimeConfig; +use crate::db::{chat_sessions, chat_sessions_stack}; +use crate::llm::LlmManager; +use crate::mcp::McpManager; +use crate::image_generate::ImageGeneratorManager; +use crate::memory::MemoryManager; +use crate::run_context::{RunContext, RunContextManager}; +use crate::tool_discovery::ToolDiscovery; +use crate::tools::ToolRegistry; use super::handler::ChatSessionHandler; diff --git a/src/core/session/mod.rs b/crates/skald-core/src/session/mod.rs similarity index 100% rename from src/core/session/mod.rs rename to crates/skald-core/src/session/mod.rs diff --git a/src/core/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs similarity index 65% rename from src/core/skald/accessors.rs rename to crates/skald-core/src/skald/accessors.rs index 5b1221c..a2813bd 100644 --- a/src/core/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -1,8 +1,12 @@ //! The logical API surface of `Skald`: one accessor per manager, named after the //! historical field, delegating into the domain bundle that now owns it. This is //! the intentional surface consumers (frontend handlers, plugin context) use — the -//! bundles themselves stay internal. Promote this block to a `SkaldApi` trait if/ -//! when `src/core/` is lifted into its own crate. +//! bundles themselves stay internal. +//! +//! Now that the core is its own crate, this is a real boundary rather than a +//! convention: everything here is `pub` because the `skald` binary lives outside, +//! and everything not here is unreachable from it. Promote the block to a +//! `SkaldApi` trait if the shells ever need to mock it. use std::sync::Arc; @@ -13,43 +17,45 @@ use tokio_util::sync::CancellationToken; use core_api::remote::RemoteAccess; use core_api::system_bus::SystemEventBus; -use crate::core::approval::ApprovalManager; -use crate::core::chat_event_bus::ChatEventBus; -use crate::core::chat_hub::ChatHub; -use crate::core::clarification::ClarificationManager; -use crate::core::command::LlmCommandManager; -use crate::core::config_store::GlobalConfigManager; -use crate::core::cron::TaskManager; -use crate::core::elicitation::ElicitationManager; -use crate::core::image_generate::ImageGeneratorManager; -use crate::core::inbox::Inbox; -use crate::core::latex::LatexCompiler; -use crate::core::llm::LlmManager; -use crate::core::location::LocationManager; -use crate::core::mcp::McpManager; -use crate::core::memory::MemoryManager; -use crate::core::plugin::PluginManager; -use crate::core::projects::tickets::ProjectTicketManager; -use crate::core::projects::ProjectManager; -use crate::core::provider::ProviderRegistry; -use crate::core::run_context::RunContextManager; -use crate::core::secrets::SecretsStore; -use crate::core::session::manager::ChatSessionManager; -use crate::core::tic::TicManager; -use crate::core::tool_catalog::ToolCatalog; -use crate::core::tools::ToolRegistry; -use crate::core::transcribe::TranscribeManager; -use crate::core::tts::TtsManager; +use crate::approval::ApprovalManager; +use crate::chat_event_bus::ChatEventBus; +use crate::chat_hub::ChatHub; +use crate::clarification::ClarificationManager; +use crate::command::LlmCommandManager; +use crate::config_store::GlobalConfigManager; +use crate::cron::TaskManager; +use crate::elicitation::ElicitationManager; +use crate::image_generate::ImageGeneratorManager; +use crate::inbox::Inbox; +use crate::latex::LatexCompiler; +use crate::llm::LlmManager; +use crate::location::LocationManager; +use crate::mcp::McpManager; +use crate::memory::MemoryManager; +use crate::plugin::PluginManager; +use crate::projects::tickets::ProjectTicketManager; +use crate::projects::ProjectManager; +use crate::provider::ProviderRegistry; +use crate::run_context::RunContextManager; +use crate::secrets::SecretsStore; +use crate::session::manager::ChatSessionManager; +use crate::tic::TicManager; +use crate::tool_catalog::ToolCatalog; +use crate::tools::ToolRegistry; +use crate::transcribe::TranscribeManager; +use crate::tts::TtsManager; +use crate::users::UserManager; use super::Skald; impl Skald { // Runtime / cross-cutting - pub(crate) fn db(&self) -> &Arc { &self.rt.db } + pub fn db(&self) -> &Arc { &self.rt.db } + pub fn users(&self) -> &Arc { &self.rt.users } pub fn config(&self) -> &Arc { &self.rt.config } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } - pub(crate) fn system_bus(&self) -> &Arc { &self.rt.system_bus } - pub(crate) fn event_bus(&self) -> &Arc { &self.rt.event_bus } + pub fn system_bus(&self) -> &Arc { &self.rt.system_bus } + pub fn event_bus(&self) -> &Arc { &self.rt.event_bus } pub fn shutdown_token(&self) -> &CancellationToken { &self.rt.shutdown_token } // Models diff --git a/src/core/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs similarity index 77% rename from src/core/skald/bundles.rs rename to crates/skald-core/src/skald/bundles.rs index 09ee5c8..927637c 100644 --- a/src/core/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -14,35 +14,35 @@ use tracing::{debug, info, warn}; use core_api::remote::RemoteAccess; -use crate::core::approval::ApprovalManager; -use crate::core::chat_hub::ChatHub; -use crate::core::clarification::ClarificationManager; -use crate::core::command::LlmCommandManager; -use crate::core::compactor::ContextCompactor; -use crate::core::config::{CoreConfig, DatetimeConfig}; -use crate::core::cron::TaskManager; -use crate::core::elicitation::ElicitationManager; -use crate::core::image_generate::ImageGeneratorManager; -use crate::core::inbox::Inbox; -use crate::core::latex::LatexCompiler; -use crate::core::llm::LlmManager; -use crate::core::location::LocationManager; -use crate::core::mcp::McpManager; -use crate::core::memory::MemoryManager; -use crate::core::plugin::PluginManager; -use crate::core::projects::tickets::ProjectTicketManager; -use crate::core::projects::ProjectManager; -use crate::core::provider::ProviderRegistry; -use crate::core::run_context::RunContextManager; -use crate::core::secrets::SecretsStore; -use crate::core::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS}; -use crate::core::session::manager::ChatSessionManager; -use crate::core::tic::TicManager; -use crate::core::tool_catalog::ToolCatalog; -use crate::core::tool_discovery::ToolDiscovery; -use crate::core::tools::ToolRegistry; -use crate::core::transcribe::TranscribeManager; -use crate::core::tts::TtsManager; +use crate::approval::ApprovalManager; +use crate::chat_hub::ChatHub; +use crate::clarification::ClarificationManager; +use crate::command::LlmCommandManager; +use crate::compactor::ContextCompactor; +use crate::config::{CoreConfig, DatetimeConfig}; +use crate::cron::TaskManager; +use crate::elicitation::ElicitationManager; +use crate::image_generate::ImageGeneratorManager; +use crate::inbox::Inbox; +use crate::latex::LatexCompiler; +use crate::llm::LlmManager; +use crate::location::LocationManager; +use crate::mcp::McpManager; +use crate::memory::MemoryManager; +use crate::plugin::PluginManager; +use crate::projects::tickets::ProjectTicketManager; +use crate::projects::ProjectManager; +use crate::provider::ProviderRegistry; +use crate::run_context::RunContextManager; +use crate::secrets::SecretsStore; +use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS}; +use crate::session::manager::ChatSessionManager; +use crate::tic::TicManager; +use crate::tool_catalog::ToolCatalog; +use crate::tool_discovery::ToolDiscovery; +use crate::tools::ToolRegistry; +use crate::transcribe::TranscribeManager; +use crate::tts::TtsManager; use tokio::sync::RwLock; @@ -62,18 +62,18 @@ pub(super) struct Models { impl Models { pub(super) async fn build(rt: &Runtime, config: &CoreConfig) -> Result { let mut provider_registry = ProviderRegistry::new(Arc::clone(&rt.system_bus)); - provider_registry.register_builtin(crate::core::llm::providers::openai::OpenAiProvider); - provider_registry.register_builtin(crate::core::llm::providers::anthropic::AnthropicProvider::new()); - provider_registry.register_builtin(crate::core::llm::providers::openrouter::OpenRouterProvider::new()); - provider_registry.register_builtin(crate::core::llm::providers::ollama::OllamaProvider::new()); - provider_registry.register_builtin(crate::core::llm::providers::lm_studio::LmStudioProvider::new()); - provider_registry.register_builtin(crate::core::llm::providers::deepseek::DeepSeekProvider::new()); - provider_registry.register_builtin(crate::core::llm::providers::zai::ZaiProvider::new()); + provider_registry.register_builtin(crate::llm::providers::openai::OpenAiProvider); + provider_registry.register_builtin(crate::llm::providers::anthropic::AnthropicProvider::new()); + provider_registry.register_builtin(crate::llm::providers::openrouter::OpenRouterProvider::new()); + provider_registry.register_builtin(crate::llm::providers::ollama::OllamaProvider::new()); + provider_registry.register_builtin(crate::llm::providers::lm_studio::LmStudioProvider::new()); + provider_registry.register_builtin(crate::llm::providers::deepseek::DeepSeekProvider::new()); + provider_registry.register_builtin(crate::llm::providers::zai::ZaiProvider::new()); let provider_registry = Arc::new(provider_registry); info!("provider registry ready ({} built-in providers)", provider_registry.all().len()); let log_flags = config.llm.requests_log.as_ref().filter(|r| r.enabled).map(|r| { - use crate::core::chatbot::logging::LogSaveFlags; + use crate::chatbot::logging::LogSaveFlags; LogSaveFlags { request_payload: r.request_payload_save, response_payload: r.response_payload_save, @@ -214,35 +214,38 @@ impl Tools { /// per interactive session by `ChatHub::send_message`. pub(super) fn build(integrations: &Integrations, tasks: &Tasks, models: &Models) -> Self { let mut tool_registry = ToolRegistry::new(); - crate::core::tools::fs::register_all(&mut tool_registry); - tool_registry.register(crate::core::tools::ast_outline::AstOutline::new()); - tool_registry.register(crate::core::tools::exec::ExecuteCmd); - tool_registry.register(crate::core::tools::read_notification::ReadNotification); - tool_registry.register(crate::core::tools::restart::Restart); + crate::tools::fs::register_all(&mut tool_registry); + tool_registry.register(crate::tools::ast_outline::AstOutline::new()); + tool_registry.register(crate::tools::exec::ExecuteCmd); + tool_registry.register(crate::tools::read_notification::ReadNotification); + tool_registry.register(crate::tools::restart::Restart); // Unified listing / toggling across mcp, plugins, cron (+ agents for list). - tool_registry.register(crate::core::tools::list_items::ListItems::new( + tool_registry.register(crate::tools::list_items::ListItems::new( Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); - tool_registry.register(crate::core::tools::toggle_item::ToggleItem::new( + tool_registry.register(crate::tools::toggle_item::ToggleItem::new( Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); - tool_registry.register(crate::core::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp))); - tool_registry.register(crate::core::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp))); - tool_registry.register(crate::core::tools::cron_jobs::DeleteCronJob(Arc::clone(&tasks.cron))); - tool_registry.register(crate::core::tools::set_secret::SetSecret(Arc::clone(&models.secrets))); - tool_registry.register(crate::core::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets))); - tool_registry.register(crate::core::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager))); + tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp))); + tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp))); + tool_registry.register(crate::tools::cron_jobs::DeleteCronJob(Arc::clone(&tasks.cron))); + tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets))); + tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets))); + tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager))); - // Mobile-connector control tools (plugin.md §11). The plugin Arc itself - // implements `RelayAgent`; we look it up and bind the tools to it. The tools - // call into the plugin lazily, so registering them before the plugin's - // runloop starts is fine (they fail gracefully while stopped). - if let Some(mc) = integrations.plugin_manager - .get_plugin_typed::("mobile-connector") - { - let agent: Arc = mc; - for tool in plugin_mobile_connector::mobile_tools(agent) { + // Tools contributed by plugins (plugin.md §11), via `Plugin::tools()`. + // The core never names a plugin crate: each one hands over whatever tools + // it wants, bound to its own handle. They are built before the plugins' + // runloops start, so they must tolerate being called while stopped. + for plugin in integrations.plugin_manager.all() { + let id = plugin.id().to_string(); + let tools = Arc::clone(plugin).tools(); + if tools.is_empty() { + continue; + } + let n = tools.len(); + for tool in tools { tool_registry.register_arc(tool); } - info!("mobile-connector tools registered"); + info!(plugin = %id, count = n, "plugin tools registered"); } debug!("tool registry built"); diff --git a/src/core/skald/mod.rs b/crates/skald-core/src/skald/mod.rs similarity index 95% rename from src/core/skald/mod.rs rename to crates/skald-core/src/skald/mod.rs index 3a60079..68d0c60 100644 --- a/src/core/skald/mod.rs +++ b/crates/skald-core/src/skald/mod.rs @@ -92,5 +92,8 @@ impl Skald { self.rt.shutdown_token.cancel(); self.rt.supervisor.join_all(tokio::time::Duration::from_secs(10)).await; self.integrations.plugin_manager.stop_all().await; + // Last: every user key leaves RAM. A restarted box is opaque again until + // each user unlocks their own database (§9). + self.rt.users.lock_all().await; } } diff --git a/src/core/skald/runtime.rs b/crates/skald-core/src/skald/runtime.rs similarity index 82% rename from src/core/skald/runtime.rs rename to crates/skald-core/src/skald/runtime.rs index b891d18..5539031 100644 --- a/src/core/skald/runtime.rs +++ b/crates/skald-core/src/skald/runtime.rs @@ -17,13 +17,17 @@ use tracing::info; use core_api::events::GlobalEvent; use core_api::system_bus::SystemEventBus; -use crate::core::chat_event_bus::ChatEventBus; -use crate::core::config_store::GlobalConfigManager; +use crate::chat_event_bus::ChatEventBus; +use crate::config_store::GlobalConfigManager; +use crate::users::UserManager; use super::supervisor::TaskSupervisor; pub(super) struct Runtime { + /// The registry pool (`system.db`). Still the only pool anything reads or + /// writes: nothing has moved to per-user pools yet. `users` owns those. pub(super) db: Arc, + pub(super) users: Arc, pub(super) config: Arc, pub(super) config_properties: Vec, pub(super) system_bus: Arc, @@ -41,6 +45,8 @@ impl Runtime { pub(super) fn bootstrap(pool: Arc) -> Self { let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool))); + let users = Arc::new(UserManager::new(Arc::clone(&pool))); + let system_bus = Arc::new(SystemEventBus::new()); info!("system event bus ready"); @@ -51,8 +57,9 @@ impl Runtime { Runtime { db: pool, + users, config, - config_properties: vec![crate::core::tic::config_set()], + config_properties: vec![crate::tic::config_set()], system_bus, event_bus, global_tx, diff --git a/src/core/skald/supervisor.rs b/crates/skald-core/src/skald/supervisor.rs similarity index 100% rename from src/core/skald/supervisor.rs rename to crates/skald-core/src/skald/supervisor.rs diff --git a/src/core/skald/wiring.rs b/crates/skald-core/src/skald/wiring.rs similarity index 96% rename from src/core/skald/wiring.rs rename to crates/skald-core/src/skald/wiring.rs index 9b64f85..75e9bb7 100644 --- a/src/core/skald/wiring.rs +++ b/crates/skald-core/src/skald/wiring.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use tracing::info; -use crate::core::config::CoreConfig; -use crate::core::elicitation::ElicitationBridge; +use crate::config::CoreConfig; +use crate::elicitation::ElicitationBridge; use super::bundles::{Conversation, Integrations, Interaction, Tasks}; use super::runtime::Runtime; @@ -44,7 +44,7 @@ pub(super) fn spawn_background( if let Some(cfg) = config.llm.requests_log.clone().filter(|r| r.enabled) { rt.supervisor.adopt_one( "llm-log-cleanup", - crate::core::db::llm_requests::cleanup::spawn( + crate::db::llm_requests::cleanup::spawn( Arc::clone(&rt.db), cfg, rt.shutdown_token.clone(), diff --git a/src/core/tic/mod.rs b/crates/skald-core/src/tic/mod.rs similarity index 95% rename from src/core/tic/mod.rs rename to crates/skald-core/src/tic/mod.rs index 7521b98..3220080 100644 --- a/src/core/tic/mod.rs +++ b/crates/skald-core/src/tic/mod.rs @@ -9,12 +9,12 @@ use tracing::{info, warn}; use core_api::{ConfigProperty, ConfigSet, PropertyType}; use core_api::system_bus::{SystemEvent, SystemEventBus}; -use crate::core::chat_hub::ChatHub; -use crate::core::config::TicConfig; -use crate::core::config_store::GlobalConfigManager; -use crate::core::db::mcp_events; -use crate::core::run_context::{RunContext, RunContextManager}; -use crate::core::session::manager::ChatSessionManager; +use crate::chat_hub::ChatHub; +use crate::config::TicConfig; +use crate::config_store::GlobalConfigManager; +use crate::db::mcp_events; +use crate::run_context::{RunContext, RunContextManager}; +use crate::session::manager::ChatSessionManager; const TIC_SOURCE: &str = "tic"; const TIC_AGENT: &str = "tic"; @@ -209,7 +209,7 @@ impl TicManager { // 6. Sink for session events — nobody subscribes; drop the receiver immediately // so the channel is drained without buffering. let (tx, _rx) = mpsc::channel(32); - let notify = crate::core::tools::notify::make_tool(Arc::clone(&self.hub), "TIC"); + let notify = crate::tools::notify::make_tool(Arc::clone(&self.hub), "TIC"); handler.handle_message(&prompt, None, None, None, None, vec![notify], std::collections::HashMap::new(), tx, true, None, None).await?; @@ -220,7 +220,7 @@ impl TicManager { // ── Prompt builder ───────────────────────────────────────────────────────────── -fn build_prompt(events: &[crate::core::db::mcp_events::McpEvent]) -> String { +fn build_prompt(events: &[crate::db::mcp_events::McpEvent]) -> String { use std::fmt::Write; let n = events.len(); diff --git a/src/core/tool_catalog.rs b/crates/skald-core/src/tool_catalog.rs similarity index 96% rename from src/core/tool_catalog.rs rename to crates/skald-core/src/tool_catalog.rs index 143b2b9..1d6a454 100644 --- a/src/core/tool_catalog.rs +++ b/crates/skald-core/src/tool_catalog.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use serde::Serialize; use serde_json::Value; -use crate::core::mcp::McpManager; -use crate::core::tools::{ToolCategory, ToolDescriptionLength, ToolRegistry}; -use crate::core::tools::tool_names as tn; +use crate::mcp::McpManager; +use crate::tools::{ToolCategory, ToolDescriptionLength, ToolRegistry}; +use crate::tools::tool_names as tn; #[derive(Debug, Clone, Serialize)] pub struct ToolInfo { diff --git a/src/core/tool_discovery.rs b/crates/skald-core/src/tool_discovery.rs similarity index 95% rename from src/core/tool_discovery.rs rename to crates/skald-core/src/tool_discovery.rs index 7aa2e78..a1bd5c9 100644 --- a/src/core/tool_discovery.rs +++ b/crates/skald-core/src/tool_discovery.rs @@ -1,12 +1,12 @@ //! `ToolDiscovery` — records every tool actually offered to the LLM so the //! approval / Security-groups UI can list and gate tools injected dynamically -//! outside the [`ToolRegistry`](crate::core::tools::ToolRegistry). +//! outside the [`ToolRegistry`](crate::tools::ToolRegistry). //! //! Many tools reach the LLM outside the registry: `InterfaceTool` closures //! (`write_todos`, `activate_tools`, `notify`, `show_file_to_user`, …), plugin //! tools (Telegram's `send_voice_message`), and provider tools (`image_generate`, //! memory backends). They are all assembled in one place — -//! [`AgentRunConfig::all_tool_defs`](crate::core::session::handler) — which is +//! [`AgentRunConfig::all_tool_defs`](crate::session::handler) — which is //! the single point this service taps. Observing there *cannot drift* from what //! is really offered, and covers every source uniformly, with zero per-component //! wiring and no tool-name knowledge in core/plugins. @@ -20,7 +20,7 @@ use serde_json::Value; use sqlx::SqlitePool; use tracing::warn; -use crate::core::db::known_tools; +use crate::db::known_tools; pub struct ToolDiscovery { db: Arc, @@ -143,7 +143,7 @@ mod tests { #[tokio::test] async fn observe_persists_new_tools_and_dedups() { let path = temp_db_path("discovery"); - let pool = Arc::new(crate::core::db::init_pool(&path).await.unwrap()); + let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap()); let disc = ToolDiscovery::new(Arc::clone(&pool)); disc.observe(&[def("show_file_to_user"), def("notify")]); diff --git a/src/core/tools/activate_tools.rs b/crates/skald-core/src/tools/activate_tools.rs similarity index 91% rename from src/core/tools/activate_tools.rs rename to crates/skald-core/src/tools/activate_tools.rs index a95acc4..260b04a 100644 --- a/src/core/tools/activate_tools.rs +++ b/crates/skald-core/src/tools/activate_tools.rs @@ -5,9 +5,9 @@ use anyhow::Result; use serde_json::{Value, json}; use sqlx::SqlitePool; -use crate::core::mcp::McpManager; -use crate::core::tools::tool_names::CONFIG_GROUP; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; +use crate::mcp::McpManager; +use crate::tools::tool_names::CONFIG_GROUP; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; /// Per-session (or per-stack) tool that activates **tool groups** on demand. /// @@ -44,9 +44,9 @@ pub struct ActivateTools { } impl Tool for ActivateTools { - fn name(&self) -> &str { crate::core::tools::tool_names::ACTIVATE_TOOLS } + fn name(&self) -> &str { crate::tools::tool_names::ACTIVATE_TOOLS } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Activate one or more tool groups so their tools become available. \ @@ -109,10 +109,10 @@ impl Tool for ActivateTools { for name in &names { match stack_id { None => { - crate::core::db::session_mcp_grants::grant(&pool, session_id, name).await?; + crate::db::session_mcp_grants::grant(&pool, session_id, name).await?; } Some(sid) => { - crate::core::db::stack_mcp_grants::grant(&pool, sid, name).await?; + crate::db::stack_mcp_grants::grant(&pool, sid, name).await?; } } } diff --git a/src/core/tools/ast_outline.rs b/crates/skald-core/src/tools/ast_outline.rs similarity index 98% rename from src/core/tools/ast_outline.rs rename to crates/skald-core/src/tools/ast_outline.rs index 8562df2..13d6e9d 100644 --- a/src/core/tools/ast_outline.rs +++ b/crates/skald-core/src/tools/ast_outline.rs @@ -1,8 +1,8 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; -use crate::core::tools::fs::read_to_string; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; +use crate::tools::fs::read_to_string; pub struct AstOutline; @@ -12,7 +12,7 @@ impl AstOutline { impl Tool for AstOutline { fn name(&self) -> &str { "get_ast_outline" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Return the structural outline of a source file: top-level definitions (functions, classes, \ diff --git a/src/core/tools/configure_plugin.rs b/crates/skald-core/src/tools/configure_plugin.rs similarity index 92% rename from src/core/tools/configure_plugin.rs rename to crates/skald-core/src/tools/configure_plugin.rs index 6153688..b99999f 100644 --- a/src/core/tools/configure_plugin.rs +++ b/crates/skald-core/src/tools/configure_plugin.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::plugin::PluginManager; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::plugin::PluginManager; +use crate::tools::{Tool, ToolDescriptionLength}; pub struct ConfigurePlugin(pub Arc); impl Tool for ConfigurePlugin { fn name(&self) -> &str { "configure_plugin" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Update the configuration of a plugin and restart it immediately. \ diff --git a/src/core/tools/cron_jobs.rs b/crates/skald-core/src/tools/cron_jobs.rs similarity index 96% rename from src/core/tools/cron_jobs.rs rename to crates/skald-core/src/tools/cron_jobs.rs index 9a96eca..f4b072a 100644 --- a/src/core/tools/cron_jobs.rs +++ b/crates/skald-core/src/tools/cron_jobs.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::cron::TaskManager; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::cron::TaskManager; +use crate::tools::{Tool, ToolDescriptionLength}; // ── execute_task ────────────────────────────────────────────────────────────── // @@ -104,8 +104,8 @@ pub fn build_execute_task_interface_tool( task_mgr: Arc, session_id: i64, run_context: Option, -) -> crate::core::session::handler::InterfaceTool { - use crate::core::session::handler::{InterfaceTool, ToolFuture}; +) -> crate::session::handler::InterfaceTool { + use crate::session::handler::{InterfaceTool, ToolFuture}; let tool = Arc::new(ExecuteTask(task_mgr)); @@ -138,7 +138,7 @@ pub struct DeleteCronJob(pub Arc); impl Tool for DeleteCronJob { fn name(&self) -> &str { "delete_cron_job" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Permanently delete a scheduled task or cron job by its numeric id." diff --git a/src/core/tools/exec.rs b/crates/skald-core/src/tools/exec.rs similarity index 96% rename from src/core/tools/exec.rs rename to crates/skald-core/src/tools/exec.rs index fd8871d..e129bb3 100644 --- a/src/core/tools/exec.rs +++ b/crates/skald-core/src/tools/exec.rs @@ -6,7 +6,7 @@ use anyhow::Result; use serde_json::{Value, json}; use tokio::io::AsyncReadExt; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; const DEFAULT_TIMEOUT_SECS: u64 = 120; const MAX_TIMEOUT_SECS: u64 = 600; @@ -15,8 +15,8 @@ const MAX_OUTPUT_BYTES: usize = 100_000; pub struct ExecuteCmd; impl Tool for ExecuteCmd { - fn name(&self) -> &str { crate::core::tools::tool_names::EXECUTE_CMD } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Shell } + fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_CMD } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell } fn description(&self) -> &str { "Execute a shell command (sh -c) on the host machine. \ diff --git a/src/core/tools/fs/edit_file.rs b/crates/skald-core/src/tools/fs/edit_file.rs similarity index 96% rename from src/core/tools/fs/edit_file.rs rename to crates/skald-core/src/tools/fs/edit_file.rs index 6e55468..0013631 100644 --- a/src/core/tools/fs/edit_file.rs +++ b/crates/skald-core/src/tools/fs/edit_file.rs @@ -1,7 +1,7 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; use super::{read_to_string, write_string}; fn normalize_ws(s: &str) -> String { @@ -64,7 +64,7 @@ impl EditFile { impl Tool for EditFile { fn name(&self) -> &str { "edit_file" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Replace a substring in a file with new text. \ diff --git a/src/core/tools/fs/grep_files.rs b/crates/skald-core/src/tools/fs/grep_files.rs similarity index 98% rename from src/core/tools/fs/grep_files.rs rename to crates/skald-core/src/tools/fs/grep_files.rs index 70bb0de..8664840 100644 --- a/src/core/tools/fs/grep_files.rs +++ b/crates/skald-core/src/tools/fs/grep_files.rs @@ -2,7 +2,7 @@ use anyhow::Result; use regex::Regex; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use super::resolve; pub struct GrepFiles; @@ -13,7 +13,7 @@ impl GrepFiles { impl Tool for GrepFiles { fn name(&self) -> &str { "grep_files" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Search for a regex pattern across files in a directory or a single file. \ diff --git a/src/core/tools/fs/insert_at_line.rs b/crates/skald-core/src/tools/fs/insert_at_line.rs similarity index 93% rename from src/core/tools/fs/insert_at_line.rs rename to crates/skald-core/src/tools/fs/insert_at_line.rs index 701089e..5a3c8cc 100644 --- a/src/core/tools/fs/insert_at_line.rs +++ b/crates/skald-core/src/tools/fs/insert_at_line.rs @@ -1,7 +1,7 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use super::{read_to_string, write_string}; pub struct InsertAtLine; @@ -12,7 +12,7 @@ impl InsertAtLine { impl Tool for InsertAtLine { fn name(&self) -> &str { "insert_at_line" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Insert new text immediately before or after a specific line number in a file. \ diff --git a/src/core/tools/fs/list_files.rs b/crates/skald-core/src/tools/fs/list_files.rs similarity index 94% rename from src/core/tools/fs/list_files.rs rename to crates/skald-core/src/tools/fs/list_files.rs index 1b22a99..ef1453c 100644 --- a/src/core/tools/fs/list_files.rs +++ b/crates/skald-core/src/tools/fs/list_files.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; use super::resolve; /// Directories to skip unconditionally when walking. @@ -19,7 +19,7 @@ impl ListFiles { impl Tool for ListFiles { fn name(&self) -> &str { "list_files" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "List files and directories under a path. \ diff --git a/src/core/tools/fs/mod.rs b/crates/skald-core/src/tools/fs/mod.rs similarity index 99% rename from src/core/tools/fs/mod.rs rename to crates/skald-core/src/tools/fs/mod.rs index 55c66d5..dde0e59 100644 --- a/src/core/tools/fs/mod.rs +++ b/crates/skald-core/src/tools/fs/mod.rs @@ -12,7 +12,7 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result}; use serde_json::Value; -use crate::core::tools::ToolRegistry; +use crate::tools::ToolRegistry; /// Extracts the `path` argument as an owned string, if present. Single-file /// tools use this to advertise their target to the UI via `Tool::target_path`, diff --git a/src/core/tools/fs/read_file.rs b/crates/skald-core/src/tools/fs/read_file.rs similarity index 94% rename from src/core/tools/fs/read_file.rs rename to crates/skald-core/src/tools/fs/read_file.rs index 69526be..35b86e0 100644 --- a/src/core/tools/fs/read_file.rs +++ b/crates/skald-core/src/tools/fs/read_file.rs @@ -1,7 +1,7 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use super::read_to_string; pub struct ReadFile; @@ -12,7 +12,7 @@ impl ReadFile { impl Tool for ReadFile { fn name(&self) -> &str { "read_file" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Read the content of a file with 1-based line numbers. \ diff --git a/src/core/tools/fs/replace_lines.rs b/crates/skald-core/src/tools/fs/replace_lines.rs similarity index 94% rename from src/core/tools/fs/replace_lines.rs rename to crates/skald-core/src/tools/fs/replace_lines.rs index 02439d1..f001949 100644 --- a/src/core/tools/fs/replace_lines.rs +++ b/crates/skald-core/src/tools/fs/replace_lines.rs @@ -1,7 +1,7 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use super::{read_to_string, write_string}; pub struct ReplaceLines; @@ -12,7 +12,7 @@ impl ReplaceLines { impl Tool for ReplaceLines { fn name(&self) -> &str { "replace_lines" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Replace a range of lines in a file with new text. \ diff --git a/src/core/tools/fs/search_file.rs b/crates/skald-core/src/tools/fs/search_file.rs similarity index 94% rename from src/core/tools/fs/search_file.rs rename to crates/skald-core/src/tools/fs/search_file.rs index e7d7801..249500a 100644 --- a/src/core/tools/fs/search_file.rs +++ b/crates/skald-core/src/tools/fs/search_file.rs @@ -1,7 +1,7 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use super::read_to_string; pub struct SearchFile; @@ -12,7 +12,7 @@ impl SearchFile { impl Tool for SearchFile { fn name(&self) -> &str { "search_file" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Search for lines containing a substring in a file. \ diff --git a/src/core/tools/fs/write_file.rs b/crates/skald-core/src/tools/fs/write_file.rs similarity index 91% rename from src/core/tools/fs/write_file.rs rename to crates/skald-core/src/tools/fs/write_file.rs index 435427c..e88357f 100644 --- a/src/core/tools/fs/write_file.rs +++ b/crates/skald-core/src/tools/fs/write_file.rs @@ -1,7 +1,7 @@ use anyhow::Result; use serde_json::{Value, json}; -use crate::core::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; +use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; use super::{resolve, write_string}; pub struct WriteFile; @@ -12,7 +12,7 @@ impl WriteFile { impl Tool for WriteFile { fn name(&self) -> &str { "write_file" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Filesystem } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem } fn description(&self) -> &str { "Create a new file or fully overwrite an existing one. \ diff --git a/src/core/tools/image_generate.rs b/crates/skald-core/src/tools/image_generate.rs similarity index 95% rename from src/core/tools/image_generate.rs rename to crates/skald-core/src/tools/image_generate.rs index 6d28eb4..cb7e743 100644 --- a/src/core/tools/image_generate.rs +++ b/crates/skald-core/src/tools/image_generate.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::image_generate::ImageGeneratorManager; -use crate::core::tools::{Tool, ToolCategory, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; +use crate::image_generate::ImageGeneratorManager; +use crate::tools::{Tool, ToolCategory, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; // ── image_generate_providers_list ───────────────────────────────────────────── diff --git a/src/core/tools/list_items.rs b/crates/skald-core/src/tools/list_items.rs similarity index 94% rename from src/core/tools/list_items.rs rename to crates/skald-core/src/tools/list_items.rs index 47f7e1b..36c66ec 100644 --- a/src/core/tools/list_items.rs +++ b/crates/skald-core/src/tools/list_items.rs @@ -3,11 +3,11 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::agents; -use crate::core::cron::TaskManager; -use crate::core::mcp::McpManager; -use crate::core::plugin::PluginManager; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::agents; +use crate::cron::TaskManager; +use crate::mcp::McpManager; +use crate::plugin::PluginManager; +use crate::tools::{Tool, ToolDescriptionLength}; /// Unified read-only listing tool. Replaces the per-resource `list_mcp`, /// `list_plugins`, `list_cron_jobs` and `list_agents` tools: same operation @@ -32,7 +32,7 @@ impl ListItems { impl Tool for ListItems { fn name(&self) -> &str { "list_items" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Introspection } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection } fn description(&self) -> &str { "List configured items of a given type. Pass `type`:\n\ diff --git a/src/core/tools/list_secrets.rs b/crates/skald-core/src/tools/list_secrets.rs similarity index 92% rename from src/core/tools/list_secrets.rs rename to crates/skald-core/src/tools/list_secrets.rs index 8c035c8..d8e51fa 100644 --- a/src/core/tools/list_secrets.rs +++ b/crates/skald-core/src/tools/list_secrets.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::secrets::{SecretsApi, SecretsStore}; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::secrets::{SecretsApi, SecretsStore}; +use crate::tools::{Tool, ToolDescriptionLength}; pub struct ListSecrets(pub Arc); impl Tool for ListSecrets { fn name(&self) -> &str { "list_secrets" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "List the names (not values) of stored secrets. \ diff --git a/src/core/tools/mod.rs b/crates/skald-core/src/tools/mod.rs similarity index 100% rename from src/core/tools/mod.rs rename to crates/skald-core/src/tools/mod.rs diff --git a/src/core/tools/notify.rs b/crates/skald-core/src/tools/notify.rs similarity index 94% rename from src/core/tools/notify.rs rename to crates/skald-core/src/tools/notify.rs index eeaebd9..2dd13d7 100644 --- a/src/core/tools/notify.rs +++ b/crates/skald-core/src/tools/notify.rs @@ -2,9 +2,9 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::core::chat_hub::ChatHub; -use crate::core::notification::Notification; -use crate::core::session::handler::{InterfaceTool, ToolFuture}; +use crate::chat_hub::ChatHub; +use crate::notification::Notification; +use crate::session::handler::{InterfaceTool, ToolFuture}; /// Build a `notify` InterfaceTool bound to the given `ChatHub`. /// @@ -16,7 +16,7 @@ pub fn make_tool(hub: Arc, default_source: impl Into) -> Interf let definition = json!({ "type": "function", "function": { - "name": crate::core::tools::tool_names::NOTIFY, + "name": crate::tools::tool_names::NOTIFY, "description": "Surface a single event to the user's home conversation as a structured \ notification. Call once per event worth surfacing. Provide factual, \ third-person data about the event — do NOT write a message to the user; \ diff --git a/src/core/tools/read_notification.rs b/crates/skald-core/src/tools/read_notification.rs similarity index 100% rename from src/core/tools/read_notification.rs rename to crates/skald-core/src/tools/read_notification.rs diff --git a/src/core/tools/register_mcp.rs b/crates/skald-core/src/tools/register_mcp.rs similarity index 94% rename from src/core/tools/register_mcp.rs rename to crates/skald-core/src/tools/register_mcp.rs index 1a54983..175000a 100644 --- a/src/core/tools/register_mcp.rs +++ b/crates/skald-core/src/tools/register_mcp.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::db::mcp_servers::UpsertParams; -use crate::core::mcp::McpManager; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::db::mcp_servers::UpsertParams; +use crate::mcp::McpManager; +use crate::tools::{Tool, ToolDescriptionLength}; pub struct RegisterMcp { mcp: Arc, @@ -17,7 +17,7 @@ impl RegisterMcp { impl Tool for RegisterMcp { fn name(&self) -> &str { "register_mcp" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Register (or update) an MCP server and connect to it immediately. \ @@ -135,7 +135,7 @@ impl DeleteMcp { impl Tool for DeleteMcp { fn name(&self) -> &str { "delete_mcp" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Permanently delete (unregister) an MCP server by name: removes it from the \ diff --git a/crates/skald-core/src/tools/restart.rs b/crates/skald-core/src/tools/restart.rs new file mode 100644 index 0000000..f726317 --- /dev/null +++ b/crates/skald-core/src/tools/restart.rs @@ -0,0 +1,68 @@ +use std::sync::OnceLock; + +use anyhow::Result; +use serde_json::{Value, json}; +use tracing::{info, warn}; + +use crate::tools::{Tool, ToolDescriptionLength}; + +/// How to restart, when exiting for a supervisor is not the answer. +/// +/// A bundled desktop app has no supervisor watching its exit code: it must tear +/// down its own webview and respawn itself. That is knowledge about the process +/// shell, and the core does not have it — so the shell installs it here. Without +/// a handler, `restart` falls back to the supervisor protocol. +/// +/// Returns only on failure; a successful handler never comes back. +pub type RestartHandler = Box Result<()> + Send + Sync>; + +static HANDLER: OnceLock = OnceLock::new(); + +/// Called once by the process shell during startup, before any tool can run. +pub fn set_restart_handler(handler: RestartHandler) { + if HANDLER.set(handler).is_err() { + warn!("a restart handler is already installed — ignoring this one"); + } +} + +pub struct Restart; + +impl Tool for Restart { + fn name(&self) -> &str { crate::tools::tool_names::RESTART } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell } + + fn description(&self) -> &str { + "Restart the skald process. Nothing is recompiled: the same binary is re-executed, \ + so this applies config.yml and database changes, which are only read at startup. \ + To load new code, build first (./build.sh), then restart. \ + Requires user approval." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {} + }) + } + + fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String { + "restart skald".to_string() + } + + fn execute(&self, _args: Value) -> Result { + // A bundled desktop app installs its own teardown-and-respawn. Normally + // this never returns. + if let Some(handler) = HANDLER.get() { + info!("restart requested — delegating to the installed handler"); + handler()?; + warn!("the restart handler returned without restarting — falling back"); + } + + // Headless: exit with code -1 (= 255 on Unix), which `run.sh` reads as + // "re-execute me". `_exit()` rather than `exit()` skips C atexit handlers + // — whisper-rs's Metal GPU cleanup aborts with SIGABRT, turning the exit + // code into 134 and stopping the supervisor instead of restarting it. + info!("restart requested — exit(-1) → supervisor re-executes the binary"); + unsafe { libc::_exit(-1) } + } +} diff --git a/src/core/tools/set_secret.rs b/crates/skald-core/src/tools/set_secret.rs similarity index 90% rename from src/core/tools/set_secret.rs rename to crates/skald-core/src/tools/set_secret.rs index 46fe85d..4192334 100644 --- a/src/core/tools/set_secret.rs +++ b/crates/skald-core/src/tools/set_secret.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::secrets::{SecretsApi, SecretsStore}; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::secrets::{SecretsApi, SecretsStore}; +use crate::tools::{Tool, ToolDescriptionLength}; pub struct SetSecret(pub Arc); impl Tool for SetSecret { fn name(&self) -> &str { "set_secret" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Store a secret value by key (e.g. HUGGINGFACE_TOKEN). \ diff --git a/src/core/tools/show_file.rs b/crates/skald-core/src/tools/show_file.rs similarity index 93% rename from src/core/tools/show_file.rs rename to crates/skald-core/src/tools/show_file.rs index 4bf47de..cb1789e 100644 --- a/src/core/tools/show_file.rs +++ b/crates/skald-core/src/tools/show_file.rs @@ -2,11 +2,11 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::core::chat_hub::ChatHub; -use crate::core::events::{GlobalEvent, ServerEvent}; -use crate::core::session::handler::{InterfaceTool, ToolFuture}; -use crate::core::tools::fs; -use crate::core::tools::tool_names::SHOW_FILE_TO_USER; +use crate::chat_hub::ChatHub; +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` and a source. /// diff --git a/src/core/tools/toggle_item.rs b/crates/skald-core/src/tools/toggle_item.rs similarity index 94% rename from src/core/tools/toggle_item.rs rename to crates/skald-core/src/tools/toggle_item.rs index cd14c37..eb20441 100644 --- a/src/core/tools/toggle_item.rs +++ b/crates/skald-core/src/tools/toggle_item.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use anyhow::Result; use serde_json::{Value, json}; -use crate::core::cron::TaskManager; -use crate::core::mcp::McpManager; -use crate::core::plugin::PluginManager; -use crate::core::tools::{Tool, ToolDescriptionLength}; +use crate::cron::TaskManager; +use crate::mcp::McpManager; +use crate::plugin::PluginManager; +use crate::tools::{Tool, ToolDescriptionLength}; /// Unified enable/disable tool. Replaces `toggle_mcp`, `toggle_plugin` and /// `toggle_cron_job`: same operation (flip an enabled flag), uniform schema @@ -29,7 +29,7 @@ impl ToggleItem { impl Tool for ToggleItem { fn name(&self) -> &str { "toggle_item" } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Config } + fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } fn description(&self) -> &str { "Enable or disable an item by kind. Pass `kind`, `id`, and `enabled`:\n\ diff --git a/src/core/tools/tool_names.rs b/crates/skald-core/src/tools/tool_names.rs similarity index 100% rename from src/core/tools/tool_names.rs rename to crates/skald-core/src/tools/tool_names.rs diff --git a/src/core/transcribe/db.rs b/crates/skald-core/src/transcribe/db.rs similarity index 100% rename from src/core/transcribe/db.rs rename to crates/skald-core/src/transcribe/db.rs diff --git a/src/core/transcribe/manager.rs b/crates/skald-core/src/transcribe/manager.rs similarity index 98% rename from src/core/transcribe/manager.rs rename to crates/skald-core/src/transcribe/manager.rs index 6697930..5005e80 100644 --- a/src/core/transcribe/manager.rs +++ b/crates/skald-core/src/transcribe/manager.rs @@ -21,9 +21,9 @@ use core_api::system_bus::{SystemEvent, SystemEventBus}; use async_trait::async_trait; -use crate::core::llm::LlmProviderRecord; -use crate::core::llm::db as llm_db; -use crate::core::provider::ProviderRegistry; +use crate::llm::LlmProviderRecord; +use crate::llm::db as llm_db; +use crate::provider::ProviderRegistry; use super::{Transcribe, TranscribeModelInfo, TranscribeModelRecord}; use super::db as transcribe_db; @@ -135,7 +135,7 @@ impl TranscribeManager { /// Fetch the list of transcription models available from a configured provider. /// Returns an error if the provider doesn't support model listing. - pub async fn list_provider_models(&self, provider_id: i64) -> Result> { + pub async fn list_provider_models(&self, provider_id: i64) -> Result> { let record = llm_db::load_all_providers(&self.pool).await? .into_iter().find(|p| p.id == provider_id) .ok_or_else(|| anyhow!("provider {provider_id} not found"))?; diff --git a/src/core/transcribe/mod.rs b/crates/skald-core/src/transcribe/mod.rs similarity index 100% rename from src/core/transcribe/mod.rs rename to crates/skald-core/src/transcribe/mod.rs diff --git a/src/core/transcribe/openai_audio.rs b/crates/skald-core/src/transcribe/openai_audio.rs similarity index 100% rename from src/core/transcribe/openai_audio.rs rename to crates/skald-core/src/transcribe/openai_audio.rs diff --git a/src/core/tts/db.rs b/crates/skald-core/src/tts/db.rs similarity index 100% rename from src/core/tts/db.rs rename to crates/skald-core/src/tts/db.rs diff --git a/src/core/tts/manager.rs b/crates/skald-core/src/tts/manager.rs similarity index 98% rename from src/core/tts/manager.rs rename to crates/skald-core/src/tts/manager.rs index 461bf46..69eed2a 100644 --- a/src/core/tts/manager.rs +++ b/crates/skald-core/src/tts/manager.rs @@ -21,9 +21,9 @@ use core_api::system_bus::{SystemEvent, SystemEventBus}; use async_trait::async_trait; -use crate::core::llm::LlmProviderRecord; -use crate::core::llm::db as llm_db; -use crate::core::provider::ProviderRegistry; +use crate::llm::LlmProviderRecord; +use crate::llm::db as llm_db; +use crate::provider::ProviderRegistry; use super::{TextToSpeech, TtsModelInfo, TtsModelRecord}; use super::db as tts_db; @@ -136,7 +136,7 @@ impl TtsManager { /// Fetch the list of TTS models available from a configured provider. /// Returns an error if the provider doesn't support model listing. - pub async fn list_provider_models(&self, provider_id: i64) -> Result> { + pub async fn list_provider_models(&self, provider_id: i64) -> Result> { let record = llm_db::load_all_providers(&self.pool).await? .into_iter().find(|p| p.id == provider_id) .ok_or_else(|| anyhow!("provider {provider_id} not found"))?; diff --git a/src/core/tts/mod.rs b/crates/skald-core/src/tts/mod.rs similarity index 100% rename from src/core/tts/mod.rs rename to crates/skald-core/src/tts/mod.rs diff --git a/src/core/tts/openai_tts.rs b/crates/skald-core/src/tts/openai_tts.rs similarity index 100% rename from src/core/tts/openai_tts.rs rename to crates/skald-core/src/tts/openai_tts.rs diff --git a/crates/skald-core/src/users/mod.rs b/crates/skald-core/src/users/mod.rs new file mode 100644 index 0000000..5290ef2 --- /dev/null +++ b/crates/skald-core/src/users/mod.rs @@ -0,0 +1,697 @@ +//! `UserManager` — the user directory, authentication, and the registry of +//! unlocked databases (blueprint §9 / §11). +//! +//! It owns the registry pool (`system.db`) and a map `userid → SqlitePool` of the +//! per-user databases that are currently **unlocked**. +//! +//! The pool *is* the unlock token. Its connect options carry the DEK as +//! SQLCipher's raw key, so an open pool means the key is in RAM and a dropped +//! pool means the database is locked again. There is no separate key registry to +//! keep in sync, and §9's lifetime — unlocked from first login until the process +//! restarts — falls out of the map's lifetime. +//! +//! Three responsibilities live here on purpose: directory CRUD, credential +//! checking, and pool lifecycle. The seam for splitting them (`UserDirectory` / +//! `UserVault`) is the `Credentials` boundary, if this ever grows. +//! +//! **Boundary**: this is credential-check plus pool lifecycle. Whatever maps an +//! HTTP session or a token to a user id sits above it. `UserManager` knows +//! nothing about cookies. + +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use anyhow::{Result, anyhow, bail}; +use sqlx::SqlitePool; +use tracing::{info, warn}; + +use crate::crypto::{self, Dek, KdfParams, KeyError}; +use crate::db::{self, DATABASE_DIR}; +use crate::db::users::{ClearVerifier, Credentials, User, UserSummary}; + +/// Why a login did not happen. Deliberately not `anyhow`: the caller has to be +/// able to tell "wrong password" from "the file is gone", and answer the two very +/// differently. +#[derive(Debug)] +pub enum AuthError { + UnknownUser, + Inactive, + /// The AEAD tag on the sealed DEK did not verify, or the stored verifier did + /// not match. One answer for both, so nothing distinguishes them from outside. + WrongPassword, + /// The user has credentials but none were supplied. + PasswordRequired, + /// The user has no verifier at all; offering a password is a caller bug. + PasswordNotSet, + /// The row exists and the password was right, but `{userid}.db` is not there. + /// Never recreated: a fresh empty database under the correct password would + /// hide the loss instead of reporting it. + MissingDatabase(PathBuf), + Internal(anyhow::Error), +} + +impl fmt::Display for AuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AuthError::UnknownUser => f.write_str("no such user"), + AuthError::Inactive => f.write_str("user is not active"), + AuthError::WrongPassword => f.write_str("wrong password"), + AuthError::PasswordRequired => f.write_str("this user requires a password"), + AuthError::PasswordNotSet => f.write_str("this user has no password"), + AuthError::MissingDatabase(p) => { + write!(f, "database file is missing: {}", p.display()) + } + AuthError::Internal(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for AuthError {} + +impl From for AuthError { + fn from(e: anyhow::Error) -> Self { + AuthError::Internal(e) + } +} + +impl From for AuthError { + fn from(e: sqlx::Error) -> Self { + AuthError::Internal(e.into()) + } +} + +pub struct UserManager { + /// `system.db`: the directory, read before anything else can be opened. + system: Arc, + /// The unlocked databases. A `std::sync::RwLock` on purpose — holding it + /// across an `.await` would make the future `!Send` and stop compiling, which + /// is the enforcement we want. + unlocked: RwLock>, + /// Where `{userid}.db` files live, and the KDF cost applied to new users. + /// Both are fields rather than constants so tests need neither the process's + /// working directory nor a real 256 MiB derivation. + db_dir: PathBuf, + kdf: KdfParams, +} + +impl UserManager { + pub fn new(system: Arc) -> Self { + Self { + system, + unlocked: RwLock::new(HashMap::new()), + db_dir: PathBuf::from(DATABASE_DIR), + kdf: KdfParams::default(), + } + } + + fn path_of(&self, id: &str) -> PathBuf { + db::user_db_path(&self.db_dir, id) + } + + /// The registry pool. Not a user's database — the directory everything else + /// is looked up in. + pub fn system(&self) -> &SqlitePool { + &self.system + } + + // ── Directory reads ─────────────────────────────────────────────────────── + + pub async fn list(&self) -> Result> { + Ok(db::users::list(&self.system).await?.iter().map(User::summary).collect()) + } + + pub async fn get(&self, id: &str) -> Result> { + Ok(db::users::get(&self.system, id).await?.map(|u| u.summary())) + } + + pub async fn by_username(&self, username: &str) -> Result> { + Ok(db::users::by_username(&self.system, username).await?.map(|u| u.summary())) + } + + pub async fn count(&self) -> Result { + db::users::count(&self.system).await + } + + // ── Unlock registry ─────────────────────────────────────────────────────── + + /// The user's pool, or `None` when the database is still locked (§9). + /// Returns a clone: `SqlitePool` is an `Arc` internally, so this is cheap. + pub fn pool_of(&self, id: &str) -> Option { + self.unlocked.read().ok()?.get(id).cloned() + } + + pub fn is_unlocked(&self, id: &str) -> bool { + self.unlocked.read().map(|m| m.contains_key(id)).unwrap_or(false) + } + + /// Login and unlock in one operation. + /// + /// For an encrypted user a single Argon2id pass answers both questions: the + /// sealed DEK either opens — password right, and here is the key — or its + /// AEAD tag fails, which is the wrong password. There is no second hash to + /// check, and none to steal from `system.db`. + /// + /// Idempotent: an already-unlocked user gets the existing pool back without + /// re-deriving anything. + /// + /// Note that an unknown user answers immediately while a real one costs a + /// full derivation. Under the threat model (§2) the adversary owns the box + /// and can simply read `users`, so this timing difference buys nothing; a + /// public login endpoint would want to think about it again. + pub async fn open_db(&self, id: &str, password: Option<&str>) -> Result { + if let Some(pool) = self.pool_of(id) { + return Ok(pool); + } + + let user = db::users::get(&self.system, id) + .await + .map_err(AuthError::Internal)? + .ok_or(AuthError::UnknownUser)?; + + if !user.active { + return Err(AuthError::Inactive); + } + + let key = self.authenticate(&user, password).await?; + + // The row exists, so the file must too — `register_user` writes it first. + let path = self.path_of(id); + if !path.exists() { + return Err(AuthError::MissingDatabase(path)); + } + + let pool = db::open_user_pool(&path, key.as_ref()) + .await + .map_err(AuthError::Internal)?; + + // Another task may have unlocked the same user while we were deriving. + // Whoever landed first wins; ours is closed below, outside the lock, + // since `close()` is async. + let winner = { + let mut map = self.unlocked.write().expect("unlocked map poisoned"); + match map.entry(id.to_string()) { + Entry::Occupied(e) => Some(e.get().clone()), + Entry::Vacant(e) => { + e.insert(pool.clone()); + None + } + } + }; + + match winner { + Some(winner) => { + pool.close().await; + Ok(winner) + } + None => { + info!(user = %id, encrypted = user.is_encrypted(), "user database unlocked"); + Ok(pool) + } + } + } + + /// Verifies the password and, for an encrypted user, recovers the DEK. + /// `Ok(None)` means the user's database is not encrypted. + async fn authenticate( + &self, + user: &User, + password: Option<&str>, + ) -> Result, AuthError> { + match &user.credentials { + Credentials::Encrypted { kdf_params, kdf_salt, database_password } => { + let pw = password.ok_or(AuthError::PasswordRequired)?; + let params = KdfParams::from_json(kdf_params)?; + let kek = crypto::derive_kek(pw, kdf_salt, ¶ms).await?; + match crypto::unwrap_dek(&kek, database_password) { + Ok(dek) => Ok(Some(dek)), + Err(KeyError::WrongPassword) => Err(AuthError::WrongPassword), + Err(e) => Err(AuthError::Internal(anyhow!(e))), + } + } + Credentials::Cleartext(Some(v)) => { + let pw = password.ok_or(AuthError::PasswordRequired)?; + let params = KdfParams::from_json(&v.kdf_params)?; + let kek = crypto::derive_kek(pw, &v.kdf_salt, ¶ms).await?; + if !crypto::verify(&kek, &v.password_hash) { + return Err(AuthError::WrongPassword); + } + Ok(None) + } + // A role that has no login of its own. Who is allowed to ask for this + // pool is the caller's problem — see the boundary note on the module. + Credentials::Cleartext(None) => { + if password.is_some() { + return Err(AuthError::PasswordNotSet); + } + Ok(None) + } + } + } + + /// Drops the pool, which drops the key: the database is opaque again until + /// the next login. + pub async fn lock(&self, id: &str) { + let pool = self.unlocked.write().expect("unlocked map poisoned").remove(id); + if let Some(p) = pool { + p.close().await; + info!(user = %id, "user database locked"); + } + } + + /// Shutdown: every key leaves RAM. + pub async fn lock_all(&self) { + let pools: Vec<_> = { + let mut map = self.unlocked.write().expect("unlocked map poisoned"); + map.drain().collect() + }; + for (id, pool) in pools { + pool.close().await; + info!(user = %id, "user database locked"); + } + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /// Creates a user: opaque id, database file, then the directory row. + /// + /// The order is load-bearing. The row lives in `system.db` and the database + /// in its own file, and no transaction spans the two. Writing the file first + /// means a crash in between leaves an orphan file — harmless garbage — rather + /// than a row whose database never existed, which would be a user that can + /// authenticate and then find nothing to open. + /// + /// The new pool is closed before returning: creating a user is not logging in + /// as them. The DEK is dropped here and survives only inside the seal. + pub async fn register_user( + &self, + username: &str, + display_name: Option<&str>, + role_id: &str, + password: Option<&str>, + encrypted: bool, + ) -> Result { + if username.trim().is_empty() { + bail!("username must not be empty"); + } + if db::users::by_username(&self.system, username).await?.is_some() { + bail!("username already taken: {username}"); + } + + let id = uuid::Uuid::new_v4().to_string(); + let (credentials, dek) = self.mint_credentials(password, encrypted).await?; + + let path = self.path_of(&id); + let pool = db::create_user_pool(&path, dek.as_ref()) + .await + .with_context_path(&path)?; + pool.close().await; + + if let Err(e) = + db::users::insert(&self.system, &id, username, display_name, role_id, &credentials).await + { + // The row never landed, so nothing points at this file. Take it back + // out rather than leaving a database nobody can reach. + self.remove_files(&path); + return Err(e); + } + + info!(user = %id, %username, encrypted, "user registered"); + Ok(id) + } + + /// Builds the auth material for a new user, and the DEK when encrypted. + async fn mint_credentials( + &self, + password: Option<&str>, + encrypted: bool, + ) -> Result<(Credentials, Option)> { + match (encrypted, password) { + (true, None) => bail!("an encrypted user needs a password"), + (true, Some(pw)) => { + let salt = crypto::random_salt(); + let kek = crypto::derive_kek(pw, &salt, &self.kdf).await?; + let dek = Dek::random(); + let sealed = crypto::wrap_dek(&kek, &dek)?; + let creds = Credentials::Encrypted { + kdf_params: self.kdf.to_json()?, + kdf_salt: salt, + database_password: sealed, + }; + Ok((creds, Some(dek))) + } + (false, Some(pw)) => { + let salt = crypto::random_salt(); + let kek = crypto::derive_kek(pw, &salt, &self.kdf).await?; + let creds = Credentials::Cleartext(Some(ClearVerifier { + kdf_params: self.kdf.to_json()?, + kdf_salt: salt, + password_hash: kek.as_verifier().to_vec(), + })); + Ok((creds, None)) + } + (false, None) => Ok((Credentials::Cleartext(None), None)), + } + } + + /// Erases a user: the row first, then the file. + /// + /// The mirror of [`Self::register_user`], and for the same reason — a crash + /// after the row is gone leaves an unreachable file, never a row without a + /// database. GDPR erasure is then a matter of deleting files. + pub async fn delete_user(&self, id: &str) -> Result<()> { + self.lock(id).await; + db::users::delete(&self.system, id).await?; + self.remove_files(&self.path_of(id)); + info!(user = %id, "user deleted"); + Ok(()) + } + + /// Re-seals the same DEK under a key derived from the new password. + /// + /// The database is never re-encrypted and an unlocked pool stays valid: only + /// the seal in `system.db` changes. Failing to open the old seal *is* the + /// check that the old password was right. + /// + /// Migrating between encrypted and cleartext is **not** done here — that one + /// needs `sqlcipher_export` to rewrite the file. + pub async fn change_password( + &self, + id: &str, + old: Option<&str>, + new: Option<&str>, + ) -> Result<(), AuthError> { + let user = db::users::get(&self.system, id) + .await + .map_err(AuthError::Internal)? + .ok_or(AuthError::UnknownUser)?; + + let credentials = match &user.credentials { + Credentials::Encrypted { kdf_params, kdf_salt, database_password } => { + let old = old.ok_or(AuthError::PasswordRequired)?; + let new = new.ok_or_else(|| { + AuthError::Internal(anyhow!("an encrypted user cannot drop its password")) + })?; + + let params = KdfParams::from_json(kdf_params)?; + let kek_old = crypto::derive_kek(old, kdf_salt, ¶ms).await?; + let dek = match crypto::unwrap_dek(&kek_old, database_password) { + Ok(dek) => dek, + Err(KeyError::WrongPassword) => return Err(AuthError::WrongPassword), + Err(e) => return Err(AuthError::Internal(anyhow!(e))), + }; + + let salt = crypto::random_salt(); + let kek_new = crypto::derive_kek(new, &salt, &self.kdf).await?; + Credentials::Encrypted { + kdf_params: self.kdf.to_json()?, + kdf_salt: salt, + database_password: crypto::wrap_dek(&kek_new, &dek)?, + } + } + + Credentials::Cleartext(existing) => { + match (existing, old) { + (Some(v), Some(old)) => { + let params = KdfParams::from_json(&v.kdf_params)?; + let kek = crypto::derive_kek(old, &v.kdf_salt, ¶ms).await?; + if !crypto::verify(&kek, &v.password_hash) { + return Err(AuthError::WrongPassword); + } + } + (Some(_), None) => return Err(AuthError::PasswordRequired), + (None, Some(_)) => return Err(AuthError::PasswordNotSet), + (None, None) => {} + } + match new { + None => Credentials::Cleartext(None), + Some(new) => { + let salt = crypto::random_salt(); + let kek = crypto::derive_kek(new, &salt, &self.kdf).await?; + Credentials::Cleartext(Some(ClearVerifier { + kdf_params: self.kdf.to_json()?, + kdf_salt: salt, + password_hash: kek.as_verifier().to_vec(), + })) + } + } + } + }; + + db::users::set_credentials(&self.system, id, &credentials) + .await + .map_err(AuthError::Internal)?; + info!(user = %id, "password changed"); + Ok(()) + } + + /// Best-effort: a leftover file is garbage, not a correctness problem, and a + /// failure to remove it must not fail the operation that asked. + fn remove_files(&self, path: &Path) { + for p in std::iter::once(path.to_path_buf()).chain(db::user_db_sidecars(path)) { + if p.exists() + && let Err(e) = std::fs::remove_file(&p) + { + warn!(path = %p.display(), error = %e, "could not remove database file"); + } + } + } +} + +/// Adds the file path to a provisioning failure, which otherwise surfaces as a +/// bare sqlx error with no hint of which database it was. +trait ContextPath { + fn with_context_path(self, path: &Path) -> Result; +} + +impl ContextPath for Result { + fn with_context_path(self, path: &Path) -> Result { + self.map_err(|e| e.context(format!("provisioning {}", path.display()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixture { + dir: PathBuf, + users: UserManager, + } + + impl Fixture { + async fn new(tag: &str) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let mut dir = std::env::temp_dir(); + dir.push(format!("skald-um-{tag}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let system = db::init_system_pool(dir.join("system.db").to_str().unwrap()) + .await + .unwrap(); + + let users = UserManager { + system: Arc::new(system), + unlocked: RwLock::new(HashMap::new()), + db_dir: dir.clone(), + // The real 256 MiB / ~1s derivation, times two per test, is not + // something a test suite should pay. + kdf: KdfParams::fast(), + }; + Fixture { dir, users } + } + + fn path_of(&self, id: &str) -> PathBuf { + self.users.path_of(id) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + async fn write_marker(pool: &SqlitePool, title: &str) { + sqlx::query("INSERT INTO chat_sessions (title) VALUES (?1)") + .bind(title) + .execute(pool) + .await + .unwrap(); + } + + async fn read_marker(pool: &SqlitePool) -> String { + sqlx::query_scalar::<_, String>("SELECT title FROM chat_sessions LIMIT 1") + .fetch_one(pool) + .await + .unwrap() + } + + #[tokio::test] + async fn register_writes_the_file_before_the_row() { + let f = Fixture::new("order").await; + let id = f.users.register_user("ada", Some("Ada"), "admin", Some("pw"), true).await.unwrap(); + + assert!(f.path_of(&id).exists(), "the database file must exist"); + assert_eq!(f.users.count().await.unwrap(), 1); + // Creating a user is not logging in as them. + assert!(!f.users.is_unlocked(&id), "register must not leave the pool unlocked"); + } + + #[tokio::test] + async fn a_duplicate_username_leaves_no_orphan_file() { + let f = Fixture::new("dup").await; + f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + + let before: Vec<_> = std::fs::read_dir(&f.dir).unwrap().collect(); + assert!(f.users.register_user("ada", None, "admin", Some("pw2"), true).await.is_err()); + let after: Vec<_> = std::fs::read_dir(&f.dir).unwrap().collect(); + + assert_eq!(before.len(), after.len(), "the failed registration must clean up its file"); + assert_eq!(f.users.count().await.unwrap(), 1); + } + + /// The full §9 cycle: unlock at login, key in RAM until the pool is dropped. + #[tokio::test] + async fn encrypted_user_round_trips_through_lock_and_unlock() { + let f = Fixture::new("cycle").await; + let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + + let pool = f.users.open_db(&id, Some("pw")).await.unwrap(); + write_marker(&pool, "private").await; + assert!(f.users.is_unlocked(&id)); + + f.users.lock(&id).await; + assert!(!f.users.is_unlocked(&id)); + assert!(f.users.pool_of(&id).is_none(), "locked means no pool"); + + let pool = f.users.open_db(&id, Some("pw")).await.unwrap(); + assert_eq!(read_marker(&pool).await, "private"); + + // The file on disk is genuinely encrypted, not merely gated by our code. + let raw = std::fs::read(f.path_of(&id)).unwrap(); + assert_ne!(&raw[..16], b"SQLite format 3\0"); + assert!(!String::from_utf8_lossy(&raw).contains("private")); + } + + #[tokio::test] + async fn a_wrong_password_is_rejected_and_unlocks_nothing() { + let f = Fixture::new("wrongpw").await; + let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + + let err = f.users.open_db(&id, Some("nope")).await.unwrap_err(); + assert!(matches!(err, AuthError::WrongPassword), "got {err:?}"); + assert!(!f.users.is_unlocked(&id)); + + assert!(matches!(f.users.open_db(&id, None).await.unwrap_err(), AuthError::PasswordRequired)); + assert!(matches!(f.users.open_db("ghost", Some("pw")).await.unwrap_err(), AuthError::UnknownUser)); + } + + /// A row without a file is the one state the ordering rule forbids. If it + /// happens anyway, say so — never silently hand back an empty database. + #[tokio::test] + async fn a_missing_file_is_reported_not_recreated() { + let f = Fixture::new("missing").await; + let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + std::fs::remove_file(f.path_of(&id)).unwrap(); + + let err = f.users.open_db(&id, Some("pw")).await.unwrap_err(); + assert!(matches!(err, AuthError::MissingDatabase(_)), "got {err:?}"); + assert!(!f.path_of(&id).exists(), "a failed login must not create a database"); + } + + #[tokio::test] + async fn changing_the_password_reseals_the_same_key() { + let f = Fixture::new("rewrap").await; + let id = f.users.register_user("ada", None, "admin", Some("old"), true).await.unwrap(); + + let pool = f.users.open_db(&id, Some("old")).await.unwrap(); + write_marker(&pool, "kept").await; + + f.users.change_password(&id, Some("old"), Some("new")).await.unwrap(); + // The DEK never changed, so the pool we are holding is still live. + assert_eq!(read_marker(&pool).await, "kept"); + + f.users.lock(&id).await; + assert!(matches!( + f.users.change_password(&id, Some("old"), Some("x")).await.unwrap_err(), + AuthError::WrongPassword + )); + + // ...and the data is still there, reachable only with the new password. + assert!(f.users.open_db(&id, Some("old")).await.is_err()); + let pool = f.users.open_db(&id, Some("new")).await.unwrap(); + assert_eq!(read_marker(&pool).await, "kept"); + } + + #[tokio::test] + async fn cleartext_users_authenticate_without_encrypting_anything() { + let f = Fixture::new("clear").await; + let pinned = f.users.register_user("kid", None, "children", Some("pin"), false).await.unwrap(); + let open = f.users.register_user("kiosk", None, "children", None, false).await.unwrap(); + + // A verifier is checked; the database itself is a plain SQLite file, which + // is what makes supervision possible at all (§12). + assert!(matches!(f.users.open_db(&pinned, Some("no")).await.unwrap_err(), AuthError::WrongPassword)); + let pool = f.users.open_db(&pinned, Some("pin")).await.unwrap(); + write_marker(&pool, "homework").await; + assert_eq!(&std::fs::read(f.path_of(&pinned)).unwrap()[..16], b"SQLite format 3\0"); + + // No verifier: no password to give, and offering one is a caller bug. + assert!(matches!(f.users.open_db(&open, Some("x")).await.unwrap_err(), AuthError::PasswordNotSet)); + f.users.open_db(&open, None).await.unwrap(); + } + + #[tokio::test] + async fn open_db_is_idempotent_and_returns_the_same_pool() { + let f = Fixture::new("idem").await; + let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + + let a = f.users.open_db(&id, Some("pw")).await.unwrap(); + // No password needed the second time: the pool *is* the unlock token. + let b = f.users.open_db(&id, None).await.unwrap(); + write_marker(&a, "shared").await; + assert_eq!(read_marker(&b).await, "shared"); + } + + #[tokio::test] + async fn deleting_a_user_evicts_the_pool_and_erases_the_files() { + let f = Fixture::new("erase").await; + let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + let pool = f.users.open_db(&id, Some("pw")).await.unwrap(); + write_marker(&pool, "gone").await; + + f.users.delete_user(&id).await.unwrap(); + + assert!(!f.users.is_unlocked(&id)); + assert_eq!(f.users.count().await.unwrap(), 0); + assert!(!f.path_of(&id).exists()); + for sidecar in db::user_db_sidecars(&f.path_of(&id)) { + assert!(!sidecar.exists(), "{} survived erasure", sidecar.display()); + } + } + + #[tokio::test] + async fn lock_all_drops_every_key() { + let f = Fixture::new("lockall").await; + let a = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap(); + let b = f.users.register_user("bob", None, "admin", None, false).await.unwrap(); + f.users.open_db(&a, Some("pw")).await.unwrap(); + f.users.open_db(&b, None).await.unwrap(); + + f.users.lock_all().await; + assert!(!f.users.is_unlocked(&a) && !f.users.is_unlocked(&b)); + } + + #[tokio::test] + async fn auth_errors_never_carry_key_material() { + let f = Fixture::new("noleak").await; + let id = f.users.register_user("ada", None, "admin", Some("hunter2"), true).await.unwrap(); + let err = f.users.open_db(&id, Some("hunter2!")).await.unwrap_err(); + let printed = format!("{err:?} {err}"); + assert!(!printed.contains("hunter2"), "the password must not reach an error"); + } +} diff --git a/src/boot.rs b/src/boot.rs deleted file mode 100644 index a24b96c..0000000 --- a/src/boot.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Curated, human-readable bootstrap progress printed to **stdout**. -//! -//! At runtime stdout is silent — only the file log (`logs/skald.log`) records -//! events. During startup, though, it is useful to see at a glance how the app -//! is configured and how it is coming up. These helpers emit a small, ordered -//! set of lines on the dedicated `boot` tracing target, which a stdout layer -//! (see [`BootFormat`], wired in `main.rs`) renders cleanly — no timestamps, -//! levels or targets, just the message, with failures shown in red. -//! -//! The same lines also land in the log file (they pass the normal `EnvFilter`), -//! so they double as a high-level startup trace. Glyphs are baked into the -//! message on purpose, so the file keeps the same readable shape. -//! -//! The stdout layer filters on the `boot` target only and is independent of -//! `RUST_LOG`, so this output always appears regardless of the log filter. - -use std::fmt; - -use tracing::field::{Field, Visit}; -use tracing::{Event, Level, info, warn}; -use tracing_subscriber::fmt::format::Writer; -use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields}; -use tracing_subscriber::registry::LookupSpan; - -/// Tracing target for curated bootstrap lines shown on stdout. -pub const TARGET: &str = "boot"; - -/// Top-level title (no glyph), e.g. `skald v0.5 — starting`. -pub fn title(msg: impl fmt::Display) { - info!(target: TARGET, "{}", msg); -} - -/// A phase header, e.g. `› Plugins — 6 active, 1 failed`. -pub fn section(msg: impl fmt::Display) { - info!(target: TARGET, "› {}", msg); -} - -/// A successful item under a phase. -pub fn ok(msg: impl fmt::Display) { - info!(target: TARGET, " ✓ {}", msg); -} - -/// An item that exists but is inactive (e.g. a disabled plugin). -pub fn off(msg: impl fmt::Display) { - info!(target: TARGET, " ○ {}", msg); -} - -/// A failed item (rendered in red on stdout; logged at WARN in the file). -pub fn fail(msg: impl fmt::Display) { - warn!(target: TARGET, " ✗ {}", msg); -} - -/// The final "app is up" line, e.g. `✅ Ready — http://localhost:8080`. -pub fn ready(msg: impl fmt::Display) { - info!(target: TARGET, "✅ {}", msg); -} - -/// Minimal stdout formatter for bootstrap lines: prints just the event's -/// message (no timestamp/level/target), in red when the level is WARN or ERROR. -pub struct BootFormat; - -#[derive(Default)] -struct MessageVisitor(String); - -impl Visit for MessageVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() == "message" { - use std::fmt::Write; - let _ = write!(self.0, "{value:?}"); - } - } -} - -impl FormatEvent for BootFormat -where - S: tracing::Subscriber + for<'a> LookupSpan<'a>, - N: for<'a> FormatFields<'a> + 'static, -{ - fn format_event( - &self, - _ctx: &FmtContext<'_, S, N>, - mut writer: Writer<'_>, - event: &Event<'_>, - ) -> fmt::Result { - let mut visitor = MessageVisitor::default(); - event.record(&mut visitor); - - // Level ordering in tracing: TRACE > DEBUG > INFO > WARN > ERROR, so - // `<= WARN` matches both WARN and ERROR. - let is_failure = *event.metadata().level() <= Level::WARN; - if writer.has_ansi_escapes() && is_failure { - write!(writer, "\u{1b}[31m{}\u{1b}[0m", visitor.0)?; - } else { - write!(writer, "{}", visitor.0)?; - } - writeln!(writer) - } -} diff --git a/src/boot_format.rs b/src/boot_format.rs new file mode 100644 index 0000000..30b83c2 --- /dev/null +++ b/src/boot_format.rs @@ -0,0 +1,56 @@ +//! How this binary renders the bootstrap lines that `skald_core::boot` emits. +//! +//! Rendering is the shell's business, not the core's: a desktop bundle or a +//! setup wizard would format the same `boot` target differently, or not at all. +//! Wired in `main.rs` as a stdout layer filtered on `boot::TARGET`, independent +//! of `RUST_LOG`, so this output always appears whatever the log filter is. + +use std::fmt; + +use tracing::field::{Field, Visit}; +use tracing::{Event, Level}; +use tracing_subscriber::fmt::format::Writer; +use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields}; +use tracing_subscriber::registry::LookupSpan; + +/// Minimal stdout formatter for bootstrap lines: prints just the event's +/// message (no timestamp/level/target), in red when the level is WARN or ERROR. +pub struct BootFormat; + +#[derive(Default)] +struct MessageVisitor(String); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + use std::fmt::Write; + let _ = write!(self.0, "{value:?}"); + } + } +} + +impl FormatEvent for BootFormat +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + fn format_event( + &self, + _ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + + // Level ordering in tracing: TRACE > DEBUG > INFO > WARN > ERROR, so + // `<= WARN` matches both WARN and ERROR. + let is_failure = *event.metadata().level() <= Level::WARN; + if writer.has_ansi_escapes() && is_failure { + write!(writer, "\u{1b}[31m{}\u{1b}[0m", visitor.0)?; + } else { + write!(writer, "{}", visitor.0)?; + } + writeln!(writer) + } +} diff --git a/src/config.rs b/src/config.rs index d6f719d..7e6de36 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; pub use core_api::provider::LlmStrength; -pub use crate::core::config::{ +pub use skald_core::config::{ LlmConfig, TicConfig, CronConfig, CompactionConfig, DatetimeConfig, LlmRequestsLogConfig, }; @@ -48,10 +48,10 @@ pub struct WebConfig { } impl Config { - pub fn into_split(self) -> (crate::core::config::CoreConfig, crate::frontend::config::FrontendConfig) { + pub fn into_split(self) -> (skald_core::config::CoreConfig, crate::frontend::config::FrontendConfig) { let tz = self.timezone.clone(); ( - crate::core::config::CoreConfig { + skald_core::config::CoreConfig { llm: self.llm, tic: self.tic, cron: self.cron, @@ -74,7 +74,7 @@ impl Config { if !config_path.exists() { std::fs::copy(default_path, config_path) .with_context(|| format!("Failed to copy {DEFAULT_CONFIG} to {CONFIG}"))?; - crate::boot::section(format!("Created {CONFIG} from {DEFAULT_CONFIG}")); + skald_core::boot::section(format!("Created {CONFIG} from {DEFAULT_CONFIG}")); } let content = std::fs::read_to_string(config_path) diff --git a/src/core/tools/restart.rs b/src/core/tools/restart.rs deleted file mode 100644 index c79b347..0000000 --- a/src/core/tools/restart.rs +++ /dev/null @@ -1,63 +0,0 @@ -use anyhow::Result; -use serde_json::{Value, json}; -use tracing::info; - -use crate::core::tools::{Tool, ToolDescriptionLength}; - -pub struct Restart; - -impl Tool for Restart { - fn name(&self) -> &str { crate::core::tools::tool_names::RESTART } - fn category(&self) -> crate::core::tools::ToolCategory { crate::core::tools::ToolCategory::Shell } - - fn description(&self) -> &str { - "Restart the skald process. \ - In headless (dev) mode exits with code -1, signalling the run.sh supervisor to rebuild \ - (cargo build) and relaunch — use this after editing the source code to load the new version. \ - In desktop (Tauri bundle) mode there is no source tree to rebuild, so the process is simply \ - restarted (cleanup + respawn) — use this to apply config.yml / database changes that are \ - only read at startup. \ - Requires user approval." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String { - "restart skald".to_string() - } - - fn execute(&self, _args: Value) -> Result { - // Desktop (Tauri bundle) mode: respawn the process manually. The bundled - // binary is read-only (no source tree to rebuild), so "restart" means: - // run Tauri-side teardown, spawn a fresh copy of the current exe, exit. - // This mirrors what `tauri-plugin-process`'s JS `restart` does internally. - #[cfg(feature = "desktop")] - { - if let Some(handle) = crate::desktop::app_handle() { - info!("restart requested — desktop mode: respawning process"); - let exe = std::env::current_exe() - .map_err(|e| anyhow::anyhow!("failed to resolve current_exe: {e}"))?; - // Tauri-side teardown (webview, tray, event loop, windows). - handle.cleanup_before_exit(); - // Spawn a fresh copy of the current binary (detached). - let _ = std::process::Command::new(exe).spawn(); - std::process::exit(0); - } - // Fall through if (somehow) the AppHandle isn't set yet — treat as - // headless and use the exit-code path. - } - - // Headless (dev) mode: exit with code -1 (= 255 on Unix). run.sh - // supervisor sees 255, runs `cargo build`, and relaunches. Use - // `_exit()` instead of `exit()` to skip C atexit handlers (e.g. Metal - // GPU cleanup in whisper-rs which crashes with SIGABRT and produces - // exit code 134 instead of 255). - info!("restart requested — headless mode: exit(-1) → supervisor rebuilds"); - unsafe { libc::_exit(-1) } - } -} diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index b4a8763..008f860 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -65,9 +65,26 @@ pub fn run() -> anyhow::Result<()> { .manage::(Mutex::new(None)) .setup(|app| { // Stash the app handle for code paths without a natural handle - // reference (notably the `restart` tool). + // reference (notably the `restart` tool, reached through the handler + // installed just below). let _ = APP_HANDLE.set(app.handle().clone()); + // Teach the core how to restart *this* shell. A bundled app has no + // supervisor reading its exit code, and the binary is read-only, so + // "restart" means: tear down Tauri, spawn a fresh copy, exit. This + // mirrors what `tauri-plugin-process`'s JS `restart` does internally. + skald_core::tools::restart::set_restart_handler(Box::new(|| { + let handle = app_handle() + .ok_or_else(|| anyhow::anyhow!("Tauri app handle is not set yet"))?; + let exe = std::env::current_exe() + .map_err(|e| anyhow::anyhow!("failed to resolve current_exe: {e}"))?; + // Tauri-side teardown (webview, tray, event loop, windows). + handle.cleanup_before_exit(); + // Spawn a fresh copy of the current binary (detached). + let _ = std::process::Command::new(exe).spawn(); + std::process::exit(0); + })); + build_tray(app)?; // Resolve the backend port from config so the webview URL is always diff --git a/src/frontend/api/agents.rs b/src/frontend/api/agents.rs index aad6ce5..09b203b 100644 --- a/src/frontend/api/agents.rs +++ b/src/frontend/api/agents.rs @@ -1,15 +1,15 @@ use axum::{Json, extract::State, response::IntoResponse}; use serde::Serialize; -use crate::core::agents::AgentMeta; -use crate::core::llm::{LlmModelInfo, sort_models_for_agent}; +use skald_core::agents::AgentMeta; +use skald_core::llm::{LlmModelInfo, sort_models_for_agent}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; pub async fn list(_: State>) -> Result>, ApiError> { - let agents = crate::core::agents::discover()?; + let agents = skald_core::agents::discover()?; Ok(Json(agents)) } @@ -24,8 +24,8 @@ pub async fn get( State(skald): State>, axum::extract::Path(id): axum::extract::Path, ) -> Result, ApiError> { - let meta = crate::core::agents::load_meta(&id)?; - let prompt = crate::core::agents::load_prompt(&id)?; + let meta = skald_core::agents::load_meta(&id)?; + let prompt = skald_core::agents::load_prompt(&id)?; let all = skald.manager().llm_manager().list_models_info().await; let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength); Ok(Json(AgentDetail { meta, prompt, models })) @@ -35,7 +35,7 @@ pub async fn get( pub async fn icon( axum::extract::Path(id): axum::extract::Path, ) -> Result { - let meta = crate::core::agents::load_meta(&id)?; + let meta = skald_core::agents::load_meta(&id)?; let icon_path = meta.icon.ok_or_else(|| { ApiError::not_found(format!("Agent '{}' has no icon configured", id)) })?; diff --git a/src/frontend/api/approval.rs b/src/frontend/api/approval.rs index c79e8aa..c31f1cb 100644 --- a/src/frontend/api/approval.rs +++ b/src/frontend/api/approval.rs @@ -5,11 +5,11 @@ use axum::{ use serde::Deserialize; use serde_json::{Value, json}; -use crate::core::approval::NewApprovalRule; -use crate::core::tool_catalog::{AllTools, McpServerMeta, ToolInfo}; +use skald_core::approval::NewApprovalRule; +use skald_core::tool_catalog::{AllTools, McpServerMeta, ToolInfo}; use std::collections::HashSet; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; @@ -110,7 +110,7 @@ pub async fn list_tools( State(skald): State>, ) -> Result, ApiError> { let mut tools = skald.catalog().list_all(); - let server_rows = crate::core::db::mcp_servers::all(skald.db()).await?; + let server_rows = skald_core::db::mcp_servers::all(skald.db()).await?; tools.mcp_servers = server_rows.into_iter() .map(|r| (r.name, McpServerMeta { friendly_name: r.friendly_name, description: r.description })) .collect(); @@ -121,7 +121,7 @@ pub async fn list_tools( // is what makes them configurable in the Security-groups grid. Names already // known as built-in or MCP tools are deduped out; the rest are grouped under // the "dynamic" category. - let discovered = crate::core::db::known_tools::all(skald.db()).await?; + let discovered = skald_core::db::known_tools::all(skald.db()).await?; let existing: HashSet<&str> = tools.built_in.iter() .chain(tools.mcp.iter()) .map(|t| t.name.as_str()) diff --git a/src/frontend/api/commands.rs b/src/frontend/api/commands.rs index 809a294..f2b725b 100644 --- a/src/frontend/api/commands.rs +++ b/src/frontend/api/commands.rs @@ -4,7 +4,7 @@ use axum::{Json, extract::State}; use core_api::command::{CommandApi, CommandInfo}; -use crate::core::skald::Skald; +use skald_core::skald::Skald; /// `GET /api/commands` — list enabled custom slash commands (name + description) /// for the composer autocomplete and the dynamic `/help`. Read-only: commands are diff --git a/src/frontend/api/config.rs b/src/frontend/api/config.rs index e405335..e3e45ec 100644 --- a/src/frontend/api/config.rs +++ b/src/frontend/api/config.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use core_api::PropertyType; use core_api::system_bus::SystemEvent; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── Response types ───────────────────────────────────────────────────────────── diff --git a/src/frontend/api/cron.rs b/src/frontend/api/cron.rs index 4e961aa..032fa59 100644 --- a/src/frontend/api/cron.rs +++ b/src/frontend/api/cron.rs @@ -5,9 +5,9 @@ use axum::{ }; use serde::Deserialize; -use crate::core::db::{scheduled_jobs, job_runs}; +use skald_core::db::{scheduled_jobs, job_runs}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; #[derive(serde::Serialize)] @@ -80,7 +80,7 @@ pub async fn set_run_context( State(skald): State>, Json(body): Json, ) -> Result<(), ApiError> { - use crate::core::run_context::RunContext; + use skald_core::run_context::RunContext; let json = body.security_group.as_ref().map(|sg| { RunContext::with_security_group(Some(sg.clone())).to_db() }); diff --git a/src/frontend/api/dev.rs b/src/frontend/api/dev.rs index 3ac9759..460a70b 100644 --- a/src/frontend/api/dev.rs +++ b/src/frontend/api/dev.rs @@ -6,7 +6,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; const KEY: &str = "DEBUG_MODE"; diff --git a/src/frontend/api/file_watch.rs b/src/frontend/api/file_watch.rs index 0d2e100..c1cf1ab 100644 --- a/src/frontend/api/file_watch.rs +++ b/src/frontend/api/file_watch.rs @@ -61,8 +61,8 @@ use serde_json::json; use tokio::sync::mpsc; use tracing::info; -use crate::core::skald::Skald; -use crate::core::tools::fs as fs_tools; +use skald_core::skald::Skald; +use skald_core::tools::fs as fs_tools; pub async fn handler( ws: WebSocketUpgrade, diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index 9f310fc..acc0897 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -9,9 +9,9 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use crate::core::skald::Skald; -use crate::core::latex::CompileError; -use crate::core::tools::fs as fs_tools; +use skald_core::skald::Skald; +use skald_core::latex::CompileError; +use skald_core::tools::fs as fs_tools; use super::ApiError; #[derive(Serialize)] @@ -59,7 +59,7 @@ pub struct FileQuery { /// frontend file viewer reads text via `res.text()` and binaries via `res.blob()`. /// /// With `?compile-latex=true` a `.tex` source is compiled to PDF (see -/// [`crate::core::latex::LatexCompiler`]); the response is then +/// [`skald_core::latex::LatexCompiler`]); the response is then /// `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. diff --git a/src/frontend/api/image_generate_models.rs b/src/frontend/api/image_generate_models.rs index ed30b62..02f4af8 100644 --- a/src/frontend/api/image_generate_models.rs +++ b/src/frontend/api/image_generate_models.rs @@ -1,9 +1,9 @@ use axum::{Json, extract::State, http::StatusCode}; use serde::Deserialize; -use crate::core::image_generate::{ImageGenerateModelInfo, ImageGenerateModelRecord}; +use skald_core::image_generate::{ImageGenerateModelInfo, ImageGenerateModelRecord}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── GET /api/image-generate/models ─────────────────────────────────────────── diff --git a/src/frontend/api/images.rs b/src/frontend/api/images.rs index fa77f81..329bcbc 100644 --- a/src/frontend/api/images.rs +++ b/src/frontend/api/images.rs @@ -6,7 +6,7 @@ use axum::{ use tokio::fs; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; /// GET /api/images/:task_id /// diff --git a/src/frontend/api/inbox.rs b/src/frontend/api/inbox.rs index 3086128..8bf988c 100644 --- a/src/frontend/api/inbox.rs +++ b/src/frontend/api/inbox.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use std::time::Duration; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── GET /api/inbox ──────────────────────────────────────────────────────────── diff --git a/src/frontend/api/llm.rs b/src/frontend/api/llm.rs index 2a5a176..0641ada 100644 --- a/src/frontend/api/llm.rs +++ b/src/frontend/api/llm.rs @@ -4,11 +4,11 @@ use axum::{Json, extract::State, http::StatusCode}; use serde::{Deserialize, Serialize}; use crate::config::LlmStrength; -use crate::core::llm::providers::RemoteLlmModelInfo; -use crate::core::llm::{LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord}; -use crate::core::provider::{ProviderUiMeta, ReasoningMode}; +use skald_core::llm::providers::RemoteLlmModelInfo; +use skald_core::llm::{LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord}; +use skald_core::provider::{ProviderUiMeta, ReasoningMode}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── GET /api/llm/providers/{id}/models ─────────────────────────────────────── diff --git a/src/frontend/api/mcp.rs b/src/frontend/api/mcp.rs index 0e46cd5..d8ab51e 100644 --- a/src/frontend/api/mcp.rs +++ b/src/frontend/api/mcp.rs @@ -3,7 +3,7 @@ use axum::extract::State; use serde_json::Value; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; /// Returns the list of running MCP servers and their available tools. pub async fn list_servers(State(skald): State>) -> Json> { diff --git a/src/frontend/api/mcp_media.rs b/src/frontend/api/mcp_media.rs index aa9f8a8..829b65d 100644 --- a/src/frontend/api/mcp_media.rs +++ b/src/frontend/api/mcp_media.rs @@ -6,8 +6,8 @@ use axum::{ use tokio::fs; use std::sync::Arc; -use crate::core::mcp::content_type_for_ext; -use crate::core::skald::Skald; +use skald_core::mcp::content_type_for_ext; +use skald_core::skald::Skald; /// GET /api/mcp-media/:file /// diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 442e0cc..352f888 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -34,7 +34,7 @@ use axum::{ routing::{delete, get, patch, post, put}, }; -use crate::core::skald::Skald; +use skald_core::skald::Skald; pub fn router() -> Router> { Router::new() diff --git a/src/frontend/api/plugins.rs b/src/frontend/api/plugins.rs index 8625c54..4dda590 100644 --- a/src/frontend/api/plugins.rs +++ b/src/frontend/api/plugins.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use serde_json::Value; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; pub async fn list(State(skald): State>) -> Result { diff --git a/src/frontend/api/projects.rs b/src/frontend/api/projects.rs index cd6b8be..fa9d783 100644 --- a/src/frontend/api/projects.rs +++ b/src/frontend/api/projects.rs @@ -7,10 +7,10 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -use crate::core::db::project_tickets::ProjectTicket; -use crate::core::db::projects::Project; -use crate::core::run_context::RunContext; -use crate::core::skald::Skald; +use skald_core::db::project_tickets::ProjectTicket; +use skald_core::db::projects::Project; +use skald_core::run_context::RunContext; +use skald_core::skald::Skald; use super::ApiError; /// Source-id prefix for a project's interactive chat session (e.g. `project-42`). @@ -274,7 +274,7 @@ pub async fn provisioning_for_source( let project = skald.projects().get(id).await? .ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?; let base = project.run_context.as_deref().and_then(RunContext::from_db); - let rc = crate::core::projects::build_runtime_run_context(&project, base); + let rc = skald_core::projects::build_runtime_run_context(&project, base); Ok((PROJECT_COORDINATOR_AGENT.to_string(), Some(rc))) } diff --git a/src/frontend/api/run_context.rs b/src/frontend/api/run_context.rs index 034977d..fd286ba 100644 --- a/src/frontend/api/run_context.rs +++ b/src/frontend/api/run_context.rs @@ -8,7 +8,7 @@ use axum::{ use serde::Deserialize; use serde_json::{Value, json}; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── Tool Permission Groups ──────────────────────────────────────────────────── @@ -88,7 +88,7 @@ pub struct SessionPath { pub session_id: i64 } pub async fn set_session_run_context( State(skald): State>, Path(p): Path, - Json(ctx): Json>, + Json(ctx): Json>, ) -> Result, ApiError> { skald.run_context_manager().set_session_run_context(p.session_id, ctx.as_ref()).await?; diff --git a/src/frontend/api/sessions.rs b/src/frontend/api/sessions.rs index ba99878..d47d126 100644 --- a/src/frontend/api/sessions.rs +++ b/src/frontend/api/sessions.rs @@ -10,13 +10,13 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::SqlitePool; -use crate::core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; -use crate::core::db::chat_sessions_stack::SessionStack; +use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; +use skald_core::db::chat_sessions_stack::SessionStack; use std::sync::Arc; -use crate::core::skald::Skald; -use crate::core::session::handler::ApprovalDecision; -use crate::core::approval::ApprovalManager; -use crate::core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn}; +use skald_core::skald::Skald; +use skald_core::session::handler::ApprovalDecision; +use skald_core::approval::ApprovalManager; +use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn}; use super::ApiError; diff --git a/src/frontend/api/stats.rs b/src/frontend/api/stats.rs index 34b1872..373c223 100644 --- a/src/frontend/api/stats.rs +++ b/src/frontend/api/stats.rs @@ -6,7 +6,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; #[derive(Deserialize, Default)] diff --git a/src/frontend/api/transcribe_audio.rs b/src/frontend/api/transcribe_audio.rs index ca68eba..a19c71d 100644 --- a/src/frontend/api/transcribe_audio.rs +++ b/src/frontend/api/transcribe_audio.rs @@ -6,7 +6,7 @@ use axum::{ use serde::Serialize; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; #[derive(Serialize)] diff --git a/src/frontend/api/transcribe_models.rs b/src/frontend/api/transcribe_models.rs index 812daba..fc9c4dd 100644 --- a/src/frontend/api/transcribe_models.rs +++ b/src/frontend/api/transcribe_models.rs @@ -1,9 +1,9 @@ use axum::{Json, extract::State, http::StatusCode}; use serde::Deserialize; -use crate::core::transcribe::{RemoteTranscribeModelInfo, TranscribeModelInfo, TranscribeModelRecord}; +use skald_core::transcribe::{RemoteTranscribeModelInfo, TranscribeModelInfo, TranscribeModelRecord}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── GET /api/transcribe/models ──────────────────────────────────────────────── diff --git a/src/frontend/api/tts_models.rs b/src/frontend/api/tts_models.rs index 105c7a7..ee70ff3 100644 --- a/src/frontend/api/tts_models.rs +++ b/src/frontend/api/tts_models.rs @@ -1,9 +1,9 @@ use axum::{Json, extract::State, http::StatusCode}; use serde::Deserialize; -use crate::core::tts::{RemoteTtsModelInfo, TtsModelInfo, TtsModelRecord}; +use skald_core::tts::{RemoteTtsModelInfo, TtsModelInfo, TtsModelRecord}; use std::sync::Arc; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use super::ApiError; // ── GET /api/tts/models ─────────────────────────────────────────────────────── diff --git a/src/frontend/api/uploads.rs b/src/frontend/api/uploads.rs index b5ccda6..6ab4d91 100644 --- a/src/frontend/api/uploads.rs +++ b/src/frontend/api/uploads.rs @@ -9,8 +9,8 @@ use tokio::io::AsyncWriteExt; use core_api::message_meta::Attachment; -use crate::core::skald::Skald; -use crate::core::tools::fs as fs_tools; +use skald_core::skald::Skald; +use skald_core::tools::fs as fs_tools; use super::ApiError; use super::sessions::SourcePath; diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index d49c568..0c610f6 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -12,9 +12,9 @@ use serde_json::Value; use tokio::sync::broadcast; use tracing::{debug, info, warn}; -use crate::core::chat_hub::{ModelCommandOutcome, SendMessageOptions}; -use crate::core::events::{ClientMessage, ServerEvent}; -use crate::core::skald::Skald; +use skald_core::chat_hub::{ModelCommandOutcome, SendMessageOptions}; +use skald_core::events::{ClientMessage, ServerEvent}; +use skald_core::skald::Skald; use core_api::command::CommandApi; #[derive(Deserialize)] @@ -359,7 +359,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) // viewer. Injected here (not in the registry) so it exists only // for ws.rs clients (web + mobile), never for the Telegram plugin. interface_tools: vec![ - crate::core::tools::show_file::make_tool( + skald_core::tools::show_file::make_tool( Arc::clone(skald.chat_hub()), source.clone(), ), @@ -418,7 +418,7 @@ fn is_resume_msg(text: &str) -> bool { /// Returns true if the message was an approval/rejection (caller should `continue`). async fn handle_approval_msg( text: &str, - chat_hub: &Arc, + chat_hub: &Arc, ) -> bool { let Ok(v) = serde_json::from_str::(text) else { return false }; let Some(request_id) = v["request_id"].as_i64() else { return false }; @@ -445,7 +445,7 @@ async fn handle_approval_msg( /// Returns true if the message was a question answer (caller should `continue`). async fn handle_question_answer_msg( text: &str, - handler: &Arc, + handler: &Arc, ) -> bool { let Ok(v) = serde_json::from_str::(text) else { return false }; if v["type"].as_str() != Some("answer_question") { return false } @@ -462,7 +462,7 @@ async fn handle_question_answer_msg( async fn handle_select_client_msg( text: &str, source: &str, - chat_hub: &Arc, + chat_hub: &Arc, ) -> bool { let Ok(v) = serde_json::from_str::(text) else { return false }; if v["type"].as_str() != Some("select_client") { return false } @@ -482,7 +482,7 @@ fn handle_data_msg(text: &str, skald: &Arc) -> bool { let Ok(v) = serde_json::from_str::(text) else { return false }; if v["type"].as_str() != Some("data") { return false } - let Ok(msg) = serde_json::from_value::(v) else { + let Ok(msg) = serde_json::from_value::(v) else { return true; }; @@ -495,7 +495,7 @@ fn handle_data_msg(text: &str, skald: &Arc) -> bool { if let (Some(lat), Some(lng)) = (lat, lng) { skald.location_manager().update( "remote", - crate::core::location::GpsCoord { latitude: lat, longitude: lng }, + skald_core::location::GpsCoord { latitude: lat, longitude: lng }, acc, live, ); diff --git a/src/frontend/api/ws_session.rs b/src/frontend/api/ws_session.rs index c4f405c..5d56ea7 100644 --- a/src/frontend/api/ws_session.rs +++ b/src/frontend/api/ws_session.rs @@ -10,7 +10,7 @@ use axum::{ use tokio::sync::broadcast; use tracing::{info, warn}; -use crate::core::skald::Skald; +use skald_core::skald::Skald; pub async fn handler( ws: WebSocketUpgrade, diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 4cb76c7..de36430 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -10,7 +10,7 @@ use tracing::{error, info}; use core_api::plugin::RouterFactory; use crate::frontend::config::FrontendConfig; -use crate::core::skald::Skald; +use skald_core::skald::Skald; use crate::frontend::server::{WebServer, WebServerHandle}; pub struct WebFrontend { @@ -63,7 +63,7 @@ impl WebFrontend { ); let handle = server.start(&addr).await?; info!(%addr, "server listening"); - crate::boot::ready(format!("Ready — http://localhost:{}", self.port)); + skald_core::boot::ready(format!("Ready — http://localhost:{}", self.port)); Ok(handle) } } diff --git a/src/frontend/server.rs b/src/frontend/server.rs index b20ba66..3e27c44 100644 --- a/src/frontend/server.rs +++ b/src/frontend/server.rs @@ -12,7 +12,7 @@ use axum::http::{HeaderValue, header}; use tower::ServiceBuilder; use crate::frontend::api; -use crate::core::skald::Skald; +use skald_core::skald::Skald; pub struct WebServer { static_dir: String, diff --git a/src/main.rs b/src/main.rs index db21c6c..a7265ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,13 @@ -mod boot; -mod core; +mod boot_format; #[cfg(feature = "desktop")] mod desktop; mod frontend; mod config; +// The core lives in `crates/skald-core`. This binary is one shell around it; +// `skald-setup` is another. +use skald_core::boot; + use std::io::IsTerminal; use std::sync::Arc; @@ -19,8 +22,8 @@ use tracing_subscriber::Layer; use core_api::plugin::Plugin; use config::Config; -use crate::core::db::{SYSTEM_DB_PATH, init_pool}; -use crate::core::skald::Skald; +use skald_core::db::{SYSTEM_DB_PATH, init_system_pool}; +use skald_core::skald::Skald; use crate::frontend::WebFrontend; use crate::frontend::server::WebServerHandle; @@ -89,7 +92,7 @@ fn init_logging() { // target filter makes it independent of RUST_LOG, so bootstrap always shows. // ANSI is enabled only on a real terminal. let boot_layer = tracing_subscriber::fmt::layer() - .event_format(boot::BootFormat) + .event_format(boot_format::BootFormat) .with_writer(std::io::stdout) .with_ansi(std::io::stdout().is_terminal()) .with_filter(Targets::new().with_target(boot::TARGET, LevelFilter::TRACE)); @@ -148,7 +151,7 @@ pub async fn run_backend() -> Result { let plugins = build_plugins(); - let pool = Arc::new(init_pool(SYSTEM_DB_PATH).await?); + let pool = Arc::new(init_system_pool(SYSTEM_DB_PATH).await?); info!(path = SYSTEM_DB_PATH, "database ready"); let skald = Skald::new(Arc::clone(&pool), &core_cfg, plugins).await?;