diff --git a/CLAUDE.md b/CLAUDE.md index e55bcc24..5a127104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,10 @@ src/ ## Key Patterns +### Architecture + +When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing. + ### Error Handling - Use `thiserror` for error types in `error.rs` - Never use `.unwrap()` in production code (tests are fine) @@ -331,13 +335,13 @@ Key test patterns: WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities. -1. Create a new crate in `examples/wasm-tools//` +1. Create a new crate in `tools-src/wasm-tools//` 2. Implement the WIT interface (`wit/tool.wit`) 3. Create `.capabilities.json` declaring required permissions 4. Build with `cargo build --target wasm32-wasip2 --release` 5. Install with `ironclaw tool install path/to/tool.wasm` -See `examples/wasm-tools/` for examples. +See `tools-src/wasm-tools/` for examples. ## Tool Architecture Principles diff --git a/Cargo.lock b/Cargo.lock index 4bc0af99..1377c479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,21 +663,6 @@ dependencies = [ "winx", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -775,6 +760,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -790,20 +784,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -819,6 +799,24 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "coolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" +dependencies = [ + "crossterm 0.29.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -969,6 +967,54 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crokey" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +dependencies = [ + "crokey-proc_macros", + "crossterm 0.29.0", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +dependencies = [ + "crossterm 0.29.0", + "proc-macro2", + "quote", + "strict", + "syn 2.0.114", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -988,6 +1034,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1002,7 +1057,6 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", - "futures-core", "mio", "parking_lot", "rustix 0.38.44", @@ -1011,6 +1065,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1040,38 +1112,14 @@ dependencies = [ "cipher", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", + "darling_core", + "darling_macro", ] [[package]] @@ -1088,24 +1136,13 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.114", -] - [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", "syn 2.0.114", ] @@ -1164,6 +1201,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "diff" version = "0.1.13" @@ -1265,6 +1324,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1310,6 +1378,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1347,6 +1421,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -1689,8 +1769,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", "serde", ] @@ -2045,15 +2123,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inout" version = "0.1.4" @@ -2064,19 +2133,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instability" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" -dependencies = [ - "darling 0.20.11", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "io-extras" version = "0.18.4" @@ -2124,7 +2180,7 @@ dependencies = [ "bytes", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", "deadpool-postgres", "dirs 6.0.0", "dotenvy", @@ -2138,12 +2194,12 @@ dependencies = [ "postgres-types", "pretty_assertions", "rand 0.8.5", - "ratatui", "refinery", "regex", "reqwest", "rust_decimal", "rust_decimal_macros", + "rustyline", "secrecy", "secret-service", "security-framework", @@ -2151,6 +2207,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "termimad", "testcontainers-modules", "thiserror 2.0.18", "tokio", @@ -2203,15 +2260,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -2258,6 +2306,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy-regex" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c13b6857ade4c8ee05c3c3dc97d2ab5415d691213825b90d3211c425c1f907" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a95c68db5d41694cea563c86a4ba4dc02141c16ef64814108cb23def4d5438" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.114", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2317,6 +2388,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2332,15 +2409,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -2417,6 +2485,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimad" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b688969b16915f3ecadc7829d5b7779dee4977e503f767f34136803d5c06f" +dependencies = [ + "once_cell", +] + [[package]] name = "mio" version = "1.1.1" @@ -2429,6 +2506,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" @@ -2442,6 +2528,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2982,6 +3080,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -3041,27 +3149,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags 2.10.0", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - [[package]] name = "rayon" version = "1.11.0" @@ -3371,6 +3458,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3469,6 +3565,40 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.30.1", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width 0.2.0", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustyline-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ryu" version = "1.0.22" @@ -3702,7 +3832,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.114", @@ -3840,6 +3970,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + [[package]] name = "stringprep" version = "0.1.5" @@ -3880,28 +4016,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4000,6 +4114,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termimad" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889a9370996b74cf46016ce35b96c248a9ac36d69aab1d112b3e09bc33affa49" +dependencies = [ + "coolor", + "crokey", + "crossbeam", + "lazy-regex", + "minimad", + "serde", + "thiserror 2.0.18", + "unicode-width 0.1.14", +] + [[package]] name = "testcontainers" version = "0.23.3" @@ -4508,17 +4638,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "unicode-width" version = "0.1.14" @@ -4922,7 +5041,7 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools 0.12.1", + "itertools", "log", "object 0.36.7", "smallvec", @@ -5666,7 +5785,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", diff --git a/Cargo.toml b/Cargo.toml index e78724d8..94177005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,9 +48,10 @@ async-trait = "0.1" # CLI clap = { version = "4", features = ["derive", "env"] } -# TUI -ratatui = "0.29" -crossterm = { version = "0.28", features = ["event-stream"] } +# Terminal +crossterm = "0.28" +rustyline = { version = "17", features = ["derive", "with-file-history"] } +termimad = "0.34" # Channel integrations axum = "0.8" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..8f482bce --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 NEAR AI + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE b/LICENSE-MIT similarity index 100% rename from LICENSE rename to LICENSE-MIT diff --git a/README.md b/README.md index d815c833..ce78526a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe ### Always Available -- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks +- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more) - **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks - **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts - **Self-repair** - Automatic detection and recovery of stuck operations @@ -66,7 +66,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe - Rust 1.85+ - PostgreSQL 15+ with pgvector extension -- NEAR AI session token (or other LLM provider) +- NEAR AI account (authentication handled via setup wizard) ### Build @@ -90,36 +90,19 @@ createdb ironclaw # Enable pgvector psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;" - -# Run migrations -refinery migrate -c refinery.toml ``` ## Configuration -Copy `.env.example` to `.env` and configure: +Run the setup wizard to configure IronClaw: ```bash -# Required -DATABASE_URL=postgres://user:pass@localhost/ironclaw -NEARAI_SESSION_TOKEN=sess_... - -# Optional: Enable channels -TELEGRAM_BOT_TOKEN=... -WHATSAPP_ACCESS_TOKEN=... -SLACK_BOT_TOKEN=xoxb-... -HTTP_PORT=8080 +ironclaw setup ``` -### Environment Variables - -| Variable | Description | Required | -|----------|-------------|----------| -| `DATABASE_URL` | PostgreSQL connection string | Yes | -| `NEARAI_SESSION_TOKEN` | NEAR AI authentication token | Yes | -| `NEARAI_MODEL` | Model to use (default: claude-3-5-sonnet) | No | -| `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No | -| `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets | +The wizard handles database connection, NEAR AI authentication (via browser OAuth), +and secrets encryption (using your system keychain). All settings are saved to +`~/.ironclaw/settings.toml`. ## Security @@ -162,10 +145,10 @@ External content passes through multiple security layers: ``` ┌─────────────────────────────────────────────────────────────────┐ │ Channels │ -│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │ -│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │ -│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │ -│ └──────────┴─────────────┴────────────┘ │ +│ ┌──────┐ ┌──────┐ ┌──────────────┐ │ +│ │ REPL │ │ HTTP │ │ WASM Channels│ │ +│ └──┬───┘ └──┬───┘ └──────┬───────┘ │ +│ └─────────┴─────────────┘ │ │ │ │ │ ┌────▼────┐ │ │ │ Router │ Intent classification │ @@ -206,28 +189,17 @@ External content passes through multiple security layers: ## Usage -### CLI Mode - ```bash -# Start interactive CLI +# First-time setup (configures database, auth, etc.) +ironclaw setup + +# Start interactive REPL cargo run # With debug logging RUST_LOG=ironclaw=debug cargo run ``` -### HTTP Server - -```bash -# Start with HTTP webhook server -HTTP_PORT=8080 cargo run - -# Send a request -curl -X POST http://localhost:8080/webhook \ - -H "Content-Type: application/json" \ - -d '{"message": "Hello, IronClaw!"}' -``` - ## Development ```bash diff --git a/channels-src/slack/Cargo.lock b/channels-src/slack/Cargo.lock new file mode 100644 index 00000000..4e646b06 --- /dev/null +++ b/channels-src/slack/Cargo.lock @@ -0,0 +1,497 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slack-channel" +version = "0.1.0" +dependencies = [ + "hex", + "hmac", + "serde", + "serde_json", + "sha2", + "wit-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index adb2c5aa..c54af12f 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, - OutgoingHttpResponse, PollConfig, + OutgoingHttpResponse, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; @@ -269,6 +269,8 @@ impl Guest for SlackChannel { } } + fn on_status(_update: StatusUpdate) {} + fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down"); } @@ -339,7 +341,7 @@ fn emit_message( // Strip @ mentions of the bot from the text for cleaner messages let cleaned_text = strip_bot_mention(&text); - channel_host::emit_message(EmittedMessage { + channel_host::emit_message(&EmittedMessage { user_id, user_name: None, // Could fetch from Slack API if needed content: cleaned_text, diff --git a/channels/whatsapp/Cargo.lock b/channels-src/whatsapp/Cargo.lock similarity index 100% rename from channels/whatsapp/Cargo.lock rename to channels-src/whatsapp/Cargo.lock diff --git a/channels/whatsapp/Cargo.toml b/channels-src/whatsapp/Cargo.toml similarity index 100% rename from channels/whatsapp/Cargo.toml rename to channels-src/whatsapp/Cargo.toml diff --git a/channels/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs similarity index 99% rename from channels/whatsapp/src/lib.rs rename to channels-src/whatsapp/src/lib.rs index 346c0f23..e28340b8 100644 --- a/channels/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, - OutgoingHttpResponse, + OutgoingHttpResponse, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; @@ -417,6 +417,8 @@ impl Guest for WhatsAppChannel { } } + fn on_status(_update: StatusUpdate) {} + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, diff --git a/channels/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json similarity index 100% rename from channels/whatsapp/whatsapp.capabilities.json rename to channels-src/whatsapp/whatsapp.capabilities.json diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index a56960ba..bc905e32 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -295,11 +295,19 @@ impl Agent { } } _ => { - // No target configured, just log - tracing::info!( - "Heartbeat notification (no target configured): {}", - &response.content - ); + // No explicit target, broadcast to all channels + // for the default user so notifications actually + // reach someone instead of vanishing into logs. + let results = channels.broadcast_all("default", response).await; + for (ch, result) in results { + if let Err(e) = result { + tracing::warn!( + "Failed to broadcast heartbeat to {}: {}", + ch, + e + ); + } + } } } } diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index e6f25379..115b8159 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -178,7 +178,7 @@ impl HeartbeatRunner { pub async fn check_heartbeat(&self) -> HeartbeatResult { // Get the heartbeat checklist let checklist = match self.workspace.heartbeat_checklist().await { - Ok(Some(content)) if !content.trim().is_empty() => content, + Ok(Some(content)) if !is_effectively_empty(&content) => content, Ok(_) => return HeartbeatResult::Skipped, Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)), }; @@ -257,6 +257,45 @@ impl HeartbeatRunner { } } +/// Check if heartbeat content is effectively empty. +/// +/// Returns true if the content contains only: +/// - Whitespace +/// - Markdown headers (lines starting with #) +/// - HTML comments (``) +/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`) +/// +/// This skips the LLM call when the user hasn't added real tasks yet, +/// saving API costs. +fn is_effectively_empty(content: &str) -> bool { + let without_comments = strip_html_comments(content); + + without_comments.lines().all(|line| { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed == "- [ ]" + || trimmed == "- [x]" + || trimmed == "-" + || trimmed == "*" + }) +} + +/// Remove HTML comments from content. +fn strip_html_comments(content: &str) -> String { + let mut result = String::with_capacity(content.len()); + let mut rest = content; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + end + 3..], + None => return result, // unclosed comment, treat rest as comment + } + } + result.push_str(rest); + result +} + /// Spawn the heartbeat runner as a background task. /// /// Returns a handle that can be used to stop the runner. @@ -301,4 +340,107 @@ mod tests { let disabled = HeartbeatConfig::default().disabled(); assert!(!disabled.enabled); } + + // ==================== strip_html_comments ==================== + + #[test] + fn test_strip_html_comments_no_comments() { + assert_eq!(strip_html_comments("hello world"), "hello world"); + } + + #[test] + fn test_strip_html_comments_single() { + assert_eq!( + strip_html_comments("beforeafter"), + "beforeafter" + ); + } + + #[test] + fn test_strip_html_comments_multiple() { + let input = "abc"; + assert_eq!(strip_html_comments(input), "abc"); + } + + #[test] + fn test_strip_html_comments_multiline() { + let input = "# Title\n\nreal content"; + assert_eq!(strip_html_comments(input), "# Title\n\nreal content"); + } + + #[test] + fn test_strip_html_comments_unclosed() { + let input = "before")); + } + + #[test] + fn test_effectively_empty_empty_checkboxes() { + assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]")); + } + + #[test] + fn test_effectively_empty_bare_list_markers() { + assert!(is_effectively_empty("-\n*\n-")); + } + + #[test] + fn test_effectively_empty_seeded_template() { + let template = "\ +# Heartbeat Checklist + +"; + assert!(is_effectively_empty(template)); + } + + #[test] + fn test_effectively_empty_real_checklist() { + let content = "\ +# Heartbeat Checklist + +- [ ] Check for unread emails needing a reply +- [ ] Review today's calendar for upcoming meetings"; + assert!(!is_effectively_empty(content)); + } + + #[test] + fn test_effectively_empty_mixed_real_and_headers() { + let content = "# Title\n\nDo something important"; + assert!(!is_effectively_empty(content)); + } + + #[test] + fn test_effectively_empty_comment_plus_real_content() { + let content = "\nActual task here"; + assert!(!is_effectively_empty(content)); + } } diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs deleted file mode 100644 index 8bae51bb..00000000 --- a/src/channels/cli/app.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Application state for the TUI. - -use std::collections::VecDeque; - -use crate::channels::cli::composer::ChatComposer; -use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; -use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest}; - -/// Events that can occur in the TUI. -#[derive(Debug, Clone)] -pub enum AppEvent { - /// Keyboard/mouse input event. - Input(crossterm::event::Event), - /// Response from the agent. - Response(String), - /// Tool execution started. - ToolStarted { name: String }, - /// Tool execution completed. - ToolCompleted { name: String, success: bool }, - /// Request approval for a tool. - ApprovalRequested(ApprovalRequest), - /// Streaming chunk received. - StreamChunk(String), - /// Log message from the application (shown in status line). - LogMessage(String), - /// Thinking/status message (shown in chat window). - ThinkingMessage(String), - /// Error message (shown in chat window). - ErrorMessage(String), - /// Available models fetched from API. - AvailableModels(Vec), - /// Force a redraw. - Redraw, - /// Quit the application. - Quit, -} - -/// Current input mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InputMode { - /// Normal input mode. - Normal, - /// Editing input. - Editing, - /// Approval overlay is active. - Approval, - /// Model selector overlay is active. - ModelSelector, -} - -/// Message in the chat history. -#[derive(Debug, Clone)] -pub struct ChatMessage { - /// Who sent this message. - pub role: MessageRole, - /// The message content. - pub content: String, - /// Optional status indicator. - pub status: Option, -} - -/// Who sent a message. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageRole { - User, - Agent, - System, -} - -/// Status of a message (for in-progress indicators). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageStatus { - Pending, - InProgress, - Complete, - Error, -} - -impl ChatMessage { - pub fn user(content: impl Into) -> Self { - Self { - role: MessageRole::User, - content: content.into(), - status: None, - } - } - - pub fn agent(content: impl Into) -> Self { - Self { - role: MessageRole::Agent, - content: content.into(), - status: None, - } - } - - pub fn system(content: impl Into) -> Self { - Self { - role: MessageRole::System, - content: content.into(), - status: None, - } - } - - pub fn with_status(mut self, status: MessageStatus) -> Self { - self.status = Some(status); - self - } -} - -/// Application state. -pub struct AppState { - /// Current input mode. - pub mode: InputMode, - /// Chat message history. - pub messages: Vec, - /// Input composer. - pub composer: ChatComposer, - /// Approval overlay (if active). - pub approval: Option, - /// Model selector overlay (if active). - pub model_selector: Option, - /// Scroll offset for messages. - pub scroll_offset: u16, - /// Whether the app should quit. - pub should_quit: bool, - /// Pending approvals queue. - pub pending_approvals: VecDeque, - /// Current streaming response buffer. - pub streaming_buffer: Option, - /// Status line message. - pub status_message: Option, - /// Whether Ctrl+D was pressed (waiting for second press to quit). - pub ctrl_d_pending: bool, - /// Currently selected model. - pub current_model: String, - /// Available models (fetched from API). - pub available_models: Vec, -} - -impl AppState { - /// Create a new app state. - pub fn new() -> Self { - // Load saved model from settings - let settings = crate::settings::Settings::load(); - let current_model = settings.model_or("claude-3-5-sonnet-20241022"); - - Self { - mode: InputMode::Editing, - messages: vec![ChatMessage::system( - "Welcome to IronClaw. Type a message or /help for commands.", - )], - composer: ChatComposer::new(), - approval: None, - model_selector: None, - scroll_offset: 0, - should_quit: false, - pending_approvals: VecDeque::new(), - streaming_buffer: None, - status_message: None, - ctrl_d_pending: false, - current_model, - available_models: Vec::new(), - } - } - - /// Show the model selector. - pub fn show_model_selector(&mut self) { - let request = ModelSelectorRequest { - current_model: self.current_model.clone(), - available_models: self.available_models.clone(), - }; - self.model_selector = Some(ModelSelectorOverlay::new(request)); - self.mode = InputMode::ModelSelector; - } - - /// Handle model selection. - pub fn handle_model_selection(&mut self, selected: Option) { - self.model_selector = None; - self.mode = InputMode::Editing; - - if let Some(model) = selected { - if model != self.current_model { - self.current_model = model.clone(); - // Save to settings - let mut settings = crate::settings::Settings::load(); - if let Err(e) = settings.set_model(&model) { - tracing::warn!("Failed to save model setting: {}", e); - } - self.messages.push(ChatMessage::system(format!( - "Switched to model: {}", - ModelSelectorOverlay::format_model_name(&model) - ))); - } - } - } - - /// Set available models (also updates selector if open). - pub fn set_available_models(&mut self, models: Vec) { - self.available_models = models.clone(); - - // Update the selector if it's currently open - if let Some(ref mut selector) = self.model_selector { - selector.request.available_models = models; - // Reset selection index if it's out of bounds - if selector.selection_index >= selector.request.available_models.len() { - selector.selection_index = 0; - } - } - } - - /// Add a user message to history. - pub fn add_user_message(&mut self, content: impl Into) { - self.messages.push(ChatMessage::user(content)); - self.scroll_to_bottom(); - } - - /// Add an agent response to history. - pub fn add_agent_message(&mut self, content: impl Into) { - // If we were streaming, finalize it - if self.streaming_buffer.is_some() { - self.streaming_buffer = None; - } - // Remove any pending thinking message before adding the response - self.clear_thinking(); - self.messages.push(ChatMessage::agent(content)); - self.scroll_to_bottom(); - } - - /// Add an error message to the chat. - pub fn add_error_message(&mut self, content: impl Into) { - self.messages.push( - ChatMessage::system(format!("Error: {}", content.into())) - .with_status(MessageStatus::Error), - ); - self.scroll_to_bottom(); - } - - /// Add or update a thinking/status message (shown as system message). - pub fn set_thinking(&mut self, content: impl Into) { - let content = content.into(); - // Check if last message is a thinking message (system with InProgress status) - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::System && last.status == Some(MessageStatus::InProgress) { - last.content = content; - return; - } - } - // Add new thinking message - self.messages - .push(ChatMessage::system(content).with_status(MessageStatus::InProgress)); - self.scroll_to_bottom(); - } - - /// Clear any thinking/status message. - pub fn clear_thinking(&mut self) { - // Remove any thinking messages (system with InProgress status) - self.messages.retain(|msg| { - !(msg.role == MessageRole::System && msg.status == Some(MessageStatus::InProgress)) - }); - } - - /// Start streaming a response. - pub fn start_streaming(&mut self) { - self.streaming_buffer = Some(String::new()); - self.messages - .push(ChatMessage::agent("").with_status(MessageStatus::InProgress)); - } - - /// Append to the streaming buffer. - pub fn append_stream(&mut self, chunk: &str) { - if let Some(ref mut buffer) = self.streaming_buffer { - buffer.push_str(chunk); - // Update the last message - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::Agent { - last.content = buffer.clone(); - } - } - } - } - - /// Finalize streaming. - pub fn finish_streaming(&mut self) { - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::Agent { - last.status = Some(MessageStatus::Complete); - } - } - self.streaming_buffer = None; - } - - /// Show an approval request. - pub fn show_approval(&mut self, request: ApprovalRequest) { - self.approval = Some(ApprovalOverlay::new(request)); - self.mode = InputMode::Approval; - } - - /// Queue an approval request. - pub fn queue_approval(&mut self, request: ApprovalRequest) { - if self.approval.is_none() { - self.show_approval(request); - } else { - self.pending_approvals.push_back(request); - } - } - - /// Handle approval response. - pub fn handle_approval_response(&mut self, approved: bool) -> Option { - let request = self.approval.take().map(|o| o.request); - - // Show next pending approval if any - if let Some(next) = self.pending_approvals.pop_front() { - self.show_approval(next); - } else { - self.mode = InputMode::Editing; - } - - if approved { request } else { None } - } - - /// Clear all pending approvals. - pub fn clear_approvals(&mut self) { - self.approval = None; - self.pending_approvals.clear(); - self.mode = InputMode::Editing; - } - - /// Set the status message. - pub fn set_status(&mut self, message: impl Into) { - self.status_message = Some(message.into()); - } - - /// Clear the status message. - pub fn clear_status(&mut self) { - self.status_message = None; - } - - /// Scroll to the bottom of messages. - pub fn scroll_to_bottom(&mut self) { - // Will be calculated based on render area in render.rs - self.scroll_offset = 0; - } - - /// Scroll up. - pub fn scroll_up(&mut self, amount: u16) { - self.scroll_offset = self.scroll_offset.saturating_add(amount); - } - - /// Scroll down. - pub fn scroll_down(&mut self, amount: u16) { - self.scroll_offset = self.scroll_offset.saturating_sub(amount); - } -} - -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} diff --git a/src/channels/cli/composer.rs b/src/channels/cli/composer.rs deleted file mode 100644 index 01e87730..00000000 --- a/src/channels/cli/composer.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Input composer with history and completion. - -use std::collections::VecDeque; - -/// Maximum number of history entries to keep. -const MAX_HISTORY: usize = 100; - -/// Available slash commands for completion. -const SLASH_COMMANDS: &[&str] = &[ - "/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit", -]; - -/// Chat input composer with history navigation and slash command completion. -pub struct ChatComposer { - /// Current input buffer. - buffer: String, - /// Cursor position in the buffer. - cursor: usize, - /// Input history. - history: VecDeque, - /// Current position in history (-1 = current input). - history_index: Option, - /// Saved current input when navigating history. - saved_input: String, - /// Completion candidates. - completions: Vec, - /// Current completion index. - completion_index: Option, -} - -impl ChatComposer { - /// Create a new composer. - pub fn new() -> Self { - Self { - buffer: String::new(), - cursor: 0, - history: VecDeque::with_capacity(MAX_HISTORY), - history_index: None, - saved_input: String::new(), - completions: Vec::new(), - completion_index: None, - } - } - - /// Get the current input buffer. - pub fn buffer(&self) -> &str { - &self.buffer - } - - /// Get the cursor position. - pub fn cursor(&self) -> usize { - self.cursor - } - - /// Check if the buffer is empty. - pub fn is_empty(&self) -> bool { - self.buffer.is_empty() - } - - /// Insert a character at the cursor. - pub fn insert(&mut self, c: char) { - self.clear_completion(); - self.buffer.insert(self.cursor, c); - self.cursor += c.len_utf8(); - } - - /// Insert a string at the cursor. - pub fn insert_str(&mut self, s: &str) { - self.clear_completion(); - self.buffer.insert_str(self.cursor, s); - self.cursor += s.len(); - } - - /// Delete the character before the cursor (backspace). - pub fn backspace(&mut self) { - self.clear_completion(); - if self.cursor > 0 { - // Find the previous character boundary - let prev = self.buffer[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - self.buffer.drain(prev..self.cursor); - self.cursor = prev; - } - } - - /// Delete the character at the cursor (delete). - pub fn delete(&mut self) { - self.clear_completion(); - if self.cursor < self.buffer.len() { - // Find the next character boundary - let next = self.buffer[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.buffer.len()); - self.buffer.drain(self.cursor..next); - } - } - - /// Move cursor left. - pub fn move_left(&mut self) { - if self.cursor > 0 { - self.cursor = self.buffer[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - } - } - - /// Move cursor right. - pub fn move_right(&mut self) { - if self.cursor < self.buffer.len() { - self.cursor = self.buffer[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.buffer.len()); - } - } - - /// Move cursor to start. - pub fn move_home(&mut self) { - self.cursor = 0; - } - - /// Move cursor to end. - pub fn move_end(&mut self) { - self.cursor = self.buffer.len(); - } - - /// Delete from cursor to end of line. - pub fn kill_line(&mut self) { - self.clear_completion(); - self.buffer.truncate(self.cursor); - } - - /// Delete from start to cursor. - pub fn kill_to_start(&mut self) { - self.clear_completion(); - self.buffer.drain(..self.cursor); - self.cursor = 0; - } - - /// Clear the entire buffer. - pub fn clear(&mut self) { - self.buffer.clear(); - self.cursor = 0; - self.clear_completion(); - } - - /// Submit the current input and return it. - pub fn submit(&mut self) -> String { - let input = std::mem::take(&mut self.buffer); - self.cursor = 0; - self.clear_completion(); - - // Add to history if non-empty and different from last entry - if !input.is_empty() && self.history.front() != Some(&input) { - self.history.push_front(input.clone()); - if self.history.len() > MAX_HISTORY { - self.history.pop_back(); - } - } - - self.history_index = None; - self.saved_input.clear(); - - input - } - - /// Navigate to previous history entry. - pub fn history_prev(&mut self) { - if self.history.is_empty() { - return; - } - - match self.history_index { - None => { - // Save current input and go to first history entry - self.saved_input = std::mem::take(&mut self.buffer); - self.history_index = Some(0); - self.buffer = self.history[0].clone(); - } - Some(i) if i + 1 < self.history.len() => { - self.history_index = Some(i + 1); - self.buffer = self.history[i + 1].clone(); - } - _ => {} - } - - self.cursor = self.buffer.len(); - self.clear_completion(); - } - - /// Navigate to next history entry. - pub fn history_next(&mut self) { - match self.history_index { - Some(0) => { - // Go back to saved input - self.history_index = None; - self.buffer = std::mem::take(&mut self.saved_input); - } - Some(i) => { - self.history_index = Some(i - 1); - self.buffer = self.history[i - 1].clone(); - } - None => {} - } - - self.cursor = self.buffer.len(); - self.clear_completion(); - } - - /// Attempt tab completion. - pub fn complete(&mut self) { - // Only complete slash commands for now - if !self.buffer.starts_with('/') { - return; - } - - if self.completions.is_empty() { - // Generate completions - let prefix = &self.buffer; - self.completions = SLASH_COMMANDS - .iter() - .filter(|cmd| cmd.starts_with(prefix)) - .map(|s| s.to_string()) - .collect(); - - if !self.completions.is_empty() { - self.completion_index = Some(0); - } - } else if let Some(i) = self.completion_index { - // Cycle through completions - self.completion_index = Some((i + 1) % self.completions.len()); - } - - // Apply completion - if let Some(i) = self.completion_index { - if let Some(completion) = self.completions.get(i) { - self.buffer = completion.clone(); - self.cursor = self.buffer.len(); - } - } - } - - /// Clear completion state. - fn clear_completion(&mut self) { - self.completions.clear(); - self.completion_index = None; - } - - /// Get current completion hint (for display). - pub fn completion_hint(&self) -> Option<&str> { - if let Some(i) = self.completion_index { - self.completions.get(i).map(|s| s.as_str()) - } else { - None - } - } - - /// Get the number of completions available. - pub fn completion_count(&self) -> usize { - self.completions.len() - } -} - -impl Default for ChatComposer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_insert_and_backspace() { - let mut composer = ChatComposer::new(); - composer.insert('h'); - composer.insert('i'); - assert_eq!(composer.buffer(), "hi"); - composer.backspace(); - assert_eq!(composer.buffer(), "h"); - } - - #[test] - fn test_history_navigation() { - let mut composer = ChatComposer::new(); - composer.insert_str("first"); - composer.submit(); - composer.insert_str("second"); - composer.submit(); - - composer.insert_str("current"); - composer.history_prev(); - assert_eq!(composer.buffer(), "second"); - composer.history_prev(); - assert_eq!(composer.buffer(), "first"); - composer.history_next(); - assert_eq!(composer.buffer(), "second"); - composer.history_next(); - assert_eq!(composer.buffer(), "current"); - } - - #[test] - fn test_completion() { - let mut composer = ChatComposer::new(); - composer.insert_str("/hel"); - composer.complete(); - assert_eq!(composer.buffer(), "/help"); - } -} diff --git a/src/channels/cli/events.rs b/src/channels/cli/events.rs deleted file mode 100644 index 680a056b..00000000 --- a/src/channels/cli/events.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Event handling for the TUI. - -use std::io; -use std::time::Duration; - -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use tokio::sync::mpsc; - -use crate::channels::IncomingMessage; -use crate::channels::cli::app::{AppEvent, AppState, InputMode}; -use crate::channels::cli::render; - -/// Tick rate for the event loop (50ms = 20fps). -const TICK_RATE: Duration = Duration::from_millis(50); - -/// Run the main event loop. -pub fn run_event_loop( - terminal: &mut Terminal>, - app: &mut AppState, - msg_tx: mpsc::Sender, - mut event_rx: mpsc::Receiver, -) -> io::Result<()> { - loop { - // Render - terminal.draw(|f| render::render(f, app))?; - - // Check for quit - send shutdown signal and exit - if app.should_quit { - // Send a shutdown message so the agent loop knows to exit - let shutdown_msg = IncomingMessage::new("tui", "system", "/shutdown"); - let _ = msg_tx.blocking_send(shutdown_msg); - // Explicitly drop to close the channel - drop(msg_tx); - return Ok(()); - } - - // Poll for terminal events - if event::poll(TICK_RATE)? { - let evt = event::read()?; - if let Err(e) = handle_event(app, evt, &msg_tx) { - tracing::error!("Event handling error: {}", e); - } - } - - // Check for app events from agent (non-blocking) - while let Ok(app_event) = event_rx.try_recv() { - handle_app_event(app, app_event); - } - } -} - -/// Handle a crossterm event. -fn handle_event( - app: &mut AppState, - event: Event, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - match event { - Event::Key(key) => handle_key(app, key, msg_tx), - Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here - Event::Resize(_, _) => Ok(()), // Terminal will handle resize - _ => Ok(()), - } -} - -/// Handle a key event. -fn handle_key( - app: &mut AppState, - key: KeyEvent, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - // Global keybindings - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => { - if app.mode == InputMode::Approval { - // Cancel all pending approvals - app.clear_approvals(); - } else { - // Quit - app.should_quit = true; - } - app.ctrl_d_pending = false; - return Ok(()); - } - KeyCode::Char('d') => { - if app.ctrl_d_pending { - // Second Ctrl+D, quit now - app.should_quit = true; - } else { - // First Ctrl+D, show hint - app.ctrl_d_pending = true; - app.set_status("Press Ctrl+D again to quit"); - } - return Ok(()); - } - _ => { - // Any other Ctrl+ combo clears the Ctrl+D pending state - app.ctrl_d_pending = false; - } - } - } else { - // Any non-Ctrl key clears the Ctrl+D pending state - app.ctrl_d_pending = false; - } - - match app.mode { - InputMode::Normal => handle_normal_mode(app, key), - InputMode::Editing => handle_editing_mode(app, key, msg_tx), - InputMode::Approval => handle_approval_mode(app, key), - InputMode::ModelSelector => handle_model_selector_mode(app, key), - } -} - -/// Handle keys in normal mode. -fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - match key.code { - KeyCode::Char('i') | KeyCode::Char('a') => { - app.mode = InputMode::Editing; - } - KeyCode::Char('q') => { - app.should_quit = true; - } - KeyCode::Up | KeyCode::Char('k') => { - app.scroll_up(1); - } - KeyCode::Down | KeyCode::Char('j') => { - app.scroll_down(1); - } - KeyCode::PageUp => { - app.scroll_up(10); - } - KeyCode::PageDown => { - app.scroll_down(10); - } - KeyCode::Char('G') => { - app.scroll_to_bottom(); - } - _ => {} - } - Ok(()) -} - -/// Handle keys in editing mode. -fn handle_editing_mode( - app: &mut AppState, - key: KeyEvent, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - match key.code { - KeyCode::Enter => { - if !app.composer.is_empty() { - let input = app.composer.submit(); - - // Handle /model command locally (TUI-specific) - if input.trim().eq_ignore_ascii_case("/model") { - app.show_model_selector(); - return Ok(()); - } - - app.add_user_message(&input); - - // Send message to agent - let msg = IncomingMessage::new("tui", "local-user", &input); - let _ = msg_tx.blocking_send(msg); - } - } - KeyCode::Esc => { - app.mode = InputMode::Normal; - } - KeyCode::Backspace => { - app.composer.backspace(); - } - KeyCode::Delete => { - app.composer.delete(); - } - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - // Move word left (simplified: just move to start) - app.composer.move_home(); - } else { - app.composer.move_left(); - } - } - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - // Move word right (simplified: just move to end) - app.composer.move_end(); - } else { - app.composer.move_right(); - } - } - KeyCode::Home => { - app.composer.move_home(); - } - KeyCode::End => { - app.composer.move_end(); - } - KeyCode::Up => { - app.composer.history_prev(); - } - KeyCode::Down => { - app.composer.history_next(); - } - KeyCode::Tab => { - app.composer.complete(); - } - KeyCode::Char(c) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - match c { - 'a' => app.composer.move_home(), - 'e' => app.composer.move_end(), - 'k' => app.composer.kill_line(), - 'u' => app.composer.kill_to_start(), - 'w' => { - // Delete word backwards (simplified: clear) - app.composer.clear(); - } - _ => {} - } - } else { - app.composer.insert(c); - } - } - _ => {} - } - Ok(()) -} - -/// Handle keys in approval mode. -fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - if let Some(ref mut overlay) = app.approval { - match key.code { - KeyCode::Left | KeyCode::Char('h') => { - overlay.select_prev(); - } - KeyCode::Right | KeyCode::Char('l') => { - overlay.select_next(); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let (approved, _always) = overlay.confirm(); - app.handle_approval_response(approved); - // TODO: If always, remember to auto-approve this tool - } - KeyCode::Char(c) => { - if let Some(approved) = overlay.handle_shortcut(c) { - app.handle_approval_response(approved); - } - } - KeyCode::Esc => { - // Deny this approval - app.handle_approval_response(false); - } - _ => {} - } - } - Ok(()) -} - -/// Handle keys in model selector mode. -fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - if let Some(ref mut overlay) = app.model_selector { - match key.code { - KeyCode::Left | KeyCode::Char('h') => { - overlay.select_prev(); - } - KeyCode::Right | KeyCode::Char('l') => { - overlay.select_next(); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let selected = overlay.selected_model().map(|s| s.to_string()); - app.handle_model_selection(selected); - } - KeyCode::Esc => { - // Cancel without changing model - app.handle_model_selection(None); - } - _ => {} - } - } - Ok(()) -} - -/// Handle an application event. -fn handle_app_event(app: &mut AppState, event: AppEvent) { - match event { - AppEvent::Response(content) => { - app.add_agent_message(content); - } - AppEvent::ToolStarted { name } => { - app.set_thinking(format!("⚙️ Running tool: {}...", name)); - } - AppEvent::ToolCompleted { name, success } => { - if success { - app.set_thinking(format!("✓ Tool {} completed", name)); - } else { - app.set_thinking(format!("✗ Tool {} failed", name)); - } - } - AppEvent::ApprovalRequested(request) => { - app.queue_approval(request); - } - AppEvent::StreamChunk(chunk) => { - if app.streaming_buffer.is_none() { - app.start_streaming(); - } - app.append_stream(&chunk); - } - AppEvent::Redraw => { - // Just triggers a redraw on next loop iteration - } - AppEvent::Quit => { - app.should_quit = true; - } - AppEvent::Input(_) => { - // Already handled directly - } - AppEvent::LogMessage(msg) => { - app.set_status(msg); - } - AppEvent::ThinkingMessage(msg) => { - app.set_thinking(msg); - } - AppEvent::ErrorMessage(msg) => { - app.add_error_message(msg); - } - AppEvent::AvailableModels(models) => { - app.set_available_models(models); - } - } -} diff --git a/src/channels/cli/mod.rs b/src/channels/cli/mod.rs deleted file mode 100644 index afeb590e..00000000 --- a/src/channels/cli/mod.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Interactive TUI channel using Ratatui. -//! -//! Provides a rich terminal interface with: -//! - Input history navigation -//! - Slash command completion -//! - Approval overlays for tool execution -//! - Streaming response display - -mod app; -mod composer; -mod events; -mod model_selector; -mod overlay; -mod render; - -use std::io; -use std::sync::Arc; - -use async_trait::async_trait; -use crossterm::{ - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use tokio::sync::{Mutex, mpsc}; -use tokio_stream::wrappers::ReceiverStream; - -use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -use crate::error::ChannelError; - -pub use app::{AppEvent, AppState, InputMode}; -pub use composer::ChatComposer; -pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; -pub use overlay::{ApprovalOverlay, ApprovalRequest}; - -/// TUI channel for interactive terminal input with Ratatui. -pub struct TuiChannel { - /// Channel for sending events to the TUI (created upfront for logging). - event_tx: mpsc::Sender, - /// Receiver end, taken when start() is called. - event_rx: Arc>>>, -} - -impl TuiChannel { - /// Create a new TUI channel. - pub fn new() -> Self { - let (event_tx, event_rx) = mpsc::channel(64); - Self { - event_tx, - event_rx: Arc::new(Mutex::new(Some(event_rx))), - } - } - - /// Get a log writer that sends messages to the TUI status line. - /// Use this to redirect tracing output to the TUI. - pub fn log_writer(&self) -> TuiLogWriter { - TuiLogWriter::new(self.event_tx.clone()) - } - - /// Get a sender for sending events to the TUI. - /// Use this to send available models or other events from outside the channel. - pub fn event_sender(&self) -> mpsc::Sender { - self.event_tx.clone() - } -} - -impl Default for TuiChannel { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl Channel for TuiChannel { - fn name(&self) -> &str { - "tui" - } - - async fn start(&self) -> Result { - let (msg_tx, msg_rx) = mpsc::channel(32); - - // Take the event receiver (can only start once) - let event_rx = { - let mut guard = self.event_rx.lock().await; - guard.take().ok_or_else(|| ChannelError::StartupFailed { - name: "tui".to_string(), - reason: "TUI channel already started".to_string(), - })? - }; - - tokio::task::spawn_blocking(move || { - if let Err(e) = run_tui(msg_tx, event_rx) { - // Try to restore terminal even on error - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - eprintln!("TUI error: {}", e); - } - }); - - Ok(Box::pin(ReceiverStream::new(msg_rx))) - } - - async fn respond( - &self, - _msg: &IncomingMessage, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - self.event_tx - .send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn send_status( - &self, - status: StatusUpdate, - _metadata: &serde_json::Value, - ) -> Result<(), ChannelError> { - let event = match status { - StatusUpdate::Thinking(msg) => AppEvent::ThinkingMessage(format!("🤔 {}", msg)), - StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name }, - StatusUpdate::ToolCompleted { name, success } => { - AppEvent::ToolCompleted { name, success } - } - StatusUpdate::StreamChunk(chunk) => AppEvent::StreamChunk(chunk), - StatusUpdate::Status(msg) => AppEvent::ThinkingMessage(msg), - }; - self.event_tx - .send(event) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn broadcast( - &self, - _user_id: &str, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - // For TUI, broadcasts appear as regular agent responses with a notification indicator - self.event_tx - .send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn health_check(&self) -> Result<(), ChannelError> { - // Channel is healthy if we haven't been closed - if self.event_tx.is_closed() { - Err(ChannelError::HealthCheckFailed { - name: "tui".to_string(), - }) - } else { - Ok(()) - } - } - - async fn shutdown(&self) -> Result<(), ChannelError> { - let _ = self.event_tx.send(AppEvent::Quit).await; - Ok(()) - } -} - -/// Run the TUI event loop (blocking). -fn run_tui( - msg_tx: mpsc::Sender, - event_rx: mpsc::Receiver, -) -> io::Result<()> { - // Setup terminal - // Note: We don't enable mouse capture so users can select text normally - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Create app state - let mut app = AppState::new(); - - // Run event loop - let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx); - - // Restore terminal - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - result -} - -/// TUI-compatible tracing writer that sends log messages to the TUI status line. -#[derive(Clone)] -pub struct TuiLogWriter { - tx: mpsc::Sender, -} - -impl TuiLogWriter { - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } -} - -impl std::io::Write for TuiLogWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - if let Ok(s) = std::str::from_utf8(buf) { - let s = s.trim(); - if !s.is_empty() { - // Fire and forget - don't block on logging - let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string())); - } - } - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } -} diff --git a/src/channels/cli/model_selector.rs b/src/channels/cli/model_selector.rs deleted file mode 100644 index 23e7d477..00000000 --- a/src/channels/cli/model_selector.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Model selector overlay for switching LLM models. - -/// Request to show the model selector. -#[derive(Debug, Clone)] -pub struct ModelSelectorRequest { - /// Currently selected model. - pub current_model: String, - /// Available models to choose from. - pub available_models: Vec, -} - -/// Model selector overlay state. -#[derive(Debug, Clone)] -pub struct ModelSelectorOverlay { - /// The request that triggered this overlay. - pub request: ModelSelectorRequest, - /// Currently highlighted index. - pub selection_index: usize, -} - -impl ModelSelectorOverlay { - /// Create a new model selector overlay. - pub fn new(request: ModelSelectorRequest) -> Self { - // Find the current model in the list, default to 0 - let selection_index = request - .available_models - .iter() - .position(|m| m == &request.current_model) - .unwrap_or(0); - - Self { - request, - selection_index, - } - } - - /// Get the list of available models. - pub fn models(&self) -> &[String] { - &self.request.available_models - } - - /// Move selection up. - pub fn select_prev(&mut self) { - let len = self.request.available_models.len(); - if len == 0 { - return; - } - if self.selection_index > 0 { - self.selection_index -= 1; - } else { - // Wrap to bottom - self.selection_index = len - 1; - } - } - - /// Move selection down. - pub fn select_next(&mut self) { - let len = self.request.available_models.len(); - if len == 0 { - return; - } - if self.selection_index < len - 1 { - self.selection_index += 1; - } else { - // Wrap to top - self.selection_index = 0; - } - } - - /// Get the currently selected model name. - pub fn selected_model(&self) -> Option<&str> { - self.request - .available_models - .get(self.selection_index) - .map(|s| s.as_str()) - } - - /// Check if the selection is the current model. - pub fn is_current(&self) -> bool { - self.selected_model() == Some(&self.request.current_model) - } - - /// Format a model name for display (shorten long names). - pub fn format_model_name(model: &str) -> String { - // Shorten fireworks model names - if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") { - return format!("fireworks/{}", rest); - } - // Shorten other long prefixes - if let Some(rest) = model.strip_prefix("accounts/") { - return rest.to_string(); - } - model.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_model_selector_navigation() { - let request = ModelSelectorRequest { - current_model: "gpt-4o".to_string(), - available_models: vec![ - "claude-3-5-sonnet".to_string(), - "gpt-4o".to_string(), - "gpt-4o-mini".to_string(), - ], - }; - let mut overlay = ModelSelectorOverlay::new(request); - - // Should start at gpt-4o index (1) - assert_eq!(overlay.selected_model(), Some("gpt-4o")); - - // Navigate down - overlay.select_next(); - assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); - - // Navigate down (wrap) - overlay.select_next(); - assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet")); - - // Navigate up - overlay.select_prev(); - assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); - } - - #[test] - fn test_format_model_name() { - assert_eq!( - ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"), - "claude-3-5-sonnet-20241022" - ); - assert_eq!( - ModelSelectorOverlay::format_model_name( - "fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct" - ), - "fireworks/llama-v3p1-405b-instruct" - ); - } - - #[test] - fn test_empty_models() { - let request = ModelSelectorRequest { - current_model: "unknown".to_string(), - available_models: vec![], - }; - let mut overlay = ModelSelectorOverlay::new(request); - assert_eq!(overlay.selected_model(), None); - - // Should not panic - overlay.select_next(); - overlay.select_prev(); - } -} diff --git a/src/channels/cli/overlay.rs b/src/channels/cli/overlay.rs deleted file mode 100644 index 1a8b4d97..00000000 --- a/src/channels/cli/overlay.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Approval overlay modal. - -use uuid::Uuid; - -/// A request for user approval before executing a tool. -#[derive(Debug, Clone)] -pub struct ApprovalRequest { - /// Unique ID for this request. - pub id: Uuid, - /// Name of the tool requesting approval. - pub tool_name: String, - /// Description of what the tool will do. - pub description: String, - /// Parameters being passed to the tool. - pub parameters: serde_json::Value, - /// Whether this is a destructive operation. - pub destructive: bool, -} - -impl ApprovalRequest { - /// Create a new approval request. - pub fn new( - tool_name: impl Into, - description: impl Into, - parameters: serde_json::Value, - ) -> Self { - Self { - id: Uuid::new_v4(), - tool_name: tool_name.into(), - description: description.into(), - parameters, - destructive: false, - } - } - - /// Mark as destructive operation. - pub fn destructive(mut self) -> Self { - self.destructive = true; - self - } -} - -/// Current selection in the approval overlay. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalSelection { - /// Yes, approve this action. - Yes, - /// No, deny this action. - No, - /// Always approve this tool (for this session). - Always, -} - -impl ApprovalSelection { - /// Get the next selection (cycling). - pub fn next(self) -> Self { - match self { - Self::Yes => Self::No, - Self::No => Self::Always, - Self::Always => Self::Yes, - } - } - - /// Get the previous selection (cycling). - pub fn prev(self) -> Self { - match self { - Self::Yes => Self::Always, - Self::No => Self::Yes, - Self::Always => Self::No, - } - } -} - -/// Approval overlay state. -pub struct ApprovalOverlay { - /// The request being shown. - pub request: ApprovalRequest, - /// Current selection. - pub selection: ApprovalSelection, -} - -impl ApprovalOverlay { - /// Create a new approval overlay. - pub fn new(request: ApprovalRequest) -> Self { - Self { - request, - selection: ApprovalSelection::Yes, - } - } - - /// Move selection left. - pub fn select_prev(&mut self) { - self.selection = self.selection.prev(); - } - - /// Move selection right. - pub fn select_next(&mut self) { - self.selection = self.selection.next(); - } - - /// Handle keyboard shortcut. - pub fn handle_shortcut(&mut self, c: char) -> Option { - match c.to_ascii_lowercase() { - 'y' => Some(true), - 'n' => Some(false), - 'a' => { - self.selection = ApprovalSelection::Always; - Some(true) - } - _ => None, - } - } - - /// Confirm the current selection. - pub fn confirm(&self) -> (bool, bool) { - match self.selection { - ApprovalSelection::Yes => (true, false), - ApprovalSelection::No => (false, false), - ApprovalSelection::Always => (true, true), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_approval_selection_cycle() { - let sel = ApprovalSelection::Yes; - assert_eq!(sel.next(), ApprovalSelection::No); - assert_eq!(sel.next().next(), ApprovalSelection::Always); - assert_eq!(sel.next().next().next(), ApprovalSelection::Yes); - } - - #[test] - fn test_approval_shortcuts() { - let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({})); - let mut overlay = ApprovalOverlay::new(request); - - assert_eq!(overlay.handle_shortcut('y'), Some(true)); - assert_eq!(overlay.handle_shortcut('n'), Some(false)); - assert_eq!(overlay.handle_shortcut('x'), None); - } -} diff --git a/src/channels/cli/render.rs b/src/channels/cli/render.rs deleted file mode 100644 index ba692260..00000000 --- a/src/channels/cli/render.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! TUI rendering with Ratatui. - -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, -}; - -use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus}; -use crate::channels::cli::model_selector::ModelSelectorOverlay; -use crate::channels::cli::overlay::ApprovalSelection; - -/// Render the entire UI. -pub fn render(frame: &mut Frame, app: &AppState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), // Messages - Constraint::Length(3), // Input - Constraint::Length(1), // Status - ]) - .split(frame.area()); - - render_messages(frame, app, chunks[0]); - render_input(frame, app, chunks[1]); - render_status(frame, app, chunks[2]); - - // Render approval overlay if active - if app.mode == InputMode::Approval { - render_approval_overlay(frame, app); - } -} - -/// Render the message history. -fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) { - // Build all lines from all messages - let mut lines: Vec = Vec::new(); - - for msg in &app.messages { - let (prefix, style) = match msg.role { - MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)), - MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)), - MessageRole::System => ( - "", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), - }; - - let status_indicator = match msg.status { - Some(MessageStatus::Pending) => " ⏳", - Some(MessageStatus::InProgress) => " ⚙️", - Some(MessageStatus::Complete) => " ✓", - Some(MessageStatus::Error) => " ✗", - None => "", - }; - - // Split content by newlines and create a line for each - let content_lines: Vec<&str> = msg.content.lines().collect(); - for (i, line_text) in content_lines.iter().enumerate() { - if i == 0 { - // First line gets the prefix - let line_content = if status_indicator.is_empty() { - format!("{}{}", prefix, line_text) - } else if content_lines.len() == 1 { - format!("{}{}{}", prefix, line_text, status_indicator) - } else { - format!("{}{}", prefix, line_text) - }; - lines.push(Line::styled(line_content, style)); - } else if i == content_lines.len() - 1 && !status_indicator.is_empty() { - // Last line gets status indicator - lines.push(Line::styled( - format!("{}{}", line_text, status_indicator), - style, - )); - } else { - // Middle lines just get the content - lines.push(Line::styled(line_text.to_string(), style)); - } - } - - // Add empty line between messages for readability - lines.push(Line::from("")); - } - - // Calculate scroll - show most recent messages - let visible_height = area.height.saturating_sub(2) as usize; // Account for borders - let total_lines = lines.len(); - let scroll_offset = total_lines.saturating_sub(visible_height); - - let text = Text::from(lines); - let messages = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL).title("Chat")) - .wrap(Wrap { trim: false }) - .scroll((scroll_offset as u16, 0)); - - frame.render_widget(messages, area); -} - -/// Render the input area (or model selector when in ModelSelector mode). -fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { - // In ModelSelector mode, render inline selector instead of input - if app.mode == InputMode::ModelSelector { - render_model_selector_inline(frame, app, area); - return; - } - - let input_style = match app.mode { - InputMode::Editing => Style::default().fg(Color::Yellow), - InputMode::Normal => Style::default(), - InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray), - }; - - let buffer = app.composer.buffer(); - let cursor = app.composer.cursor(); - - // Build the input text with cursor - let (before, after) = buffer.split_at(cursor.min(buffer.len())); - let cursor_char = after.chars().next().unwrap_or(' '); - let after_cursor = if after.is_empty() { - "" - } else { - &after[cursor_char.len_utf8()..] - }; - - let input = Paragraph::new(Line::from(vec![ - Span::raw(before), - Span::styled( - cursor_char.to_string(), - Style::default().bg(Color::White).fg(Color::Black), - ), - Span::raw(after_cursor), - ])) - .style(input_style) - .block(Block::default().borders(Borders::ALL).title("Input")); - - frame.render_widget(input, area); - - // Show cursor in editing mode - if app.mode == InputMode::Editing { - // Calculate cursor position accounting for the block border - let cursor_x = area.x + 1 + cursor as u16; - let cursor_y = area.y + 1; - frame.set_cursor_position((cursor_x, cursor_y)); - } -} - -/// Render inline model selector in the input area. -fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) { - let Some(ref overlay) = app.model_selector else { - return; - }; - - let models = overlay.models(); - - // Build horizontal list of models - let mut spans: Vec = Vec::new(); - - if models.is_empty() { - spans.push(Span::styled( - "Loading models...", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )); - } else { - for (i, model) in models.iter().enumerate() { - if i > 0 { - spans.push(Span::raw(" ")); - } - - let display_name = ModelSelectorOverlay::format_model_name(model); - let is_selected = i == overlay.selection_index; - let is_current = model == &overlay.request.current_model; - - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else if is_current { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if is_current { "●" } else { " " }; - spans.push(Span::styled(format!("{}{}", prefix, display_name), style)); - } - } - - let content = Paragraph::new(Line::from(spans)) - .block(Block::default().borders(Borders::ALL).title(Span::styled( - "Select Model", - Style::default().fg(Color::Cyan), - ))) - .scroll(( - 0, - calculate_model_scroll(overlay, area.width.saturating_sub(2)), - )); - - frame.render_widget(content, area); -} - -/// Calculate horizontal scroll offset to keep selected model visible. -fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 { - let models = overlay.models(); - if models.is_empty() { - return 0; - } - - // Estimate position of selected model (rough calculation) - let mut pos: u16 = 0; - for (i, model) in models.iter().enumerate() { - let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing - if i == overlay.selection_index { - // Check if selection is beyond visible area - if pos > visible_width { - return pos.saturating_sub(visible_width / 2); - } - return 0; - } - pos += name_len; - } - 0 -} - -/// Render the status line. -fn render_status(frame: &mut Frame, app: &AppState, area: Rect) { - let status_text = if let Some(ref msg) = app.status_message { - msg.clone() - } else { - match app.mode { - InputMode::Normal | InputMode::Editing => { - let model = ModelSelectorOverlay::format_model_name(&app.current_model); - format!("{} | /model to switch", model) - } - InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(), - InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(), - } - }; - - let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray)); - - frame.render_widget(status, area); -} - -/// Render the approval overlay. -fn render_approval_overlay(frame: &mut Frame, app: &AppState) { - let Some(ref overlay) = app.approval else { - return; - }; - - let area = frame.area(); - - // Calculate overlay size and position - let overlay_width = (area.width * 60 / 100).min(60); - let overlay_height = 12; - let overlay_x = (area.width - overlay_width) / 2; - let overlay_y = (area.height - overlay_height) / 2; - - let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height); - - // Clear the area behind the overlay - frame.render_widget(Clear, overlay_area); - - // Build overlay content - let title = if overlay.request.destructive { - "⚠️ Approval Required (Destructive)" - } else { - "Approval Required" - }; - - let title_style = if overlay.request.destructive { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - }; - - // Build the text content - let mut lines = vec![ - Line::from(vec![ - Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&overlay.request.tool_name), - ]), - Line::from(""), - Line::from(overlay.request.description.as_str()), - Line::from(""), - ]; - - // Add parameters preview (truncated) - let params_str = serde_json::to_string_pretty(&overlay.request.parameters) - .unwrap_or_else(|_| "{}".to_string()); - let params_preview: String = params_str.chars().take(100).collect(); - lines.push(Line::from(vec![ - Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(params_preview, Style::default().fg(Color::DarkGray)), - ])); - lines.push(Line::from("")); - - // Add selection buttons - let yes_style = if overlay.selection == ApprovalSelection::Yes { - Style::default().bg(Color::Green).fg(Color::Black) - } else { - Style::default().fg(Color::Green) - }; - - let no_style = if overlay.selection == ApprovalSelection::No { - Style::default().bg(Color::Red).fg(Color::Black) - } else { - Style::default().fg(Color::Red) - }; - - let always_style = if overlay.selection == ApprovalSelection::Always { - Style::default().bg(Color::Blue).fg(Color::Black) - } else { - Style::default().fg(Color::Blue) - }; - - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(" [Y]es ", yes_style), - Span::raw(" "), - Span::styled(" [N]o ", no_style), - Span::raw(" "), - Span::styled(" [A]lways ", always_style), - ])); - - let content = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(Span::styled(title, title_style)), - ) - .wrap(Wrap { trim: true }); - - frame.render_widget(content, overlay_area); -} diff --git a/src/channels/http.rs b/src/channels/http.rs index 986ebf7c..77576a46 100644 --- a/src/channels/http.rs +++ b/src/channels/http.rs @@ -1,6 +1,5 @@ //! HTTP webhook channel for receiving messages via HTTP POST. -use std::net::SocketAddr; use std::sync::Arc; use async_trait::async_trait; @@ -32,8 +31,6 @@ struct HttpChannelState { tx: RwLock>>, /// Pending responses keyed by message ID. pending_responses: RwLock>>, - /// Server shutdown signal. - shutdown_tx: RwLock>>, /// Expected webhook secret for authentication (if configured). webhook_secret: Option, /// Fixed user ID for this HTTP channel. @@ -74,7 +71,6 @@ impl HttpChannel { state: Arc::new(HttpChannelState { tx: RwLock::new(None), pending_responses: RwLock::new(std::collections::HashMap::new()), - shutdown_tx: RwLock::new(None), webhook_secret, user_id, rate_limit: tokio::sync::Mutex::new(RateLimitState { @@ -84,6 +80,24 @@ impl HttpChannel { }), } } + + /// Return the channel's axum routes with state applied. + /// + /// The returned `Router` shares the same `Arc` that + /// `start()` later populates. Before `start()` is called the webhook + /// handler returns 503 ("Channel not started"). + pub fn routes(&self) -> Router { + Router::new() + .route("/health", get(health_handler)) + .route("/webhook", post(webhook_handler)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .with_state(self.state.clone()) + } + + /// Return the configured host and port for this channel. + pub fn addr(&self) -> (&str, u16) { + (&self.config.host, self.config.port) + } } #[derive(Debug, Deserialize)] @@ -303,53 +317,11 @@ impl Channel for HttpChannel { let (tx, rx) = mpsc::channel(256); *self.state.tx.write().await = Some(tx); - let state = self.state.clone(); - let host = self.config.host.clone(); - let port = self.config.port; - - // Parse address before spawning so we can return errors - let addr: SocketAddr = - format!("{}:{}", host, port) - .parse() - .map_err(|e| ChannelError::StartupFailed { - name: "http".to_string(), - reason: format!("Invalid address '{}:{}': {}", host, port, e), - })?; - - // Bind listener before spawning so we can return errors - let listener = - tokio::net::TcpListener::bind(addr) - .await - .map_err(|e| ChannelError::StartupFailed { - name: "http".to_string(), - reason: format!("Failed to bind to {}: {}", addr, e), - })?; - - tracing::info!("HTTP channel listening on {}", addr); - - // Create router - let app = Router::new() - .route("/health", get(health_handler)) - .route("/webhook", post(webhook_handler)) - .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) - .with_state(state.clone()); - - // Create shutdown channel - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - *self.state.shutdown_tx.write().await = Some(shutdown_tx); - - // Spawn server (listener is already bound, serve errors are logged) - tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = shutdown_rx.await; - tracing::info!("HTTP channel shutting down"); - }) - .await - { - tracing::error!("HTTP server error: {}", e); - } - }); + tracing::info!( + "HTTP channel ready ({}:{})", + self.config.host, + self.config.port + ); Ok(Box::pin(ReceiverStream::new(rx))) } @@ -363,13 +335,10 @@ impl Channel for HttpChannel { if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) { let _ = tx.send(response.content); } - // For async webhooks, we'd need to make an HTTP callback here - // but that requires the caller to provide a callback URL Ok(()) } async fn health_check(&self) -> Result<(), ChannelError> { - // Check if we have an active sender if self.state.tx.read().await.is_some() { Ok(()) } else { @@ -380,11 +349,6 @@ impl Channel for HttpChannel { } async fn shutdown(&self) -> Result<(), ChannelError> { - // Send shutdown signal - if let Some(tx) = self.state.shutdown_tx.write().await.take() { - let _ = tx.send(()); - } - // Clear the message sender *self.state.tx.write().await = None; Ok(()) } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 18f471fa..08d742e8 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -9,9 +9,9 @@ //! ┌─────────────────────────────────────────────────────────────────────┐ //! │ ChannelManager │ //! │ │ -//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ... │ -//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +//! │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ +//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │ +//! │ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘ │ //! │ │ │ │ │ //! │ └─────────────────┴─────────────────┘ │ //! │ │ │ @@ -28,16 +28,16 @@ //! See the [`wasm`] module for details. mod channel; -pub mod cli; mod http; mod manager; mod repl; pub mod wasm; pub mod web; +mod webhook_server; pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -pub use cli::{AppEvent, TuiChannel}; pub use http::HttpChannel; pub use manager::ChannelManager; pub use repl::ReplChannel; pub use web::GatewayChannel; +pub use webhook_server::{WebhookServer, WebhookServerConfig}; diff --git a/src/channels/repl.rs b/src/channels/repl.rs index a498ff92..35fdcb07 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -1,6 +1,8 @@ -//! Interactive REPL channel for debugging and testing. +//! Interactive REPL channel with line editing and markdown rendering. //! -//! Provides a command-line interface for interacting with the agent. +//! Provides the primary CLI interface for interacting with the agent. +//! Uses rustyline for line editing, history, and tab-completion. +//! Uses termimad for rendering markdown responses inline. //! //! ## Commands //! @@ -14,23 +16,113 @@ //! - `/new` - Start a new thread //! - `yes`/`no`/`always` - Respond to tool approval prompts -use std::io::{self, BufRead, Write}; +use std::borrow::Cow; +use std::io::{self, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; +use rustyline::completion::Completer; +use rustyline::config::Config; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{CompletionType, Editor, Helper}; +use termimad::MadSkin; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; -/// REPL channel for interactive agent debugging. +/// Slash commands available in the REPL. +const SLASH_COMMANDS: &[&str] = &[ + "/help", + "/quit", + "/exit", + "/debug", + "/undo", + "/redo", + "/clear", + "/compact", + "/new", + "/interrupt", +]; + +/// Rustyline helper for slash-command tab completion. +struct ReplHelper; + +impl Completer for ReplHelper { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + if !line.starts_with('/') { + return Ok((0, vec![])); + } + + let prefix = &line[..pos]; + let matches: Vec = SLASH_COMMANDS + .iter() + .filter(|cmd| cmd.starts_with(prefix)) + .map(|cmd| cmd.to_string()) + .collect(); + + Ok((0, matches)) + } +} + +impl Hinter for ReplHelper { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + if !line.starts_with('/') || pos < line.len() { + return None; + } + + SLASH_COMMANDS + .iter() + .find(|cmd| cmd.starts_with(line) && **cmd != line) + .map(|cmd| cmd[line.len()..].to_string()) + } +} + +impl Highlighter for ReplHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Cow::Owned(format!("\x1b[90m{hint}\x1b[0m")) + } +} + +impl Validator for ReplHelper {} +impl Helper for ReplHelper {} + +/// Build a termimad skin with our color scheme. +fn make_skin() -> MadSkin { + let mut skin = MadSkin::default(); + skin.set_headers_fg(termimad::crossterm::style::Color::Yellow); + skin.bold.set_fg(termimad::crossterm::style::Color::White); + skin.italic + .set_fg(termimad::crossterm::style::Color::Magenta); + skin.inline_code + .set_fg(termimad::crossterm::style::Color::Green); + skin.code_block + .set_fg(termimad::crossterm::style::Color::Green); + skin +} + +/// REPL channel with line editing and markdown rendering. pub struct ReplChannel { /// Optional single message to send (for -m flag). single_message: Option, /// Debug mode flag (shared with input thread). debug_mode: Arc, + /// Whether we're currently streaming (chunks have been printed without a trailing newline). + is_streaming: Arc, } impl ReplChannel { @@ -39,6 +131,7 @@ impl ReplChannel { Self { single_message: None, debug_mode: Arc::new(AtomicBool::new(false)), + is_streaming: Arc::new(AtomicBool::new(false)), } } @@ -47,6 +140,7 @@ impl ReplChannel { Self { single_message: Some(message), debug_mode: Arc::new(AtomicBool::new(false)), + is_streaming: Arc::new(AtomicBool::new(false)), } } @@ -64,7 +158,7 @@ impl Default for ReplChannel { fn print_help() { println!( r#" -IronClaw REPL - Interactive debugging mode +IronClaw REPL Commands: /help Show this help message @@ -90,6 +184,14 @@ Tips: ); } +/// Get the history file path (~/.ironclaw/history). +fn history_path() -> std::path::PathBuf { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".ironclaw") + .join("history") +} + #[async_trait] impl Channel for ReplChannel { fn name(&self) -> &str { @@ -102,38 +204,50 @@ impl Channel for ReplChannel { let debug_mode = Arc::clone(&self.debug_mode); std::thread::spawn(move || { - // If single message mode, send it and exit + // Single message mode: send it and return if let Some(msg) = single_message { let incoming = IncomingMessage::new("repl", "user", &msg); - if tx.blocking_send(incoming).is_err() { - return; - } - // Wait a bit for response, then the channel will close + let _ = tx.blocking_send(incoming); return; } - // Interactive REPL mode - let stdin = io::stdin(); - let mut stdout = io::stdout(); + // Set up rustyline + let config = Config::builder() + .history_ignore_dups(true) + .expect("valid config") + .auto_add_history(true) + .completion_type(CompletionType::List) + .build(); + + let mut rl = match Editor::with_config(config) { + Ok(editor) => editor, + Err(e) => { + eprintln!("Failed to initialize line editor: {e}"); + return; + } + }; + + rl.set_helper(Some(ReplHelper)); + + // Load history + let hist_path = history_path(); + if let Some(parent) = hist_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = rl.load_history(&hist_path); println!("IronClaw REPL - Type /help for commands, /quit to exit"); println!(); loop { - // Print prompt let prompt = if debug_mode.load(Ordering::Relaxed) { - "[debug] > " + "\x1b[33m[debug]\x1b[0m \x1b[36m>\x1b[0m " } else { - "> " + "\x1b[36m>\x1b[0m " }; - print!("{}", prompt); - let _ = stdout.flush(); - // Read line - let mut line = String::new(); - match stdin.lock().read_line(&mut line) { - Ok(0) => break, // EOF - Ok(_) => { + match rl.readline(prompt) { + Ok(line) => { let line = line.trim(); if line.is_empty() { continue; @@ -164,9 +278,26 @@ impl Channel for ReplChannel { break; } } - Err(_) => break, + Err(ReadlineError::Interrupted) => { + // Ctrl+C: send /interrupt + let msg = IncomingMessage::new("repl", "user", "/interrupt"); + if tx.blocking_send(msg).is_err() { + break; + } + } + Err(ReadlineError::Eof) => { + // Ctrl+D: quit + break; + } + Err(e) => { + eprintln!("Input error: {e}"); + break; + } } } + + // Save history on exit + let _ = rl.save_history(&history_path()); }); Ok(Box::pin(ReceiverStream::new(rx))) @@ -177,8 +308,23 @@ impl Channel for ReplChannel { _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { + // If we were streaming, the content was already printed via StreamChunk. + // Just finish the line and reset. + if self.is_streaming.swap(false, Ordering::Relaxed) { + println!(); + println!(); + return Ok(()); + } + + // Render markdown + let skin = make_skin(); + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + let text = termimad::FmtText::from(&skin, &response.content, Some(width)); + println!(); - println!("{}", response.content); + print!("{text}"); println!(); Ok(()) } @@ -193,37 +339,27 @@ impl Channel for ReplChannel { match status { StatusUpdate::Thinking(msg) => { if debug { - eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg); - } else { - eprint!("."); - let _ = io::stderr().flush(); + eprintln!("\x1b[90m[thinking] {msg}\x1b[0m"); } } StatusUpdate::ToolStarted { name } => { - if debug { - eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name); - } else { - eprintln!("\x1b[33m⚡ {}\x1b[0m", name); - } + eprintln!(" \x1b[33m>> {name}\x1b[0m"); } StatusUpdate::ToolCompleted { name, success } => { - if debug { - if success { - eprintln!("\x1b[32m[tool:done] {} ✓\x1b[0m", name); - } else { - eprintln!("\x1b[31m[tool:fail] {} ✗\x1b[0m", name); - } - } else if !success { - eprintln!("\x1b[31m✗ {} failed\x1b[0m", name); + if success { + eprintln!(" \x1b[32m<< {name}\x1b[0m"); + } else { + eprintln!(" \x1b[31m<< {name} failed\x1b[0m"); } } StatusUpdate::StreamChunk(chunk) => { - print!("{}", chunk); + self.is_streaming.store(true, Ordering::Relaxed); + print!("{chunk}"); let _ = io::stdout().flush(); } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { - eprintln!("\x1b[90m[status] {}\x1b[0m", msg); + eprintln!("\x1b[90m[status] {msg}\x1b[0m"); } } } @@ -235,9 +371,15 @@ impl Channel for ReplChannel { _user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { - println!(); - println!("\x1b[36m[notification]\x1b[0m {}", response.content); - println!(); + let skin = make_skin(); + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + + eprintln!("\x1b[36m[notification]\x1b[0m"); + let text = termimad::FmtText::from(&skin, &response.content, Some(width)); + eprint!("{text}"); + eprintln!(); Ok(()) } diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 7535d033..e6f4f0d8 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -95,9 +95,7 @@ pub use loader::{ DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir, discover_channels, }; -pub use router::{ - RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router, -}; +pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router}; pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig}; pub use schema::{ ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema, diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 44f81f99..0bd3182f 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -4,7 +4,6 @@ //! registered paths. Handles secret validation at the host level. use std::collections::HashMap; -use std::net::SocketAddr; use std::sync::Arc; use axum::{ @@ -469,56 +468,6 @@ pub fn create_wasm_channel_router( .with_state(state) } -/// HTTP server for WASM channel webhooks. -pub struct WasmChannelServer { - router: Arc, - extension_manager: Option>, -} - -impl WasmChannelServer { - /// Create a new server. - pub fn new(router: Arc) -> Self { - Self { - router, - extension_manager: None, - } - } - - /// Set the extension manager for OAuth callback handling. - pub fn with_extension_manager( - mut self, - manager: Arc, - ) -> Self { - self.extension_manager = Some(manager); - self - } - - /// Start the HTTP server. - /// - /// Returns a handle that can be used to shut down the server. - pub async fn start( - &self, - addr: SocketAddr, - ) -> Result, std::io::Error> { - let app = create_wasm_channel_router(self.router.clone(), self.extension_manager.clone()); - - let listener = tokio::net::TcpListener::bind(addr).await?; - - tracing::info!( - addr = %addr, - "WASM channel HTTP server started" - ); - - let handle = tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app).await { - tracing::error!("WASM channel HTTP server error: {}", e); - } - }); - - Ok(handle) - } -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/src/channels/webhook_server.rs b/src/channels/webhook_server.rs new file mode 100644 index 00000000..e38341f6 --- /dev/null +++ b/src/channels/webhook_server.rs @@ -0,0 +1,92 @@ +//! Unified HTTP server for all webhook routes. +//! +//! Composes route fragments from HttpChannel, WASM channel router, etc. +//! into a single axum server. Channels define routes but never spawn servers. + +use std::net::SocketAddr; + +use axum::Router; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::error::ChannelError; + +/// Configuration for the unified webhook server. +pub struct WebhookServerConfig { + /// Address to bind the server to. + pub addr: SocketAddr, +} + +/// A single HTTP server that hosts all webhook routes. +/// +/// Channels contribute route fragments via `add_routes()`, then a single +/// `start()` call binds the listener and spawns the server task. +pub struct WebhookServer { + config: WebhookServerConfig, + routes: Vec, + shutdown_tx: Option>, + handle: Option>, +} + +impl WebhookServer { + /// Create a new webhook server with the given bind address. + pub fn new(config: WebhookServerConfig) -> Self { + Self { + config, + routes: Vec::new(), + shutdown_tx: None, + handle: None, + } + } + + /// Accumulate a route fragment. Each fragment should already have its + /// state applied via `.with_state()`. + pub fn add_routes(&mut self, router: Router) { + self.routes.push(router); + } + + /// Bind the listener, merge all route fragments, and spawn the server. + pub async fn start(&mut self) -> Result<(), ChannelError> { + let mut app = Router::new(); + for fragment in self.routes.drain(..) { + app = app.merge(fragment); + } + + let listener = tokio::net::TcpListener::bind(self.config.addr) + .await + .map_err(|e| ChannelError::StartupFailed { + name: "webhook_server".to_string(), + reason: format!("Failed to bind to {}: {}", self.config.addr, e), + })?; + + tracing::info!("Webhook server listening on {}", self.config.addr); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + self.shutdown_tx = Some(shutdown_tx); + + let handle = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + tracing::info!("Webhook server shutting down"); + }) + .await + { + tracing::error!("Webhook server error: {}", e); + } + }); + + self.handle = Some(handle); + Ok(()) + } + + /// Signal graceful shutdown and wait for the server task to finish. + pub async fn shutdown(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.handle.take() { + let _ = handle.await; + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3ec13857..005a22c4 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -41,10 +41,6 @@ pub struct Cli { #[arg(long, global = true)] pub no_db: bool, - /// Simple REPL mode without TUI (for testing) - #[arg(long, global = true)] - pub repl: bool, - /// Single message mode - send one message and exit #[arg(short, long, global = true)] pub message: Option, diff --git a/src/main.rs b/src/main.rs index fec51b31..491f92aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,11 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps, SessionManager}, channels::{ - AppEvent, ChannelManager, GatewayChannel, HttpChannel, ReplChannel, TuiChannel, + ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer, + WebhookServerConfig, wasm::{ RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, - WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer, + WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router, }, web::log_layer::{LogBroadcaster, WebLogLayer}, }, @@ -39,7 +40,7 @@ use ironclaw::{ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - // Handle non-agent commands first (they don't need TUI/full setup) + // Handle non-agent commands first (they don't need full setup) match &cli.command { Some(Command::Tool(tool_cmd)) => { // Simple logging for CLI commands @@ -177,8 +178,7 @@ async fn main() -> anyhow::Result<()> { Err(e) => return Err(e.into()), }; - // Initialize session manager and authenticate BEFORE TUI setup - // This allows the auth menu to display cleanly without TUI interference + // Initialize session manager and authenticate before channel setup let session_config = SessionConfig { auth_base_url: config.llm.nearai.auth_base_url.clone(), session_path: config.llm.nearai.session_path.clone(), @@ -187,10 +187,9 @@ async fn main() -> anyhow::Result<()> { let session = create_session_manager(session_config).await; // Ensure we're authenticated before proceeding (may trigger login flow) - // This happens before TUI so the menu displays correctly session.ensure_authenticated().await?; - // Initialize tracing and channels based on mode + // Initialize tracing let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug")); @@ -198,53 +197,19 @@ async fn main() -> anyhow::Result<()> { // This gets wired to the gateway's /api/logs/events SSE endpoint later. let log_broadcaster = Arc::new(LogBroadcaster::new()); - // Determine which mode to use: REPL, single message, or TUI - let use_repl = cli.repl || cli.message.is_some(); + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .with(WebLogLayer::new(Arc::clone(&log_broadcaster))) + .init(); - // Create appropriate channel based on mode - let (tui_channel, tui_event_sender, repl_channel) = if use_repl { - // REPL mode - use simple stdin/stdout - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer().with_target(false)) - .with(WebLogLayer::new(Arc::clone(&log_broadcaster))) - .init(); - - let repl = if let Some(ref msg) = cli.message { - ReplChannel::with_message(msg.clone()) - } else { - ReplChannel::new() - }; - - (None, None, Some(repl)) + // Create CLI channel + let repl_channel = if let Some(ref msg) = cli.message { + Some(ReplChannel::with_message(msg.clone())) } else if config.channels.cli.enabled { - // TUI mode - let channel = TuiChannel::new(); - let log_writer = channel.log_writer(); - let event_sender = channel.event_sender(); - - tracing_subscriber::registry() - .with(env_filter) - .with( - tracing_subscriber::fmt::layer() - .with_writer(log_writer) - .without_time() - .with_target(false) - .with_level(true), - ) - .with(WebLogLayer::new(Arc::clone(&log_broadcaster))) - .init(); - - (Some(channel), Some(event_sender), None) + Some(ReplChannel::new()) } else { - // No CLI - just logging - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer().with_target(false)) - .with(WebLogLayer::new(Arc::clone(&log_broadcaster))) - .init(); - - (None, None, None) + None }; tracing::info!("Starting IronClaw..."); @@ -266,34 +231,6 @@ async fn main() -> anyhow::Result<()> { let llm = create_llm_provider(&config.llm, session.clone())?; tracing::info!("LLM provider initialized: {}", llm.model_name()); - // Fetch available models and send to TUI (async, non-blocking) - if let Some(ref event_tx) = tui_event_sender { - let llm_for_models = llm.clone(); - let event_tx = event_tx.clone(); - tokio::spawn(async move { - match llm_for_models.list_models().await { - Ok(models) if !models.is_empty() => { - let _ = event_tx.send(AppEvent::AvailableModels(models)).await; - } - Ok(_) => { - let _ = event_tx - .send(AppEvent::ErrorMessage( - "No models available from API".into(), - )) - .await; - } - Err(e) => { - let _ = event_tx - .send(AppEvent::ErrorMessage(format!( - "Failed to fetch models: {}", - e - ))) - .await; - } - } - }); - } - // Initialize safety layer let safety = Arc::new(SafetyLayer::new(&config.safety)); tracing::info!("Safety layer initialized"); @@ -536,7 +473,6 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); - // Add REPL channel if in REPL mode if let Some(repl) = repl_channel { channels.add(Box::new(repl)); if cli.message.is_some() { @@ -545,25 +481,11 @@ async fn main() -> anyhow::Result<()> { tracing::info!("REPL mode enabled"); } } - // Add TUI channel if CLI is enabled (already created for logging hookup) - else if let Some(tui) = tui_channel { - channels.add(Box::new(tui)); - tracing::info!("TUI channel enabled"); - } - // Add HTTP channel if configured and not CLI-only mode - if !cli.cli_only && !use_repl { - if let Some(ref http_config) = config.channels.http { - channels.add(Box::new(HttpChannel::new(http_config.clone()))); - tracing::info!( - "HTTP channel enabled on {}:{}", - http_config.host, - http_config.port - ); - } - } + // Collect webhook route fragments; a single WebhookServer hosts them all. + let mut webhook_routes: Vec = Vec::new(); - // Load WASM channels if enabled + // Load WASM channels and register their webhook routes. if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) { Ok(runtime) => { @@ -575,7 +497,6 @@ async fn main() -> anyhow::Result<()> { .await { Ok(results) => { - // Create router for WASM channel webhooks let wasm_router = Arc::new(WasmChannelRouter::new()); let mut has_webhook_channels = false; @@ -583,10 +504,8 @@ async fn main() -> anyhow::Result<()> { let channel_name = loaded.name().to_string(); tracing::info!("Loaded WASM channel: {}", channel_name); - // Get webhook secret name from capabilities (generic) let secret_name = loaded.webhook_secret_name(); - // Get webhook secret for this channel from secrets store let webhook_secret = if let Some(ref secrets) = secrets_store { secrets .get_decrypted("default", &secret_name) @@ -597,12 +516,9 @@ async fn main() -> anyhow::Result<()> { None }; - // Get the secret header name from capabilities let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); - // Register channel with router for webhook handling - // Use known webhook path based on channel name let webhook_path = format!("/webhook/{}", channel_name); let endpoints = vec![RegisteredEndpoint { channel_name: channel_name.clone(), @@ -613,8 +529,6 @@ async fn main() -> anyhow::Result<()> { let channel_arc = Arc::new(loaded.channel); - // Inject runtime config into the channel (tunnel_url, webhook_secret) - // This must be done before start() is called { let mut config_updates = std::collections::HashMap::new(); @@ -660,7 +574,6 @@ async fn main() -> anyhow::Result<()> { .await; has_webhook_channels = true; - // Inject credentials for this channel (generic pattern-based injection) if let Some(ref secrets) = secrets_store { match inject_channel_credentials( &channel_arc, @@ -688,32 +601,14 @@ async fn main() -> anyhow::Result<()> { } } - // Wrap in SharedWasmChannel for ChannelManager - // Both the router and ChannelManager share the same underlying channel channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } - // Start WASM channel webhook server if we have channels with webhooks if has_webhook_channels && config.tunnel.public_url.is_some() { - let mut server = WasmChannelServer::new(wasm_router); - if let Some(ref ext_mgr) = extension_manager { - server = server.with_extension_manager(Arc::clone(ext_mgr)); - } - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080)); - match server.start(addr).await { - Ok(_handle) => { - tracing::info!( - "WASM channel webhook server started on {}", - addr - ); - } - Err(e) => { - tracing::error!( - "Failed to start WASM channel webhook server: {}", - e - ); - } - } + webhook_routes.push(create_wasm_channel_router( + wasm_router, + extension_manager.as_ref().map(Arc::clone), + )); } for (path, err) in &results.errors { @@ -735,6 +630,43 @@ async fn main() -> anyhow::Result<()> { } } + // Add HTTP channel if configured and not CLI-only mode. + // Extract its routes for the unified server; the channel itself just + // provides the mpsc stream. + let mut webhook_server_addr: Option = None; + if !cli.cli_only { + if let Some(ref http_config) = config.channels.http { + let http_channel = HttpChannel::new(http_config.clone()); + webhook_routes.push(http_channel.routes()); + let (host, port) = http_channel.addr(); + webhook_server_addr = Some( + format!("{}:{}", host, port) + .parse() + .expect("HttpConfig host:port must be a valid SocketAddr"), + ); + channels.add(Box::new(http_channel)); + tracing::info!( + "HTTP channel enabled on {}:{}", + http_config.host, + http_config.port + ); + } + } + + // Start the unified webhook server if any routes were registered. + let mut webhook_server = if !webhook_routes.is_empty() { + let addr = + webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080))); + let mut server = WebhookServer::new(WebhookServerConfig { addr }); + for routes in webhook_routes { + server.add_routes(routes); + } + server.start().await?; + Some(server) + } else { + None + }; + // Create workspace for agent (shared with memory tools) let workspace = store.as_ref().map(|s| { let mut ws = Workspace::new("default", s.pool()); @@ -811,6 +743,11 @@ async fn main() -> anyhow::Result<()> { // Run the agent (blocks until shutdown) agent.run().await?; + // Shut down the webhook server if one was started + if let Some(ref mut server) = webhook_server { + server.shutdown().await; + } + tracing::info!("Agent shutdown complete"); Ok(()) } diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index 1b6ebf90..e72df9f8 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -12,6 +12,34 @@ use tokio::fs; use crate::context::JobContext; use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::workspace::paths as ws_paths; + +/// Well-known workspace filenames that must go through memory_write, not write_file. +/// +/// If the LLM tries to write one of these via the filesystem tool we reject +/// immediately and point it at the correct tool. +const WORKSPACE_FILES: &[&str] = &[ + ws_paths::HEARTBEAT, + ws_paths::MEMORY, + ws_paths::IDENTITY, + ws_paths::SOUL, + ws_paths::AGENTS, + ws_paths::USER, + ws_paths::README, +]; + +/// Check whether `path` resolves to a workspace file that should be written +/// through `memory_write` instead of `write_file`. +fn is_workspace_path(path: &str) -> bool { + let filename = std::path::Path::new(path) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(path); + + WORKSPACE_FILES.iter().any(|ws| *ws == filename) + || path.starts_with("daily/") + || path.starts_with("context/") +} /// Maximum file size for reading (1MB). const MAX_READ_SIZE: u64 = 1024 * 1024; @@ -276,6 +304,15 @@ impl Tool for WriteFileTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?; + // Reject workspace paths: these live in the database, not on disk. + if is_workspace_path(path_str) { + return Err(ToolError::InvalidParameters(format!( + "'{}' is a workspace memory file. Use the memory_write tool instead of write_file. \ + For HEARTBEAT.md use target='heartbeat', for MEMORY.md use target='memory'.", + path_str + ))); + } + let content = params .get("content") .and_then(|v| v.as_str()) @@ -726,6 +763,78 @@ mod tests { assert!(content.contains("println!(\"new\")")); } + #[tokio::test] + async fn test_write_file_rejects_workspace_paths() { + let dir = TempDir::new().unwrap(); + let tool = WriteFileTool::new().with_base_dir(dir.path().to_path_buf()); + let ctx = JobContext::default(); + + let workspace_files = &[ + "HEARTBEAT.md", + "MEMORY.md", + "IDENTITY.md", + "SOUL.md", + "AGENTS.md", + "USER.md", + "README.md", + ]; + + for filename in workspace_files { + let path = dir.path().join(filename); + let err = tool + .execute( + serde_json::json!({ + "path": path.to_str().unwrap(), + "content": "test" + }), + &ctx, + ) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("memory_write"), + "Rejection for {} should mention memory_write, got: {}", + filename, + msg + ); + } + + // daily/ and context/ prefixes should also be rejected + for prefix_path in &["daily/2024-01-15.md", "context/vision.md"] { + let err = tool + .execute( + serde_json::json!({ + "path": prefix_path, + "content": "test" + }), + &ctx, + ) + .await + .unwrap_err(); + + assert!( + err.to_string().contains("memory_write"), + "Rejection for {} should mention memory_write", + prefix_path + ); + } + + // Regular files should still work + let regular_path = dir.path().join("normal.txt"); + let result = tool + .execute( + serde_json::json!({ + "path": regular_path.to_str().unwrap(), + "content": "fine" + }), + &ctx, + ) + .await; + assert!(result.is_ok()); + } + #[tokio::test] async fn test_list_dir() { let dir = TempDir::new().unwrap(); diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index d50b4ee4..d77b3c59 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -133,10 +133,11 @@ impl Tool for MemoryWriteTool { } fn description(&self) -> &str { - "Write to persistent memory. Use for important facts, decisions, preferences, \ - or lessons learned that should be remembered across sessions. Use 'memory' target \ - for curated long-term facts, 'daily_log' for timestamped session notes, or \ - provide a custom path for arbitrary file creation." + "Write to persistent memory (database-backed, NOT the local filesystem). \ + Use for important facts, decisions, preferences, or lessons learned that should \ + be remembered across sessions. Targets: 'memory' for curated long-term facts, \ + 'daily_log' for timestamped session notes, 'heartbeat' for the periodic \ + checklist (HEARTBEAT.md), or provide a custom path for arbitrary file creation." } fn parameters_schema(&self) -> serde_json::Value { @@ -149,7 +150,7 @@ impl Tool for MemoryWriteTool { }, "target": { "type": "string", - "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, or a path like 'projects/alpha/notes.md'", + "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, or a path like 'projects/alpha/notes.md'", "default": "daily_log" }, "append": { @@ -214,6 +215,20 @@ impl Tool for MemoryWriteTool { .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d")) } + "heartbeat" => { + if append { + self.workspace + .append(paths::HEARTBEAT, content) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; + } else { + self.workspace + .write(paths::HEARTBEAT, content) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; + } + paths::HEARTBEAT.to_string() + } path => { if append { self.workspace diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 32cc2121..24f77217 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -60,6 +60,23 @@ use uuid::Uuid; use crate::error::WorkspaceError; +/// Default template seeded into HEARTBEAT.md on first access. +/// +/// Intentionally comment-only so the heartbeat runner treats it as +/// "effectively empty" and skips the LLM call until the user adds +/// real tasks. +const HEARTBEAT_SEED: &str = "\ +# Heartbeat Checklist + +"; + /// Workspace provides database-backed memory storage for an agent. /// /// Each workspace is scoped to a user (and optionally an agent). @@ -246,10 +263,17 @@ impl Workspace { } /// Get the heartbeat checklist (HEARTBEAT.md). + /// + /// Returns the DB-stored checklist if it exists, otherwise falls back + /// to the in-memory seed template. The seed is never written to the + /// database; the user creates the real file via `memory_write` when + /// they actually want periodic checks. The seed content is all HTML + /// comments, which the heartbeat runner treats as "effectively empty" + /// and skips the LLM call. pub async fn heartbeat_checklist(&self) -> Result, WorkspaceError> { match self.read(paths::HEARTBEAT).await { Ok(doc) => Ok(Some(doc.content)), - Err(WorkspaceError::DocumentNotFound { .. }) => Ok(None), + Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())), Err(e) => Err(e), } } diff --git a/examples/wasm-tools/slack/.gitignore b/tools-src/wasm-tools/slack/.gitignore similarity index 100% rename from examples/wasm-tools/slack/.gitignore rename to tools-src/wasm-tools/slack/.gitignore diff --git a/examples/wasm-tools/slack/Cargo.toml b/tools-src/wasm-tools/slack/Cargo.toml similarity index 100% rename from examples/wasm-tools/slack/Cargo.toml rename to tools-src/wasm-tools/slack/Cargo.toml diff --git a/examples/wasm-tools/slack/README.md b/tools-src/wasm-tools/slack/README.md similarity index 99% rename from examples/wasm-tools/slack/README.md rename to tools-src/wasm-tools/slack/README.md index aee92f18..c1c9efaf 100644 --- a/examples/wasm-tools/slack/README.md +++ b/tools-src/wasm-tools/slack/README.md @@ -34,7 +34,7 @@ A standalone WASM component that provides Slack integration for IronClaw. This s ## Building ```bash -cd examples/wasm-tools/slack +cd tools-src/wasm-tools/slack cargo component build --release ``` diff --git a/examples/wasm-tools/slack/slack-tool.capabilities.json b/tools-src/wasm-tools/slack/slack-tool.capabilities.json similarity index 100% rename from examples/wasm-tools/slack/slack-tool.capabilities.json rename to tools-src/wasm-tools/slack/slack-tool.capabilities.json diff --git a/examples/wasm-tools/slack/src/api.rs b/tools-src/wasm-tools/slack/src/api.rs similarity index 100% rename from examples/wasm-tools/slack/src/api.rs rename to tools-src/wasm-tools/slack/src/api.rs diff --git a/examples/wasm-tools/slack/src/lib.rs b/tools-src/wasm-tools/slack/src/lib.rs similarity index 100% rename from examples/wasm-tools/slack/src/lib.rs rename to tools-src/wasm-tools/slack/src/lib.rs diff --git a/examples/wasm-tools/slack/src/types.rs b/tools-src/wasm-tools/slack/src/types.rs similarity index 100% rename from examples/wasm-tools/slack/src/types.rs rename to tools-src/wasm-tools/slack/src/types.rs