diff --git a/Cargo.lock b/Cargo.lock index f10468d..cfde852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,29 +88,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" 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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object", -] - [[package]] name = "argon2" version = "0.5.3" @@ -432,12 +409,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" @@ -668,15 +639,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "combine" version = "4.6.7" @@ -1147,34 +1109,13 @@ dependencies = [ "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]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "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", + "derive_more-impl", ] [[package]] @@ -1335,16 +1276,6 @@ dependencies = [ "serde", ] -[[package]] -name = "dptree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db96968fcf52fe063a98c75df1d1f2b1fba304e7ae29b72fdc81c1165b7e2fd0" -dependencies = [ - "colored", - "futures", -] - [[package]] name = "dtoa" version = "1.0.11" @@ -1475,16 +1406,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "erased-serde" version = "0.4.10" @@ -2267,7 +2188,7 @@ checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" name = "honcho-client" version = "0.1.0" dependencies = [ - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tracing", @@ -2390,7 +2311,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", ] [[package]] @@ -2567,38 +2487,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png 0.18.1", -] - -[[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]] name = "indexmap" version = "1.9.3" @@ -3031,7 +2919,7 @@ dependencies = [ "anyhow", "async-trait", "core-api", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tracing", @@ -3052,12 +2940,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "managed" version = "0.8.0" @@ -3097,7 +2979,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -3179,16 +3061,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "muda" version = "0.19.3" @@ -3606,15 +3478,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3930,26 +3793,6 @@ dependencies = [ "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]] name = "pin-project-lite" version = "0.2.17" @@ -3992,7 +3835,7 @@ dependencies = [ "anyhow", "async-trait", "core-api", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -4007,48 +3850,12 @@ dependencies = [ "async-trait", "core-api", "parking_lot", - "reqwest 0.13.4", + "reqwest", "serde_json", "tokio", "tracing", ] -[[package]] -name = "plugin-honcho" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "core-api", - "honcho-client", - "serde_json", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "plugin-mobile-connector" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum", - "chrono", - "core-api", - "hex", - "image", - "qrcode", - "rand 0.9.4", - "serde", - "serde_json", - "skald-relay-client", - "skald-relay-common", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "plugin-tailscale-remote" version = "0.1.0" @@ -4064,24 +3871,6 @@ dependencies = [ "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]] name = "plugin-transcribe-whisper-local" version = "0.1.0" @@ -4103,7 +3892,7 @@ dependencies = [ "anyhow", "async-trait", "core-api", - "reqwest 0.13.4", + "reqwest", "serde_json", "tokio", "tracing", @@ -4116,7 +3905,7 @@ dependencies = [ "anyhow", "async-trait", "core-api", - "reqwest 0.13.4", + "reqwest", "serde_json", "tokio", "tracing", @@ -4445,31 +4234,6 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" -dependencies = [ - "image", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -4479,61 +4243,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -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.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -4647,15 +4356,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "redox_syscall" version = "0.5.18" @@ -4742,48 +4442,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "reqwest" version = "0.13.4" @@ -4823,19 +4481,10 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - [[package]] name = "ring" version = "0.17.14" @@ -4952,7 +4601,6 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -5122,7 +4770,7 @@ checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.11.1", "cssparser", - "derive_more 2.1.1", + "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", @@ -5475,14 +5123,11 @@ dependencies = [ "notify", "plugin-comfyui", "plugin-elevenlabs", - "plugin-honcho", - "plugin-mobile-connector", "plugin-tailscale-remote", - "plugin-telegram-bot", "plugin-transcribe-whisper-local", "plugin-tts-kokoro", "plugin-tts-orpheus-3b", - "reqwest 0.13.4", + "reqwest", "rustls", "serde", "serde_json", @@ -5529,7 +5174,7 @@ dependencies = [ "quote", "rand 0.10.1", "regex", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "sha2 0.10.9", @@ -5623,7 +5268,7 @@ dependencies = [ "jsonwebtoken", "prost", "rand 0.8.6", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "sha2 0.10.9", @@ -5931,19 +5576,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "static_assertions" version = "1.1.0" @@ -6139,18 +5771,6 @@ dependencies = [ "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]] name = "tao" version = "0.35.3" @@ -6238,7 +5858,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "serde_repr", @@ -6421,76 +6041,6 @@ dependencies = [ "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]] name = "tempfile" version = "3.27.0" @@ -8004,19 +7554,6 @@ dependencies = [ "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]] name = "wasm-streams" version = "0.5.0" @@ -8052,16 +7589,6 @@ dependencies = [ "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]] name = "web_atoms" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 3661065..638f890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,7 @@ members = [ "crates/core-api", "crates/mcp-client", "crates/plugin-tailscale-remote", - "crates/plugin-honcho", "crates/plugin-transcribe-whisper-local", - "crates/plugin-telegram-bot", "crates/plugin-comfyui", "crates/plugin-tts-orpheus-3b", "crates/plugin-tts-kokoro", @@ -18,7 +16,6 @@ members = [ "crates/skald-relay-common", "crates/skald-relay-server", "crates/skald-relay-client", - "crates/plugin-mobile-connector", ] resolver = "2" @@ -84,14 +81,11 @@ llm-client = { path = "crates/llm-client" } core-api = { path = "crates/core-api" } mcp-client = { path = "crates/mcp-client" } plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" } -plugin-honcho = { path = "crates/plugin-honcho" } plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true } -plugin-telegram-bot = { path = "crates/plugin-telegram-bot" } plugin-comfyui = { path = "crates/plugin-comfyui" } plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" } plugin-tts-kokoro = { path = "crates/plugin-tts-kokoro" } plugin-elevenlabs = { path = "crates/plugin-elevenlabs" } -plugin-mobile-connector = { path = "crates/plugin-mobile-connector" } # ── Desktop bundle (Tauri) ─────────────────────────────────────────────────── # Optional, activated by the `desktop` feature. Wraps the headless server in a diff --git a/crates/core-api/src/plugin.rs b/crates/core-api/src/plugin.rs index 807df45..8e83a1a 100644 --- a/crates/core-api/src/plugin.rs +++ b/crates/core-api/src/plugin.rs @@ -6,7 +6,6 @@ use serde_json::Value; use tokio::sync::RwLock; use crate::approval::ApprovalApi; -use crate::bus::ChatEventBus; use crate::command::CommandApi; use crate::system_bus::SystemEventBus; use crate::chat_hub::ChatHubApi; @@ -49,7 +48,6 @@ pub struct PluginContext { pub tts_provider: Arc, pub api_provider_registry: Arc, pub location: Arc, - pub event_bus: Arc, pub system_bus: Arc, pub web_port: u16, pub remote_slot: Arc>>>, diff --git a/crates/core-api/src/tool.rs b/crates/core-api/src/tool.rs index f282b7a..d3e7231 100644 --- a/crates/core-api/src/tool.rs +++ b/crates/core-api/src/tool.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::pin::Pin; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use anyhow::Result; use serde_json::Value; @@ -34,6 +34,24 @@ pub enum ToolCategory { Config, } +// ── ToolContext ─────────────────────────────────────────────────────────────── + +/// Per-invocation execution context threaded into a tool's [`Tool::run_with`]. +/// +/// Carries the identity of the session driving the call and the owner's database +/// pool. Owner-bound tools (e.g. cron management) read `pool` to act on the +/// caller's own `{userid}.db` rather than a manager captured globally at +/// registration time. Context-free tools ignore it — the default `run_with` +/// delegates to `run`, so most tools need no change. +#[derive(Clone)] +pub struct ToolContext { + /// The session that issued this tool call. Ids are local to `pool`. + pub session_id: i64, + /// The owner's unlocked database pool (per-user in multi-user mode; the shared + /// `system.db` in the transitional single-pool state). + pub pool: Arc, +} + // ── Tool trait ──────────────────────────────────────────────────────────────── /// A single LLM-callable tool. @@ -91,6 +109,15 @@ pub trait Tool: Send + Sync { Box::new(SimpleExecution::new(self.execute_typed(args))) } + /// Context-aware variant of [`run`](Self::run): the session driver threads a + /// [`ToolContext`] (session id + owner pool) so owner-bound tools can act on + /// the caller's own database instead of a globally-captured manager. The + /// default ignores the context and delegates to `run`, so context-free tools + /// need no change. + fn run_with<'a>(&'a self, _ctx: &ToolContext, args: Value) -> Box { + self.run(args) + } + /// Logical category of this tool. fn category(&self) -> ToolCategory; diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index a5ad228..d463982 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -109,7 +109,6 @@ impl PluginManager { tts_provider: Arc::clone(skald.tts_manager()) as _, api_provider_registry: Arc::clone(skald.provider_registry()) as _, location: Arc::clone(skald.location_manager()) as _, - event_bus: Arc::clone(skald.event_bus()), system_bus: Arc::clone(skald.system_bus()), web_port, remote_slot: Arc::clone(skald.remote()), diff --git a/crates/skald-core/src/session/handler/llm_loop.rs b/crates/skald-core/src/session/handler/llm_loop.rs index 18d7b7c..386da4b 100644 --- a/crates/skald-core/src/session/handler/llm_loop.rs +++ b/crates/skald-core/src/session/handler/llm_loop.rs @@ -9,7 +9,7 @@ use crate::chatbot::{LlmTurn, ToolCall}; use crate::db::{chat_history, chat_llm_tools}; use crate::events::ServerEvent; use crate::tools::{ - ExecutionOutcome, SimpleExecution, ToolDescriptionLength, ToolExecution, ToolResult, + ExecutionOutcome, SimpleExecution, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult, }; use futures::stream::{self, StreamExt}; @@ -426,6 +426,9 @@ impl ChatSessionHandler { } // Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills // the child via kill_on_drop when the work future is dropped on /stop). - self.tools.run(name, args) + // The ToolContext carries this session's id and owner pool so owner-bound + // registry tools (e.g. cron management) act on the caller's own database. + let ctx = ToolContext { session_id: self.session_id, pool: Arc::clone(&self.db) }; + self.tools.run(name, &ctx, args) } } diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index f8575f8..1f57c15 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -52,6 +52,17 @@ impl Skald { // Runtime / cross-cutting pub fn db(&self) -> &Arc { &self.rt.db } pub fn users(&self) -> &Arc { &self.rt.users } + + /// The caller's per-user owner-bound runtime (chat/hub/cron/interaction), + /// built lazily on first use. `None` when the user's database is still locked + /// (not logged in). The pool is the unlock token (§9); a present pool means an + /// unlocked database, so a context can be built for it. + pub async fn user_context(&self, user_id: &str) -> Option> { + let pool = self.rt.users.pool_of(user_id)?; + self.rt_user_contexts().resolve(user_id, pool).await.ok() + } + + fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts } pub fn sessions(&self) -> &Arc { &self.rt.sessions } pub fn config(&self) -> &Arc { &self.rt.config } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index 927637c..fd3fc2c 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -226,7 +226,7 @@ impl Tools { Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp))); tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp))); - tool_registry.register(crate::tools::cron_jobs::DeleteCronJob(Arc::clone(&tasks.cron))); + tool_registry.register(crate::tools::cron_jobs::DeleteCronJob); tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager))); diff --git a/crates/skald-core/src/skald/mod.rs b/crates/skald-core/src/skald/mod.rs index 68d0c60..4b30dd5 100644 --- a/crates/skald-core/src/skald/mod.rs +++ b/crates/skald-core/src/skald/mod.rs @@ -23,10 +23,13 @@ mod accessors; mod bundles; mod runtime; mod supervisor; +mod user_context; mod wiring; use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tasks, Tools}; use runtime::Runtime; +use user_context::{UserContextFactory, UserContextRegistry}; +pub use user_context::UserContext; use wiring::{spawn_background, wire}; pub struct Skald { @@ -39,6 +42,10 @@ pub struct Skald { conversation: Conversation, interaction: Interaction, infra: Infra, + /// Per-user owner-bound runtimes (chat/hub/cron/interaction), built lazily on + /// first use after a user's pool is unlocked. The global bundles above still + /// serve deferred subsystems and the not-yet-migrated call sites. + user_contexts: UserContextRegistry, } impl Skald { @@ -68,8 +75,15 @@ impl Skald { wire(&tasks, &conversation, &integrations, &interaction); spawn_background(&rt, &tasks, &conversation, &integrations, config); + // Per-user context factory: captures the global capability managers, so a + // per-user chat/hub/cron/interaction stack can be stamped out on demand. + let user_contexts = UserContextRegistry::new(UserContextFactory::new( + &rt, &models, &media, &tools, &integrations, &conversation, config, + )); + let skald = Arc::new(Skald { rt, models, media, tools, integrations, tasks, conversation, interaction, infra, + user_contexts, }); // Inject the fully-constructed instance into the plugin manager — the one diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs new file mode 100644 index 0000000..58dd13b --- /dev/null +++ b/crates/skald-core/src/skald/user_context.rs @@ -0,0 +1,238 @@ +//! Per-user runtime context (blueprint §9 / §11 / §5.1). +//! +//! The owner-bound managers — chat sessions, chat hub, cron, and the +//! approval/clarification/elicitation/inbox interaction stack — must operate on +//! *one* user's `{userid}.db` and emit on *one* user's server→client channel, so +//! that no chat content, WS event, job or pending approval crosses between users. +//! They are built **lazily** on first use after the user's pool is unlocked, and +//! live exactly as long as that pool (§9: from first login until restart). +//! +//! `UserManager` stays the pool-lifecycle owner (§11 boundary). This factory sits +//! at the `Skald` layer, where the global *capability* managers (LLM, tools, MCP, +//! memory, providers) are visible, and stamps out the per-user instances against a +//! given pool, wiring their construction cycles and starting the per-user cron +//! loop. Capability managers are shared by reference; only owner-bound state is +//! per-user. +//! +//! Split of pools inside one context: session/history/jobs/hub use the **user +//! pool**; approval *rules* and `known_tools` are instance-wide registry data, so +//! `ApprovalManager` and `ToolDiscovery` read the **registry pool** (`system.db`) +//! while `ApprovalManager` still emits on the user's channel and keeps its own +//! pending map. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Result; +use chrono_tz::Tz; +use sqlx::SqlitePool; +use tokio::sync::{broadcast, Mutex}; +use tokio_util::sync::CancellationToken; + +use core_api::events::GlobalEvent; +use core_api::system_bus::SystemEventBus; + +use crate::approval::ApprovalManager; +use crate::chat_event_bus::ChatEventBus; +use crate::chat_hub::ChatHub; +use crate::clarification::ClarificationManager; +use crate::compactor::ContextCompactor; +use crate::config::{CompactionConfig, CoreConfig, DatetimeConfig}; +use crate::cron::TaskManager; +use crate::elicitation::ElicitationManager; +use crate::image_generate::ImageGeneratorManager; +use crate::inbox::Inbox; +use crate::llm::LlmManager; +use crate::mcp::McpManager; +use crate::memory::MemoryManager; +use crate::run_context::RunContextManager; +use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS}; +use crate::session::manager::ChatSessionManager; +use crate::tool_discovery::ToolDiscovery; +use crate::tools::ToolRegistry; + +use super::bundles::{Conversation, Integrations, Media, Models, Tools}; +use super::runtime::Runtime; + +/// One unlocked user's owner-bound runtime. Lifetime = the pool's lifetime. +pub struct UserContext { + pub user_id: String, + pub pool: Arc, + pub event_bus: Arc, + pub sessions: Arc, + pub chat_hub: Arc, + pub cron: Arc, + pub approval: Arc, + pub clarification: Arc, + pub elicitation: Arc, + pub inbox: Inbox, + /// Per-user server→client push channel. WS handlers subscribe here (via the + /// hub) so a user's `ServerEvent`s never reach another user's socket. + pub global_tx: broadcast::Sender, +} + +/// Captures the global capability managers + resolved config once, and stamps out +/// a [`UserContext`] per unlocked pool. +pub(super) struct UserContextFactory { + registry_pool: Arc, + llm_manager: Arc, + tools: Arc, + mcp: Arc, + memory_manager: Arc, + image_generator_manager: Arc, + run_context_manager: Arc, + system_bus: Arc, + supervisor: Arc, + shutdown_token: CancellationToken, + max_history_messages: usize, + max_tool_rounds: usize, + max_parallel_subagents: usize, + max_tool_result_chars: Option, + datetime_config: DatetimeConfig, + compaction: Option, + cron_tz: Option, +} + +impl UserContextFactory { + pub(super) fn new( + rt: &Runtime, + models: &Models, + media: &Media, + tools: &Tools, + integrations: &Integrations, + conversation: &Conversation, + config: &CoreConfig, + ) -> Self { + let cron_tz = config.timezone.as_deref().and_then(|s| s.parse::().ok()); + Self { + registry_pool: Arc::clone(&rt.db), + llm_manager: Arc::clone(&models.llm_manager), + tools: Arc::clone(&tools.tools), + mcp: Arc::clone(&integrations.mcp), + memory_manager: Arc::clone(&models.memory_manager), + image_generator_manager: Arc::clone(&media.image_generator_manager), + run_context_manager: Arc::clone(&conversation.run_context_manager), + system_bus: Arc::clone(&rt.system_bus), + supervisor: Arc::clone(&rt.supervisor), + shutdown_token: rt.shutdown_token.clone(), + max_history_messages: config.llm.max_history_messages, + max_tool_rounds: config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS), + max_parallel_subagents: config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS), + max_tool_result_chars: config.llm.max_tool_result_chars, + datetime_config: DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime }, + compaction: config.llm.compaction.clone(), + cron_tz, + } + } + + async fn build(&self, user_id: &str, pool: SqlitePool) -> Result> { + let pool = Arc::new(pool); + let event_bus = Arc::new(ChatEventBus::new()); + let (global_tx, _) = broadcast::channel::(512); + + // Interaction stack, per-user. Approval reads the shared registry rules but + // emits on this user's channel and keeps its own pending map — no cross-user + // collision on request_id / session_id. Rules are seeded once by the global + // ApprovalManager at boot, so no re-seeding here. + let approval = Arc::new(ApprovalManager::new(Arc::clone(&self.registry_pool), global_tx.clone())); + let clarification = ClarificationManager::new(global_tx.clone()); + let elicitation = ElicitationManager::new(global_tx.clone()); + let inbox = Inbox::new( + Arc::clone(&approval), + Arc::clone(&clarification), + Arc::clone(&elicitation), + Arc::clone(&self.tools), + ); + + let compactor = self.compaction.as_ref().map(|cfg| { + Arc::new(ContextCompactor::new( + cfg.clone(), + Arc::clone(&self.llm_manager), + Arc::clone(&event_bus), + )) + }); + + let manager = Arc::new(ChatSessionManager::new( + Arc::clone(&pool), + Arc::clone(&self.llm_manager), + self.max_history_messages, + self.max_tool_rounds, + self.max_parallel_subagents, + self.max_tool_result_chars, + self.datetime_config.clone(), + Arc::clone(&self.tools), + Arc::clone(&self.mcp), + Arc::clone(&approval), + Arc::clone(&clarification), + Arc::clone(&event_bus), + Arc::clone(&self.memory_manager), + Arc::clone(&self.image_generator_manager), + compactor, + Arc::clone(&self.run_context_manager), + // known_tools is registry data → discovery writes to the registry pool. + Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))), + )); + + let chat_hub = ChatHub::new( + Arc::clone(&pool), + Arc::clone(&manager), + Arc::clone(&approval), + global_tx.clone(), + self.shutdown_token.clone(), + ); + chat_hub.register("web").await; + chat_hub.register("talk").await; + + let cron = TaskManager::new(Arc::clone(&pool), self.cron_tz, Arc::clone(&self.system_bus)); + cron.set_session(Arc::clone(&manager)); + cron.set_hub(Arc::clone(&chat_hub)); + cron.set_self_arc(Arc::clone(&cron)); + chat_hub.set_task_mgr(Arc::clone(&cron)); + + // Per-user cron loop. `start()` observes the shutdown token, so it stops on + // shutdown; adopting it lets the supervisor also join it. The name is leaked + // to satisfy the `&'static str` label — bounded by the (small) user count. + let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str()); + self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone())); + + Ok(Arc::new(UserContext { + user_id: user_id.to_string(), + pool, + event_bus, + sessions: manager, + chat_hub, + cron, + approval, + clarification, + elicitation, + inbox, + global_tx, + })) + } +} + +/// The live per-user contexts, keyed by user id, plus the factory that builds them. +/// A `tokio::Mutex` serialises the build so a context (and its cron loop) is created +/// at most once per user, even under concurrent first-use. +pub(super) struct UserContextRegistry { + factory: UserContextFactory, + contexts: Mutex>>, +} + +impl UserContextRegistry { + pub(super) fn new(factory: UserContextFactory) -> Self { + Self { factory, contexts: Mutex::new(HashMap::new()) } + } + + /// Returns the user's context, building it from `pool` on first use. Idempotent: + /// once built, the same `Arc` is returned until restart. + pub(super) async fn resolve(&self, user_id: &str, pool: SqlitePool) -> Result> { + let mut guard = self.contexts.lock().await; + if let Some(ctx) = guard.get(user_id) { + return Ok(Arc::clone(ctx)); + } + let ctx = self.factory.build(user_id, pool).await?; + guard.insert(user_id.to_string(), Arc::clone(&ctx)); + Ok(ctx) + } +} diff --git a/crates/skald-core/src/tools/cron_jobs.rs b/crates/skald-core/src/tools/cron_jobs.rs index f4b072a..5088972 100644 --- a/crates/skald-core/src/tools/cron_jobs.rs +++ b/crates/skald-core/src/tools/cron_jobs.rs @@ -4,7 +4,7 @@ use anyhow::Result; use serde_json::{Value, json}; use crate::cron::TaskManager; -use crate::tools::{Tool, ToolDescriptionLength}; +use crate::tools::{SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult}; // ── execute_task ────────────────────────────────────────────────────────────── // @@ -134,7 +134,11 @@ pub fn build_execute_task_interface_tool( // ── delete_cron_job ─────────────────────────────────────────────────────────── -pub struct DeleteCronJob(pub Arc); +/// Deleting a job is a plain DELETE on the owner's `scheduled_jobs`, so this tool +/// acts on `ToolContext::pool` (the caller's own database) rather than capturing a +/// globally-scoped `TaskManager` at registration — a job created in a user's own +/// space is deleted from that same space. +pub struct DeleteCronJob; impl Tool for DeleteCronJob { fn name(&self) -> &str { "delete_cron_job" } @@ -159,12 +163,16 @@ impl Tool for DeleteCronJob { format!("delete cron job #{id}") } - fn execute(&self, args: Value) -> Result { - let id = args["id"].as_i64().ok_or_else(|| anyhow::anyhow!("id must be an integer"))?; - if self.0.delete_job(id)? { - Ok(format!("Task {id} deleted.")) - } else { - Ok(format!("No task with id {id}.")) - } + fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { + let pool = Arc::clone(&ctx.pool); + Box::new(SimpleExecution::new(Box::pin(async move { + let id = args["id"].as_i64().ok_or_else(|| anyhow::anyhow!("id must be an integer"))?; + let msg = if crate::db::scheduled_jobs::delete(&pool, id).await? { + format!("Task {id} deleted.") + } else { + format!("No task with id {id}.") + }; + Ok(ToolResult::Text(msg)) + }))) } } diff --git a/crates/skald-core/src/tools/mod.rs b/crates/skald-core/src/tools/mod.rs index 9c6a86d..688cd1a 100644 --- a/crates/skald-core/src/tools/mod.rs +++ b/crates/skald-core/src/tools/mod.rs @@ -55,7 +55,7 @@ use anyhow::Result; use serde_json::Value; pub use core_api::tool::{ - drive_execution, ExecutionOutcome, SimpleExecution, Tool, ToolCategory, + drive_execution, ExecutionOutcome, SimpleExecution, Tool, ToolCategory, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult, truncate_label, }; @@ -207,8 +207,12 @@ impl ToolRegistry { /// Start a [`ToolExecution`] for a registered tool, or `None` if `name` is not /// in the registry (MCP / interface tools are handled by the caller). The /// returned handle borrows the registry, which outlives the turn. - pub fn run(&self, name: &str, args: Value) -> Option> { - self.tools.get(name).map(|tool| tool.run(args)) + /// + /// `ctx` carries the caller's session id and owner pool: owner-bound tools + /// (e.g. cron management) act on `ctx.pool` instead of a globally-captured + /// manager. Context-free tools ignore it via the default `run_with`. + pub fn run(&self, name: &str, ctx: &ToolContext, args: Value) -> Option> { + self.tools.get(name).map(|tool| tool.run_with(ctx, args)) } } diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index 0c610f6..1fbf5b6 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -6,17 +6,20 @@ use axum::{ ws::{Message, WebSocket, WebSocketUpgrade}, }, response::IntoResponse, + Extension, }; use serde::Deserialize; use serde_json::Value; use tokio::sync::broadcast; use tracing::{debug, info, warn}; -use skald_core::chat_hub::{ModelCommandOutcome, SendMessageOptions}; +use skald_core::chat_hub::{ChatHub, ModelCommandOutcome, SendMessageOptions}; use skald_core::events::{ClientMessage, ServerEvent}; use skald_core::skald::Skald; use core_api::command::CommandApi; +use super::guard::AuthUser; + #[derive(Deserialize)] pub struct WsParams { source: Option, @@ -66,18 +69,35 @@ fn dynamic_help(skald: &Skald) -> String { // ── Upgrade ─────────────────────────────────────────────────────────────────── pub async fn handler( - ws: WebSocketUpgrade, - Query(params): Query, - State(skald): State>, + ws: WebSocketUpgrade, + Query(params): Query, + Extension(auth): Extension, + State(skald): State>, ) -> impl IntoResponse { let source = params.source.unwrap_or_else(|| "web".to_string()); - ws.on_upgrade(move |socket| handle_socket(socket, skald, source)) + ws.on_upgrade(move |socket| handle_socket(socket, skald, source, auth.user_id)) } // ── Socket loop ─────────────────────────────────────────────────────────────── -async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) { - let session_handler = match skald.chat_hub().session_handler(&source).await { +async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, user_id: String) { + // Resolve the caller's per-user runtime. The pool is unlocked at login, so an + // authenticated connection normally has a context; a missing one means the + // database re-locked (e.g. a restart with no re-login) — report and close. + let ctx = match skald.user_context(&user_id).await { + Some(c) => c, + None => { + let _ = socket.send(to_msg(&ServerEvent::Error { + message: "session expired — please log in again".to_string(), + })).await; + return; + } + }; + // Every chat operation for this connection goes through the user's own hub, so + // sessions land in their `{userid}.db` and events never cross to another user. + let chat_hub: Arc = Arc::clone(&ctx.chat_hub); + + let session_handler = match chat_hub.session_handler(&source).await { Ok(h) => h, Err(e) => { let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await; @@ -85,9 +105,9 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } }; - info!(source, "WebSocket connected"); + info!(source, user = %user_id, "WebSocket connected"); - let mut rx = skald.chat_hub().events(&source); + let mut rx = chat_hub.events(&source); // Tell this (possibly reloaded) client whether a turn is already running for // its session, so it can restore the STOP button. Sent after subscribing to @@ -109,7 +129,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) // ── resume ──────────────────────────────────────────────────── if is_resume_msg(&text) { info!("web WS: resume requested"); - let hub = Arc::clone(skald.chat_hub()); + let hub = Arc::clone(&chat_hub); let src = source.clone(); tokio::spawn(async move { if let Err(e) = hub.resume(&src).await { @@ -127,10 +147,10 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) session_handler.cancel_pending_questions().await; continue; } - if handle_approval_msg(&text, skald.chat_hub()).await { continue; } + if handle_approval_msg(&text, &chat_hub).await { continue; } if handle_question_answer_msg(&text, &session_handler).await { continue; } if handle_data_msg(&text, &skald) { continue; } - if handle_select_client_msg(&text, &source, skald.chat_hub()).await { continue; } + if handle_select_client_msg(&text, &source, &chat_hub).await { continue; } // ── /sethome ────────────────────────────────────────────────── let client_msg: ClientMessage = match serde_json::from_str(&text) { @@ -146,7 +166,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) let cmd = client_msg.content.trim(); if cmd == "/sethome" { - let msg = match skald.chat_hub().set_home(&source).await { + let msg = match chat_hub.set_home(&source).await { Ok(_) => "🏠 Web impostato come **home**. Le notifiche degli agenti arriveranno qui.".to_string(), Err(e) => format!("⚠️ Errore: {e}"), }; @@ -172,7 +192,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } if cmd == "/context" { - match skald.chat_hub().context_info(&source).await { + match chat_hub.context_info(&source).await { Ok((input, output)) => { let input_str = input.map_or("?".to_string(), |t| t.to_string()); let output_str = output.map_or("?".to_string(), |t| t.to_string()); @@ -192,7 +212,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } if cmd == "/cost" { - match skald.chat_hub().cost_info(&source).await { + match chat_hub.cost_info(&source).await { Ok(Some(c)) => { let _ = socket.send(to_msg(&ServerEvent::Done { message_id: 0, @@ -219,7 +239,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } if cmd == "/compact" { - match skald.chat_hub().force_compact(&source).await { + match chat_hub.force_compact(&source).await { Ok(true) => { let _ = socket.send(to_msg(&ServerEvent::Done { message_id: 0, @@ -246,7 +266,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } if cmd == "/resettools" { - match skald.chat_hub().reset_mcp(&source).await { + match chat_hub.reset_mcp(&source).await { Ok(()) => { let _ = socket.send(to_msg(&ServerEvent::Done { message_id: 0, @@ -264,7 +284,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } if cmd == "/models" { - let items = skald.chat_hub().list_clients_marked(&source).await; + let items = chat_hub.list_clients_marked(&source).await; let content = format_models_md(&items); let _ = socket.send(to_msg(&ServerEvent::Done { message_id: 0, @@ -277,7 +297,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) } if let Some(arg) = cmd.strip_prefix("/model").map(str::trim) { - let outcome = skald.chat_hub().apply_model_command(&source, arg).await; + let outcome = chat_hub.apply_model_command(&source, arg).await; let content = match outcome { ModelCommandOutcome::Set(name) => format!("✅ Model set: **{name}**"), ModelCommandOutcome::Cleared => "✅ Model reset to **auto**.".to_string(), @@ -353,14 +373,14 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) // client lives in ChatHub.selected_clients[source]. The web // `/model` command and the dropdown both flow through // set_selected_client, which broadcasts ClientSelected. - client_name: skald.chat_hub().get_selected_client(&source).await, + client_name: chat_hub.get_selected_client(&source).await, extra_system_context: Some(WEB_FORMAT_CONTEXT.to_string()), // SPA-only tool: lets the assistant open a file in the user's // viewer. Injected here (not in the registry) so it exists only // for ws.rs clients (web + mobile), never for the Telegram plugin. interface_tools: vec![ skald_core::tools::show_file::make_tool( - Arc::clone(skald.chat_hub()), + Arc::clone(&chat_hub), source.clone(), ), ], @@ -368,7 +388,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String) }; // send_message only enqueues — the turn runs on ChatHub's per-source // consumer — so awaiting inline keeps this WS read loop responsive. - if let Err(e) = skald.chat_hub().send_message(&source, &content, opts).await { + if let Err(e) = chat_hub.send_message(&source, &content, opts).await { tracing::error!(error = %e, source = %source, "send_message enqueue failed"); } } diff --git a/src/frontend/api/ws_session.rs b/src/frontend/api/ws_session.rs index 5d56ea7..015761d 100644 --- a/src/frontend/api/ws_session.rs +++ b/src/frontend/api/ws_session.rs @@ -6,24 +6,34 @@ use axum::{ ws::{Message, WebSocket, WebSocketUpgrade}, }, response::IntoResponse, + Extension, }; use tokio::sync::broadcast; use tracing::{info, warn}; use skald_core::skald::Skald; +use super::guard::AuthUser; + pub async fn handler( - ws: WebSocketUpgrade, - Path(id): Path, - State(skald): State>, + ws: WebSocketUpgrade, + Path(id): Path, + Extension(auth): Extension, + State(skald): State>, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, skald, id)) + ws.on_upgrade(move |socket| handle_socket(socket, skald, id, auth.user_id)) } -async fn handle_socket(mut socket: WebSocket, skald: Arc, session_id: i64) { - info!(session_id, "session-watch WS connected"); +async fn handle_socket(mut socket: WebSocket, skald: Arc, session_id: i64, user_id: String) { + info!(session_id, user = %user_id, "session-watch WS connected"); - let mut rx = skald.chat_hub().events("session-watch"); + // Watch this user's own event stream, so a session-watch only ever sees events + // for sessions in the watcher's `{userid}.db`. + let ctx = match skald.user_context(&user_id).await { + Some(c) => c, + None => return, + }; + let mut rx = ctx.chat_hub.events("session-watch"); loop { tokio::select! { diff --git a/src/main.rs b/src/main.rs index a7265ea..9567831 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,15 +164,16 @@ pub async fn run_backend() -> Result { /// Build the plugin list. Extracted so both entry points share the same set. fn build_plugins() -> Vec> { + // 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> = vec![ - Arc::new(plugin_honcho::HonchoPlugin::new()), - Arc::new(plugin_telegram_bot::TelegramPlugin::new("secrets")), Arc::new(plugin_tailscale_remote::RemotePlugin::new()), Arc::new(plugin_comfyui::ComfyUIPlugin::new()), Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()), Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()), Arc::new(plugin_elevenlabs::ElevenLabsPlugin::new()), - Arc::new(plugin_mobile_connector::MobileConnectorPlugin::new()), ]; #[cfg(feature = "whisper-local")] plugins.push(Arc::new(plugin_transcribe_whisper_local::WhisperLocalPlugin::new()));