Compare commits

..
2 Commits
Author SHA1 Message Date
dguiducci a847dda88f feat(memory): dual-pool memory namespace, FTS search, and prompt injection
Add a virtual memory namespace backed by SQLite, surfaced through the
fs-tools, with private (per-user) and shared (system) stores.

Storage
- `memory_docs` owner table + external-content FTS5 index with sync triggers.
- `db/memory_docs.rs` accessor: get / upsert / list / search (bm25+snippet) / delete.

Routing (tools/fs)
- `classify_memory` splits paths on the raw first component; `..` clamps inside
  the store, never escaping to disk.
- read/write/list/edit/insert/replace/search_file route `user-memory/` to the
  owner pool and `shared-memory/` to the system pool (a singleton captured in
  `register_all`); every other path stays on disk. Each tool extracts a pure
  transform shared between its disk and memory paths.
- New `memory_search` tool over the FTS index (scope private/shared/all),
  with a sanitised FTS5 query. grep_files stays disk-only.

Approval
- `user-memory/*` allow (read+write); `shared-memory/*` reads allow,
  writes require approval so the agent can't silently push one person's data
  into shared memory. `memory_search` allowed via a path-less rule.
- migrate away the old `memory/*` and blanket `shared-memory/*` rows.

Prompt injection
- `MessageBuilder::load_inject_memory` reads `user-memory/` (owner pool) and
  `shared-memory/` (system pool) inject entries from SQLite; disk paths
  unchanged. The system pool is threaded ChatSessionManager -> handler ->
  MessageBuilder.
- main and project-coordinator inject `user-memory/index.md` +
  `shared-memory/index.md`; common/memory.md rewritten for the two stores.
2026-07-11 02:11:00 +01:00
dguiducci 5848829a92 telegram bot user isolation, config store, user context channels 2026-07-11 01:02:37 +01:00
42 changed files with 2428 additions and 673 deletions
+6 -2
View File
@@ -69,7 +69,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| `crates/skald-core/src/chat_hub/` | `ChatHub`: broadcast events to all connected WS clients | | `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/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/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/tools/` | Built-in tools: `exec`, `restart`, `list_agents`, `fs/*` (also route `user-memory/`/`shared-memory/` to `memory_docs` — see DB tables), `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/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/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend |
| `crates/skald-core/src/db/` | sqlx SQLite — see below | | `crates/skald-core/src/db/` | sqlx SQLite — see below |
@@ -100,10 +100,14 @@ Two rules keep the boundary real, and both are enforced by the compiler:
The schema is split into two buckets (§5.1), and the split is the point: The schema is split into two buckets (§5.1), and the split is the point:
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`. - **`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`. - **`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`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below.
**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. Two keys crossed and were fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model) and `project_tickets.job_id` (fixed by moving `projects`/`project_tickets` into the owner bucket). **No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. 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).
**Memory namespace (blueprint §5).** `memory_docs` (accessor `db/memory_docs.rs``get`/`upsert`/`list`/`search`(FTS)/`delete`) backs a virtual note store surfaced through the fs-tools, **not** the disk. Two sibling roots (not the blueprint's nested `memory/{userid}` + `memory/shared`): `user-memory/…` routes to the caller's own pool (`ToolContext::pool`), `shared-memory/…` to the system pool (a singleton captured in `fs::register_all`). `tools/fs/classify_memory()` decides on the raw first path component (a `..` in the tail clamps inside the store, never escapes to disk); `read_file`/`write_file`/`list_files`/`edit_file`/`insert_at_line`/`replace_lines`/`search_file` override `run_with` to route memory paths (each extracting a pure transform shared with its on-disk `execute`) and leave every other path on disk. Approval (seeded in `seed_fs_path_rules`): `user-memory/*` is `@fs_any allow` (private, frictionless); `shared-memory/*` is `@fs_read allow` + `@fs_write require` — reads free, **writes need approval** so the agent can't silently push one person's data into shared memory. `grep_files` stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, `memory_search` (`tools/fs/memory_search.rs`), over the `memory_docs` FTS index — allowed by a path-less rule (it takes `query`, not `path`).
**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `main` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
`system.db` currently gets **both** bucket functions, because nothing has migrated to per-user pools yet. That is transitional. `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. `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.
Generated
+417 -14
View File
@@ -88,6 +88,29 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "aquamarine"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
dependencies = [
"include_dir",
"itertools 0.10.5",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ar_archive_writer"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348"
dependencies = [
"object",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -639,6 +662,15 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -1109,13 +1141,34 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_more"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [
"derive_more-impl 1.0.0",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [ dependencies = [
"derive_more-impl", "derive_more-impl 2.1.1",
]
[[package]]
name = "derive_more-impl"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"unicode-xid",
] ]
[[package]] [[package]]
@@ -1276,6 +1329,16 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "dptree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db96968fcf52fe063a98c75df1d1f2b1fba304e7ae29b72fdc81c1165b7e2fd0"
dependencies = [
"colored",
"futures",
]
[[package]] [[package]]
name = "dtoa" name = "dtoa"
version = "1.0.11" version = "1.0.11"
@@ -1406,6 +1469,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erasable"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]] [[package]]
name = "erased-serde" name = "erased-serde"
version = "0.4.10" version = "0.4.10"
@@ -1859,11 +1932,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -2188,7 +2263,7 @@ checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
name = "honcho-client" name = "honcho-client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"tracing", "tracing",
@@ -2311,6 +2386,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.7",
] ]
[[package]] [[package]]
@@ -2487,6 +2563,25 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2919,7 +3014,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"tracing", "tracing",
@@ -2940,6 +3035,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "managed" name = "managed"
version = "0.8.0" version = "0.8.0"
@@ -2979,7 +3080,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -3478,6 +3579,15 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -3793,6 +3903,26 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -3835,7 +3965,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -3850,7 +3980,7 @@ dependencies = [
"async-trait", "async-trait",
"core-api", "core-api",
"parking_lot", "parking_lot",
"reqwest", "reqwest 0.13.4",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
@@ -3871,6 +4001,24 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "plugin-telegram-bot"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"core-api",
"rand 0.10.1",
"regex",
"serde",
"serde_json",
"teloxide",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "plugin-transcribe-whisper-local" name = "plugin-transcribe-whisper-local"
version = "0.1.0" version = "0.1.0"
@@ -3892,7 +4040,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest", "reqwest 0.13.4",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
@@ -3905,7 +4053,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest", "reqwest 0.13.4",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
@@ -4234,6 +4382,16 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "psm"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -4243,6 +4401,62 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.2",
"lru-slab",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -4350,12 +4564,30 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rc-box"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0"
dependencies = [
"erasable",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -4442,6 +4674,48 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -4481,10 +4755,19 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -4601,6 +4884,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -4770,7 +5054,7 @@ checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cssparser", "cssparser",
"derive_more", "derive_more 2.1.1",
"log", "log",
"new_debug_unreachable", "new_debug_unreachable",
"phf 0.13.1", "phf 0.13.1",
@@ -5124,10 +5408,11 @@ dependencies = [
"plugin-comfyui", "plugin-comfyui",
"plugin-elevenlabs", "plugin-elevenlabs",
"plugin-tailscale-remote", "plugin-tailscale-remote",
"plugin-telegram-bot",
"plugin-transcribe-whisper-local", "plugin-transcribe-whisper-local",
"plugin-tts-kokoro", "plugin-tts-kokoro",
"plugin-tts-orpheus-3b", "plugin-tts-orpheus-3b",
"reqwest", "reqwest 0.13.4",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
@@ -5174,7 +5459,7 @@ dependencies = [
"quote", "quote",
"rand 0.10.1", "rand 0.10.1",
"regex", "regex",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
@@ -5268,7 +5553,7 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"prost", "prost",
"rand 0.8.6", "rand 0.8.6",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
@@ -5576,6 +5861,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -5771,6 +6069,18 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "take_mut"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60"
[[package]]
name = "takecell"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e"
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.35.3" version = "0.35.3"
@@ -5858,7 +6168,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -6041,6 +6351,76 @@ dependencies = [
"toml 1.1.2+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]]
name = "teloxide"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84992abeed3ae42e8401b25d266d12bcba1def0abe59d22f6b9781167545f71e"
dependencies = [
"aquamarine",
"bytes",
"derive_more 1.0.0",
"dptree",
"either",
"futures",
"log",
"mime",
"pin-project",
"serde",
"serde_json",
"teloxide-core",
"teloxide-macros",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"url",
]
[[package]]
name = "teloxide-core"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7a34ca8e971fa892e633858c07547fe138ef4a02e4a4eaa1d35e517d6e0bc4"
dependencies = [
"bitflags 2.11.1",
"bytes",
"chrono",
"derive_more 1.0.0",
"either",
"futures",
"log",
"mime",
"once_cell",
"pin-project",
"rc-box",
"reqwest 0.12.28",
"rgb",
"serde",
"serde_json",
"serde_with",
"stacker",
"take_mut",
"takecell",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"url",
"uuid",
]
[[package]]
name = "teloxide-macros"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300fadcaf0c182f19b5ca10bf23a45dc9a48925f00c704405fd90ee2c03942f9"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -7554,6 +7934,19 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
@@ -7589,6 +7982,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.5" version = "0.2.5"
+2
View File
@@ -8,6 +8,7 @@ members = [
"crates/core-api", "crates/core-api",
"crates/mcp-client", "crates/mcp-client",
"crates/plugin-tailscale-remote", "crates/plugin-tailscale-remote",
"crates/plugin-telegram-bot",
"crates/plugin-transcribe-whisper-local", "crates/plugin-transcribe-whisper-local",
"crates/plugin-comfyui", "crates/plugin-comfyui",
"crates/plugin-tts-orpheus-3b", "crates/plugin-tts-orpheus-3b",
@@ -81,6 +82,7 @@ llm-client = { path = "crates/llm-client" }
core-api = { path = "crates/core-api" } core-api = { path = "crates/core-api" }
mcp-client = { path = "crates/mcp-client" } mcp-client = { path = "crates/mcp-client" }
plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" } plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" }
plugin-telegram-bot = { path = "crates/plugin-telegram-bot" }
plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true } plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true }
plugin-comfyui = { path = "crates/plugin-comfyui" } plugin-comfyui = { path = "crates/plugin-comfyui" }
plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" } plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" }
+30 -10
View File
@@ -1,20 +1,41 @@
# Persistent memory # Persistent memory
All memory lives in `data/memory/`. Entry point: `data/memory/index.md` — one line per file with a brief summary. You have two persistent note stores, kept as Markdown and searchable. **Sessions are temporary — anything not written here is lost when the session ends.** Save proactively.
- **`user-memory/`** — your **private** memory for this user. Nobody else can read it. Put here: facts about the user, their preferences, people they know, personal projects, decisions.
- **`shared-memory/`** — memory **shared with the whole group**. Every member can read it. Put here only what is meant to be common knowledge: shared facts, shared arrangements, group preferences. **Never** put one person's private information here. Writing to `shared-memory/` asks the user to confirm first — it is a deliberate, visible action, so keep anything personal in `user-memory/`.
When unsure where something belongs, prefer `user-memory/`.
## The indexes
Each store has an `index.md` — one line per note with a brief summary — and **both are injected into your context automatically** at the start of each session (look for them below):
- `user-memory/index.md` — your private notes.
- `shared-memory/index.md` — the group's shared notes.
Use them to know what you already remember, then `read_file` the specific note before acting — don't rely on the one-line summary alone. **Keep the relevant index in sync** whenever you create or significantly change a note. Updating `shared-memory/index.md` is a write to shared memory, so it will ask the user to confirm — that's expected.
## When to save ## When to save
Save **immediately** (do not postpone) when: Save **immediately** (do not postpone) when:
- The user shares a new fact about themselves, a project, a person, or a preference - The user shares a new fact about themselves, a project, a person, or a preference
- A decision is made that may be relevant in future sessions - A decision is made that may matter in a future session
- You notice an inconsistency with what was previously saved → correct it - You notice that something you saved before is now wrong → correct it
## When to read ## When to read
At the start of each session, read `data/memory/index.md` silently. Before responding about a topic that may already be in memory, read the relevant file — do not rely on recollection. Before responding about a topic that may already be in memory, look it up — do not rely on recollection:
## File format - The injected `user-memory/index.md` tells you what exists; `read_file` the note it points to.
- `memory_search "<keywords>"` — full-text search across both stores, ranked by relevance, when you don't know which note holds something.
## Organising notes
Use clear, topic-based paths — e.g. `user-memory/people/alice.md`, `user-memory/projects/website.md`, `shared-memory/wifi.md`. Keep one topic per note.
## Note format
```md ```md
# Title # Title
@@ -28,8 +49,7 @@ _Updated: YYYY-MM-DD_
## How to update ## How to update
1. `read_file` to get the exact current content 1. `read_file` the note to get its exact current content.
2. `edit_file` to modify — always keep the `_Updated: YYYY-MM-DD_` date in sync 2. `edit_file` to change part of it — keep the `_Updated:_` date in sync.
3. Use `write_file` only when creating a new file or fully rewriting one 3. Use `write_file` only to create a new note or fully rewrite one.
4. Keep `user-memory/index.md` in sync when you add or significantly change a note.
Always keep `data/memory/index.md` in sync when you create or significantly update a file.
+2 -4
View File
@@ -2,11 +2,9 @@
You are an extremely powerful general-purpose personal assistant. You help the user with any task — research, writing, planning, analysis, coding, or anything else they bring to you. You are an extremely powerful general-purpose personal assistant. You help the user with any task — research, writing, planning, analysis, coding, or anything else they bring to you.
Your personality and tone are defined in `data/memory/SOUL.md`. If the file exists, it is automatically injected into your system context — look for it at the end of this prompt.
Think outside the box: you can use tools, write and execute Python scripts on the fly, or even modify your own source code. Think outside the box: you can use tools, write and execute Python scripts on the fly, or even modify your own source code.
The `data/` directory (inside your working directory) is your own space — write there freely; you have permission to create and modify anything under it. **Default to `data/` for everything you produce**: generated files, notes, one-shot scripts, downloads, and persistent memory (e.g. `data/memory/`, `data/notifications.md`). When a path is relative, prefix it with `data/` — a bare filename lands in the project root, which is not where your working files belong. Write **outside** `data/` (the project root, `src/`, `web/`, `agents/`, config, …) only when a specific, well-defined goal genuinely requires it and cannot be accomplished within `data/`. The `data/` directory (inside your working directory) is your own space — write there freely; you have permission to create and modify anything under it. **Default to `data/` for everything you produce**: generated files, notes, one-shot scripts, downloads. When a path is relative, prefix it with `data/` — a bare filename lands in the project root, which is not where your working files belong. (Persistent **memory** is separate: durable facts go to `user-memory/` or `shared-memory/`, not under `data/` — see the Memory section.) Write **outside** `data/` (the project root, `src/`, `web/`, `agents/`, config, …) only when a specific, well-defined goal genuinely requires it and cannot be accomplished within `data/`.
You have access to tools, persistent memory system and sub agents. Use both proactively. Sub agents also help to keep your context windows small and concise. You have access to tools, persistent memory system and sub agents. Use both proactively. Sub agents also help to keep your context windows small and concise.
@@ -113,7 +111,7 @@ Configuration tools are hidden by default to keep context small. Call `activate_
## Memory reminder ## Memory reminder
Sessions are temporary — the user can close and start a new one at any moment. **Context alone is not enough.** If something is worth remembering, write it to a file in `data/memory/` immediately. If it stays only in context, it is gone forever when the session ends. Sessions are temporary — the user can close and start a new one at any moment. **Context alone is not enough.** If something is worth remembering, save it to `user-memory/` immediately (or `shared-memory/` if it's meant for the whole group). If it stays only in context, it is gone forever when the session ends.
--- ---
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"name": "Main Assistant", "name": "Main Assistant",
"description": "General-purpose assistant: helps the user with any task using tools, and persists all relevant information in data/memory", "description": "General-purpose assistant: helps the user with any task using tools, and persists all relevant information in memory",
"friendly_description": "Your general-purpose assistant — helps with any task and remembers what matters in memory.", "friendly_description": "Your general-purpose assistant — helps with any task and remembers what matters in memory.",
"type": "chat", "type": "chat",
"inject_memory": ["data/memory/index.md", "data/memory/SOUL.md"], "inject_memory": ["user-memory/index.md", "shared-memory/index.md"],
"icon": "icon.png", "icon": "icon.png",
"strength": "average" "strength": "average"
} }
+1 -1
View File
@@ -29,7 +29,7 @@ Delegate work to these task specialists via `execute_task` / `execute_subtask`:
Your system prompt already contains, without you asking: Your system prompt already contains, without you asking:
- The project's **name**, **description**, and **working directory** (the project root — all relative file paths resolve there). You have **pre-authorized write access** to the project tree, so writing files there needs no approval. - The project's **name**, **description**, and **working directory** (the project root — all relative file paths resolve there). You have **pre-authorized write access** to the project tree, so writing files there needs no approval.
- **`data/memory/index.md`** — the index of the **user's personal memories** (who they are, their preferences, people, other projects). It is injected automatically. Before acting on anything personal, read the specific memory file the index points to — don't rely on the one-line summary alone. - **`user-memory/index.md`** and **`shared-memory/index.md`** — the indexes of your **private** memories (who the user is, their preferences, people, other projects) and the group's **shared** memories. Both are injected automatically. Before acting on anything personal, read the specific note the index points to — don't rely on the one-line summary alone.
- **`SKALD.md`** at the project root — this project's **living diary** (see below). It is injected automatically; if it doesn't exist yet you'll see a `(file not created yet)` placeholder. - **`SKALD.md`** at the project root — this project's **living diary** (see below). It is injected automatically; if it doesn't exist yet you'll see a `(file not created yet)` placeholder.
Treat all of this as ground truth. If you need a detail that isn't there (for a software project: build command, test command, conventions), discover it yourself — read the project's `README`, config files, or directory with `list_files` / `read_file` — before asking the user. Treat all of this as ground truth. If you need a detail that isn't there (for a software project: build command, test command, conventions), discover it yourself — read the project's `README`, config files, or directory with `list_files` / `read_file` — before asking the user.
+1 -1
View File
@@ -5,6 +5,6 @@
"type": "chat", "type": "chat",
"scope": "reasoning", "scope": "reasoning",
"strength": "average", "strength": "average",
"inject_memory": ["data/memory/index.md", "$WD/SKALD.md"], "inject_memory": ["user-memory/index.md", "shared-memory/index.md", "$WD/SKALD.md"],
"icon": "icon.png" "icon": "icon.png"
} }
+14
View File
@@ -0,0 +1,14 @@
use anyhow::Result;
use async_trait::async_trait;
/// Read/write access to the instance-wide key/value config store
/// (`config` table in `system.db`).
///
/// [`ConfigApi::set`] emits `ConfigKeyUpdated` on the system bus when the
/// value changes, so subscribers (e.g. the Telegram plugin reloading its
/// bindings) are notified without polling.
#[async_trait]
pub trait ConfigApi: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<String>>;
async fn set(&self, key: &str, value: &str) -> Result<()>;
}
+2
View File
@@ -3,6 +3,7 @@ pub const APP_NAME: &str = "Skald";
pub mod approval; pub mod approval;
pub mod bus; pub mod bus;
pub mod config_api;
pub mod system_bus; pub mod system_bus;
pub mod chatbot; pub mod chatbot;
pub mod chat_hub; pub mod chat_hub;
@@ -18,6 +19,7 @@ pub mod plugin;
pub mod provider; pub mod provider;
pub mod remote; pub mod remote;
pub mod tool; pub mod tool;
pub mod user_channel;
pub mod secrets; pub mod secrets;
pub mod transcribe; pub mod transcribe;
pub mod tts; pub mod tts;
+9 -3
View File
@@ -5,12 +5,10 @@ use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::approval::ApprovalApi;
use crate::command::CommandApi; use crate::command::CommandApi;
use crate::config_api::ConfigApi;
use crate::system_bus::SystemEventBus; use crate::system_bus::SystemEventBus;
use crate::chat_hub::ChatHubApi;
use crate::image_generate::ImageGenerateRegistry; use crate::image_generate::ImageGenerateRegistry;
use crate::inbox::InboxApi;
use crate::location::LocationUpdater; use crate::location::LocationUpdater;
use crate::memory::Memory; use crate::memory::Memory;
use crate::provider::ApiProviderRegistry; use crate::provider::ApiProviderRegistry;
@@ -18,6 +16,7 @@ use crate::remote::RemoteAccess;
use crate::secrets::SecretsApi; use crate::secrets::SecretsApi;
use crate::transcribe::{TranscribeProvider, TranscribeRegistry}; use crate::transcribe::{TranscribeProvider, TranscribeRegistry};
use crate::tts::{TtsProvider, TtsRegistry}; use crate::tts::{TtsProvider, TtsRegistry};
use crate::user_channel::UserChannelApi;
/// Closure that builds a fresh Axum router (e.g. for the mesh-facing server). /// Closure that builds a fresh Axum router (e.g. for the mesh-facing server).
pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>; pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
@@ -33,6 +32,9 @@ pub struct PluginContext {
/// Custom file-based slash commands (`commands/<name>/`). Read-only from the /// Custom file-based slash commands (`commands/<name>/`). Read-only from the
/// plugin side — lets the Telegram bot resolve `/command` expansions. /// plugin side — lets the Telegram bot resolve `/command` expansions.
pub command: Arc<dyn CommandApi>, pub command: Arc<dyn CommandApi>,
/// Key/value config store (`config` table in `system.db`). `set` emits
/// `ConfigKeyUpdated` on the system bus.
pub config: Arc<dyn ConfigApi>,
/// Skald's shared SQLite pool — lets plugins create/use their own tables /// Skald's shared SQLite pool — lets plugins create/use their own tables
/// (e.g. `relay_*`) in the main DB. See plugin.md §12.1. /// (e.g. `relay_*`) in the main DB. See plugin.md §12.1.
pub db: Arc<sqlx::SqlitePool>, pub db: Arc<sqlx::SqlitePool>,
@@ -45,6 +47,10 @@ pub struct PluginContext {
pub api_provider_registry: Arc<dyn ApiProviderRegistry>, pub api_provider_registry: Arc<dyn ApiProviderRegistry>,
pub location: Arc<dyn LocationUpdater>, pub location: Arc<dyn LocationUpdater>,
pub system_bus: Arc<SystemEventBus>, pub system_bus: Arc<SystemEventBus>,
/// Channel-to-session resolver (blueprint §13). Lets channel plugins
/// (Telegram, mobile, …) look up an unlocked user's chat hub, approval
/// manager and event stream by user id.
pub user_channel: Arc<dyn UserChannelApi>,
pub web_port: u16, pub web_port: u16,
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>, pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
pub router_factory: RouterFactory, pub router_factory: RouterFactory,
+57
View File
@@ -0,0 +1,57 @@
//! Channel-to-session contract (blueprint §13).
//!
//! In the multi-user architecture, chat hubs, approval managers and event
//! streams are per-user (inside [`UserContext`]). External channels (Telegram,
//! mobile, …) need a way to resolve a user's owner-bound runtime at runtime,
//! without depending on the concrete `Skald` / `UserContext` types.
//!
//! [`UserChannelApi`] is the lookup seam: given a `user_id`, returns a
//! [`UserChannelHandle`] when the user's database is unlocked (§9), or `None`
//! when it is still locked. The handle exposes the per-user [`ChatHubApi`],
//! [`ApprovalApi`] and event stream — everything a channel adapter needs to
//! route a message and receive the response events.
//!
//! This is the "one contract" of §13: each channel (Telegram, mobile, …) is a
//! thin adapter over it, so N channel rewrites become 1 contract + N adapters.
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::broadcast;
use crate::approval::ApprovalApi;
use crate::chat_hub::ChatHubApi;
use crate::events::GlobalEvent;
/// Resolves an unlocked user's channel handle.
///
/// Implemented by the application core (`Skald`) and injected into
/// [`crate::plugin::PluginContext`] as `user_channel`.
#[async_trait]
pub trait UserChannelApi: Send + Sync {
/// Returns the user's handle if their database is unlocked in this boot
/// (§9: from first login until restart). `None` = locked — the caller
/// should prompt the user to log in.
async fn resolve_user(&self, user_id: &str) -> Option<Arc<dyn UserChannelHandle>>;
}
/// Handle to one unlocked user's owner-bound runtime.
///
/// Lifetime = the user's pool lifetime (§9). Cloning the returned `Arc`s is
/// cheap (they share the underlying state). The event receiver obtained from
/// [`UserChannelHandle::subscribe`] is independent per call — each subscriber
/// gets every future event.
pub trait UserChannelHandle: Send + Sync {
/// The opaque user id this handle belongs to.
fn user_id(&self) -> &str;
/// The user's chat hub — send messages, manage sessions, query context.
fn chat_hub(&self) -> Arc<dyn ChatHubApi>;
/// The user's approval manager — resolve pending tool-call approvals.
fn approval(&self) -> Arc<dyn ApprovalApi>;
/// Subscribe to the user's server→client event stream.
/// Events are scoped to this user; no cross-user leakage.
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent>;
}
+87 -95
View File
@@ -1,79 +1,91 @@
use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime;
use anyhow::Result;
use chrono::{DateTime, Local, Utc}; use chrono::{DateTime, Local, Utc};
use rand::RngExt; use rand::RngExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::ParseMode; use teloxide::types::ParseMode;
use tokio::time::Duration; use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{error, info}; use tracing::{error, info, warn};
use core_api::config_api::ConfigApi;
use core_api::system_bus::SystemEvent;
use super::TgShared; use super::TgShared;
// ── Whitelist file schema ───────────────────────────────────────────────────── /// Config-table key under which all Telegram bindings are stored as JSON.
// pub(crate) const CONFIG_KEY: &str = "telegram";
// Written to secrets/telegram_whitelist.json.
// The main agent edits this file directly to authorise users.
#[derive(Debug, Serialize, Deserialize, Default)] // ── Bindings schema (stored as JSON in the `config` table) ────────────────────
pub struct WhitelistFile {
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct TelegramConfig {
#[serde(default)] #[serde(default)]
pub whitelist: Vec<i64>, pub bindings: Vec<Binding>,
#[serde(default)] #[serde(default)]
pub pending_pairings: Vec<PairingEntry>, pub pending_pairings: Vec<PairingEntry>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PairingEntry { pub struct Binding {
pub code: String, pub chat_id: i64,
pub chat_id: i64, pub user_id: String,
pub issued_at: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<String>,
} }
pub(crate) async fn load_wl(secrets_dir: &Path) -> WhitelistFile { #[derive(Debug, Serialize, Deserialize, Clone)]
let path = secrets_dir.join("telegram_whitelist.json"); pub struct PairingEntry {
match tokio::fs::read_to_string(&path).await { pub code: String,
Ok(s) => serde_json::from_str(&s).unwrap_or_default(), pub chat_id: i64,
Err(_) => WhitelistFile::default(), pub issued_at: String,
}
// ── Config-table read/write ────────────────────────────────────────────────────
/// Reads the Telegram config from the `config` table. Returns `Default` when
/// the key is absent or unparseable (never fails the caller).
pub(crate) async fn load_config(config: &dyn ConfigApi) -> anyhow::Result<TelegramConfig> {
match config.get(CONFIG_KEY).await? {
Some(json) => Ok(serde_json::from_str(&json).unwrap_or_default()),
None => Ok(TelegramConfig::default()),
} }
} }
pub(crate) async fn save_wl(secrets_dir: &Path, wl: &WhitelistFile) -> Result<()> { /// Writes the Telegram config to the `config` table. `ConfigApi::set` emits a
tokio::fs::create_dir_all(secrets_dir).await?; /// `ConfigKeyUpdated` event when the value changes, so the in-memory cache and
let path = secrets_dir.join("telegram_whitelist.json"); /// any forwarders are updated automatically.
tokio::fs::write(&path, serde_json::to_string_pretty(wl)?).await?; pub(crate) async fn save_config(
Ok(()) config: &dyn ConfigApi,
cfg: &TelegramConfig,
) -> anyhow::Result<()> {
config.set(CONFIG_KEY, &serde_json::to_string_pretty(cfg)?).await
} }
// ── Pairing ─────────────────────────────────────────────────────────────────── // ── Pairing ───────────────────────────────────────────────────────────────────
/// Pairing codes older than this are considered abandoned and pruned, so the /// Pairing codes older than this are considered abandoned and pruned.
/// whitelist file does not accumulate stale `pending_pairings` entries.
const PAIRING_TTL_HOURS: i64 = 24; const PAIRING_TTL_HOURS: i64 = 24;
/// Called when an unbound `chat_id` sends a message. Generates (or reuses) a
/// pairing code, persists it to the config table, and replies with instructions.
pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) { pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
let mut wl = load_wl(&shared.secrets_dir).await; let mut cfg = shared.bindings.read().await.clone();
// Drop pairing codes past their TTL. Entries with an unparseable timestamp // Prune expired codes.
// are kept (don't silently lose data on a format change).
let cutoff = Utc::now() - chrono::Duration::hours(PAIRING_TTL_HOURS); let cutoff = Utc::now() - chrono::Duration::hours(PAIRING_TTL_HOURS);
let before = wl.pending_pairings.len(); cfg.pending_pairings.retain(|e| match DateTime::parse_from_rfc3339(&e.issued_at) {
wl.pending_pairings.retain(|e| match DateTime::parse_from_rfc3339(&e.issued_at) {
Ok(ts) => ts.with_timezone(&Utc) > cutoff, Ok(ts) => ts.with_timezone(&Utc) > cutoff,
Err(_) => true, Err(_) => true,
}); });
let pruned = wl.pending_pairings.len() != before;
// Re-use an existing (non-expired) code if one is already pending for this chat. // Reuse an existing code for this chat, or generate a new one.
let (code, added) = if let Some(entry) = wl.pending_pairings.iter().find(|e| e.chat_id == chat_id.0) { let (code, added) = if let Some(entry) = cfg.pending_pairings.iter().find(|e| e.chat_id == chat_id.0) {
(entry.code.clone(), false) (entry.code.clone(), false)
} else { } else {
let code = generate_code(); let code = generate_code();
wl.pending_pairings.push(PairingEntry { cfg.pending_pairings.push(PairingEntry {
code: code.clone(), code: code.clone(),
chat_id: chat_id.0, chat_id: chat_id.0,
issued_at: Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string(), issued_at: Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string(),
@@ -81,14 +93,16 @@ pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgSh
(code, true) (code, true)
}; };
// Persist if we added a new code or pruned expired ones.
if added || pruned {
if let Err(e) = save_wl(&shared.secrets_dir, &wl).await {
error!(error = %e, "telegram: failed to write whitelist file");
}
}
if added { if added {
info!(chat_id = chat_id.0, code = %code, "TELEGRAM PAIRING: code written to telegram_whitelist.json"); if let Err(e) = save_config(&*shared.config, &cfg).await {
error!(error = %e, "telegram: failed to write pairing to config table");
} else {
// Update the in-memory cache immediately (the config_listener will
// also fire, but this avoids a race if the user sends another
// message before the event arrives).
*shared.bindings.write().await = cfg.clone();
}
info!(chat_id = chat_id.0, code = %code, "TELEGRAM PAIRING: code written to config table");
} }
bot.send_message( bot.send_message(
@@ -96,7 +110,7 @@ pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgSh
format!( format!(
"🔐 <b>Pairing required.</b>\n\n\ "🔐 <b>Pairing required.</b>\n\n\
Code: <code>{code}</code>\n\n\ Code: <code>{code}</code>\n\n\
Provide this code to the web agent to authorize access.", Ask the admin to authorize this chat using the telegram_pairing tool.",
), ),
) )
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
@@ -110,60 +124,38 @@ pub(crate) fn generate_code() -> String {
(0..6).map(|_| CHARS[rng.random_range(0..CHARS.len())] as char).collect() (0..6).map(|_| CHARS[rng.random_range(0..CHARS.len())] as char).collect()
} }
// ── Whitelist watchdog ──────────────────────────────────────────────────────── // ── Config listener ────────────────────────────────────────────────────────────
//
// Polls telegram_whitelist.json every 10 s for mtime changes.
// When a new chat_id appears in `whitelist` (agent moved it from pending),
// sends a welcome message so the user knows they are authorized.
pub(crate) async fn whitelist_watchdog(bot: Bot, secrets_dir: PathBuf, cancel: CancellationToken) {
let path = secrets_dir.join("telegram_whitelist.json");
let mut last_mtime: Option<SystemTime> = tokio::fs::metadata(&path).await.ok()
.and_then(|m| m.modified().ok());
let mut known_wl = load_wl(&secrets_dir).await.whitelist;
let mut interval = tokio::time::interval(Duration::from_secs(10));
interval.tick().await; // skip the immediate first tick
/// Subscribes to the system bus and reloads the in-memory bindings whenever the
/// `"telegram"` config key changes. Replaces the old file-polling watchdog.
pub(crate) async fn config_listener(
shared: Arc<TgShared>,
mut rx: broadcast::Receiver<SystemEvent>,
cancel: CancellationToken,
) {
info!("telegram: config listener started");
loop { loop {
tokio::select! { tokio::select! {
_ = cancel.cancelled() => break, _ = cancel.cancelled() => {
_ = interval.tick() => { info!("telegram: config listener stopped");
let new_mtime = tokio::fs::metadata(&path).await.ok() return;
.and_then(|m| m.modified().ok()); }
if new_mtime.is_none() || new_mtime == last_mtime { result = rx.recv() => match result {
continue; Ok(SystemEvent::ConfigKeyUpdated { key, new_value, .. }) if key == CONFIG_KEY => {
} match serde_json::from_str::<TelegramConfig>(&new_value) {
last_mtime = new_mtime; Ok(cfg) => {
let n = cfg.bindings.len();
let wl = load_wl(&secrets_dir).await; *shared.bindings.write().await = cfg;
let newly_authorized: Vec<i64> = wl.whitelist.iter() info!(bindings = n, "telegram: bindings reloaded from config event");
.filter(|id| !known_wl.contains(id)) }
.cloned() Err(e) => warn!(error = %e, "telegram: failed to parse config from event"),
.collect();
if !newly_authorized.is_empty() {
info!(users = ?newly_authorized, "telegram: new users authorized — sending welcome");
for &chat_id in &newly_authorized {
bot.send_message(
ChatId(chat_id),
"✅ <b>Access granted!</b>\n\
You can now talk to your agent.\n\n\
/help for available commands.",
)
.parse_mode(ParseMode::Html)
.await
.ok();
} }
} }
Ok(_) => {}
known_wl = wl.whitelist; Err(broadcast::error::RecvError::Lagged(n)) => {
info!( warn!(skipped = n, "telegram: config listener lagged");
whitelist = known_wl.len(), }
pending = wl.pending_pairings.len(), Err(broadcast::error::RecvError::Closed) => return,
"telegram: whitelist file reloaded"
);
} }
} }
} }
+129 -106
View File
@@ -1,4 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, ParseMode}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, ParseMode};
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -6,17 +7,17 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use core_api::events::{GlobalEvent, ServerEvent}; use core_api::events::{GlobalEvent, ServerEvent};
use core_api::user_channel::UserChannelHandle;
use super::TgShared; use super::TgShared;
use super::auth::load_wl;
use super::helpers::{escape_html, label_to_html, send_long}; use super::helpers::{escape_html, label_to_html, send_long};
/// Sends an inline keyboard for an approval request and records the request.
/// Sends an inline keyboard for an approval request and records the request_id.
async fn send_approval_keyboard( async fn send_approval_keyboard(
bot: &Bot, bot: &Bot,
chat_id: ChatId, chat_id: ChatId,
text: String, text: String,
user_id: String,
request_id: i64, request_id: i64,
shared: &Arc<TgShared>, shared: &Arc<TgShared>,
) { ) {
@@ -37,101 +38,122 @@ async fn send_approval_keyboard(
.reply_markup(keyboard) .reply_markup(keyboard)
.await .await
{ {
Ok(m) => { shared.pending_approvals.lock().await.insert(m.id, request_id); } Ok(m) => {
shared.pending_approvals.lock().await.insert(
m.id,
super::PendingApproval { user_id, request_id },
);
}
Err(e) => error!(error = %e, "telegram: failed to send approval message"), Err(e) => error!(error = %e, "telegram: failed to send approval message"),
} }
} }
// ── Persistent background forwarder ────────────────────────────────────────── // ── Per-user forwarder ────────────────────────────────────────────────────────
/// Spawned once when the plugin starts. /// Spawns forwarders for all bound users whose contexts are already unlocked.
/// Stays subscribed to the "telegram" broadcast channel forever, forwarding /// Called at plugin start. Users who log in later get their forwarder spawned
/// events to the home chat_id. This is the only subscriber — per-message /// lazily on first incoming message.
/// subscriptions are not used — so it also catches background notifications pub(crate) async fn spawn_forwarders_for_bound_users(
/// that arrive without a user message triggering them. bot: &Bot,
/// shared: &Arc<TgShared>,
/// Re-subscribes immediately after each `Done`/`Error` so no events from the cancel: &CancellationToken,
/// next turn are missed. Safe because Tokio's cooperative scheduler guarantees
/// no other task runs between the re-subscription point and the next `await`,
/// and the processing mutex in `ChatSessionHandler` serialises turns.
pub(crate) async fn persistent_forwarder(
bot: Bot,
shared: Arc<TgShared>,
cancel: CancellationToken,
) { ) {
info!("telegram: persistent forwarder started"); let bindings = shared.bindings.read().await.clone();
for b in &bindings.bindings {
if let Some(handle) = shared.user_channel.resolve_user(&b.user_id).await {
ensure_forwarder(bot.clone(), Arc::clone(shared), &b.user_id, b.chat_id, handle, cancel.clone()).await;
}
}
}
let mut rx = shared.chat_hub.events("telegram"); /// Spawns a per-user forwarder if one is not already running for `user_id`.
/// The forwarder subscribes to the user's event stream and routes `ServerEvent`s
/// to the bound Telegram `chat_id`.
pub(crate) async fn ensure_forwarder(
bot: Bot,
shared: Arc<TgShared>,
user_id: &str,
chat_id: i64,
handle: Arc<dyn UserChannelHandle>,
cancel: CancellationToken,
) {
let mut forwarders = shared.forwarders.lock().await;
if forwarders.contains(user_id) {
return;
}
forwarders.insert(user_id.to_string());
let uid = user_id.to_string();
info!(user_id = %uid, chat_id, "telegram: spawning per-user forwarder");
let shared_c = Arc::clone(&shared);
let cancel_c = cancel.clone();
tokio::spawn(user_forwarder(bot, shared_c, uid, chat_id, handle, cancel_c));
}
/// One forwarder per unlocked user. Subscribes to the user's `global_tx` and
/// routes events to Telegram. Exits when the broadcast channel closes (user
/// context dropped at restart / lock) or the plugin is cancelled.
async fn user_forwarder(
bot: Bot,
shared: Arc<TgShared>,
user_id: String,
chat_id: i64,
handle: Arc<dyn UserChannelHandle>,
cancel: CancellationToken,
) {
let mut rx = handle.subscribe();
let tg_chat = ChatId(chat_id);
// Single loop: rx is updated in-place on Done/Error so we never miss events
// from the next turn (re-subscription happens before the async send).
loop { loop {
let ge: GlobalEvent = tokio::select! { let ge: GlobalEvent = tokio::select! {
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
info!("telegram: persistent forwarder stopped"); info!(user_id = %user_id, "telegram: forwarder cancelled");
return; break;
} }
result = rx.recv() => match result { result = rx.recv() => match result {
Ok(e) => e, Ok(e) => e,
Err(broadcast::error::RecvError::Lagged(n)) => { Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "telegram: persistent forwarder lagged"); warn!(user_id = %user_id, skipped = n, "telegram: forwarder lagged");
continue; continue;
} }
Err(broadcast::error::RecvError::Closed) => return, Err(broadcast::error::RecvError::Closed) => {
info!(user_id = %user_id, "telegram: forwarder — user context closed, exiting");
break;
}
}, },
}; };
// ApprovalResolved is handled regardless of source so Telegram removes its // ApprovalResolved is handled regardless of source so Telegram removes
// keyboard even when the approval was resolved via web or REST. // its keyboard even when the approval was resolved via web or REST.
if let ServerEvent::ApprovalResolved { request_id, approved, .. } = ge.event { if let ServerEvent::ApprovalResolved { request_id, .. } = ge.event {
let label = if approved { "✅ Approved" } else { "❌ Rejected" };
let mut pending = shared.pending_approvals.lock().await; let mut pending = shared.pending_approvals.lock().await;
if let Some((&msg_id, _)) = pending.iter().find(|(_, rid)| **rid == request_id) { if let Some((&msg_id, _)) = pending.iter().find(|(_, pa)| pa.request_id == request_id) {
let msg_id = msg_id; let msg_id = msg_id;
pending.remove(&msg_id); pending.remove(&msg_id);
drop(pending); drop(pending);
if let Some(cid) = resolve_chat_id(&shared).await { bot.delete_message(tg_chat, msg_id).await.ok();
bot.delete_message(cid, msg_id).await.ok();
}
} }
continue; continue;
} }
// All other events: only process if they belong to the "telegram" source. // Only process events from the "telegram" source.
if ge.source.as_deref() != Some("telegram") { if ge.source.as_deref() != Some("telegram") {
tracing::debug!(event_type = ge.event.type_name(), source = ?ge.source, "persistent_forwarder: skipping non-telegram event");
continue; continue;
} }
let event = ge.event; let event = ge.event;
tracing::debug!(event_type = event.type_name(), "persistent_forwarder: processing telegram event");
// Resolve the destination chat_id (last known user, or first in whitelist).
// For terminal events (Done/Error) with no known chat, still re-subscribe.
let chat_id = match resolve_chat_id(&shared).await {
Some(id) => id,
None => {
warn!(event_type = %event.type_name(), "telegram: persistent_forwarder — no chat_id resolved, dropping event");
if matches!(event, ServerEvent::Done { .. } | ServerEvent::Error { .. }) {
rx = shared.chat_hub.events("telegram");
}
continue;
}
};
match event { match event {
ServerEvent::Done { content, .. } => { ServerEvent::Done { content, .. } => {
// Re-subscribe BEFORE any await so we don't miss the next turn.
rx = shared.chat_hub.events("telegram");
if !content.trim().is_empty() { if !content.trim().is_empty() {
send_long(&bot, chat_id, &content, Some(ParseMode::Html)).await; send_long(&bot, tg_chat, &content, Some(ParseMode::Html)).await;
} }
} }
ServerEvent::Error { message } => { ServerEvent::Error { message } => {
rx = shared.chat_hub.events("telegram");
bot.send_message( bot.send_message(
chat_id, tg_chat,
format!("⚠️ <b>Error:</b> {}", escape_html(&message)), format!("⚠️ <b>Error:</b> {}", escape_html(&message)),
) )
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
@@ -140,7 +162,7 @@ pub(crate) async fn persistent_forwarder(
} }
ServerEvent::ToolStart { label_short, .. } => { ServerEvent::ToolStart { label_short, .. } => {
bot.send_message(chat_id, format!("🔧 <i>{}</i>…", label_to_html(&label_short))) bot.send_message(tg_chat, format!("🔧 <i>{}</i>…", label_to_html(&label_short)))
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
.await .await
.ok(); .ok();
@@ -148,7 +170,7 @@ pub(crate) async fn persistent_forwarder(
ServerEvent::Thinking { content, .. } => { ServerEvent::Thinking { content, .. } => {
if !content.trim().is_empty() { if !content.trim().is_empty() {
send_long(&bot, chat_id, &content, Some(ParseMode::Html)).await; send_long(&bot, tg_chat, &content, Some(ParseMode::Html)).await;
} }
} }
@@ -156,7 +178,7 @@ pub(crate) async fn persistent_forwarder(
let preview = prompt_preview.chars().take(300).collect::<String>(); let preview = prompt_preview.chars().take(300).collect::<String>();
let ellipsis = if prompt_preview.len() > 300 { "" } else { "" }; let ellipsis = if prompt_preview.len() > 300 { "" } else { "" };
bot.send_message( bot.send_message(
chat_id, tg_chat,
format!( format!(
"🤖 <b>{}</b> → <b>{}</b>\n<blockquote>{}{ellipsis}</blockquote>", "🤖 <b>{}</b> → <b>{}</b>\n<blockquote>{}{ellipsis}</blockquote>",
escape_html(&parent_agent_id), escape_html(&parent_agent_id),
@@ -173,7 +195,7 @@ pub(crate) async fn persistent_forwarder(
let preview = result_preview.chars().take(300).collect::<String>(); let preview = result_preview.chars().take(300).collect::<String>();
let ellipsis = if result_preview.len() > 300 { "" } else { "" }; let ellipsis = if result_preview.len() > 300 { "" } else { "" };
bot.send_message( bot.send_message(
chat_id, tg_chat,
format!( format!(
"✅ <b>{}</b> finished → <b>{}</b>\n<blockquote>{}{ellipsis}</blockquote>", "✅ <b>{}</b> finished → <b>{}</b>\n<blockquote>{}{ellipsis}</blockquote>",
escape_html(&agent_id), escape_html(&agent_id),
@@ -195,7 +217,7 @@ pub(crate) async fn persistent_forwarder(
escape_html(&path), escape_html(&path),
escape_html(&preview), escape_html(&preview),
); );
send_approval_keyboard(&bot, chat_id, text, request_id, &shared).await; send_approval_keyboard(&bot, tg_chat, text, user_id.clone(), request_id, &shared).await;
} }
ServerEvent::ApprovalRequired { request_id, tool_name, arguments, .. } => { ServerEvent::ApprovalRequired { request_id, tool_name, arguments, .. } => {
@@ -209,16 +231,15 @@ pub(crate) async fn persistent_forwarder(
escape_html(&tool_name), escape_html(&tool_name),
escape_html(&args_preview), escape_html(&args_preview),
); );
send_approval_keyboard(&bot, chat_id, text, request_id, &shared).await; send_approval_keyboard(&bot, tg_chat, text, user_id.clone(), request_id, &shared).await;
} }
ServerEvent::AgentQuestion { request_id, tool_call_id, title, question, suggested_answers, .. } => { ServerEvent::AgentQuestion { request_id, title, question, suggested_answers, .. } => {
info!(request_id, tool_call_id, %question, "telegram: persistent_forwarder received AgentQuestion"); info!(request_id, %question, "telegram: forwarder received AgentQuestion");
// If a previous question is still pending, disable its (now-dead) // Disable any previously-pending question for this chat.
// buttons so tapping them doesn't silently no-op. if let Some(prev) = shared.pending_questions.lock().await.remove(&chat_id) {
if let Some(prev) = shared.pending_question.lock().await.take() { bot.edit_message_reply_markup(tg_chat, prev.message_id)
bot.edit_message_reply_markup(chat_id, prev.message_id)
.reply_markup(InlineKeyboardMarkup::new(vec![vec![ .reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback("⏭ Superseded by a newer question", "noop"), InlineKeyboardButton::callback("⏭ Superseded by a newer question", "noop"),
]])) ]]))
@@ -240,27 +261,27 @@ pub(crate) async fn persistent_forwarder(
.collect(); .collect();
Some(InlineKeyboardMarkup::new(buttons)) Some(InlineKeyboardMarkup::new(buttons))
}; };
let mut req = bot.send_message(chat_id, header).parse_mode(ParseMode::Html); let mut req = bot.send_message(tg_chat, header).parse_mode(ParseMode::Html);
if let Some(kb) = keyboard { if let Some(kb) = keyboard {
req = req.reply_markup(kb); req = req.reply_markup(kb);
} }
match req.await { match req.await {
Ok(m) => { Ok(m) => {
info!(request_id, msg_id = m.id.0, "telegram: AgentQuestion sent to user, pending_question set"); shared.pending_questions.lock().await.insert(chat_id, super::PendingQuestion {
*shared.pending_question.lock().await = Some(super::PendingQuestion { user_id: user_id.clone(),
request_id, request_id,
message_id: m.id, message_id: m.id,
suggested_answers, suggested_answers,
}); });
} }
Err(e) => error!(error = %e, request_id, "telegram: failed to send AgentQuestion to user"), Err(e) => error!(error = %e, request_id, "telegram: failed to send AgentQuestion"),
} }
} }
ServerEvent::LlmFailed { tried, last_error } => { ServerEvent::LlmFailed { tried, last_error } => {
let models = tried.join(", "); let models = tried.join(", ");
bot.send_message( bot.send_message(
chat_id, tg_chat,
format!( format!(
"⚠️ <b>LLM unavailable</b>\nTried: <code>{}</code>\n{}", "⚠️ <b>LLM unavailable</b>\nTried: <code>{}</code>\n{}",
escape_html(&models), escape_html(&models),
@@ -277,23 +298,14 @@ pub(crate) async fn persistent_forwarder(
_ => {} _ => {}
} }
} }
}
/// Resolves the Telegram chat_id to use for outbound messages. // Clean up: remove this user from the active forwarders set.
/// Prefers the last chat_id that sent a message; falls back to the first shared.forwarders.lock().await.remove(&user_id);
/// whitelisted user. info!(user_id = %user_id, "telegram: forwarder exited");
async fn resolve_chat_id(shared: &TgShared) -> Option<ChatId> {
if let Some(id) = *shared.home_chat_id.lock().await {
return Some(id);
}
let wl = load_wl(&shared.secrets_dir).await;
wl.whitelist.first().map(|&id| ChatId(id))
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
/// Truncates `s` to at most `max_chars` Unicode scalar values.
/// Appends `…` if truncated. Never panics on multibyte UTF-8 content.
fn truncate_chars(s: &str, max_chars: usize) -> String { fn truncate_chars(s: &str, max_chars: usize) -> String {
let mut chars = s.chars(); let mut chars = s.chars();
let truncated: String = chars.by_ref().take(max_chars).collect(); let truncated: String = chars.by_ref().take(max_chars).collect();
@@ -333,14 +345,20 @@ pub(crate) async fn callback_handler(
let req_id = parts.next().and_then(|s| s.parse::<i64>().ok()); let req_id = parts.next().and_then(|s| s.parse::<i64>().ok());
let idx_str = parts.next().and_then(|s| s.parse::<usize>().ok()); let idx_str = parts.next().and_then(|s| s.parse::<usize>().ok());
if let (Some(request_id), Some(idx)) = (req_id, idx_str) { if let (Some(request_id), Some(idx)) = (req_id, idx_str) {
let mut pq = shared.pending_question.lock().await; let pq_map = shared.pending_questions.lock().await;
if let Some(pq_inner) = pq.as_ref() { if let Some(pq) = pq_map.get(&msg_chat_id.0) {
if pq_inner.request_id == request_id { if pq.request_id == request_id {
let answer = pq_inner.suggested_answers.get(idx).cloned().unwrap_or_default(); let user_id = pq.user_id.clone();
drop(pq); let answer = pq.suggested_answers.get(idx).cloned().unwrap_or_default();
*shared.pending_question.lock().await = None; drop(pq_map);
shared.chat_hub.resolve_question("telegram", request_id, answer.clone()).await; shared.pending_questions.lock().await.remove(&msg_chat_id.0);
info!(request_id, %answer, "telegram: clarification answered via button");
if let Some(handle) = shared.user_channel.resolve_user(&user_id).await {
handle.chat_hub().resolve_question("telegram", request_id, answer.clone()).await;
info!(request_id, %answer, "telegram: clarification answered via button");
} else {
warn!(user_id = %user_id, "telegram: user locked, cannot resolve clarification");
}
bot.edit_message_reply_markup(msg_chat_id, msg_id) bot.edit_message_reply_markup(msg_chat_id, msg_id)
.reply_markup(InlineKeyboardMarkup::new(vec![vec![ .reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback(format!("{answer}"), "noop"), InlineKeyboardButton::callback(format!("{answer}"), "noop"),
@@ -380,20 +398,25 @@ pub(crate) async fn callback_handler(
if let Some((request_id, action, label)) = parsed { if let Some((request_id, action, label)) = parsed {
let stored = shared.pending_approvals.lock().await.remove(&msg_id); let stored = shared.pending_approvals.lock().await.remove(&msg_id);
if let Some(stored_id) = stored { if let Some(pa) = stored {
if stored_id == request_id { if pa.request_id == request_id {
match action { if let Some(handle) = shared.user_channel.resolve_user(&pa.user_id).await {
ApprovalAction::Approve => let approval = handle.approval();
shared.approval.approve(request_id).await, match action {
ApprovalAction::Reject => ApprovalAction::Approve =>
shared.approval.reject(request_id, String::new()).await, approval.approve(request_id).await,
ApprovalAction::BypassTime(secs) => ApprovalAction::Reject =>
shared.approval.approve_with_bypass(request_id, Some(secs)).await, approval.reject(request_id, String::new()).await,
ApprovalAction::BypassSession => ApprovalAction::BypassTime(secs) =>
shared.approval.approve_with_bypass(request_id, None).await, approval.approve_with_bypass(request_id, Some(secs)).await,
ApprovalAction::BypassSession =>
approval.approve_with_bypass(request_id, None).await,
}
info!(request_id, label, "telegram: approval resolved");
bot.delete_message(msg_chat_id, msg_id).await.ok();
} else {
warn!(user_id = %pa.user_id, "telegram: user locked, cannot resolve approval");
} }
info!(request_id, label, "telegram: approval resolved");
bot.delete_message(msg_chat_id, msg_id).await.ok();
} }
} else { } else {
warn!(request_id, "telegram: approval not found (already resolved?)"); warn!(request_id, "telegram: approval not found (already resolved?)");
+89 -103
View File
@@ -8,11 +8,13 @@ use core_api::chat_hub::{ModelCommandOutcome, SendMessageOptions};
use core_api::command::expand_template; use core_api::command::expand_template;
use core_api::location::GpsCoord; use core_api::location::GpsCoord;
use core_api::message_meta::{CommandRef, MessageMetadata}; use core_api::message_meta::{CommandRef, MessageMetadata};
use core_api::user_channel::UserChannelHandle;
use super::TELEGRAM_FORMAT_CONTEXT; use super::TELEGRAM_FORMAT_CONTEXT;
use super::TgShared; use super::TgShared;
use super::attachments::TelegramAttachment; use super::attachments::TelegramAttachment;
use super::auth::{handle_pairing, load_wl}; use super::auth::handle_pairing;
use super::events::ensure_forwarder;
// ── Available commands help text (shared by /help and unknown-command replies) ── // ── Available commands help text (shared by /help and unknown-command replies) ──
const HELP_TEXT: &str = "<b>Available commands</b>\n\n\ const HELP_TEXT: &str = "<b>Available commands</b>\n\n\
@@ -28,9 +30,6 @@ const HELP_TEXT: &str = "<b>Available commands</b>\n\n\
/sethome — receive agent notifications here\n\ /sethome — receive agent notifications here\n\
/help — this message"; /help — this message";
/// Builds the `/help` text: the static system-command list plus a dynamically
/// discovered "Custom commands" section (`commands/<name>/`). Descriptions are
/// HTML-escaped since the message is sent with `ParseMode::Html`.
fn help_text(command: &dyn core_api::command::CommandApi) -> String { fn help_text(command: &dyn core_api::command::CommandApi) -> String {
let mut out = String::from(HELP_TEXT); let mut out = String::from(HELP_TEXT);
let cmds = command.list_enabled(); let cmds = command.list_enabled();
@@ -48,9 +47,6 @@ fn help_text(command: &dyn core_api::command::CommandApi) -> String {
} }
// ── Incoming message classification ─────────────────────────────────────────── // ── Incoming message classification ───────────────────────────────────────────
//
// To add a new media type: add a variant to IncomingEvent, handle it in
// classify_message, then dispatch it in message_handler.
pub(crate) enum IncomingEvent { pub(crate) enum IncomingEvent {
Text(String), Text(String),
@@ -93,13 +89,6 @@ pub(crate) fn classify_message(msg: &Message) -> Option<IncomingEvent> {
let text = msg.text()?; let text = msg.text()?;
// A command is any message that *starts* with '/'. We deliberately do NOT
// rely on teloxide's BotCommand entities: those are emitted for every
// "/token" anywhere in the text, so a normal sentence containing a "/path"
// (e.g. "stop /usr/bin/foo") would be misclassified as a command. A leading
// slash is the only signal. Arguments are parsed from the message `text`
// (not `entity.text()`, which spans only "/model" and would drop the arg).
// An unknown command is handled by the dispatcher, which replies with help.
if text.starts_with('/') { if text.starts_with('/') {
let full = text.trim_start_matches('/'); let full = text.trim_start_matches('/');
let mut parts = full.splitn(2, ' '); let mut parts = full.splitn(2, ' ');
@@ -126,31 +115,58 @@ pub(crate) async fn message_handler(
) -> ResponseResult<()> { ) -> ResponseResult<()> {
let chat_id = msg.chat.id; let chat_id = msg.chat.id;
// Whitelist check — re-read the file on every message so agent edits are // Resolve chat_id → user_id from bindings.
// picked up without a plugin restart. let user_id = match shared.user_for_chat(chat_id.0).await {
let wl = load_wl(&shared.secrets_dir).await; Some(uid) => uid,
if !wl.whitelist.contains(&chat_id.0) { None => {
handle_pairing(&bot, chat_id, &shared).await; handle_pairing(&bot, chat_id, &shared).await;
return Ok(()); return Ok(());
} }
};
// Track the last active chat_id so the persistent forwarder knows // Resolve the user's per-user context (must be unlocked, §9).
// where to send background notifications. let handle = match shared.user_channel.resolve_user(&user_id).await {
*shared.home_chat_id.lock().await = Some(chat_id); Some(h) => h,
None => {
bot.send_message(
chat_id,
"🔒 Your account is locked. Please log in via the web app first, \
then send another message.",
)
.await
.ok();
return Ok(());
}
};
// Ensure a per-user forwarder is running so the response events reach
// this Telegram chat. The forwarder will exit on broadcast-close (user
// locked) or plugin stop; a fresh CancellationToken is fine here since
// the global plugin cancel drops the dispatcher (and thus the shared Arc).
ensure_forwarder(
bot.clone(),
Arc::clone(&shared),
&user_id,
chat_id.0,
Arc::clone(&handle),
tokio_util::sync::CancellationToken::new(),
).await;
let Some(incoming) = classify_message(&msg) else { let Some(incoming) = classify_message(&msg) else {
bot.send_message(chat_id, "Unsupported message format.").await.ok(); bot.send_message(chat_id, "Unsupported message format.").await.ok();
return Ok(()); return Ok(());
}; };
let hub = handle.chat_hub();
match incoming { match incoming {
IncomingEvent::Command { ref name, .. } if name == "clear" || name == "new" => { IncomingEvent::Command { ref name, .. } if name == "clear" || name == "new" => {
handle_clear(&bot, chat_id, &shared).await; handle_clear(&bot, chat_id, &hub).await;
} }
IncomingEvent::Command { ref name, .. } if name == "sethome" => { IncomingEvent::Command { ref name, .. } if name == "sethome" => {
match shared.chat_hub.set_home("telegram").await { match hub.set_home("telegram").await {
Ok(_) => { Ok(_) => {
info!("telegram: set as home source"); info!("telegram: set as home source for user {}", user_id);
bot.send_message(chat_id, "🏠 Telegram set as <b>home</b>. Agent notifications will be delivered here.") bot.send_message(chat_id, "🏠 Telegram set as <b>home</b>. Agent notifications will be delivered here.")
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
.await .await
@@ -168,30 +184,28 @@ pub(crate) async fn message_handler(
.ok(); .ok();
} }
IncomingEvent::Command { ref name, .. } if name == "stop" => { IncomingEvent::Command { ref name, .. } if name == "stop" => {
handle_stop(&bot, chat_id, &shared).await; hub.cancel("telegram").await;
info!("telegram: agent cancelled via /stop");
bot.send_message(chat_id, "⏹ Agent stopped.").await.ok();
} }
IncomingEvent::Command { ref name, .. } if name == "context" => { IncomingEvent::Command { ref name, .. } if name == "context" => {
handle_context(&bot, chat_id, &shared).await; handle_context(&bot, chat_id, &hub).await;
} }
IncomingEvent::Command { ref name, .. } if name == "cost" => { IncomingEvent::Command { ref name, .. } if name == "cost" => {
handle_cost(&bot, chat_id, &shared).await; handle_cost(&bot, chat_id, &hub).await;
} }
IncomingEvent::Command { ref name, .. } if name == "compact" => { IncomingEvent::Command { ref name, .. } if name == "compact" => {
handle_compact(&bot, chat_id, &shared).await; handle_compact(&bot, chat_id, &hub).await;
} }
IncomingEvent::Command { ref name, .. } if name == "resettools" => { IncomingEvent::Command { ref name, .. } if name == "resettools" => {
handle_reset_mcp(&bot, chat_id, &shared).await; handle_reset_mcp(&bot, chat_id, &hub).await;
} }
IncomingEvent::Command { ref name, .. } if name == "models" => { IncomingEvent::Command { ref name, .. } if name == "models" => {
handle_list_models(&bot, chat_id, &shared).await; handle_list_models(&bot, chat_id, &hub).await;
} }
IncomingEvent::Command { ref name, ref args, .. } if name == "model" => { IncomingEvent::Command { ref name, ref args, .. } if name == "model" => {
handle_set_model(&bot, chat_id, args, &shared).await; handle_set_model(&bot, chat_id, args, &hub).await;
} }
// A recognised custom `/command` expands its `COMMAND.md` template into a
// normal user message (fully interactive: the model can then ask questions,
// iterate, dispatch sub-agents). Any other `/...` is an unknown command and
// is never forwarded to the LLM — reply with a not-found notice + help.
IncomingEvent::Command { ref name, ref args, .. } => { IncomingEvent::Command { ref name, ref args, .. } => {
if let Some(resolved) = shared.command.resolve(name) { if let Some(resolved) = shared.command.resolve(name) {
let args_str = args.join(" "); let args_str = args.join(" ");
@@ -208,7 +222,7 @@ pub(crate) async fn message_handler(
}), }),
..Default::default() ..Default::default()
}; };
handle_llm_message(bot, chat_id, content, Some(metadata), shared).await; handle_llm_message(bot, chat_id, content, Some(metadata), shared, &handle).await;
} else { } else {
bot.send_message( bot.send_message(
chat_id, chat_id,
@@ -220,10 +234,10 @@ pub(crate) async fn message_handler(
} }
} }
IncomingEvent::Voice { file_id } => { IncomingEvent::Voice { file_id } => {
handle_voice(&bot, chat_id, file_id, &shared).await; handle_voice(&bot, chat_id, file_id, &shared, &handle).await;
} }
IncomingEvent::Attachment(attachment) => { IncomingEvent::Attachment(attachment) => {
handle_attachment(bot, chat_id, attachment, shared).await; handle_attachment(bot, chat_id, attachment, shared, &handle).await;
} }
_ => { _ => {
let text = match &incoming { let text = match &incoming {
@@ -233,14 +247,15 @@ pub(crate) async fn message_handler(
| IncomingEvent::Attachment(_) => unreachable!(), | IncomingEvent::Attachment(_) => unreachable!(),
}; };
// If a clarification question is pending, treat any text as the answer. // If a clarification question is pending for this chat, treat any
// text as the answer.
{ {
let mut pq = shared.pending_question.lock().await; let mut pq_map = shared.pending_questions.lock().await;
if let Some(pq_inner) = pq.take() { if let Some(pq) = pq_map.remove(&chat_id.0) {
let request_id = pq_inner.request_id; let request_id = pq.request_id;
let question_msg_id = pq_inner.message_id; let question_msg_id = pq.message_id;
drop(pq); drop(pq_map);
shared.chat_hub.resolve_question("telegram", request_id, text.clone()).await; hub.resolve_question("telegram", request_id, text.clone()).await;
tracing::info!(request_id, %text, "telegram: clarification answered via text"); tracing::info!(request_id, %text, "telegram: clarification answered via text");
bot.edit_message_reply_markup(chat_id, question_msg_id) bot.edit_message_reply_markup(chat_id, question_msg_id)
.reply_markup(teloxide::types::InlineKeyboardMarkup::new(vec![vec![ .reply_markup(teloxide::types::InlineKeyboardMarkup::new(vec![vec![
@@ -255,17 +270,17 @@ pub(crate) async fn message_handler(
} }
} }
handle_llm_message(bot, chat_id, text, None, shared).await; handle_llm_message(bot, chat_id, text, None, shared, &handle).await;
} }
} }
Ok(()) Ok(())
} }
// ── /clear command ──────────────────────────────────────────────────────────── // ── Command handlers ──────────────────────────────────────────────────────────
async fn handle_clear(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) { async fn handle_clear(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match shared.chat_hub.clear("telegram").await { match hub.clear("telegram").await {
Ok(_) => { Ok(_) => {
info!("telegram: session cleared via /clear"); info!("telegram: session cleared via /clear");
bot.send_message(chat_id, "🆕 New conversation started.").await.ok(); bot.send_message(chat_id, "🆕 New conversation started.").await.ok();
@@ -277,10 +292,8 @@ async fn handle_clear(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
} }
} }
// ── /context command ────────────────────────────────────────────────────────── async fn handle_context(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.context_info("telegram").await {
async fn handle_context(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.context_info("telegram").await {
Ok((input, output)) => { Ok((input, output)) => {
let input_str = input.map_or("?".to_string(), |t| t.to_string()); let input_str = input.map_or("?".to_string(), |t| t.to_string());
let output_str = output.map_or("?".to_string(), |t| t.to_string()); let output_str = output.map_or("?".to_string(), |t| t.to_string());
@@ -298,10 +311,8 @@ async fn handle_context(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
} }
} }
// ── /cost command ───────────────────────────────────────────────────────────── async fn handle_cost(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.cost_info("telegram").await {
async fn handle_cost(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.cost_info("telegram").await {
Ok(Some(c)) => { Ok(Some(c)) => {
bot.send_message(chat_id, format!("💰 Session cost: ${c:.4}")).await.ok(); bot.send_message(chat_id, format!("💰 Session cost: ${c:.4}")).await.ok();
} }
@@ -314,10 +325,8 @@ async fn handle_cost(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
} }
} }
// ── /compact command ────────────────────────────────────────────────────────── async fn handle_compact(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.force_compact("telegram").await {
async fn handle_compact(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.force_compact("telegram").await {
Ok(true) => { Ok(true) => {
info!("telegram: manual compaction succeeded"); info!("telegram: manual compaction succeeded");
bot.send_message(chat_id, "✅ Context compacted.").await.ok(); bot.send_message(chat_id, "✅ Context compacted.").await.ok();
@@ -332,10 +341,8 @@ async fn handle_compact(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
} }
} }
// ── /resettools command ─────────────────────────────────────────────────────── async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.reset_mcp("telegram").await {
async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.reset_mcp("telegram").await {
Ok(()) => { Ok(()) => {
info!("telegram: tool-group grants reset via /resettools"); info!("telegram: tool-group grants reset via /resettools");
bot.send_message(chat_id, "✅ Activated tool groups removed from the session.").await.ok(); bot.send_message(chat_id, "✅ Activated tool groups removed from the session.").await.ok();
@@ -347,23 +354,8 @@ async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
} }
} }
// ── /stop command ──────────────────────────────────────────────────────────── async fn handle_list_models(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
let items = hub.list_clients_marked("telegram").await;
async fn handle_stop(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
shared.chat_hub.cancel("telegram").await;
info!("telegram: agent cancelled via /stop");
bot.send_message(chat_id, "⏹ Agent stopped.").await.ok();
}
// ── /models and /model commands ──────────────────────────────────────────────
//
// Business logic (resolve arg, mutate pin, broadcast) lives in
// `ChatHub::apply_model_command` / `ChatHub::list_clients_marked`. Here we only
// format for Telegram (HTML) and send via the bot — same pattern the web WS
// handler uses with Markdown.
async fn handle_list_models(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
let items = shared.chat_hub.list_clients_marked("telegram").await;
let mut text = String::from("<b>Available models</b>\n\n"); let mut text = String::from("<b>Available models</b>\n\n");
for (i, name, is_current) in &items { for (i, name, is_current) in &items {
let marker = if *is_current { "" } else { "" }; let marker = if *is_current { "" } else { "" };
@@ -381,9 +373,9 @@ async fn handle_list_models(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>)
.ok(); .ok();
} }
async fn handle_set_model(bot: &Bot, chat_id: ChatId, args: &[String], shared: &Arc<TgShared>) { async fn handle_set_model(bot: &Bot, chat_id: ChatId, args: &[String], hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
let arg = args.first().cloned().unwrap_or_default(); let arg = args.first().cloned().unwrap_or_default();
let outcome = shared.chat_hub.apply_model_command("telegram", &arg).await; let outcome = hub.apply_model_command("telegram", &arg).await;
let text = match outcome { let text = match outcome {
ModelCommandOutcome::Set(name) => format!("✅ Model set: <b>{}</b>", super::helpers::escape_html(&name)), ModelCommandOutcome::Set(name) => format!("✅ Model set: <b>{}</b>", super::helpers::escape_html(&name)),
ModelCommandOutcome::Cleared => "✅ Model reset to <b>auto</b>.".to_string(), ModelCommandOutcome::Cleared => "✅ Model reset to <b>auto</b>.".to_string(),
@@ -403,25 +395,22 @@ async fn handle_llm_message(
text: String, text: String,
metadata: Option<MessageMetadata>, metadata: Option<MessageMetadata>,
shared: Arc<TgShared>, shared: Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) { ) {
bot.send_chat_action(chat_id, ChatAction::Typing).await.ok(); bot.send_chat_action(chat_id, ChatAction::Typing).await.ok();
// The persistent_forwarder (spawned once in start()) is always subscribed let hub = handle.chat_hub();
// to the "telegram" broadcast channel and will pick up all events for this let client_name = hub.get_selected_client("telegram").await;
// turn — including Done → send to Telegram. No per-message subscription needed.
let client_name = shared.chat_hub.get_selected_client("telegram").await;
let opts = SendMessageOptions { let opts = SendMessageOptions {
client_name, client_name,
extra_system_context: Some(TELEGRAM_FORMAT_CONTEXT.to_string()), extra_system_context: Some(TELEGRAM_FORMAT_CONTEXT.to_string()),
tail_reminder: Some(super::TELEGRAM_FORMAT_REMINDER.to_string()), tail_reminder: Some(super::TELEGRAM_FORMAT_REMINDER.to_string()),
interface_tools: super::tools::interface_tools(bot, chat_id, &*shared.tts).await, interface_tools: super::tools::interface_tools(bot.clone(), chat_id, &*shared.tts).await,
metadata, metadata,
..Default::default() ..Default::default()
}; };
// send_message only enqueues — the turn runs on ChatHub's per-source consumer — if let Err(e) = hub.send_message("telegram", &text, opts).await {
// so awaiting inline keeps this message handler responsive.
if let Err(e) = shared.chat_hub.send_message("telegram", &text, opts).await {
error!(error = %e, "telegram: enqueue error"); error!(error = %e, "telegram: enqueue error");
} }
} }
@@ -433,6 +422,7 @@ async fn handle_voice(
chat_id: ChatId, chat_id: ChatId,
file_id: String, file_id: String,
shared: &Arc<TgShared>, shared: &Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) { ) {
use teloxide::net::Download; use teloxide::net::Download;
@@ -477,7 +467,7 @@ async fn handle_voice(
The user sent a voice message. The following is the audio transcript:\n\n\ The user sent a voice message. The following is the audio transcript:\n\n\
{text}" {text}"
); );
handle_llm_message(bot.clone(), chat_id, message, None, Arc::clone(shared)).await; handle_llm_message(bot.clone(), chat_id, message, None, Arc::clone(shared), handle).await;
} }
// ── Edited message (live location updates) ──────────────────────────────────── // ── Edited message (live location updates) ────────────────────────────────────
@@ -500,8 +490,8 @@ async fn handle_attachment(
chat_id: ChatId, chat_id: ChatId,
attachment: TelegramAttachment, attachment: TelegramAttachment,
shared: Arc<TgShared>, shared: Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) { ) {
// Update LocationManager immediately, before any LLM dispatch.
if let TelegramAttachment::Location { latitude, longitude, accuracy, is_live } = &attachment { if let TelegramAttachment::Location { latitude, longitude, accuracy, is_live } = &attachment {
let coord = GpsCoord { latitude: *latitude, longitude: *longitude }; let coord = GpsCoord { latitude: *latitude, longitude: *longitude };
shared.location.update("telegram", coord, *accuracy, *is_live); shared.location.update("telegram", coord, *accuracy, *is_live);
@@ -519,9 +509,6 @@ async fn handle_attachment(
}; };
match saved { match saved {
// Document / Photo: carry the file as structured metadata (rendered as a
// chip in the copilot UI; the LLM gets the shared [SYSTEM INFO] block).
// The caption, if any, becomes the user's text for this turn.
Some(att) => { Some(att) => {
info!(chat_id = chat_id.0, path = %att.path, "telegram: attachment saved, forwarding to LLM"); info!(chat_id = chat_id.0, path = %att.path, "telegram: attachment saved, forwarding to LLM");
let caption = match &attachment { let caption = match &attachment {
@@ -530,12 +517,11 @@ async fn handle_attachment(
TelegramAttachment::Location { .. } => None, TelegramAttachment::Location { .. } => None,
}.unwrap_or_default(); }.unwrap_or_default();
let metadata = MessageMetadata { attachments: vec![att], ..Default::default() }; let metadata = MessageMetadata { attachments: vec![att], ..Default::default() };
handle_llm_message(bot, chat_id, caption, Some(metadata), shared).await; handle_llm_message(bot, chat_id, caption, Some(metadata), shared, handle).await;
} }
// Location (no file): keep the textual system-info block.
None => { None => {
let message = attachment.system_info_message(None); let message = attachment.system_info_message(None);
handle_llm_message(bot, chat_id, message, None, shared).await; handle_llm_message(bot, chat_id, message, None, shared, handle).await;
} }
} }
} }
+123 -79
View File
@@ -1,22 +1,29 @@
/// Telegram plugin — connects the Skald LLM to a private Telegram bot. /// Telegram plugin — connects the Skald LLM to a private Telegram bot.
/// ///
/// # Multi-user architecture (blueprint §13)
///
/// One bot serves many Telegram chats, each bound to a Skald user via the
/// `chat_id ↔ user_id` pairing stored in the config table (key `"telegram"`).
/// Incoming messages resolve the user's per-user context via
/// [`UserChannelApi`], then dispatch through that user's `ChatHub`. A per-user
/// forwarder subscribes to the user's event stream and routes `ServerEvent`s
/// back to the bound Telegram chat.
///
/// # Pairing /// # Pairing
/// Unknown users receive a pairing code in chat. The code is also written to ///
/// `secrets/telegram_whitelist.json` under `pending_pairings`. The main agent /// Unknown chats receive a pairing code. The admin's agent calls the
/// (via `read_file` / `write_file`) can inspect that file and move the /// `telegram_pairing` tool (category `Config`) to bind the `chat_id` to a
/// `chat_id` into the `whitelist` array to complete the authorisation — no /// `user_id`. The binding is written to the config table; the resulting
/// code changes required, just a file edit. /// `ConfigKeyUpdated` event reloads the in-memory cache instantly.
/// ///
/// # Human-in-the-loop approvals /// # Human-in-the-loop approvals
/// Tool calls requiring approval emit a `PendingWrite` event; the plugin
/// forwards it to Telegram as an inline-keyboard message with
/// [✅ Approve] [❌ Reject] / [⏱ 15 min] [🔄 Session] buttons.
/// ///
/// # Adding new message types /// Tool calls requiring approval emit a `PendingWrite` / `ApprovalRequired`
/// 1. Add a variant to `IncomingEvent` in `handlers.rs`. /// event; the per-user forwarder sends it to Telegram as an inline-keyboard
/// 2. Handle it in `classify_message` (same file). /// message. Button presses resolve the approval through that user's
/// 3. Dispatch it in `message_handler` (same file). /// `ApprovalApi`.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -26,18 +33,18 @@ use async_trait::async_trait;
use serde_json::{Value, json}; use serde_json::{Value, json};
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::MessageId; use teloxide::types::MessageId;
use tokio::sync::Mutex; use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{info, warn}; use tracing::{info, warn};
use core_api::approval::ApprovalApi;
use core_api::chat_hub::ChatHubApi;
use core_api::command::CommandApi; use core_api::command::CommandApi;
use core_api::config_api::ConfigApi;
use core_api::location::LocationUpdater; use core_api::location::LocationUpdater;
use core_api::plugin::{Plugin, PluginContext}; use core_api::plugin::{Plugin, PluginContext};
use core_api::transcribe::{Transcribe, TranscribeProvider}; use core_api::transcribe::TranscribeProvider;
use core_api::tts::TtsProvider; use core_api::tts::TtsProvider;
use core_api::user_channel::UserChannelApi;
mod attachments; mod attachments;
mod auth; mod auth;
@@ -56,7 +63,6 @@ FORBIDDEN (will appear as raw symbols): ** * _ ` # | and Markdown tables.\n\
• Headers → <b>text</b>\n\ • Headers → <b>text</b>\n\
• Structured data → bullet lists with •, never | tables\n\ • Structured data → bullet lists with •, never | tables\n\
• Escape & < > as &amp; &lt; &gt;"; • Escape & < > as &amp; &lt; &gt;";
/// Short reminder injected near the end of the message list to counter /// Short reminder injected near the end of the message list to counter
/// instruction drift in long conversations. /// instruction drift in long conversations.
pub(crate) const TELEGRAM_FORMAT_REMINDER: &str = "\ pub(crate) const TELEGRAM_FORMAT_REMINDER: &str = "\
@@ -67,61 +73,90 @@ No Markdown: no ** * _ ` # |. No tables — use bullet lists.";
/// A pending `ask_user_clarification` question waiting for the user's reply. /// A pending `ask_user_clarification` question waiting for the user's reply.
pub(crate) struct PendingQuestion { pub(crate) struct PendingQuestion {
pub(crate) request_id: i64, pub(crate) user_id: String,
pub(crate) message_id: MessageId, pub(crate) request_id: i64,
pub(crate) message_id: MessageId,
/// Suggested answers (used to resolve the selection when the user taps a button). /// Suggested answers (used to resolve the selection when the user taps a button).
pub(crate) suggested_answers: Vec<String>, pub(crate) suggested_answers: Vec<String>,
} }
/// A pending tool-call approval shown as an inline keyboard.
pub(crate) struct PendingApproval {
pub(crate) user_id: String,
pub(crate) request_id: i64,
}
/// Global state shared across all Telegram handlers and the per-user forwarders.
///
/// Per-user state (ChatHub, ApprovalApi, event stream) is resolved at runtime
/// via [`UserChannelApi`] — it is NOT held here. Only global capabilities and
/// pairing/multiplexing state live in `TgShared`.
pub(crate) struct TgShared { pub(crate) struct TgShared {
pub(crate) chat_hub: Arc<dyn ChatHubApi>, // ── Global capabilities ──
/// Custom slash-command resolver (`commands/<name>/`). Read-only: lets the pub(crate) user_channel: Arc<dyn UserChannelApi>,
/// bot expand a recognised `/command` into a template before forwarding it to pub(crate) command: Arc<dyn CommandApi>,
/// the LLM, mirroring the WS handler. pub(crate) config: Arc<dyn ConfigApi>,
pub(crate) command: Arc<dyn CommandApi>, pub(crate) transcribe: Arc<dyn TranscribeProvider>,
pub(crate) approval: Arc<dyn ApprovalApi>, pub(crate) tts: Arc<dyn TtsProvider>,
pub(crate) transcribe: Arc<dyn TranscribeProvider>, pub(crate) location: Arc<dyn LocationUpdater>,
pub(crate) tts: Arc<dyn TtsProvider>, pub(crate) uploads_dir: PathBuf,
pub(crate) location: Arc<dyn LocationUpdater>,
/// MessageId of the approval message → request_id. // ── Pairing / bindings (config-table-backed, cached in memory) ──
pub(crate) pending_approvals: Mutex<HashMap<MessageId, i64>>, pub(crate) bindings: RwLock<auth::TelegramConfig>,
/// Currently active clarification question (at most one at a time per session).
pub(crate) pending_question: Mutex<Option<PendingQuestion>>, // ── Per-chat pending state ──
pub(crate) secrets_dir: PathBuf, /// Approval message_id → pending approval (carries user_id for routing).
/// Base directory for file attachments: `<data_root>/uploads/telegram/`. pub(crate) pending_approvals: Mutex<HashMap<MessageId, PendingApproval>>,
pub(crate) uploads_dir: PathBuf, /// chat_id → pending clarification question (at most one per chat).
/// Last chat_id that sent a message — used as the target for background notifications. pub(crate) pending_questions: Mutex<HashMap<i64, PendingQuestion>>,
/// Set on every incoming message; read by the persistent event forwarder.
pub(crate) home_chat_id: Mutex<Option<ChatId>>, // ── Forwarder tracking ──
/// user_ids with an active per-user forwarder task.
pub(crate) forwarders: Mutex<HashSet<String>>,
} }
impl TgShared { impl TgShared {
pub(crate) async fn transcriber(&self) -> Option<Arc<dyn Transcribe>> { pub(crate) async fn transcriber(&self) -> Option<Arc<dyn core_api::transcribe::Transcribe>> {
self.transcribe.get().await self.transcribe.get().await
} }
/// Looks up the `user_id` bound to a Telegram `chat_id`, if any.
pub(crate) async fn user_for_chat(&self, chat_id: i64) -> Option<String> {
self.bindings.read().await
.bindings.iter()
.find(|b| b.chat_id == chat_id)
.map(|b| b.user_id.clone())
}
} }
// ── Plugin struct ───────────────────────────────────────────────────────────── // ── Plugin struct ─────────────────────────────────────────────────────────────
pub struct TelegramPlugin { pub struct TelegramPlugin {
secrets_dir: PathBuf,
/// Bot token — set by reload() before start() is called. /// Bot token — set by reload() before start() is called.
token: Mutex<String>, token: Mutex<String>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cancel: Mutex<Option<CancellationToken>>, cancel: Mutex<Option<CancellationToken>>,
handle: Mutex<Option<JoinHandle<()>>>, handle: Mutex<Option<JoinHandle<()>>>,
/// Runtime shared state, populated by `start()`. Accessible to the pairing
/// tool so it can write bindings before/after the dispatcher is running.
shared: std::sync::OnceLock<Arc<TgShared>>,
} }
impl TelegramPlugin { impl TelegramPlugin {
pub fn new(secrets_dir: impl Into<PathBuf>) -> Self { pub fn new() -> Self {
Self { Self {
secrets_dir: secrets_dir.into(), token: Mutex::new(String::new()),
token: Mutex::new(String::new()), running: Arc::new(AtomicBool::new(false)),
running: Arc::new(AtomicBool::new(false)), cancel: Mutex::new(None),
cancel: Mutex::new(None), handle: Mutex::new(None),
handle: Mutex::new(None), shared: std::sync::OnceLock::new(),
} }
} }
/// Returns the shared runtime state if the plugin is running.
pub(crate) fn shared(&self) -> Option<&Arc<TgShared>> {
self.shared.get()
}
} }
#[async_trait] #[async_trait]
@@ -149,7 +184,6 @@ impl Plugin for TelegramPlugin {
} }
fn as_any(&self) -> &dyn std::any::Any { self } fn as_any(&self) -> &dyn std::any::Any { self }
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self } fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> { async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
@@ -189,49 +223,59 @@ impl Plugin for TelegramPlugin {
anyhow::bail!("telegram: token is empty — set it via the plugins API"); anyhow::bail!("telegram: token is empty — set it via the plugins API");
} }
let uploads_dir = self.secrets_dir let uploads_dir = std::env::current_dir()
.parent() .unwrap_or_default()
.unwrap_or(std::path::Path::new("."))
.join("uploads") .join("uploads")
.join("telegram"); .join("telegram");
// Register "telegram" source with ChatHub (idempotent). // Load bindings from the config table (or default if absent).
// ChatHub restores the active session from the sources table automatically. let telegram_config = auth::load_config(&*ctx.config).await
ctx.chat_hub.register("telegram").await; .unwrap_or_default();
info!("telegram: registered with ChatHub"); info!(
bindings = telegram_config.bindings.len(),
pending = telegram_config.pending_pairings.len(),
"telegram: config loaded",
);
let shared = Arc::new(TgShared { let shared = Arc::new(TgShared {
chat_hub: Arc::clone(&ctx.chat_hub), user_channel: Arc::clone(&ctx.user_channel),
command: Arc::clone(&ctx.command), command: Arc::clone(&ctx.command),
approval: Arc::clone(&ctx.approval), config: Arc::clone(&ctx.config),
transcribe: Arc::clone(&ctx.transcribe), transcribe: Arc::clone(&ctx.transcribe),
tts: Arc::clone(&ctx.tts_provider), tts: Arc::clone(&ctx.tts_provider),
location: Arc::clone(&ctx.location), location: Arc::clone(&ctx.location),
pending_approvals: Mutex::new(HashMap::new()),
pending_question: Mutex::new(None),
secrets_dir: self.secrets_dir.clone(),
uploads_dir, uploads_dir,
home_chat_id: Mutex::new(None), bindings: RwLock::new(telegram_config),
pending_approvals: Mutex::new(HashMap::new()),
pending_questions: Mutex::new(HashMap::new()),
forwarders: Mutex::new(HashSet::new()),
}); });
let _ = self.shared.set(Arc::clone(&shared));
let bot = Bot::new(&token); let bot = Bot::new(&token);
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
tokio::spawn(events::persistent_forwarder( // Config listener: reloads bindings when the "telegram" config key
bot.clone(), // changes (e.g. the pairing tool writes a new binding).
Arc::clone(&shared), {
cancel.clone(), let shared_c = Arc::clone(&shared);
)); let cancel_c = cancel.clone();
let bus_rx = ctx.system_bus.subscribe();
tokio::spawn(auth::config_listener(shared_c, bus_rx, cancel_c));
}
let hub_clone = Arc::clone(&ctx.chat_hub); // Spawn forwarders for already-unlocked paired users.
tokio::spawn(async move { {
if let Err(e) = hub_clone.resume("telegram").await { let shared_c = Arc::clone(&shared);
tracing::warn!(error = %e, "telegram: startup resume failed"); let bot_c = bot.clone();
} let cancel_c = cancel.clone();
}); tokio::spawn(async move {
events::spawn_forwarders_for_bound_users(&bot_c, &shared_c, &cancel_c).await;
});
}
let cancel_clone = cancel.clone(); let cancel_clone = cancel.clone();
let cancel_wdg = cancel.clone();
let running_clone = Arc::clone(&self.running); let running_clone = Arc::clone(&self.running);
self.running.store(true, Ordering::Relaxed); self.running.store(true, Ordering::Relaxed);
@@ -240,9 +284,6 @@ impl Plugin for TelegramPlugin {
.branch(Update::filter_edited_message().endpoint(handlers::edited_message_handler)) .branch(Update::filter_edited_message().endpoint(handlers::edited_message_handler))
.branch(Update::filter_callback_query().endpoint(events::callback_handler)); .branch(Update::filter_callback_query().endpoint(events::callback_handler));
let secrets_dir_wdg = self.secrets_dir.clone();
let bot_wdg = bot.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut dispatcher = Dispatcher::builder(bot, handler) let mut dispatcher = Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![shared]) .dependencies(dptree::deps![shared])
@@ -250,9 +291,8 @@ impl Plugin for TelegramPlugin {
info!("telegram plugin: dispatcher starting"); info!("telegram plugin: dispatcher starting");
tokio::select! { tokio::select! {
_ = cancel_clone.cancelled() => info!("telegram plugin: cancellation received"), _ = cancel_clone.cancelled() => info!("telegram plugin: cancellation received"),
_ = dispatcher.dispatch() => warn!("telegram plugin: dispatcher exited unexpectedly"), _ = dispatcher.dispatch() => warn!("telegram plugin: dispatcher exited unexpectedly"),
_ = auth::whitelist_watchdog(bot_wdg, secrets_dir_wdg, cancel_wdg) => {}
} }
running_clone.store(false, Ordering::Relaxed); running_clone.store(false, Ordering::Relaxed);
info!("telegram plugin: stopped"); info!("telegram plugin: stopped");
@@ -273,4 +313,8 @@ impl Plugin for TelegramPlugin {
self.running.store(false, Ordering::Relaxed); self.running.store(false, Ordering::Relaxed);
Ok(()) Ok(())
} }
fn tools(self: Arc<Self>) -> Vec<Arc<dyn core_api::tool::Tool>> {
vec![Arc::new(tools::TelegramPairingTool::new(self))]
}
} }
+158 -1
View File
@@ -1,12 +1,17 @@
use std::sync::Arc; use std::sync::Arc;
use serde_json::json; use anyhow::Result;
use serde_json::{json, Value};
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::InputFile; use teloxide::types::InputFile;
use core_api::interface_tool::InterfaceTool; use core_api::interface_tool::InterfaceTool;
use core_api::tool::{Tool, ToolCategory, ToolDescriptionLength};
use core_api::tts::{TextToSpeech, TtsProvider}; use core_api::tts::{TextToSpeech, TtsProvider};
use super::auth::{Binding, load_config, save_config};
use super::TelegramPlugin;
/// Returns all LLM-callable tools available in a Telegram session. /// Returns all LLM-callable tools available in a Telegram session.
/// ///
/// Each tool captures `bot` and `chat_id` so its handler can send content /// Each tool captures `bot` and `chat_id` so its handler can send content
@@ -225,3 +230,155 @@ async fn to_ogg_opus(audio: Vec<u8>, format: &str) -> anyhow::Result<Vec<u8>> {
} }
Ok(out.stdout) Ok(out.stdout)
} }
// ── telegram_pairing (registry tool, category Config) ─────────────────────────
/// Tool that binds a Telegram `chat_id` to a Skald `user_id`.
///
/// Category `Config` — excluded from the default tool list, activated
/// explicitly by the admin's agent. The admin calls this after a user reports
/// their pairing code from Telegram.
///
/// The binding is written to the config table (key `"telegram"`); the
/// resulting `ConfigKeyUpdated` event reloads the plugin's in-memory cache
/// instantly.
pub struct TelegramPairingTool {
plugin: Arc<TelegramPlugin>,
}
impl TelegramPairingTool {
pub fn new(plugin: Arc<TelegramPlugin>) -> Self {
Self { plugin }
}
}
impl Tool for TelegramPairingTool {
fn name(&self) -> &str { "telegram_pairing" }
fn category(&self) -> ToolCategory { ToolCategory::Config }
fn description(&self) -> &str {
"Bind a Telegram chat to a Skald user so they can chat with the agent via Telegram. \
Use `action: \"bind\"` with either a `code` (from the pairing message the user received) \
or a `chat_id` + `user_id`. Use `action: \"list\"` to see current bindings. \
Use `action: \"unbind\"` with a `chat_id` to remove a binding."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["bind", "unbind", "list"],
"description": "bind: create a chat_id→user_id binding. unbind: remove it. list: show all bindings.",
"default": "bind"
},
"code": {
"type": "string",
"description": "Pairing code shown to the Telegram user (alternative to chat_id+user_id)."
},
"chat_id": {
"type": "integer",
"description": "Telegram chat id (use when not resolving via code)."
},
"user_id": {
"type": "string",
"description": "Skald user id to bind to (required for bind when not using code)."
}
}
})
}
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
"telegram_pairing".to_string()
}
fn execute(&self, args: Value) -> Result<String> {
let shared = self.plugin.shared()
.ok_or_else(|| anyhow::anyhow!("telegram: plugin is not running"))?
.clone();
let action = args.get("action")
.and_then(Value::as_str)
.unwrap_or("bind");
// Block on since Tool::execute is sync.
let rt = tokio::runtime::Handle::try_current()
.map_err(|e| anyhow::anyhow!("telegram_pairing: no tokio runtime: {e}"))?;
rt.block_on(async {
let cfg_api = &*shared.config;
match action {
"list" => {
let cfg = load_config(cfg_api).await.unwrap_or_default();
if cfg.bindings.is_empty() {
return Ok("No Telegram bindings.".to_string());
}
let lines: Vec<String> = cfg.bindings.iter()
.map(|b| format!(" chat_id={} → user_id={}{}", b.chat_id, b.user_id,
b.display.as_ref().map(|d| format!(" ({d})")).unwrap_or_default()))
.collect();
Ok(format!("Telegram bindings:\n{}", lines.join("\n")))
}
"unbind" => {
let chat_id = args.get("chat_id")
.and_then(Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: `chat_id` required for unbind"))?;
let mut cfg = load_config(cfg_api).await.unwrap_or_default();
let before = cfg.bindings.len();
cfg.bindings.retain(|b| b.chat_id != chat_id);
if cfg.bindings.len() == before {
return Ok(format!("chat_id {chat_id} is not bound."));
}
save_config(cfg_api, &cfg).await?;
Ok(format!("Unbound chat_id {chat_id}."))
}
"bind" => {
let mut cfg = load_config(cfg_api).await.unwrap_or_default();
// Resolve chat_id + user_id either from a pairing code or
// from explicit arguments.
let (chat_id, user_id) = if let Some(code) = args.get("code").and_then(Value::as_str) {
let entry = cfg.pending_pairings.iter()
.find(|e| e.code == code)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: code '{code}' not found (it may have expired or already been used)"))?;
let chat_id = entry.chat_id;
let user_id = args.get("user_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: `user_id` required (the code only identifies the chat)"))?
.to_string();
// Remove the used pairing entry.
cfg.pending_pairings.retain(|e| e.code != code);
(chat_id, user_id)
} else {
let chat_id = args.get("chat_id")
.and_then(Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: either `code` or `chat_id`+`user_id` required"))?;
let user_id = args.get("user_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: `user_id` required"))?
.to_string();
(chat_id, user_id)
};
// Replace any existing binding for this chat_id.
cfg.bindings.retain(|b| b.chat_id != chat_id);
cfg.bindings.push(Binding {
chat_id,
user_id: user_id.clone(),
display: None,
});
save_config(cfg_api, &cfg).await?;
Ok(format!("Bound Telegram chat_id {chat_id} to user_id {user_id}."))
}
other => Err(anyhow::anyhow!("telegram_pairing: unknown action '{other}'")),
}
})
}
}
+77 -29
View File
@@ -29,11 +29,12 @@
//! determines the action; if none matches the tool requires approval //! determines the action; if none matches the tool requires approval
//! (default-closed). //! (default-closed).
//! //!
//! ## Hardcoded exception //! ## Memory namespace
//! //!
//! File-write tools targeting `memory/` paths bypass the rule engine and are //! The virtual memory roots `user-memory/` and `shared-memory/` (blueprint §5) are
//! always allowed (this mirrors the original behaviour and can be replaced by //! allowed by **seeded rules**, not a hardcoded bypass: `seed_fs_path_rules` stamps
//! an explicit `allow` rule later). //! `@fs_any allow user-memory/*` and `@fs_any allow shared-memory/*`, editable in the
//! File System panel like any other path rule.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -327,31 +328,52 @@ impl ApprovalManager {
/// Priority `5` places these path-scoped rules *before* the `*` catch-all (999999), /// Priority `5` places these path-scoped rules *before* the `*` catch-all (999999),
/// so an unmatched path still falls through to the default `require`. /// so an unmatched path still falls through to the default `require`.
/// ///
/// - `memory/*` → **allow** (the LLM manages its own memory; replaces the former /// - `user-memory/*` → **allow** (the caller's private memory namespace, blueprint §5;
/// hardcoded `is_memory_path` bypass). /// the LLM manages its own memory). Reads *and* writes: `@fs_any`.
/// - `shared-memory/*` → reads **allow** (`@fs_read`), writes **require** (`@fs_write`):
/// shared memory is visible to everyone, so a write is a deliberate, human-confirmed
/// act — the agent must not silently push one person's information into it.
/// - `data/*` → **allow** (scratch/data workspace). /// - `data/*` → **allow** (scratch/data workspace).
/// - `secrets/*` → **deny** (`@fs_any` denies reads *and* writes; a read would leak /// - `secrets/*` → **deny** (`@fs_any` denies reads *and* writes; a read would leak
/// the secret into the LLM context / history / WS stream, and `Deny` is /// the secret into the LLM context / history / WS stream, and `Deny` is
/// non-bypassable). The `/*` pattern also matches the `secrets` dir node itself, so /// non-bypassable). The `/*` pattern also matches the `secrets` dir node itself, so
/// recursive `list_files`/`grep_files` rooted at it are covered. /// recursive `list_files`/`grep_files` rooted at it are covered.
/// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`,
/// not `path`), so it needs a tool-scoped rule rather than a path pattern.
pub async fn seed_fs_path_rules(&self) -> Result<()> { pub async fn seed_fs_path_rules(&self) -> Result<()> {
// (tool_pattern, path_pattern, action, note) // (tool_pattern, path_pattern, action, note). `path_pattern = None` is a
let rules: &[(&str, &str, &str, &str)] = &[ // tool-scoped rule that matches regardless of args.
("@fs_any", "memory/*", "allow", "auto-allow memory/"), let rules: &[(&str, Option<&str>, &str, &str)] = &[
("@fs_any", "data/*", "allow", "auto-allow data/"), ("@fs_any", Some("user-memory/*"), "allow", "auto-allow user-memory/"),
("@fs_any", "secrets/*", "deny", "deny secrets/ access"), ("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"),
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"),
("@fs_any", Some("data/*"), "allow", "auto-allow data/"),
("@fs_any", Some("secrets/*"), "deny", "deny secrets/ access"),
("memory_search", None, "allow", "allow memory_search"),
]; ];
let mut seeded = 0; let mut seeded = 0;
for (tool_pattern, path_pattern, action, note) in rules { for &(tool_pattern, path_pattern, action, note) in rules {
let exists: i64 = sqlx::query_scalar( // A NULL path can't be matched with `=` (NULL comparisons are never true),
"SELECT COUNT(*) FROM approval_rules // so the existence check branches on it — otherwise the row would re-insert
WHERE tool_pattern = ? AND path_pattern = ? AND group_id = 'default'", // on every boot.
) let exists: i64 = match path_pattern {
.bind(tool_pattern) Some(p) => sqlx::query_scalar(
.bind(path_pattern) "SELECT COUNT(*) FROM approval_rules
.fetch_one(self.db.as_ref()) WHERE tool_pattern = ? AND path_pattern = ? AND group_id = 'default'",
.await?; )
.bind(tool_pattern)
.bind(p)
.fetch_one(self.db.as_ref())
.await?,
None => sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules
WHERE tool_pattern = ? AND path_pattern IS NULL AND group_id = 'default'",
)
.bind(tool_pattern)
.fetch_one(self.db.as_ref())
.await?,
};
if exists > 0 { if exists > 0 {
continue; continue;
} }
@@ -360,7 +382,7 @@ impl ApprovalManager {
VALUES (?, ?, ?, ?, 5, 'default')", VALUES (?, ?, ?, ?, 5, 'default')",
) )
.bind(tool_pattern) .bind(tool_pattern)
.bind(path_pattern) .bind(path_pattern) // Option<&str> → NULL when None
.bind(action) .bind(action)
.bind(note) .bind(note)
.execute(self.db.as_ref()) .execute(self.db.as_ref())
@@ -383,7 +405,10 @@ impl ApprovalManager {
/// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs /// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs
/// gating now lives in the File System panel + the `*` catch-all; /// gating now lives in the File System panel + the `*` catch-all;
/// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`); /// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`);
/// - the old `secrets` deny rows (`note = 'deny reading secrets/'`). /// - the old `secrets` deny rows (`note = 'deny reading secrets/'`);
/// - the old single `memory/*` allow row (`note = 'auto-allow memory/'`) — the memory
/// namespace split into `user-memory/` + `shared-memory/`, so the `memory/*` pattern
/// no longer routes and would otherwise linger as a stale allow on a disk `./memory/`.
/// ///
/// Idempotent: a no-op once the legacy rows are gone. Run before `seed_fs_path_rules`. /// Idempotent: a no-op once the legacy rows are gone. Run before `seed_fs_path_rules`.
pub async fn migrate_legacy_fs_rules(&self) -> Result<()> { pub async fn migrate_legacy_fs_rules(&self) -> Result<()> {
@@ -406,7 +431,20 @@ impl ApprovalManager {
.await? .await?
.rows_affected(); .rows_affected();
let total = n1 + n2 + n3; let n4 = sqlx::query("DELETE FROM approval_rules WHERE note = 'auto-allow memory/'")
.execute(self.db.as_ref())
.await?
.rows_affected();
// An earlier build seeded `@fs_any allow shared-memory/*`; shared writes now
// require approval, so that blanket allow must go (the new @fs_read/@fs_write
// rows are seeded fresh by `seed_fs_path_rules`).
let n5 = sqlx::query("DELETE FROM approval_rules WHERE note = 'auto-allow shared-memory/'")
.execute(self.db.as_ref())
.await?
.rows_affected();
let total = n1 + n2 + n3 + n4 + n5;
if total > 0 { if total > 0 {
info!("approval_rules: migrated {total} legacy filesystem rules to @fs_* File System panel"); info!("approval_rules: migrated {total} legacy filesystem rules to @fs_* File System panel");
} }
@@ -419,8 +457,8 @@ impl ApprovalManager {
/// ///
/// Evaluation order: /// Evaluation order:
/// 1. Rules for `group_id` first, then "default" group as fallback, sorted by priority ASC. /// 1. Rules for `group_id` first, then "default" group as fallback, sorted by priority ASC.
/// First match wins. (`memory/` auto-allow is a seeded `@fs_any allow memory/*` rule, /// First match wins. (The `user-memory/` and `shared-memory/` auto-allows are seeded
/// not a hardcoded exception — see `seed_fs_path_rules`.) /// `@fs_any allow …/*` rules, not a hardcoded exception — see `seed_fs_path_rules`.)
/// 2. Session bypass: if a matching bypass is active, `Require` → `Allow`. /// 2. Session bypass: if a matching bypass is active, `Require` → `Allow`.
/// `Deny` is never bypassed. /// `Deny` is never bypassed.
/// 3. No match → `Require` (default-closed policy). /// 3. No match → `Require` (default-closed policy).
@@ -1119,21 +1157,31 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(legacy, 0, "legacy fs rules should be removed by migration"); assert_eq!(legacy, 0, "legacy fs rules should be removed by migration");
// …and replaced by exactly the three @fs_* token rows. // …and replaced by exactly the five @fs_* token rows (shared-memory has two:
// read-allow and write-require).
let fs_rows: i64 = sqlx::query_scalar( let fs_rows: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'", "SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
) )
.fetch_one(db.as_ref()) .fetch_one(db.as_ref())
.await .await
.unwrap(); .unwrap();
assert_eq!(fs_rows, 3, "memory/data/secrets @fs_* rules should be seeded"); assert_eq!(fs_rows, 5, "user-memory + shared-memory(r/w) + data + secrets @fs_* rules should be seeded");
// Gate decisions through the real check() path. // Gate decisions through the real check() path.
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult { async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
mgr.check(1, None, "main", "web", tool, &json!({ "path": path }), Some("default")).await mgr.check(1, None, "main", "web", tool, &json!({ "path": path }), Some("default")).await
} }
assert!(matches!(decide(&mgr, "write_file", "memory/notes.md").await, GateResult::Allow)); // user-memory auto-allows reads and writes; the old `memory/*` no longer matches.
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow)); assert!(matches!(decide(&mgr, "write_file", "user-memory/notes.md").await, GateResult::Allow));
assert!(matches!(decide(&mgr, "list_files", "user-memory").await, GateResult::Allow));
assert!(matches!(decide(&mgr, "write_file", "memory/notes.md").await, GateResult::Require));
// shared-memory: reads allowed, writes require approval.
assert!(matches!(decide(&mgr, "read_file", "shared-memory/casa.md").await, GateResult::Allow));
assert!(matches!(decide(&mgr, "write_file", "shared-memory/casa.md").await, GateResult::Require));
assert!(matches!(decide(&mgr, "edit_file", "shared-memory/casa.md").await, GateResult::Require));
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow));
// memory_search is allowed by a path-less tool rule (it has `query`, not `path`).
assert!(matches!(decide(&mgr, "memory_search", "ignored").await, GateResult::Allow));
// Improvement over legacy: secrets *writes* are now denied too, not just reads. // Improvement over legacy: secrets *writes* are now denied too, not just reads.
assert!(matches!(decide(&mgr, "write_file", "secrets/key").await, GateResult::Deny)); assert!(matches!(decide(&mgr, "write_file", "secrets/key").await, GateResult::Deny));
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Deny)); assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Deny));
+32 -3
View File
@@ -2,13 +2,16 @@ use std::sync::Arc;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use core_api::system_bus::{SystemEvent, SystemEventBus};
pub struct GlobalConfigManager { pub struct GlobalConfigManager {
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
system_bus: Arc<SystemEventBus>,
} }
impl GlobalConfigManager { impl GlobalConfigManager {
pub fn new(pool: Arc<SqlitePool>) -> Self { pub fn new(pool: Arc<SqlitePool>, system_bus: Arc<SystemEventBus>) -> Self {
Self { pool } Self { pool, system_bus }
} }
pub async fn get(&self, key: &str) -> anyhow::Result<Option<String>> { pub async fn get(&self, key: &str) -> anyhow::Result<Option<String>> {
@@ -19,7 +22,16 @@ impl GlobalConfigManager {
Ok(row.map(|(v,)| v)) Ok(row.map(|(v,)| v))
} }
/// Sets a config key and emits [`SystemEvent::ConfigKeyUpdated`] on the
/// system bus when the value actually changes. No-op (no write, no event)
/// when the new value equals the current one.
pub async fn set(&self, key: &str, value: &str) -> anyhow::Result<()> { pub async fn set(&self, key: &str, value: &str) -> anyhow::Result<()> {
let old_value = self.get(key).await?;
if old_value.as_deref() == Some(value) {
return Ok(());
}
sqlx::query( sqlx::query(
"INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) "INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
@@ -30,6 +42,13 @@ impl GlobalConfigManager {
.bind(value) .bind(value)
.execute(&*self.pool) .execute(&*self.pool)
.await?; .await?;
self.system_bus.send(SystemEvent::ConfigKeyUpdated {
key: key.to_string(),
old_value,
new_value: value.to_string(),
});
Ok(()) Ok(())
} }
@@ -41,3 +60,13 @@ impl GlobalConfigManager {
Ok(()) Ok(())
} }
} }
#[async_trait::async_trait]
impl core_api::config_api::ConfigApi for GlobalConfigManager {
async fn get(&self, key: &str) -> anyhow::Result<Option<String>> {
GlobalConfigManager::get(self, key).await
}
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()> {
GlobalConfigManager::set(self, key, value).await
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Accessor for `memory_docs` — the backing store of the virtual `memory/`
//! namespace (blueprint §5).
//!
//! The **pool is the namespace**: a user pool holds that user's private notes
//! (`memory/{userid}`), the system pool holds shared notes (`memory/shared`).
//! Callers pass a `path` already stripped of the `memory/…` prefix — the file
//! it lands in decides the namespace, the row keeps only the tail. `path` is
//! UNIQUE, so [`upsert`] is the single write path for both create and edit, and
//! the `memory_docs_fts` triggers keep the full-text index in step underneath.
use anyhow::Result;
use sqlx::SqlitePool;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MemoryDoc {
pub id: i64,
pub path: String,
pub content: String,
pub created_at: String,
pub updated_at: String,
}
/// One row of a directory-style listing: metadata only, no `content` body.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MemoryEntry {
pub path: String,
pub updated_at: String,
}
/// One full-text hit: the matching note's path and a highlighted excerpt.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MemoryHit {
pub path: String,
pub snippet: String,
}
const SELECT: &str = "SELECT id, path, content, created_at, updated_at FROM memory_docs";
/// Fetch one note by its exact path.
pub async fn get(pool: &SqlitePool, path: &str) -> Result<Option<MemoryDoc>> {
let row = sqlx::query_as::<_, MemoryDoc>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE path = ?")))
.bind(path)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Create the note at `path`, or overwrite it if it already exists. `created_at`
/// survives an overwrite; `updated_at` is bumped. Returns the stored row.
pub async fn upsert(pool: &SqlitePool, path: &str, content: &str) -> Result<MemoryDoc> {
sqlx::query(
"INSERT INTO memory_docs (path, content)
VALUES (?, ?)
ON CONFLICT(path) DO UPDATE SET
content = excluded.content,
updated_at = datetime('now')",
)
.bind(path)
.bind(content)
.execute(pool)
.await?;
let row = sqlx::query_as::<_, MemoryDoc>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE path = ?")))
.bind(path)
.fetch_one(pool)
.await?;
Ok(row)
}
/// List notes whose path starts with `prefix` (pass `""` for all), most recently
/// edited first. Metadata only — the `content` body is not loaded.
pub async fn list(pool: &SqlitePool, prefix: &str) -> Result<Vec<MemoryEntry>> {
// Escaped LIKE prefix: a literal `%`/`_` in the caller's path must match as
// itself, not as a wildcard. `\` is the escape character.
let pattern = format!("{}%", escape_like(prefix));
let rows = sqlx::query_as::<_, MemoryEntry>(
"SELECT path, updated_at FROM memory_docs
WHERE path LIKE ? ESCAPE '\\'
ORDER BY updated_at DESC",
)
.bind(pattern)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Full-text search over note bodies and paths, best match first. `query` is
/// FTS5 MATCH syntax; `snippet` is a short excerpt of the body with the matched
/// terms wrapped in `[` … `]`.
pub async fn search(pool: &SqlitePool, query: &str, limit: i64) -> Result<Vec<MemoryHit>> {
let rows = sqlx::query_as::<_, MemoryHit>(
// `memory_docs_fts` is external-content, so its rowid is `memory_docs.id`;
// join back for the path, and read the excerpt from content column 1.
"SELECT d.path AS path,
snippet(memory_docs_fts, 1, '[', ']', '…', 12) AS snippet
FROM memory_docs_fts
JOIN memory_docs d ON d.id = memory_docs_fts.rowid
WHERE memory_docs_fts MATCH ?
ORDER BY bm25(memory_docs_fts)
LIMIT ?",
)
.bind(query)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Delete the note at `path`. Returns whether a row was removed.
pub async fn delete(pool: &SqlitePool, path: &str) -> Result<bool> {
let n = sqlx::query("DELETE FROM memory_docs WHERE path = ?")
.bind(path)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
/// Escapes `%`, `_` and `\` so a caller-supplied string is a literal LIKE prefix.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '%' | '_' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// A standalone owner-schema database in a throwaway temp dir. `tag` plus an
/// atomic counter keep parallel tests from colliding on the same file.
/// Returns the pool and the dir so the caller can wipe it (SQLite leaves
/// `-wal`/`-shm` sidecars beside the file).
async fn owner_pool(tag: &str) -> (SqlitePool, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("skald-memdocs-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
(pool, dir)
}
#[tokio::test]
async fn upsert_is_create_then_overwrite_and_fts_follows() {
let (pool, dir) = owner_pool("upsert").await;
// create
let doc = upsert(&pool, "notes/spesa.md", "latte e pane").await.unwrap();
assert_eq!(doc.path, "notes/spesa.md");
assert_eq!(doc.content, "latte e pane");
let first_id = doc.id;
// get by exact path
assert_eq!(get(&pool, "notes/spesa.md").await.unwrap().unwrap().content, "latte e pane");
assert!(get(&pool, "notes/altro.md").await.unwrap().is_none());
// overwrite: same row, new content, created_at preserved
let doc2 = upsert(&pool, "notes/spesa.md", "latte, pane, uova").await.unwrap();
assert_eq!(doc2.id, first_id, "upsert must update in place, not insert a new row");
assert_eq!(doc2.content, "latte, pane, uova");
assert_eq!(doc2.created_at, doc.created_at, "created_at survives an overwrite");
assert_eq!(list(&pool, "").await.unwrap().len(), 1, "still one row for that path");
// FTS follows the update: the removed token is gone, the new one is found
assert!(search(&pool, "uova", 10).await.unwrap().iter().any(|h| h.path == "notes/spesa.md"));
assert!(search(&pool, "latte", 10).await.unwrap().iter().any(|h| h.path == "notes/spesa.md"));
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn list_by_prefix_and_delete_deindexes() {
let (pool, dir) = owner_pool("list").await;
upsert(&pool, "notes/spesa.md", "latte pane uova").await.unwrap();
upsert(&pool, "notes/idee.md", "un'idea brillante").await.unwrap();
upsert(&pool, "diary/2026.md", "oggi e' successo").await.unwrap();
let notes = list(&pool, "notes/").await.unwrap();
assert_eq!(notes.len(), 2, "prefix listing is scoped to the subtree");
assert!(notes.iter().all(|e| e.path.starts_with("notes/")));
assert_eq!(list(&pool, "").await.unwrap().len(), 3, "empty prefix lists everything");
// delete removes the row and de-indexes it from FTS
assert!(delete(&pool, "notes/spesa.md").await.unwrap());
assert!(get(&pool, "notes/spesa.md").await.unwrap().is_none());
assert!(search(&pool, "uova", 10).await.unwrap().is_empty(), "delete must de-index");
assert!(!delete(&pool, "notes/spesa.md").await.unwrap(), "second delete is a no-op");
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
}
+64
View File
@@ -13,6 +13,7 @@ pub mod llm_requests;
pub mod llm_request_payloads; pub mod llm_request_payloads;
pub mod mcp_events; pub mod mcp_events;
pub mod mcp_servers; pub mod mcp_servers;
pub mod memory_docs;
pub mod plugins; pub mod plugins;
pub mod roles; pub mod roles;
pub mod scheduled_jobs; pub mod scheduled_jobs;
@@ -710,6 +711,60 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// Backing store for the virtual `memory/` namespace (blueprint §5). MD-only
// notes keyed by a path *relative to the namespace root* — the file is the
// namespace, so no `memory/{userid}` / `memory/shared` prefix is stored:
// routing picks the pool, the row keeps only the tail. Because this is an
// owner table, the same schema backs private memory in each `{userid}.db`
// (behind SQLCipher) and shared memory in `system.db` (cleartext, the
// household owner) — §5.1. `path` is UNIQUE, so a write is an upsert.
sqlx::query(
"CREATE TABLE IF NOT EXISTS memory_docs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
// Full-text index over memory notes: the payoff of a SQLite backing over
// opaque file blobs (§5) — a decrypted session can search / RAG its own
// memory. External-content FTS5 keeps no second copy of `content`; the
// triggers below mirror every change from `memory_docs`. FTS5 is compiled
// into the bundled SQLCipher build, so this works inside encrypted user
// files too.
sqlx::query(
"CREATE VIRTUAL TABLE IF NOT EXISTS memory_docs_fts USING fts5(
path, content,
content='memory_docs',
content_rowid='id'
)",
)
.execute(pool)
.await?;
for trigger in [
"CREATE TRIGGER IF NOT EXISTS memory_docs_ai AFTER INSERT ON memory_docs BEGIN
INSERT INTO memory_docs_fts(rowid, path, content)
VALUES (new.id, new.path, new.content);
END",
"CREATE TRIGGER IF NOT EXISTS memory_docs_ad AFTER DELETE ON memory_docs BEGIN
INSERT INTO memory_docs_fts(memory_docs_fts, rowid, path, content)
VALUES ('delete', old.id, old.path, old.content);
END",
"CREATE TRIGGER IF NOT EXISTS memory_docs_au AFTER UPDATE ON memory_docs BEGIN
INSERT INTO memory_docs_fts(memory_docs_fts, rowid, path, content)
VALUES ('delete', old.id, old.path, old.content);
INSERT INTO memory_docs_fts(rowid, path, content)
VALUES (new.id, new.path, new.content);
END",
] {
sqlx::query(trigger).execute(pool).await?;
}
Ok(()) Ok(())
} }
@@ -764,6 +819,15 @@ mod tests {
one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").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(); one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap();
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap(); one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
// Fires the AFTER INSERT trigger into the external-content FTS5 table.
one("INSERT INTO memory_docs (path, content) VALUES ('notes/x.md', 'hello world')").await.unwrap();
// ...and the FTS index actually answers a MATCH.
let (hits,): (i64,) = sqlx::query_as(
"SELECT count(*) FROM memory_docs_fts WHERE memory_docs_fts MATCH 'world'",
)
.fetch_one(&pool).await.unwrap();
assert_eq!(hits, 1, "memory_docs_fts must index inserted notes");
pool.close().await; pool.close().await;
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
+2
View File
@@ -97,6 +97,7 @@ impl PluginManager {
Ok(PluginContext { Ok(PluginContext {
command: Arc::clone(skald.command_manager()) as _, command: Arc::clone(skald.command_manager()) as _,
config: Arc::clone(skald.config()) as Arc<dyn core_api::config_api::ConfigApi>,
db: Arc::clone(skald.db()), db: Arc::clone(skald.db()),
secrets: Arc::clone(skald.secrets()) as _, secrets: Arc::clone(skald.secrets()) as _,
transcribe: Arc::clone(skald.transcribe_manager()) as _, transcribe: Arc::clone(skald.transcribe_manager()) as _,
@@ -107,6 +108,7 @@ impl PluginManager {
api_provider_registry: Arc::clone(skald.provider_registry()) as _, api_provider_registry: Arc::clone(skald.provider_registry()) as _,
location: Arc::clone(skald.location_manager()) as _, location: Arc::clone(skald.location_manager()) as _,
system_bus: Arc::clone(skald.system_bus()), system_bus: Arc::clone(skald.system_bus()),
user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>,
web_port, web_port,
remote_slot: Arc::clone(skald.remote()), remote_slot: Arc::clone(skald.remote()),
router_factory, router_factory,
@@ -34,6 +34,9 @@ fn system_timezone() -> Option<&'static str> {
/// without needing the full handler and all its dependencies. /// without needing the full handler and all its dependencies.
pub struct MessageBuilder { pub struct MessageBuilder {
pub pool: Arc<SqlitePool>, pub pool: Arc<SqlitePool>,
/// The shared (`system.db`) pool, for injecting `shared-memory/` notes. The
/// owner `pool` above backs `user-memory/`.
pub shared_pool: Arc<SqlitePool>,
pub session_id: i64, pub session_id: i64,
pub mcp: Arc<McpManager>, pub mcp: Arc<McpManager>,
pub datetime_config: DatetimeConfig, pub datetime_config: DatetimeConfig,
@@ -93,9 +96,7 @@ impl MessageBuilder {
You can edit them with `edit_file` or `write_file` using the path shown.\n" You can edit them with `edit_file` or `write_file` using the path shown.\n"
); );
for mem_path in &meta.inject_memory { for mem_path in &meta.inject_memory {
// Resolve the entry to (absolute path to read, path to show the agent). let (content, display) = self.load_inject_memory(mem_path).await;
let (abs, display) = self.resolve_memory_path(mem_path);
let content = tokio::fs::read_to_string(&abs).await.ok();
match content { match content {
Some(c) => static_content.push_str(&format!( Some(c) => static_content.push_str(&format!(
"\n<memory_file path=\"{display}\">\n{c}\n</memory_file>\n" "\n<memory_file path=\"{display}\">\n{c}\n</memory_file>\n"
@@ -405,6 +406,27 @@ impl MessageBuilder {
/// when the file lives under it, absolute otherwise** — so when the agent references /// when the file lives under it, absolute otherwise** — so when the agent references
/// it back via `edit_file`/`write_file`, the loop's working-directory injection /// it back via `edit_file`/`write_file`, the loop's working-directory injection
/// (which rewrites relative paths against the WD) resolves to the very same file. /// (which rewrites relative paths against the WD) resolves to the very same file.
/// Loads an `inject_memory` entry, returning `(content, display_path)`.
///
/// Virtual memory paths are read from SQLite: `user-memory/…` from the owner
/// `pool`, `shared-memory/…` from the `shared_pool` (`system.db`). Everything
/// else (`data/…`, `$WD/…`) is an ordinary disk read. A missing note / file
/// yields `None`, rendered as "(file not created yet)".
async fn load_inject_memory(&self, mem_path: &str) -> (Option<String>, String) {
use crate::tools::fs::{classify_memory, MemScope};
if let Some(m) = classify_memory(mem_path) {
let pool = match m.scope {
MemScope::User => &self.pool,
MemScope::Shared => &self.shared_pool,
};
let content = crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
return (content, mem_path.to_string());
}
let (abs, display) = self.resolve_memory_path(mem_path);
(tokio::fs::read_to_string(&abs).await.ok(), display)
}
fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) { fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) {
let wd = self.working_directory.clone() let wd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
@@ -28,6 +28,7 @@ impl ChatSessionHandler {
.map(|rc| rc.effective_working_dir()); .map(|rc| rc.effective_working_dir());
let builder = MessageBuilder { let builder = MessageBuilder {
pool: Arc::clone(&self.db), pool: Arc::clone(&self.db),
shared_pool: Arc::clone(&self.shared_pool),
session_id: self.scratchpad_sid(), session_id: self.scratchpad_sid(),
mcp: Arc::clone(&self.mcp), mcp: Arc::clone(&self.mcp),
datetime_config: self.datetime_config.clone(), datetime_config: self.datetime_config.clone(),
@@ -262,6 +262,9 @@ impl ApprovalDecision {
pub struct ChatSessionHandler { pub struct ChatSessionHandler {
pub session_id: i64, pub session_id: i64,
pub(super) db: Arc<SqlitePool>, pub(super) db: Arc<SqlitePool>,
/// The shared (`system.db`) pool. Owner-bound work uses `db`; this is only for
/// cross-owner reads, e.g. injecting `shared-memory/` notes into the prompt.
pub(super) shared_pool: Arc<SqlitePool>,
/// The authenticated user who owns this session. Threaded into `ChatOptions` /// The authenticated user who owns this session. Threaded into `ChatOptions`
/// so the telemetry metadata row in `system.db` carries `user_id`. /// so the telemetry metadata row in `system.db` carries `user_id`.
pub(super) user_id: String, pub(super) user_id: String,
@@ -332,6 +335,7 @@ impl ChatSessionHandler {
pub fn new( pub fn new(
session_id: i64, session_id: i64,
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String, user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
@@ -357,6 +361,7 @@ impl ChatSessionHandler {
Self { Self {
session_id, session_id,
db, db,
shared_pool,
user_id, user_id,
llm_manager, llm_manager,
max_history_messages, max_history_messages,
+6
View File
@@ -22,6 +22,9 @@ use super::handler::ChatSessionHandler;
pub struct ChatSessionManager { pub struct ChatSessionManager {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
/// The shared (`system.db`) pool, threaded to each handler for cross-owner
/// reads such as injecting `shared-memory/` notes.
shared_pool: Arc<SqlitePool>,
user_id: String, user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
@@ -48,6 +51,7 @@ pub struct ChatSessionManager {
impl ChatSessionManager { impl ChatSessionManager {
pub fn new( pub fn new(
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String, user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
@@ -68,6 +72,7 @@ impl ChatSessionManager {
) -> Self { ) -> Self {
Self { Self {
db, db,
shared_pool,
user_id, user_id,
llm_manager, llm_manager,
max_history_messages, max_history_messages,
@@ -155,6 +160,7 @@ impl ChatSessionManager {
let handler = Arc::new(ChatSessionHandler::new( let handler = Arc::new(ChatSessionHandler::new(
session_id, session_id,
self.db.clone(), self.db.clone(),
self.shared_pool.clone(),
self.user_id.clone(), self.user_id.clone(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
+13
View File
@@ -16,6 +16,7 @@ use tokio_util::sync::CancellationToken;
use core_api::remote::RemoteAccess; use core_api::remote::RemoteAccess;
use core_api::system_bus::SystemEventBus; use core_api::system_bus::SystemEventBus;
use core_api::user_channel::UserChannelApi;
use crate::approval::ApprovalManager; use crate::approval::ApprovalManager;
use crate::chat_event_bus::ChatEventBus; use crate::chat_event_bus::ChatEventBus;
@@ -112,3 +113,15 @@ impl Skald {
pub fn location_manager(&self) -> &Arc<LocationManager> { &self.infra.location_manager } pub fn location_manager(&self) -> &Arc<LocationManager> { &self.infra.location_manager }
pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote } pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote }
} }
// ── UserChannelApi ────────────────────────────────────────────────────────────
use super::user_context::UserContextHandle;
#[async_trait::async_trait]
impl UserChannelApi for Skald {
async fn resolve_user(&self, user_id: &str) -> Option<std::sync::Arc<dyn core_api::user_channel::UserChannelHandle>> {
let ctx = self.user_context(user_id).await?;
Some(std::sync::Arc::new(UserContextHandle::new(ctx)))
}
}
+3 -2
View File
@@ -204,9 +204,9 @@ impl Tools {
/// Captures sibling managers (mcp, plugins, cron, secrets) into the tool /// Captures sibling managers (mcp, plugins, cron, secrets) into the tool
/// registry. `execute_task` is deliberately NOT registered here — it is injected /// registry. `execute_task` is deliberately NOT registered here — it is injected
/// per interactive session by `ChatHub::send_message`. /// per interactive session by `ChatHub::send_message`.
pub(super) fn build(integrations: &Integrations, tasks: &Tasks, models: &Models) -> Self { pub(super) fn build(rt: &Runtime, integrations: &Integrations, tasks: &Tasks, models: &Models) -> Self {
let mut tool_registry = ToolRegistry::new(); let mut tool_registry = ToolRegistry::new();
crate::tools::fs::register_all(&mut tool_registry); crate::tools::fs::register_all(&mut tool_registry, Arc::clone(&rt.db));
tool_registry.register(crate::tools::ast_outline::AstOutline::new()); tool_registry.register(crate::tools::ast_outline::AstOutline::new());
tool_registry.register(crate::tools::exec::ExecuteCmd); tool_registry.register(crate::tools::exec::ExecuteCmd);
tool_registry.register(crate::tools::read_notification::ReadNotification); tool_registry.register(crate::tools::read_notification::ReadNotification);
@@ -338,6 +338,7 @@ impl Conversation {
let manager = Arc::new(ChatSessionManager::new( let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&rt.db), Arc::clone(&rt.db),
Arc::clone(&rt.db), // shared pool == system.db (this is the ownerless manager)
String::new(), String::new(),
Arc::clone(&models.llm_manager), Arc::clone(&models.llm_manager),
config.llm.max_history_messages, config.llm.max_history_messages,
+1 -1
View File
@@ -66,7 +66,7 @@ impl Skald {
let media = Media::build(&rt, &models).await?; let media = Media::build(&rt, &models).await?;
let integrations = Integrations::build(&rt, plugins); let integrations = Integrations::build(&rt, plugins);
let tasks = Tasks::build(&rt, config); let tasks = Tasks::build(&rt, config);
let tools = Tools::build(&integrations, &tasks, &models); let tools = Tools::build(&rt, &integrations, &tasks, &models);
let interaction = Interaction::build(&rt, &tools).await?; let interaction = Interaction::build(&rt, &tools).await?;
let conversation = Conversation::build(&rt, &models, &media, &tools, &integrations, &interaction, config).await?; let conversation = Conversation::build(&rt, &models, &media, &tools, &integrations, &interaction, config).await?;
let infra = Infra::build(); let infra = Infra::build();
+4 -4
View File
@@ -45,14 +45,14 @@ pub(super) struct Runtime {
impl Runtime { impl Runtime {
/// Wires the cross-cutting primitives. Infallible. /// Wires the cross-cutting primitives. Infallible.
pub(super) fn bootstrap(pool: Arc<SqlitePool>) -> Self { pub(super) fn bootstrap(pool: Arc<SqlitePool>) -> Self {
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool))); let system_bus = Arc::new(SystemEventBus::new());
info!("system event bus ready");
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool), Arc::clone(&system_bus)));
let users = Arc::new(UserManager::new(Arc::clone(&pool))); let users = Arc::new(UserManager::new(Arc::clone(&pool)));
let sessions = Arc::new(SessionStore::new(Arc::clone(&users))); let sessions = Arc::new(SessionStore::new(Arc::clone(&users)));
let system_bus = Arc::new(SystemEventBus::new());
info!("system event bus ready");
let event_bus = Arc::new(ChatEventBus::new()); let event_bus = Arc::new(ChatEventBus::new());
info!("chat event bus ready"); info!("chat event bus ready");
@@ -29,8 +29,11 @@ use sqlx::SqlitePool;
use tokio::sync::{broadcast, Mutex}; use tokio::sync::{broadcast, Mutex};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use core_api::approval::ApprovalApi;
use core_api::chat_hub::ChatHubApi;
use core_api::events::GlobalEvent; use core_api::events::GlobalEvent;
use core_api::system_bus::SystemEventBus; use core_api::system_bus::SystemEventBus;
use core_api::user_channel::UserChannelHandle;
use crate::approval::ApprovalManager; use crate::approval::ApprovalManager;
use crate::chat_event_bus::ChatEventBus; use crate::chat_event_bus::ChatEventBus;
@@ -156,6 +159,7 @@ impl UserContextFactory {
let manager = Arc::new(ChatSessionManager::new( let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&pool), Arc::clone(&pool),
Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection
user_id.to_string(), user_id.to_string(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
@@ -257,3 +261,38 @@ impl UserContextRegistry {
Ok(ctx) Ok(ctx)
} }
} }
// ── UserChannelHandle impl ────────────────────────────────────────────────────
/// Concrete [`UserChannelHandle`] wrapping a live [`UserContext`].
///
/// Constructed by [`Skald`](super::Skald) when resolving a user for a channel
/// plugin. The concrete type stays private — callers receive
/// `Arc<dyn UserChannelHandle>`.
pub(super) struct UserContextHandle {
ctx: Arc<UserContext>,
}
impl UserContextHandle {
pub(super) fn new(ctx: Arc<UserContext>) -> Self {
Self { ctx }
}
}
impl UserChannelHandle for UserContextHandle {
fn user_id(&self) -> &str {
&self.ctx.user_id
}
fn chat_hub(&self) -> Arc<dyn ChatHubApi> {
Arc::clone(&self.ctx.chat_hub) as Arc<dyn ChatHubApi>
}
fn approval(&self) -> Arc<dyn ApprovalApi> {
Arc::clone(&self.ctx.approval) as Arc<dyn ApprovalApi>
}
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent> {
self.ctx.global_tx.subscribe()
}
}
+76 -40
View File
@@ -1,8 +1,55 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; use crate::tools::{
use super::{read_to_string, write_string}; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT,
};
use super::{classify_memory, read_to_string, write_string, MemScope};
/// Applies the substring edit to `content`, returning the new content. Shared by
/// the on-disk [`EditFile::execute`] and the `memory/` routing in `run_with`;
/// `display` is the path used in error messages.
fn apply_edit(content: &str, args: &Value, display: &str) -> Result<String> {
let old = args["old"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
let new = args["new"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
let updated = if replace_all {
if !content.contains(old) {
anyhow::bail!(
"Text not found in {display}. \
Call read_file first and copy the text exactly as shown after the '| ' prefix."
);
}
content.replace(old, new)
} else {
let exact_count = content.matches(old).count();
if exact_count > 1 {
anyhow::bail!(
"Text found {exact_count} times in {display}. \
Include more surrounding context in `old` to make it unique, or set replace_all=true."
);
}
if exact_count == 1 {
content.replacen(old, new, 1)
} else {
let normalized_old = normalize_ws(old);
let (start, end) = find_normalized(content, &normalized_old)
.ok_or_else(|| anyhow::anyhow!(
"Text not found in {display}. \
Call read_file first and copy the text exactly as shown after the '| ' prefix."
))?;
format!("{}{}{}", &content[..start], new, &content[end..])
}
};
Ok(updated)
}
fn normalize_ws(s: &str) -> String { fn normalize_ws(s: &str) -> String {
s.lines() s.lines()
@@ -56,10 +103,13 @@ fn find_normalized(haystack: &str, normalized_needle: &str) -> Option<(usize, us
None None
} }
pub struct EditFile; pub struct EditFile {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl EditFile { impl EditFile {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
} }
impl Tool for EditFile { impl Tool for EditFile {
@@ -102,46 +152,32 @@ impl Tool for EditFile {
truncate_label(&format!("edit_file `{path}`"), MAX_LABEL_SHORT) truncate_label(&format!("edit_file `{path}`"), MAX_LABEL_SHORT)
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
Box::new(SimpleExecution::new(Box::pin(async move {
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
anyhow::bail!("No note at {path}");
};
let updated = apply_edit(&doc.content, &args, &path)?;
crate::db::memory_docs::upsert(&pool, &rel, &updated).await?;
Ok(ToolResult::Text(format!("Edited {path}.")))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str() let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?; .ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let old = args["old"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
let new = args["new"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
let content = read_to_string(user_path)?; let content = read_to_string(user_path)?;
let updated = apply_edit(&content, &args, user_path)?;
let updated = if replace_all {
if !content.contains(old) {
anyhow::bail!(
"Text not found in {user_path}. \
Call read_file first and copy the text exactly as shown after the '| ' prefix."
);
}
content.replace(old, new)
} else {
let exact_count = content.matches(old).count();
if exact_count > 1 {
anyhow::bail!(
"Text found {exact_count} times in {user_path}. \
Include more surrounding context in `old` to make it unique, or set replace_all=true."
);
}
if exact_count == 1 {
content.replacen(old, new, 1)
} else {
let normalized_old = normalize_ws(old);
let (start, end) = find_normalized(&content, &normalized_old)
.ok_or_else(|| anyhow::anyhow!(
"Text not found in {user_path}. \
Call read_file first and copy the text exactly as shown after the '| ' prefix."
))?;
format!("{}{}{}", &content[..start], new, &content[end..])
}
};
write_string(user_path, &updated)?; write_string(user_path, &updated)?;
Ok(format!("Edited {user_path}.")) Ok(format!("Edited {user_path}."))
} }
@@ -1,13 +1,49 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use crate::tools::{
use super::{read_to_string, write_string}; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
};
use super::{classify_memory, read_to_string, write_string, MemScope};
pub struct InsertAtLine; pub struct InsertAtLine {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl InsertAtLine { impl InsertAtLine {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
}
/// Inserts `content` before/after `line` in `text`, returning the new text and a
/// result message. Shared by the on-disk `execute` and the `memory/` routing;
/// `display` is the path used in the message.
fn apply_insert(text: &str, args: &Value, display: &str) -> Result<(String, String)> {
let line_num = args["line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
let new_text = args["content"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
let placement = args["placement"].as_str().unwrap_or("after");
anyhow::ensure!(line_num >= 1, "line must be >= 1");
let mut lines: Vec<&str> = text.split('\n').collect();
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
let updated = lines.join("\n");
let msg = format!(
"Inserted {} line(s) {} line {} in {display}.",
new_lines.len(), placement, line_num
);
Ok((updated, msg))
} }
impl Tool for InsertAtLine { impl Tool for InsertAtLine {
@@ -53,32 +89,33 @@ impl Tool for InsertAtLine {
} }
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
Box::new(SimpleExecution::new(Box::pin(async move {
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
anyhow::bail!("No note at {path}");
};
let (updated, msg) = apply_insert(&doc.content, &args, &path)?;
crate::db::memory_docs::upsert(&pool, &rel, &updated).await?;
Ok(ToolResult::Text(msg))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str() let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?; .ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let line_num = args["line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
let new_text = args["content"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
let placement = args["placement"].as_str().unwrap_or("after");
anyhow::ensure!(line_num >= 1, "line must be >= 1");
let text = read_to_string(user_path)?; let text = read_to_string(user_path)?;
let mut lines: Vec<&str> = text.split('\n').collect(); let (updated, msg) = apply_insert(&text, &args, user_path)?;
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
let updated = lines.join("\n");
write_string(user_path, &updated)?; write_string(user_path, &updated)?;
Ok(msg)
Ok(format!(
"Inserted {} line(s) {} line {} in {user_path}.",
new_lines.len(), placement, line_num
))
} }
} }
+40 -5
View File
@@ -1,20 +1,28 @@
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; use crate::tools::{
use super::resolve; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT,
};
use super::{classify_memory, resolve, MemScope};
/// Directories to skip unconditionally when walking. /// Directories to skip unconditionally when walking.
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read /// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
/// working directory) never reveals the contents of the secrets store. /// working directory) never reveals the contents of the secrets store.
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"]; const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
pub struct ListFiles; pub struct ListFiles {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl ListFiles { impl ListFiles {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
} }
impl Tool for ListFiles { impl Tool for ListFiles {
@@ -27,7 +35,8 @@ impl Tool for ListFiles {
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \ Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Skips .git, target, node_modules, .cache. \ Skips .git, target, node_modules, .cache. \
Returns a JSON array of paths relative to the requested directory. \ Returns a JSON array of paths relative to the requested directory. \
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration." Use depth=1 for immediate contents only, depth=2-3 for moderate exploration. \
Listing under user-memory/ (private) or shared-memory/ (shared) lists your memory notes instead of disk."
} }
fn parameters_schema(&self) -> Value { fn parameters_schema(&self) -> Value {
@@ -56,6 +65,32 @@ impl Tool for ListFiles {
truncate_label(&format!("list_files `{path}`"), MAX_LABEL_SHORT) truncate_label(&format!("list_files `{path}`"), MAX_LABEL_SHORT)
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute). Memory is a
/// flat key space, so `depth`/`dirs_only` don't apply — the whole subtree
/// under the prefix is returned, keyed relative to the requested directory.
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = args["path"].as_str().unwrap_or("").to_string();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
Box::new(SimpleExecution::new(Box::pin(async move {
// Treat `rel` as a directory prefix: match `rel/…` (or everything at
// the root), then strip it so results are relative to what was asked.
let prefix = if rel.is_empty() || rel.ends_with('/') { rel } else { format!("{rel}/") };
let entries = crate::db::memory_docs::list(&pool, &prefix).await?;
let mut paths: Vec<String> = entries.into_iter()
.map(|e| e.path.strip_prefix(&prefix).unwrap_or(&e.path).to_string())
.collect();
paths.sort();
Ok(ToolResult::Text(serde_json::to_string(&paths)?))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str().unwrap_or("."); let user_path = args["path"].as_str().unwrap_or(".");
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize; let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
@@ -0,0 +1,125 @@
//! `memory_search` — full-text search over the virtual memory namespace (§5).
//!
//! Unlike the fs-tools, this does **not** route a path: it searches note *content*
//! through the `memory_docs` FTS5 index (`memory_docs::search`, bm25-ranked with a
//! highlighted snippet). `user-memory` is the caller's own pool (`ToolContext::pool`);
//! `shared-memory` is the system pool captured at registration. Kept a distinct tool
//! rather than folding FTS into `grep_files`: grep is regex-per-line over a tree,
//! this is ranked keyword recall — different semantics, so different names.
use std::sync::Arc;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::db::memory_docs::{self, MemoryHit};
use crate::tools::{
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT,
};
pub struct MemorySearch {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl MemorySearch {
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
}
/// Turns free text into a robust FTS5 MATCH query: each whitespace token becomes a
/// quoted term (AND-combined), so arbitrary input — colons, dashes, punctuation —
/// can't trip an FTS5 syntax error. Returns `None` when there are no tokens.
fn fts_query(input: &str) -> Option<String> {
let terms: Vec<String> = input
.split_whitespace()
.map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
.collect();
(!terms.is_empty()).then(|| terms.join(" "))
}
fn render_hits(store: &str, hits: &[MemoryHit], out: &mut String) {
for h in hits {
out.push_str(&format!("[{store}] {}{}\n", h.path, h.snippet));
}
}
impl Tool for MemorySearch {
fn name(&self) -> &str { "memory_search" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection }
fn description(&self) -> &str {
"Full-text search across your memory notes by keyword, ranked by relevance. \
Searches user-memory/ (private) and shared-memory/ (shared); set scope to narrow it. \
Returns matching note paths with a short highlighted snippet — open one with read_file. \
Use this to recall where you wrote something instead of listing and reading notes one by one."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Keywords to search for. Plain words; all must appear." },
"scope": {
"type": "string",
"enum": ["all", "private", "shared"],
"description": "Which store to search: 'private' (user-memory), 'shared' (shared-memory), or 'all' (default)."
},
"limit": { "type": "integer", "description": "Max results per store (default 10, max 50).", "default": 10 }
},
"required": ["query"]
})
}
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
let q = args["query"].as_str().unwrap_or("?");
truncate_label(&format!("memory_search \"{q}\""), MAX_LABEL_SHORT)
}
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let user_pool = Arc::clone(&ctx.pool);
let shared_pool = Arc::clone(&self.shared_pool);
Box::new(SimpleExecution::new(Box::pin(async move {
let raw = args["query"].as_str().unwrap_or("");
let Some(q) = fts_query(raw) else {
anyhow::bail!("memory_search needs a non-empty query");
};
let scope = args["scope"].as_str().unwrap_or("all");
let limit = args["limit"].as_u64().unwrap_or(10).clamp(1, 50) as i64;
let mut out = String::new();
let mut total = 0usize;
if scope == "all" || scope == "private" {
let hits = memory_docs::search(&user_pool, &q, limit).await?;
total += hits.len();
render_hits("user-memory", &hits, &mut out);
}
if scope == "all" || scope == "shared" {
let hits = memory_docs::search(&shared_pool, &q, limit).await?;
total += hits.len();
render_hits("shared-memory", &hits, &mut out);
}
if total == 0 {
return Ok(ToolResult::Text(format!("No memory notes match {raw:?}.")));
}
Ok(ToolResult::Text(format!("{total} result(s):\n{out}")))
})))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fts_query_quotes_tokens_and_survives_punctuation() {
assert_eq!(fts_query("spesa settimana").unwrap(), "\"spesa\" \"settimana\"");
// colons / dashes would be FTS5 operators unquoted; quoting makes them literal
assert_eq!(fts_query("budget: 2026-07").unwrap(), "\"budget:\" \"2026-07\"");
// an embedded quote is escaped by doubling
assert_eq!(fts_query("say \"hi\"").unwrap(), "\"say\" \"\"\"hi\"\"\"");
assert!(fts_query(" ").is_none());
}
}
+262 -9
View File
@@ -2,15 +2,18 @@ mod edit_file;
mod grep_files; mod grep_files;
mod insert_at_line; mod insert_at_line;
mod list_files; mod list_files;
mod memory_search;
mod read_file; mod read_file;
mod replace_lines; mod replace_lines;
mod search_file; mod search_file;
mod write_file; mod write_file;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::Value; use serde_json::Value;
use sqlx::SqlitePool;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
@@ -25,11 +28,65 @@ pub use edit_file::EditFile;
pub use grep_files::GrepFiles; pub use grep_files::GrepFiles;
pub use insert_at_line::InsertAtLine; pub use insert_at_line::InsertAtLine;
pub use list_files::ListFiles; pub use list_files::ListFiles;
pub use memory_search::MemorySearch;
pub use read_file::ReadFile; pub use read_file::ReadFile;
pub use replace_lines::ReplaceLines; pub use replace_lines::ReplaceLines;
pub use search_file::SearchFile; pub use search_file::SearchFile;
pub use write_file::WriteFile; pub use write_file::WriteFile;
// ── Virtual memory namespace (blueprint §5) ───────────────────────────────────
//
// Two sibling top-level roots, each backed by the `memory_docs` table in SQLite
// rather than the disk. The fs-tools intercept these prefixes in `run_with` and
// route reads/writes to the `memory_docs` accessor on the right pool, so the LLM
// uses ordinary read/write/list against what looks like two folders.
/// The current user's **private** memory — routed to `ctx.pool` (`{userid}.db`,
/// behind SQLCipher).
pub const USER_MEMORY_ROOT: &str = "user-memory";
/// The instance-wide **shared** memory — routed to the system pool (`system.db`,
/// cleartext, readable by every member).
pub const SHARED_MEMORY_ROOT: &str = "shared-memory";
/// Which memory store a path resolves to.
pub(crate) enum MemScope {
/// `user-memory/…` → the caller's own pool (`ToolContext::pool`).
User,
/// `shared-memory/…` → the shared system pool.
Shared,
}
/// A path that falls inside the virtual memory namespace: the store it belongs to
/// and the note key **relative to that store's root** (the root prefix stripped).
pub(crate) struct MemRef {
pub scope: MemScope,
pub rel: String,
}
/// Classifies a user-supplied path. Returns `Some` when it lands under one of the
/// virtual memory roots — to be routed to SQLite — and `None` for an ordinary
/// disk path.
///
/// The **first** component decides the store, taken raw *before* normalization, so
/// a `..` in the tail can never drop the memory root and silently fall back to a
/// disk path. The tail is then normalized (resolving `.`/`..`) and clamped at the
/// store root, so a memory path stays within its store and an absolute path is
/// always disk.
pub(crate) fn classify_memory(user_path: &str) -> Option<MemRef> {
let mut parts = user_path.trim_start_matches("./").splitn(2, ['/', '\\']);
let scope = match parts.next()? {
USER_MEMORY_ROOT => MemScope::User,
SHARED_MEMORY_ROOT => MemScope::Shared,
_ => return None,
};
// Normalize the tail within the store (empty = the root itself). `..` clamps
// at the root rather than escaping upward.
let tail = parts.next().unwrap_or("");
let rel = lexical_normalize(Path::new(tail)).to_string_lossy().replace('\\', "/");
Some(MemRef { scope, rel })
}
/// Resolve a user-supplied path: /// Resolve a user-supplied path:
/// - starts with `/` → absolute path, used as-is /// - starts with `/` → absolute path, used as-is
/// - otherwise → relative to the process working directory (project root) /// - otherwise → relative to the process working directory (project root)
@@ -127,13 +184,209 @@ pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
.with_context(|| format!("Failed to write: {}", abs.display())) .with_context(|| format!("Failed to write: {}", abs.display()))
} }
pub fn register_all(registry: &mut ToolRegistry) { /// Registers the filesystem tools. `shared_pool` is the system (`shared-memory`)
registry.register(EditFile::new()); /// pool captured once here — a global singleton — and handed to the memory-aware
registry.register(GrepFiles::new()); /// tools; each still resolves the per-user (`user-memory`) pool per call from the
registry.register(InsertAtLine::new()); /// `ToolContext`.
registry.register(ListFiles::new()); pub fn register_all(registry: &mut ToolRegistry, shared_pool: Arc<SqlitePool>) {
registry.register(ReadFile::new()); registry.register(EditFile::new(Arc::clone(&shared_pool)));
registry.register(ReplaceLines::new()); registry.register(GrepFiles::new()); // not memory-aware yet — see blueprint Prossimi passi
registry.register(SearchFile::new()); registry.register(InsertAtLine::new(Arc::clone(&shared_pool)));
registry.register(WriteFile::new()); registry.register(ListFiles::new(Arc::clone(&shared_pool)));
registry.register(ReadFile::new(Arc::clone(&shared_pool)));
registry.register(ReplaceLines::new(Arc::clone(&shared_pool)));
registry.register(SearchFile::new(Arc::clone(&shared_pool)));
registry.register(MemorySearch::new(Arc::clone(&shared_pool)));
registry.register(WriteFile::new(shared_pool));
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use serde_json::json;
use crate::tools::{ExecutionOutcome, Tool, ToolContext};
#[test]
fn classify_memory_splits_root_from_key() {
let u = classify_memory("user-memory/notes/x.md").unwrap();
assert!(matches!(u.scope, MemScope::User));
assert_eq!(u.rel, "notes/x.md");
let s = classify_memory("./shared-memory/casa.md").unwrap();
assert!(matches!(s.scope, MemScope::Shared));
assert_eq!(s.rel, "casa.md");
// bare roots (with/without trailing slash) resolve to the empty key
assert_eq!(classify_memory("user-memory").unwrap().rel, "");
assert_eq!(classify_memory("shared-memory/").unwrap().rel, "");
// `..` clamps inside the store instead of falling back to a disk path
assert_eq!(classify_memory("user-memory/../secret.md").unwrap().rel, "secret.md");
// ordinary, absolute, and look-alike paths are disk (None)
assert!(classify_memory("src/main.rs").is_none());
assert!(classify_memory("/etc/hosts").is_none());
assert!(classify_memory("user-memoryish/x").is_none());
}
/// A throwaway owner-schema pool (as `Arc`, ready for a `ToolContext`), plus its
/// dir for cleanup. `tag` + a counter keep parallel tests off the same file.
async fn store(tag: &str) -> (Arc<SqlitePool>, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("skald-fsmem-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
(Arc::new(pool), dir)
}
/// Drives a tool through the context-aware path and returns its text result.
async fn drive(tool: &dyn Tool, ctx: &ToolContext, args: Value) -> Result<String, String> {
let exec = tool.run_with(ctx, args);
match exec.wait().await {
ExecutionOutcome::Completed(r) => Ok(r.to_wire()),
ExecutionOutcome::Failed(e) => Err(e),
ExecutionOutcome::Cancelled => Err("cancelled".into()),
}
}
#[tokio::test]
async fn memory_tools_route_and_isolate_user_vs_shared() {
let (user, udir) = store("user").await;
let (shared, sdir) = store("shared").await;
// The shared pool is captured by the tools; the user pool arrives per call.
let write = WriteFile::new(Arc::clone(&shared));
let read = ReadFile::new(Arc::clone(&shared));
let list = ListFiles::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
// Private write lands in the user pool — and never in the shared one.
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte\npane"}))
.await.unwrap();
assert!(out.starts_with("Created user-memory/spesa.md"), "{out}");
assert!(crate::db::memory_docs::get(&user, "spesa.md").await.unwrap().is_some());
assert!(crate::db::memory_docs::get(&shared, "spesa.md").await.unwrap().is_none(),
"a user-memory write must not touch the shared store");
// Shared write lands in the shared pool — and never in the user one.
drive(&write, &ctx, json!({"path":"shared-memory/casa.md","content":"wifi 1234"}))
.await.unwrap();
assert!(crate::db::memory_docs::get(&shared, "casa.md").await.unwrap().is_some());
assert!(crate::db::memory_docs::get(&user, "casa.md").await.unwrap().is_none());
// Read back with 1-based line numbers; a missing note errors.
let r = drive(&read, &ctx, json!({"path":"user-memory/spesa.md"})).await.unwrap();
assert!(r.contains("| latte") && r.contains("| pane"), "{r}");
assert!(drive(&read, &ctx, json!({"path":"user-memory/nope.md"})).await.is_err());
// A second write to the same key overwrites (and says so).
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte"}))
.await.unwrap();
assert!(out.starts_with("Overwrote user-memory/spesa.md"), "{out}");
// Listing returns keys relative to the requested directory.
drive(&write, &ctx, json!({"path":"user-memory/notes/idee.md","content":"x"}))
.await.unwrap();
let l = drive(&list, &ctx, json!({"path":"user-memory"})).await.unwrap();
assert_eq!(serde_json::from_str::<Vec<String>>(&l).unwrap(),
vec!["notes/idee.md".to_string(), "spesa.md".to_string()]);
let l = drive(&list, &ctx, json!({"path":"user-memory/notes"})).await.unwrap();
assert_eq!(serde_json::from_str::<Vec<String>>(&l).unwrap(),
vec!["idee.md".to_string()]);
let _ = std::fs::remove_dir_all(&udir);
let _ = std::fs::remove_dir_all(&sdir);
}
#[tokio::test]
async fn memory_edit_insert_replace_search_route_to_the_note() {
let (user, udir) = store("edit-user").await;
let (shared, sdir) = store("edit-shared").await;
let write = WriteFile::new(Arc::clone(&shared));
let edit = EditFile::new(Arc::clone(&shared));
let insert = InsertAtLine::new(Arc::clone(&shared));
let replace = ReplaceLines::new(Arc::clone(&shared));
let search = SearchFile::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
async fn note(pool: &SqlitePool, path: &str) -> String {
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
}
drive(&write, &ctx, json!({"path":"user-memory/todo.md","content":"latte\npane\nuvoa"}))
.await.unwrap();
// edit_file: fix the typo, in place
let out = drive(&edit, &ctx, json!({"path":"user-memory/todo.md","old":"uvoa","new":"uova"}))
.await.unwrap();
assert_eq!(out, "Edited user-memory/todo.md.");
assert_eq!(note(&user, "todo.md").await, "latte\npane\nuova");
// insert_at_line: add a line after line 1
drive(&insert, &ctx, json!({"path":"user-memory/todo.md","line":1,"content":"burro","placement":"after"}))
.await.unwrap();
assert_eq!(note(&user, "todo.md").await, "latte\nburro\npane\nuova");
// replace_lines: collapse lines 23 into one
drive(&replace, &ctx, json!({"path":"user-memory/todo.md","from_line":2,"to_line":3,"new":"olio"}))
.await.unwrap();
assert_eq!(note(&user, "todo.md").await, "latte\nolio\nuova");
// search_file: find a line inside the note
let s = drive(&search, &ctx, json!({"path":"user-memory/todo.md","query":"olio"})).await.unwrap();
assert!(s.contains("match(es) in user-memory/todo.md"), "{s}");
assert!(s.contains("| olio"), "{s}");
// editing a note that doesn't exist errors, not creates
assert!(drive(&edit, &ctx, json!({"path":"user-memory/ghost.md","old":"a","new":"b"}))
.await.is_err());
let _ = std::fs::remove_dir_all(&udir);
let _ = std::fs::remove_dir_all(&sdir);
}
#[tokio::test]
async fn memory_search_scopes_to_user_shared_or_all() {
let (user, udir) = store("search-user").await;
let (shared, sdir) = store("search-shared").await;
let write = WriteFile::new(Arc::clone(&shared));
let search = MemorySearch::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
// one note in each store, both mentioning "wifi"
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
.await.unwrap();
drive(&write, &ctx, json!({"path":"shared-memory/casa.md","content":"wifi di casa 1234"}))
.await.unwrap();
// scope=private → only the user store
let r = drive(&search, &ctx, json!({"query":"wifi","scope":"private"})).await.unwrap();
assert!(r.contains("[user-memory] rete.md"), "{r}");
assert!(!r.contains("shared-memory"), "{r}");
// scope=shared → only the shared store
let r = drive(&search, &ctx, json!({"query":"wifi","scope":"shared"})).await.unwrap();
assert!(r.contains("[shared-memory] casa.md"), "{r}");
assert!(!r.contains("[user-memory]"), "{r}");
// scope=all (default) → both, and the snippet highlights the term
let r = drive(&search, &ctx, json!({"query":"wifi"})).await.unwrap();
assert!(r.contains("[user-memory] rete.md") && r.contains("[shared-memory] casa.md"), "{r}");
assert!(r.contains("[wifi]"), "snippet should highlight the match: {r}");
// no match → a friendly message, not an error
let r = drive(&search, &ctx, json!({"query":"inesistente"})).await.unwrap();
assert!(r.starts_with("No memory notes match"), "{r}");
let _ = std::fs::remove_dir_all(&udir);
let _ = std::fs::remove_dir_all(&sdir);
}
} }
+68 -32
View File
@@ -1,13 +1,50 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use crate::tools::{
use super::read_to_string; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
};
use super::{classify_memory, read_to_string, MemScope};
pub struct ReadFile; pub struct ReadFile {
/// The `shared-memory` (system) pool. `user-memory` resolves per call from the
/// `ToolContext`; only the shared store is a global singleton captured here.
shared_pool: Arc<SqlitePool>,
}
impl ReadFile { impl ReadFile {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
}
/// Render `content` with 1-based line numbers, honouring the same
/// `start`/`end_line`/`limit` windowing as the disk path. Shared by the on-disk
/// [`ReadFile::execute`] and the `memory/` routing in [`ReadFile::run_with`].
fn number_lines(content: &str, start: usize, end_line: Option<usize>, limit: Option<usize>) -> String {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let end = match (end_line, limit) {
(Some(e), _) => e.min(total),
(None, Some(l)) => (start + l).min(total),
(None, None) => total,
};
if start >= total && total > 0 {
return format!("(file has only {total} lines; start_line {} is out of range)", start + 1);
}
let end = end.max(start);
let width = total.to_string().len().max(3);
lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
.collect::<Vec<_>>()
.join("\n")
} }
impl Tool for ReadFile { impl Tool for ReadFile {
@@ -19,7 +56,8 @@ impl Tool for ReadFile {
Use instead of cat/head/tail in the terminal. \ Use instead of cat/head/tail in the terminal. \
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \ Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \ For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
Use limit to cap output when end_line is unknown." Use limit to cap output when end_line is unknown. \
Paths under user-memory/ (private) or shared-memory/ (shared) read a note from your memory instead of disk."
} }
fn parameters_schema(&self) -> Value { fn parameters_schema(&self) -> Value {
@@ -70,37 +108,35 @@ impl Tool for ReadFile {
} }
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
let start = args["start_line"].as_u64().map(|n| (n as usize).saturating_sub(1)).unwrap_or(0);
let end_line = args["end_line"].as_u64().map(|n| n as usize);
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
Box::new(SimpleExecution::new(Box::pin(async move {
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
anyhow::bail!("No note at {path}");
};
Ok(ToolResult::Text(number_lines(&doc.content, start, end_line, limit)))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str() let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?; .ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let content = read_to_string(user_path)?; let content = read_to_string(user_path)?;
let lines: Vec<&str> = content.lines().collect(); let start = args["start_line"].as_u64().map(|n| (n as usize).saturating_sub(1)).unwrap_or(0);
let total = lines.len(); let end_line = args["end_line"].as_u64().map(|n| n as usize);
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize); let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
let start = args["start_line"].as_u64() Ok(number_lines(&content, start, end_line, limit))
.map(|n| (n as usize).saturating_sub(1))
.unwrap_or(0);
let end = match (args["end_line"].as_u64(), limit) {
(Some(e), _) => (e as usize).min(total),
(None, Some(l)) => (start + l).min(total),
(None, None) => total,
};
if start >= total && total > 0 {
return Ok(format!("(file has only {total} lines; start_line {start_line} is out of range)",
start_line = start + 1));
}
let end = end.max(start);
let width = total.to_string().len().max(3);
let numbered = lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
.collect::<Vec<_>>()
.join("\n");
Ok(numbered)
} }
} }
+70 -32
View File
@@ -1,13 +1,56 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use crate::tools::{
use super::{read_to_string, write_string}; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
};
use super::{classify_memory, read_to_string, write_string, MemScope};
pub struct ReplaceLines; pub struct ReplaceLines {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl ReplaceLines { impl ReplaceLines {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
}
/// Replaces the inclusive 1-based line range with `new`, returning the new content
/// and a result message. Shared by the on-disk `execute` and the `memory/` routing;
/// `display` is the path used in the message.
fn apply_replace(content: &str, args: &Value, display: &str) -> Result<(String, String)> {
let from_line = args["from_line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
let to_line = args["to_line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
let new = args["new"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
let mut lines: Vec<&str> = content.lines().collect();
let total = lines.len();
if from_line > total {
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
}
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = content.ends_with('\n');
let mut updated = lines.join("\n");
if has_trailing { updated.push('\n'); }
let msg = format!(
"Replaced lines {from_line}{to_clamped} in {display} with {} new lines.",
new.lines().count()
);
Ok((updated, msg))
} }
impl Tool for ReplaceLines { impl Tool for ReplaceLines {
@@ -51,38 +94,33 @@ impl Tool for ReplaceLines {
} }
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
Box::new(SimpleExecution::new(Box::pin(async move {
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
anyhow::bail!("No note at {path}");
};
let (updated, msg) = apply_replace(&doc.content, &args, &path)?;
crate::db::memory_docs::upsert(&pool, &rel, &updated).await?;
Ok(ToolResult::Text(msg))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str() let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?; .ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let from_line = args["from_line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
let to_line = args["to_line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
let new = args["new"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
let content = read_to_string(user_path)?; let content = read_to_string(user_path)?;
let mut lines: Vec<&str> = content.lines().collect(); let (updated, msg) = apply_replace(&content, &args, user_path)?;
let total = lines.len();
if from_line > total {
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
}
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = content.ends_with('\n');
let mut updated = lines.join("\n");
if has_trailing { updated.push('\n'); }
write_string(user_path, &updated)?; write_string(user_path, &updated)?;
Ok(msg)
Ok(format!(
"Replaced lines {from_line}{to_clamped} in {user_path} with {} new lines.",
new.lines().count()
))
} }
} }
+78 -43
View File
@@ -1,13 +1,67 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL}; use crate::tools::{
use super::read_to_string; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
};
use super::{classify_memory, read_to_string, MemScope};
pub struct SearchFile; pub struct SearchFile {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl SearchFile { impl SearchFile {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
}
/// Renders the case-insensitive substring search over `text` with context lines.
/// Shared by the on-disk `execute` and the `memory/` routing; `display` is the
/// path shown in the output.
fn render_search(text: &str, args: &Value, display: &str) -> Result<String> {
let query = args["query"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
let lines: Vec<&str> = text.lines().collect();
let lower_query = query.to_lowercase();
let width = lines.len().to_string().len().max(3);
let matches: Vec<usize> = lines.iter().enumerate()
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
return Ok(format!("No matches found for {:?} in {display}.", query));
}
let mut chunks: Vec<(usize, usize)> = Vec::new();
for &m in &matches {
let start = m.saturating_sub(context);
let end = (m + context).min(lines.len() - 1);
if let Some(last) = chunks.last_mut() {
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
}
chunks.push((start, end));
}
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
let mut out = format!("{} match(es) in {display}:\n", match_set.len());
for (ci, (start, end)) in chunks.iter().enumerate() {
if ci > 0 { out.push_str(" ···\n"); }
for idx in *start..=*end {
let marker = if match_set.contains(&idx) { ">" } else { " " };
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
}
}
Ok(out)
} }
impl Tool for SearchFile { impl Tool for SearchFile {
@@ -53,48 +107,29 @@ impl Tool for SearchFile {
} }
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
Box::new(SimpleExecution::new(Box::pin(async move {
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
anyhow::bail!("No note at {path}");
};
Ok(ToolResult::Text(render_search(&doc.content, &args, &path)?))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str() let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?; .ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let query = args["query"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
let text = read_to_string(user_path)?; let text = read_to_string(user_path)?;
let lines: Vec<&str> = text.lines().collect(); render_search(&text, &args, user_path)
let lower_query = query.to_lowercase();
let width = lines.len().to_string().len().max(3);
let matches: Vec<usize> = lines.iter().enumerate()
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
return Ok(format!("No matches found for {:?} in {user_path}.", query));
}
let mut chunks: Vec<(usize, usize)> = Vec::new();
for &m in &matches {
let start = m.saturating_sub(context);
let end = (m + context).min(lines.len() - 1);
if let Some(last) = chunks.last_mut() {
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
}
chunks.push((start, end));
}
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
let mut out = format!("{} match(es) in {user_path}:\n", match_set.len());
for (ci, (start, end)) in chunks.iter().enumerate() {
if ci > 0 { out.push_str(" ···\n"); }
for idx in *start..=*end {
let marker = if match_set.contains(&idx) { ">" } else { " " };
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
}
}
Ok(out)
} }
} }
+39 -5
View File
@@ -1,13 +1,22 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; use crate::tools::{
use super::{resolve, write_string}; SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
truncate_label, MAX_LABEL_SHORT,
};
use super::{classify_memory, resolve, write_string, MemScope};
pub struct WriteFile; pub struct WriteFile {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
shared_pool: Arc<SqlitePool>,
}
impl WriteFile { impl WriteFile {
pub fn new() -> Self { Self } pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
} }
impl Tool for WriteFile { impl Tool for WriteFile {
@@ -18,7 +27,8 @@ impl Tool for WriteFile {
"Create a new file or fully overwrite an existing one. \ "Create a new file or fully overwrite an existing one. \
Use instead of echo/cat heredoc in the terminal. \ Use instead of echo/cat heredoc in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \ Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead." OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead. \
Write Markdown under user-memory/ (private to you) or shared-memory/ (shared with everyone) to save a durable note in your memory instead of on disk."
} }
fn parameters_schema(&self) -> Value { fn parameters_schema(&self) -> Value {
@@ -48,6 +58,30 @@ impl Tool for WriteFile {
truncate_label(&format!("write_file `{path}`"), MAX_LABEL_SHORT) truncate_label(&format!("write_file `{path}`"), MAX_LABEL_SHORT)
} }
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
/// path falls through to the on-disk [`execute`](Self::execute).
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let path = super::path_arg(&args).unwrap_or_default();
let Some(m) = classify_memory(&path) else { return self.run(args); };
let pool = match m.scope {
MemScope::User => Arc::clone(&ctx.pool),
MemScope::Shared => Arc::clone(&self.shared_pool),
};
let rel = m.rel;
let content = args["content"].as_str().map(str::to_string);
Box::new(SimpleExecution::new(Box::pin(async move {
let content = content.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
if rel.is_empty() {
anyhow::bail!("{path} is a memory root, not a note — write to a path like {path}/notes.md");
}
let existed = crate::db::memory_docs::get(&pool, &rel).await?.is_some();
crate::db::memory_docs::upsert(&pool, &rel, &content).await?;
let verb = if existed { "Overwrote" } else { "Created" };
Ok(ToolResult::Text(format!("{verb} {path} ({} bytes).", content.len())))
})))
}
fn execute(&self, args: Value) -> Result<String> { fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str() let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?; .ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
+2 -14
View File
@@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use core_api::PropertyType; use core_api::PropertyType;
use core_api::system_bus::SystemEvent;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::ApiError;
@@ -108,20 +107,9 @@ pub async fn set_property(
return Err(ApiError::not_found("unknown config key")); return Err(ApiError::not_found("unknown config key"));
} }
let old_value = skald.config().get(&p.key).await?; // GlobalConfigManager::set handles the no-op check and emits
// ConfigKeyUpdated on the system bus when the value actually changes.
// No-op if value didn't change.
if old_value.as_deref() == Some(body.value.as_str()) {
return Ok(StatusCode::OK);
}
skald.config().set(&p.key, &body.value).await?; skald.config().set(&p.key, &body.value).await?;
skald.system_bus().send(SystemEvent::ConfigKeyUpdated {
key: p.key.clone(),
old_value,
new_value: body.value,
});
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
+1 -4
View File
@@ -164,12 +164,9 @@ pub async fn run_backend() -> Result<Backend> {
/// Build the plugin list. Extracted so both entry points share the same set. /// Build the plugin list. Extracted so both entry points share the same set.
fn build_plugins() -> Vec<Arc<dyn Plugin>> { fn build_plugins() -> Vec<Arc<dyn Plugin>> {
// NOTE (multi-user slice): telegram-bot, mobile-connector and honcho are
// single-user / global-ChatEventBus-subscriber plugins. They assume one user
// and cannot work under per-user isolation yet, so they are dropped from the
// build until they become multi-user-aware. See blueprint §17 and the plan.
let mut plugins: Vec<Arc<dyn Plugin>> = vec![ let mut plugins: Vec<Arc<dyn Plugin>> = vec![
Arc::new(plugin_tailscale_remote::RemotePlugin::new()), Arc::new(plugin_tailscale_remote::RemotePlugin::new()),
Arc::new(plugin_telegram_bot::TelegramPlugin::new()),
Arc::new(plugin_comfyui::ComfyUIPlugin::new()), Arc::new(plugin_comfyui::ComfyUIPlugin::new()),
Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()), Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()),
Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()), Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()),