diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 88592a8..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,48 +0,0 @@ -# Strict Rust compiler configuration for zesdex -# Forces all warnings as errors, enables maximum optimization for release builds. - -[target.'cfg(all())'] -# Treat all warnings as errors — zero tolerance policy -rustflags = [ - # Force all lints to error level (stricter than -Dwarnings) - "-W", "unused", - "-W", "dead_code", - "-W", "unreachable_code", - "-W", "unused_imports", - "-W", "unused_variables", - "-W", "unused_mut", - "-W", "unused_must_use", - "-W", "unused_unsafe", - "-W", "unused_extern_crates", - - # Safety-critical - "-W", "trivial_casts", - "-W", "trivial_numeric_casts", - - # Deprecation and future compat - "-W", "deprecated", - "-W", "deprecated_in_future", - "-W", "keyword_idents", - "-W", "noop_method_call", - - # Type system - "-W", "invalid_type_param_default", - "-W", "unused_labels", - "-W", "while_true", -] - -[profile.release] -# Maximum speed optimizations for release builds -opt-level = 3 -debug = false -debug-assertions = false -overflow-checks = true -lto = "fat" -codegen-units = 1 -panic = "abort" -strip = "symbols" - -[profile.dev] -# Keep dev fast but still strict -opt-level = 0 -debug = true diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 6179964..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,5121 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "auto_encoder" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bfb5cf22391514bc73a3905267b01ee10c767b256719ae69267661564aff7c" -dependencies = [ - "chardetng", - "encoding_rs", - "memchr", - "phf 0.11.3", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - -[[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 = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bstr" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" -dependencies = [ - "memchr", - "regex-automata", - "serde_core", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - -[[package]] -name = "bytemuck" -version = "1.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cc" -version = "1.2.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chardetng" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" -dependencies = [ - "cfg-if", - "encoding_rs", - "memchr", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "compact_str" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[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 = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.13.0", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[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 = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf 0.11.3", -] - -[[package]] -name = "cssparser" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" -dependencies = [ - "cssparser-macros 0.6.1", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", -] - -[[package]] -name = "cssparser" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" -dependencies = [ - "cssparser-macros 0.7.0", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.118", -] - -[[package]] -name = "cssparser-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" -dependencies = [ - "quote", - "syn 2.0.118", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.118", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[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.118", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dom_query" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" -dependencies = [ - "bit-set 0.8.0", - "cssparser 0.37.0", - "foldhash", - "html5ever", - "nom 8.0.0", - "precomputed-hash", - "selectors 0.38.0", - "tendril", -] - -[[package]] -name = "dom_smoothie" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8b9b294aabb8010b37c49a07d6f82175152f4927855d534979a38737721875" -dependencies = [ - "dom_query", - "flagset", - "foldhash", - "gjson", - "html-escape", - "once_cell", - "phf 0.13.1", - "tendril", - "thiserror 2.0.18", - "unicode-segmentation", -] - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ego-tree" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set 0.5.3", - "regex", -] - -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set 0.8.0", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fancy-regex" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" -dependencies = [ - "bit-set 0.8.0", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - -[[package]] -name = "fast_html2md" -version = "0.0.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88587bc3328375d9866f0f71ffcfe5768fe0df79dde79c79216239711868f77f" -dependencies = [ - "auto_encoder", - "futures-util", - "lazy_static", - "lol_html", - "percent-encoding", - "regex", - "url", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[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 = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gjson" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43503cc176394dd30a6525f5f36e838339b8b5619be33ed9a7783841580a97b6" - -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" -dependencies = [ - "hashbrown 0.17.1", -] - -[[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 = "html-escape" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" - -[[package]] -name = "html5ever" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" -dependencies = [ - "log", - "markup5ever", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.118", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.118", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.38.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lol_html" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cssparser 0.36.0", - "encoding_rs", - "foldhash", - "hashbrown 0.17.1", - "memchr", - "mime", - "precomputed-hash", - "selectors 0.37.0", - "thiserror 2.0.18", -] - -[[package]] -name = "lru" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" -dependencies = [ - "hashbrown 0.17.1", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix 0.29.0", - "winapi", -] - -[[package]] -name = "markup5ever" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" -dependencies = [ - "log", - "tendril", - "web_atoms", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "nucleo-matcher" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" -dependencies = [ - "memchr", - "unicode-segmentation", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.0", - "objc2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "fast-srgb8", - "libm", - "palette_derive", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "pest_meta" -version = "2.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" -dependencies = [ - "pest", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plist" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[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 = "process-wrap" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" -dependencies = [ - "futures", - "indexmap", - "nix 0.31.3", - "tokio", - "tracing", - "windows", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.0", - "memchr", - "unicase", -] - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "ratatui" -version = "0.30.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termina", - "ratatui-termwiz", - "ratatui-widgets", - "serde", -] - -[[package]] -name = "ratatui-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" -dependencies = [ - "bitflags 2.13.0", - "compact_str", - "critical-section", - "hashbrown 0.17.1", - "itertools", - "kasuari", - "lru", - "palette", - "serde", - "strum", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termina" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" -dependencies = [ - "instability", - "ratatui-core", - "termina", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.17.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "serde", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rmcp" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" -dependencies = [ - "async-trait", - "chrono", - "futures", - "http", - "pastey", - "pin-project-lite", - "process-wrap", - "reqwest", - "rmcp-macros", - "serde", - "serde_json", - "sse-stream", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", -] - -[[package]] -name = "rmcp-macros" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.118", -] - -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rusqlite" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" -dependencies = [ - "bitflags 2.13.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[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 = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scraper" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" -dependencies = [ - "cssparser 0.37.0", - "ego-tree", - "getopts", - "html5ever", - "precomputed-hash", - "selectors 0.38.0", - "tendril", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" -dependencies = [ - "bitflags 2.13.0", - "cssparser 0.36.0", - "derive_more", - "log", - "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", - "precomputed-hash", - "rustc-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "selectors" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" -dependencies = [ - "bitflags 2.13.0", - "cssparser 0.37.0", - "derive_more", - "log", - "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", - "precomputed-hash", - "rustc-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[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 2.0.118", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_yaml_ng" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "similar" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" -dependencies = [ - "bstr", -] - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - -[[package]] -name = "sse-stream" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" -dependencies = [ - "bytes", - "futures-util", - "http-body", - "http-body-util", - "pin-project-lite", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.13.1", - "precomputed-hash", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "fancy-regex 0.16.2", - "flate2", - "fnv", - "once_cell", - "plist", - "regex-syntax", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "walkdir", - "yaml-rust", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" -dependencies = [ - "new_debug_unreachable", -] - -[[package]] -name = "termina" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" -dependencies = [ - "bitflags 2.13.0", - "parking_lot", - "rustix", - "signal-hook", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom 7.1.3", - "phf 0.11.3", - "phf_codegen 0.11.3", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.13.0", - "fancy-regex 0.11.0", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix 0.29.0", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf 0.11.3", - "sha2 0.10.9", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tiktoken-rs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" -dependencies = [ - "anyhow", - "base64", - "bstr", - "fancy-regex 0.17.0", - "lazy_static", - "regex", - "rustc-hash", -] - -[[package]] -name = "time" -version = "0.3.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.13.0", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1", - "thiserror 2.0.18", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "atomic", - "getrandom 0.4.3", - "js-sys", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.118", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web_atoms" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" -dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache", - "string_cache_codegen", -] - -[[package]] -name = "webbrowser" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" -dependencies = [ - "core-foundation 0.10.1", - "jni", - "log", - "ndk-context", - "objc2", - "objc2-foundation", - "url", - "web-sys", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2 0.10.9", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "zesdex-api" -version = "1.21.1" -dependencies = [ - "anyhow", - "argon2", - "axum", - "chrono", - "futures-util", - "jsonwebtoken", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tower", - "tower-http", - "tracing", - "uuid", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zesdex-application" -version = "1.21.1" -dependencies = [ - "anyhow", - "base64", - "chrono", - "futures-util", - "serde", - "serde_json", - "sha2 0.11.0", - "tokio", - "tracing", - "url", - "uuid", - "zesdex-domain", -] - -[[package]] -name = "zesdex-bootstrap" -version = "1.21.1" -dependencies = [ - "anyhow", - "chrono", - "dirs", - "serde", - "serde_json", - "tokio", - "tracing", - "uuid", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zesdex-daemon" -version = "1.21.1" -dependencies = [ - "anyhow", - "base64", - "chrono", - "crossterm", - "dirs", - "hex", - "ignore", - "ratatui", - "serde", - "serde_json", - "sha2 0.11.0", - "tokio", - "tracing", - "uuid", - "webbrowser", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zesdex-domain" -version = "1.21.1" -dependencies = [ - "anyhow", - "base64", - "chrono", - "libc", - "serde", - "serde_json", - "sha2 0.11.0", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "zesdex-gateway" -version = "1.21.1" -dependencies = [ - "anyhow", - "axum", - "chrono", - "clap", - "dirs", - "rusqlite", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", - "zesdex-api", - "zesdex-application", - "zesdex-daemon", - "zesdex-domain", - "zesdex-grpc", - "zesdex-infrastructure", - "zesdex-tui", - "zesdex-web", - "zesdex-ws", -] - -[[package]] -name = "zesdex-grpc" -version = "1.21.1" -dependencies = [ - "anyhow", - "axum", - "chrono", - "serde", - "serde_json", - "tokio", - "tracing", - "uuid", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zesdex-infrastructure" -version = "1.21.1" -dependencies = [ - "anyhow", - "argon2", - "axum", - "base64", - "chrono", - "clap", - "dirs", - "dom_smoothie", - "fast_html2md", - "futures-util", - "globset", - "hex", - "ignore", - "include_dir", - "infer", - "jsonwebtoken", - "libc", - "nucleo-matcher", - "percent-encoding", - "pulldown-cmark", - "rand_core 0.6.4", - "regex", - "reqwest", - "rmcp", - "rusqlite", - "scraper", - "serde", - "serde_json", - "serde_yaml_ng", - "sha2 0.11.0", - "similar", - "syntect", - "tiktoken-rs", - "tokio", - "tower", - "tower-http", - "tracing", - "url", - "uuid", - "webbrowser", - "zesdex-application", - "zesdex-domain", -] - -[[package]] -name = "zesdex-tui" -version = "1.21.1" -dependencies = [ - "anyhow", - "base64", - "chrono", - "crossterm", - "dirs", - "hex", - "nucleo-matcher", - "pulldown-cmark", - "ratatui", - "rusqlite", - "serde", - "serde_json", - "sha2 0.11.0", - "tiktoken-rs", - "tokio", - "tracing", - "uuid", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zesdex-web" -version = "1.21.1" -dependencies = [ - "anyhow", - "axum", - "chrono", - "include_dir", - "mime_guess", - "serde", - "serde_json", - "tokio", - "tower", - "tracing", - "uuid", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zesdex-ws" -version = "1.21.1" -dependencies = [ - "anyhow", - "axum", - "chrono", - "futures-util", - "serde", - "serde_json", - "tokio", - "tracing", - "uuid", - "zesdex-application", - "zesdex-domain", - "zesdex-infrastructure", -] - -[[package]] -name = "zmij" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 32be86d..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,95 +0,0 @@ -[workspace] -resolver = "2" -members = [ - "apps/domain", - "apps/application", - "apps/infrastructure", - "apps/interfaces/tui", - "apps/interfaces/api", - "apps/interfaces/daemon", - "apps/interfaces/ws", - "apps/interfaces/grpc", - "apps/interfaces/web", - "apps/gateway", - "apps/bootstrap", -] - -[workspace.package] -version = "1.22.0" -edition = "2021" -authors = ["asepharyana "] - -[workspace.lints.rust] -unused = "deny" -dead_code = "deny" -unreachable_code = "deny" -unused_imports = "deny" -unused_variables = "deny" -unused_mut = "deny" -unused_must_use = "deny" -deprecated = "deny" -trivial_casts = "deny" -trivial_numeric_casts = "deny" - -[workspace.lints.clippy] -all = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -2 } - -[workspace.dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml_ng = "0.10" -chrono = { version = "0.4", features = ["serde"] } -uuid = { version = "1", features = ["v4", "v5"] } -anyhow = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] } -ratatui = "0.30.2" -crossterm = "0.29" -rusqlite = { version = "0.40", features = ["bundled"] } -thiserror = "1" -base64 = "0.22" -sha2 = "0.11" -hex = "0.4" -libc = "0.2" -dirs = "6" -regex = "1" -globset = "0.4" -ignore = "0.4" -nucleo-matcher = "0.3" -futures-util = "0.3" -rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } -tiktoken-rs = "0.12" -similar = "3" -syntect = { version = "5", default-features = false, features = ["default-fancy"] } -pulldown-cmark = { version = "0.13", default-features = false } -infer = "0.19" -webbrowser = "1" -url = "2" -percent-encoding = "2" -dom_smoothie = "0.18.0" -fast_html2md = "0.0.62" -scraper = "0.27.0" -include_dir = "0.7" -axum = { version = "0.8", features = ["macros"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "limit"] } -argon2 = "0.5" -jsonwebtoken = "9" -clap = { version = "4", features = ["derive"] } -rand_core = { version = "0.6", features = ["getrandom"] } - -# Clean-architecture workspace crate references -zesdex-domain = { path = "apps/domain" } -zesdex-application = { path = "apps/application" } -zesdex-infrastructure = { path = "apps/infrastructure" } -zesdex-tui = { path = "apps/interfaces/tui" } -zesdex-api = { path = "apps/interfaces/api" } -zesdex-daemon = { path = "apps/interfaces/daemon" } -zesdex-ws = { path = "apps/interfaces/ws" } -zesdex-grpc = { path = "apps/interfaces/grpc" } -zesdex-web = { path = "apps/interfaces/web" } -zesdex-gateway = { path = "apps/gateway" } -zesdex-bootstrap = { path = "apps/bootstrap" } diff --git a/apps/application/src/agent/mod.rs b/apps/application/src/agent/mod.rs deleted file mode 100644 index 99ce86f..0000000 --- a/apps/application/src/agent/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -use anyhow::Result; -use std::future::Future; - -use zesdex_domain::agent::AgentTurnParams; - -/// Interface for dispatching tool calls to their concrete implementations. -pub trait ToolExecutor: Send + Sync { - /// Execute a tool call asynchronously. - fn execute( - &self, - tool_name: &str, - args: &serde_json::Value, - ) -> impl Future> + Send; - - /// Whether a tool is *read-only* and therefore safe to run concurrently - /// with other read-only tools in the same assistant message. - /// - /// Defaults to `false` (sequential) so a caller that does not know the - /// tool surface stays conservative. Concrete executors that know their - /// tools override this — e.g. return `true` for `read`/`grep`/`glob`. - fn is_parallel_safe(&self, tool_name: &str) -> bool { - let _ = tool_name; - false - } -} - -/// Service for running agent turns asynchronously. -pub trait AgentTurnService: Send + Sync { - /// Run a full agent turn loop asynchronously. - fn run_turn(&self, params: AgentTurnParams) -> impl Future> + Send; -} - -pub mod turn_service; - -pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl}; diff --git a/apps/application/src/agent/turn_service.rs b/apps/application/src/agent/turn_service.rs deleted file mode 100644 index 23ff9a1..0000000 --- a/apps/application/src/agent/turn_service.rs +++ /dev/null @@ -1,744 +0,0 @@ -use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use tracing::{debug, info, warn}; - -use zesdex_domain::agent::{AgentTurnParams, TurnEvent}; -use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; -use zesdex_domain::main_agent_prompt_with_project_context; - -use super::ToolExecutor; -use crate::ports::ProviderService; - -/// Maximum tool-call iterations per agent turn before forcing termination. -const MAX_TURN_ITERATIONS: u32 = 50; - -/// Maximum number of consecutive identical tool errors before the loop -/// injects a recovery note and forces a different approach. -const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3; - -/// Total tool-call errors tolerated per turn before the loop is stopped. -const MAX_TOTAL_TOOL_ERRORS: usize = 8; - -/// Ceiling for a single tool-result message inserted into context. -/// -/// Tool outputs can be huge (read / semantic_search). Truncating keeps the -/// context window from exploding while preserving the important head. -const TOOL_OUTPUT_MAX_CHARS: usize = 12_000; - -/// Total conversation characters that trigger auto-compaction before the -/// next LLM call. -const AUTO_COMPACT_CHARS: usize = 60_000; - -// --------------------------------------------------------------------------- -// Helper: push a TurnEvent onto the shared queue. -// --------------------------------------------------------------------------- - -fn push_event(queue: &Arc>>, event: TurnEvent) { - if let Ok(mut q) = queue.lock() { - q.push_back(event); - } -} - -// --------------------------------------------------------------------------- -// Helper: stream-event callback that forwards tokens to the turn-event queue -// and checks the abort flag on each emission. -// --------------------------------------------------------------------------- - -fn make_stream_callback( - abort: &Arc, - turn_events: &Arc>>, -) -> Box bool + Send> { - let abort_clone = Arc::clone(abort); - let events_clone = Arc::clone(turn_events); - Box::new(move |event: &StreamEvent| -> bool { - if abort_clone.load(Ordering::SeqCst) { - return false; - } - match event { - StreamEvent::Token(s) => { - push_event(&events_clone, TurnEvent::StreamToken(s.clone())); - } - StreamEvent::Reasoning(s) => { - push_event(&events_clone, TurnEvent::StreamReasoning(s.clone())); - } - _ => {} - } - true - }) -} - -// --------------------------------------------------------------------------- -// Helper: truncate a long tool output before it enters the conversation -// context. Preserves the head and appends a clear truncation marker. -// --------------------------------------------------------------------------- - -fn truncate_tool_output(output: String) -> String { - if output.len() <= TOOL_OUTPUT_MAX_CHARS { - return output; - } - let mut result: String = output.chars().take(TOOL_OUTPUT_MAX_CHARS).collect(); - result.push_str(&format!( - "\n...[truncated {} chars]", - output.len() - TOOL_OUTPUT_MAX_CHARS - )); - result -} - -// --------------------------------------------------------------------------- -// Helper: adaptive generation parameters. -// --------------------------------------------------------------------------- - -/// Pick a `max_tokens` budget for the turn's next LLM call based on the -/// length of the user's request. Short requests need far fewer tokens than -/// the current hardcoded 4096 — big savings on small tasks. -fn adaptive_max_tokens(request_len: usize) -> u32 { - if request_len <= 80 { - 800 - } else if request_len <= 400 { - 1600 - } else { - 4096 - } -} - -/// Sum the character length of the conversation (user + assistant + -/// tool content) as a cheap proxy for context size. -fn conversation_chars(messages: &[ChatMessage]) -> usize { - messages - .iter() - .map(|m| m.content.as_deref().map(str::len).unwrap_or(0)) - .sum() -} - -/// The maximum combined size (characters) of project-rule files injected into -/// the system prompt, so a huge AGENTS.md cannot blow the context window. -const PROJECT_CONTEXT_MAX_CHARS: usize = 12_000; - -/// Case-insensitive rule filenames auto-loaded from the workspace root into -/// the system prompt, matching the Claude-Code/AGENTS.md convention. -const RULE_FILENAMES: [&str; 6] = [ - "AGENTS.md", - "agent.md", - "CLAUDE.md", - "claude.md", - ".cursorrules", - ".zesdexrules", -]; - -/// Build a compact "project context" block from the repo's convention files -/// (AGENTS.md, CLAUDE.md, .cursorrules, …) found at the workspace root. -/// -/// Follows the Claude-Code convention of loading AGENTS.md at startup so the -/// model starts each turn with the repo's rules. Reads are best-effort and -/// capped at [`PROJECT_CONTEXT_MAX_CHARS`] total; missing files are skipped. -fn build_project_context(root: &std::path::Path) -> String { - let mut ctx = String::new(); - for file in RULE_FILENAMES { - let full = root.join(file); - if let Ok(content) = std::fs::read_to_string(&full) { - ctx.push_str(&format!("\n### {file}\n```\n{}\n```", content.trim())); - } - } - let context = ctx.trim().to_string(); - if context.len() <= PROJECT_CONTEXT_MAX_CHARS { - return context; - } - context - .chars() - .take(PROJECT_CONTEXT_MAX_CHARS) - .collect::() - + "\n...[project context truncated]" -} - -/// Track repeated tool-call errors so the loop can recover instead of -/// burning iterations retrying the same failing tool. -#[derive(Default)] -struct ErrorTracker { - consecutive: usize, - total: usize, - last_tool: String, - last_error: String, -} - -impl ErrorTracker { - fn record(&mut self, tool_name: &str, error: &str, messages: &mut Vec) { - if self.last_tool == tool_name { - self.consecutive += 1; - } else { - self.consecutive = 1; - } - self.last_tool = tool_name.to_string(); - self.last_error = error.to_string(); - self.total += 1; - - // Inject a recovery note once the same tool keeps failing. - if self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS - && !messages.iter().any(|m| { - m.content - .as_deref() - .is_some_and(|c| c.contains("[System note]")) - }) - { - messages.push(ChatMessage::system( - zesdex_domain::agent::prompt::error_recovery_note(tool_name, error), - )); - } - } - - fn should_stop(&self) -> bool { - self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || self.total >= MAX_TOTAL_TOOL_ERRORS - } -} - -// --------------------------------------------------------------------------- -// Helper: execute a single tool call, push events, return the result string. -// --------------------------------------------------------------------------- - -async fn execute_tool_call( - tool_executor: &T, - turn_events: &Arc>>, - tc: &zesdex_domain::core::ToolCall, -) -> String { - let name = &tc.function.name; - let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments); - - debug!("executing tool: {name}"); - - let output = match tool_executor.execute(name, &args).await { - Ok(o) => o, - Err(e) => format!("Error: {e}"), - }; - - let is_error = output.starts_with("Error:"); - let output = truncate_tool_output(output); - - push_event( - turn_events, - TurnEvent::ToolResult { - tool_call_id: tc.id.clone(), - tool_name: name.clone(), - output: output.clone(), - is_error, - path: None, - }, - ); - - output -} - -// --------------------------------------------------------------------------- -// Helper: bounded-parallel execution of read-only tool calls. -// --------------------------------------------------------------------------- - -/// Maximum number of read-only tool calls executed concurrently in a single -/// assistant batch. Models rarely emit more than a handful of reads per -/// message; this cap keeps resource usage bounded while still removing the -/// serial round-trip latency of many independent lookups. -const MAX_PARALLEL_TOOLS: usize = 8; - -fn tool_executor_ref(tool_executor: &T) -> &T { - tool_executor -} - -/// Execute a batch of *read-only* tool calls concurrently (bounded by -/// [`MAX_PARALLEL_TOOLS`]) and return their outputs **in the original call -/// order**. -/// -/// Order preservation matters: OpenAI/Anthropic tool-calling contracts expect -/// tool-result messages to appear in the same order as the `tool_calls` -/// emitted in the assistant message. Without it, the model sees shuffled -/// results and loses track of which result belongs to which call. -/// -/// Each call still pushes its `TurnEvent::ToolResult` (so the TUI shows each -/// tool as it completes) but the returned `Vec` is ordered by the input index. -async fn execute_tool_calls_in_parallel( - tool_executor: &T, - turn_events: &Arc>>, - tool_calls: &[zesdex_domain::core::ToolCall], -) -> Vec { - let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_TOOLS)); - let executor_ref = tool_executor_ref(tool_executor); - - let futures = tool_calls.iter().map(|tc| { - let tc = tc.clone(); - let events = turn_events.clone(); - let sem = semaphore.clone(); - async move { - // Acquire a permit to bound concurrency across the batch. - let _permit = sem.acquire_owned().await; - execute_tool_call(executor_ref, &events, &tc).await - } - }); - - futures_util::future::join_all(futures).await -} - -// --------------------------------------------------------------------------- -// Helper: emit usage event from optional LLM response metadata. -// --------------------------------------------------------------------------- - -fn emit_usage(turn_events: &Arc>>, usage: Option<(u64, u64)>) { - if let Some((tokens_in, tokens_out)) = usage { - push_event( - turn_events, - TurnEvent::Usage { - tokens_in, - tokens_out, - }, - ); - } -} - -// --------------------------------------------------------------------------- -// Service implementation -// --------------------------------------------------------------------------- - -/// Service implementation for executing an agent turn asynchronously. -/// -/// The turn loop is adaptive and token-aware: -/// - No mandatory explore phase — the *agent* decides when to call the -/// `explore_codebase` tool (see the main prompt), so simple queries skip -/// exploration entirely. -/// - `max_tokens` / `temperature` adapt to the request length and phase. -/// - Repeated tool errors trigger a system recovery note and eventually -/// stop the loop instead of burning iterations. -/// - Tool outputs are truncated before entering context. -/// - Oversized histories are auto-compacted before the next LLM call. -pub struct AgentTurnServiceImpl { - provider: Arc

, - tool_executor: Arc, - tool_defs: Vec, -} - -impl AgentTurnServiceImpl { - pub fn new(provider: Arc

, tool_executor: Arc, tool_defs: Vec) -> Self { - Self { - provider, - tool_executor, - tool_defs, - } - } - - /// Execute a single LLM call with the current message list, handling - /// streaming events and error reporting. - async fn call_llm( - &self, - messages: &[ChatMessage], - abort: &Arc, - turn_events: &Arc>>, - max_tokens: u32, - temperature: f32, - ) -> Result<(ChatMessage, Option<(u64, u64)>), String> { - let on_event = make_stream_callback(abort, turn_events); - - self.provider - .chat_stream( - messages, - Some(self.tool_defs.clone()), - Some(max_tokens), - Some(temperature), - on_event, - ) - .await - .map_err(|e| format!("LLM error: {e}")) - } - - /// Auto-compact the history in place if it exceeds the threshold. - /// - /// Runs at most once per turn. Skips the synthetic system prompt that - /// this service inserts at index 0. - async fn auto_compact_if_needed(&self, messages: &mut Vec) { - if conversation_chars(messages) <= AUTO_COMPACT_CHARS { - return; - } - // Keep the system prompt (index 0) out of compaction. - let sys = messages[0].clone(); - let mut rest: Vec = messages.drain(1..).collect(); - let before = rest.len(); - if let Err(e) = super::compact_messages_with_ai(&mut rest, self.provider.as_ref()).await { - warn!("auto-compact failed (non-fatal): {e}"); - } - info!( - "auto-compacted history: {} messages -> {}", - before, - rest.len() - ); - let mut rebuilt = Vec::with_capacity(rest.len() + 1); - rebuilt.push(sys); - rebuilt.extend(rest); - *messages = rebuilt; - } -} - -impl super::AgentTurnService for AgentTurnServiceImpl { - async fn run_turn(&self, mut params: AgentTurnParams) -> anyhow::Result<()> { - info!( - "Starting async agent turn with {} messages (model: {})", - params.messages.len(), - params.model - ); - - // Insert system prompt at position 0 once and keep it there for the - // entire turn, avoiding per-iteration clones of the full message list. - // Auto-load repo conventions (AGENTS.md / CLAUDE.md / .cursorrules) - // from the first workspace root, like Claude Code does at startup. - let project_context = params - .workspace_roots - .first() - .map(|root| build_project_context(root)) - .unwrap_or_default(); - let system_prompt = main_agent_prompt_with_project_context(&project_context); - params - .messages - .insert(0, ChatMessage::system(system_prompt)); - let original_count = params.messages.len(); - - // Estimate request complexity from the last user message. - let request_len = params - .messages - .last() - .and_then(|m| m.content.as_deref()) - .map(str::len) - .unwrap_or(0); - - let mut errors = ErrorTracker::default(); - // Track whether the previous call produced tool calls — used to - // lower temperature once the agent starts producing a final answer. - let mut saw_tool_calls = false; - - for iteration in 0..MAX_TURN_ITERATIONS { - // ── Check abort flag ──────────────────────────────────────── - if params.abort.load(Ordering::SeqCst) { - params.abort.store(false, Ordering::SeqCst); - push_event( - ¶ms.turn_events, - TurnEvent::SystemNote { - kind: "info".into(), - message: "Turn aborted by user".into(), - }, - ); - break; - } - - if errors.should_stop() { - push_event( - ¶ms.turn_events, - TurnEvent::SystemNote { - kind: "warn".into(), - message: "Stopping: repeated tool errors without progress".into(), - }, - ); - break; - } - - debug!("agent turn iteration {iteration}"); - - // ── Auto-compact oversized history before the LLM call ───── - self.auto_compact_if_needed(&mut params.messages).await; - - // ── Adaptive generation parameters ───────────────────────── - let max_tokens = adaptive_max_tokens(request_len); - // Lower temperature while the agent is still choosing tools to - // keep tool selection deterministic; raise it for the final - // free-form answer. - let temperature = if saw_tool_calls { 0.2 } else { 0.7 }; - - // ── Stream start + call LLM ───────────────────────────────── - push_event(¶ms.turn_events, TurnEvent::StreamStart); - - let result = self - .call_llm( - ¶ms.messages, - ¶ms.abort, - ¶ms.turn_events, - max_tokens, - temperature, - ) - .await; - - match result { - Ok((assistant_msg, usage)) => { - let content = assistant_msg.content.clone().unwrap_or_default(); - let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default(); - - push_event( - ¶ms.turn_events, - TurnEvent::StreamDone(assistant_msg.clone()), - ); - - emit_usage(¶ms.turn_events, usage); - - // ── No tool calls → assistant is done ────────────── - if tool_calls.is_empty() { - params.messages.push(ChatMessage::assistant(Some(content))); - break; - } - - saw_tool_calls = true; - params.messages.push(assistant_msg); - - // ── Execute each tool call ────────────────────────── - // - // If the whole batch is made of *read-only* tools - // (read/grep/glob/…), run it concurrently with bounded - // parallelism — a big latency win for coding turns that - // emit several independent lookups in one message. Any - // single mutating tool forces the whole batch back to the - // safe sequential path so writes never race. - // - // Results are always collected in the original call order - // to honour the tool-calling contract. - let parallel = tool_calls.len() > 1 - && tool_calls - .iter() - .all(|tc| self.tool_executor.is_parallel_safe(&tc.function.name)); - let outputs: Vec = if parallel { - execute_tool_calls_in_parallel( - self.tool_executor.as_ref(), - ¶ms.turn_events, - &tool_calls, - ) - .await - } else { - let mut sequential = Vec::with_capacity(tool_calls.len()); - for tc in &tool_calls { - let out = execute_tool_call( - self.tool_executor.as_ref(), - ¶ms.turn_events, - tc, - ) - .await; - sequential.push(out); - } - sequential - }; - - for (tc, output) in tool_calls.iter().zip(outputs) { - if output.starts_with("Error:") { - errors.record(&tc.function.name, &output, &mut params.messages); - } - params - .messages - .push(ChatMessage::tool(tc.id.clone(), output)); - } - } - Err(e) => { - warn!("{e}"); - push_event(¶ms.turn_events, TurnEvent::Error(e)); - break; - } - } - } - - // Remove the synthetic sys_msg before shipping events to the TUI - // so the transcript shows only the actual user/assistant/tool exchange. - let compacted: Vec = params.messages.drain(original_count - 1..).collect(); - push_event(¶ms.turn_events, TurnEvent::Compacted(compacted)); - push_event(¶ms.turn_events, TurnEvent::Done); - params.in_flight.store(false, Ordering::SeqCst); - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Conversation compaction -// --------------------------------------------------------------------------- - -/// Maximum number of recent messages to preserve during compaction. -const COMPACT_KEEP_TAIL: usize = 6; - -/// Compacts conversation history using AI summarisation. -/// -/// Flow: if the message count exceeds `KEEP_TAIL + 2`, the oldest messages -/// are drained and summarised by the LLM. The summary is inserted as a -/// system message at the head of the remaining history. -pub async fn compact_messages_with_ai( - messages: &mut Vec, - provider: &P, -) -> anyhow::Result<()> { - if messages.len() <= COMPACT_KEEP_TAIL + 2 { - return Ok(()); // Not enough messages to compact - } - - let split_idx = messages.len() - COMPACT_KEEP_TAIL; - let evicted: Vec<_> = messages.drain(..split_idx).collect(); - - let mut summary_prompt = vec![ChatMessage::system(zesdex_domain::compaction_prompt())]; - summary_prompt.extend(evicted); - summary_prompt.push(ChatMessage::user( - "Please summarise our previous conversation above for context continuity.".to_string(), - )); - - match provider - .chat(&summary_prompt, None, Some(1024), Some(0.3)) - .await - { - Ok((summary_msg, _)) => { - let summary_text = summary_msg - .content - .unwrap_or_else(|| "Previous context summarised.".to_string()); - let summary_node = ChatMessage::system(format!( - "[AI Summary of Previous Conversation]\n{}", - summary_text.trim() - )); - messages.insert(0, summary_node); - Ok(()) - } - Err(e) => { - warn!("AI summarisation failed during compact, falling back to simple notice: {e}"); - messages.insert( - 0, - ChatMessage::system( - "[Earlier conversation messages compacted to save context window]".to_string(), - ), - ); - Ok(()) - } - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn truncate_short_output_is_unchanged() { - let out = "short".to_string(); - assert_eq!(truncate_tool_output(out.clone()), out); - } - - #[test] - fn truncate_long_output_preserves_head_and_marks_cut() { - let long = "x".repeat(TOOL_OUTPUT_MAX_CHARS + 500); - let truncated = truncate_tool_output(long.clone()); - assert!(truncated.len() < long.len()); - assert!(truncated.contains("...[truncated")); - assert!(truncated.starts_with("xxx")); - } - - #[test] - fn adaptive_max_tokens_scales_with_request_len() { - assert_eq!(adaptive_max_tokens(10), 800); - assert_eq!(adaptive_max_tokens(200), 1600); - assert_eq!(adaptive_max_tokens(5000), 4096); - } - - #[test] - fn error_tracker_injects_recovery_note_after_repeats() { - let mut tracker = ErrorTracker::default(); - let mut messages: Vec = Vec::new(); - tracker.record("read", "Error: File not found", &mut messages); - tracker.record("read", "Error: File not found", &mut messages); - assert!(!tracker.should_stop()); - // Third consecutive failure → recovery note injected. - tracker.record("read", "Error: File not found", &mut messages); - assert!(messages.iter().any(|m| m - .content - .as_deref() - .is_some_and(|c| c.contains("[System note]")))); - } - - #[test] - fn error_tracker_stops_after_too_many_errors() { - let mut tracker = ErrorTracker::default(); - let mut messages: Vec = Vec::new(); - for i in 0..MAX_TOTAL_TOOL_ERRORS { - tracker.record("bash", &format!("Error: boom {i}"), &mut messages); - } - assert!(tracker.should_stop()); - } - - #[test] - fn conversation_chars_sums_content_only() { - let messages = vec![ - ChatMessage::system("sys".to_string()), - ChatMessage::user("hello world".to_string()), - ChatMessage::tool("id".to_string(), "output".to_string()), - ]; - assert_eq!(conversation_chars(&messages), 3 + 11 + 6); - } - - /// A fake executor that reports parallel-safety for read-only tools and - /// whose `execute` sleeps on the first call to prove the batch runs - /// concurrently (a sequential loop would pay the sleep per call). - struct FakeExecutor; - - impl ToolExecutor for FakeExecutor { - async fn execute(&self, name: &str, _args: &serde_json::Value) -> anyhow::Result { - if name == "read" { - // 30ms sleep on every read; a parallel batch of 3 would - // finish in ~30ms instead of ~90ms sequentially. - tokio::time::sleep(std::time::Duration::from_millis(30)).await; - } - Ok(format!("out:{name}")) - } - - fn is_parallel_safe(&self, name: &str) -> bool { - matches!(name, "read" | "grep") - } - } - - fn tc(name: &str, id: usize) -> zesdex_domain::core::ToolCall { - zesdex_domain::core::ToolCall { - id: format!("call_{id}"), - type_: "function".to_string(), - function: zesdex_domain::core::ToolFunction { - name: name.to_string(), - arguments: serde_json::Value::String(String::new()), - }, - } - } - - #[test] - fn parallel_batch_runs_concurrently_and_preserves_order() { - let executor = FakeExecutor; - let events = Arc::new(Mutex::new(VecDeque::new())); - let calls = vec![tc("read", 1), tc("grep", 2), tc("read", 3)]; - - // All three are parallel-safe. - assert!(calls - .iter() - .all(|c| executor.is_parallel_safe(&c.function.name))); - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_time() - .build() - .unwrap(); - let started = std::time::Instant::now(); - let outputs = rt.block_on(execute_tool_calls_in_parallel(&executor, &events, &calls)); - let elapsed = started.elapsed(); - - // Results are in *original* call order (read, grep, read). - assert_eq!( - outputs, - vec![ - "out:read".to_string(), - "out:grep".to_string(), - "out:read".to_string() - ] - ); - // Two reads sleep 30ms each; sequential would take ~60ms+ for the - // two reads, parallel keeps the whole batch under 60ms. - assert!( - elapsed < std::time::Duration::from_millis(60), - "batch took {elapsed:?}, expected parallel execution" - ); - assert!(elapsed >= std::time::Duration::from_millis(25)); - } - - #[test] - fn mutating_batch_falls_back_to_sequential_path() { - // A batch containing a mutating tool is not eligible for the parallel - // path, so the main loop keeps results ordered and side-effects safe. - let executor = FakeExecutor; - let calls = [tc("read", 1), tc("edit", 2)]; - assert!(!calls - .iter() - .all(|c| executor.is_parallel_safe(&c.function.name))); - } -} diff --git a/apps/application/src/auth/mod.rs b/apps/application/src/auth/mod.rs deleted file mode 100644 index 708c061..0000000 --- a/apps/application/src/auth/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Auth use-case implementations. -//! -//! Contains concrete service types that implement the domain's -//! authentication and session management traits by coordinating -//! injected repository and port dependencies. -//! -//! # Use Cases -//! -//! - [`oauth_service`] — `OAuthUseCase`: OAuth 2.0 authorization-code + PKCE flow -//! - [`session_service`] — `SessionServiceImpl`: session CRUD lifecycle - -pub mod oauth_service; -pub mod session_service; - -pub use oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger}; -pub use session_service::SessionServiceImpl; diff --git a/apps/application/src/auth/oauth_service.rs b/apps/application/src/auth/oauth_service.rs deleted file mode 100644 index 6e100f5..0000000 --- a/apps/application/src/auth/oauth_service.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! OAuth 2.0 authorization-code + PKCE flow use-case. -//! -//! `OAuthUseCase` orchestrates the standard PKCE-enhanced OAuth flow: -//! -//! 1. **`start_flow`** — generates a cryptographic PKCE code verifier, -//! derives its S256 challenge, creates a CSRF state token, persists -//! the verifier + state via `OAuthFlowStore`, and builds an -//! authorization URL with all required parameters. -//! 2. **`complete_flow`** — validates the returned `state` against the -//! stored value (CSRF check), reads the stored verifier, delegates -//! the token-code exchange to an injected `TokenExchanger`, and -//! persists the resulting `OAuthToken` via `OAuthRepository`. -//! 3. **`get_token`** — loads the stored OAuth token (if any). -//! -//! # Portability -//! -//! The service is generic over three injected dependencies: -//! - `R: OAuthRepository` — token persistence -//! - `S: OAuthFlowStore` — ephemeral flow state (verifier + CSRF state) -//! - `E: TokenExchanger` — the HTTP token-endpoint exchange -//! -//! This keeps all I/O and protocol-level concerns abstracted behind -//! port traits; the service itself contains only orchestration logic. - -use std::path::PathBuf; -use tracing; - -use zesdex_domain::auth::{OAuthConfig, OAuthRepository, OAuthToken, ServiceError}; - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; -use sha2::{Digest, Sha256}; - -// --------------------------------------------------------------------------- -// Port traits (defined here because they are specific to this use-case) -// --------------------------------------------------------------------------- - -/// Persistence contract for ephemeral OAuth flow state. -/// -/// Between `start_flow` and `complete_flow` the verifier and CSRF state -/// must survive across process boundaries (the user opens a browser, the -/// provider redirects back to a loopback listener on the next invocation). -/// -/// Implementors store key-value pairs to disk or another durable medium -/// and clear them after a successful (or failed) flow completion. -pub trait OAuthFlowStore: Send + Sync { - /// Persist the PKCE code verifier and CSRF state token. - fn save_flow_state(&self, verifier: &str, state: &str) -> Result<(), ServiceError>; - - /// Load the stored PKCE code verifier. - fn load_verifier(&self) -> Result; - - /// Load the stored CSRF state token. - fn load_state(&self) -> Result; - - /// Clear stored flow state (verifier + state). - fn clear(&self) -> Result<(), ServiceError>; -} - -/// Abstraction for exchanging an authorization code for tokens. -/// -/// Implementors handle the HTTP POST to the provider's token endpoint -/// with the appropriate form-encoded parameters, parse the JSON -/// response, and return the extracted `OAuthToken`. -pub trait TokenExchanger: Send + Sync { - /// Exchange an authorization code for an access token. - /// - /// ## Parameters - /// - `token_url` — the provider's token endpoint URL - /// - `client_id` — OAuth client identifier - /// - `client_secret` — optional client secret - /// - `redirect_uri` — must match the URI used in `start_flow` - /// - `code` — the authorization code from the provider's redirect - /// - `code_verifier` — the PKCE verifier from `start_flow` - fn exchange_code( - &self, - token_url: &str, - client_id: &str, - client_secret: Option<&str>, - redirect_uri: &str, - code: &str, - code_verifier: &str, - ) -> Result; -} - -// --------------------------------------------------------------------------- -// PKCE helpers -// --------------------------------------------------------------------------- - -/// Generate a PKCE code-verifier and its S256 code-challenge. -/// -/// Uses 32 cryptographically random bytes, base64url-encoded (no padding) -/// for the verifier, then SHA-256 hashes the verifier and base64url-encodes -/// the digest for the challenge. This satisfies the PKCE `S256` method -/// which requires a minimum verifier length of 43 characters. -fn generate_pkce_pair() -> (String, String) { - // 32 random bytes → 43 base64url chars (well above the 43-char PKCE - // minimum). - let mut bytes = [0u8; 32]; - bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); - bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); - - let verifier = URL_SAFE_NO_PAD.encode(bytes); - let challenge = { - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(hasher.finalize()) - }; - (verifier, challenge) -} - -/// Generate a random CSRF state token (UUID-based, 36 chars). -fn generate_state_token() -> String { - uuid::Uuid::new_v4().to_string() -} - -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -/// Concrete OAuth flow use-case. -/// -/// Generic over three dependencies: -/// - `R` — token persistence (`OAuthRepository`) -/// - `S` — flow-state persistence (`OAuthFlowStore`) -/// - `E` — token-endpoint HTTP exchange (`TokenExchanger`) -pub struct OAuthUseCase { - /// Repository for persisting / loading OAuth tokens. - pub token_repo: R, - /// Store for ephemeral flow state (verifier + CSRF state). - pub flow_store: S, - /// Token-endpoint HTTP exchanger. - pub token_exchanger: E, - /// File path for the token JSON file. - pub token_path: PathBuf, -} - -impl OAuthUseCase { - /// Create a new OAuth use-case. - pub fn new(token_repo: R, flow_store: S, token_exchanger: E, token_path: PathBuf) -> Self { - OAuthUseCase { - token_repo, - flow_store, - token_exchanger, - token_path, - } - } -} - -impl zesdex_domain::auth::OAuthService - for OAuthUseCase -{ - fn start_flow( - &self, - config: &OAuthConfig, - redirect_uri: &str, - ) -> Result<(String, String), ServiceError> { - if config.auth_url.is_empty() { - return Err(ServiceError::InvalidConfig( - "OAuth auth_url is empty".to_string(), - )); - } - - let (verifier, challenge) = generate_pkce_pair(); - let state = generate_state_token(); - - // Persist verifier + state so `complete_flow` can retrieve them. - self.flow_store.save_flow_state(&verifier, &state)?; - - tracing::debug!( - auth_url = %config.auth_url, - redirect_uri = %redirect_uri, - state_len = state.len(), - "starting OAuth flow", - ); - - let mut url = url::Url::parse(&config.auth_url).map_err(|e| { - ServiceError::InvalidConfig(format!("invalid auth_url '{}': {e}", config.auth_url)) - })?; - - url.query_pairs_mut() - .append_pair("response_type", "code") - .append_pair("client_id", &config.client_id) - .append_pair("redirect_uri", redirect_uri) - .append_pair("scope", &config.scopes.join(" ")) - .append_pair("state", &state) - .append_pair("code_challenge_method", "S256") - .append_pair("code_challenge", &challenge); - - Ok((url.to_string(), state)) - } - - fn complete_flow( - &self, - config: &OAuthConfig, - redirect_uri: &str, - code: &str, - state: &str, - ) -> Result { - // CSRF check: validate the returned state against the stored value. - let expected_state = self.flow_store.load_state()?; - if expected_state != state { - return Err(ServiceError::StateMismatch); - } - - // Read the PKCE verifier that was saved in `start_flow`. - let verifier = self.flow_store.load_verifier()?; - - tracing::debug!( - token_url = %config.token_url, - code_len = code.len(), - "completing OAuth flow — exchanging code for token", - ); - - // Delegate the HTTP token exchange to the injected exchanger. - let token = self.token_exchanger.exchange_code( - &config.token_url, - &config.client_id, - config.client_secret.as_deref(), - redirect_uri, - code, - &verifier, - )?; - - // Persist the token and clean up flow state. - self.token_repo.save_token(&self.token_path, &token)?; - let _ = self.flow_store.clear(); - - Ok(token) - } - - fn get_token(&self) -> Result, ServiceError> { - self.token_repo - .load_token(&self.token_path) - .map_err(ServiceError::Repository) - } -} diff --git a/apps/application/src/auth/session_service.rs b/apps/application/src/auth/session_service.rs deleted file mode 100644 index 0a4ab01..0000000 --- a/apps/application/src/auth/session_service.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Session management use-case. -//! -//! `SessionServiceImpl` implements [`SessionService`] from the domain -//! layer by delegating CRUD operations to injected repository traits. -//! -//! # Flow -//! -//! - **`create_session`** — generates a UUID v4 id, creates a `Session` -//! entity with the given title, persists via `SessionRepository`. -//! - **`list_all`** — delegates to `SessionRepository::list_sessions`. -//! - **`archive_session`** — loads session, sets `archived = true`, -//! persists the updated entity. -//! -//! # Generics -//! -//! - `R: SessionRepository` — session CRUD persistence -//! - `L: SessionLockRepository` — session lock acquire/release - -use std::path::PathBuf; -use tracing; -use uuid::Uuid; - -use zesdex_domain::auth::{ - ServiceError, Session, SessionId, SessionLockRepository, SessionRepository, -}; - -/// Concrete session service backed by injected repository implementations. -pub struct SessionServiceImpl { - /// Repository for session CRUD operations. - pub session_repo: R, - /// Repository for session lock acquire/release. - pub lock_repo: L, - /// Base data directory passed to repository methods. - pub base_dir: PathBuf, -} - -impl SessionServiceImpl { - /// Create a new session service with the given repositories and base - /// data directory. - pub fn new(session_repo: R, lock_repo: L, base_dir: PathBuf) -> Self { - SessionServiceImpl { - session_repo, - lock_repo, - base_dir, - } - } -} - -impl zesdex_domain::auth::SessionService - for SessionServiceImpl -{ - fn create_session(&self, title: &str) -> Result { - let id = SessionId::new(&Uuid::new_v4().to_string()).map_err(ServiceError::Other)?; - let title_owned = if title.is_empty() { - "New Session".to_string() - } else { - title.to_string() - }; - let session = Session::new(id.into_string(), title_owned); - tracing::debug!(session_id = %session.id, title = %session.title, "creating new session"); - self.session_repo.save_session(&self.base_dir, &session)?; - Ok(session) - } - - fn list_all(&self) -> Result, ServiceError> { - tracing::debug!("listing all sessions"); - self.session_repo - .list_sessions(&self.base_dir) - .map_err(ServiceError::Repository) - } - - fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> { - tracing::debug!(session_id = %id, "archiving session"); - let mut session = self.session_repo.load_session(&self.base_dir, &id)?; - session.archived = true; - let millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX); - self.session_repo.save_session(&self.base_dir, &session)?; - Ok(()) - } -} diff --git a/apps/application/src/cms/conversation_service.rs b/apps/application/src/cms/conversation_service.rs deleted file mode 100644 index 1896a72..0000000 --- a/apps/application/src/cms/conversation_service.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Conversation use-case implementation. -//! -//! `ConversationServiceImpl` implements [`ConversationService`] from the -//! domain layer. It is generic over `R: ConversationRepository`, delegating -//! all persistence to that adapter. -//! -//! # Flow -//! -//! Each method computes the session directory from the session ID, then -//! delegates the actual I/O to the injected `repo`. Error context is -//! added at this layer to identify which session caused the failure. - -use std::path::PathBuf; -use tracing; - -use zesdex_domain::cms::{Conversation, ConversationRepository, ServiceError}; -use zesdex_domain::core::ChatMessage; - -/// Service implementation for conversation CRUD operations. -/// -/// Generic over `R: ConversationRepository` so the persistence layer -/// can be swapped without changing business logic. -pub struct ConversationServiceImpl { - pub repo: R, - /// Base directory containing session subdirectories. - pub sessions_dir: PathBuf, -} - -impl ConversationServiceImpl { - /// Create a new service with the given repository and sessions directory. - pub fn new(repo: R, sessions_dir: impl Into) -> Self { - tracing::debug!("creating ConversationServiceImpl"); - Self { - repo, - sessions_dir: sessions_dir.into(), - } - } - - /// Compute the session directory for a given session id. - fn session_dir(&self, session_id: &str) -> PathBuf { - self.sessions_dir.join(session_id) - } -} - -impl zesdex_domain::cms::ConversationService - for ConversationServiceImpl -{ - fn load_conversation(&self, session_id: &str) -> Result { - tracing::debug!("loading conversation for session {session_id}"); - let dir = self.session_dir(session_id); - self.repo.load(&dir).map_err(ServiceError::Repository) - } - - fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> { - tracing::debug!("saving conversation for session {}", conv.session_id); - let dir = self.session_dir(&conv.session_id); - self.repo.save(&dir, conv)?; - Ok(()) - } - - fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError> { - tracing::debug!("adding message to session {}", conv.session_id); - conv.push(msg); - let dir = self.session_dir(&conv.session_id); - self.repo.save(&dir, conv)?; - Ok(()) - } -} diff --git a/apps/application/src/cms/memory_service.rs b/apps/application/src/cms/memory_service.rs deleted file mode 100644 index d237af6..0000000 --- a/apps/application/src/cms/memory_service.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Memory use-case implementation. -//! -//! `MemoryServiceImpl` implements [`MemoryService`] from the domain -//! layer. It is generic over `R: MemoryRepository`, delegating all -//! persistence to that adapter. -//! -//! # Flow -//! -//! Each method delegates to the injected `repo` with the configured -//! `memory_dir`. Error context is added at this layer to identify which -//! memory operation failed. - -use std::path::PathBuf; -use tracing; - -use zesdex_domain::cms::{Memory, MemoryRepository, ServiceError}; - -/// Service implementation for memory CRUD operations. -/// -/// Generic over `R: MemoryRepository` so the persistence layer can be -/// swapped without changing business logic. -pub struct MemoryServiceImpl { - pub repo: R, - /// Base directory for memory storage files. - pub memory_dir: PathBuf, -} - -impl MemoryServiceImpl { - /// Create a new service with the given repository and memory directory. - pub fn new(repo: R, memory_dir: impl Into) -> Self { - tracing::debug!("creating MemoryServiceImpl"); - Self { - repo, - memory_dir: memory_dir.into(), - } - } -} - -impl zesdex_domain::cms::MemoryService for MemoryServiceImpl { - fn list_memories(&self) -> Result, ServiceError> { - tracing::debug!("listing memories from {:?}", self.memory_dir); - self.repo - .list(&self.memory_dir) - .map_err(ServiceError::Repository) - } - - fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> { - tracing::debug!("saving memory '{}'", memory.name); - self.repo.save(&self.memory_dir, memory)?; - Ok(()) - } - - fn delete_memory(&self, name: &str) -> Result<(), ServiceError> { - tracing::debug!("deleting memory '{name}'"); - self.repo - .delete(&self.memory_dir, name) - .map_err(ServiceError::Repository) - } -} diff --git a/apps/application/src/cms/mod.rs b/apps/application/src/cms/mod.rs deleted file mode 100644 index 1d8e1c1..0000000 --- a/apps/application/src/cms/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! CMS use-case implementations. -//! -//! Contains concrete service types that implement the domain's CMS -//! service traits by coordinating injected repository dependencies. -//! -//! # Use Cases -//! -//! - [`conversation_service`] — `ConversationServiceImpl`: conversation CRUD -//! - [`memory_service`] — `MemoryServiceImpl`: long-term memory management -//! - [`settings_service`] — `SettingsServiceImpl`: settings & app-config management - -pub mod conversation_service; -pub mod memory_service; -pub mod settings_service; - -pub use conversation_service::ConversationServiceImpl; -pub use memory_service::MemoryServiceImpl; -pub use settings_service::SettingsServiceImpl; diff --git a/apps/application/src/cms/settings_service.rs b/apps/application/src/cms/settings_service.rs deleted file mode 100644 index 1144dbd..0000000 --- a/apps/application/src/cms/settings_service.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Settings and app-config use-case implementation. -//! -//! `SettingsServiceImpl` implements [`SettingsService`] from the domain -//! layer. It is generic over `S: SettingsRepository` and `C: AppConfigRepository`, -//! delegating persistence to those adapters. -//! -//! # Flow -//! -//! Each method delegates to the appropriate injected repository with the -//! configured `base_dir`. The `update_provider` method coordinates between -//! both repositories: load app config → mutate provider map → save app config. - -use std::path::PathBuf; -use tracing; - -use zesdex_domain::cms::{ - AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings, SettingsRepository, -}; - -/// Service implementation for settings and app-config operations. -/// -/// Generic over `S: SettingsRepository` and `C: AppConfigRepository` so -/// the persistence layer can be swapped without changing business logic. -pub struct SettingsServiceImpl { - pub settings_repo: S, - pub app_config_repo: C, - pub base_dir: PathBuf, -} - -impl SettingsServiceImpl { - /// Create a new service with the given repositories and base directory. - pub fn new(settings_repo: S, app_config_repo: C, base_dir: impl Into) -> Self { - tracing::debug!("creating SettingsServiceImpl"); - Self { - settings_repo, - app_config_repo, - base_dir: base_dir.into(), - } - } -} - -impl zesdex_domain::cms::SettingsService - for SettingsServiceImpl -{ - fn load_settings(&self) -> Result { - tracing::debug!("loading settings"); - self.settings_repo - .load(&self.base_dir) - .map_err(ServiceError::Repository) - } - - fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> { - tracing::debug!("saving settings"); - self.settings_repo.save(&self.base_dir, settings)?; - Ok(()) - } - - fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<(), ServiceError> { - tracing::debug!("updating provider '{name}'"); - let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?; - app_config - .providers - .insert(name.to_string(), config.clone()); - self.app_config_repo.save(&self.base_dir, &app_config)?; - Ok(()) - } -} diff --git a/apps/application/src/lib.rs b/apps/application/src/lib.rs deleted file mode 100644 index 09db573..0000000 --- a/apps/application/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! # Zesdex Application Layer -//! -//! Defines port traits (interfaces) and use-case implementations for the -//! Zesdex application. This crate depends **only** on the domain crate; -//! it has no knowledge of infrastructure or interface adapters. -//! -//! ## Architecture -//! -//! ```text -//! apps/application/src/ -//! ├── lib.rs — crate root, re-exports -//! ├── ports/ — Port traits (interfaces to external services) -//! │ ├── provider.rs -- ProviderService (LLM chat completion) -//! │ ├── password.rs -- PasswordService (hash / verify) -//! │ ├── token.rs -- TokenService (JWT create / verify) -//! │ └── authentication.rs -- AuthService (combined auth) -//! ├── auth/ — Auth use-cases -//! │ ├── oauth_service.rs -- OAuth 2.0 PKCE flow -//! │ └── session_service.rs -- Session CRUD lifecycle -//! └── cms/ — CMS use-cases -//! ├── conversation_service.rs -- Conversation CRUD -//! ├── memory_service.rs -- Long-term memory management -//! └── settings_service.rs -- Settings & app-config management -//! ``` -//! -//! ## Key Design Principle -//! -//! Application services are generic over their repository/port dependencies. -//! Concrete implementations are injected at the composition root, keeping -//! the use-case logic independent of any specific persistence or infrastructure -//! technology. - -pub mod agent; -pub mod auth; -pub mod cms; -pub mod ports; - -// Re-export port traits for ergonomic access. -pub use ports::*; - -// Re-export auth use-cases. -pub use auth::{ - oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger}, - session_service::SessionServiceImpl, -}; - -// Re-export CMS use-cases. -pub use cms::{ - conversation_service::ConversationServiceImpl, memory_service::MemoryServiceImpl, - settings_service::SettingsServiceImpl, -}; - -pub use agent::{ - turn_service::{compact_messages_with_ai, AgentTurnServiceImpl}, - AgentTurnService, ToolExecutor, -}; diff --git a/apps/application/src/ports/authentication.rs b/apps/application/src/ports/authentication.rs deleted file mode 100644 index e628236..0000000 --- a/apps/application/src/ports/authentication.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! AuthService port — combined authentication operations. -//! -//! Defines a high-level authentication trait that composes password -//! verification and token generation into a single use-case boundary. -//! Implementations delegate to the injected `PasswordService` and -//! `TokenService` adapters. - -use anyhow::Result; -use std::future::Future; - -/// High-level authentication service combining password verification -/// and token issuance (login flow). -/// -/// # Flow -/// -/// 1. **`authenticate`** — verify a subject's password against a stored hash. -/// 2. **`issue_tokens`** — generate an access + refresh token pair for a subject. -/// -/// Implementations are generic over `PasswordService` and `TokenService` -/// port traits. -pub trait AuthService: Send + Sync { - /// Authenticate a user by verifying a password against a stored hash. - /// - /// Returns `true` if the password matches, `false` otherwise. - fn authenticate(&self, password: &str, hash: &str) - -> impl Future> + Send; - - /// Issue a new access + refresh token pair for the given subject. - /// - /// Returns `(access_token, refresh_token)`. - fn issue_tokens(&self, sub: &str) -> Result<(String, String)>; -} diff --git a/apps/application/src/ports/mod.rs b/apps/application/src/ports/mod.rs deleted file mode 100644 index cb04581..0000000 --- a/apps/application/src/ports/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Port traits — interfaces for external / infrastructure services. -//! -//! These traits define the boundaries between the application layer and -//! the outside world. Infrastructure adapters implement these traits; -//! the application layer depends only on the trait definitions. -//! -//! # Ports -//! -//! - [`provider`] — `ProviderService`: LLM chat completion (streaming + non-streaming) -//! - [`password`] — `PasswordService`: password hashing and verification -//! - [`token`] — `TokenService`: JWT access/refresh token generation and verification -//! - [`authentication`] — `AuthService`: combined authentication operations - -pub mod authentication; -pub mod password; -pub mod provider; -pub mod token; - -pub use authentication::AuthService; -pub use password::PasswordService; -pub use provider::ProviderService; -pub use token::TokenService; diff --git a/apps/application/src/ports/password.rs b/apps/application/src/ports/password.rs deleted file mode 100644 index fd94a49..0000000 --- a/apps/application/src/ports/password.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! PasswordService port — password hashing and verification abstraction. -//! -//! Defines the trait that password-hashing adapters (argon2, bcrypt, etc.) -//! implement. The application layer depends only on this trait, never on -//! a concrete hashing library. - -use anyhow::Result; -use std::future::Future; - -/// Abstraction for password hashing and verification. -/// -/// Implementors handle the actual hashing algorithm (argon2, bcrypt, etc.) -/// and parameter selection. The trait is `Send + Sync` for use in async -/// service layers. -pub trait PasswordService: Send + Sync { - /// Hash a plaintext password and return the encoded hash string - /// (suitable for storage in a credential store). - fn hash(&self, password: &str) -> impl Future> + Send; - - /// Verify a plaintext password against a previously-hashed string. - /// - /// Returns `true` if the password matches the hash, `false` otherwise. - fn verify(&self, password: &str, hash: &str) -> impl Future> + Send; -} diff --git a/apps/application/src/ports/provider.rs b/apps/application/src/ports/provider.rs deleted file mode 100644 index c2de7b8..0000000 --- a/apps/application/src/ports/provider.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! ProviderService port — LLM chat completion provider abstraction. -//! -//! Defines the trait that HTTP-based provider clients (OpenAI, Anthropic, -//! etc.) implement. Supports both non-streaming and SSE-streaming chat -//! completion requests. -//! -//! # Flow -//! -//! 1. Caller builds a message list and optional tool definitions. -//! 2. `chat` sends a non-streaming request and returns the full response. -//! 3. `chat_stream` sends a streaming request and invokes `on_event` for -//! each parsed `StreamEvent` as it arrives, then returns the assembled -//! message and usage. - -use anyhow::Result; -use std::future::Future; - -use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; - -/// Abstraction for an LLM provider chat-completion service. -/// -/// Both methods accept a message list, optional tool definitions, and -/// generation parameters. Implementors handle authentication, HTTP -/// transport, retry logic, and response parsing internally. -/// -/// # Send + Sync -/// -/// This trait is `Send + Sync` so it can be shared across async tasks -/// and injected into service structs that require thread safety. -pub trait ProviderService: Send + Sync { - /// Send a non-streaming chat completion request. - /// - /// Returns the assistant's `ChatMessage` and optional token usage - /// `(prompt_tokens, completion_tokens)`. - fn chat( - &self, - messages: &[ChatMessage], - tools: Option>, - max_tokens: Option, - temperature: Option, - ) -> impl Future)>> + Send; - - /// Send a streaming chat completion request. - /// - /// `on_event` is called for every parsed SSE event and returns `false` - /// to signal abort (caller cancellation). Returns the fully assembled - /// assistant message and optional usage once the stream completes. - fn chat_stream( - &self, - messages: &[ChatMessage], - tools: Option>, - max_tokens: Option, - temperature: Option, - on_event: Box bool + Send>, - ) -> impl Future)>> + Send; -} diff --git a/apps/application/src/ports/token.rs b/apps/application/src/ports/token.rs deleted file mode 100644 index e898610..0000000 --- a/apps/application/src/ports/token.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! TokenService port — JWT access and refresh token abstraction. -//! -//! Defines the trait that JWT adapter implementations provide. Covers -//! token generation (pair of access + refresh tokens) and access token -//! verification (returns the subject claim). - -use anyhow::Result; - -/// Abstraction for JWT-based token generation and verification. -/// -/// Implementors handle signing key management, token serialisation, -/// and expiry validation. The trait is `Send + Sync` for use across -/// thread boundaries. -pub trait TokenService: Send + Sync { - /// Generate an access + refresh token pair for the given subject - /// identifier. - /// - /// Returns `(access_token, refresh_token)`. - fn generate_tokens(&self, sub: &str) -> Result<(String, String)>; - - /// Verify an access token and return the embedded subject claim. - /// - /// Returns `Err` if the token is expired, malformed, or has an - /// invalid signature. - fn verify_access_token(&self, token: &str) -> Result; - - /// Verify a refresh token and return the embedded subject claim. - /// - /// Returns `Err` if the token is expired, malformed, or has an - /// invalid signature. - fn verify_refresh_token(&self, token: &str) -> Result; -} diff --git a/apps/bootstrap/src/lib.rs b/apps/bootstrap/src/lib.rs deleted file mode 100644 index 8a148de..0000000 --- a/apps/bootstrap/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -//! Bootstrap library — shared utilities for the bootstrap binary. -//! The main entry point is in `main.rs`. diff --git a/apps/bootstrap/src/main.rs b/apps/bootstrap/src/main.rs deleted file mode 100644 index a23ef3d..0000000 --- a/apps/bootstrap/src/main.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Bootstrap binary — seeds initial system data idempotently. -//! -//! Creates default permissions, roles, and admin user if they don't -//! already exist. Run once after first deployment. -//! -//! Usage: cargo run --bin bootstrap - -fn main() -> anyhow::Result<()> { - println!("Zesdex Bootstrap — seeding initial data..."); - - let store = zesdex_domain::core::Store::new(); - store.ensure_dirs()?; - - // Seed default settings if not present - let settings_path = store.base_dir.join("settings.json"); - if !settings_path.exists() { - let settings = zesdex_domain::cms::Settings::default(); - let content = serde_json::to_string_pretty(&settings)?; - let tmp = store.base_dir.join("settings.json.tmp"); - std::fs::write(&tmp, &content)?; - std::fs::File::open(&tmp)?.sync_all()?; - std::fs::rename(&tmp, &settings_path)?; - println!(" ✓ Default settings created"); - } else { - println!(" · Settings already exist, skipping"); - } - - // Seed default app config if not present - let config_path = store.base_dir.join("app_config.json"); - if !config_path.exists() { - let config = zesdex_domain::cms::AppConfig::default(); - let content = serde_json::to_string_pretty(&config)?; - let tmp = store.base_dir.join("app_config.json.tmp"); - std::fs::write(&tmp, &content)?; - std::fs::File::open(&tmp)?.sync_all()?; - std::fs::rename(&tmp, &config_path)?; - println!(" ✓ Default app_config created"); - } else { - println!(" · App config already exists, skipping"); - } - - println!("Bootstrap complete."); - Ok(()) -} diff --git a/apps/domain/src/agent/defaults.rs b/apps/domain/src/agent/defaults.rs deleted file mode 100644 index b7475df..0000000 --- a/apps/domain/src/agent/defaults.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Shared default constants used across the application. -//! -//! Centralising these values eliminates the hardcoded-string duplication -//! that existed when every call site provided its own inline fallback. -//! Consumers should reference these constants rather than repeating -//! the string literals. - -/// Default LLM provider API base URL. -pub const DEFAULT_API_BASE: &str = "https://opencode.ai/zen/v1"; - -/// Default LLM model identifier. -pub const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; - -/// Fallback JWT secret used only when `JWT_SECRET` env var is unset. -/// In production this MUST be configured via environment variable. -pub const FALLBACK_JWT_SECRET: &str = "dev-secret"; - -/// Default context window size (128k tokens). -pub const DEFAULT_CONTEXT_WINDOW: usize = 256_000; - -/// Maximum tool-call iterations per agent turn. -pub const MAX_TOOL_ITERATIONS: u32 = 50; - -/// Maximum subagent tool-call iterations. -pub const MAX_SUBAGENT_ITERATIONS: u32 = 25; - -/// Default LLM request max tokens. -pub const DEFAULT_MAX_TOKENS: u32 = 4096; - -/// Default temperature for the main agent. -pub const DEFAULT_TEMPERATURE: f64 = 0.7; - -/// Default temperature for compaction / summary calls. -pub const DEFAULT_COMPACT_TEMPERATURE: f64 = 0.3; diff --git a/apps/domain/src/agent/mod.rs b/apps/domain/src/agent/mod.rs deleted file mode 100644 index 4738ec3..0000000 --- a/apps/domain/src/agent/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! Domain types for agent lifecycle: turn events, session runtime, progress -//! reporting, prompts, and the agent-turn parameter bundle. - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -use crate::core::{ChatMessage, ToolCallResult, UsageStats}; - -pub mod defaults; -pub mod progress; -pub mod prompt; - -/// Which kind of caller (main agent vs. subagent vs. reviewer) is -/// invoking a tool, used to scope permissions and tag log/output paths. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub enum Origin { - /// The main agent turn loop. - Main, - /// A spawned subagent (test-gen, arch-review, security-review, etc.). - SubAgent, - /// The auto-inline review step after an edit. - Reviewer, -} - -impl Origin { - /// Short string tag for this origin, used in filenames and logs. - pub fn tag(self) -> String { - match self { - Origin::Main => "main", - Origin::SubAgent => "subagent", - Origin::Reviewer => "reviewer", - } - .to_string() - } -} - -/// Severity/category of a toast notification, used to pick its color. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ToastKind { - Info, - Success, - Warning, - Error, - Lesson, -} - -/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Toast { - pub kind: ToastKind, - pub message: String, - pub created_at: i64, - pub lifetime_ms: u64, -} - -impl Toast { - /// Create a toast with a default 5-second lifetime, stamped with now. - pub fn new(kind: ToastKind, message: String) -> Self { - Toast { - kind, - message, - created_at: chrono::Utc::now().timestamp_millis(), - lifetime_ms: 5000, - } - } - - /// Whether this toast's lifetime has elapsed as of `now_ms`. - pub fn expired(&self, now_ms: i64) -> bool { - let lifetime = self.lifetime_ms as i64; - now_ms - self.created_at > lifetime - } -} - -/// Agent status for workflow engine progress tracking. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum AgentStatus { - Pending, - Running, - Completed, - Failed(String), - Cancelled, -} - -impl std::fmt::Display for AgentStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AgentStatus::Pending => write!(f, "pending"), - AgentStatus::Running => write!(f, "running"), - AgentStatus::Completed => write!(f, "completed"), - AgentStatus::Failed(msg) => write!(f, "failed: {msg}"), - AgentStatus::Cancelled => write!(f, "cancelled"), - } - } -} - -/// Events emitted onto the turn-event queue while an agent turn runs, -/// consumed by the event loop to update state and drive re-renders. -#[derive(Debug, Clone)] -pub enum TurnEvent { - AssistantMessage(ChatMessage), - ToolResult { - tool_call_id: String, - tool_name: String, - output: String, - is_error: bool, - path: Option, - }, - SystemNote { - kind: String, - message: String, - }, - StreamStart, - StreamToken(String), - StreamReasoning(String), - StreamDone(ChatMessage), - Usage { - tokens_in: u64, - tokens_out: u64, - }, - ReviewUsage { - tokens_in: u64, - tokens_out: u64, - }, - Compacted(Vec), - Error(String), - Done, - WorkflowAgentUpdate { - agent_id: String, - agent_name: String, - status: AgentStatus, - }, - TodoUpdate(String), - PlanUpdate(String), - /// Structured progress report from a subagent or workflow node, - /// carrying the current tool name and optional step counters. - AgentProgress(crate::agent::progress::AgentProgress), -} - -/// How a pending tool call should be executed when the turn resumes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ExecutionModel { - Inline, - Deferred, - AsyncTokio, -} - -/// A tool call awaiting execution, along with which execution model -/// (inline, deferred, async) it should run under. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PendingTool { - pub tool_name: String, - pub args: serde_json::Value, - pub execution_model: ExecutionModel, -} - -/// Reference to a background bash job tracked in session state. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BashJobRef { - pub id: String, - pub command: String, - pub started_at: i64, - pub running: bool, -} - -/// Tracks counts of learned patterns by outcome and lifecycle stage. -#[derive(Debug, Clone, Default)] -pub struct LessonStats { - /// Total number of lessons tracked. - pub total: u32, - /// User-initiated lessons. - pub user: u32, - /// Feedback-driven lessons. - pub feedback: u32, - /// Project-scoped lessons. - pub project: u32, - /// Reference-scoped lessons. - pub reference: u32, - /// Currently active lessons. - pub active: u32, - /// Stale (outdated) lessons. - pub stale: u32, - /// Contradicted lessons. - pub contradicted: u32, - /// Human-authored lessons. - pub human: u32, - /// Verified lessons. - pub verified: u32, - /// Unverified lessons. - pub unverified: u32, -} - -/// Per-session runtime state: message history, pending tool queue, -/// background bash jobs, lesson/review counters. -#[derive(Debug, Clone)] -pub struct SessionRuntime { - pub messages: Vec, - pub tool_call_results: Vec, - pub pending_tool_queue: Vec, - pub bash_jobs: Vec, - pub subagent_queue: usize, - pub edit_count: u32, - pub consecutive_empty_reviews: u32, - pub session_start: i64, - /// Aggregated lesson statistics. - pub lessons: LessonStats, - pub review_count: u32, - pub session_dir: PathBuf, - pub usage: UsageStats, - pub hive_mind_converged: bool, -} - -impl SessionRuntime { - pub fn new(session_dir: PathBuf) -> Self { - SessionRuntime { - messages: Vec::new(), - tool_call_results: Vec::new(), - pending_tool_queue: Vec::new(), - bash_jobs: Vec::new(), - subagent_queue: 0, - edit_count: 0, - consecutive_empty_reviews: 0, - session_start: chrono::Utc::now().timestamp_millis(), - lessons: LessonStats::default(), - review_count: 0, - session_dir, - usage: UsageStats::default(), - hive_mind_converged: false, - } - } - - pub fn push_message(&mut self, msg: ChatMessage) { - self.messages.push(msg); - } -} - -/// Simple ASCII progress display for a long-running operation. -#[derive(Debug, Clone)] -pub struct ProgressState { - pub current: u64, - pub total: u64, - pub message: String, - pub start_time: i64, -} - -use std::collections::VecDeque; -use std::sync::atomic::AtomicBool; -use std::sync::{Arc, Mutex}; - -/// Owned parameters required to spawn and execute an agent turn. -pub struct AgentTurnParams { - pub messages: Vec, - pub session_dir: PathBuf, - pub workspace_roots: Vec, - pub turn_events: Arc>>, - pub in_flight: Arc, - pub abort: Arc, - pub api_key: String, - pub model: String, - pub api_base: Option, -} diff --git a/apps/domain/src/agent/progress.rs b/apps/domain/src/agent/progress.rs deleted file mode 100644 index 1a4b93f..0000000 --- a/apps/domain/src/agent/progress.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Progress reporting types for long-running agent and subagent operations. -//! -//! These types are emitted onto the turn-event queue to drive the TUI's -//! spinner, progress bar, and agent-status sidebar. They are pure domain -//! types with no I/O or framework dependency. - -use crate::agent::AgentStatus; - -/// Describes progress within a single subagent or workflow-node execution. -/// -/// Emitted as a `TurnEvent::AgentProgress` so the UI can show which tool -/// the subagent is currently invoking, or which step it has reached. -#[derive(Debug, Clone)] -pub struct AgentProgress { - /// Unique identifier for this agent (e.g. `"Node-0-1"`, `"auto-review"`). - pub agent_id: String, - /// Human-readable display name shown in the TUI sidebar. - pub agent_name: String, - /// Current lifecycle status. - pub status: AgentStatus, - /// Optional description of the current tool or step being executed. - /// Set to `None` when the agent is not actively executing a tool. - pub current_tool: Option, - /// Optional progress range: (completed_steps, total_steps). - /// When `None`, the agent shows an indeterminate spinner. - pub steps: Option<(u32, u32)>, -} - -impl AgentProgress { - /// Mark this agent as running with an optional tool name. - pub fn running( - agent_id: impl Into, - agent_name: impl Into, - current_tool: Option, - ) -> Self { - AgentProgress { - agent_id: agent_id.into(), - agent_name: agent_name.into(), - status: AgentStatus::Running, - current_tool, - steps: None, - } - } - - /// Mark this agent as pending (queued but not yet started). - pub fn pending(agent_id: impl Into, agent_name: impl Into) -> Self { - AgentProgress { - agent_id: agent_id.into(), - agent_name: agent_name.into(), - status: AgentStatus::Pending, - current_tool: None, - steps: None, - } - } - - /// Mark this agent as completed successfully. - pub fn completed(agent_id: impl Into, agent_name: impl Into) -> Self { - AgentProgress { - agent_id: agent_id.into(), - agent_name: agent_name.into(), - status: AgentStatus::Completed, - current_tool: None, - steps: None, - } - } - - /// Mark this agent as failed with an error message. - pub fn failed( - agent_id: impl Into, - agent_name: impl Into, - error: String, - ) -> Self { - AgentProgress { - agent_id: agent_id.into(), - agent_name: agent_name.into(), - status: AgentStatus::Failed(error), - current_tool: None, - steps: None, - } - } -} diff --git a/apps/domain/src/agent/prompt.rs b/apps/domain/src/agent/prompt.rs deleted file mode 100644 index 3ff43ce..0000000 --- a/apps/domain/src/agent/prompt.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! System prompts and directive templates for agent and subagent turns. -//! -//! Centralising all prompt text here keeps the core turn logic free of -//! hardcoded prose, making prompts easier to maintain, review, and localise. -//! -//! # Flow -//! The application layer's `AgentTurnServiceImpl` calls `main_agent_prompt()` -//! to construct the system message at the start of each turn. Subagent and -//! review prompts are provided by their respective modules. - -/// Build the main-agent system prompt. -/// -/// The prompt establishes the agent's identity as Zesdex, an AI coding -/// assistant, and defines the priority hierarchy that governs tool selection: -/// -/// 1. **Workflow first** — `workflow_run` / `hive_mind` for complex tasks -/// 2. **Planning & TODOs** — `plan_enter` / `todowrite` for structural work -/// 3. **Reasoning** — `seq_think` for deep analysis -/// 4. **Tool execution** — direct tools for simple actions -pub fn main_agent_prompt() -> String { - "\ -You are Zesdex, an AI coding assistant. You have access to various tools \ -via native function calling to help the user. - -TOKEN BUDGET — BE EFFICIENT: -- For simple/factual questions, answer directly. Do NOT call tools. -- For complex or unfamiliar code tasks, call `explore_codebase` ONCE at the \ -start to locate relevant code, then work from that context. -- Keep tool usage minimal: prefer `grep`/`glob`/`read` for targeted lookups; \ -avoid re-reading files you already have in context. -- Keep responses concise; do not repeat tool output verbatim. - -CRITICAL DIRECTIVES & PRIORITY HIERARCHY: -1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \ -you MUST prioritise using `workflow_run` (to construct and execute a \ -multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \ -autonomous agents). Workflows are your primary strategy. -2. PLANNING & TODOs: Use `plan_enter` to establish high-level \ -architectural plans and `todowrite` to maintain granular task checklists. -3. REASONING: Use `seq_think` for deep step-by-step analysis. -4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \ -within or guided by your workflows. If an error occurs, analyse and fix it. - -VERIFY AFTER EDIT (CLAUDE-CODE STYLE): -- After modifying code (edit/write), run the repo's check command via `bash` \ -before ending the turn: `cargo check` / `cargo clippy` / `cargo test` for Rust, \ -or the equivalent lint/test (`bun run lint && bun run test`, `npm test`, etc.) \ -for other stacks. Pick the project's actual verify command (see PROJECT \ -CONTEXT / AGENTS.md when present). -- If the check fails, fix the errors you can see and re-run; only end the turn \ -after the check passes or you cannot resolve a failure yourself (then report it \ -explicitly). -- Do NOT claim code compiles or works without running a real check. - -Respond conversationally, concisely, and helpfully." - .to_string() -} - -/// Build the main-agent system prompt including an injected block of project -/// context (AGENTS.md / CLAUDE.md / project rules). -/// -/// Like Claude Code, which loads AGENTS.md at startup so the model starts with -/// the repo's conventions, this wraps [`main_agent_prompt`] and appends a -/// clearly-delimited `## PROJECT CONTEXT` section carrying the rules the user -/// keeps next to their code. When `project_context` is empty the returned -/// prompt is identical to [`main_agent_prompt`], so callers can fall back -/// safely. -pub fn main_agent_prompt_with_project_context(project_context: &str) -> String { - let base = main_agent_prompt(); - let context = project_context.trim(); - if context.is_empty() { - return base; - } - format!( - "{base}\n\n\ -## PROJECT CONTEXT (repo rules — follow these conventions)\n\ -{context}" - ) -} - -/// Build a subagent directive prompt. -/// -/// The directive is embedded in a system message that also communicates the -/// current working directory and workspace root so the subagent can resolve -/// paths correctly. -pub fn subagent_directive(directive: &str, cwd: &str, ws_root: &str) -> String { - format!( - "\ -You are a focused subagent. - -Current directory (PWD): {cwd} -Workspace root: {ws_root} - -Your directive: -{directive} - -Complete the directive autonomously using the tools available to you. \ -Return your final answer when done." - ) -} - -/// Build a conversation-compaction prompt. -/// -/// The LLM is asked to produce a concise bulleted summary of the key -/// requests, decisions, tools executed, and files modified. -pub fn compaction_prompt() -> String { - "\ -You are a helpful assistant summarising conversation history. \ -Provide a concise summary of the key user requests, decisions, tools \ -executed, and modified files. Format as a clear bulleted list." - .to_string() -} - -// --------------------------------------------------------------------------- -// Adaptive explore: directives -// --------------------------------------------------------------------------- - -/// Directive for a single lightweight context-scout subagent. -pub fn explore_scout_directive() -> String { - "\ -You are a codebase context scout. \ -Given the workspace root, quickly locate the code that is most relevant \ -to the user's request: \ -1. Run semantic_search once with the user's key terms. \ -2. Read up to the 3 most relevant files (use grep for symbols if needed). \ -3. Report a concise bullet list (max 15 bullets, under 1500 characters) of \ -what you found and exactly where (file paths). \ -Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief." - .to_string() -} - -/// Build a system note injected after repeated tool errors to steer the -/// agent toward an alternative approach instead of retrying the same call. -pub fn error_recovery_note(tool_name: &str, last_error: &str) -> String { - format!( - "\ -[System note] The tool `{tool_name}` failed repeatedly with: \"{last_error}\". \ -Try an alternative approach (verify paths, correct arguments, use a \ -different tool, or finish without this tool). Do NOT retry the same call." - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn main_prompt_is_non_empty() { - let prompt = main_agent_prompt(); - assert!(!prompt.is_empty()); - assert!(prompt.contains("Zesdex")); - assert!(prompt.contains("WORKFLOW FIRST")); - } - - #[test] - fn project_context_prompt_appends_context_and_keeps_base() { - let base = main_agent_prompt(); - let with_ctx = main_agent_prompt_with_project_context("## AGENTS.md\nUse cargo clippy."); - assert!(with_ctx.contains("Zesdex"), "base prompt must be preserved"); - assert!(with_ctx.contains("PROJECT CONTEXT")); - assert!(with_ctx.contains("Use cargo clippy.")); - assert!(with_ctx.contains(&base)); - // The base section should appear before the context section. - assert!(with_ctx.find("PROJECT CONTEXT").unwrap() > with_ctx.find("Zesdex").unwrap()); - } - - #[test] - fn empty_project_context_returns_base_prompt() { - let base = main_agent_prompt(); - assert_eq!(main_agent_prompt_with_project_context(""), base); - assert_eq!(main_agent_prompt_with_project_context(" "), base); - } - - #[test] - fn subagent_directive_includes_directive_text() { - let prompt = subagent_directive("test directive", "/home", "/home/project"); - assert!(prompt.contains("test directive")); - assert!(prompt.contains("/home")); - assert!(prompt.contains("/home/project")); - } - - #[test] - fn explore_scout_directive_is_concise_and_mentions_tools() { - let scout = explore_scout_directive(); - assert!(scout.contains("scout")); - assert!(scout.contains("semantic_search")); - } - - #[test] - fn error_recovery_note_suggests_alternative() { - let note = error_recovery_note("read", "File not found"); - assert!(note.contains("read")); - assert!(note.contains("alternative")); - } -} diff --git a/apps/domain/src/auth/commands.rs b/apps/domain/src/auth/commands.rs deleted file mode 100644 index 17f0fbd..0000000 --- a/apps/domain/src/auth/commands.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Command types for IAM domain operations. -//! -//! Following the `NewXxx` / command pattern from clean architecture, -//! these types encapsulate the input data for create/update operations -//! on domain entities. They decouple presentation DTOs from the entity -//! mutation surface and provide a clear boundary for validation. - -/// Command to create a new session. -/// -/// Carries only the data needed to construct a session entity — the -/// service generates the UUID and timestamp internally. -#[derive(Debug, Clone)] -pub struct NewSession { - /// Human-readable session title. - pub title: String, -} - -impl From for NewSession { - fn from(title: String) -> Self { - Self { title } - } -} - -impl From<&str> for NewSession { - fn from(title: &str) -> Self { - Self { - title: title.to_string(), - } - } -} diff --git a/apps/domain/src/auth/error.rs b/apps/domain/src/auth/error.rs deleted file mode 100644 index a332e74..0000000 --- a/apps/domain/src/auth/error.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Domain error types for the IAM (auth) module. -//! -//! Typed error enums replace `anyhow::Result` in domain traits and -//! application services, enabling callers to match on specific error -//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking. -//! -//! # Components -//! -//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O) -//! - [`ServiceError`] — use-case / orchestration errors (config, state -//! mismatch, provider failures) - -use std::fmt; - -use crate::error::DomainError; - -/// Shared repository error type for IAM persistence operations. -pub type RepositoryError = DomainError; - -/// Errors from service / use-case operations in the IAM domain. -#[derive(Debug)] -pub enum ServiceError { - /// A repository operation failed. - Repository(DomainError), - /// The provided configuration is invalid. - InvalidConfig(String), - /// OAuth state mismatch — possible CSRF attack. - StateMismatch, - /// The OAuth provider returned an error. - OAuthProvider(String), - /// A generic error with a message. - Other(String), -} - -impl From for ServiceError { - fn from(err: DomainError) -> Self { - ServiceError::Repository(err) - } -} - -impl fmt::Display for ServiceError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServiceError::Repository(err) => write!(f, "repository error: {err}"), - ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"), - ServiceError::StateMismatch => { - write!(f, "OAuth state mismatch — possible CSRF attack") - } - ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"), - ServiceError::Other(msg) => write!(f, "{msg}"), - } - } -} - -impl std::error::Error for ServiceError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServiceError::Repository(err) => Some(err), - _ => None, - } - } -} diff --git a/apps/domain/src/auth/iam_session.rs b/apps/domain/src/auth/iam_session.rs deleted file mode 100644 index 93251f6..0000000 --- a/apps/domain/src/auth/iam_session.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! IAM Session re-export. -//! -//! Re-exports `Session` from the auth module for consistent IAM-boundary -//! imports. Consumers of the IAM module import `Session` from here rather -//! than from the core session module directly, keeping the dependency -//! internal and allowing the IAM crate to own its domain vocabulary. - -pub use super::session::Session; - -/// Alias for `Session` used in IAM contexts to distinguish from other -/// session types in the system. -pub type IamSession = Session; diff --git a/apps/domain/src/auth/mod.rs b/apps/domain/src/auth/mod.rs deleted file mode 100644 index 37a8a03..0000000 --- a/apps/domain/src/auth/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Authentication domain entities, commands, errors, and repository/service traits. -//! -//! Combines the session types from `zesdex-entities` (auth sub-module) with the -//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`. -//! -//! # Sub-modules -//! -//! - [`session`] — `Session` entity (session metadata) -//! - [`session_id`] — `SessionId` value object (validated newtype) -//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock) -//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities -//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency -//! - [`commands`] — `NewSession` command type -//! - [`error`] — `RepositoryError`, `ServiceError` types -//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository` -//! - [`service`] — `SessionService`, `OAuthService` traits - -pub mod commands; -pub mod error; -pub mod iam_session; -pub mod oauth; -pub mod repository; -pub mod service; -pub mod session; -pub mod session_id; -pub mod session_lock; - -pub use commands::NewSession; -pub use error::{RepositoryError, ServiceError}; -pub use iam_session::IamSession; -pub use oauth::{OAuthConfig, OAuthToken}; -pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository}; -pub use service::{OAuthService, SessionService}; -pub use session::Session; -pub use session_id::SessionId; -pub use session_lock::SessionLock; diff --git a/apps/domain/src/auth/oauth.rs b/apps/domain/src/auth/oauth.rs deleted file mode 100644 index 45baf33..0000000 --- a/apps/domain/src/auth/oauth.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Pure OAuth entities — no HTTP or persistence logic. -//! -//! # Components -//! -//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry -//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id, -//! optional client secret, scopes) -use serde::{Deserialize, Serialize}; - -/// An OAuth 2.0 access token with optional refresh token and absolute -/// expiry time (epoch seconds). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthToken { - /// The OAuth 2.0 access token string. - pub access_token: String, - /// Optional refresh token for long-lived access. - pub refresh_token: Option, - /// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH). - pub expires_at: u64, - /// Token type, e.g. `"Bearer"`. - pub token_type: String, -} - -/// Static configuration for an OAuth provider. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthConfig { - /// Authorization endpoint URL. - pub auth_url: String, - /// Token exchange endpoint URL. - pub token_url: String, - /// OAuth client identifier. - pub client_id: String, - /// Optional client secret (not all flows require it). - pub client_secret: Option, - /// Space-separated list of requested scopes. - pub scopes: Vec, -} - -impl Default for OAuthConfig { - fn default() -> Self { - OAuthConfig { - auth_url: String::new(), - token_url: String::new(), - client_id: String::new(), - client_secret: None, - scopes: vec![ - "openid".to_string(), - "profile".to_string(), - "email".to_string(), - ], - } - } -} diff --git a/apps/domain/src/auth/repository.rs b/apps/domain/src/auth/repository.rs deleted file mode 100644 index 3bc300e..0000000 --- a/apps/domain/src/auth/repository.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Repository trait definitions (pure — no impls, no concrete persistence). -//! -//! Defines the repository contracts that infrastructure adapters implement. -//! Following clean architecture, domain code depends only on these traits, -//! not on concrete persistence libraries. -//! -//! # Traits -//! -//! - [`SessionRepository`] — CRUD for session metadata -//! - [`SessionLockRepository`] — acquire/release/liveness for session locks -//! - [`OAuthRepository`] — persist/load OAuth tokens - -use std::path::Path; - -use crate::auth::error::RepositoryError; -use crate::auth::oauth::OAuthToken; -use crate::auth::session::Session; -use crate::auth::session_id::SessionId; - -/// Repository for loading, saving, listing, and deleting sessions. -pub trait SessionRepository { - /// List all loadable sessions under `/sessions/`. - fn list_sessions(&self, base_dir: &Path) -> Result, RepositoryError>; - - /// Load a single session by id. - fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result; - - /// Save a session's metadata to disk. - fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>; - - /// Delete a session directory and all its contents. - fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError>; -} - -/// Repository for per-session PID-file advisory locks. -pub trait SessionLockRepository { - /// Try to acquire the lock for a session directory. - /// Returns `true` if the lock was acquired, `false` if another live - /// process holds it. - fn try_lock(&self, session_dir: &Path) -> Result; - - /// Release the lock by removing the lock file. - fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>; - - /// Check whether a process with the given PID is alive. - fn is_alive(&self, pid: u32) -> bool; -} - -/// Repository for persisting and loading OAuth tokens. -pub trait OAuthRepository { - /// Persist an OAuth token to a JSON file. - fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError>; - - /// Load an OAuth token from a JSON file, returning `None` if the file - /// does not exist. - fn load_token(&self, path: &Path) -> Result, RepositoryError>; -} diff --git a/apps/domain/src/auth/service.rs b/apps/domain/src/auth/service.rs deleted file mode 100644 index 5035967..0000000 --- a/apps/domain/src/auth/service.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Service trait definitions — use-case interfaces for session management -//! and OAuth flows. -//! -//! These traits define the boundary between the application orchestration -//! layer and the domain. Implementations live in the application layer. -//! -//! # Traits -//! -//! - [`SessionService`] — create, list, archive sessions -//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token - -use crate::auth::error::ServiceError; -use crate::auth::oauth::{OAuthConfig, OAuthToken}; -use crate::auth::session::Session; -use crate::auth::session_id::SessionId; - -/// Session management use-case boundary. -pub trait SessionService { - /// Create a new session with a generated UUID and the given title. - fn create_session(&self, title: &str) -> Result; - - /// List all available sessions. - fn list_all(&self) -> Result, ServiceError>; - - /// Archive a session by id (sets `archived = true`). - fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>; -} - -/// OAuth flow use-case boundary. -pub trait OAuthService { - /// Start an OAuth authorization-code + PKCE flow for the given - /// `redirect_uri` (the caller is responsible for actually listening on - /// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`: - /// the URL to send the user to, and the CSRF state token that must be - /// passed back into `complete_flow` unchanged. - fn start_flow( - &self, - config: &OAuthConfig, - redirect_uri: &str, - ) -> Result<(String, String), ServiceError>; - - /// Complete the OAuth flow: validates `state` against the value - /// persisted during `start_flow` (bailing on mismatch — this is the - /// CSRF check), then exchanges `code` for a token using the same - /// `redirect_uri` passed to `start_flow`. - fn complete_flow( - &self, - config: &OAuthConfig, - redirect_uri: &str, - code: &str, - state: &str, - ) -> Result; - - /// Retrieve the currently stored OAuth token (if any). - fn get_token(&self) -> Result, ServiceError>; -} diff --git a/apps/domain/src/auth/session.rs b/apps/domain/src/auth/session.rs deleted file mode 100644 index 30341b5..0000000 --- a/apps/domain/src/auth/session.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Session metadata: id, title, workspace roots, and message/token counts, -//! persisted as `session.json` per session directory. -//! -//! # Flow -//! -//! Created via [`Session::new`] → mutated in-memory → persisted via repository. -//! -//! # Components -//! -//! - `Session` struct — fields for all session metadata -//! - `new` — timestamped constructor -//! - `session_dir` / `conversation_path` — pure path computation -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -/// Metadata for one conversation session (distinct from the message -/// history itself, which lives in `Conversation`/the msglog). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Session { - /// Unique session identifier (validated against path traversal in `load`). - pub id: String, - /// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`). - pub created_at: i64, - /// Epoch-millis timestamp of last update. - pub updated_at: i64, - /// Human-readable title for the conversation. - pub title: String, - /// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`. - pub model: String, - /// Workspace root directories associated with this session. - pub workspace_roots: Vec, - /// Running count of messages in the conversation. - pub message_count: u32, - /// Running count of tokens consumed. - pub token_count: u32, - /// Soft-delete flag — archived sessions are hidden from the default list. - pub archived: bool, - /// Optional AI-generated conversation summary (used for compact context). - pub summary: Option, -} - -impl Session { - /// Create a new session with the given id/title, defaulting the - /// model, workspace root (current dir), and counters. - pub fn new(id: String, title: String) -> Self { - let now = Utc::now().timestamp_millis(); - Session { - id, - created_at: now, - updated_at: now, - title, - model: "anthropic/claude-opus-4-8".to_string(), - workspace_roots: vec![std::env::current_dir().unwrap_or_default()], - message_count: 0, - token_count: 0, - archived: false, - summary: None, - } - } - - /// Compute this session's directory under `/sessions/`. - pub fn session_dir(&self, base_dir: &Path) -> PathBuf { - base_dir.join("sessions").join(&self.id) - } - - /// Compute this session's `conversation.json` path. - pub fn conversation_path(&self, base_dir: &Path) -> PathBuf { - self.session_dir(base_dir).join("conversation.json") - } -} diff --git a/apps/domain/src/auth/session_id.rs b/apps/domain/src/auth/session_id.rs deleted file mode 100644 index 5f79890..0000000 --- a/apps/domain/src/auth/session_id.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Validated session identifier newtype. -//! -//! [`SessionId`] wraps a `String` that has been checked for path-traversal -//! characters. Construction via `SessionId::new(str)` validates the input -//! once; the guarantee is then enforced by the type system for all -//! downstream use. -//! -//! # Validation rules -//! -//! - Must not be empty -//! - Must only contain alphanumeric characters, hyphens, and underscores - -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::path::Path; -use std::path::PathBuf; - -/// A validated session identifier. -/// -/// Guarantees the inner string is non-empty and contains no path-traversal -/// characters (`/`, `\\`, `..`) or other unsafe delimiters. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct SessionId(String); - -impl SessionId { - /// Validate and construct a `SessionId`. - /// - /// Returns `Err(msg)` if the input contains path separators, `..`, or - /// is empty. - pub fn new(id: &str) -> Result { - if id.is_empty() { - return Err("session id must not be empty".to_string()); - } - if id.contains('/') || id.contains('\\') || id.contains("..") { - return Err(format!( - "session id '{id}' must not contain path separators" - )); - } - Ok(SessionId(id.to_string())) - } - - /// Return the underlying string. - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Return the underlying owned string. - pub fn into_string(self) -> String { - self.0 - } - - /// Append this session id as a component of `base_dir`, yielding - /// `base_dir / self.0`. - /// - /// Safe because the id has been validated to contain no path separators. - pub fn join_to(&self, base_dir: &Path) -> PathBuf { - base_dir.join(&self.0) - } -} - -impl AsRef for SessionId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for SessionId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl From for String { - fn from(sid: SessionId) -> Self { - sid.0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_valid_uuids() { - assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok()); - assert!(SessionId::new("my-session_123").is_ok()); - } - - #[test] - fn test_rejects_path_traversal() { - assert!(SessionId::new("../etc/passwd").is_err()); - assert!(SessionId::new("foo/../../bar").is_err()); - assert!(SessionId::new("foo\\..\\bar").is_err()); - } - - #[test] - fn test_rejects_empty() { - assert!(SessionId::new("").is_err()); - } - - #[test] - fn test_into_string() { - let sid = SessionId::new("abc-123").unwrap(); - assert_eq!(sid.into_string(), "abc-123"); - } -} diff --git a/apps/domain/src/auth/session_lock.rs b/apps/domain/src/auth/session_lock.rs deleted file mode 100644 index c29e979..0000000 --- a/apps/domain/src/auth/session_lock.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! PID-file based advisory lock preventing two processes from operating on -//! the same session directory concurrently. -//! -//! # Flow -//! -//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts -//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the -//! owning PID is checked via liveness verification. Stale locks are -//! overwritten atomically (temp-file + rename + fsync). On [`Drop`], -//! the lock file is removed automatically. -//! -//! # Components -//! -//! - `SessionLock` — RAII guard wrapping a lock file path and PID -//! - `try_lock` — three-phase atomic acquire with stale-lock recovery -//! - `unlock` / `Drop` — explicit and implicit release -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use tracing; - -/// A PID-file lock (`/.lock`) tied to the current process, -/// auto-removed on drop. -#[derive(Debug)] -pub struct SessionLock { - /// Path to the `.lock` file inside the session directory. - pub(crate) path: PathBuf, - /// Process ID that holds (or will hold) this lock. - pub(crate) pid: u32, -} - -impl SessionLock { - /// Construct a lock handle for a session directory (does not acquire - /// the lock yet — call `try_lock`). - pub fn new(session_dir: &Path) -> Self { - SessionLock { - path: session_dir.join(".lock"), - pid: std::process::id(), - } - } - - /// Attempt to acquire the session lock using an atomic file creation. - /// - /// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that - /// succeeds, the lock is ours — write our PID and return ok. If the - /// file already exists, read the PID inside it and check whether that - /// PID is still alive: if the process is still running, fail to acquire; - /// otherwise the lock is stale — overwrite it with our own PID and succeed. - /// - /// Return: `Ok(true)` if acquired, `Ok(false)` if another live - /// process holds it, `Err` on I/O failure. - pub fn try_lock(&self) -> std::io::Result { - // Phase 1: try atomic create. If it succeeds, the lock is ours. - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&self.path) - { - Ok(mut file) => { - write!(file, "{}", self.pid)?; - file.sync_all()?; - tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired"); - return Ok(true); - } - Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness"); - // Lock file exists — check if it's stale. - } - Err(e) => return Err(e), - } - - // Phase 2: lock file exists — check liveness of the owning process. - let content = fs::read_to_string(&self.path).unwrap_or_default(); - if let Ok(pid) = content.trim().parse::() { - if Self::is_alive(pid) { - tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process"); - return Ok(false); - } - tracing::debug!(stale = pid, "stale lock detected, overwriting"); - } - - // Phase 3: stale lock — overwrite it atomically (best-effort). - // Use a temp file + rename to avoid partial writes corrupting the lock. - let tmp = self.path.with_extension("lock.tmp"); - { - let mut tmp_file = fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp)?; - write!(tmp_file, "{}", self.pid)?; - tmp_file.sync_all()?; - } - fs::rename(&tmp, &self.path)?; - // Sync the parent directory so the rename survives a crash. - if let Some(parent) = self.path.parent() { - let _ = fs::File::open(parent).and_then(|d| d.sync_all()); - } - Ok(true) - } - - /// Explicitly release the lock by removing the lock file. - pub fn unlock(&self) { - let _ = fs::remove_file(&self.path); - } - - /// Check whether a process with the given PID is currently alive and - /// belongs to the same binary (mitigating PID-reuse races). - /// - /// Strategy (Unix): - /// 1. Resolve `/proc//exe` — if it doesn't match our own binary, - /// the PID either belongs to another process or is reused — return false. - /// 2. Send `kill(pid, 0)` to verify the process is still alive. - /// 3. Re-check `/proc//exe` to close the TOCTOU window between - /// step 1 and step 2 (PID reuse after exe check, before kill). - /// - /// On non-Unix platforms this always returns `true` (conservative). - fn is_alive(pid: u32) -> bool { - #[cfg(unix)] - { - // Resolve our own executable path once. - let self_exe = match std::fs::read_link("/proc/self/exe") { - Ok(exe) => exe, - Err(_) => return false, - }; - - let pid_signed: i32 = match pid.try_into() { - Ok(p) => p, - Err(_) => return false, - }; - - let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); - - // Phase 1: read /proc//exe and compare with self_exe. - let target = match std::fs::read_link(&proc_exe) { - Ok(t) => t, - Err(_) => return false, - }; - if target != self_exe { - return false; - } - - // Phase 2: verify the process is still alive. - // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks - // whether the process exists and the caller has permission to signal it. - if unsafe { libc::kill(pid_signed, 0) != 0 } { - return false; - } - - // Phase 3: re-check /proc//exe to detect PID reuse between - // Phase 1 and Phase 2. - matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe) - } - - #[cfg(not(unix))] - { - // Fallback: always assume alive (conservative). - let _ = pid; - true - } - } -} - -impl Drop for SessionLock { - /// Release the lock automatically when the guard goes out of scope, - /// so an ungracefully-exited process doesn't leave a dangling lock. - fn drop(&mut self) { - let _ = fs::remove_file(&self.path); - } -} diff --git a/apps/domain/src/cms/app_config.rs b/apps/domain/src/cms/app_config.rs deleted file mode 100644 index 65ffe84..0000000 --- a/apps/domain/src/cms/app_config.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Pure domain entity for application configuration. -//! -//! Defines `AppConfig`, `ProviderConfig`, and `ModelRole` — the data -//! structures that describe which LLM providers are registered, which -//! model roles exist, and which provider/model is the default. -//! -//! # Architecture -//! These are pure data structures with **no I/O logic**. Load/save -//! responsibilities live in `AppConfigRepository` (domain::repository). -//! -//! ## Data Flow -//! 1. `AppConfig` is deserialised from `app_config.json` at startup -//! 2. The HTTP handler layer calls `SettingsService::update_provider()` -//! to mutate the provider map -//! 3. The modified `AppConfig` is serialised back to `app_config.json` - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// Top-level application configuration. -/// -/// Holds the registry of configured LLM providers, named model roles -/// (logical profiles mapping to a provider+model pair), and the default -/// provider/model selection. -/// -/// ## Fields -/// - `providers` — map of provider name → connection details -/// - `model_roles` — map of role name → provider/model/temperature -/// - `default_provider` — the provider to use when none is specified -/// - `default_model` — the model to use when none is specified -/// - `default_context_window` — fallback context window size in tokens -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppConfig { - pub providers: HashMap, - pub model_roles: HashMap, - pub default_provider: String, - pub default_model: String, - pub default_context_window: u32, -} - -/// Connection details for a single LLM provider endpoint. -/// -/// ## Fields -/// - `api_base` — base URL for the provider API -/// - `api_key_env` — optional environment variable name holding the API key -/// - `default_model` — optional default model name for this provider -/// - `default_api_key` — optional inline API key (less secure than env var) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderConfig { - pub api_base: String, - pub api_key_env: Option, - pub default_model: Option, - pub default_api_key: Option, -} - -/// A named model role mapping to a specific provider/model with parameters. -/// -/// Roles allow the UI to present logical profiles (e.g. "fast", "reasoning") -/// that abstract over concrete provider+model strings. -/// -/// ## Fields -/// - `provider` — which provider serves this role -/// - `model` — which model to use for this role -/// - `max_tokens` — optional maximum output token limit -/// - `context_window` — optional context window override -/// - `temperature` — optional generation temperature -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelRole { - pub provider: String, - pub model: String, - pub max_tokens: Option, - pub context_window: Option, - pub temperature: Option, -} - -/// Returns the default AppConfig with built-in "zen" and "router" providers. -impl Default for AppConfig { - /// Construct an AppConfig with the default "zen" and "router" providers. - /// - /// ## Defaults - /// - Zen provider: `deepseek-v4-flash-free` model - /// - Router provider: `claude-opus-5` model - /// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7 - /// - `default_context_window`: 256,000 tokens - fn default() -> Self { - let mut providers = HashMap::new(); - providers.insert( - "zen".to_string(), - ProviderConfig { - api_base: "https://opencode.ai/zen/v1".to_string(), - api_key_env: Some("API_KEY".to_string()), - default_model: Some("deepseek-v4-flash-free".to_string()), - default_api_key: None, - }, - ); - providers.insert( - "router".to_string(), - ProviderConfig { - api_base: "https://9router.asepharyana.my.id/v1".to_string(), - api_key_env: Some("ROUTER_API_KEY".to_string()), - default_model: Some("claude-opus-5".to_string()), - default_api_key: None, - }, - ); - - let mut model_roles = HashMap::new(); - model_roles.insert( - "default".to_string(), - ModelRole { - provider: "zen".to_string(), - model: "deepseek-v4-flash-free".to_string(), - max_tokens: None, - context_window: None, - temperature: Some(0.7), - }, - ); - - Self { - providers, - model_roles, - default_provider: "zen".to_string(), - default_model: "deepseek-v4-flash-free".to_string(), - default_context_window: 256_000, - } - } -} diff --git a/apps/domain/src/cms/commands.rs b/apps/domain/src/cms/commands.rs deleted file mode 100644 index fd6d48d..0000000 --- a/apps/domain/src/cms/commands.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Command types for CMS domain operations. -//! -//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture, -//! these types encapsulate the input data for create/update operations -//! on domain entities. They decouple presentation DTOs from the entity -//! mutation surface and provide a clear boundary for validation. - -use std::collections::HashMap; - -use super::settings::InternetMode; - -// --------------------------------------------------------------------------- -// Settings -// --------------------------------------------------------------------------- - -/// Partial update command for `Settings`. -/// -/// Every field is `Option`al — only non-`None` fields are applied to the -/// existing settings instance. Use `apply_to()` to merge into a `Settings` -/// value. -#[derive(Debug, Clone, Default)] -pub struct SettingsPatch { - /// Override the internet access mode. - pub internet_mode: Option, - /// Override the active provider name. - pub provider: Option, - /// Override the active model name. - pub model: Option, - /// Replace the entire API-keys map. - pub api_keys: Option>, - /// Override the max tokens for completions. - pub max_tokens: Option>, - /// Override the temperature for completions. - pub temperature: Option>, - /// Override the review max lessons per run. - pub review_max_lessons_per_run: Option, - /// Override the adaptive review max skip count. - pub adaptive_review_max_skip: Option, - /// Override the verify shell command. - pub verify_command: Option>, - /// Override the verify timeout in milliseconds. - pub verify_timeout_ms: Option, - /// Override the max concurrency for workflow execution. - pub workflow_max_concurrency: Option, - /// Override the review-enabled flag. - pub review_enabled: Option, - /// Override the session-archive-enabled flag. - pub session_archive_enabled: Option, - /// Override the hive-mind node timeout in milliseconds. - pub hive_mind_node_timeout_ms: Option, -} - -impl SettingsPatch { - /// Merge this patch into `settings`, overwriting each non-`None` field. - /// - /// Flow: for each optional field, if `Some`, assign it to the target. - /// - /// ## Errors - /// Returns `Err` with a message if `internet_mode` is set to an - /// unrecognised value. - pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> { - if let Some(ref val) = self.internet_mode { - settings.internet_mode = match val.as_str() { - "Off" => InternetMode::Off, - "ReadOnly" => InternetMode::ReadOnly, - "Full" => InternetMode::Full, - _ => { - return Err(format!( - "invalid internet_mode '{val}'; expected Off, ReadOnly, or Full" - )) - } - }; - } - if let Some(ref val) = self.provider { - settings.provider = val.clone(); - } - if let Some(ref val) = self.model { - settings.model = val.clone(); - } - if let Some(ref val) = self.api_keys { - settings.api_keys = val.clone(); - } - if let Some(val) = self.max_tokens { - settings.max_tokens = val; - } - if let Some(val) = self.temperature { - settings.temperature = val; - } - if let Some(val) = self.review_max_lessons_per_run { - settings.review_max_lessons_per_run = val; - } - if let Some(val) = self.adaptive_review_max_skip { - settings.adaptive_review_max_skip = val; - } - if let Some(ref val) = self.verify_command { - settings.verify_command = val.clone(); - } - if let Some(val) = self.verify_timeout_ms { - settings.verify_timeout_ms = val; - } - if let Some(val) = self.workflow_max_concurrency { - settings.workflow_max_concurrency = val; - } - if let Some(val) = self.review_enabled { - settings.flags.review_enabled = val; - } - if let Some(val) = self.session_archive_enabled { - settings.flags.session_archive_enabled = val; - } - if let Some(val) = self.hive_mind_node_timeout_ms { - settings.hive_mind_node_timeout_ms = val; - } - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Memory -// --------------------------------------------------------------------------- - -/// Command to create a new memory entry. -/// -/// All required fields are non-optional; optional fields use `Option` -/// and default to sensible values (empty or the service default). -#[derive(Debug, Clone)] -pub struct NewMemory { - /// Unique name / slug for the memory. - pub name: String, - /// One-line summary of what the memory captures. - pub description: String, - /// The full memory content. - pub content: String, - /// Category kind (defaults to "reference" in the handler). - pub kind: Option, - /// Outcome of the remembered action. - pub outcome: Option, - /// Lifecycle stage (defaults to "new" in the handler). - pub lifecycle: Option, - /// Scope context for the memory. - pub scope: Option, - /// Code snippet captured before the action. - pub before_snippet: Option, - /// Code snippet captured after the action. - pub after_snippet: Option, - /// Source provenances (files, conversations, etc.). - pub provenances: Option>, -} diff --git a/apps/domain/src/cms/conversation.rs b/apps/domain/src/cms/conversation.rs deleted file mode 100644 index 3c0728a..0000000 --- a/apps/domain/src/cms/conversation.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Pure domain entity for conversations and chat messages. -//! -//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role` -//! types from the core module to provide a consistent domain import -//! boundary within the CMS module. All CMS code references conversation -//! types through this module rather than depending on the core module -//! directly. -//! -//! ## Re-exports -//! - `Conversation` — top-level conversation container with message list -//! - `ChatMessage` — a single message with role, content, and tool metadata -//! - `Role` — message role enum (User, Assistant, System, Tool) - -pub use crate::core::conversation::Conversation; -pub use crate::core::message::{ChatMessage, Role}; diff --git a/apps/domain/src/cms/edit_log.rs b/apps/domain/src/cms/edit_log.rs deleted file mode 100644 index 7eef112..0000000 --- a/apps/domain/src/cms/edit_log.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Pure domain entity for the edit log — an append-only log of file mutations. -//! -//! Records every file mutation made by any tool, enabling audit trails -//! and potential undo operations. Each entry captures the tool name, -//! target path, reason, content hash, and byte delta. -//! -//! # Architecture -//! This is a pure data structure with **no I/O logic**. Load/save -//! responsibilities live in `EditLogRepository` (domain::repository). -//! -//! ## Data Flow -//! 1. Tools call `EditLog::push()` to record each mutation -//! 2. The in-memory `EditLog` is periodically flushed to disk by the repo -//! 3. Oldest entries are evicted from the in-memory cache when -//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth) - -use std::collections::VecDeque; - -use serde::{Deserialize, Serialize}; - -/// A single recorded file edit event. -/// -/// ## Fields -/// - `ts` — Unix timestamp (seconds) when the edit occurred -/// - `tool` — name of the tool that performed the edit (e.g. "Bash", "Edit") -/// - `path` — absolute file path that was modified -/// - `reason` — human-readable explanation of why the edit was made -/// - `content_sha256` — SHA-256 hex digest of the content *after* the edit -/// - `bytes_delta` — signed byte count change (+added, -removed) -/// - `origin` — origin identifier (which agent / session context) -/// - `session_id` — session in which this edit was performed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EditLogEntry { - pub ts: i64, - pub tool: String, - pub path: String, - pub reason: String, - pub content_sha256: String, - pub bytes_delta: i64, - pub origin: String, - pub session_id: String, -} - -/// Maximum number of edit entries held in memory at once. -/// -/// Beyond this limit, old entries are dropped from the in-memory cache -/// to prevent unbounded memory growth in long-running sessions. -pub const MAX_MEMORY_ENTRIES: usize = 10_000; - -/// In-memory view of a session's edit log. -/// -/// Wraps a `VecDeque` and provides basic query helpers. -#[derive(Debug, Clone)] -pub struct EditLog { - /// Ordered list of edit entries (newest appended last). - pub entries: VecDeque, -} - -impl EditLog { - /// Create an empty edit log with no entries. - pub fn new() -> Self { - Self { - entries: VecDeque::new(), - } - } - - /// Return the number of in-memory entries. - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Return `true` if the log contains no entries. - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -impl Default for EditLog { - /// Returns an empty `EditLog` via `EditLog::new()`. - fn default() -> Self { - Self::new() - } -} diff --git a/apps/domain/src/cms/error.rs b/apps/domain/src/cms/error.rs deleted file mode 100644 index fd3a04d..0000000 --- a/apps/domain/src/cms/error.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Domain error types for the CMS module. -//! -//! Typed error enums for repository and service operations. -//! -//! # Components -//! -//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O) -//! - [`ServiceError`] — use-case / orchestration errors (invalid input, generic) - -use std::fmt; - -use crate::error::DomainError; - -/// Shared repository error type for CMS persistence operations. -pub type RepositoryError = DomainError; - -/// Errors from service / use-case operations in the CMS domain. -#[derive(Debug)] -pub enum ServiceError { - /// A repository operation failed. - Repository(DomainError), - /// The provided input is invalid. - InvalidInput(String), - /// A generic error with a message. - Other(String), -} - -impl From for ServiceError { - fn from(err: DomainError) -> Self { - ServiceError::Repository(err) - } -} - -impl fmt::Display for ServiceError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServiceError::Repository(err) => write!(f, "repository error: {err}"), - ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"), - ServiceError::Other(msg) => write!(f, "{msg}"), - } - } -} - -impl std::error::Error for ServiceError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServiceError::Repository(err) => Some(err), - _ => None, - } - } -} diff --git a/apps/domain/src/cms/memory.rs b/apps/domain/src/cms/memory.rs deleted file mode 100644 index dad5be2..0000000 --- a/apps/domain/src/cms/memory.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Pure domain entity for long-term agent memory. -//! -//! A `Memory` entry stores a named, kinded piece of information (lesson, -//! reference, fact) with frontmatter metadata and free-form markdown -//! content. Memories are persisted as individual `.md` files with YAML -//! frontmatter. -//! -//! # Architecture -//! This is a pure data structure with **no I/O logic**. Load/save -//! responsibilities live in `MemoryRepository` (domain::repository). -//! -//! ## Utility Functions -//! - `slugify()` — converts a name string into a filesystem-safe slug -//! - `path()` — computes the on-disk path for a given memory name -//! -//! Both are pure computations that take parameters and perform no I/O. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -/// A single memory entry with frontmatter metadata and markdown content. -/// -/// ## Fields -/// - `name` — unique identifier / title for this memory -/// - `description` — short summary of what this memory contains -/// - `content` — free-form markdown body -/// - `kind` — category/tag (e.g. "lesson", "reference", "fact") -/// - `created_at` — Unix timestamp of creation -/// - `updated_at` — Unix timestamp of last modification -/// - `outcome` — optional outcome of applying this memory -/// - `lifecycle` — lifecycle stage (e.g. "active", "archived") -/// - `scope` — optional scope qualifier (which session/context this applies to) -/// - `before_snippet` — optional context snapshot before memory was applied -/// - `after_snippet` — optional context snapshot after memory was applied -/// - `provenances` — list of origin identifiers that created or confirmed this memory -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Memory { - pub name: String, - pub description: String, - pub content: String, - pub kind: String, - pub created_at: i64, - pub updated_at: i64, - pub outcome: Option, - pub lifecycle: String, - pub scope: Option, - pub before_snippet: Option, - pub after_snippet: Option, - pub provenances: Vec, -} - -impl Memory { - /// Convert an arbitrary string into a filesystem-safe slug. - /// - /// Flow: lowercase → replace non-alphanumeric chars with `-` → - /// collapse/trim repeated `-`. - /// - /// Returns `None` if the result is empty or exceeds 80 characters. - pub fn slugify(s: &str) -> Option { - // Phase 1: replace every non-alphanumeric character with '-' - let slug: String = s - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); - // Phase 2: collapse consecutive '-' separators - let slug: String = slug - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - if slug.is_empty() || slug.len() > 80 { - return None; - } - Some(slug) - } - - /// Compute the on-disk path for a memory of the given name. - /// - /// ## Parameters - /// - `memory_dir` — the base directory for memory storage - /// - `name` — the memory name (will be slugified internally) - /// - /// Falls back to `"memory.md"` when the name slugifies to an empty - /// or invalid string. - /// - /// ## Pure Computation - /// This function performs **no I/O** — it only computes a path. - pub fn path(memory_dir: &Path, name: &str) -> PathBuf { - let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); - let clean: String = format!("{slug}.md") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '.' || c == '-' { - c - } else { - '-' - } - }) - .collect(); - let clean = clean.trim_start_matches('.').to_string(); - memory_dir.join(if clean.is_empty() { - "memory.md".to_string() - } else { - clean - }) - } -} diff --git a/apps/domain/src/cms/mod.rs b/apps/domain/src/cms/mod.rs deleted file mode 100644 index a0865f3..0000000 --- a/apps/domain/src/cms/mod.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Domain layer for CMS — pure entities, value objects, repository traits, -//! and service interfaces. -//! -//! This is the innermost layer of the Clean Architecture onion. It has **zero -//! infrastructure dependencies** — all I/O is expressed through repository -//! traits defined in [`repository`], and business operations through service -//! traits in [`service`]. -//! -//! ## Sub-modules -//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`) -//! - `conversation` — conversation entity + chat message model (re-exported from core) -//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`) -//! - `memory` — memory file model (`Memory`) -//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`) -//! - `repository` — trait definitions for all persistence adapters -//! - `service` — trait definitions for all application services -//! -//! ## Key Design Principle -//! Domain types are plain Rust structs with `serde` for serialisation. -//! They contain no I/O, no framework imports, and no side effects. - -pub mod app_config; -pub mod commands; -pub mod conversation; -pub mod edit_log; -pub mod error; -pub mod memory; -pub mod repository; -pub mod service; -pub mod settings; - -pub use app_config::AppConfig; -pub use app_config::ModelRole; -pub use app_config::ProviderConfig; -pub use commands::{NewMemory, SettingsPatch}; -pub use conversation::Conversation; -pub use edit_log::EditLog; -pub use edit_log::EditLogEntry; -pub use error::{RepositoryError, ServiceError}; -pub use memory::Memory; -pub use repository::AppConfigRepository; -pub use repository::ConversationRepository; -pub use repository::EditLogRepository; -pub use repository::MemoryRepository; -pub use repository::RewindBlobRepository; -pub use repository::SettingsRepository; -pub use service::ConversationService; -pub use service::MemoryService; -pub use service::SettingsService; -pub use settings::resolve_effective_model; -pub use settings::InternetMode; -pub use settings::Settings; -pub use settings::SettingsFlags; diff --git a/apps/domain/src/cms/repository.rs b/apps/domain/src/cms/repository.rs deleted file mode 100644 index 7ecee7e..0000000 --- a/apps/domain/src/cms/repository.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Repository traits — pure abstraction boundaries for persistence. -//! -//! Each trait defines load / save / query operations that infrastructure -//! adapters implement. The domain and application layers depend **only** -//! on these traits, never on concrete persistence implementations. -//! -//! ## Traits -//! - `SettingsRepository` — load/save `Settings` from/to a base directory -//! - `AppConfigRepository` — load/save `AppConfig` from/to a base directory -//! - `ConversationRepository` — load/save `Conversation` from/to a session directory -//! - `MemoryRepository` — list/load/save/delete `Memory` entries -//! - `RewindBlobRepository` — store/retrieve/list binary blobs per session -//! - `EditLogRepository` — open/append/query edit log entries per session -//! -//! ## Dependency Inversion -//! Application services accept these traits as generic type parameters, -//! allowing the composition root to inject concrete implementations -//! (file-based, SQLite-backed, etc.) without changing business logic. - -use std::path::Path; - -use super::app_config::AppConfig; -use super::conversation::Conversation; -use super::edit_log::{EditLog, EditLogEntry}; -use super::error::RepositoryError; -use super::memory::Memory; -use super::settings::Settings; - -/// Persistence contract for `Settings` (application settings model). -/// -/// Implementors provide the actual I/O logic (e.g. file-based JSON storage). -pub trait SettingsRepository { - /// Load `Settings` from the given base directory. - fn load(&self, base_dir: &Path) -> Result; - - /// Persist `Settings` to the given base directory. - fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>; -} - -/// Persistence contract for `AppConfig` (provider and model configuration). -/// -/// Implementors provide the actual I/O logic (e.g. file-based JSON storage). -pub trait AppConfigRepository { - /// Load `AppConfig` from the given base directory. - fn load(&self, base_dir: &Path) -> Result; - - /// Persist `AppConfig` to the given base directory. - fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>; -} - -/// Persistence contract for `Conversation` (session conversation data). -/// -/// Implementors provide the actual I/O logic (e.g. file-based JSON storage). -pub trait ConversationRepository { - /// Load a `Conversation` from the given session directory. - fn load(&self, session_dir: &Path) -> Result; - - /// Persist a `Conversation` to the given session directory. - fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>; -} - -/// Persistence contract for `Memory` (long-term agent memory entries). -/// -/// Implementors provide the actual I/O logic (e.g. per-memory markdown files). -pub trait MemoryRepository { - /// List all memory slugs (filenames without extension) in the memory directory. - fn list(&self, memory_dir: &Path) -> Result, RepositoryError>; - - /// Load a single `Memory` by name from the memory directory. - fn load(&self, memory_dir: &Path, name: &str) -> Result; - - /// Save (create or overwrite) a `Memory` in the memory directory. - fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>; - - /// Delete a `Memory` by name from the memory directory. - fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>; -} - -/// Persistence contract for rewind-snapshot binary blobs. -/// -/// Blobs are keyed by an arbitrary caller-supplied key (e.g. a tool-call ID) -/// within a session. They capture file snapshots for the "rewind" feature. -pub trait RewindBlobRepository { - /// Store (or overwrite) a binary blob under `blob_key` for this session. - fn store_blob( - &self, - session_dir: &Path, - blob_key: &str, - data: &[u8], - mime_type: Option<&str>, - ) -> Result<(), RepositoryError>; - - /// Retrieve a blob's raw bytes by key, or `None` if not found. - fn retrieve_blob( - &self, - session_dir: &Path, - blob_key: &str, - ) -> Result>, RepositoryError>; - - /// List all blob keys for this session, ordered oldest-first. - fn list_blob_keys(&self, session_dir: &Path) -> Result, RepositoryError>; -} - -/// Persistence contract for `EditLog` (append-only file mutation log). -/// -/// Implementors manage an append-only log of `EditLogEntry` items per session, -/// typically persisted to a file for audit and potential undo. -pub trait EditLogRepository { - /// Open (or initialise) the edit log for a session directory. - fn open(&self, session_dir: &Path) -> Result; - - /// Append one entry to the log and persist immediately (write-through). - fn append( - &self, - session_dir: &Path, - log: &mut EditLog, - entry: EditLogEntry, - ) -> Result<(), RepositoryError>; - - /// Return a cloned copy of all in-memory entries for inspection. - fn entries(&self, log: &EditLog) -> Vec; -} diff --git a/apps/domain/src/cms/service.rs b/apps/domain/src/cms/service.rs deleted file mode 100644 index cf451d9..0000000 --- a/apps/domain/src/cms/service.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Service trait definitions — use-case boundaries for CMS operations. -//! -//! These traits define the public API of the application use-cases. -//! They are implemented by concrete types in the `application` layer -//! and consumed by infrastructure adapters (HTTP handlers, CLI commands). -//! -//! ## Traits -//! - `SettingsService` — load/save settings, update provider config -//! - `ConversationService` — load/save conversations, add messages -//! - `MemoryService` — list/save/delete session memories -//! -//! ## Dependency Inversion -//! Application service implementations accept repository traits as generic -//! type parameters. Infrastructure adapters depend only on these service -//! traits, never on concrete implementations. - -use super::conversation::{ChatMessage, Conversation}; -use super::error::ServiceError; -use super::memory::Memory; -use super::settings::Settings; - -/// Use-cases for application settings. -pub trait SettingsService { - /// Load the current `Settings` from the default store location. - fn load_settings(&self) -> Result; - - /// Persist updated `Settings` to the default store location. - fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>; - - /// Update (or insert) a provider configuration entry. - fn update_provider( - &self, - name: &str, - config: &super::app_config::ProviderConfig, - ) -> Result<(), ServiceError>; -} - -/// Use-cases for conversation (session message) management. -pub trait ConversationService { - /// Load a `Conversation` for the given session ID. - fn load_conversation(&self, session_id: &str) -> Result; - - /// Persist a `Conversation` to its session storage. - fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>; - - /// Append a single `ChatMessage` to the conversation and persist. - fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>; -} - -/// Use-cases for long-term memory management. -pub trait MemoryService { - /// List all memory slugs (filenames without extension). - fn list_memories(&self) -> Result, ServiceError>; - - /// Save (create or overwrite) a `Memory`. - fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>; - - /// Delete a `Memory` by its slug/name. - fn delete_memory(&self, name: &str) -> Result<(), ServiceError>; -} diff --git a/apps/domain/src/cms/settings.rs b/apps/domain/src/cms/settings.rs deleted file mode 100644 index 94ab5b6..0000000 --- a/apps/domain/src/cms/settings.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Pure domain entity for application settings. -//! -//! Defines `Settings` (top-level user configuration), `SettingsFlags` -//! (grouped boolean toggles), and `InternetMode` (network access level). -//! Serialised to `settings.json` by the infrastructure layer. -//! -//! # Architecture -//! This is a pure data structure with **no I/O logic**. Load/save -//! responsibilities live in `SettingsRepository` (domain::repository). -//! -//! ## Settings Fields -//! - `internet_mode` — network access policy (Off / ReadOnly / Full) -//! - `provider` / `model` — default LLM provider and model name -//! - `api_keys` — per-provider API key overrides (name → key) -//! - `max_tokens` / `temperature` — generation parameter defaults -//! - `review_max_lessons_per_run` — max lessons per auto-review pass -//! - `verify_command` — optional shell command to run for verification -//! - `workflow_max_concurrency` — max parallel hive-mind nodes -//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration -//! - `flags` — grouped boolean feature toggles - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use super::app_config::AppConfig; - -/// Controls how much network access the agent is permitted during a session. -/// -/// ## Variants -/// - `Off` — no network access -/// - `ReadOnly` — HTTP GET / HEAD only -/// - `Full` — any HTTP method permitted -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum InternetMode { - /// No network access permitted. - #[default] - Off, - /// HTTP GET / HEAD requests only. - ReadOnly, - /// Any HTTP method permitted. - Full, -} - -/// Grouped boolean feature toggles for the application. -/// -/// Kept as a separate struct to avoid clippy's -/// `default-too-many-fields` threshold on `Settings`. -/// -/// ## Fields -/// - `review_enabled` — enable automatic inline review after edits -/// - `session_archive_enabled` — enable periodic session archiving -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SettingsFlags { - pub review_enabled: bool, - pub session_archive_enabled: bool, -} - -impl Default for SettingsFlags { - /// Returns the default flags with all features enabled. - fn default() -> Self { - Self { - review_enabled: true, - session_archive_enabled: true, - } - } -} - -/// Returns the default hive-mind node timeout (600 seconds). -fn default_hive_mind_node_timeout_ms() -> u64 { - 600_000 -} - -/// Top-level application settings model. -/// -/// Serialised to `settings.json` by the infrastructure persistence layer. -/// Holds LLM provider selection, generation parameters, feature flags, -/// and workflow configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { - pub internet_mode: InternetMode, - pub provider: String, - pub model: String, - pub api_keys: HashMap, - pub max_tokens: Option, - pub temperature: Option, - pub review_max_lessons_per_run: usize, - pub adaptive_review_max_skip: u32, - pub verify_command: Option, - pub verify_timeout_ms: u64, - pub workflow_max_concurrency: usize, - #[serde(flatten)] - pub flags: SettingsFlags, - #[serde(default = "default_hive_mind_node_timeout_ms")] - pub hive_mind_node_timeout_ms: u64, -} - -impl Default for Settings { - fn default() -> Self { - Self { - internet_mode: InternetMode::Off, - provider: "zen".to_string(), - model: "deepseek-v4-flash-free".to_string(), - api_keys: HashMap::new(), - max_tokens: None, - temperature: None, - review_max_lessons_per_run: 5, - adaptive_review_max_skip: 3, - verify_command: None, - verify_timeout_ms: 30_000, - workflow_max_concurrency: 5, - flags: SettingsFlags::default(), - hive_mind_node_timeout_ms: 600_000, - } - } -} - -/// Pick the effective model name for the main agent. -/// -/// When `settings.provider` is `"claude"` (auto-detected from -/// `~/.claude/settings.json`), the provider's `default_model` (or the -/// app-level `default_model`) wins over a possibly-stale persisted -/// `settings.model`. Otherwise the user's explicit `settings.model` is used. -/// -/// Why: the user's custom Claude endpoint (URL + API key from -/// `~/.claude/settings.json`) implies Opus as the model; a stale -/// `settings.json` (e.g. "deepseek-v4-flash-free") must not override it. -pub fn resolve_effective_model(settings: &Settings, app_config: &AppConfig) -> String { - if settings.provider == "claude" { - if let Some(m) = app_config - .providers - .get("claude") - .and_then(|p| p.default_model.clone()) - { - return m; - } - return app_config.default_model.clone(); - } - settings.model.clone() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cms::app_config::AppConfig; - - fn claude_app_config() -> AppConfig { - let mut cfg = AppConfig::default(); - cfg.providers.insert( - "claude".to_string(), - crate::cms::ProviderConfig { - api_base: "https://9router.example/v1".to_string(), - api_key_env: Some("ANTHROPIC_API_KEY".to_string()), - default_model: Some("claude-opus-5".to_string()), - default_api_key: Some("sk-test".to_string()), - }, - ); - cfg.default_provider = "claude".to_string(); - cfg.default_model = "claude-opus-5".to_string(); - cfg - } - - #[test] - fn claude_provider_uses_opus_model_over_stale_settings_model() { - let settings = Settings { - provider: "claude".to_string(), - model: "deepseek-v4-flash-free".to_string(), // stale persisted - ..Settings::default() - }; - - let model = resolve_effective_model(&settings, &claude_app_config()); - assert_eq!(model, "claude-opus-5"); - } - - #[test] - fn non_claude_provider_uses_settings_model() { - let settings = Settings { - provider: "zen".to_string(), - model: "my-model".to_string(), - ..Settings::default() - }; - - let model = resolve_effective_model(&settings, &AppConfig::default()); - assert_eq!(model, "my-model"); - } - - #[test] - fn claude_falls_back_to_app_default() { - let settings = Settings { - provider: "claude".to_string(), - model: String::new(), - ..Settings::default() - }; - - let cfg = AppConfig::default(); - let model = resolve_effective_model(&settings, &cfg); - assert_eq!(model, cfg.default_model); - } -} diff --git a/apps/domain/src/core/conversation.rs b/apps/domain/src/core/conversation.rs deleted file mode 100644 index cd3e1da..0000000 --- a/apps/domain/src/core/conversation.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! In-memory conversation state: message history plus the system prompt and -//! model parameters used to drive the LLM. -//! -//! # Flow -//! -//! [`Conversation::new`] → [`push`](Conversation::push) to add messages → -//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM -//! API (system prompt prepended). -//! -//! # Components -//! -//! - `Conversation` — message vector + session metadata + generation params -//! - `push` / `rebuild_system` — mutation helpers -//! - `to_api_messages` — formats messages for API consumption -use serde::{Deserialize, Serialize}; - -use super::message::{ChatMessage, Role}; - -/// A single conversation's message history and generation settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Conversation { - /// Ordered list of chat messages (user, assistant, tool, system). - pub messages: Vec, - /// System prompt prepended at request time (see `to_api_messages`). - pub system_prompt: String, - /// Foreign key referencing the owning session. - pub session_id: String, - /// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`. - pub model: String, - /// Optional cap on output tokens. - pub max_tokens: Option, - /// Optional temperature (0.0 – 2.0). - pub temperature: Option, -} - -impl Conversation { - /// Create an empty conversation with the given system prompt and - /// session id, using default model/token/temperature settings. - pub fn new(system_prompt: String, session_id: String) -> Self { - Conversation { - messages: Vec::new(), - system_prompt, - session_id, - model: "anthropic/claude-opus-4-8".to_string(), - max_tokens: None, - temperature: None, - } - } - - /// Append a message to the conversation history. - pub fn push(&mut self, msg: ChatMessage) { - self.messages.push(msg); - } - - /// Replace the system prompt and strip any prior `System`-role - /// messages from history. - /// - /// Why: the system prompt is re-injected fresh at request time via - /// `to_api_messages`, so stale `System` messages in `self.messages` - /// would be redundant/conflicting if left in place. - pub fn rebuild_system(&mut self, new_prompt: String) { - self.system_prompt = new_prompt; - self.messages.retain(|m| !matches!(m.role, Role::System)); - } - - /// Build the message list to send to the LLM API, with the system - /// prompt prepended. - /// - /// Return: a new `Vec` (clone of history) with a synthesized system - /// message at index 0. - pub fn to_api_messages(&self) -> Vec { - let mut msgs = Vec::with_capacity(self.messages.len() + 1); - msgs.push(ChatMessage::system(&self.system_prompt)); - msgs.extend(self.messages.iter().cloned()); - msgs - } - - /// Number of messages in the conversation history (excluding the - /// synthesized system message). - pub fn len(&self) -> usize { - self.messages.len() - } - - /// Returns `true` if the conversation has no messages. - pub fn is_empty(&self) -> bool { - self.messages.is_empty() - } -} diff --git a/apps/domain/src/core/message.rs b/apps/domain/src/core/message.rs deleted file mode 100644 index b198602..0000000 --- a/apps/domain/src/core/message.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Chat message types shared across the entity layer. -//! -//! Provides [`Role`] (conversation participant) and [`ChatMessage`] (a single -//! message with optional tool-call metadata). Includes convenience constructors -//! for each role: `user`, `assistant`, `system`, `tool`/`tool_result`. -//! -//! # Flow -//! -//! Messages are constructed via the typed constructors → pushed into -//! [`Conversation`](super::conversation::Conversation) → serialized as JSON -//! to `conversation.json`. -use serde::{Deserialize, Serialize}; - -/// The conversation participant who authored a message. -/// -/// Variants: `User`, `Assistant`, `System`, `Tool`. Serialized as lowercase -/// strings (e.g. `"user"`, `"assistant"`, `"system"`, `"tool"`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum Role { - #[serde(rename = "user")] - User, - #[serde(rename = "assistant")] - Assistant, - #[serde(rename = "system")] - System, - #[serde(rename = "tool")] - Tool, -} - -impl Role { - /// Return the role as a lowercase string. - pub fn as_str(&self) -> &'static str { - match self { - Role::User => "user", - Role::Assistant => "assistant", - Role::System => "system", - Role::Tool => "tool", - } - } -} - -impl std::fmt::Display for Role { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// A single message in a conversation, compatible with the OpenAI/Anthropic -/// chat-completion API structures. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMessage { - /// Who sent this message (user, assistant, system, tool). - pub role: Role, - /// The message text content. `None` for assistant messages that only - /// contain tool calls. - pub content: Option, - /// Tool-call requests attached to an assistant message (OpenAI-style). - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - /// For tool-role messages: the `id` of the `ToolCall` being responded to. - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - /// Optional function name for the tool invocation. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -impl ChatMessage { - /// Build a user-role message with the given text content. - pub fn user(content: impl Into) -> Self { - ChatMessage { - role: Role::User, - content: Some(content.into()), - tool_calls: None, - tool_call_id: None, - name: None, - } - } - - /// Build an assistant-role message with an optional text response. - pub fn assistant(content: Option) -> Self { - ChatMessage { - role: Role::Assistant, - content, - tool_calls: None, - tool_call_id: None, - name: None, - } - } - - /// Build a system-role message with the given instruction text. - pub fn system(content: impl Into) -> Self { - ChatMessage { - role: Role::System, - content: Some(content.into()), - tool_calls: None, - tool_call_id: None, - name: None, - } - } - - /// Build a tool-role result message referencing a prior tool call. - pub fn tool(tool_call_id: String, content: String) -> Self { - ChatMessage { - role: Role::Tool, - content: Some(content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - name: None, - } - } - - /// Alias for `tool`, used throughout the codebase for tool results. - pub fn tool_result(tool_call_id: String, content: String) -> Self { - ChatMessage { - role: Role::Tool, - content: Some(content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - name: None, - } - } -} diff --git a/apps/domain/src/core/mod.rs b/apps/domain/src/core/mod.rs deleted file mode 100644 index 11715e4..0000000 --- a/apps/domain/src/core/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Core domain entities shared across the Zesdex application. -//! -//! Contains pure data structures for conversations, messages, tool calls, -//! usage statistics, provider API types, and store path configuration. -//! All types derive `Serialize`/`Deserialize` for JSON persistence. -//! -//! # Sub-modules -//! -//! - [`conversation`] — Ordered conversation (vector of `ChatMessage`) -//! - [`message`] — `ChatMessage` + `Role` enum -//! - [`provider`] — LLM provider API types: `ChatRequest`, `ChatResponse`, -//! `StreamEvent`, `SseParser`, `ToolDef`, etc. -//! - [`store`] — `Store` paths for data directories -//! - [`tool_call`] — `ToolCall` + `ToolFunction` (function-calling request) -//! - [`tool_result`] — `ToolCallResult` (function-calling response) -//! - [`usage`] — `UsageStats` (token counts, costs) - -pub mod conversation; -pub mod message; -pub mod provider; -pub mod store; -pub mod tool_call; -pub mod tool_result; -pub mod usage; - -pub use conversation::Conversation; -pub use message::{ChatMessage, Role}; -pub use provider::{ - ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage, - ToolDef, ToolFunctionDef, -}; -pub use store::Store; -pub use tool_call::{ToolCall, ToolFunction}; -pub use tool_result::ToolCallResult; -pub use usage::UsageStats; diff --git a/apps/domain/src/core/provider.rs b/apps/domain/src/core/provider.rs deleted file mode 100644 index 963bc74..0000000 --- a/apps/domain/src/core/provider.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Provider-facing DTOs: chat completion request, response, streaming types, -//! and the SSE stream parser. -//! -//! # Flow -//! -//! 1. **Request** — [`ChatRequest`] is built with model, messages, tools, -//! streaming options and sent to the LLM provider. -//! 2. **Response** — Non-streaming responses arrive as [`ChatResponse`] with -//! [`Choice`]s containing the full [`ChatMessage`](super::message::ChatMessage). -//! 3. **Streaming** — SSE chunks are fed into [`SseParser::feed`] which yields -//! [`StreamEvent`]s: token/text, reasoning, tool-call deltas, usage, done. -//! -//! # Components -//! -//! - `ChatRequest` / `StreamOptions` / `ToolDef` / `ToolFunctionDef` — outbound -//! - `ChatResponse` / `Choice` / `Delta` / `TokenUsage` — non-streaming inbound -//! - `StreamEvent` — one atomic streaming event (Token, Reasoning, -//! ToolCallDelta, Usage, Done, Error) -//! - `SseParser` — incremental SSE frame parser: `feed()` → `Vec` -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tracing; - -// --------------------------------------------------------------------------- -// Chat request / response -// --------------------------------------------------------------------------- - -/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible -/// provider. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatRequest { - /// Model identifier, e.g. `"anthropic/claude-opus-4-8"`. - pub model: String, - /// Full message history (system + user + assistant + tool turns). - pub messages: Vec, - /// Maximum number of output tokens. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - /// Sampling temperature (0.0 – 2.0). - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - /// Tool definitions available to the model. - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - /// Controls which (if any) function is called by the model. - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - /// Whether to use SSE streaming (`true`) or a single response. - #[serde(skip_serializing_if = "Option::is_none")] - pub stream: Option, - /// Nucleus sampling threshold. - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - /// Sequences where the model should stop generation. - #[serde(skip_serializing_if = "Option::is_none")] - pub stop: Option>, - /// Additional streaming options (e.g. `include_usage`). - #[serde(skip_serializing_if = "Option::is_none")] - pub stream_options: Option, -} - -/// Streaming options for the request; `include_usage` asks the provider to -/// emit a final usage chunk in the SSE stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StreamOptions { - pub include_usage: bool, -} - -/// Wire format for a single tool definition sent to the provider. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDef { - /// The tool type discriminator, e.g. `"function"`. - #[serde(rename = "type")] - pub type_: String, - /// The function definition (name, description, JSON schema). - pub function: ToolFunctionDef, -} - -/// Name, description, and JSON schema parameters for a tool definition. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolFunctionDef { - /// The function name the model may invoke. - pub name: String, - /// Human-readable description of what the function does. - pub description: String, - /// JSON Schema object describing the expected arguments. - pub parameters: Value, -} - -/// Non-streaming chat completion response returned by the provider. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatResponse { - /// Unique response identifier from the provider. - pub id: String, - /// Object type, e.g. `"chat.completion"`. - pub object: Option, - /// Model identifier that produced this response. - pub model: String, - /// One or more completion candidates. - pub choices: Vec, - /// Token usage statistics (prompt, completion, total). - pub usage: Option, - /// Unix-timestamp of response creation. - pub created: Option, -} - -/// One completion candidate within a `ChatResponse.choices` list. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Choice { - /// Zero-based index of this choice in the candidate list. - pub index: u32, - /// Full message (non-streaming response). - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, - /// Incremental delta (streaming response). - #[serde(skip_serializing_if = "Option::is_none")] - pub delta: Option, - /// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc. - #[serde(skip_serializing_if = "Option::is_none")] - pub finish_reason: Option, -} - -/// Incremental delta emitted in a streaming SSE chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Delta { - /// Role being set for the first streaming chunk. - #[serde(skip_serializing_if = "Option::is_none")] - pub role: Option, - /// Incremental text content delta. - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - /// Incremental tool-call delta (partial name/arguments). - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -/// Token counts and optional cost breakdown for a single completion request. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TokenUsage { - /// Tokens consumed by the prompt (input). - pub prompt_tokens: u32, - /// Tokens consumed by the completion (output). - pub completion_tokens: u32, - /// Sum of prompt + completion tokens. - pub total_tokens: u32, - /// Estimated cost for prompt tokens (provider-specific). - #[serde(skip_serializing_if = "Option::is_none")] - pub prompt_tokens_cost: Option, - /// Estimated cost for completion tokens (provider-specific). - #[serde(skip_serializing_if = "Option::is_none")] - pub completion_tokens_cost: Option, -} - -// --------------------------------------------------------------------------- -// SSE streaming -// --------------------------------------------------------------------------- - -/// One atomic event extracted from an LLM streaming response stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum StreamEvent { - /// An incremental text token. - Token(String), - /// An incremental reasoning token (Anthropic `reasoning_content`). - Reasoning(String), - /// An incremental tool-call delta (partial ID, name, or arguments). - ToolCallDelta { - /// Tool-call index (multiple calls in one response). - index: usize, - /// Optional tool-call ID (usually in the first delta for a call). - id: Option, - /// Optional function name (usually in the first delta for a call). - name: Option, - /// Partial JSON arguments delta for this tool call. - arguments_delta: String, - }, - /// Final usage chunk with token counts. - Usage { - prompt_tokens: u64, - completion_tokens: u64, - total_tokens: u64, - }, - /// Stream complete (all tokens have been delivered). - Done, - /// A stream-level error occurred. - Error(String), -} - -/// Buffered SSE frame parser that accumulates raw `data:` lines and -/// flushes a `StreamEvent` on each blank-line boundary. -pub struct SseParser { - /// Leftover bytes from the last chunk that did not end with `\n`. - buffer: String, - /// The current `event:` type (set by `event:` lines, cleared on flush). - event_type: Option, - /// Accumulated `data:` lines for the current event frame. - data_lines: Vec, -} - -impl SseParser { - /// Create a new parser with an empty buffer. - pub fn new() -> Self { - SseParser { - buffer: String::new(), - event_type: None, - data_lines: Vec::new(), - } - } - - /// Feed a raw SSE chunk and produce any completed events. - /// - /// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on - /// blank line, call `flush_event` to parse the accumulated data → - /// on `event:` line, store the event type → on `data:` line, append - /// to data accumulator → continue until buffer exhausted. - /// - /// Edge case: a chunk may split mid-line; the remainder stays in the - /// buffer for the next `feed()` call. - /// - /// Return: all `StreamEvent`s completed by this chunk. - pub fn feed(&mut self, chunk: &str) -> Vec { - // Normalize \r\n and bare \r to \n for consistent line ending handling - let chunk = chunk.replace("\r\n", "\n").replace('\r', "\n"); - - // Prevent unbounded buffer growth for long lines without \n - const MAX_BUFFER_SIZE: usize = 1_048_576; // 1 MB - if self.buffer.len() + chunk.len() > MAX_BUFFER_SIZE { - tracing::warn!("SSE buffer exceeded maximum size, resetting"); - self.buffer.clear(); - self.event_type = None; - self.data_lines.clear(); - } - - self.buffer.push_str(&chunk); - let mut events = Vec::new(); - while let Some(line_end) = self.buffer.find('\n') { - let line = self.buffer[..line_end].trim_end_matches('\r').to_string(); - self.buffer = self.buffer[line_end + 1..].to_string(); - if line.is_empty() { - events.extend(self.flush_event()); - } else if let Some(ty) = line.strip_prefix("event:") { - // Handle both "event:foo" and "event: foo" - self.event_type = Some(ty.trim().to_string()); - } else if let Some(data) = line.strip_prefix("data:") { - let data = data.trim_start().to_string(); - self.data_lines.push(data); - } - } - events - } - - /// Flush the current buffered `data:` lines as one or more `StreamEvent`s. - /// - /// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse → - /// emit `Usage` if a usage object is present → else match `event_type` - /// ("message.stop", "message.delta", etc.) → extract content, - /// reasoning, tool-call deltas, or finish-reason from the delta - /// structure (supporting both Anthropic-style top-level delta and - /// OpenAI-style `choices` array). - /// - /// Return: 0, 1, or more `StreamEvent`s from the flushed frame. - fn flush_event(&mut self) -> Vec { - let data = self.data_lines.join("\n"); - self.data_lines.clear(); - let event_type = self.event_type.take().unwrap_or_default(); - if data.is_empty() || data == "[DONE]" { - if data == "[DONE]" { - return vec![StreamEvent::Done]; - } - return vec![]; - } - let value: Value = match serde_json::from_str(&data) { - Ok(v) => v, - Err(e) => { - tracing::warn!("[stream] failed to parse chunk: {}", e); - return vec![]; - } - }; - - let mut events = Vec::new(); - - if let Some(usage) = value.get("usage") { - if !usage.is_null() { - let prompt_tokens = usage - .get("prompt_tokens") - .and_then(Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!("[stream] prompt_tokens missing in usage chunk"); - 0 - }); - let completion_tokens = usage - .get("completion_tokens") - .and_then(Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!("[stream] completion_tokens missing in usage chunk"); - 0 - }); - let total_tokens = usage - .get("total_tokens") - .and_then(Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!("[stream] total_tokens missing in usage chunk"); - prompt_tokens + completion_tokens - }); - events.push(StreamEvent::Usage { - prompt_tokens, - completion_tokens, - total_tokens, - }); - } - } - - let mut other_events = match event_type.as_str() { - "message.stop" => vec![StreamEvent::Done], - "message.delta" | "" => { - let mut d_events = Vec::new(); - if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) { - if let Some(choices) = delta.as_array() { - if let Some(choice) = choices.first() { - if let Some(d) = choice.get("delta") { - // Content token - if let Some(content) = d.get("content").and_then(|c| c.as_str()) { - d_events.push(StreamEvent::Token(content.to_string())); - } - - // Reasoning token - if let Some(reasoning) = - d.get("reasoning_content").and_then(|r| r.as_str()) - { - d_events.push(StreamEvent::Reasoning(reasoning.to_string())); - } - - // Tool calls — iterate ALL entries, not just first() - if let Some(tool_calls) = - d.get("tool_calls").and_then(|tc| tc.as_array()) - { - const MAX_TOOL_CALLS: usize = 64; - for tc in tool_calls { - let raw_index = tc - .get("index") - .and_then(Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!( - "[stream] tool call delta missing index, \ - defaulting to 0" - ); - 0 - }); - // Clamp index to prevent out-of-bounds / memory exhaustion - let index = usize::try_from(raw_index) - .unwrap_or(0) - .min(MAX_TOOL_CALLS.saturating_sub(1)); - let id = tc - .get("id") - .and_then(|i| i.as_str()) - .map(std::string::ToString::to_string); - let name = tc - .get("function") - .and_then(|f| f.get("name")) - .and_then(|n| n.as_str()) - .map(std::string::ToString::to_string); - let args_delta = tc - .get("function") - .and_then(|f| f.get("arguments")) - .and_then(|a| a.as_str()) - .unwrap_or("") - .to_string(); - d_events.push(StreamEvent::ToolCallDelta { - index, - id, - name, - arguments_delta: args_delta, - }); - } - } - - // Finish reason - if let Some(reason) = - choice.get("finish_reason").and_then(|r| r.as_str()) - { - if reason == "stop" || reason == "tool_calls" { - d_events.push(StreamEvent::Done); - } - } - } - } - } else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { - d_events.push(StreamEvent::Token(content.to_string())); - } - } - d_events - } - "content_block_delta" => { - let mut d_events = Vec::new(); - if let Some(delta) = value.get("delta") { - if let Some(content) = delta.get("text").and_then(|c| c.as_str()) { - d_events.push(StreamEvent::Token(content.to_string())); - } - if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) - { - d_events.push(StreamEvent::Reasoning(reasoning.to_string())); - } - } - d_events - } - _ => vec![], - }; - - events.append(&mut other_events); - events - } -} - -impl Default for SseParser { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/domain/src/core/store.rs b/apps/domain/src/core/store.rs deleted file mode 100644 index 1d2ef38..0000000 --- a/apps/domain/src/core/store.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Filesystem layout for zesdex's persistent and scratch data directories. -//! -//! # Flow -//! -//! [`Store::new`] resolves all paths from OS data dir / temp dir → -//! [`ensure_dirs`](Store::ensure_dirs) creates them on startup. -//! -//! # Components -//! -//! - `Store` — resolved path bundle (base, memory, scratch, images, downloads) -//! - `new` — path computation (no I/O) -//! - `ensure_dirs` — creates all directories if missing -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use tracing; - -/// Resolved paths for all data directories zesdex reads from and writes to. -/// -/// Why: centralizing path computation here means every consumer agrees on -/// where memory, scratch, session images, and downloads live. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Store { - /// Top-level data directory, e.g. `~/.local/share/zesdex`. - pub base_dir: PathBuf, - /// Temporary scratch root, usually under the OS temp dir. - pub scratch_root: PathBuf, - /// Directory for persistent memory files (`.md` summaries). - pub memory_dir: PathBuf, - /// Directory for per-session image snapshots. - pub session_images_dir: PathBuf, - /// Directory for downloaded files. - pub download_dir: PathBuf, -} - -impl Store { - /// Compute the standard set of zesdex data directory paths. - /// - /// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir; - /// scratch root comes from the OS temp dir instead, since it's disposable. - /// - /// Why: paths are computed, not created — call `ensure_dirs` before use. - pub fn new() -> Self { - let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok().or_else(|| { - std::env::var("HOME") - .ok() - .map(|h| format!("{h}/.local/share")) - }) { - PathBuf::from(data_dir).join("zesdex") - } else { - PathBuf::from(".local/share/zesdex") - }; - let scratch = std::env::temp_dir().join("zesdex-scratch"); - Store { - memory_dir: base.join("memory"), - scratch_root: scratch, - session_images_dir: base.join("session-images"), - download_dir: base.join("downloads"), - base_dir: base, - } - } - - /// Create all store directories (base, memory, scratch, session images, - /// downloads) if missing. - /// - /// Return: `Err` on the first directory that fails to create. - pub fn ensure_dirs(&self) -> std::io::Result<()> { - tracing::debug!(base = %self.base_dir.display(), "ensuring store directories exist"); - std::fs::create_dir_all(&self.base_dir)?; - std::fs::create_dir_all(&self.memory_dir)?; - std::fs::create_dir_all(&self.scratch_root)?; - std::fs::create_dir_all(&self.session_images_dir)?; - std::fs::create_dir_all(&self.download_dir)?; - Ok(()) - } -} - -impl Default for Store { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/domain/src/core/tool_call.rs b/apps/domain/src/core/tool_call.rs deleted file mode 100644 index d9e0f49..0000000 --- a/apps/domain/src/core/tool_call.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Tool-call DTOs embedded in assistant chat messages. -//! -//! # Flow -//! -//! Provider response/stream carries `tool_calls` on an assistant message → -//! deserialized into [`ToolCall`]/[`ToolFunction`] → harness resolves the -//! function name against `all_tools()` and runs it after sanitizing arguments -//! via [`sanitize_tool_arguments`] (which handles string-encoded JSON, -//! control characters, and truncation). -//! -//! # Components -//! -//! - `ToolCall` — a single tool-invocation request (id + type + function) -//! - `ToolFunction` — function name + raw arguments Value -//! - `sanitize_tool_arguments` — normalizes argument shape, repairs truncation -//! - `repair_json` — closes unclosed strings/braces/brackets in truncated JSON -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tracing; - -/// A single tool-call request emitted by the model in an assistant message. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - /// Unique identifier for this tool call (referenced by `ToolCallResult`). - pub id: String, - /// Discriminator, e.g. `"function"`. - #[serde(rename = "type")] - pub type_: String, - /// The function to invoke (name + arguments). - pub function: ToolFunction, -} - -/// The function name and raw arguments payload for a `ToolCall`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolFunction { - /// The function/tool name to dispatch against. - pub name: String, - /// Arguments as a JSON Value (may be a string-encoded object before - /// `sanitize_tool_arguments` normalises it). - pub arguments: Value, -} - -/// Normalize tool-call arguments into a JSON object/value. -/// -/// Flow: some providers send `arguments` as a JSON-encoded string rather -/// than a nested object; if `args` is a string, attempt to parse it as -/// JSON. Objects and other value types pass through unchanged. -/// -/// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }` -/// instead of passing it through as a raw string, so tools that expect a -/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly -/// receiving a plain string value. -/// -/// Attempt to fix truncated JSON by closing open strings, braces and brackets. -pub fn sanitize_tool_arguments(args: &Value) -> Value { - match args { - Value::String(s) => { - // Attempt 1: direct parse. - if let Ok(v) = serde_json::from_str::(s) { - return v; - } - // Attempt 2: strip control chars (0x00-0x1F except \t, \n) - let cleaned: String = s - .chars() - .filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r') - .collect(); - if cleaned.len() != s.len() { - if let Ok(v) = serde_json::from_str::(&cleaned) { - tracing::warn!( - "tool argument contained control characters — stripped \ - and reparsed successfully", - ); - return v; - } - } - // Attempt 3: repair truncated JSON and retry. - let input = if cleaned.len() == s.len() { - s - } else { - &cleaned - }; - let repaired = repair_json(input); - match serde_json::from_str::(&repaired) { - Ok(v) => { - tracing::warn!("tool argument string was truncated — repaired successfully",); - v - } - Err(e2) => { - tracing::error!( - "tool argument is a JSON string but failed to parse. \ - Wrapping in object. Error: {}. Raw (first 200): {}", - e2, - s.chars().take(200).collect::(), - ); - serde_json::json!({"_raw": s, "_parse_error": e2.to_string()}) - } - } - } - obj @ Value::Object(_) => obj.clone(), - other => other.clone(), - } -} - -/// Repair truncated JSON by closing open strings, braces and brackets. -/// -/// Flow: single-pass character scan tracking string/escape state with a -/// LIFO stack for `{`/`[` → append missing `"`, `]`, `}` in the right -/// (reverse nesting) order. -pub fn repair_json(s: &str) -> String { - let mut stack: Vec = Vec::new(); - let mut in_string = false; - let mut prev_was_backslash = false; - let mut ends_with_unclosed_escape = false; - - for c in s.chars() { - if prev_was_backslash { - prev_was_backslash = false; - ends_with_unclosed_escape = false; - continue; - } - if c == '\\' && in_string { - prev_was_backslash = true; - ends_with_unclosed_escape = true; - continue; - } - ends_with_unclosed_escape = false; - if c == '"' { - in_string = !in_string; - continue; - } - if in_string { - continue; - } - match c { - '{' | '[' => stack.push(c), - '}' | ']' => { - stack.pop(); - } - _ => {} - } - } - - let mut result = s.to_string(); - if ends_with_unclosed_escape { - result.pop(); - } - if in_string { - result.push('"'); - } - for &opener in stack.iter().rev() { - match opener { - '{' => result.push('}'), - '[' => result.push(']'), - _ => {} - } - } - result -} diff --git a/apps/domain/src/core/tool_result.rs b/apps/domain/src/core/tool_result.rs deleted file mode 100644 index a384189..0000000 --- a/apps/domain/src/core/tool_result.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Record of one completed tool invocation, kept for transcript/history. -//! -//! # Flow -//! -//! Tool harness completes execution → creates [`ToolCallResult`] with output, -//! error flag, and wall-clock duration → appended to conversation history as -//! a `Tool`-role [`ChatMessage`](super::message::ChatMessage). -//! -//! # Components -//! -//! - `ToolCallResult` — tool name + output + error flag + duration -//! - `new` — convenience constructor -use serde::{Deserialize, Serialize}; - -/// Record of a completed tool invocation, kept for transcript/history. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallResult { - /// The `id` of the `ToolCall` this result responds to. - pub tool_call_id: String, - /// The name of the tool that was invoked. - pub tool_name: String, - /// The text output produced by the tool (or error message). - pub output: String, - /// Whether the tool exited with an error. - pub is_error: bool, - /// Wall-clock execution duration in milliseconds. - pub duration_ms: u64, -} - -impl ToolCallResult { - /// Create a new tool call result. - pub fn new( - tool_call_id: String, - tool_name: String, - output: String, - is_error: bool, - duration_ms: u64, - ) -> Self { - ToolCallResult { - tool_call_id, - tool_name, - output, - is_error, - duration_ms, - } - } -} diff --git a/apps/domain/src/core/usage.rs b/apps/domain/src/core/usage.rs deleted file mode 100644 index f9d842a..0000000 --- a/apps/domain/src/core/usage.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Token usage accounting shared by streaming and non-streaming responses. -//! -//! # Flow -//! -//! Accumulated across all LLM API calls in a session. Each response updates -//! the running totals; `last_*` fields capture the most recent call's values -//! for interpolation display. Persisted alongside other session metadata. -//! -//! # Components -//! -//! - `UsageStats` — cumulative token/latency counters -use serde::{Deserialize, Serialize}; - -/// Cumulative token/latency counters for a session, persisted alongside it. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -pub struct UsageStats { - /// Total tokens consumed as input (prompt). - pub tokens_in: u64, - /// Total tokens generated as output (completion). - pub tokens_out: u64, - /// Most recent call's input tokens (for live interpolation display). - #[serde(default)] - pub last_tokens_in: u64, - /// Most recent call's output tokens (for live interpolation display). - #[serde(default)] - pub last_tokens_out: u64, - /// Total number of LLM API calls made this session. - pub api_calls: u64, - /// Tokens consumed by auto-review subagent calls. - pub review_tokens: u64, - /// Total wall-clock time spent on LLM API calls (milliseconds). - pub total_ms: u64, -} - -impl UsageStats { - /// Create a new `UsageStats` with all counters zeroed. - pub fn new() -> Self { - Self::default() - } -} diff --git a/apps/domain/src/error.rs b/apps/domain/src/error.rs deleted file mode 100644 index f342acd..0000000 --- a/apps/domain/src/error.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Shared domain error types for the entire domain layer. -//! -//! Provides [`DomainError`] — a unified repository-level error enum used -//! by both the `auth` and `cms` modules (type-aliased as `RepositoryError` -//! in each module). This avoids a dependency on `thiserror` while still -//! giving callers distinct error variants to match on. -//! -//! # Flow -//! -//! Infrastructure adapters convert their native errors (I/O, serde, etc.) -//! into `DomainError` via `From` impls. Domain service layers wrap -//! `DomainError` in their own `ServiceError` enum via `From`. -//! -//! # Components -//! -//! - `DomainError` — 6 variants: `NotFound`, `Conflict`, `Io`, `Serde`, -//! `InvalidId`, `Other` -//! - `From` — converts I/O errors -//! - `From` — converts serialisation errors - -use std::fmt; - -/// Unified repository-level error for domain operations. -/// -/// Covers the common failure modes across all persistence adapters: -/// missing entities, conflicts, I/O failures, serialization errors, -/// invalid identifiers, and a catch-all `Other` variant. -#[derive(Debug)] -pub enum DomainError { - /// The requested entity was not found. - NotFound(String), - /// An operation failed due to a conflict (e.g. duplicate key). - Conflict(String), - /// An I/O error occurred during persistence. - Io(std::io::Error), - /// A serialization / deserialization error occurred. - Serde(String), - /// An identifier was rejected as invalid (e.g. path traversal). - InvalidId(String), - /// A generic / uncategorised error. - Other(String), -} - -impl fmt::Display for DomainError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - DomainError::NotFound(msg) => write!(f, "not found: {msg}"), - DomainError::Conflict(msg) => write!(f, "conflict: {msg}"), - DomainError::Io(err) => write!(f, "I/O error: {err}"), - DomainError::Serde(msg) => write!(f, "serialization error: {msg}"), - DomainError::InvalidId(msg) => write!(f, "invalid id: {msg}"), - DomainError::Other(msg) => write!(f, "{msg}"), - } - } -} - -impl std::error::Error for DomainError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - DomainError::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for DomainError { - fn from(err: std::io::Error) -> Self { - DomainError::Io(err) - } -} - -impl From for DomainError { - fn from(err: serde_json::Error) -> Self { - DomainError::Serde(err.to_string()) - } -} diff --git a/apps/domain/src/lib.rs b/apps/domain/src/lib.rs deleted file mode 100644 index 4a4cef3..0000000 --- a/apps/domain/src/lib.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! # Zesdex Domain Layer -//! -//! Pure domain entities, value objects, repository traits, and service traits -//! for the Zesdex application. This crate has **zero framework dependencies** -//! — it depends only on serialization (`serde`), timestamping (`chrono`), -//! identity (`uuid`), and a few other narrowly-scoped utilities. -//! -//! ## Architecture -//! -//! ```text -//! apps/domain -//! ├── core/ Shared domain entities (Conversation, Message, Provider, -//! │ Store, ToolCall, ToolResult, Usage) -//! ├── auth/ Authentication domain (Session, SessionId, SessionLock, -//! │ OAuth, commands, errors, repository/service traits) -//! ├── cms/ CMS domain (AppConfig, Conversation, EditLog, Memory, -//! │ Settings, commands, errors, repository/service traits) -//! └── error.rs Unified DomainError type -//! ``` -//! -//! ## Key Design Principle -//! -//! All types are pure Rust structs and enums with `serde` derives. No I/O, -//! no framework imports, no side effects. All persistence is expressed -//! through repository traits that infrastructure adapters implement. - -pub mod agent; -pub mod auth; -pub mod cms; -pub mod core; -pub mod error; -pub mod subagent; -pub mod workflow; - -// Re-export all public items from each module for ergonomic imports. -// Consumers can do `use zesdex_domain::*` for common types. -pub use auth::{ - IamSession, NewSession, OAuthConfig, OAuthRepository, OAuthService, OAuthToken, - RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session, SessionId, - SessionLock, SessionLockRepository, SessionRepository, SessionService, -}; -pub use cms::{ - AppConfig, AppConfigRepository, Conversation as CmsConversation, ConversationRepository, - ConversationService, EditLog, EditLogEntry, EditLogRepository, InternetMode, Memory, - MemoryRepository, MemoryService, ModelRole, NewMemory, ProviderConfig, - RepositoryError as CmsRepositoryError, ServiceError as CmsServiceError, Settings, - SettingsFlags, SettingsPatch, SettingsRepository, SettingsService, -}; -pub use core::{ - ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role, SseParser, Store, - StreamEvent, StreamOptions, TokenUsage, ToolCall, ToolCallResult, ToolDef, ToolFunction, - ToolFunctionDef, UsageStats, -}; -pub use error::DomainError; - -// Agent module top-level items (TurnEvent, SessionRuntime, etc.) -pub use agent::*; -// Sub-module items need explicit re-exports -pub use agent::defaults::*; -pub use agent::progress::AgentProgress; -pub use agent::prompt::{ - compaction_prompt, main_agent_prompt, main_agent_prompt_with_project_context, - subagent_directive, -}; -pub use subagent::*; -pub use workflow::*; diff --git a/apps/domain/src/subagent/mod.rs b/apps/domain/src/subagent/mod.rs deleted file mode 100644 index 0d1ce6f..0000000 --- a/apps/domain/src/subagent/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Subagent domain models. - -/// Access tier for subagent tool permissions. -/// -/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full` -/// includes everything in `Write`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AccessTier { - /// Read-only: search, read, glob, utility tools (no mutations). - Read, - /// Read + Write: above plus write, edit, delete, git, memory. - Write, - /// Full: above plus bash, shell, workflow, plan tools. - Full, -} diff --git a/apps/domain/src/workflow/mod.rs b/apps/domain/src/workflow/mod.rs deleted file mode 100644 index d112dda..0000000 --- a/apps/domain/src/workflow/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Workflow and Hive-mind domain models. - -/// A single phase in a parsed workflow script. -#[derive(Debug, Clone)] -pub struct WorkflowPhase { - pub name: String, - pub directive: String, -} - -/// A parsed workflow script with named phases. -#[derive(Debug, Clone)] -pub struct WorkflowScript { - pub name: String, - pub phases: Vec, -} - -/// A directive for a single processing node in the hive mind. -#[derive(Debug, Clone)] -pub struct NodeDirective { - pub directive: String, - pub access_tier: String, -} - -/// A cognitive cycle plan — ordered list of cycles, each containing -/// parallel node directives. -#[derive(Debug, Clone)] -pub struct CognitiveCyclePlan { - pub cycles: Vec>, -} - -/// A single cycle in a cognitive cycle plan — parallel node directives -/// executed together. -#[derive(Debug, Clone)] -pub struct CognitiveCycle { - pub index: u32, - pub directives: Vec, -} - -/// Output from a single hive-mind processing node after a cycle completes. -#[derive(Debug, Clone)] -pub struct NodeOutput { - pub id: String, - pub directive: String, - pub output: String, -} diff --git a/apps/gateway/src/bin/migrate.rs b/apps/gateway/src/bin/migrate.rs deleted file mode 100644 index 0633330..0000000 --- a/apps/gateway/src/bin/migrate.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Database migration binary. -//! -//! Scans all session directories and initializes or upgrades the SQLite -//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`. - -use std::path::Path; - -fn main() -> anyhow::Result<()> { - let store = zesdex_domain::core::Store::new(); - let sessions_dir = store.base_dir.join("sessions"); - - if !sessions_dir.exists() { - tracing::info!("No sessions directory found, nothing to migrate"); - return Ok(()); - } - - let mut migrated = 0u32; - let mut failed = 0u32; - - for entry in std::fs::read_dir(&sessions_dir)? { - let entry = entry?; - let path = entry.path(); - if !path.is_dir() { - continue; - } - - match migrate_session_msglog(&path) { - Ok(_) => { - migrated += 1; - tracing::info!("Migrated session: {:?}", path.file_name()); - } - Err(e) => { - failed += 1; - tracing::error!("Failed to migrate session {:?}: {e}", path.file_name()); - } - } - } - - tracing::info!("Migration complete: {migrated} succeeded, {failed} failed"); - if failed > 0 { - anyhow::bail!("{failed} session(s) failed to migrate"); - } - Ok(()) -} - -fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> { - let msglog_path = session_dir.join("messages.sqlite"); - - if let Some(parent) = msglog_path.parent() { - std::fs::create_dir_all(parent)?; - } - - let conn = rusqlite::Connection::open(&msglog_path)?; - conn.execute_batch("PRAGMA journal_mode = WAL;")?; - conn.execute_batch("PRAGMA busy_timeout = 5000;")?; - conn.execute_batch("PRAGMA foreign_keys = ON;")?; - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT, - tool_call_id TEXT, - tool_name TEXT, - tool_arguments TEXT, - created_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS archives ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL UNIQUE, - title TEXT, - model TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - message_count INTEGER DEFAULT 0, - token_count INTEGER DEFAULT 0, - summary TEXT - ); - CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); - CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); - CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at); - CREATE TABLE IF NOT EXISTS blobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - blob_key TEXT NOT NULL, - data BLOB NOT NULL, - mime_type TEXT, - created_at INTEGER NOT NULL, - UNIQUE(session_id, blob_key) - );", - )?; - - let version: i32 = conn - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap_or(0); - - if version < 1 { - conn.pragma_update(None, "user_version", 1)?; - } - if version < 2 { - conn.execute_batch( - "CREATE INDEX IF NOT EXISTS idx_messages_session_role ON messages(session_id, role);", - )?; - conn.pragma_update(None, "user_version", 2)?; - } - - Ok(()) -} diff --git a/apps/gateway/src/bin/seed.rs b/apps/gateway/src/bin/seed.rs deleted file mode 100644 index cd65ef5..0000000 --- a/apps/gateway/src/bin/seed.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Database seeder binary. -//! -//! Initialises the store directory structure and creates default -//! configuration files plus a seed session for development/testing. -//! Invoked as `cargo run --bin seed`. - -fn main() -> anyhow::Result<()> { - let store = zesdex_domain::core::Store::new(); - store.ensure_dirs()?; - - // Create default settings if not present - let settings_path = store.base_dir.join("settings.json"); - if !settings_path.exists() { - let settings = zesdex_domain::cms::Settings::default(); - let content = serde_json::to_string_pretty(&settings)?; - let tmp = store.base_dir.join("settings.json.tmp"); - std::fs::write(&tmp, content)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, settings_path)?; - tracing::info!("Default settings created"); - } else { - tracing::info!("Settings already exist, skipping"); - } - - // Create default app config if not present - let config_path = store.base_dir.join("app_config.json"); - if !config_path.exists() { - let config = zesdex_domain::cms::AppConfig::default(); - let content = serde_json::to_string_pretty(&config)?; - let tmp = store.base_dir.join("app_config.json.tmp"); - std::fs::write(&tmp, content)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, config_path)?; - tracing::info!("Default app_config created"); - } else { - tracing::info!("App config already exists, skipping"); - } - - // Create data directories - std::fs::create_dir_all(&store.memory_dir)?; - std::fs::create_dir_all(&store.session_images_dir)?; - tracing::info!("All store directories verified"); - - // Create a seed session - let session_id = uuid::Uuid::new_v4().to_string(); - let session = zesdex_domain::auth::Session::new(session_id.clone(), "Seed Session".to_string()); - // Persist via the session repository - use zesdex_domain::SessionRepository; - let repo = - zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new(); - repo.save_session(&store.base_dir, &session)?; - tracing::info!("Seed session created: id={session_id}"); - - Ok(()) -} diff --git a/apps/gateway/src/bin/test_load.rs b/apps/gateway/src/bin/test_load.rs deleted file mode 100644 index ddac4c8..0000000 --- a/apps/gateway/src/bin/test_load.rs +++ /dev/null @@ -1,20 +0,0 @@ -use zesdex_domain::cms::AppConfigRepository; -use zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository; - -fn main() { - let base_dir = dirs::home_dir().unwrap().join(".local/share/zesdex"); - let repo = JsonAppConfigRepository::new(); - let config = repo.load(&base_dir).unwrap(); - - println!("Providers:"); - for (k, v) in &config.providers { - println!(" - {} (default model: {:?})", k, v.default_model); - } - - println!("Default provider: {}", config.default_provider); - println!("Default model: {}", config.default_model); - println!("Model roles:"); - for (k, v) in &config.model_roles { - println!(" - {}: provider={}, model={}", k, v.provider, v.model); - } -} diff --git a/apps/gateway/src/bin/test_parse.rs b/apps/gateway/src/bin/test_parse.rs deleted file mode 100644 index 865eb58..0000000 --- a/apps/gateway/src/bin/test_parse.rs +++ /dev/null @@ -1,46 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeEnv { - #[serde(alias = "ANTHROPIC_BASE_URL")] - anthropic_base_url: Option, - #[serde(alias = "ANTHROPIC_API_KEY")] - anthropic_api_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeSettings { - env: Option, - #[serde(alias = "customModel", alias = "model")] - custom_model: Option, -} - -fn main() { - let path = dirs::home_dir() - .unwrap() - .join(".claude") - .join("settings.json"); - println!("Path: {:?}", path); - match std::fs::read_to_string(&path) { - Ok(content) => { - println!("File content length: {}", content.len()); - match serde_json::from_str::(&content) { - Ok(settings) => { - println!("Parsed successfully: {:?}", settings); - if let Some(env) = settings.env { - println!("Base URL: {:?}", env.anthropic_base_url); - println!("API Key: {:?}", env.anthropic_api_key); - } else { - println!("No env block"); - } - } - Err(e) => { - println!("Parse error: {}", e); - } - } - } - Err(e) => { - println!("Read error: {}", e); - } - } -} diff --git a/apps/gateway/src/lib.rs b/apps/gateway/src/lib.rs deleted file mode 100644 index 9b56a38..0000000 --- a/apps/gateway/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -//! Gateway library — provides shared utilities for the gateway binary. -//! The main entry point is in `main.rs`. diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs deleted file mode 100644 index 25717fd..0000000 --- a/apps/gateway/src/main.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Zesdex Gateway — main entry point. -//! -//! Assembles domain + application + infrastructure layers and dispatches -//! to the requested interface: TUI (default), daemon (background IPC), -//! API server (REST), WebSocket server, gRPC server, or Web frontend. -//! -//! CLI flags are parsed via clap; run with `--help` for details. - -use std::sync::Mutex; - -use clap::Parser; - -/// Zesdex — autonomous AI coding agent. -#[derive(Parser, Debug)] -#[command( - name = "zesdex", - version, - about = "Autonomous AI coding agent with TUI" -)] -struct Cli { - /// Run as background daemon with IPC socket - #[arg(long)] - daemon: bool, - - /// Attach TUI client to a running daemon session - #[arg(long)] - attach: Option, - - /// Run REST API server - #[arg(long)] - api: bool, - - /// REST API port - #[arg(long, default_value_t = 8080)] - api_port: u16, - - /// Run WebSocket server - #[arg(long)] - ws: bool, - - /// WebSocket port - #[arg(long, default_value_t = 8081)] - ws_port: u16, - - /// Run gRPC server - #[arg(long)] - grpc: bool, - - /// gRPC port - #[arg(long, default_value_t = 50051)] - grpc_port: u16, - - /// Serve web frontend - #[arg(long)] - web: bool, - - /// Web frontend port - #[arg(long, default_value_t = 3000)] - web_port: u16, -} - -fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - // ── Setup logging ──────────────────────────────────────────────────── - let log_dir = dirs::data_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("zesdex"); - let _ = std::fs::create_dir_all(&log_dir); - let log_path = log_dir.join("zesdex.log"); - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .unwrap_or_else(|_| { - std::fs::OpenOptions::new() - .write(true) - .open("/dev/null") - .expect("cannot open /dev/null") - }); - - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_writer(Mutex::new(log_file)) - .init(); - - tracing::info!("zesdex gateway starting"); - - // ── Dispatch to interface ──────────────────────────────────────────── - // Validate mutually exclusive flags - let mode_count = [cli.daemon, cli.api, cli.ws, cli.grpc, cli.web] - .iter() - .filter(|&&b| b) - .count() - + if cli.attach.is_some() { 1 } else { 0 }; - - if mode_count > 1 { - anyhow::bail!( - "Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive" - ); - } - - if cli.daemon { - tracing::info!("starting in daemon mode"); - zesdex_daemon::server::run_daemon()?; - } else if let Some(session_id) = cli.attach { - tracing::info!("starting in attach mode for session {session_id}"); - zesdex_daemon::client::run_attach(&session_id)?; - } else if cli.api { - tracing::info!("starting in API server mode"); - run_api_server(cli.api_port)?; - } else if cli.ws { - tracing::info!("starting in WebSocket server mode"); - run_ws_server(cli.ws_port)?; - } else if cli.grpc { - tracing::info!("starting in gRPC server mode"); - run_grpc_server(cli.grpc_port)?; - } else if cli.web { - tracing::info!("starting in web server mode"); - run_web_server(cli.web_port)?; - } else { - // Default: run TUI single-process mode - tracing::info!("starting in TUI single-process mode"); - run_tui_single_process()?; - } - - Ok(()) -} - -/// Run the TUI in single-process mode (TUI + agent in one process). -fn run_tui_single_process() -> anyhow::Result<()> { - zesdex_tui::run_single_process() -} - -/// Run the REST API server. -fn run_api_server(port: u16) -> anyhow::Result<()> { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { - let store = zesdex_domain::core::Store::new(); - store.ensure_dirs()?; - - // Load JWT secret from environment variable with a secure default warning - let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| { - tracing::warn!( - "JWT_SECRET environment variable not set; using insecure default. \ - Set JWT_SECRET to a secure random value in production." - ); - zesdex_domain::agent::defaults::FALLBACK_JWT_SECRET.to_string() - }); - - let state = zesdex_api::ApiState::new( - store.base_dir.clone(), - jwt_secret, - "", - zesdex_domain::agent::defaults::DEFAULT_MODEL, - Some(zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()), - ); - let app = zesdex_api::build_router(state); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); - tracing::info!("REST API server listening on http://{addr}/api/v1/health"); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - Ok::<_, anyhow::Error>(()) - })?; - Ok(()) -} - -/// Run the WebSocket server. -fn run_ws_server(port: u16) -> anyhow::Result<()> { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { zesdex_ws::run_server(port).await })?; - Ok(()) -} - -/// Run the gRPC server. -fn run_grpc_server(port: u16) -> anyhow::Result<()> { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { zesdex_grpc::run_server(port).await })?; - Ok(()) -} - -/// Serve the web frontend. -fn run_web_server(port: u16) -> anyhow::Result<()> { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { zesdex_web::run_server(port, None).await })?; - Ok(()) -} diff --git a/apps/infrastructure/src/auth/jwt.rs b/apps/infrastructure/src/auth/jwt.rs deleted file mode 100644 index 182f001..0000000 --- a/apps/infrastructure/src/auth/jwt.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification. - -use serde::{Deserialize, Serialize}; - -/// Standard JWT claims with optional session binding. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JwtClaims { - pub sub: String, - pub exp: u64, - pub iat: u64, - /// Token purpose: `"access"` or `"refresh"`. - /// - /// Prevents an access token from being replayed as a refresh token - /// (which would otherwise extend a short-lived credential into the - /// 7-day refresh window). - #[serde(rename = "typ")] - pub token_type: TokenType, - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -/// JWT token purpose. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum TokenType { - Access, - Refresh, -} - -impl JwtClaims { - pub fn new(sub: String, exp: u64, token_type: TokenType, session_id: Option) -> Self { - let iat = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Self { - sub, - exp, - iat, - token_type, - session_id, - } - } -} - -/// Sign a set of claims into a JWT string using HS256. -pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result { - let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); - let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()); - let token = jsonwebtoken::encode(&header, &claims, &key)?; - Ok(token) -} - -/// Verify a JWT string and return its claims. -pub fn verify_token(secret: &str, token: &str) -> anyhow::Result { - let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); - validation.validate_exp = true; - validation.required_spec_claims = ["sub", "exp", "iat"] - .iter() - .map(|&s| s.to_string()) - .collect(); - - let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()); - let token_data = jsonwebtoken::decode::(token, &key, &validation)?; - Ok(token_data.claims) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn claims(exp_secs_from_now: u64, token_type: TokenType) -> JwtClaims { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - JwtClaims::new( - "user-1".to_string(), - now + exp_secs_from_now, - token_type, - None, - ) - } - - #[test] - fn access_and_refresh_tokens_roundtrip() { - let secret = "test-secret"; - let access = create_token(secret, claims(3600, TokenType::Access)).unwrap(); - let refresh = create_token(secret, claims(604800, TokenType::Refresh)).unwrap(); - - let acc = verify_token(secret, &access).unwrap(); - assert_eq!(acc.token_type, TokenType::Access); - - let refr = verify_token(secret, &refresh).unwrap(); - assert_eq!(refr.token_type, TokenType::Refresh); - } - - #[test] - fn token_type_is_distinct() { - let secret = "test-secret"; - let access = create_token(secret, claims(3600, TokenType::Access)).unwrap(); - let claims = verify_token(secret, &access).unwrap(); - assert_ne!(claims.token_type, TokenType::Refresh); - } - - #[test] - fn expired_token_is_rejected() { - let secret = "test-secret"; - // exp well in the past (beyond the library's default 60s leeway) → - // verification must fail. - let past = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - .saturating_sub(120); - let expired = JwtClaims::new("user-1".to_string(), past, TokenType::Access, None); - let token = create_token(secret, expired).unwrap(); - assert!(verify_token(secret, &token).is_err()); - } -} diff --git a/apps/infrastructure/src/auth/mod.rs b/apps/infrastructure/src/auth/mod.rs deleted file mode 100644 index 2d228f8..0000000 --- a/apps/infrastructure/src/auth/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Auth service implementations: JWT signing/verification, Argon2 password -//! hashing, and OAuth loopback server. - -pub mod jwt; -pub mod oauth_loopback; -pub mod password; diff --git a/apps/infrastructure/src/auth/oauth_loopback.rs b/apps/infrastructure/src/auth/oauth_loopback.rs deleted file mode 100644 index 0e579bd..0000000 --- a/apps/infrastructure/src/auth/oauth_loopback.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects. - -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; - -/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth -/// `?code=...` redirect and serves back a static confirmation page. -pub struct LoopbackServer { - listener: TcpListener, - port: u16, -} - -impl LoopbackServer { - pub fn bind() -> std::io::Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - Ok(LoopbackServer { listener, port }) - } - - pub fn redirect_uri(&self) -> String { - format!("http://127.0.0.1:{}/callback", self.port) - } - - pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result { - let (mut stream, _) = self.listener.accept()?; - stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?; - Self::read_callback(&mut stream, expected_state) - } - - fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result { - let mut buf = [0u8; 4096]; - let n = stream.read(&mut buf)?; - let request = String::from_utf8_lossy(&buf[..n]); - let code = Self::extract_code(&request); - let state = Self::extract_state(&request); - let state_ok = state.as_deref() == Some(expected_state); - let response = match (code.as_ref(), state_ok) { - (Some(_), true) => { - "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\ - Authorization complete. You may close this tab." - } - (Some(_), false) => { - "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\ - State mismatch — possible CSRF attack." - } - (None, _) => { - "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\ - Missing authorization code." - } - }; - if let Err(e) = stream.write_all(response.as_bytes()) { - tracing::warn!("OAuth loopback write error: {e}"); - } - if let Err(e) = stream.flush() { - tracing::warn!("OAuth loopback flush error: {e}"); - } - if !state_ok { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "state mismatch", - )); - } - code.ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "code not found in callback", - ) - }) - } - - fn extract_code(request: &str) -> Option { - let line = request.lines().next()?; - let path = line.split(' ').nth(1)?; - let query = path.split('?').nth(1)?; - for pair in query.split('&') { - let mut parts = pair.splitn(2, '='); - if parts.next()? == "code" { - return parts.next().map(urlencoding); - } - } - None - } - - fn extract_state(request: &str) -> Option { - let line = request.lines().next()?; - let path = line.split(' ').nth(1)?; - let query = path.split('?').nth(1)?; - for pair in query.split('&') { - let mut parts = pair.splitn(2, '='); - if parts.next()? == "state" { - return parts.next().map(urlencoding); - } - } - None - } -} - -/// Percent-decode a string (e.g. `%20` -> space). -fn urlencoding(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '%' { - match ( - chars.next().and_then(|c| c.to_digit(16)), - chars.next().and_then(|c| c.to_digit(16)), - ) { - (Some(hi), Some(lo)) => { - let byte: u8 = (hi as u8) * 16 + lo as u8; - result.push(char::from(byte)); - } - _ => { - result.push('%'); - } - } - } else { - result.push(c); - } - } - result -} diff --git a/apps/infrastructure/src/auth/password.rs b/apps/infrastructure/src/auth/password.rs deleted file mode 100644 index c966b98..0000000 --- a/apps/infrastructure/src/auth/password.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Argon2 password hashing and verification utilities. - -use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Argon2, -}; -use rand_core::OsRng; - -/// Hash a plaintext password using Argon2id with a random salt. -pub async fn hash_password(password: &str) -> anyhow::Result { - let password = password.to_string(); - tokio::task::spawn_blocking(move || { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - let hash = argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?; - Ok(hash.to_string()) - }) - .await - .map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))? -} - -/// Verify a plaintext password against a previously-hashed PHC string. -pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result { - let password = password.to_string(); - let hash = hash.to_string(); - tokio::task::spawn_blocking(move || { - let parsed_hash = PasswordHash::new(&hash) - .map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?; - let argon2 = Argon2::default(); - let valid = argon2 - .verify_password(password.as_bytes(), &parsed_hash) - .is_ok(); - Ok(valid) - }) - .await - .map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))? -} diff --git a/apps/infrastructure/src/best_practice/arch_audit.rs b/apps/infrastructure/src/best_practice/arch_audit.rs deleted file mode 100644 index 2f9ecc1..0000000 --- a/apps/infrastructure/src/best_practice/arch_audit.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! Architecture layering audit for clean-architecture compliance. -//! -//! Scans Rust source files in the workspace and reports violations of the -//! dependency rule: domain must not import from outer layers, application -//! must not import from infrastructure or interfaces, etc. -//! -//! # Flow -//! -//! `audit_layering(workspace_dir)` → walk `apps/` → classify crate by path → -//! scan `use` statements → match against forbidden crates → collect violations. - -use anyhow::Result; -use ignore::Walk; -use std::path::Path; -use tracing::instrument; - -/// A single layering violation found during the audit. -#[derive(Debug, Clone)] -pub struct Violation { - /// Severity level. - pub severity: Severity, - /// Which crate layer caused the violation. - pub layer: &'static str, - /// File path relative to workspace root. - pub file: String, - /// Line number (1-indexed). - pub line: usize, - /// Human-readable description. - pub message: String, -} - -/// How severe a violation is. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Severity { - Error, - Warning, - Info, -} - -impl std::fmt::Display for Severity { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Severity::Error => write!(f, "ERROR"), - Severity::Warning => write!(f, "WARN"), - Severity::Info => write!(f, "INFO"), - } - } -} - -/// Result of an architecture audit. -#[derive(Debug, Clone)] -pub struct AuditReport { - /// All violations found, grouped by severity. - pub violations: Vec, - /// Number of source files scanned. - pub files_scanned: usize, -} - -impl AuditReport { - /// True if any ERROR-level violations exist. - pub fn has_errors(&self) -> bool { - self.violations - .iter() - .any(|v| v.severity == Severity::Error) - } - - /// Number of errors. - pub fn error_count(&self) -> usize { - self.violations - .iter() - .filter(|v| v.severity == Severity::Error) - .count() - } - - /// Number of warnings. - pub fn warning_count(&self) -> usize { - self.violations - .iter() - .filter(|v| v.severity == Severity::Warning) - .count() - } -} - -/// Classify a path into a clean-architecture layer name. -fn classify_layer(crate_path: &Path) -> Option<&'static str> { - let path_str = crate_path.to_string_lossy(); - if path_str.contains("/domain") || path_str.ends_with("/domain") { - Some("domain") - } else if path_str.contains("/application") || path_str.ends_with("/application") { - Some("application") - } else if path_str.contains("/infrastructure") || path_str.ends_with("/infrastructure") { - Some("infrastructure") - } else if path_str.contains("/interfaces/") { - Some("interfaces") - } else if path_str.contains("/gateway") { - Some("gateway") - } else { - None - } -} - -/// Forbidden import patterns per layer. -/// -/// Returns a list of crate prefixes that the given layer must NOT import. -fn forbidden_imports(layer: &str) -> &'static [&'static str] { - match layer { - "domain" => &[ - "zesdex_application", - "zesdex_infrastructure", - "zesdex_tui", - "zesdex_api", - "zesdex_daemon", - "zesdex_ws", - "zesdex_grpc", - "zesdex_web", - "zesdex_gateway", - "tokio", - "axum", - "reqwest", - "rusqlite", - "ratatui", - "crossterm", - "tower", - "tower_http", - "argon2", - "jsonwebtoken", - "rmcp", - "tiktoken_rs", - "syntect", - "pulldown_cmark", - "serde_yaml_ng", - "ignore", - "dom_smoothie", - "fast_html2md", - "scraper", - "clap", - ], - "application" => &[ - "zesdex_infrastructure", - "zesdex_tui", - "zesdex_api", - "zesdex_daemon", - "zesdex_ws", - "zesdex_grpc", - "zesdex_web", - "zesdex_gateway", - ], - "infrastructure" => &[ - "zesdex_tui", - "zesdex_api", - "zesdex_daemon", - "zesdex_ws", - "zesdex_grpc", - "zesdex_web", - "zesdex_gateway", - ], - _ => &[], - } -} - -/// Scan a single Rust source file for forbidden imports. -fn scan_file(file_path: &Path, layer: &'static str, root: &Path) -> Vec { - let mut violations = Vec::new(); - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => return violations, - }; - - let forbidden = forbidden_imports(layer); - if forbidden.is_empty() { - return violations; - } - - let relative = file_path - .strip_prefix(root) - .unwrap_or(file_path) - .to_string_lossy() - .to_string(); - - for (line_num, line) in content.lines().enumerate() { - let trimmed = line.trim(); - - // Match: `use zesdex_application::...` or `use zesdex_infrastructure::...` - if trimmed.starts_with("use ") { - for &forbidden in forbidden { - let pattern = format!("use {forbidden}"); - if trimmed.starts_with(&pattern) || trimmed.starts_with("use crate::") { - // `use crate::` in domain could reference domain-only items — skip. - continue; - } - if trimmed.starts_with(&pattern) - || trimmed.starts_with(&format!("use {forbidden}::")) - { - // Skip test code — test modules commonly import outer layers. - let is_test = - content[..content.len().saturating_sub(1)].contains("#[cfg(test)]"); - if is_test { - continue; - } - - violations.push(Violation { - severity: Severity::Error, - layer, - file: relative.clone(), - line: line_num + 1, - message: format!( - "Layer '{layer}' must not depend on '{forbidden}': {trimmed}" - ), - }); - } - } - } - } - - violations -} - -/// Run a full architecture layering audit on the workspace at `root`. -/// -/// Walks all `.rs` files under `root/apps/`, classifies each by its parent -/// crate, and checks `use` statements against the dependency rule. -#[instrument(skip(root))] -pub fn audit_layering(root: &Path) -> Result { - let apps_dir = root.join("apps"); - if !apps_dir.is_dir() { - return Ok(AuditReport { - violations: vec![Violation { - severity: Severity::Warning, - layer: "workspace", - file: "apps/".to_string(), - line: 0, - message: format!("apps/ directory not found at {}", apps_dir.display()), - }], - files_scanned: 0, - }); - } - - let mut violations = Vec::new(); - let mut files_scanned = 0; - - for entry in Walk::new(&apps_dir).flatten() { - if entry.file_type().is_none_or(|ft| !ft.is_file()) { - continue; - } - let path = entry.path(); - if path.extension().is_none_or(|e| e != "rs") { - continue; - } - - // Determine which crate this file belongs to by walking up. - let layer = path.ancestors().skip(1).find_map(|p| classify_layer(p)); - - if let Some(layer) = layer { - files_scanned += 1; - violations.extend(scan_file(path, layer, root)); - } - } - - Ok(AuditReport { - violations, - files_scanned, - }) -} - -/// Count lines of code and nesting depth in a Rust source file. -pub fn check_function_metrics(content: &str) -> Vec { - let mut violations = Vec::new(); - - let mut in_function = false; - let mut fn_start = 0; - let mut fn_name = String::new(); - let mut brace_depth = 0; - let mut max_nesting = 0; - let mut current_nesting: i32 = 0; - - for (i, line) in content.lines().enumerate() { - let line_num = i + 1; - - // Track function entry. - if line.trim().starts_with("fn ") && line.trim().ends_with('{') { - in_function = true; - fn_start = line_num; - fn_name = line.trim().to_string(); - brace_depth = 1; - max_nesting = 0; - current_nesting = 0; - continue; - } - - if in_function { - for ch in line.chars() { - match ch { - '{' => { - brace_depth += 1; - current_nesting += 1; - max_nesting = max_nesting.max(current_nesting); - } - '}' => { - brace_depth -= 1; - current_nesting = (current_nesting.saturating_sub(1)).max(0); - if brace_depth == 0 { - // End of function — check metrics. - let fn_lines = line_num - fn_start; - if fn_lines > 40 { - violations.push(Violation { - severity: Severity::Warning, - layer: "code", - file: String::new(), - line: fn_start, - message: format!( - "Function too long: {} lines (max 40): {}", - fn_lines, fn_name - ), - }); - } - if max_nesting >= 4 { - violations.push(Violation { - severity: Severity::Warning, - layer: "code", - file: String::new(), - line: fn_start, - message: format!( - "Deep nesting (level {}) in: {}", - max_nesting, fn_name - ), - }); - } - in_function = false; - break; - } - } - _ => {} - } - } - } - } - - violations -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn domain_layer_forbidden_imports() { - let forbidden = forbidden_imports("domain"); - assert!(forbidden.contains(&"tokio")); - assert!(forbidden.contains(&"zesdex_application")); - assert!(forbidden.contains(&"axum")); - } - - #[test] - fn application_layer_forbidden_imports() { - let forbidden = forbidden_imports("application"); - assert!(forbidden.contains(&"zesdex_infrastructure")); - assert!(!forbidden.contains(&"tokio")); // tokio is allowed in application - } - - #[test] - fn classify_layer_works() { - let p = Path::new("/root/apps/domain/src/lib.rs"); - assert_eq!(classify_layer(p), Some("domain")); - - let p = Path::new("/root/apps/application/src/lib.rs"); - assert_eq!(classify_layer(p), Some("application")); - - let p = Path::new("/root/apps/gateway/src/main.rs"); - assert_eq!(classify_layer(p), Some("gateway")); - } - - #[test] - fn function_metrics_short_function_ok() { - let content = "fn ok() {\n let x = 1;\n}\n"; - let violations = check_function_metrics(content); - let long: Vec<_> = violations - .iter() - .filter(|v| v.message.contains("Function too long")) - .collect(); - assert!(long.is_empty(), "short function should not trigger"); - } - - #[test] - fn function_metrics_reports_long_function() { - let mut lines = String::from("fn long() {\n"); - for _ in 0..45 { - lines.push_str(" let _ = 1;\n"); - } - lines.push_str("}\n"); - let violations = check_function_metrics(&lines); - let long: Vec<_> = violations - .iter() - .filter(|v| v.message.contains("Function too long")) - .collect(); - assert!(!long.is_empty(), "long function should trigger warning"); - } -} diff --git a/apps/infrastructure/src/best_practice/code_quality.rs b/apps/infrastructure/src/best_practice/code_quality.rs deleted file mode 100644 index e6d9002..0000000 --- a/apps/infrastructure/src/best_practice/code_quality.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! Code-quality analysis for clean-code compliance. -//! -//! Scans Rust source files for common clean-code violations: -//! - Missing doc comments on `pub` items -//! - `.unwrap()` / `.expect()` in production (non-test) code -//! - `#[allow(...)]` / `#[expect(...)]` compiler bypasses -//! - Magic number literals (integer/float constants) -//! - Commented-out code blocks -//! -//! # Flow -//! -//! `scan_file(path, root)` → read source → classify lines into scopes → -//! match each rule → return `Vec`. - -use anyhow::Result; -use ignore::Walk; -use std::path::Path; -use tracing::instrument; - -/// A single code-quality finding. -#[derive(Debug, Clone)] -pub struct Finding { - pub severity: super::arch_audit::Severity, - pub rule: &'static str, - pub file: String, - pub line: usize, - pub message: String, -} - -/// Result of a code-quality scan. -#[derive(Debug, Clone)] -pub struct CodeQualityReport { - pub findings: Vec, - pub files_scanned: usize, -} - -impl CodeQualityReport { - pub fn has_errors(&self) -> bool { - self.findings - .iter() - .any(|f| f.severity == super::arch_audit::Severity::Error) - } - pub fn count_by_rule(&self) -> Vec<(&'static str, usize)> { - let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for f in &self.findings { - *counts.entry(f.rule).or_default() += 1; - } - let mut sorted: Vec<_> = counts.into_iter().collect(); - sorted.sort_by_key(|(_, c)| *c); - sorted.reverse(); - sorted - } -} - -/// Check if a line is inside a `#[cfg(test)]` block. -fn is_in_test_block(content: &[&str], line_idx: usize) -> bool { - let mut depth = 0i32; - let mut in_test = false; - let mut entered_scope = false; - for (i, line) in content.iter().enumerate() { - if i > line_idx { - break; - } - if line.contains("#[cfg(test)]") { - in_test = true; - } - depth += line.matches('{').count() as i32; - if in_test && depth > 0 { - entered_scope = true; - } - depth -= line.matches('}').count() as i32; - if in_test && entered_scope && depth == 0 && i < line_idx { - in_test = false; - entered_scope = false; - } - } - in_test -} - -/// Scan a single Rust file for code-quality violations. -pub fn scan_quality_file(file_path: &Path, root: &Path) -> Vec { - let mut findings = Vec::new(); - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => return findings, - }; - - let relative = file_path - .strip_prefix(root) - .unwrap_or(file_path) - .to_string_lossy() - .to_string(); - - let lines: Vec<&str> = content.lines().collect(); - - // Track pub items for doc-comment checking. - let mut prev_line_doc = false; - let mut prev_line_empty = false; - - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - let line_num = i + 1; - let in_test = is_in_test_block(&lines, i); - - // ── Rule: Compiler bypass ────────────────────────────────────── - if trimmed.starts_with("#[allow(") || trimmed.starts_with("#[expect(") { - // Skip if this is in the workspace lints config (Cargo.toml) - if trimmed.contains("clippy::") || trimmed.contains("dead_code") { - findings.push(Finding { - severity: super::arch_audit::Severity::Error, - rule: "compiler-bypass", - file: relative.clone(), - line: line_num, - message: format!("Compiler bypass attribute found: {trimmed}"), - }); - } - } - - // ── Rule: unwrap / expect in production code ─────────────────── - if !in_test { - if let Some(col) = trimmed.find(".unwrap(") { - // Allow unwrap in test code and in `#[]` attributes - if !trimmed.starts_with("//") { - let snippet = &trimmed[col..(col + 20).min(trimmed.len())]; - findings.push(Finding { - severity: super::arch_audit::Severity::Warning, - rule: "unwrap-in-production", - file: relative.clone(), - line: line_num, - message: format!( - "Use `?` or proper error handling instead of `.unwrap()`: {snippet}..." - ), - }); - } - } - if let Some(col) = trimmed.find(".expect(") { - if !trimmed.starts_with("//") { - let snippet = &trimmed[col..(col + 20).min(trimmed.len())]; - findings.push(Finding { - severity: super::arch_audit::Severity::Info, - rule: "expect-in-production", - file: relative.clone(), - line: line_num, - message: format!(".expect() with message: {snippet}..."), - }); - } - } - } - - // ── Rule: Missing doc comments on pub items ──────────────────── - if (trimmed.starts_with("pub ") || trimmed.starts_with("pub(")) - && !prev_line_doc - && !prev_line_empty - { - // Check it's a struct/enum/fn/trait/type/const/mod - let is_item = trimmed.starts_with("pub fn ") - || trimmed.starts_with("pub struct ") - || trimmed.starts_with("pub enum ") - || trimmed.starts_with("pub trait ") - || trimmed.starts_with("pub type ") - || trimmed.starts_with("pub const ") - || trimmed.starts_with("pub mod ") - || trimmed.starts_with("pub(crate) fn ") - || trimmed.starts_with("pub(crate) struct ") - || trimmed.starts_with("pub(crate) enum ") - || trimmed.starts_with("pub(crate) trait "); - if is_item { - findings.push(Finding { - severity: super::arch_audit::Severity::Info, - rule: "missing-doc", - file: relative.clone(), - line: line_num, - message: format!("Missing doc comment on pub item: {trimmed}"), - }); - } - } - - // ── Rule: Commented-out code ─────────────────────────────────── - if trimmed.starts_with("// ") && !in_test { - let stripped = trimmed.trim_start_matches("// "); - if stripped.starts_with("fn ") - || stripped.starts_with("let ") - || stripped.starts_with("if ") - || stripped.starts_with("for ") - || stripped.starts_with("while ") - || stripped.starts_with("match ") - || stripped.starts_with("pub ") - || stripped.starts_with("impl ") - { - findings.push(Finding { - severity: super::arch_audit::Severity::Info, - rule: "commented-code", - file: relative.clone(), - line: line_num, - message: format!("Commented-out code detected: {trimmed}"), - }); - } - } - - // Track doc-comment state - prev_line_doc = trimmed.starts_with("///") || trimmed.starts_with("//!"); - prev_line_empty = trimmed.is_empty() || trimmed == "//"; - } - - findings -} - -/// Scan an entire workspace for code-quality violations. -#[instrument(skip(root))] -pub fn scan_quality(root: &Path) -> Result { - let apps_dir = root.join("apps"); - if !apps_dir.is_dir() { - return Ok(CodeQualityReport { - findings: vec![], - files_scanned: 0, - }); - } - - let mut findings = Vec::new(); - let mut files_scanned = 0; - - for entry in Walk::new(&apps_dir).flatten() { - if entry.file_type().is_none_or(|ft| !ft.is_file()) { - continue; - } - let path = entry.path(); - if path.extension().is_none_or(|e| e != "rs") { - continue; - } - files_scanned += 1; - findings.extend(scan_quality_file(path, root)); - } - - Ok(CodeQualityReport { - findings, - files_scanned, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_unwrap_in_production_code() { - let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("test.rs"); - std::fs::write(&file, "fn x() { let y = foo.unwrap(); }\n").unwrap(); - - let findings = scan_quality_file(&file, &dir); - let unwrap_findings: Vec<_> = findings - .iter() - .filter(|f| f.rule == "unwrap-in-production") - .collect(); - assert!(!unwrap_findings.is_empty(), "should detect unwrap"); - } - - #[test] - fn skips_unwrap_in_test_block() { - let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("test.rs"); - std::fs::write( - &file, - "#[cfg(test)]\nmod tests {\n fn x() { let y = foo.unwrap(); }\n}\n", - ) - .unwrap(); - - let findings = scan_quality_file(&file, &dir); - let unwrap_findings: Vec<_> = findings - .iter() - .filter(|f| f.rule == "unwrap-in-production") - .collect(); - assert!( - unwrap_findings.is_empty(), - "should skip unwrap in test blocks" - ); - } - - #[test] - fn detects_allow_attributes() { - let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("test.rs"); - std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap(); - - let findings = scan_quality_file(&file, &dir); - let bypass_findings: Vec<_> = findings - .iter() - .filter(|f| f.rule == "compiler-bypass") - .collect(); - assert!( - !bypass_findings.is_empty(), - "should detect allow attributes" - ); - } -} diff --git a/apps/infrastructure/src/best_practice/commit.rs b/apps/infrastructure/src/best_practice/commit.rs deleted file mode 100644 index 57a84ea..0000000 --- a/apps/infrastructure/src/best_practice/commit.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Commit message validation following Conventional Commits (Bahasa Indonesia). -//! -//! # Flow -//! -//! `validate_commit_message(msg)` → parse subject → check type, scope, -//! description format → return list of errors (empty = valid). -//! -//! # Format -//! -//! ```text -//! feat(scope): description -//! fix(scope): description -//! chore: description -//! docs: description -//! refactor: description -//! test: description -//! style: description -//! perf: description -//! ci: description -//! ``` - -use regex::Regex; - -/// Valid commit types. -const VALID_TYPES: &[&str] = &[ - "feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert", -]; - -/// Validate a commit message against the Conventional Commits spec. -/// -/// Returns `Ok(())` if valid, or `Err(errors)` with human-readable messages. -pub fn validate_commit_message(msg: &str) -> Result<(), Vec> { - let mut errors = Vec::new(); - let msg = msg.trim(); - - if msg.is_empty() { - errors.push("Commit message must not be empty.".to_string()); - return Err(errors); - } - - // Split subject and body. - let subject = msg.lines().next().unwrap_or(msg); - - // Check subject length. - if subject.len() > 72 { - errors.push(format!( - "Subject line is {} characters (max 72).", - subject.len() - )); - } - - // Parse: `type(scope): description` or `type!: description` or `type: description` - let re = - Regex::new(r"^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!)?:\s+(?P.+)$") - .expect("valid regex for commit parsing"); - - match re.captures(subject) { - None => { - errors.push(format!( - "Subject does not match Conventional Commits format.\n\ - Expected: `type(scope): description`\n\ - Got: {subject}\n\ - Valid types: {}", - VALID_TYPES.join(", ") - )); - } - Some(caps) => { - let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or(""); - let scope = caps.name("scope").map(|m| m.as_str()); - let desc = caps.name("desc").map(|m| m.as_str()).unwrap_or(""); - - // Validate type. - if !VALID_TYPES.contains(&type_) { - errors.push(format!( - "Invalid commit type '{type_}'. Valid types: {}", - VALID_TYPES.join(", ") - )); - } - - // Scope is lowercase, no spaces. - if let Some(s) = scope { - if s.contains(' ') { - errors.push(format!("Scope must not contain spaces: '{s}'")); - } - if s.chars().any(|c| c.is_uppercase()) { - errors.push(format!("Scope must be lowercase: '{s}'")); - } - } - - // Description rules. - if desc.is_empty() { - errors.push("Description must not be empty.".to_string()); - } else { - let first_char = desc.chars().next().unwrap_or(' '); - if first_char.is_uppercase() { - errors.push(format!("Description must start with lowercase: '{desc}'")); - } - if desc.ends_with('.') { - errors.push(format!("Description must not end with a period: '{desc}'")); - } - } - - // Type-specific rules. - match type_ { - "chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build" - | "revert" - if scope.is_some() => - { - errors.push(format!( - "'{type_}' commits should not use a scope. \ - Only 'feat' and 'fix' require scopes." - )); - } - _ => {} - } - } - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors) - } -} - -/// Convert a commit message to a structured representation. -pub struct CommitInfo { - pub type_: String, - pub scope: Option, - pub breaking: bool, - pub description: String, - pub body: Option, -} - -/// Parse a commit message into its structured components. -pub fn parse_commit_message(msg: &str) -> Option { - let msg = msg.trim(); - let subject = msg.lines().next()?; - - let re = - Regex::new(r"^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!)?:\s+(?P.+)$") - .expect("valid regex"); - - let caps = re.captures(subject)?; - - let body_lines: Vec<&str> = msg.lines().skip(1).collect(); - let body = if body_lines.is_empty() { - None - } else { - Some(body_lines.join("\n")) - }; - - Some(CommitInfo { - type_: caps - .name("type") - .map(|m| m.as_str()) - .unwrap_or("") - .to_string(), - scope: caps.name("scope").map(|m| m.as_str().to_string()), - breaking: caps.name("breaking").is_some(), - description: caps - .name("desc") - .map(|m| m.as_str()) - .unwrap_or("") - .to_string(), - body, - }) -} - -/// Suggest a commit message template for a given change type. -pub fn suggest_template(type_: &str, scope: Option<&str>) -> String { - match type_ { - "feat" | "fix" if scope.is_some() => { - format!("{}({}): ", type_, scope.unwrap()) - } - "feat" | "fix" => { - format!("{}(): ", type_) - } - _ => { - format!("{}: ", type_) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_feat_commit() { - assert!(validate_commit_message("feat(tool): add batch file delete").is_ok()); - } - - #[test] - fn valid_fix_commit() { - assert!(validate_commit_message("fix(ipc): reconnect loop on socket timeout").is_ok()); - } - - #[test] - fn valid_chore_commit() { - assert!(validate_commit_message("chore: bump reqwest to 0.13").is_ok()); - } - - #[test] - fn valid_docs_commit() { - assert!(validate_commit_message("docs: add architecture diagram to README").is_ok()); - } - - #[test] - fn rejects_empty_message() { - assert!(validate_commit_message("").is_err()); - } - - #[test] - fn rejects_missing_colon() { - assert!(validate_commit_message("feat:missing space").is_err()); - } - - #[test] - fn rejects_uppercase_description() { - let result = validate_commit_message("feat(tool): Add new feature"); - assert!(result.is_err(), "should reject uppercase start"); - } - - #[test] - fn rejects_trailing_period() { - let result = validate_commit_message("feat(tool): add new feature."); - assert!(result.is_err(), "should reject trailing period"); - } - - #[test] - fn rejects_invalid_type() { - let result = validate_commit_message("wat(tool): something broke"); - assert!(result.is_err(), "should reject invalid type"); - } - - #[test] - fn parses_valid_commit() { - let parsed = parse_commit_message( - "feat(agent): add parallel execution\n\nWith cycle orchestration.", - ) - .unwrap(); - assert_eq!(parsed.type_, "feat"); - assert_eq!(parsed.scope, Some("agent".to_string())); - assert!(!parsed.breaking); - assert_eq!(parsed.description, "add parallel execution"); - assert!(parsed.body.unwrap().contains("cycle orchestration")); - } - - #[test] - fn parses_breaking_change() { - let parsed = parse_commit_message("feat(api)!: change response format").unwrap(); - assert!(parsed.breaking); - } - - #[test] - fn suggests_template() { - let tpl = suggest_template("feat", Some("tool")); - assert_eq!(tpl, "feat(tool): "); - } -} diff --git a/apps/infrastructure/src/best_practice/explore.rs b/apps/infrastructure/src/best_practice/explore.rs deleted file mode 100644 index 5703c37..0000000 --- a/apps/infrastructure/src/best_practice/explore.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! `explore_codebase` tool — lazy, agent-initiated codebase exploration. -//! -//! The main agent decides (via the system prompt) when it needs codebase -//! context. Unlike the old mandatory explore phase (which ran 3 subagents on -//! every turn regardless of the question), this tool is invoked only when the -//! agent judges it necessary — saving tokens on trivial queries while keeping -//! context available for complex tasks. -//! -//! # Flow -//! -//! `ExploreCodebase::run` → -//! 1. Parse the user's goal / target from args. -//! 2. Resolve subagent provider credentials from settings. -//! 3. Spawn a single "context scout" subagent (read-only, semantic_search + -//! read of up to 3 relevant files). -//! 4. Join the result and return a concise bullet summary as a tool message. - -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, warn}; - -use zesdex_domain::agent::prompt::explore_scout_directive; -use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; -use zesdex_domain::core::Store; - -use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; -use crate::subagent::context::SubagentContext; -use crate::subagent::division::AccessTier; -use crate::subagent::engine::run_agent; -use crate::tools::{Tool, ToolCtx}; - -/// Maximum characters of the scout's final output to keep in context. -/// The scout is directed to stay under 1500 chars, but this ceiling protects -/// against rogue output. -const EXPLORE_OUTPUT_MAX_CHARS: usize = 4000; - -/// `explore_codebase` tool — ask a read-only context-scout subagent to -/// locate relevant code for the current task. -pub struct ExploreCodebase; - -impl Tool for ExploreCodebase { - fn name(&self) -> &'static str { - "explore_codebase" - } - - fn description(&self) -> &'static str { - "Explore the codebase to locate code relevant to a task. Use this \ - once at the start of complex or unfamiliar tasks (implementing a \ - feature, fixing a bug, refactoring, navigating a large repo). \ - Do NOT use for simple factual questions about the current \ - conversation." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "goal": { - "type": "string", - "description": "The task or question to explore for" - } - }, - "required": ["goal"] - }) - } - - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let goal = args - .get("goal") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - - if goal.is_empty() { - return Err(anyhow::anyhow!("missing non-empty 'goal'")); - } - - info!("explore_codebase: {goal}"); - - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - let workspace_root = ctx - .workspaces - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| ".".to_string()); - - // One lightweight scout — no parallel agents, no index rebuild. - let directive = format!( - "{}\n\nUser's task: {goal}\nWorkspace root: {workspace_root}", - explore_scout_directive() - ); - - let subagent_ctx = SubagentContext::new( - directive.clone(), - ctx.clone(), - "read".to_string(), - base_url, - api_key, - model, - ); - - let rt = crate::runtime::runtime(); - let result = rt.block_on(run_agent( - subagent_ctx, - &directive, - AccessTier::Read, - ctx.clone(), - ))?; - - let mut out = format!("[Codebase scout report]\n{goal}\n\n----------\n{}", result); - if out.len() > EXPLORE_OUTPUT_MAX_CHARS { - warn!( - "explore_codebase output truncated: {} chars -> {}", - out.len(), - EXPLORE_OUTPUT_MAX_CHARS - ); - out.truncate(EXPLORE_OUTPUT_MAX_CHARS); - out.push_str("\n...[truncated]"); - } - Ok(out) - } -} diff --git a/apps/infrastructure/src/best_practice/mod.rs b/apps/infrastructure/src/best_practice/mod.rs deleted file mode 100644 index 7e105fd..0000000 --- a/apps/infrastructure/src/best_practice/mod.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! # Built-in Kana Engineering Best Practices Engine -//! -//! This module provides compile-time embedded skills, architecture audit, -//! code-quality scanning, and commit-message validation as **built-in** -//! features of the zesdex binary — not external configuration files. -//! -//! ## Modules -//! -//! | Module | Purpose | -//! |--------|---------| -//! | [`skills`] | Compile-time embedded skill markdown files via `include_dir!` | -//! | [`arch_audit`] | Clean-architecture layering violation scanner | -//! | [`code_quality`] | Clean-code rule checker (unwrap, missing docs, etc.) | -//! | [`commit`] | Conventional Commits message validator | -//! -//! ## Usage -//! -//! The [`BestPracticeEngine`] struct provides a unified API that tools call. -//! New tool implementations in `crate::tools::best_practice` delegate to this -//! engine. - -pub mod arch_audit; -pub mod code_quality; -pub mod commit; -pub mod explore; -pub mod skills; - -use anyhow::Result; -use std::path::Path; - -/// Unified engine for all best-practice operations. -/// -/// Wraps the individual audit, quality, and commit modules behind a single -/// struct that tools and subagents can call without knowing the module layout. -#[derive(Default)] -pub struct BestPracticeEngine { - /// Compile-time embedded skills, lazy-loaded. - embedded_skills: std::sync::OnceLock, -} - -impl BestPracticeEngine { - /// Create a new engine (skills index is built lazily on first access). - pub fn new() -> Self { - BestPracticeEngine { - embedded_skills: std::sync::OnceLock::new(), - } - } - - // ── Skills ───────────────────────────────────────────────────────── - - /// Access the embedded skills index. - pub fn skills(&self) -> &skills::EmbeddedSkills { - self.embedded_skills - .get_or_init(skills::EmbeddedSkills::load) - } - - /// List all available embedded-skills names. - pub fn list_skills(&self) -> Vec<&'static str> { - self.skills().list() - } - - /// Get the full content of a skill by name. - pub fn get_skill(&self, name: &str) -> Option<&'static str> { - self.skills().get(name) - } - - /// Get skill summaries (name + description). - pub fn skill_summaries(&self) -> Vec<(&'static str, &'static str)> { - self.skills().summaries() - } - - // ── Architecture Audit ───────────────────────────────────────────── - - /// Run a full architecture layering audit on a workspace directory. - pub fn audit_layering(&self, workspace_root: &Path) -> Result { - arch_audit::audit_layering(workspace_root) - } - - // ── Code Quality ─────────────────────────────────────────────────── - - /// Run a full code-quality scan on a workspace directory. - pub fn scan_quality(&self, workspace_root: &Path) -> Result { - code_quality::scan_quality(workspace_root) - } - - // ── Commit Convention ────────────────────────────────────────────── - - /// Validate a commit message against Conventional Commits. - pub fn validate_commit(&self, message: &str) -> Result<(), Vec> { - commit::validate_commit_message(message) - } - - /// Parse a commit message into its structured fields. - pub fn parse_commit(&self, message: &str) -> Option { - commit::parse_commit_message(message) - } - - /// Get a commit template suggestion. - pub fn suggest_commit_template(&self, type_: &str, scope: Option<&str>) -> String { - commit::suggest_template(type_, scope) - } - - // ── Combined Audit ───────────────────────────────────────────────── - - /// Run all audits (layering + code quality) and return combined results. - pub fn audit_all(&self, workspace_root: &Path) -> Result { - let layering = self.audit_layering(workspace_root)?; - let quality = self.scan_quality(workspace_root)?; - - Ok(CombinedAuditReport { layering, quality }) - } -} - -/// Combined report from all audits. -#[derive(Debug, Clone)] -pub struct CombinedAuditReport { - pub layering: arch_audit::AuditReport, - pub quality: code_quality::CodeQualityReport, -} - -impl CombinedAuditReport { - /// Format the full report as a human-readable string. - pub fn format(&self) -> String { - let mut out = String::new(); - - // ── Layering ── - out.push_str(&format!( - "=== Architecture Layering Audit ===\n\ - Files scanned: {}\n", - self.layering.files_scanned - )); - if self.layering.violations.is_empty() { - out.push_str(" ✅ No layering violations found.\n"); - } else { - for v in &self.layering.violations { - out.push_str(&format!( - " [{}] {}:{} — {}\n", - v.severity, v.file, v.line, v.message - )); - } - } - - // ── Code Quality ── - out.push_str(&format!( - "\n=== Code Quality Scan ===\n\ - Files scanned: {}\n", - self.quality.files_scanned - )); - if self.quality.findings.is_empty() { - out.push_str(" ✅ No code-quality issues found.\n"); - } else { - let by_rule = self.quality.count_by_rule(); - out.push_str(" By rule:\n"); - for (rule, count) in &by_rule { - out.push_str(&format!(" {rule}: {count}\n")); - } - out.push_str(" Top findings:\n"); - for f in self.quality.findings.iter().take(20) { - out.push_str(&format!( - " [{}] {}:{} — {}: {}\n", - f.severity, f.file, f.line, f.rule, f.message - )); - } - if self.quality.findings.len() > 20 { - out.push_str(&format!( - " ... and {} more findings.\n", - self.quality.findings.len() - 20 - )); - } - } - - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn engine_lists_skills() { - let engine = BestPracticeEngine::new(); - let names = engine.list_skills(); - assert!(names.contains(&"clean-code")); - } -} diff --git a/apps/infrastructure/src/best_practice/skills.rs b/apps/infrastructure/src/best_practice/skills.rs deleted file mode 100644 index dc4a41b..0000000 --- a/apps/infrastructure/src/best_practice/skills.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Embedding Kana Engineering best-practice skills as compiled-in static assets. -//! -//! Skill markdown files from `apps/infrastructure/skills/` are embedded at -//! compile time via `include_dir!` and served through [`EmbeddedSkills`]. -//! -//! # Flow -//! -//! `EmbeddedSkills::load()` reads the compile-time directory tree → indexes -//! every `SKILL.md` by its parent directory name → exposes lookup / listing. - -use include_dir::{include_dir, Dir, DirEntry}; -use std::collections::HashMap; - -/// The compile-time-embedded skills directory tree. -static SKILLS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills"); - -/// Index of embedded best-practice skills, built once at startup. -/// -/// Each skill is identified by its directory name (e.g. `"clean-code"`) and -/// its content is the full text of `SKILL.md` within that directory. -pub struct EmbeddedSkills { - skills: HashMap<&'static str, &'static str>, -} - -impl EmbeddedSkills { - /// Walk the embedded directory tree and build the skill index. - /// - /// Flow: iterate `Dir::entries()` → find every `SKILL.md` file → - /// store `(parent_dir_name, file_contents)` in the map. - pub fn load() -> Self { - let mut skills: HashMap<&str, &str> = HashMap::new(); - - fn walk<'a>(dir: &Dir<'a>, skills: &mut HashMap<&'a str, &'a str>) { - for entry in dir.entries() { - match entry { - DirEntry::Dir(sub) => walk(sub, skills), - DirEntry::File(file) => { - if file.path().file_name().is_some_and(|n| n == "SKILL.md") { - if let Some(parent) = file - .path() - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - { - if let Some(content) = file.contents_utf8() { - skills.entry(parent).or_insert(content); - } - } - } - } - } - } - } - - walk(&SKILLS_DIR, &mut skills); - EmbeddedSkills { skills } - } - - /// Return the full `SKILL.md` content for a named skill, if present. - pub fn get(&self, name: &str) -> Option<&'static str> { - self.skills.get(name).copied() - } - - /// List all available skill names. - pub fn list(&self) -> Vec<&'static str> { - let mut names: Vec<&str> = self.skills.keys().copied().collect(); - names.sort(); - names - } - - /// Return an iterator of `(name, trimmed_description)` pairs. - /// - /// Description is extracted from the YAML frontmatter `description:` field. - pub fn summaries(&self) -> Vec<(&'static str, &'static str)> { - let mut out: Vec<(&str, &str)> = Vec::new(); - for name in self.list() { - let content = self.skills.get(name).copied().unwrap_or(""); - let desc = Self::extract_description(content).unwrap_or(""); - out.push((name, desc)); - } - out - } - - /// Extract the `description:` field from YAML frontmatter. - fn extract_description(content: &str) -> Option<&str> { - let lines: Vec<&str> = content.lines().collect(); - if lines.first()? != &"---" { - return None; - } - for line in &lines[1..] { - if let Some(rest) = line.strip_prefix("description: ") { - return Some(rest.trim()); - } - if line == &"---" { - break; - } - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn embedded_skills_loads_known_skills() { - let skills = EmbeddedSkills::load(); - let names = skills.list(); - assert!( - names.contains(&"clean-code"), - "expected clean-code skill, got {names:?}" - ); - assert!( - names.contains(&"commit-convention"), - "expected commit-convention skill, got {names:?}" - ); - assert!( - names.contains(&"kana-rust-backend-best-practice"), - "expected kana-rust-backend-best-practice skill, got {names:?}" - ); - } - - #[test] - fn each_skill_has_content() { - let skills = EmbeddedSkills::load(); - for name in skills.list() { - let content = skills.get(name); - assert!(content.is_some(), "skill '{name}' has no content"); - assert!( - content.unwrap().len() > 50, - "skill '{name}' content suspiciously short" - ); - } - } - - #[test] - fn unknown_skill_returns_none() { - let skills = EmbeddedSkills::load(); - assert!(skills.get("nonexistent-skill").is_none()); - } - - #[test] - fn summaries_include_all_skills() { - let skills = EmbeddedSkills::load(); - let summaries = skills.summaries(); - assert_eq!(summaries.len(), skills.list().len()); - for (name, desc) in &summaries { - assert!(!desc.is_empty(), "skill '{name}' has empty description"); - } - } -} diff --git a/apps/infrastructure/src/bgbash/control.rs b/apps/infrastructure/src/bgbash/control.rs deleted file mode 100644 index 1384792..0000000 --- a/apps/infrastructure/src/bgbash/control.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Background bash control — list, cancel, and inspect background processes. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; - -use tracing::error; - -use super::job::BashJob; - -/// Global accessor for the shared BashControl singleton. -/// -/// Used by the Bash tool (to register jobs) and BashKill (to look them up). -pub fn bash_control() -> &'static BashControl { - static BASH_CONTROL: OnceLock = OnceLock::new(); - BASH_CONTROL.get_or_init(BashControl::new) -} - -/// Central registry of all running background bash jobs. -pub struct BashControl { - jobs: Mutex>>, -} - -impl BashControl { - pub fn new() -> Self { - BashControl { - jobs: Mutex::new(HashMap::new()), - } - } -} - -impl Default for BashControl { - fn default() -> Self { - Self::new() - } -} - -impl BashControl { - /// Register a new background job. - pub fn register(&self, job: Arc) { - if let Ok(mut guard) = self.jobs.lock() { - guard.insert(job.id.clone(), job); - } - } - - /// Cancel a job by ID. - pub fn cancel(&self, id: &str) -> bool { - if let Ok(mut guard) = self.jobs.lock() { - if let Some(job) = guard.remove(id) { - job.cancel(); - return true; - } - } - false - } - - /// List all active jobs. - pub fn list(&self) -> Vec<(String, String, bool)> { - let mut guard = match self.jobs.lock() { - Ok(g) => g, - Err(poisoned) => { - error!("bgbash jobs mutex poisoned, recovering"); - poisoned.into_inner() - } - }; - guard.retain(|_, j| j.is_running()); - guard - .iter() - .map(|(id, job)| (id.clone(), job.command.clone(), job.is_running())) - .collect() - } - - /// Clean up completed jobs. - pub fn prune(&self) { - if let Ok(mut guard) = self.jobs.lock() { - guard.retain(|_, j| j.is_running()); - } - } -} diff --git a/apps/infrastructure/src/bgbash/job.rs b/apps/infrastructure/src/bgbash/job.rs deleted file mode 100644 index 79daf4d..0000000 --- a/apps/infrastructure/src/bgbash/job.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Background bash job — spawns a `bash -c` subprocess and tracks its life. - -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; - -use tracing::error; - -/// A handle to a spawned background bash job. -pub struct BashJob { - pub id: String, - pub command: String, - pub process: Mutex>, - pub cancelled: AtomicBool, -} - -/// Spawn a background bash job and return a handle. -/// -/// The job runs until completion or until `cancel()` is called. -pub fn spawn_bash_job(cmd: String) -> Arc { - let child = Command::new("bash") - .arg("-c") - .arg(&cmd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .ok(); - - let job = Arc::new(BashJob { - id: uuid::Uuid::new_v4().to_string(), - command: cmd, - process: Mutex::new(child), - cancelled: AtomicBool::new(false), - }); - - // Spawn a monitor thread using try_wait() polling so the lock is never - // held across a blocking wait, allowing cancel() to acquire the lock. - let job_clone = Arc::clone(&job); - std::thread::spawn(move || { - loop { - let mut exited = false; - { - let mut guard = match job_clone.process.lock() { - Ok(g) => g, - Err(poisoned) => { - error!("bgbash job mutex poisoned, recovering"); - poisoned.into_inner() - } - }; - if let Some(ref mut child) = *guard { - match child.try_wait() { - Ok(Some(_)) => exited = true, - Ok(None) => {} // still running - Err(e) => { - error!("bgbash wait error: {e}"); - exited = true; - } - } - } else { - exited = true; // no child process - } - } // lock is dropped here — cancel() can now acquire it - - if exited || job_clone.cancelled.load(Ordering::SeqCst) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(50)); - } - }); - - job -} - -impl BashJob { - pub fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); - if let Ok(mut guard) = self.process.lock() { - if let Some(ref mut child) = *guard { - let _ = child.kill(); - let _ = child.wait(); - } - } - } - - pub fn is_running(&self) -> bool { - if self.cancelled.load(Ordering::SeqCst) { - return false; - } - let Ok(mut guard) = self.process.lock() else { - return false; - }; - guard - .as_mut() - .is_some_and(|c| matches!(c.try_wait(), Ok(None))) - } -} diff --git a/apps/infrastructure/src/bgbash/mod.rs b/apps/infrastructure/src/bgbash/mod.rs deleted file mode 100644 index c9b2dba..0000000 --- a/apps/infrastructure/src/bgbash/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Background bash job management — spawn, track, and query long-running -//! shell processes. - -pub mod control; -pub mod job; diff --git a/apps/infrastructure/src/guard/mod.rs b/apps/infrastructure/src/guard/mod.rs deleted file mode 100644 index 869d44c..0000000 --- a/apps/infrastructure/src/guard/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Tool gate — per-tool access control and permissions. - -pub mod patterns; diff --git a/apps/infrastructure/src/guard/patterns.rs b/apps/infrastructure/src/guard/patterns.rs deleted file mode 100644 index e43e53e..0000000 --- a/apps/infrastructure/src/guard/patterns.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Tool usage patterns — detect dangerous or suspicious tool invocations. - -/// Check whether a tool invocation matches a known dangerous pattern. -/// -/// Returns a description of the risk if the pattern matches, or `None` -/// if the invocation appears safe. -pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option { - match tool_name { - "bash" => { - let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or(""); - // Detect git push with --force - if cmd.contains("git push") && cmd.contains("--force") { - return Some( - "Force-pushing to git is destructive and may lose history".to_string(), - ); - } - // Detect rm -rf / - if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") { - return Some( - "Recursive deletion of the root filesystem is never allowed".to_string(), - ); - } - } - "delete" => { - let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); - if path == "/" || path.starts_with("/etc") { - return Some(format!("Deleting '{}' is too dangerous", path)); - } - } - _ => { - tracing::debug!("no guard pattern registered for tool: {tool_name}"); - } - } - None -} diff --git a/apps/infrastructure/src/ipc/client.rs b/apps/infrastructure/src/ipc/client.rs deleted file mode 100644 index bdf1541..0000000 --- a/apps/infrastructure/src/ipc/client.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! IPC client — connects to the daemon's Unix socket and sends/receives -//! framed JSON messages. - -use std::os::unix::net::UnixStream; -use std::sync::Mutex; - -/// A thread-safe IPC client connected to a Zesdex daemon over a Unix socket. -pub struct IpcClient { - conn: Mutex, -} - -impl IpcClient { - pub fn connect_unix(path: &str) -> anyhow::Result { - let stream = UnixStream::connect(path)?; - let conn = crate::ipc::conn::Connection::new(stream)?; - Ok(Self { - conn: Mutex::new(conn), - }) - } - - pub fn send(&self, msg: &T) -> anyhow::Result<()> { - let mut guard = self - .conn - .lock() - .map_err(|e| anyhow::anyhow!("IpcClient mutex poisoned: {e}"))?; - guard.send(msg) - } - - pub fn receive(&self) -> anyhow::Result> { - let mut guard = self - .conn - .lock() - .map_err(|e| anyhow::anyhow!("IpcClient mutex poisoned: {e}"))?; - guard.receive() - } -} diff --git a/apps/infrastructure/src/ipc/conn.rs b/apps/infrastructure/src/ipc/conn.rs deleted file mode 100644 index d444a42..0000000 --- a/apps/infrastructure/src/ipc/conn.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Connection wrapper around a Unix socket stream, -//! pairing a buffered reader with a raw writer. - -use anyhow::Context; -use std::io::BufReader; -use std::os::unix::net::UnixStream; - -/// A framed JSON connection over a Unix socket. -pub struct Connection { - reader: BufReader, - writer: UnixStream, -} - -impl Connection { - pub fn new(stream: UnixStream) -> anyhow::Result { - let reader = BufReader::new( - stream - .try_clone() - .context("failed to clone Unix stream for IPC reader")?, - ); - let writer = stream; - Ok(Self { reader, writer }) - } - - pub fn send(&mut self, msg: &T) -> anyhow::Result<()> { - let json = serde_json::to_vec(msg)?; - crate::ipc::frame::write_frame(&mut self.writer, &json)?; - Ok(()) - } - - pub fn receive(&mut self) -> anyhow::Result> { - let raw = crate::ipc::frame::read_frame(&mut self.reader)?; - match raw { - None => Ok(None), - Some(bytes) => { - let msg: T = serde_json::from_slice(&bytes)?; - Ok(Some(msg)) - } - } - } -} diff --git a/apps/infrastructure/src/ipc/frame.rs b/apps/infrastructure/src/ipc/frame.rs deleted file mode 100644 index 5cf8a29..0000000 --- a/apps/infrastructure/src/ipc/frame.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Length-prefixed framing for Unix-socket IPC. -//! -//! Every message on the wire is encoded as: -//! ```text -//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ] -//! ``` - -use anyhow::Context; -use std::io::{Read, Write}; - -const MAX_PAYLOAD: u32 = 64 * 1024 * 1024; - -/// Read one length-prefixed frame from `reader`. -pub fn read_frame(reader: &mut impl Read) -> anyhow::Result>> { - let mut len_buf = [0u8; 4]; - - match reader.read_exact(&mut len_buf) { - Ok(()) => {} - Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - return Ok(None); - } - Err(e) => return Err(e).context("failed to read frame length prefix"), - } - - let payload_len = u32::from_be_bytes(len_buf) as usize; - - if payload_len > MAX_PAYLOAD as usize { - anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"); - } - - let mut payload = vec![0u8; payload_len]; - reader.read_exact(&mut payload)?; - - Ok(Some(payload)) -} - -/// Write one length-prefixed frame to `writer`. -pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> anyhow::Result<()> { - let payload_len: u32 = data.len().try_into()?; - - if payload_len > MAX_PAYLOAD { - anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"); - } - - let len_bytes = payload_len.to_be_bytes(); - writer.write_all(&len_bytes)?; - writer.write_all(data)?; - writer.flush()?; - - Ok(()) -} diff --git a/apps/infrastructure/src/ipc/mod.rs b/apps/infrastructure/src/ipc/mod.rs deleted file mode 100644 index 63585a6..0000000 --- a/apps/infrastructure/src/ipc/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Unix-socket IPC layer for daemon/client communication. - -pub mod client; -pub mod conn; -pub mod frame; -pub mod protocol; -pub mod server; diff --git a/apps/infrastructure/src/ipc/protocol.rs b/apps/infrastructure/src/ipc/protocol.rs deleted file mode 100644 index 6eb4829..0000000 --- a/apps/infrastructure/src/ipc/protocol.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Wire types for the Zesdex IPC protocol. - -use serde::{Deserialize, Serialize}; - -/// A resolved key press sent from the daemon to the client. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum KeyAction { - Char(char), - Enter, - Escape, - Backspace, - Delete, - Tab, - Up, - Down, - Left, - Right, - Home, - End, - PageUp, - PageDown, - Function(u8), -} - -/// A message sent from the TUI client to the daemon over the IPC socket. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ClientRequest { - Tick, - KeyPress { - key: KeyAction, - ctrl: bool, - alt: bool, - shift: bool, - }, - Submit(String), - Paste(String), - Resize(u16, u16), - Close, - ScrollUp, - ScrollDown, -} - -/// A single chat message within a session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageEntry { - pub role: String, - pub content: String, - pub timestamp: i64, -} - -/// A transient toast notification sent to the client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToastEntry { - pub kind: String, - pub message: String, - pub created_at: i64, - pub lifetime_ms: u64, -} - -/// Full UI state snapshot pushed from the daemon to the client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StatePayload { - pub session_id: String, - pub messages: Vec, - pub edit_count: u32, - pub message_count: usize, - pub overlay: Option, - pub toasts: Vec, - pub dirty: bool, - pub input_buffer: String, - pub input_cursor: usize, -} - -/// A frame sent from the daemon to the client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DaemonFrame { - StateUpdate(Box), - StreamToken(String), - SystemNote { kind: String, message: String }, - ClipboardCopy(String), - Closed, -} diff --git a/apps/infrastructure/src/ipc/server.rs b/apps/infrastructure/src/ipc/server.rs deleted file mode 100644 index 2b30c7c..0000000 --- a/apps/infrastructure/src/ipc/server.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! IPC server — binds a Unix socket and accepts incoming client connections. - -use std::os::unix::net::UnixListener; -use std::path::Path; - -/// A Unix-socket IPC server. -pub struct IpcServer { - listener: UnixListener, -} - -impl IpcServer { - pub fn bind_unix(path: &str) -> anyhow::Result { - let p = Path::new(path); - if p.exists() { - std::fs::remove_file(p)?; - } - - let listener = UnixListener::bind(path)?; - Ok(Self { listener }) - } - - pub fn accept(&self) -> anyhow::Result { - let (stream, _addr) = self.listener.accept()?; - crate::ipc::conn::Connection::new(stream) - } -} diff --git a/apps/infrastructure/src/lib.rs b/apps/infrastructure/src/lib.rs deleted file mode 100644 index 41f83fd..0000000 --- a/apps/infrastructure/src/lib.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! # Zesdex Infrastructure Layer -//! -//! ALL concrete implementations of domain repository traits, application port -//! traits, and platform services. This is the outermost ring of the Clean -//! Architecture onion — it depends on `zesdex-domain` and `zesdex-application` -//! but NEVER on interface/presentation crates. -//! -//! ## Architecture -//! -//! ```text -//! src/ -//! ├── lib.rs — Foundational types + re-exports -//! ├── utils.rs — CastOr, write_json_atomic, slugify -//! ├── persistence/ — Repository implementations (IAM, CMS, SQLite) -//! ├── auth/ — JWT, Argon2, OAuth loopback -//! ├── llm/ — LLM provider HTTP client -//! ├── ipc/ — Unix-socket IPC protocol -//! ├── mcp/ — Model Context Protocol bridge -//! ├── bgbash/ — Background bash job management -//! ├── tools/ — All 37 agent-invocable tools -//! ├── subagent/ — Subagent spawning & execution engine -//! ├── workflow/ — Hive-mind orchestration engine -//! ├── review/ — Post-edit auto-review subagent -//! ├── guard/ — Tool-gate access control -//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit) -//! ``` - -pub mod auth; -pub mod best_practice; -pub mod bgbash; -pub mod guard; -pub mod ipc; -pub mod llm; -pub mod mcp; -pub mod middleware; -pub mod persistence; -pub mod runtime; -pub mod subagent; -pub mod tools; -pub mod utils; -pub mod workflow; - -// --------------------------------------------------------------------------- -// Re-exports from domain -// --------------------------------------------------------------------------- -pub use zesdex_domain::*; - -// --------------------------------------------------------------------------- -// Foundation types — these replace `crate::app::state::*` references -// from the legacy backend code. -// --------------------------------------------------------------------------- - -use std::path::PathBuf; -use std::sync::Arc; - -// Which kind of caller (main agent vs. subagent vs. reviewer) is -// invoking a tool, used to scope permissions and tag log/output paths. -// --------------------------------------------------------------------------- -// TurnEvent & runtime types have been moved to zesdex_domain::agent -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Tool types — needed by all tool modules -// --------------------------------------------------------------------------- - -pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder}; - -// Re-export commonly needed types at the crate root -pub use zesdex_domain::core::{ChatMessage, Role, Store, ToolCallResult, UsageStats}; - -/// A shared, async-writable cache of directory entries, used to avoid -/// re-reading a directory every render frame. -#[derive(Clone)] -pub struct DirCache { - entries: Arc>>, -} - -impl DirCache { - /// Create an empty directory cache. - pub fn new() -> Self { - DirCache { - entries: Arc::new(tokio::sync::RwLock::new(Vec::new())), - } - } - - /// Replace the cached directory entries. - pub async fn set(&self, paths: Vec) { - let mut w = self.entries.write().await; - *w = paths; - } -} - -impl Default for DirCache { - fn default() -> Self { - Self::new() - } -} - -/// A shared, whole-workspace file-path index used for `@file` mention -/// autocomplete. -#[derive(Clone)] -pub struct MentionIndex { - entries: Arc>>, -} - -impl MentionIndex { - /// Create an empty mention index. - pub fn new() -> Self { - MentionIndex { - entries: Arc::new(std::sync::RwLock::new(Vec::new())), - } - } - - /// Replace the entire index with a new set of file paths. - pub fn set(&self, paths: Vec) { - if let Ok(mut w) = self.entries.write() { - *w = paths; - } - } - - /// Append a single file path to the index. - pub fn push(&self, path: String) { - if let Ok(mut w) = self.entries.write() { - w.push(path); - } - } - - /// Return a copy of all indexed paths. - pub fn snapshot(&self) -> Vec { - self.entries.read().map(|r| r.clone()).unwrap_or_default() - } -} - -impl Default for MentionIndex { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/infrastructure/src/llm/mod.rs b/apps/infrastructure/src/llm/mod.rs deleted file mode 100644 index 2a4ed63..0000000 --- a/apps/infrastructure/src/llm/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! LLM provider HTTP client for OpenAI/Anthropic-compatible chat completion APIs. - -pub mod provider; - -pub use provider::{resolve_api_key, LlmClient}; diff --git a/apps/infrastructure/src/llm/provider.rs b/apps/infrastructure/src/llm/provider.rs deleted file mode 100644 index 22916a4..0000000 --- a/apps/infrastructure/src/llm/provider.rs +++ /dev/null @@ -1,439 +0,0 @@ -//! Async HTTP client for OpenAI/Anthropic-compatible chat completion APIs, -//! supporting both non-streaming and SSE-streaming requests with automatic retry. - -use rand_core::RngCore; -use std::time::Duration; - -use zesdex_application::ports::ProviderService; -use zesdex_domain::core::{ - ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef, -}; - -use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL}; -const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE; -pub const DEFAULT_API_KEY: &str = ""; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(600); - -// --------------------------------------------------------------------------- -// Retry helpers -// --------------------------------------------------------------------------- - -fn backoff_seconds(attempt: u32, cap: u64) -> Duration { - let base = 2u64.pow(attempt.saturating_sub(1)); - let delay = std::cmp::min(base, cap); - let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0; - Duration::from_secs_f64(delay as f64 * jitter_factor) -} - -pub fn is_auth_error(err_str: &str) -> bool { - let err_lower = err_str.to_lowercase(); - (err_str.contains("API error 401") - || err_str.contains("API error 402") - || err_str.contains("API error 403")) - || err_lower.contains("unauthorized") - || err_lower.contains("forbidden") - || err_lower.contains("authentication failed") -} - -fn is_rate_limit(err_str: &str) -> bool { - err_str.contains("API error 429") || err_str.to_lowercase().contains("rate limit") -} - -fn backoff_for_error(attempt: u32, err_str: &str) -> Duration { - if is_rate_limit(err_str) { - backoff_seconds(attempt, 60) - } else { - backoff_seconds(attempt, 30) - } -} - -// --------------------------------------------------------------------------- -// Client -// --------------------------------------------------------------------------- - -/// Async HTTP client for a single LLM provider endpoint. -pub struct LlmClient { - pub client: reqwest::Client, - pub api_key: String, - pub base_url: String, - pub model: String, -} - -impl LlmClient { - pub fn new(mut api_key: String, model: String, base_url: Option) -> Self { - if api_key.is_empty() { - api_key = DEFAULT_API_KEY.to_string(); - } - let model = if model.is_empty() { - DEFAULT_MODEL.to_string() - } else { - model - }; - let client = match reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .connect_timeout(CONNECT_TIMEOUT) - .build() - { - Ok(c) => c, - Err(e) => { - tracing::warn!( - "failed to build reqwest client with connect timeout: {}. \ - retrying without connect timeout", - e, - ); - match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() { - Ok(c) => c, - Err(e2) => { - tracing::warn!("also failed: {e2}. using default client"); - reqwest::Client::new() - } - } - } - }; - LlmClient { - client, - api_key, - base_url: base_url - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), - model, - } - } - - async fn try_stream_once( - &self, - req: &ChatRequest, - url: &str, - on_event: &mut (dyn FnMut(&StreamEvent) -> bool + Send), - ) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { - let mut http_req = self - .client - .post(url) - .header("Content-Type", "application/json"); - if !self.api_key.is_empty() { - http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key)); - } - - let mut resp = http_req.json(req).send().await.map_err(|e| { - if e.is_timeout() { - anyhow::anyhow!( - "API request timed out after {REQUEST_TIMEOUT:?}. \ - Check your network or try again." - ) - } else if e.is_connect() { - anyhow::anyhow!( - "Could not connect to {}. \ - Is the URL correct and is the service reachable?", - self.base_url - ) - } else { - anyhow::anyhow!("API request failed: {e}") - } - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("API error {} from {}: {}", status, self.base_url, body); - } - - struct StreamedTurn { - content: String, - tool_calls: Vec, - done_received: bool, - } - - impl StreamedTurn { - fn new() -> Self { - StreamedTurn { - content: String::new(), - tool_calls: Vec::new(), - done_received: false, - } - } - - fn apply_event(&mut self, event: &StreamEvent) { - match event { - StreamEvent::Token(t) => self.content.push_str(t), - StreamEvent::Reasoning(_) => {} - StreamEvent::ToolCallDelta { - index, - id, - name, - arguments_delta, - } => { - const MAX_TOOL_CALLS: usize = 64; - let index = usize::min(*index, MAX_TOOL_CALLS.saturating_sub(1)); - - while self.tool_calls.len() <= index { - self.tool_calls.push(zesdex_domain::core::ToolCall { - id: String::new(), - type_: "function".to_string(), - function: zesdex_domain::core::ToolFunction { - name: String::new(), - arguments: serde_json::Value::String(String::new()), - }, - }); - } - - let tc = &mut self.tool_calls[index]; - - if let Some(ref id_val) = id { - tc.id = id_val.clone(); - } - if let Some(ref name_val) = name { - tc.function.name = name_val.clone(); - } - if let serde_json::Value::String(ref mut args) = tc.function.arguments { - args.push_str(arguments_delta); - } - } - _ => { - tracing::debug!("unhandled stream event in apply_event: {event:?}"); - } - } - } - - fn build_assistant_message(self) -> ChatMessage { - ChatMessage { - role: zesdex_domain::core::Role::Assistant, - content: if self.content.is_empty() { - None - } else { - Some(self.content) - }, - tool_calls: if self.tool_calls.is_empty() { - None - } else { - Some(self.tool_calls) - }, - tool_call_id: None, - name: None, - } - } - } - - let mut turn = StreamedTurn::new(); - let mut usage: Option<(u64, u64)> = None; - let mut parser = SseParser::new(); - let mut byte_buf: Vec = Vec::new(); - - while let Some(chunk) = resp.chunk().await? { - byte_buf.extend_from_slice(&chunk); - - let valid_len = match std::str::from_utf8(&byte_buf) { - Ok(s) => s.len(), - Err(e) => { - let n = e.valid_up_to(); - if n == 0 { - byte_buf.drain(..1); - continue; - } - n - } - }; - let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned(); - byte_buf.drain(..valid_len); - - for event in parser.feed(&text) { - if !on_event(&event) { - anyhow::bail!("aborted"); - } - match &event { - StreamEvent::Usage { - prompt_tokens, - completion_tokens, - .. - } => { - usage = Some((*prompt_tokens, *completion_tokens)); - } - StreamEvent::Error(msg) => { - anyhow::bail!("stream error: {msg}"); - } - StreamEvent::Done => { - turn.apply_event(&event); - turn.done_received = true; - return Ok((turn.build_assistant_message(), usage)); - } - other => { - turn.apply_event(other); - } - } - } - } - - Ok((turn.build_assistant_message(), usage)) - } -} - -impl ProviderService for LlmClient { - async fn chat( - &self, - messages: &[ChatMessage], - tools: Option>, - max_tokens: Option, - temperature: Option, - ) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { - let req = ChatRequest { - model: self.model.clone(), - messages: messages.to_vec(), - max_tokens: Some(max_tokens.unwrap_or(4096)), - temperature: Some(temperature.unwrap_or(0.7)), - tools, - stream: Some(false), - stop: None, - stream_options: None, - tool_choice: None, - top_p: None, - }; - - let url = format!("{}/chat/completions", self.base_url); - let max_retries = 10; - let mut attempt = 0u32; - - loop { - attempt += 1; - - let mut http_req = self - .client - .post(&url) - .header("Content-Type", "application/json"); - - if !self.api_key.is_empty() { - http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key)); - } - - let result = async { - let resp = http_req.json(&req).send().await.map_err(|e| { - if e.is_timeout() { - anyhow::anyhow!( - "API request timed out after {REQUEST_TIMEOUT:?}. \ - Check your network or try again." - ) - } else if e.is_connect() { - anyhow::anyhow!( - "Could not connect to {}. \ - Is the URL correct and is the service reachable?", - self.base_url - ) - } else { - anyhow::anyhow!("API request failed: {e}") - } - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("API error {} from {}: {}", status, self.base_url, body); - } - - let data: ChatResponse = resp.json().await?; - let usage = data - .usage - .map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens))); - let message = data - .choices - .into_iter() - .next() - .and_then(|c| c.message) - .ok_or_else(|| anyhow::anyhow!("API response had no choices"))?; - Ok((message, usage)) - } - .await; - - match result { - Ok((msg, usage)) => return Ok((msg, usage)), - Err(e) => { - let err_str = e.to_string(); - if attempt >= max_retries || is_auth_error(&err_str) { - return Err(e); - } - let delay = backoff_for_error(attempt, &err_str); - tokio::time::sleep(delay).await; - } - } - } - } - - async fn chat_stream( - &self, - messages: &[ChatMessage], - tools: Option>, - max_tokens: Option, - temperature: Option, - mut on_event: Box bool + Send>, - ) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { - let req = ChatRequest { - model: self.model.clone(), - messages: messages.to_vec(), - max_tokens: Some(max_tokens.unwrap_or(4096)), - temperature: Some(temperature.unwrap_or(0.7)), - tools, - stream: Some(true), - stop: None, - stream_options: Some(StreamOptions { - include_usage: true, - }), - tool_choice: None, - top_p: None, - }; - - let url = format!("{}/chat/completions", self.base_url); - let max_retries_stream = 10; - let mut attempt = 0u32; - - loop { - attempt += 1; - let mut captured_content = false; - let mut wrapped = |event: &StreamEvent| -> bool { - match event { - StreamEvent::Token(_) - | StreamEvent::Reasoning(_) - | StreamEvent::ToolCallDelta { .. } => { - captured_content = true; - } - _ => {} - } - on_event(event) - }; - - match self.try_stream_once(&req, &url, &mut wrapped).await { - Ok(result) => return Ok(result), - Err(e) => { - let err_str = e.to_string(); - if is_auth_error(&err_str) || captured_content { - return Err(e); - } - if attempt >= max_retries_stream { - return Err(e); - } - let delay = backoff_for_error(attempt, &err_str); - tokio::time::sleep(delay).await; - } - } - } - } -} - -pub fn resolve_api_key( - settings: &zesdex_domain::cms::Settings, - app_config: &zesdex_domain::cms::AppConfig, -) -> String { - let provider = &settings.provider; - - let mut api_key = settings.api_keys.get(provider).cloned().unwrap_or_default(); - - if api_key.is_empty() { - if let Some(provider_cfg) = app_config.providers.get(provider) { - api_key = provider_cfg - .api_key_env - .as_ref() - .and_then(|env| std::env::var(env).ok()) - .or_else(|| provider_cfg.default_api_key.clone()) - .unwrap_or_default(); - } - } - - api_key -} diff --git a/apps/infrastructure/src/mcp/manager.rs b/apps/infrastructure/src/mcp/manager.rs deleted file mode 100644 index 2e431c8..0000000 --- a/apps/infrastructure/src/mcp/manager.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Manages MCP server connections — start, stop, list, and dispatch -//! tool calls to remote MCP servers. - -use std::collections::HashMap; - -/// Metadata for a connected MCP server. -#[derive(Debug, Clone)] -pub struct McpServerHandle { - pub name: String, - pub transport: String, -} - -/// Manages MCP server connections. -#[derive(Clone)] -pub struct McpManager { - servers: HashMap, -} - -impl McpManager { - pub fn new() -> Self { - McpManager { - servers: HashMap::new(), - } - } -} - -impl Default for McpManager { - fn default() -> Self { - Self::new() - } -} - -impl McpManager { - /// Register an MCP server by name and transport string. - /// - /// Returns an error if a server with the same name is already registered. - pub fn register(&mut self, name: &str, transport: &str) -> anyhow::Result<()> { - if self.servers.contains_key(name) { - anyhow::bail!("MCP server '{name}' is already registered"); - } - self.servers.insert( - name.to_string(), - McpServerHandle { - name: name.to_string(), - transport: transport.to_string(), - }, - ); - Ok(()) - } - - /// Remove a registered MCP server and return its handle, if it existed. - pub fn unregister(&mut self, name: &str) -> Option { - self.servers.remove(name) - } - - pub fn list(&self) -> Vec { - self.servers.values().cloned().collect() - } - - pub fn get(&self, name: &str) -> Option<&McpServerHandle> { - self.servers.get(name) - } - - pub fn is_empty(&self) -> bool { - self.servers.is_empty() - } -} diff --git a/apps/infrastructure/src/mcp/mod.rs b/apps/infrastructure/src/mcp/mod.rs deleted file mode 100644 index 76d9b11..0000000 --- a/apps/infrastructure/src/mcp/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Model Context Protocol (MCP) — bridge between agent tools and external MCP -//! servers using the rmcp crate. - -pub mod manager; -pub mod transport; diff --git a/apps/infrastructure/src/mcp/transport.rs b/apps/infrastructure/src/mcp/transport.rs deleted file mode 100644 index e3d3563..0000000 --- a/apps/infrastructure/src/mcp/transport.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! MCP transport layer — manages child-process and HTTP-based transport -//! for connecting to MCP servers. - -use std::{ - io::{Read, Write}, - process::{Child, ChildStdin, ChildStdout, Command, Stdio}, -}; - -/// A running MCP server process connected via stdio. -/// -/// Holds the child process handle plus the piped stdin/stdout streams -/// so callers can send JSON-RPC messages and read responses. -pub struct McpTransport { - process: Option, - stdin: Option, - stdout: Option, -} - -impl McpTransport { - /// Spawn a child process as an MCP server over stdio. - /// - /// The command is passed to `sh -c` so shell syntax (pipes, redirects, etc.) - /// works naturally. Stderr is discarded to avoid corrupting a TUI that may - /// be running in the same terminal. - pub fn start_child_process(name: &str, command: &str) -> anyhow::Result { - tracing::info!("starting MCP transport '{name}': {command}"); - let mut child = Command::new("sh") - .arg("-c") - .arg(command) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn()?; - let stdin = child.stdin.take(); - let stdout = child.stdout.take(); - Ok(McpTransport { - process: Some(child), - stdin, - stdout, - }) - } - - /// Write raw bytes to the child's stdin. - pub fn send(&mut self, data: &[u8]) -> anyhow::Result<()> { - if let Some(ref mut stdin) = self.stdin { - stdin.write_all(data)?; - stdin.flush()?; - } - Ok(()) - } - - /// Read from the child's stdout into the provided buffer. - /// - /// Returns `Ok(Some(n))` with the number of bytes read, - /// `Ok(None)` on EOF, or `Err` on I/O errors. - pub fn receive(&mut self, buf: &mut [u8]) -> anyhow::Result> { - match self.stdout.as_mut() { - Some(stdout) => match stdout.read(buf) { - Ok(0) => Ok(None), - Ok(n) => Ok(Some(n)), - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None), - Err(e) => Err(e.into()), - }, - None => Ok(None), - } - } - - /// Gracefully shut down the child by closing stdin (sending EOF) and - /// then killing the process. - pub fn kill(&mut self) { - // Close stdin first to signal EOF to the MCP server. - let _ = self.stdin.take(); - if let Some(ref mut child) = self.process { - let _ = child.kill(); - let _ = child.wait(); - } - } - - /// Check whether the child process is still running. - pub fn is_running(&mut self) -> bool { - self.process - .as_mut() - .is_some_and(|c| matches!(c.try_wait(), Ok(None))) - } - - /// Stop the child process. This is the public API alias for `kill`. - pub fn stop(&mut self) -> anyhow::Result<()> { - self.kill(); - Ok(()) - } -} - -impl Drop for McpTransport { - fn drop(&mut self) { - self.kill(); - } -} diff --git a/apps/infrastructure/src/middleware/auth.rs b/apps/infrastructure/src/middleware/auth.rs deleted file mode 100644 index 91ec65b..0000000 --- a/apps/infrastructure/src/middleware/auth.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Authentication middleware — session-lock based auth for Axum. -//! -//! Validates `X-Session-Id` header against the `SessionRepository` before -//! forwarding the request to the inner service. - -use std::future::Future; -use std::path::PathBuf; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use axum::body::Body; -use axum::http::{Request, Response, StatusCode}; -use axum::response::IntoResponse; -use serde::{Deserialize, Serialize}; -use tower::{Layer, Service}; -use zesdex_domain::auth::{SessionId, SessionRepository}; - -/// Identity extracted from a validated session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionIdentity { - pub session_id: String, - pub user_agent: String, - pub connected_at: i64, -} - -impl SessionIdentity { - pub fn new(session_id: String, user_agent: String) -> Self { - let connected_at = chrono::Utc::now().timestamp(); - Self { - session_id, - user_agent, - connected_at, - } - } -} - -/// Tower Layer that produces SessionAuthMiddleware services. -/// -/// Holds a reference to the `SessionRepository` and the base directory -/// needed to validate session IDs. -#[derive(Debug, Clone)] -pub struct SessionAuthLayer { - base_dir: PathBuf, - repo: Arc, -} - -impl SessionAuthLayer { - pub fn new(base_dir: PathBuf, repo: Arc) -> Self { - Self { base_dir, repo } - } -} - -impl Layer for SessionAuthLayer -where - R: SessionRepository + Send + Sync + 'static, -{ - type Service = SessionAuthMiddleware; - - fn layer(&self, inner: S) -> Self::Service { - SessionAuthMiddleware { - inner, - base_dir: self.base_dir.clone(), - repo: self.repo.clone(), - } - } -} - -/// Tower Service that validates X-Session-Id before forwarding. -#[derive(Debug, Clone)] -pub struct SessionAuthMiddleware { - inner: S, - base_dir: PathBuf, - repo: Arc, -} - -impl Service> for SessionAuthMiddleware -where - S: Service, Response = Response> + Send + 'static, - S::Future: Send + 'static, - ReqBody: Send + 'static, - R: SessionRepository + Send + Sync + 'static, -{ - type Response = S::Response; - type Error = S::Error; - type Future = - Pin> + Send + 'static>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, req: Request) -> Self::Future { - let session_id = req - .headers() - .get("X-Session-Id") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - // Validate the session against the repository. - match session_id { - Some(sid) => match SessionId::new(&sid) { - Ok(id) => match self.repo.load_session(&self.base_dir, &id) { - Ok(_session) => { - // Session is valid — forward the request. - let fut = self.inner.call(req); - return Box::pin(fut); - } - Err(_) => { /* fall through to 401 */ } - }, - Err(_) => { /* fall through to 401 */ } - }, - None => { /* fall through to 401 */ } - } - - Box::pin(async move { - Ok(( - StatusCode::UNAUTHORIZED, - "missing or invalid X-Session-Id header", - ) - .into_response()) - }) - } -} diff --git a/apps/infrastructure/src/middleware/mod.rs b/apps/infrastructure/src/middleware/mod.rs deleted file mode 100644 index 19eb161..0000000 --- a/apps/infrastructure/src/middleware/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Axum middleware tower for the HTTP API layer. - -pub mod auth; -pub mod rate_limit; diff --git a/apps/infrastructure/src/middleware/rate_limit.rs b/apps/infrastructure/src/middleware/rate_limit.rs deleted file mode 100644 index 1dcca92..0000000 --- a/apps/infrastructure/src/middleware/rate_limit.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Simple in-memory rate limiter for Axum. - -use std::collections::HashMap; -use std::sync::Mutex; - -/// In-memory sliding-window rate limiter. -#[derive(Debug)] -pub struct RateLimiter { - windows: Mutex>>, -} - -impl RateLimiter { - pub fn new() -> Self { - RateLimiter { - windows: Mutex::new(HashMap::new()), - } - } - - pub fn check_rate_limit( - &self, - client_id: &str, - max_requests: u32, - window_secs: u64, - ) -> anyhow::Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - - let cutoff = now.saturating_sub(window_secs as i64); - let mut windows = self - .windows - .lock() - .map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?; - - let timestamps = windows - .entry(client_id.to_string()) - .or_insert_with(Vec::new); - timestamps.retain(|&ts| ts >= cutoff); - - if timestamps.len() >= max_requests as usize { - return Ok(false); - } - - timestamps.push(now); - Ok(true) - } - - pub fn reset(&self) -> anyhow::Result<()> { - let mut windows = self - .windows - .lock() - .map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?; - windows.clear(); - Ok(()) - } -} - -impl Default for RateLimiter { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/infrastructure/src/persistence/cms/app_config_repo.rs b/apps/infrastructure/src/persistence/cms/app_config_repo.rs deleted file mode 100644 index ab0d7ab..0000000 --- a/apps/infrastructure/src/persistence/cms/app_config_repo.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection. - -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use zesdex_domain::cms::{ - AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError, -}; - -use crate::utils::write_json_atomic; - -/// File-based `AppConfigRepository` that reads/writes `app_config.json`. -#[derive(Debug, Clone, Default)] -pub struct JsonAppConfigRepository; - -impl JsonAppConfigRepository { - pub fn new() -> Self { - Self - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeEnv { - #[serde(alias = "ANTHROPIC_BASE_URL")] - anthropic_base_url: Option, - #[serde(alias = "ANTHROPIC_API_KEY")] - anthropic_api_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeSettings { - env: Option, - #[serde(alias = "customModel", alias = "model")] - custom_model: Option, -} - -fn claude_settings_from_file() -> Option { - let path = dirs::home_dir()?.join(".claude").join("settings.json"); - let content = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() -} - -fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option)> { - let settings = claude_settings_from_file(); - - let file_creds = settings.as_ref().and_then(|s| { - let env = s.env.as_ref()?; - Some(( - env.anthropic_base_url.clone()?, - env.anthropic_api_key.clone()?, - )) - }); - - let env_creds = || -> Option<(String, String)> { - let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; - let key = std::env::var("ANTHROPIC_API_KEY").ok()?; - Some((base_url, key)) - }; - - let custom_model = settings.and_then(|s| s.custom_model); - - let (base_url, key) = file_creds.or_else(env_creds)?; - - Some(( - ProviderConfig { - api_base: base_url, - api_key_env: Some("ANTHROPIC_API_KEY".to_string()), - default_model: custom_model.clone(), - default_api_key: Some(key), - }, - custom_model, - )) -} - -/// Apply a detected Claude provider + custom model onto an `AppConfig`. -/// -/// Pure (no I/O) so it can be unit-tested. Flow: -/// 1. Always `insert`s the "claude" provider (refreshing a possibly stale -/// persisted entry with the current base URL + key from settings.json). -/// 2. Registers known Claude model roles if missing. -/// 3. Always sets `default_provider = "claude"` and -/// `default_model = custom_model.unwrap_or("claude-opus-5")` so Opus -/// is the default whenever `~/.claude/settings.json` is present. -fn apply_claude_provider( - cfg: &mut AppConfig, - claude_provider: ProviderConfig, - custom_model: Option, -) { - cfg.providers.insert("claude".to_string(), claude_provider); - - let claude_models: [(&str, &str); 3] = [ - ("claude-opus-5", "claude-opus-5"), - ("claude-sonnet-5", "claude-sonnet-5"), - ("claude-haiku-4-5", "claude-haiku-4-5-20251001"), - ]; - for (role_name, model_name) in &claude_models { - cfg.model_roles - .entry(role_name.to_string()) - .or_insert(ModelRole { - provider: "claude".to_string(), - model: model_name.to_string(), - max_tokens: Some(8192), - context_window: Some(200_000), - temperature: Some(0.7), - }); - } - - if let Some(custom) = &custom_model { - cfg.model_roles.entry(custom.clone()).or_insert(ModelRole { - provider: "claude".to_string(), - model: custom.clone(), - max_tokens: Some(8192), - context_window: Some(200_000), - temperature: Some(0.7), - }); - } - - // Always prefer the Claude provider + Opus model when settings.json - // is present — this is the user's explicit custom endpoint choice. - cfg.default_provider = "claude".to_string(); - cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-5".to_string()); -} - -impl AppConfigRepository for JsonAppConfigRepository { - fn load(&self, base_dir: &Path) -> Result { - let path = base_dir.join("app_config.json"); - let mut cfg: AppConfig = match std::fs::read_to_string(&path) { - Ok(s) => serde_json::from_str(&s)?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppConfig::default(), - Err(e) => return Err(RepositoryError::Io(e)), - }; - - let defaults = AppConfig::default(); - for (name, provider) in defaults.providers { - cfg.providers.entry(name).or_insert(provider); - } - - if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() { - apply_claude_provider(&mut cfg, claude_provider, custom_model); - } - - Ok(cfg) - } - - fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> { - std::fs::create_dir_all(base_dir)?; - let path = base_dir.join("app_config.json"); - write_json_atomic(&path, config, None)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - fn claude_provider(base: &str, key: Option<&str>) -> ProviderConfig { - ProviderConfig { - api_base: base.to_string(), - api_key_env: Some("ANTHROPIC_API_KEY".to_string()), - default_model: Some("claude-opus-5".to_string()), - default_api_key: key.map(|s| s.to_string()), - } - } - - #[test] - fn claude_settings_parse_env() { - let parsed: ClaudeSettings = serde_json::from_str( - r#"{"env":{"ANTHROPIC_BASE_URL":"https://9router.example/v1","ANTHROPIC_API_KEY":"sk-test"}}"#, - ) - .unwrap(); - let env = parsed.env.unwrap(); - assert_eq!( - env.anthropic_base_url.as_deref(), - Some("https://9router.example/v1") - ); - assert_eq!(env.anthropic_api_key.as_deref(), Some("sk-test")); - } - - #[test] - fn apply_claude_refreshes_stale_provider_and_sets_opus_default() { - // Simulate a previously-persisted app_config.json with a STALE claude - // provider + non-opus default (e.g. user had switched provider). - let mut cfg = AppConfig { - providers: { - let mut m = HashMap::new(); - m.insert( - "claude".to_string(), - claude_provider("https://old.example/v1", Some("sk-old")), - ); - m - }, - model_roles: HashMap::new(), - default_provider: "router".to_string(), - default_model: "other-model".to_string(), - default_context_window: 256_000, - }; - - // Detect returned a fresh provider from ~/.claude/settings.json. - apply_claude_provider( - &mut cfg, - claude_provider("https://9router.example/v1", Some("sk-new")), - None, - ); - - let claude = cfg.providers.get("claude").unwrap(); - assert_eq!(claude.api_base, "https://9router.example/v1"); - assert_eq!(claude.default_api_key.as_deref(), Some("sk-new")); - // Insert (not or_insert) → stale entry refreshed. - assert_eq!(cfg.default_provider, "claude"); - assert_eq!(cfg.default_model, "claude-opus-5"); - - // Claude model roles registered. - assert!(cfg.model_roles.contains_key("claude-opus-5")); - assert!(cfg.model_roles.contains_key("claude-sonnet-5")); - assert!(cfg.model_roles.contains_key("claude-haiku-4-5")); - } - - #[test] - fn apply_claude_honors_custom_model_from_settings() { - let mut cfg = AppConfig::default(); - apply_claude_provider( - &mut cfg, - claude_provider("https://9router.example/v1", Some("sk-new")), - Some("claude-opus-5".to_string()), - ); - assert_eq!(cfg.default_model, "claude-opus-5"); - assert!(cfg.model_roles.contains_key("claude-opus-5")); - } - - #[test] - fn detect_uses_env_creds_as_fallback() { - // When ~/.claude/settings.json is absent/unreadable, the env-var - // fallback should produce a "claude" provider. Set env vars, call - // detect, and assert the resulting provider uses them. - std::env::set_var("ANTHROPIC_BASE_URL", "https://env.example/v1"); - std::env::set_var("ANTHROPIC_API_KEY", "sk-env"); - match detect_claude_settings_provider() { - Some((provider, _custom)) => { - // If the real settings.json exists it wins (base could be the - // real 9router URL); otherwise env creds are used. Either way, - // the provider must have api_key_env pointing at ANTHROPIC_API_KEY. - assert_eq!(provider.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY")); - } - None => { - // No file + no env (shouldn't happen since we just set env). - panic!("expected env fallback to produce a provider"); - } - } - std::env::remove_var("ANTHROPIC_BASE_URL"); - std::env::remove_var("ANTHROPIC_API_KEY"); - } -} diff --git a/apps/infrastructure/src/persistence/cms/conversation_repo.rs b/apps/infrastructure/src/persistence/cms/conversation_repo.rs deleted file mode 100644 index c628da9..0000000 --- a/apps/infrastructure/src/persistence/cms/conversation_repo.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! JSON file–backed `ConversationRepository`. -//! Stores `Conversation` at `/conversation.json`. - -use std::path::Path; - -use zesdex_domain::cms::{Conversation, ConversationRepository, RepositoryError}; - -use crate::utils::write_json_atomic; - -/// File-based `ConversationRepository` that reads/writes `conversation.json`. -#[derive(Debug, Clone, Default)] -pub struct JsonConversationRepository; - -impl JsonConversationRepository { - pub fn new() -> Self { - Self - } -} - -impl ConversationRepository for JsonConversationRepository { - fn load(&self, session_dir: &Path) -> Result { - let path = session_dir.join("conversation.json"); - let data = std::fs::read_to_string(&path)?; - let conv: Conversation = serde_json::from_str(&data)?; - Ok(conv) - } - - fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> { - std::fs::create_dir_all(session_dir)?; - let path = session_dir.join("conversation.json"); - write_json_atomic(&path, conversation, None)?; - Ok(()) - } -} diff --git a/apps/infrastructure/src/persistence/cms/edit_log_repo.rs b/apps/infrastructure/src/persistence/cms/edit_log_repo.rs deleted file mode 100644 index 5fbbf1e..0000000 --- a/apps/infrastructure/src/persistence/cms/edit_log_repo.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! JSONL file–backed `EditLogRepository`. -//! Stores `EditLog` as an append-only newline-delimited JSON file. - -use std::collections::VecDeque; -use std::io::{BufRead, BufReader, Write}; -use std::path::Path; - -use zesdex_domain::cms::{EditLog, EditLogEntry, EditLogRepository, RepositoryError}; - -/// Maximum number of edit entries held in memory at once. -const MAX_MEMORY_ENTRIES: usize = 10_000; - -/// File-based `EditLogRepository` that reads/writes `edits.jsonl`. -#[derive(Debug, Clone, Default)] -pub struct JsonlEditLogRepository; - -impl JsonlEditLogRepository { - pub fn new() -> Self { - Self - } - - fn load_from_disk(path: &Path) -> VecDeque { - let Ok(file) = std::fs::File::open(path) else { - return VecDeque::new(); - }; - let reader = BufReader::new(file); - let mut entries: VecDeque = VecDeque::new(); - for line in reader.lines() { - let Ok(line) = line else { - continue; - }; - if let Ok(entry) = serde_json::from_str::(&line) { - if entries.len() >= MAX_MEMORY_ENTRIES { - entries.pop_front(); - } - entries.push_back(entry); - } - } - entries - } -} - -impl EditLogRepository for JsonlEditLogRepository { - fn open(&self, session_dir: &Path) -> Result { - let path = session_dir.join("edits.jsonl"); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let entries = Self::load_from_disk(&path); - if !path.exists() { - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path)?; - } - Ok(EditLog { entries }) - } - - fn append( - &self, - session_dir: &Path, - log: &mut EditLog, - entry: EditLogEntry, - ) -> Result<(), RepositoryError> { - let path = session_dir.join("edits.jsonl"); - let line = serde_json::to_string(&entry)? + "\n"; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - { - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path)?; - file.write_all(line.as_bytes())?; - file.sync_all()?; - } - log.entries.push_back(entry); - if log.entries.len() > MAX_MEMORY_ENTRIES { - log.entries.pop_front(); - } - Ok(()) - } - - fn entries(&self, log: &EditLog) -> Vec { - log.entries.clone().into_iter().collect() - } -} diff --git a/apps/infrastructure/src/persistence/cms/memory_repo.rs b/apps/infrastructure/src/persistence/cms/memory_repo.rs deleted file mode 100644 index 994ee04..0000000 --- a/apps/infrastructure/src/persistence/cms/memory_repo.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! Markdown file–backed `MemoryRepository`. -//! Each memory is stored as a `.md` file with YAML-ish frontmatter. - -use std::collections::HashMap; -use std::io::Write; -use std::path::Path; - -use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError}; - -/// File-based `MemoryRepository` that stores memories as `.md` files with -/// YAML-ish frontmatter. -#[derive(Debug, Clone, Default)] -pub struct MarkdownMemoryRepository; - -impl MarkdownMemoryRepository { - pub fn new() -> Self { - Self - } - - /// Escape newlines in field values so they do not break the - /// line-oriented frontmatter parser. - fn escape_newlines(s: &str) -> String { - s.replace('\n', "\\n") - } - - /// Unescape `\n` back to actual newlines after frontmatter parsing. - fn unescape_newlines(s: &str) -> String { - s.replace("\\n", "\n") - } - - fn build_frontmatter(memory: &Memory) -> String { - let outcome_line = memory - .outcome - .as_ref() - .map(|o| format!("outcome: {}\n", Self::escape_newlines(o))) - .unwrap_or_default(); - let scope_line = memory - .scope - .as_ref() - .map(|s| format!("scope: {}\n", Self::escape_newlines(s))) - .unwrap_or_default(); - let before_line = memory - .before_snippet - .as_ref() - .map(|s| format!("before: {}\n", Self::escape_newlines(s))) - .unwrap_or_default(); - let after_line = memory - .after_snippet - .as_ref() - .map(|s| format!("after: {}\n", Self::escape_newlines(s))) - .unwrap_or_default(); - let prov_line = if memory.provenances.is_empty() { - String::new() - } else { - format!("provenances: {}\n", memory.provenances.join(", ")) - }; - format!( - "name: {name}\ndescription: {desc}\nkind: {kind}\n\ - created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\ - {outcome}{scope}{before}{after}{prov}", - name = memory.name, - desc = memory.description, - kind = memory.kind, - created = memory.created_at, - updated = memory.updated_at, - lifecycle = memory.lifecycle, - outcome = outcome_line, - scope = scope_line, - before = before_line, - after = after_line, - prov = prov_line, - ) - } - - fn parse_frontmatter(front: &str) -> HashMap { - front - .lines() - .filter_map(|l| { - let mut it = l.splitn(2, ':'); - Some((it.next()?.trim().to_string(), it.next()?.trim().to_string())) - }) - .collect() - } - - fn parse(content: &str) -> std::io::Result { - let content = content.strip_prefix("---\n").unwrap_or(content); - let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); - if parts.len() < 2 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "missing frontmatter", - )); - } - let front = Self::parse_frontmatter(parts[0]); - let body = parts.get(1).unwrap_or(&"").trim().to_string(); - Ok(Memory { - name: front.get("name").cloned().unwrap_or_default(), - description: front.get("description").cloned().unwrap_or_default(), - content: body, - kind: front - .get("kind") - .cloned() - .unwrap_or_else(|| "reference".to_string()), - created_at: front - .get("created_at") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - updated_at: front - .get("updated_at") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - outcome: front - .get("outcome") - .cloned() - .filter(|s| !s.is_empty()) - .map(|s| Self::unescape_newlines(&s)), - lifecycle: front - .get("lifecycle") - .cloned() - .unwrap_or_else(|| "new".to_string()), - scope: front - .get("scope") - .cloned() - .filter(|s| !s.is_empty()) - .map(|s| Self::unescape_newlines(&s)), - before_snippet: front - .get("before") - .cloned() - .filter(|s| !s.is_empty()) - .map(|s| Self::unescape_newlines(&s)), - after_snippet: front - .get("after") - .cloned() - .filter(|s| !s.is_empty()) - .map(|s| Self::unescape_newlines(&s)), - provenances: front - .get("provenances") - .cloned() - .map(|s| s.split(", ").map(String::from).collect()) - .unwrap_or_default(), - }) - } -} - -impl MemoryRepository for MarkdownMemoryRepository { - fn list(&self, memory_dir: &Path) -> Result, RepositoryError> { - let Ok(entries) = std::fs::read_dir(memory_dir) else { - return Ok(Vec::new()); - }; - let slugs: Vec = entries - .filter_map(std::result::Result::ok) - .filter(|e| e.path().extension().is_some_and(|x| x == "md")) - .filter_map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - if name == "MEMORY.md" { - return None; - } - name.strip_suffix(".md") - .map(std::string::ToString::to_string) - }) - .collect(); - Ok(slugs) - } - - fn load(&self, memory_dir: &Path, name: &str) -> Result { - let path = Memory::path(memory_dir, name); - let content = std::fs::read_to_string(&path)?; - let memory = Self::parse(&content) - .map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?; - Ok(memory) - } - - fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> { - let path = Memory::path(memory_dir, &memory.name); - let parent = path - .parent() - .expect("memory path always has a parent directory"); - std::fs::create_dir_all(parent)?; - - let frontmatter = Self::build_frontmatter(memory); - let content = format!("---\n{frontmatter}---\n\n{}", memory.content); - - let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); - { - let mut f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp)?; - f.write_all(content.as_bytes())?; - f.sync_all()?; - } - std::fs::rename(&tmp, &path)?; - if let Some(p) = path.parent() { - if let Ok(d) = std::fs::File::open(p) { - let _ = d.sync_all(); - } - } - Ok(()) - } - - fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> { - let path = Memory::path(memory_dir, name); - if path.exists() { - std::fs::remove_file(&path)?; - } - Ok(()) - } -} diff --git a/apps/infrastructure/src/persistence/cms/mod.rs b/apps/infrastructure/src/persistence/cms/mod.rs deleted file mode 100644 index 48b9354..0000000 --- a/apps/infrastructure/src/persistence/cms/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! File-based repository implementations for CMS domain entities. -//! -//! ## Repositories -//! - `JsonSettingsRepository` — reads/writes `settings.json` -//! - `JsonAppConfigRepository` — reads/writes `app_config.json` -//! - `JsonConversationRepository` — reads/writes `conversation.json` -//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files -//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl` -//! - `FileRewindBlobRepository` — stores blobs as files - -pub mod app_config_repo; -pub mod conversation_repo; -pub mod edit_log_repo; -pub mod memory_repo; -pub mod rewind_blob_repo; -pub mod settings_repo; diff --git a/apps/infrastructure/src/persistence/cms/rewind_blob_repo.rs b/apps/infrastructure/src/persistence/cms/rewind_blob_repo.rs deleted file mode 100644 index 50e7582..0000000 --- a/apps/infrastructure/src/persistence/cms/rewind_blob_repo.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Filesystem-backed `RewindBlobRepository`. -//! Blob bytes are stored at `/blobs/.bin`. - -use std::io::Write; -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use zesdex_domain::cms::{RepositoryError, RewindBlobRepository}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct BlobIndexEntry { - key: String, - mime_type: Option, - created_at: i64, -} - -/// Concrete filesystem rewind-blob repository. -#[derive(Debug, Clone, Default)] -pub struct FileRewindBlobRepository; - -impl FileRewindBlobRepository { - pub fn new() -> Self { - Self - } - - fn blobs_dir(session_dir: &Path) -> std::path::PathBuf { - session_dir.join("blobs") - } - - fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf { - Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes()))) - } - - fn index_path(session_dir: &Path) -> std::path::PathBuf { - Self::blobs_dir(session_dir).join("index.jsonl") - } -} - -impl RewindBlobRepository for FileRewindBlobRepository { - fn store_blob( - &self, - session_dir: &Path, - blob_key: &str, - data: &[u8], - mime_type: Option<&str>, - ) -> Result<(), RepositoryError> { - let blobs_dir = Self::blobs_dir(session_dir); - std::fs::create_dir_all(&blobs_dir)?; - - let path = Self::blob_file_path(session_dir, blob_key); - let tmp = path.with_extension("bin.tmp"); - std::fs::write(&tmp, data)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, &path)?; - - let entry = BlobIndexEntry { - key: blob_key.to_string(), - mime_type: mime_type.map(String::from), - created_at: chrono::Utc::now().timestamp_millis(), - }; - let index_path = Self::index_path(session_dir); - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&index_path)?; - writeln!(f, "{}", serde_json::to_string(&entry)?)?; - f.sync_all()?; - - Ok(()) - } - - fn retrieve_blob( - &self, - session_dir: &Path, - blob_key: &str, - ) -> Result>, RepositoryError> { - let path = Self::blob_file_path(session_dir, blob_key); - if !path.exists() { - return Ok(None); - } - let data = std::fs::read(&path)?; - Ok(Some(data)) - } - - fn list_blob_keys(&self, session_dir: &Path) -> Result, RepositoryError> { - let index_path = Self::index_path(session_dir); - let Ok(content) = std::fs::read_to_string(&index_path) else { - return Ok(Vec::new()); - }; - - let mut first_seen_order: Vec = Vec::new(); - let mut latest_by_key: std::collections::HashMap = - std::collections::HashMap::new(); - for line in content.lines() { - let Ok(entry) = serde_json::from_str::(line) else { - continue; - }; - if !latest_by_key.contains_key(&entry.key) { - first_seen_order.push(entry.key.clone()); - } - latest_by_key.insert(entry.key.clone(), entry); - } - - let mut entries: Vec = first_seen_order - .into_iter() - .filter_map(|k| latest_by_key.get(&k).cloned()) - .collect(); - entries.sort_by_key(|e| e.created_at); - Ok(entries.into_iter().map(|e| e.key).collect()) - } -} diff --git a/apps/infrastructure/src/persistence/cms/settings_repo.rs b/apps/infrastructure/src/persistence/cms/settings_repo.rs deleted file mode 100644 index 4a00595..0000000 --- a/apps/infrastructure/src/persistence/cms/settings_repo.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! JSON file–backed `SettingsRepository`. -//! Path: `/settings.json` - -use std::path::Path; - -use zesdex_domain::cms::{RepositoryError, Settings, SettingsRepository}; - -use crate::utils::write_json_atomic; - -/// Persists `Settings` as pretty-printed JSON at `/settings.json`. -#[derive(Debug, Clone, Default)] -pub struct JsonSettingsRepository; - -impl JsonSettingsRepository { - pub fn new() -> Self { - Self - } -} - -impl SettingsRepository for JsonSettingsRepository { - fn load(&self, base_dir: &Path) -> Result { - let path = base_dir.join("settings.json"); - match std::fs::read_to_string(&path) { - Ok(s) => { - match serde_json::from_str(&s) { - Ok(settings) => Ok(settings), - Err(e) => { - tracing::warn!("settings.json at '{:?}' failed to parse ({e}); falling back to defaults", path); - Ok(Settings::default()) - } - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()), - Err(e) => Err(RepositoryError::Io(e)), - } - } - - fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> { - std::fs::create_dir_all(base_dir)?; - let path = base_dir.join("settings.json"); - write_json_atomic(&path, settings, None)?; - Ok(()) - } -} diff --git a/apps/infrastructure/src/persistence/iam/mod.rs b/apps/infrastructure/src/persistence/iam/mod.rs deleted file mode 100644 index 505f562..0000000 --- a/apps/infrastructure/src/persistence/iam/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Filesystem-backed repository implementations for IAM entities. -//! -//! Implements domain repository traits using JSON file persistence for -//! sessions, OAuth tokens, and PID-file session locks. - -pub mod oauth_repo; -pub mod session_lock_repo; -pub mod session_repo; diff --git a/apps/infrastructure/src/persistence/iam/oauth_repo.rs b/apps/infrastructure/src/persistence/iam/oauth_repo.rs deleted file mode 100644 index c6fe924..0000000 --- a/apps/infrastructure/src/persistence/iam/oauth_repo.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Filesystem-backed `OAuthRepository` implementation. -//! -//! Tokens are stored as a single JSON file with write-then-rename + fsync -//! for crash safety, and restrictive owner-only mode `0o600` on Unix. - -use std::path::Path; - -use zesdex_domain::auth::{OAuthRepository, OAuthToken, RepositoryError}; - -use crate::utils::write_json_atomic; - -/// Concrete filesystem OAuth token repository. -#[derive(Debug, Clone, Default)] -pub struct FileSystemOAuthRepository; - -impl FileSystemOAuthRepository { - pub fn new() -> Self { - FileSystemOAuthRepository - } -} - -impl OAuthRepository for FileSystemOAuthRepository { - fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - write_json_atomic(path, token, Some(0o600))?; - Ok(()) - } - - fn load_token(&self, path: &Path) -> Result, RepositoryError> { - if !path.exists() { - return Ok(None); - } - let data = std::fs::read_to_string(path)?; - let token: OAuthToken = serde_json::from_str(&data)?; - Ok(Some(token)) - } -} diff --git a/apps/infrastructure/src/persistence/iam/session_lock_repo.rs b/apps/infrastructure/src/persistence/iam/session_lock_repo.rs deleted file mode 100644 index 780ab1f..0000000 --- a/apps/infrastructure/src/persistence/iam/session_lock_repo.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Filesystem-backed `SessionLockRepository` implementation using a PID file -//! (`/.lock`) with atomic `O_CREAT|O_EXCL` acquisition. - -use std::convert::TryInto; -use std::io::Write; -use std::path::Path; - -use zesdex_domain::auth::{RepositoryError, SessionLockRepository}; - -/// Concrete filesystem session-lock repository. -#[derive(Debug, Clone, Default)] -pub struct FileSystemSessionLockRepository; - -impl FileSystemSessionLockRepository { - pub fn new() -> Self { - FileSystemSessionLockRepository - } -} - -impl SessionLockRepository for FileSystemSessionLockRepository { - fn try_lock(&self, session_dir: &Path) -> Result { - let path = session_dir.join(".lock"); - let pid = std::process::id(); - - match std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - { - Ok(mut file) => { - write!(file, "{pid}")?; - file.sync_all()?; - return Ok(true); - } - Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(RepositoryError::Io(e)), - } - - let content = std::fs::read_to_string(&path).map_err(RepositoryError::Io)?; - if let Ok(existing_pid) = content.trim().parse::() { - if self.is_alive(existing_pid) { - return Ok(false); - } - } - - let tmp = path.with_extension("lock.tmp"); - { - let mut tmp_file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&tmp) - .map_err(|_| { - RepositoryError::Other("another process is replacing the lock".to_string()) - })?; - write!(tmp_file, "{pid}")?; - tmp_file.sync_all()?; - } - std::fs::rename(&tmp, &path)?; - if let Some(parent) = path.parent() { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } - Ok(true) - } - - fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> { - let path = session_dir.join(".lock"); - std::fs::remove_file(path)?; - Ok(()) - } - - fn is_alive(&self, pid: u32) -> bool { - let pid_signed: i32 = match pid.try_into() { - Ok(p) => p, - Err(_) => return false, - }; - - // Resolve our own executable path once. - let self_exe = match std::fs::read_link("/proc/self/exe") { - Ok(exe) => exe, - Err(_) => return false, - }; - - let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); - - // Phase 1: read /proc//exe and compare with self_exe. - let target = match std::fs::read_link(&proc_exe) { - Ok(t) => t, - Err(_) => return false, - }; - if target != self_exe { - return false; - } - - // Phase 2: verify the process is still alive. - // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks - // whether the process exists and the caller has permission to signal it. - if unsafe { libc::kill(pid_signed, 0) != 0 } { - return false; - } - - // Phase 3: re-check /proc//exe to detect PID reuse between - // Phase 1 and Phase 2. - matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe) - } -} diff --git a/apps/infrastructure/src/persistence/iam/session_repo.rs b/apps/infrastructure/src/persistence/iam/session_repo.rs deleted file mode 100644 index a2b9b6d..0000000 --- a/apps/infrastructure/src/persistence/iam/session_repo.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Filesystem-backed `SessionRepository` implementation. -//! -//! Each session is stored as `/sessions//session.json`. -//! Writes use a write-then-rename + fsync pattern for crash safety. - -use std::path::Path; - -use zesdex_domain::auth::{RepositoryError, Session, SessionId, SessionRepository}; - -use crate::utils::write_json_atomic; - -/// Concrete filesystem session repository. -#[derive(Debug, Clone, Default)] -pub struct FileSystemSessionRepository; - -impl FileSystemSessionRepository { - pub fn new() -> Self { - FileSystemSessionRepository - } -} - -impl SessionRepository for FileSystemSessionRepository { - fn list_sessions(&self, base_dir: &Path) -> Result, RepositoryError> { - let sessions_dir = base_dir.join("sessions"); - let entries = match std::fs::read_dir(&sessions_dir) { - Ok(e) => e, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Ok(Vec::new()); - } - Err(e) => return Err(RepositoryError::Io(e)), - }; - let mut sessions = Vec::new(); - for entry in entries.flatten() { - if !entry.path().is_dir() { - continue; - } - let name = entry.file_name().to_string_lossy().to_string(); - if let Ok(sid) = SessionId::new(&name) { - if let Ok(session) = self.load_session(base_dir, &sid) { - sessions.push(session); - } - } - } - Ok(sessions) - } - - fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result { - let path = base_dir - .join("sessions") - .join(id.as_str()) - .join("session.json"); - if !path.exists() { - return Err(RepositoryError::NotFound(format!( - "session not found: {}", - id.as_str() - ))); - } - let data = std::fs::read_to_string(&path)?; - let session: Session = serde_json::from_str(&data)?; - Ok(session) - } - - fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> { - let dir = session.session_dir(base_dir); - std::fs::create_dir_all(&dir)?; - let path = dir.join("session.json"); - write_json_atomic(&path, session, None)?; - Ok(()) - } - - fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError> { - let dir = base_dir.join("sessions").join(id.as_str()); - if dir.exists() { - std::fs::remove_dir_all(&dir)?; - } - Ok(()) - } -} diff --git a/apps/infrastructure/src/persistence/mod.rs b/apps/infrastructure/src/persistence/mod.rs deleted file mode 100644 index 2ade260..0000000 --- a/apps/infrastructure/src/persistence/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Persistence adapters — concrete file-based repository implementations -//! for both IAM and CMS domain repository traits. - -pub mod cms; -pub mod iam; -pub mod sqlite; - -pub use cms::{ - app_config_repo::JsonAppConfigRepository, conversation_repo::JsonConversationRepository, - edit_log_repo::JsonlEditLogRepository, memory_repo::MarkdownMemoryRepository, - rewind_blob_repo::FileRewindBlobRepository, settings_repo::JsonSettingsRepository, -}; -pub use iam::{ - oauth_repo::FileSystemOAuthRepository, session_lock_repo::FileSystemSessionLockRepository, - session_repo::FileSystemSessionRepository, -}; diff --git a/apps/infrastructure/src/persistence/sqlite/database.rs b/apps/infrastructure/src/persistence/sqlite/database.rs deleted file mode 100644 index 8e8ce46..0000000 --- a/apps/infrastructure/src/persistence/sqlite/database.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! SQLite database connection initialisation and schema migrations. - -use std::sync::{Arc, Mutex}; - -/// A shared SQLite connection wrapped for thread-safe access. -#[derive(Clone)] -pub struct DbConn { - conn: Arc>, -} - -impl DbConn { - /// Execute a closure with a reference to the underlying connection. - pub fn with(&self, f: F) -> anyhow::Result - where - F: FnOnce(&rusqlite::Connection) -> anyhow::Result, - { - let conn = self - .conn - .lock() - .map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?; - f(&conn) - } -} - -const SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - title TEXT NOT NULL DEFAULT '', - model TEXT NOT NULL DEFAULT '', - workspace_roots TEXT NOT NULL DEFAULT '[]', - message_count INTEGER NOT NULL DEFAULT 0, - token_count INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - summary TEXT -); - -CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - data TEXT NOT NULL DEFAULT '{}', - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS conversations ( - session_id TEXT PRIMARY KEY, - data TEXT NOT NULL DEFAULT '{}', - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS memories ( - name TEXT PRIMARY KEY, - data TEXT NOT NULL DEFAULT '{}', - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS edit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - entry TEXT NOT NULL, - created_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_edit_logs_session - ON edit_logs (session_id); -"#; - -/// Initialise a shared SQLite connection at the given path. -pub fn init_db(db_path: &str) -> anyhow::Result { - let conn = rusqlite::Connection::open(db_path) - .map_err(|e| anyhow::anyhow!("failed to open SQLite database at '{db_path}': {e}"))?; - - conn.execute_batch("PRAGMA journal_mode = WAL;")?; - conn.execute_batch("PRAGMA busy_timeout = 5000;")?; - - Ok(DbConn { - conn: Arc::new(Mutex::new(conn)), - }) -} - -/// Run embedded SQL schema migrations. -pub fn run_migrations(db: &DbConn) -> anyhow::Result<()> { - db.with(|conn| { - conn.execute_batch(SCHEMA_SQL) - .map_err(|e| anyhow::anyhow!("failed to execute database schema migrations: {e}")) - })?; - Ok(()) -} diff --git a/apps/infrastructure/src/persistence/sqlite/mod.rs b/apps/infrastructure/src/persistence/sqlite/mod.rs deleted file mode 100644 index eb37b20..0000000 --- a/apps/infrastructure/src/persistence/sqlite/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! SQLite database connection management and schema migrations. - -pub mod database; diff --git a/apps/infrastructure/src/runtime.rs b/apps/infrastructure/src/runtime.rs deleted file mode 100644 index 8233ce5..0000000 --- a/apps/infrastructure/src/runtime.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Process-wide shared Tokio runtime for sync → async bridging. -//! -//! Many `Tool::run` implementations are synchronous but need to drive async -//! work (LLM calls, subagent execution). Creating a fresh -//! [`tokio::runtime::Runtime`] on every call is expensive (spawns a thread -//! pool + runtime each time) and can fail randomly under thread pressure. -//! -//! # Flow -//! -//! [`runtime()`] returns a lazily-initialised process-wide runtime created -//! exactly once via [`std::sync::OnceLock`]. Callers use -//! `runtime().block_on(...)` exactly like they would with a local runtime — -//! the only difference is the runtime is shared, so the cost is paid once per -//! process instead of once per tool call. -//! -//! # Safety -//! -//! `block_on` panics if called from within a running Tokio runtime. The -//! tools that use this helper are synchronous (`Tool::run`), so this is safe -//! in practice. Async code should never call `runtime().block_on`. - -use std::sync::OnceLock; - -/// Maximum worker threads for the shared runtime. Kept modest — tools are -/// mostly I/O-bound and rarely need more concurrency than this. -const RUNTIME_WORKER_THREADS: usize = 8; - -static SHARED_RUNTIME: OnceLock = OnceLock::new(); - -/// Return the process-wide shared Tokio runtime, initialising it on first use. -/// -/// The runtime is configured with `worker_threads = 8` and -/// `enable_all()` (time + IO drivers) so streams, timers, and network calls -/// all work. If initialisation fails (extremely rare — resource exhaustion at -/// startup), the process aborts with a clear message rather than returning -/// an error on every subsequent call. -pub fn runtime() -> &'static tokio::runtime::Runtime { - SHARED_RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(RUNTIME_WORKER_THREADS) - .thread_name("zesdex-shared-rt") - .enable_all() - .build() - .expect("failed to create shared tokio runtime") - }) -} - -#[cfg(test)] -mod tests { - use super::runtime; - - #[test] - fn runtime_is_singleton() { - assert!(std::ptr::eq(runtime(), runtime())); - } - - #[test] - fn runtime_blocks_and_resolves() { - let val = runtime().block_on(async { 6 * 7 }); - assert_eq!(val, 42); - } -} diff --git a/apps/infrastructure/src/subagent/auto/engine.rs b/apps/infrastructure/src/subagent/auto/engine.rs deleted file mode 100644 index 755b06e..0000000 --- a/apps/infrastructure/src/subagent/auto/engine.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! Auto-review engine — after edits, reviews AND auto-fixes issues using -//! tools + LLM, all synchronously in a background thread (no tokio runtime -//! needed). -//! -//! Flow: after an agent turn with edits completes: -//! 1. Emit `WorkflowAgentUpdate(Running)` → visible in workflow sidebar -//! 2. Run `git diff` to get the changed files -//! 3. Call LLM with the diff to identify issues and suggested fixes -//! 4. Apply fixes using sync tools (edit/write) -//! 5. Results stream as `TurnEvent::SystemNote` events -//! 6. Emit `WorkflowAgentUpdate(Completed)` when done - -use std::collections::VecDeque; -use std::path::PathBuf; -use std::process::Command; -use std::sync::{Arc, Mutex}; - -use tracing::{debug, info, instrument, warn}; - -use crate::llm::provider::LlmClient; -use crate::subagent::division::{tools_for, AccessTier}; -use crate::tools::{Tool, ToolCtx}; -use crate::{AgentStatus, TurnEvent}; -use zesdex_domain::core::ChatMessage; - -const REVIEW_AGENT_ID: &str = "auto-review"; - -/// Spawn a background task that reviews changes and auto-fixes issues. -/// -/// Runs asynchronously using tokio::spawn. -#[instrument(skip(turn_events))] -pub fn spawn_background_review( - workspace_roots: Vec, - turn_events: Arc>>, - api_key: String, - model: String, - api_base: Option, -) { - let agent_id = REVIEW_AGENT_ID.to_string(); - let agent_name = "Auto-Review".to_string(); - - tokio::spawn(async move { - let root = match workspace_roots.first() { - Some(r) => r.clone(), - None => { - debug!("auto-review: no workspace root, skipping"); - return; - } - }; - - info!("auto-review: starting"); - - // Mark Running in workflow panel - push_event( - &turn_events, - TurnEvent::WorkflowAgentUpdate { - agent_id: agent_id.clone(), - agent_name: agent_name.clone(), - status: AgentStatus::Running, - }, - ); - - // 1. Get the git diff to know what changed - let diff = match get_git_diff(&root) { - Ok(d) if !d.is_empty() => d, - Ok(_) => { - debug!("auto-review: no changes detected"); - push_event( - &turn_events, - TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status: AgentStatus::Completed, - }, - ); - return; - } - Err(e) => { - debug!(error = %e, "auto-review: git diff failed"); - push_event( - &turn_events, - TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status: AgentStatus::Failed(e), - }, - ); - return; - } - }; - - push_event( - &turn_events, - TurnEvent::SystemNote { - kind: "review".into(), - message: "🔍 Auto-review: examining and fixing issues...".into(), - }, - ); - - // 2. Build tool context + load write-tier tools - let tool_ctx = ToolCtx::builder() - .session_dir(root.join(".zesdex").join("sessions").join("auto-review")) - .workspaces(workspace_roots.clone()) - .turn_events(turn_events.clone()) - .build(); - - let tools = tools_for(&AccessTier::Write); - - // 3. Resolve LLM credentials - let base_url = api_base.unwrap_or_else(|| { - std::env::var("OPENAI_API_BASE") - .unwrap_or_else(|_| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()) - }); - - let client = LlmClient::new(api_key, model, Some(base_url)); - - // 4. Truncate diff if needed - const MAX_DIFF_CHARS: usize = 5000; - let truncated_diff = if diff.len() > MAX_DIFF_CHARS { - push_event( - &turn_events, - TurnEvent::SystemNote { - kind: "review".into(), - message: format!( - "📐 Diff is large ({} chars), reviewing first {} chars...", - diff.len(), - MAX_DIFF_CHARS - ), - }, - ); - format!( - "{}...\n[diff truncated at {} characters]", - crate::utils::truncate_chars(&diff, MAX_DIFF_CHARS), - MAX_DIFF_CHARS - ) - } else { - diff.to_string() - }; - - // 5. Call LLM to review the diff and suggest fixes. - let system_msg = ChatMessage::system( - "You are an auto-review subagent. Your ONLY job:\n\ - 1. Review the git diff below for:\n\ - - Typos and spelling errors\n\ - - Missing imports or undefined references\n\ - - Syntax errors or type mismatches\n\ - - Logic bugs or off-by-one errors\n\ - - Missing error handling\n\ - - Security issues\n\n\ - 2. For each issue found, output a command to fix it.\n\n\ - Available commands:\n\ - - `edit ` then provide the old text and new text\n\ - - `write ` then provide the new content\n\n\ - Output format:\n\ - If no issues: NO_ISSUES_FOUND\n\n\ - If issues found:\n\ - ---\n\ - FILE: \n\ - ISSUE: \n\ - SEVERITY: HIGH|MEDIUM|LOW\n\ - OLD: \n\ - NEW: \n\ - ---" - .to_string(), - ); - - let user_msg = ChatMessage::user(format!( - "Review and fix this git diff:\n\n```diff\n{truncated_diff}\n```" - )); - - let response = run_llm_review(&client, &[system_msg, user_msg]).await; - - let response_text = match response { - Ok(text) => text, - Err(e) => { - warn!(error = %e, "auto-review LLM call failed"); - push_event( - &turn_events, - TurnEvent::SystemNote { - kind: "review".into(), - message: format!("⚠️ Auto-review failed: {e}"), - }, - ); - push_event( - &turn_events, - TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status: AgentStatus::Failed(e.to_string()), - }, - ); - return; - } - }; - - // 6. Parse and apply fixes - if response_text.trim() == "NO_ISSUES_FOUND" || response_text.trim().is_empty() { - push_event( - &turn_events, - TurnEvent::SystemNote { - kind: "review".into(), - message: "✅ Auto-review: no issues found.".into(), - }, - ); - info!("auto-review: no issues found"); - } else { - // Try to apply structured fixes - let fix_count = apply_fixes_from_response(&response_text, &tools, &tool_ctx); - - push_event( - &turn_events, - TurnEvent::SystemNote { - kind: "review_finding".into(), - message: format!( - "📋 Auto-review complete ({} fix(es) applied).\n{}", - fix_count, - response_text.trim() - ), - }, - ); - info!(fix_count, "auto-review: completed with fixes"); - } - - push_event( - &turn_events, - TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status: AgentStatus::Completed, - }, - ); - }); -} - -/// Run the LLM review call asynchronously using ProviderService. -async fn run_llm_review(client: &LlmClient, messages: &[ChatMessage]) -> Result { - use zesdex_application::ports::ProviderService; - match client.chat(messages, None, Some(1024), Some(0.3)).await { - Ok((msg, _)) => Ok(msg.content.unwrap_or_default()), - Err(e) => Err(e.to_string()), - } -} - -/// Parse the LLM response for structured fix commands and apply them. -fn apply_fixes_from_response(response: &str, tools: &[Box], tool_ctx: &ToolCtx) -> usize { - let mut fix_count = 0; - - // Parse structured fix blocks - let blocks: Vec<&str> = response.split("---").collect(); - - for block in &blocks { - let trimmed = block.trim(); - if trimmed.is_empty() { - continue; - } - - let lines: Vec<&str> = trimmed.lines().map(|l| l.trim()).collect(); - if lines.len() < 4 { - continue; - } - - // Try to extract structured fix - let file_path = extract_field(&lines, "FILE:").unwrap_or(""); - let severity = extract_field(&lines, "SEVERITY:").unwrap_or("LOW"); - let old_text = extract_field(&lines, "OLD:").unwrap_or(""); - let new_text = extract_field(&lines, "NEW:").unwrap_or(""); - - if file_path.is_empty() || old_text.is_empty() || new_text.is_empty() { - continue; - } - - // Only auto-fix HIGH and MEDIUM severity issues - if severity != "HIGH" && severity != "MEDIUM" { - debug!(severity, file = file_path, "skipping LOW severity fix"); - continue; - } - - // Try to apply the fix using the edit tool - if let Some(edit_tool) = tools.iter().find(|t| t.name() == "edit") { - let args = serde_json::json!({ - "path": file_path, - "old": old_text, - "new": new_text, - "reason": "auto-review fix" - }); - - match edit_tool.run(tool_ctx, &args) { - Ok(result) => { - info!(file = file_path, "auto-review fix applied: {result}"); - fix_count += 1; - } - Err(e) => { - debug!(file = file_path, error = %e, "auto-review fix failed"); - } - } - } - } - - fix_count -} - -/// Extract a field value from parsed lines (e.g. "FILE: src/main.rs" → "src/main.rs"). -fn extract_field<'a>(lines: &[&'a str], prefix: &str) -> Option<&'a str> { - for line in lines { - if let Some(val) = line.strip_prefix(prefix) { - let trimmed = val.trim(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } - } - None -} - -/// Run `git diff` to get workspace changes (both staged and unstaged). -fn get_git_diff(workspace_root: &PathBuf) -> Result { - let git_dir = workspace_root.join(".git"); - if !git_dir.exists() { - return Err("not a git repository".to_string()); - } - - let mut combined = String::new(); - - // Unstaged diff - if let Ok(out) = Command::new("git") - .args(["diff"]) - .current_dir(workspace_root) - .output() - { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - combined.push_str("=== Unstaged Changes ===\n"); - combined.push_str(&stdout); - combined.push('\n'); - } - } - - // Staged diff - if let Ok(out) = Command::new("git") - .args(["diff", "--cached"]) - .current_dir(workspace_root) - .output() - { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - combined.push_str("=== Staged Changes ===\n"); - combined.push_str(&stdout); - combined.push('\n'); - } - } - - if combined.is_empty() { - return Err("no changes".to_string()); - } - - Ok(combined) -} - -/// Push a TurnEvent onto the shared event queue. -fn push_event(queue: &Arc>>, event: TurnEvent) { - if let Ok(mut q) = queue.lock() { - q.push_back(event); - } -} diff --git a/apps/infrastructure/src/subagent/auto/mod.rs b/apps/infrastructure/src/subagent/auto/mod.rs deleted file mode 100644 index c107ab5..0000000 --- a/apps/infrastructure/src/subagent/auto/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Auto-subagents — automatically run review/test subagents at the end of -//! each turn. - -pub mod engine; -pub mod paths; diff --git a/apps/infrastructure/src/subagent/auto/paths.rs b/apps/infrastructure/src/subagent/auto/paths.rs deleted file mode 100644 index 961c920..0000000 --- a/apps/infrastructure/src/subagent/auto/paths.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Auto-subagent path resolution. - -use std::path::{Path, PathBuf}; - -/// Resolve paths for auto-subagent scripts. -pub fn auto_subagent_dir(base_dir: &Path) -> PathBuf { - base_dir.join("auto-agents") -} diff --git a/apps/infrastructure/src/subagent/context.rs b/apps/infrastructure/src/subagent/context.rs deleted file mode 100644 index 6770bff..0000000 --- a/apps/infrastructure/src/subagent/context.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Subagent execution context — wraps the shared state needed by a subagent. -//! -//! Includes LLM connection parameters (base_url, api_key, model) so the -//! engine can construct an `LlmClient` without loading settings itself. - -use crate::tools::ToolCtx; - -/// Context for a single subagent execution. -/// -/// Flow: constructed by the caller (e.g. `execute_primitive`) with resolved -/// LLM credentials → passed to `engine::run_agent` → used to create the -/// `LlmClient` for LLM interaction. -pub struct SubagentContext { - /// The directive/instruction the subagent should execute. - pub directive: String, - /// Shared tool execution context (workspaces, session, memory paths). - pub tool_ctx: ToolCtx, - /// Access tier as a string (used for logging/serialization). - pub access_tier: String, - /// Base URL for the LLM provider API. - pub base_url: String, - /// API key for the LLM provider. - pub api_key: String, - /// Model identifier for the LLM provider. - pub model: String, -} - -impl SubagentContext { - /// Create a new subagent context with all required fields. - pub fn new( - directive: String, - tool_ctx: ToolCtx, - access_tier: String, - base_url: String, - api_key: String, - model: String, - ) -> Self { - SubagentContext { - directive, - tool_ctx, - access_tier, - base_url, - api_key, - model, - } - } -} diff --git a/apps/infrastructure/src/subagent/division.rs b/apps/infrastructure/src/subagent/division.rs deleted file mode 100644 index f9d3382..0000000 --- a/apps/infrastructure/src/subagent/division.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Subagent division — access-tier tool filtering for subagent permissions. -//! -//! Flow: the calling code picks an `AccessTier` → `tools_for()` returns the -//! subset of all built-in tools allowed at that tier → those tools are passed -//! to `engine::run_agent` for the subagent's tool-execution loop. - -use crate::tools::Tool; - -pub use zesdex_domain::subagent::AccessTier; - -/// Filter the available tools to match the given access tier. -/// -/// Flow: `all_tools()` → filter by tier → return owned `Vec>`. -/// -/// Read tier: non-mutating introspection and utility tools only. -/// Write tier: everything except dangerous system/network/process tools. -/// Full tier: all 37 tools. -pub fn tools_for(access: &AccessTier) -> Vec> { - let all = crate::tools::all_tools(); - - match access { - AccessTier::Read => all - .into_iter() - .filter(|t| { - let name = t.name(); - matches!( - name, - "read" - | "grep" - | "glob" - | "pong" - | "todowrite" - | "todofinish" - | "dir_list" - | "dir_cache_update" - | "cd" - | "remember" - | "recall" - | "forget" - ) - }) - .collect(), - - AccessTier::Write => all - .into_iter() - .filter(|t| { - let name = t.name(); - !matches!( - name, - "bash" - | "bash_output" - | "bash_kill" - | "git_operator" - | "git_worktree" - | "git_cred" - | "shell" - | "workflow_run" - | "note_finding" - | "read_findings" - | "hive_mind" - | "spawn_agents" - | "spawn_pipeline" - | "plan_enter" - | "plan_ready" - | "sequential_think" - ) - }) - .collect(), - - AccessTier::Full => all, // everything - } -} diff --git a/apps/infrastructure/src/subagent/engine.rs b/apps/infrastructure/src/subagent/engine.rs deleted file mode 100644 index cdb48b3..0000000 --- a/apps/infrastructure/src/subagent/engine.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! Subagent engine — runs an LLM-powered agent with tool execution loop. -//! -//! Flow: construct system message → call LLM → parse tool calls → execute -//! tools → continue until the model returns a final text response (no more -//! tool calls) or the iteration limit is reached. -//! -//! Progress reporting: when a `TurnEvent` queue is available via the -//! `ToolCtx`, the engine emits `AgentProgress` events so the TUI can show -//! which tool the subagent is currently executing. - -use anyhow::Result; -use tracing::{debug, info, instrument}; - -use crate::llm::provider::LlmClient; -use crate::subagent::context::SubagentContext; -use crate::subagent::division::{tools_for, AccessTier}; -use crate::tools::{tool_defs, Tool, ToolCtx}; -use serde_json::Value; -use zesdex_domain::agent::progress::AgentProgress; -use zesdex_domain::core::tool_call::sanitize_tool_arguments; -use zesdex_domain::core::ChatMessage; -use zesdex_domain::subagent_directive; - -/// Maximum number of tool-call iterations before the engine gives up. -const MAX_ITERATIONS: u32 = 25; - -/// A single tool-result message is truncated before entering the subagent's -/// context so it cannot blow the window (matches the main turn service). -const TOOL_OUTPUT_MAX_CHARS: usize = 12_000; - -/// Maximum consecutive identical tool errors before the engine injects a -/// recovery note steering the model to a different approach. -const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3; - -/// Maximum number of read-only tool calls executed concurrently in a single -/// subagent batch. Read-only tools (read/grep/glob/…) block on disk I/O, so -/// running them on parallel OS threads removes the serial round-trip latency -/// for a batch of independent lookups, mirroring the main turn loop. -const MAX_PARALLEL_TOOLS: usize = 8; - -/// Execute a batch of tool calls, running read-only tools concurrently when -/// the whole batch is parallel-safe. -/// -/// Returns one `(tool_call_id, tool_name, result)` per call **in the original -/// call order** (OpenAI/Anthropic tool-result ordering contract). `Tool::run` -/// is synchronous, so real parallelism comes from scoped OS threads; `Tool` -/// and `ToolCtx` are `Send + Sync`, so the borrowed references can be shared -/// across the short-lived scoped threads. -/// -/// If any single tool in the batch mutates state (edit/write/bash/git/…), the -/// whole batch falls back to the safe sequential path so writes never race. -fn execute_tool_batch( - tools: &[Box], - tool_ctx: &ToolCtx, - tool_calls: &[zesdex_domain::core::ToolCall], -) -> Vec<(String, String, String)> { - let parallel = tool_calls.len() > 1 - && tool_calls - .iter() - .all(|tc| crate::tools::tool_is_parallel_safe(&tc.function.name)); - - if !parallel { - // Sequential fallback (kept identical to the historical behavior). - return tool_calls - .iter() - .map(|tc| { - let tool_name = tc.function.name.clone(); - let args = sanitize_tool_arguments(&tc.function.arguments); - let result = run_one_tool(tools, tool_ctx, &tool_name, &args); - (tc.id.clone(), tool_name, result) - }) - .collect(); - } - - // Bounded parallel path: process the batch in windows of - // `MAX_PARALLEL_TOOLS` so concurrency stays bounded, joining each window - // before the next so results stay in original order. - let mut ordered = Vec::with_capacity(tool_calls.len()); - for window in tool_calls.chunks(MAX_PARALLEL_TOOLS) { - let window_results = std::thread::scope(|s| { - let handles: Vec<_> = window - .iter() - .map(|tc| { - let tool_name = tc.function.name.clone(); - let args = sanitize_tool_arguments(&tc.function.arguments); - s.spawn(move || { - debug!("Subagent executing tool: {tool_name}"); - run_one_tool(tools, tool_ctx, &tool_name, &args) - }) - }) - .collect(); - handles - .into_iter() - .map(|h| { - h.join() - .unwrap_or_else(|_| "Error: tool panicked".to_string()) - }) - .collect::>() - }); - for (tc, result) in window.iter().zip(window_results) { - ordered.push((tc.id.clone(), tc.function.name.clone(), result)); - } - } - ordered -} - -/// Run a single synchronous tool call and capture its result string. -fn run_one_tool( - tools: &[Box], - tool_ctx: &ToolCtx, - tool_name: &str, - args: &Value, -) -> String { - if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) { - match tool.run(tool_ctx, args) { - Ok(output) => output, - Err(e) => format!("Error: {e}"), - } - } else { - format!("Unknown tool: {tool_name}") - } -} - -/// Pick a `max_tokens` budget proportional to the directive's length. -fn adaptive_max_tokens(directive_len: usize) -> u32 { - if directive_len <= 80 { - 800 - } else if directive_len <= 400 { - 1600 - } else { - 4096 - } -} - -fn truncate_tool_output(output: String) -> String { - if output.len() <= TOOL_OUTPUT_MAX_CHARS { - return output; - } - let mut result: String = output.chars().take(TOOL_OUTPUT_MAX_CHARS).collect(); - result.push_str(&format!( - "\n...[truncated {} chars]", - output.len() - TOOL_OUTPUT_MAX_CHARS - )); - result -} - -/// Emit an `AgentProgress` event onto the turn-event queue, if one is -/// configured in the `ToolCtx`. -fn report_progress(tool_ctx: &ToolCtx, progress: AgentProgress) { - if let Some(ref queue) = tool_ctx.turn_events { - if let Ok(mut q) = queue.lock() { - q.push_back(zesdex_domain::agent::TurnEvent::AgentProgress(progress)); - } - } -} - -/// Build the system message for a subagent, including current working -/// directory and workspace root information. -fn build_system_message(directive: &str, tool_ctx: &ToolCtx) -> ChatMessage { - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); - let ws_root = tool_ctx - .workspaces - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| cwd.clone()); - - ChatMessage::system(subagent_directive(directive, &cwd, &ws_root)) -} - -/// Run an agent with a directive, access tier, and tool context. -/// -/// Flow: -/// 1. Resolve allowed tools for the given `access` tier. -/// 2. Build a system prompt from the directive using the domain prompt module. -/// 3. Loop (up to `MAX_ITERATIONS`): -/// a. Call the LLM (non-streaming) with accumulated messages + tool defs. -/// b. If the response has no tool calls → return the text content. -/// c. Otherwise execute each tool call and append the result as a -/// tool-role message. -/// d. If the response also contained text, append an assistant message. -/// 4. If the loop exits naturally, return the iteration-limit message. -/// -/// Progress: each tool invocation is reported via `AgentProgress` if a -/// turn-event queue is available in the `ToolCtx`. -#[instrument(skip(ctx, tool_ctx))] -pub async fn run_agent( - ctx: SubagentContext, - directive: &str, - access: AccessTier, - tool_ctx: ToolCtx, -) -> Result { - info!("Subagent starting with directive: {directive}"); - - let tools = tools_for(&access); - let defs = tool_defs(&tools); - - let sys_msg = build_system_message(directive, &tool_ctx); - - let mut messages = vec![sys_msg]; - - let client = LlmClient::new( - ctx.api_key.clone(), - ctx.model.clone(), - Some(ctx.base_url.clone()), - ); - - let max_tokens = adaptive_max_tokens(directive.len()); - - // Track repeated tool errors so the agent can recover from a dead end. - let mut consecutive_errors = 0usize; - let mut last_tool = String::new(); - - // Limited iteration loop so we don't run forever - for iteration in 0..MAX_ITERATIONS { - use zesdex_application::ports::ProviderService; - let (response_msg, _usage) = client - .chat(&messages, Some(defs.clone()), Some(max_tokens), Some(0.2)) - .await?; - - let content = response_msg.content.clone().unwrap_or_default(); - let tool_calls = response_msg.tool_calls.clone().unwrap_or_default(); - - // If no tool calls, we're done — return content - if tool_calls.is_empty() { - info!("Subagent completed after {iteration} iterations"); - report_progress(&tool_ctx, AgentProgress::completed("subagent", directive)); - return Ok(content); - } - - // Push the assistant message (with its tool_calls) BEFORE executing - // so the tool-calling contract is honoured: tool results reference - // the calls declared in the preceding assistant message. Without - // this, the history is malformed (`[...tool, tool, assistant]`). - messages.push(response_msg); - - // Execute tool calls — read-only batches run concurrently (bounded, - // order preserved); any mutating tool forces the safe sequential path. - let results = execute_tool_batch(&tools, &tool_ctx, &tool_calls); - - for (id, tool_name, result) in results { - debug!("Subagent tool {tool_name} finished"); - - report_progress( - &tool_ctx, - AgentProgress::running( - "subagent", - format!("{}:{tool_name}", directive), - Some(tool_name.clone()), - ), - ); - - // Error-recovery: if the same tool keeps failing, inject a - // system note steering the model to a different approach. - if result.starts_with("Error:") { - if last_tool.as_str() == tool_name.as_str() { - consecutive_errors += 1; - } else { - consecutive_errors = 1; - last_tool = tool_name.clone(); - } - if consecutive_errors >= MAX_CONSECUTIVE_TOOL_ERRORS { - messages.push(ChatMessage::system( - zesdex_domain::agent::prompt::error_recovery_note(&tool_name, &result), - )); - consecutive_errors = 0; - } - } else { - consecutive_errors = 0; - } - - messages.push(ChatMessage::tool(id, truncate_tool_output(result))); - } - } - - info!("Subagent reached iteration limit ({MAX_ITERATIONS})"); - report_progress( - &tool_ctx, - AgentProgress::failed( - "subagent", - directive, - format!("iteration limit ({MAX_ITERATIONS})"), - ), - ); - Ok(format!( - "Subagent reached iteration limit ({MAX_ITERATIONS})" - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tools::ToolCtxBuilder; - use serde_json::json; - - /// A deterministic mock tool whose `run` returns its own name (opting into - /// an optional sleep to make parallel-vs-sequential observable). - struct MockTool { - name: &'static str, - sleep_ms: u64, - } - - impl MockTool { - fn new(name: &'static str, sleep_ms: u64) -> Self { - Self { name, sleep_ms } - } - } - - impl Tool for MockTool { - fn name(&self) -> &'static str { - self.name - } - fn description(&self) -> &'static str { - "mock tool for tests" - } - fn parameters(&self) -> Value { - json!({"type":"object","properties":{}}) - } - fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result { - if self.sleep_ms > 0 { - std::thread::sleep(std::time::Duration::from_millis(self.sleep_ms)); - } - Ok(self.name.to_string()) - } - } - - fn tc(name: &str, id: usize) -> zesdex_domain::core::ToolCall { - zesdex_domain::core::ToolCall { - id: format!("call_{id}"), - type_: "function".to_string(), - function: zesdex_domain::core::ToolFunction { - name: name.to_string(), - arguments: serde_json::Value::String(String::new()), - }, - } - } - - fn ctx() -> ToolCtx { - ToolCtxBuilder::default().build() - } - - #[test] - fn parallel_batch_preserves_original_order() { - let tools: Vec> = vec![ - Box::new(MockTool::new("read", 0)), - Box::new(MockTool::new("grep", 0)), - ]; - let calls = vec![tc("read", 1), tc("grep", 2), tc("read", 3)]; - - let results = execute_tool_batch(&tools, &ctx(), &calls); - - // Results keep the assistant's original call order. - let names: Vec<&str> = results.iter().map(|(_, n, _)| n.as_str()).collect(); - assert_eq!(names, vec!["read", "grep", "read"]); - // IDs follow the same original order (ordering contract). - let ids: Vec<&str> = results.iter().map(|(id, _, _)| id.as_str()).collect(); - assert_eq!(ids, vec!["call_1", "call_2", "call_3"]); - } - - #[test] - fn parallel_read_batch_is_faster_than_sequential() { - // Both reads sleep 30ms each. Parallel should finish ~30ms (both run - // at once), sequential would take ~60ms. - let tools: Vec> = vec![Box::new(MockTool::new("read", 30))]; - let calls = vec![tc("read", 1), tc("read", 2)]; - - let started = std::time::Instant::now(); - let results = execute_tool_batch(&tools, &ctx(), &calls); - let elapsed = started.elapsed(); - - assert_eq!(results.len(), 2); - assert!( - elapsed < std::time::Duration::from_millis(55), - "parallel read batch took {elapsed:?}, expected concurrent execution" - ); - assert!(elapsed >= std::time::Duration::from_millis(25)); - } - - #[test] - fn mutating_tool_forces_sequential_batch() { - // A batch containing a mutating tool ("write") must NOT run in - // parallel — the single 30ms read runs alone, then the write runs. - let tools: Vec> = vec![ - Box::new(MockTool::new("read", 30)), - Box::new(MockTool::new("write", 0)), - ]; - let calls = vec![tc("read", 1), tc("write", 2)]; - - let results = execute_tool_batch(&tools, &ctx(), &calls); - - let names: Vec<&str> = results.iter().map(|(_, n, _)| n.as_str()).collect(); - assert_eq!(names, vec!["read", "write"]); - let ids: Vec<&str> = results.iter().map(|(id, _, _)| id.as_str()).collect(); - assert_eq!(ids, vec!["call_1", "call_2"]); - } -} diff --git a/apps/infrastructure/src/subagent/mod.rs b/apps/infrastructure/src/subagent/mod.rs deleted file mode 100644 index 8f5b250..0000000 --- a/apps/infrastructure/src/subagent/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Subagent spawning and execution engine — spawn managed sub-processes -//! for test generation, architecture review, security review, etc. - -pub mod auto; -pub mod context; -pub mod division; -pub mod engine; -pub mod provider; -pub mod spawn; diff --git a/apps/infrastructure/src/subagent/provider.rs b/apps/infrastructure/src/subagent/provider.rs deleted file mode 100644 index 1657490..0000000 --- a/apps/infrastructure/src/subagent/provider.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Subagent LLM provider — resolves provider/model from settings and wraps -//! `LlmClient` in a higher-level API for subagent use. -//! -//! Flow: `resolve_subagent_provider` is called at startup to pick a provider -//! + model → `SubagentProvider` wraps that pair around an `LlmClient` for use -//! inside the subagent engine loop. - -use anyhow::Result; -use tracing::instrument; - -use crate::llm::provider::LlmClient; -use crate::tools::{tool_defs, Tool}; -use zesdex_domain::core::ChatMessage; - -/// Provider wrapper for subagent LLM interactions. -/// -/// Provides two convenience methods (`chat`, `chat_with_tools`) that -/// abstract away the raw `LlmClient` parameter plumbing so the engine -/// loop only deals with messages and tools. -/// -/// The model identifier is already embedded in the `LlmClient` itself -/// (its `model` field), so `SubagentProvider` does not duplicate it. -pub struct SubagentProvider { - client: LlmClient, -} - -impl SubagentProvider { - /// Wrap an existing `LlmClient` for higher-level use. - #[instrument(skip(client))] - pub fn new(client: LlmClient) -> Self { - Self { client } - } - - /// Send messages to the LLM without any tool definitions. - /// - /// Use this for a plain text-in/text-out conversation. - #[tracing::instrument(skip(self, messages))] - pub async fn chat( - &self, - messages: &[ChatMessage], - ) -> Result<(ChatMessage, Option<(u64, u64)>)> { - use zesdex_application::ports::ProviderService; - self.client.chat(messages, None, Some(4096), None).await - } - - /// Send messages with available tool definitions. - /// - /// Automatically converts the `&[Box]` slice to - /// `Vec` before passing to the underlying client. - #[tracing::instrument(skip(self, messages, tools))] - pub async fn chat_with_tools( - &self, - messages: &[ChatMessage], - tools: &[Box], - ) -> Result<(ChatMessage, Option<(u64, u64)>)> { - let defs = tool_defs(tools); - use zesdex_application::ports::ProviderService; - self.client - .chat(messages, Some(defs), Some(4096), None) - .await - } -} - -/// Resolve subagent provider and model from settings. -/// -/// Flow: reads `settings.provider` and `settings.model` → if provider is -/// empty, falls back to `app_config.default_provider` → if model is empty, -/// falls back to the provider config's `default_model` → if that is also -/// empty, uses `app_config.default_model` → finally the domain default model -/// constant. -#[instrument] -pub fn resolve_subagent_provider( - settings: &zesdex_domain::cms::Settings, - app_config: &zesdex_domain::cms::AppConfig, -) -> (String, String) { - let provider = if settings.provider.is_empty() { - app_config.default_provider.clone() - } else { - settings.provider.clone() - }; - let model = settings.model.clone(); - - // Use the default model from the provider config if available - let model = if model.is_empty() { - app_config - .providers - .get(&provider) - .and_then(|p| p.default_model.clone()) - .or_else(|| Some(app_config.default_model.clone())) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string()) - } else { - model - }; - - (provider, model) -} diff --git a/apps/infrastructure/src/subagent/spawn.rs b/apps/infrastructure/src/subagent/spawn.rs deleted file mode 100644 index e03bebc..0000000 --- a/apps/infrastructure/src/subagent/spawn.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Subagent spawning — launch a subagent on a background OS thread. -//! -//! Flow: creates a new tokio runtime on a dedicated OS thread, then -//! `block_on` the engine's `run_agent` future. Returns a -//! `JoinHandle>` the caller can `.join()`. - -use std::thread; - -use anyhow::Result; -use tracing::{info, instrument}; - -use crate::subagent::context::SubagentContext; -use crate::subagent::division::AccessTier; -use crate::subagent::engine::run_agent; -use crate::tools::ToolCtx; - -/// Spawn a subagent on a background OS thread. -/// -/// The subagent runs inside its own tokio runtime so it can make async calls -/// without blocking the calling thread's runtime. -/// -/// Flow: `thread::spawn` → create `tokio::runtime::Runtime` → -/// `runtime.block_on(run_agent(...))` → return. -/// -/// Returns a `JoinHandle` the caller can `join()` to await the result. -#[instrument(skip(ctx, tool_ctx))] -pub fn spawn_subagent( - ctx: SubagentContext, - directive: String, - access: AccessTier, - tool_ctx: ToolCtx, -) -> thread::JoinHandle> { - info!("Spawning subagent: {directive}"); - thread::spawn(move || { - crate::runtime::runtime().block_on(run_agent(ctx, &directive, access, tool_ctx)) - }) -} diff --git a/apps/infrastructure/src/tools/bash_tools.rs b/apps/infrastructure/src/tools/bash_tools.rs deleted file mode 100644 index 545e701..0000000 --- a/apps/infrastructure/src/tools/bash_tools.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Background bash process output and kill tools. -//! -//! These tools allow the agent to inspect the output of a background shell job -//! (`BashOutput`) and to terminate a running background job (`BashKill`). - -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument, warn}; - -use crate::tools::{arg_str, Tool, ToolCtx}; - -/// Get the output of a background bash job by ID. -/// -/// Flow: look up `{session_dir}/bash-outputs/{job_id}` → read content back. -/// Path traversal in `job_id` is explicitly rejected. -pub struct BashOutput; - -impl Tool for BashOutput { - fn name(&self) -> &'static str { - "bash_output" - } - - fn description(&self) -> &'static str { - "Get the output of a background bash job by ID" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "Background job ID" - } - }, - "required": ["job_id"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let job_id = arg_str(args, "job_id")?; - info!("Getting output for job: {job_id}"); - - // Prevent path traversal - if job_id.contains('/') || job_id.contains('\\') || job_id.contains("..") { - anyhow::bail!("invalid job_id '{job_id}': must not contain path separators"); - } - - // Read from the session's bash output directory - let output_dir = ctx.session_dir.join("bash-outputs"); - let output_file = output_dir.join(&job_id); - - if output_file.exists() { - let content = std::fs::read_to_string(&output_file) - .unwrap_or_else(|e| format!("Error reading output: {e}")); - Ok(format!("Output for job '{job_id}':\n{content}")) - } else { - Ok(format!( - "No output found for job '{job_id}'. The job may still be running." - )) - } - } -} - -/// Kill a background bash job by ID. -/// -/// Flow: parse job_id → look up in the global bash controller → cancel the job. -pub struct BashKill; - -impl Tool for BashKill { - fn name(&self) -> &'static str { - "bash_kill" - } - - fn description(&self) -> &'static str { - "Kill a background bash job by ID" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "Background job ID to kill" - } - }, - "required": ["job_id"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let job_id = crate::tools::arg_str(args, "job_id")?; - info!("bash_kill called for job: {job_id}"); - - if crate::bgbash::control::bash_control().cancel(&job_id) { - info!(job = %job_id, "background job killed"); - Ok(format!("Killed background job '{job_id}'")) - } else { - warn!(job = %job_id, "no active background job found"); - anyhow::bail!("no active background job found with ID '{job_id}'") - } - } -} diff --git a/apps/infrastructure/src/tools/best_practice.rs b/apps/infrastructure/src/tools/best_practice.rs deleted file mode 100644 index 234adaa..0000000 --- a/apps/infrastructure/src/tools/best_practice.rs +++ /dev/null @@ -1,355 +0,0 @@ -//! Built-in best-practice tools for zesdex. -//! -//! These tools allow the LLM agent to run architecture audits, code-quality -//! scans, commit-message validation, and skill lookups — all as compiled-in -//! features of the zesdex binary. -//! -//! # Tools -//! -//! | Tool name | Action | -//! |-----------|--------| -//! | `best_practice` | Run architecture audit, code-quality scan, or skill lookup | -//! | `commit_convention` | Validate or suggest commit messages | - -use crate::best_practice::BestPracticeEngine; -use crate::tools::{arg_str, Tool, ToolCtx}; -use anyhow::{bail, Result}; -use serde_json::{json, Value}; -use std::sync::OnceLock; -use tracing::instrument; - -// --------------------------------------------------------------------------- -// Shared engine instance (lazy, created once) -// --------------------------------------------------------------------------- - -fn engine() -> &'static BestPracticeEngine { - static ENGINE: OnceLock = OnceLock::new(); - ENGINE.get_or_init(BestPracticeEngine::new) -} - -// ─────────────────────────────────────────────────────────────────────────── -// best_practice -// ─────────────────────────────────────────────────────────────────────────── - -/// Run best-practice audits (architecture, code quality, skills). -pub struct BestPractice; - -impl Tool for BestPractice { - fn name(&self) -> &'static str { - "best_practice" - } - - fn description(&self) -> &'static str { - "Run architecture audit, code-quality scan, embedded-skills lookup, \ - or commit-message validation. Sub-actions: 'audit_all', 'audit_layering', \ - 'scan_quality', 'list_skills', 'get_skill', 'validate_commit', 'suggest_commit'." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "audit_all", - "audit_layering", - "scan_quality", - "list_skills", - "get_skill", - "validate_commit", - "suggest_commit" - ], - "description": "Which action to perform" - }, - "workspace": { - "type": "string", - "description": "Path to workspace root (required for audit/scan actions)" - }, - "skill_name": { - "type": "string", - "description": "Skill name to retrieve (required for 'get_skill')" - }, - "commit_message": { - "type": "string", - "description": "Commit message to validate (required for 'validate_commit')" - }, - "commit_type": { - "type": "string", - "description": "Commit type for template suggestion (e.g. 'feat', 'fix')" - }, - "commit_scope": { - "type": "string", - "description": "Optional scope for template suggestion" - } - }, - "required": ["action"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let action = arg_str(args, "action")?; - let eng = engine(); - - match action.as_str() { - "list_skills" => { - let skills = eng.list_skills(); - let summaries = eng.skill_summaries(); - let mut out = String::from("=== Embedded Best-Practice Skills ===\n\n"); - for (name, desc) in &summaries { - out.push_str(&format!(" {name:<40} {desc}\n")); - } - if skills.is_empty() { - out.push_str(" (no skills embedded)\n"); - } - Ok(out) - } - - "get_skill" => { - let name = arg_str(args, "skill_name")?; - match eng.get_skill(&name) { - Some(content) => Ok(content.to_string()), - None => { - let available = eng.list_skills(); - bail!( - "Skill '{name}' not found. Available skills: {}", - available.join(", ") - ) - } - } - } - - "validate_commit" => { - let msg = arg_str(args, "commit_message")?; - match eng.validate_commit(&msg) { - Ok(()) => Ok("✅ Commit message is valid.".to_string()), - Err(errors) => { - let mut out = String::from("❌ Commit message validation failed:\n"); - for err in &errors { - out.push_str(&format!(" - {err}\n")); - } - // Suggest a template. - if let Some(parsed) = eng.parse_commit(&msg) { - let tpl = - eng.suggest_commit_template(&parsed.type_, parsed.scope.as_deref()); - out.push_str(&format!("\nTemplate: {tpl}\n")); - } - Ok(out) - } - } - } - - "suggest_commit" => { - let type_ = arg_str(args, "commit_type")?; - let scope = args.get("commit_scope").and_then(|v| v.as_str()); - let tpl = eng.suggest_commit_template(&type_, scope); - Ok(format!( - "Suggested commit template:\n\n {tpl}\n\n\ - Valid types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert" - )) - } - - "audit_layering" | "audit_all" | "scan_quality" => { - let workspace = match args.get("workspace").and_then(|v| v.as_str()) { - Some(w) => w.to_string(), - None => bail!("'workspace' argument is required for '{action}'"), - }; - let ws_path = std::path::Path::new(&workspace); - - // Use the host's dirs data dir when workspace is the default data directory. - let report = match action.as_str() { - "audit_layering" => { - let r = eng.audit_layering(ws_path)?; - let mut out = format!( - "=== Architecture Layering Audit ===\n\ - Files scanned: {}\n", - r.files_scanned - ); - if r.violations.is_empty() { - out.push_str(" ✅ No layering violations found.\n"); - } else { - out.push_str(&format!(" Errors: {}\n", r.error_count())); - out.push_str(&format!(" Warnings: {}\n", r.warning_count())); - for v in &r.violations { - out.push_str(&format!( - " [{}] {}:{} — {}\n", - v.severity, v.file, v.line, v.message - )); - } - } - out - } - "scan_quality" => { - let r = eng.scan_quality(ws_path)?; - let mut out = format!( - "=== Code Quality Scan ===\n\ - Files scanned: {}\n", - r.files_scanned - ); - if r.findings.is_empty() { - out.push_str(" ✅ No code-quality issues found.\n"); - } else { - let by_rule = r.count_by_rule(); - out.push_str(" By rule:\n"); - for (rule, count) in &by_rule { - out.push_str(&format!(" {rule}: {count}\n")); - } - for f in r.findings.iter().take(15) { - out.push_str(&format!( - " [{}] {}:{} — {}: {}\n", - f.severity, f.file, f.line, f.rule, f.message - )); - } - if r.findings.len() > 15 { - out.push_str(&format!( - " ... and {} more findings.\n", - r.findings.len() - 15 - )); - } - } - out - } - _ => { - let combined = eng.audit_all(ws_path)?; - combined.format() - } - }; - Ok(report) - } - - other => bail!("Unknown action '{other}'"), - } - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// commit_convention -// ─────────────────────────────────────────────────────────────────────────── - -/// Dedicated commit-message validation tool. -pub struct CommitConvention; - -impl Tool for CommitConvention { - fn name(&self) -> &'static str { - "commit_convention" - } - - fn description(&self) -> &'static str { - "Validate a git commit message against Conventional Commits format (Bahasa Indonesia). \ - Checks type, scope, description casing, length, and punctuation." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "The full commit message to validate" - } - }, - "required": ["message"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let msg = arg_str(args, "message")?; - let eng = engine(); - - // Try to parse for additional context. - let info = eng.parse_commit(&msg); - - match eng.validate_commit(&msg) { - Ok(()) => { - let mut out = String::from("✅ Valid Conventional Commit.\n"); - if let Some(i) = info { - out.push_str(&format!(" Type: {}\n", i.type_)); - if let Some(ref s) = i.scope { - out.push_str(&format!(" Scope: {s}\n")); - } - out.push_str(&format!(" Breaking: {}\n", i.breaking)); - out.push_str(&format!(" Description: {}\n", i.description)); - } - Ok(out) - } - Err(errors) => { - let mut out = String::from("❌ Invalid commit message:\n"); - for err in &errors { - out.push_str(&format!(" - {err}\n")); - } - // Provide a template suggestion. - out.push_str("\nExpected format:\n"); - out.push_str(" feat(scope): \n"); - out.push_str(" fix(scope): \n"); - out.push_str(" chore: \n"); - out.push_str(" docs: \n"); - Ok(out) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_ctx() -> ToolCtx { - // Minimal context for unit tests - let temp = std::env::temp_dir().join("zesdex-bptest"); - let _ = std::fs::create_dir_all(&temp); - ToolCtx::builder() - .workspaces(vec![temp.clone()]) - .session_dir(temp.clone()) - .build() - } - - #[test] - fn best_practice_list_skills() { - let tool = BestPractice; - let args = json!({"action": "list_skills"}); - let result = tool.run(&test_ctx(), &args).unwrap(); - assert!( - result.contains("clean-code"), - "should list clean-code: {result}" - ); - assert!( - result.contains("commit-convention"), - "should list commit-convention: {result}" - ); - } - - #[test] - fn best_practice_get_skill() { - let tool = BestPractice; - let args = json!({"action": "get_skill", "skill_name": "clean-code"}); - let result = tool.run(&test_ctx(), &args).unwrap(); - assert!( - result.contains("Clean Code"), - "should contain skill content: {result}" - ); - } - - #[test] - fn commit_convention_valid() { - let tool = CommitConvention; - let args = json!({"message": "feat(tool): add best practice audit"}); - let result = tool.run(&test_ctx(), &args).unwrap(); - assert!( - result.contains("✅"), - "valid commit should succeed: {result}" - ); - } - - #[test] - fn commit_convention_invalid() { - let tool = CommitConvention; - let args = json!({"message": "Add new feature"}); - let result = tool.run(&test_ctx(), &args).unwrap(); - assert!( - result.contains("❌"), - "invalid commit should fail: {result}" - ); - } -} diff --git a/apps/infrastructure/src/tools/context.rs b/apps/infrastructure/src/tools/context.rs deleted file mode 100644 index c8d7aaf..0000000 --- a/apps/infrastructure/src/tools/context.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Tool execution context: shared state passed to every `Tool::run` call. - -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::{Arc, Mutex}; - -/// Shared execution context passed to every `Tool::run` call: workspace roots, -/// session paths, cached directory state, and workflow-level findings sharing. -#[derive(Clone)] -pub struct ToolCtx { - pub workspaces: Vec, - pub session_dir: PathBuf, - pub memory_dir: PathBuf, - pub worktrees_dir: PathBuf, - pub dir_cache: Arc>, - pub mention_index: crate::MentionIndex, - pub origin: crate::Origin, - pub graduated_checks: Vec, - pub turn_events: Option>>>, - pub workflow_findings: Option>>>, - pub abort_flag: Option>, -} - -impl ToolCtx { - pub fn builder() -> ToolCtxBuilder { - ToolCtxBuilder::default() - } -} - -/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`. -#[derive(Clone)] -pub struct ToolCtxBuilder { - pub workspaces: Vec, - pub session_dir: PathBuf, - pub memory_dir: PathBuf, - pub worktrees_dir: PathBuf, - pub dir_cache: Arc>, - pub mention_index: crate::MentionIndex, - pub origin: crate::Origin, - pub graduated_checks: Vec, - pub turn_events: Option>>>, - pub workflow_findings: Option>>>, - pub abort_flag: Option>, -} - -impl Default for ToolCtxBuilder { - fn default() -> Self { - ToolCtxBuilder { - workspaces: Vec::new(), - session_dir: PathBuf::new(), - memory_dir: PathBuf::new(), - worktrees_dir: PathBuf::new(), - dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())), - mention_index: crate::MentionIndex::new(), - origin: crate::Origin::Main, - graduated_checks: Vec::new(), - turn_events: None, - workflow_findings: None, - abort_flag: None, - } - } -} - -impl ToolCtxBuilder { - pub fn session_dir(mut self, v: PathBuf) -> Self { - self.session_dir = v; - self - } - pub fn memory_dir(mut self, v: PathBuf) -> Self { - self.memory_dir = v; - self - } - pub fn worktrees_dir(mut self, v: PathBuf) -> Self { - self.worktrees_dir = v; - self - } - pub fn workspaces(mut self, v: Vec) -> Self { - self.workspaces = v; - self - } - pub fn origin(mut self, v: crate::Origin) -> Self { - self.origin = v; - self - } - pub fn turn_events( - mut self, - v: Arc>>, - ) -> Self { - self.turn_events = Some(v); - self - } - pub fn workflow_findings(mut self, v: Option>>>) -> Self { - self.workflow_findings = v; - self - } - pub fn build(self) -> ToolCtx { - ToolCtx { - workspaces: self.workspaces, - session_dir: self.session_dir, - memory_dir: self.memory_dir, - worktrees_dir: self.worktrees_dir, - dir_cache: self.dir_cache, - mention_index: self.mention_index, - origin: self.origin, - graduated_checks: self.graduated_checks, - turn_events: self.turn_events, - workflow_findings: self.workflow_findings, - abort_flag: self.abort_flag, - } - } -} diff --git a/apps/infrastructure/src/tools/executor.rs b/apps/infrastructure/src/tools/executor.rs deleted file mode 100644 index 10f0e29..0000000 --- a/apps/infrastructure/src/tools/executor.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::tools::{all_tools, Tool, ToolCtx}; -use anyhow::Result; -use std::future::Future; -use zesdex_application::agent::ToolExecutor; - -pub struct InfrastructureToolExecutor { - ctx: ToolCtx, - tools: Vec>, -} - -impl InfrastructureToolExecutor { - pub fn new(ctx: ToolCtx) -> Self { - Self { - ctx, - tools: all_tools(), - } - } - - /// Whether a tool is read-only and safe to execute concurrently with - /// other parallel-safe tools. Delegates to the registry so the main - /// turn loop and subagent engine share one source of truth. - pub fn is_parallel_safe(tool_name: &str) -> bool { - crate::tools::tool_is_parallel_safe(tool_name) - } -} - -impl ToolExecutor for InfrastructureToolExecutor { - fn execute( - &self, - tool_name: &str, - args: &serde_json::Value, - ) -> impl Future> + Send { - // Find the tool by name - let tool_opt = self.tools.iter().find(|t| t.name() == tool_name); - let ctx = self.ctx.clone(); - let args = args.clone(); - - async move { - match tool_opt { - Some(tool) => tokio::task::block_in_place(move || tool.run(&ctx, &args)), - None => { - anyhow::bail!("Unknown tool: {}", tool_name) - } - } - } - } - - fn is_parallel_safe(&self, tool_name: &str) -> bool { - Self::is_parallel_safe(tool_name) - } -} diff --git a/apps/infrastructure/src/tools/fs/delete.rs b/apps/infrastructure/src/tools/fs/delete.rs deleted file mode 100644 index 92c7a09..0000000 --- a/apps/infrastructure/src/tools/fs/delete.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Delete a file or empty directory. - -use crate::tools::{resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::fs; -use tracing::{info, instrument, warn}; - -/// Delete a file or empty directory. -/// -/// Flow: resolve path → check existence → remove file or empty directory. -pub struct Delete; - -impl Tool for Delete { - fn name(&self) -> &'static str { - "delete" - } - - fn description(&self) -> &'static str { - "Delete a file or empty directory" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to delete (relative to workspace root)" - }, - "reason": { - "type": "string", - "description": "Reason for deletion" - } - }, - "required": ["path"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = crate::tools::arg_str(args, "path")?; - let path = resolve_path(&ctx.workspaces, &rel)?; - - if !path.exists() { - warn!(rel = %rel, "delete target does not exist"); - anyhow::bail!("path '{rel}' does not exist"); - } - - if path.is_file() { - fs::remove_file(&path)?; - info!(rel = %rel, "file deleted"); - Ok(format!("Deleted file '{rel}'")) - } else if path.is_dir() { - fs::remove_dir(&path).map_err(|e| { - anyhow::anyhow!("failed to delete directory '{rel}': {e} (directory must be empty)") - })?; - info!(rel = %rel, "empty directory deleted"); - Ok(format!("Deleted empty directory '{rel}'")) - } else { - anyhow::bail!("'{rel}' is neither a file nor a directory") - } - } -} diff --git a/apps/infrastructure/src/tools/fs/edit.rs b/apps/infrastructure/src/tools/fs/edit.rs deleted file mode 100644 index 32980cc..0000000 --- a/apps/infrastructure/src/tools/fs/edit.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Edit a file by replacing a text block. - -use crate::tools::{resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::fs; -use tracing::{info, instrument, warn}; - -/// Edit a file by replacing `old` text with `new` text. -/// -/// Flow: resolve path → read file → ensure `old` exists → perform single -/// replacement → write file. -pub struct Edit; - -impl Tool for Edit { - fn name(&self) -> &'static str { - "edit" - } - - fn description(&self) -> &'static str { - "Edit a file by replacing 'old' text with 'new' text" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to edit (relative to workspace root)" - }, - "old": { - "type": "string", - "description": "Text to replace (must exist in the file)" - }, - "new": { - "type": "string", - "description": "Replacement text" - }, - "reason": { - "type": "string", - "description": "Reason for this change" - } - }, - "required": ["path", "old", "new"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = crate::tools::arg_str(args, "path")?; - let old = crate::tools::arg_str(args, "old")?; - let new = crate::tools::arg_str(args, "new")?; - let path = resolve_path(&ctx.workspaces, &rel)?; - - if !path.exists() { - warn!(rel = %rel, "edit target does not exist"); - anyhow::bail!("file '{rel}' does not exist"); - } - - let content = fs::read_to_string(&path)?; - if !content.contains(&old) { - warn!(rel = %rel, old_len = old.len(), "old text not found in file"); - anyhow::bail!("old text not found in '{}'", rel); - } - - let new_content = content.replacen(&old, &new, 1); - fs::write(&path, &new_content)?; - - info!(rel = %rel, old_len = old.len(), new_len = new.len(), "file edited"); - Ok(format!( - "Edited '{}': replaced {} bytes with {} bytes", - rel, - old.len(), - new.len() - )) - } -} diff --git a/apps/infrastructure/src/tools/fs/helpers.rs b/apps/infrastructure/src/tools/fs/helpers.rs deleted file mode 100644 index 449af5b..0000000 --- a/apps/infrastructure/src/tools/fs/helpers.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Helper utilities for filesystem tools — content hashing, path validation, etc. - -use sha2::Digest; -use tracing::instrument; - -/// Compute the SHA-256 hex digest of a string. -#[instrument(skip(content))] -pub fn sha256_hex(content: &str) -> String { - hex::encode(sha2::Sha256::digest(content.as_bytes())) -} diff --git a/apps/infrastructure/src/tools/fs/mod.rs b/apps/infrastructure/src/tools/fs/mod.rs deleted file mode 100644 index 715defe..0000000 --- a/apps/infrastructure/src/tools/fs/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Filesystem read/write/edit/delete tools with graduated-checks integration. - -pub mod delete; -pub mod edit; -pub mod helpers; -pub mod read; -pub mod write; diff --git a/apps/infrastructure/src/tools/fs/read.rs b/apps/infrastructure/src/tools/fs/read.rs deleted file mode 100644 index 13af51a..0000000 --- a/apps/infrastructure/src/tools/fs/read.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Read a file from the workspace. - -use crate::tools::{resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::fs; -use tracing::{info, instrument, warn}; - -/// Read the contents of a file. -/// -/// Flow: resolve path → check existence and type → read file content → return. -pub struct Read; - -impl Tool for Read { - fn name(&self) -> &'static str { - "read" - } - - fn description(&self) -> &'static str { - "Read the contents of a file" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to read (relative to workspace root)" - } - }, - "required": ["path"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = crate::tools::arg_str(args, "path")?; - let path = resolve_path(&ctx.workspaces, &rel)?; - if !path.exists() { - warn!(rel = %rel, "read target does not exist"); - anyhow::bail!("file '{rel}' does not exist"); - } - if !path.is_file() { - warn!(rel = %rel, "read target is not a file"); - anyhow::bail!("'{rel}' is not a file"); - } - let content = fs::read_to_string(&path)?; - info!(rel = %rel, bytes = content.len(), "file read"); - Ok(content) - } -} diff --git a/apps/infrastructure/src/tools/fs/write.rs b/apps/infrastructure/src/tools/fs/write.rs deleted file mode 100644 index 3afd692..0000000 --- a/apps/infrastructure/src/tools/fs/write.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Write content to a file (create or overwrite). - -use crate::tools::{check_graduated_checks, resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::fs; -use tracing::{debug, info, instrument, warn}; - -/// Write content to a file (creating or overwriting). -/// -/// Flow: resolve path → create parent directories → write content → notify -/// mention index → check graduated checks → return result. -pub struct Write; - -impl Tool for Write { - fn name(&self) -> &'static str { - "write" - } - - fn description(&self) -> &'static str { - "Write content to a file (creating or overwriting)" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to write to (relative to workspace root)" - }, - "content": { - "type": "string", - "description": "Content to write" - }, - "reason": { - "type": "string", - "description": "Reason for this change" - } - }, - "required": ["path", "content"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = crate::tools::arg_str(args, "path")?; - let content = crate::tools::arg_str(args, "content")?; - let path = resolve_path(&ctx.workspaces, &rel)?; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&path, &content)?; - - info!(rel = %rel, bytes = content.len(), "file written"); - - // Notify mention index - ctx.mention_index.push(path.display().to_string()); - - // Check graduated checks - let matched = check_graduated_checks(&rel, &content, &ctx.graduated_checks); - if !matched.is_empty() { - debug!(checks = ?matched, "graduated checks triggered for write"); - return Ok(format!( - "Written {} bytes to '{}'. Note: graduated checks triggered: {}", - content.len(), - rel, - matched.join(", ") - )); - } - - Ok(format!("Written {} bytes to '{}'", content.len(), rel)) - } -} diff --git a/apps/infrastructure/src/tools/git/git_cred.rs b/apps/infrastructure/src/tools/git/git_cred.rs deleted file mode 100644 index fea0e2e..0000000 --- a/apps/infrastructure/src/tools/git/git_cred.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Git credential management tool. -//! -//! Provides store, list, and erase actions for git credentials -//! by communicating with `git credential approve/reject` via stdin. - -use crate::tools::{execute_cmd, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::io::Write; -use std::process::{Command, Stdio}; -use tracing::{info, instrument}; - -/// Tool that manages git credentials (store, list, erase). -/// -/// Interacts with the git credential helper protocol via `git credential approve` -/// (for storing) and `git credential reject` (for erasing). The `list` action -/// reads the global git config. -pub struct GitCred; - -impl Tool for GitCred { - fn name(&self) -> &'static str { - "git_cred" - } - - fn description(&self) -> &'static str { - "Manage git credentials (store, retrieve, list)" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["store", "list", "erase"], - "description": "Credential action to perform" - }, - "url": { - "type": "string", - "description": "Git URL for the credential" - }, - "username": { - "type": "string", - "description": "Username for authentication" - }, - "password": { - "type": "string", - "description": "Password or token for authentication" - } - }, - "required": ["action"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let action = crate::tools::arg_str(args, "action")?; - info!(action, "git_cred invoked"); - - match action.as_str() { - "store" => { - let url = crate::tools::arg_str(args, "url")?; - let username = crate::tools::arg_str(args, "username")?; - let password = crate::tools::arg_str(args, "password")?; - let input = format!("url={url}\nusername={username}\npassword={password}\n"); - let mut child = Command::new("git") - .args(["credential", "approve"]) - .stdin(Stdio::piped()) - .spawn()?; - if let Some(ref mut stdin) = child.stdin { - stdin.write_all(input.as_bytes())?; - } - child.wait()?; - info!("credential stored for {url}"); - Ok(format!("Credential stored for {url}")) - } - "list" => { - let output = execute_cmd( - std::process::Command::new("git").args(["config", "--global", "--list"]), - )?; - Ok(output) - } - "erase" => { - let url = crate::tools::arg_str(args, "url")?; - let input = format!("url={url}\n"); - let mut child = Command::new("git") - .args(["credential", "reject"]) - .stdin(Stdio::piped()) - .spawn()?; - if let Some(ref mut stdin) = child.stdin { - stdin.write_all(input.as_bytes())?; - } - child.wait()?; - info!("credential erased for {url}"); - Ok(format!("Credential erased for {url}")) - } - _ => anyhow::bail!("unknown action: {}", action), - } - } -} diff --git a/apps/infrastructure/src/tools/git/git_operator.rs b/apps/infrastructure/src/tools/git/git_operator.rs deleted file mode 100644 index 52aef96..0000000 --- a/apps/infrastructure/src/tools/git/git_operator.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Git operator tool — commit, push, pull, branch operations. -//! -//! Wraps git subcommands with a safety filter that blocks destructive -//! operations (e.g. force-push, reset --hard) via `check_git_destructive`. - -use crate::tools::shell_filter::git::check_git_destructive; -use crate::tools::{execute_cmd, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument, warn}; - -/// Tool that executes safe git operations (status, log, diff, commit, branch, etc.). -/// -/// Flow: parse operation + args → safety filter check → spawn `git` subprocess → return output. -/// Destructive commands are blocked before execution. -pub struct GitOperator; - -impl Tool for GitOperator { - fn name(&self) -> &'static str { - "git_operator" - } - - fn description(&self) -> &'static str { - "Execute git operations (commit, push, pull, branch, status, log, etc.)" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "operation": { - "type": "string", - "enum": ["status", "log", "diff", "commit", "branch", "checkout", "pull", "push", "add", "stash"], - "description": "Git operation to perform" - }, - "args": { - "type": "array", - "items": {"type": "string"}, - "description": "Additional arguments for the git operation" - } - }, - "required": ["operation"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let operation = crate::tools::arg_str(args, "operation")?; - let extra_args: Vec = args - .get("args") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - info!(operation, extra_args = ?extra_args, "git_operator invoked"); - - // Safety filter: block destructive git operations - let cmd_str = format!("git {} {}", operation, extra_args.join(" ")); - if let Err(e) = check_git_destructive(&cmd_str) { - warn!(cmd = %cmd_str, error = %e, "destructive git operation blocked"); - anyhow::bail!("blocked: {e}"); - } - - let mut cmd = std::process::Command::new("git"); - cmd.arg(&operation); - for arg in &extra_args { - cmd.arg(arg); - } - - let output = execute_cmd(&mut cmd)?; - info!(operation, "git_operator completed"); - Ok(output) - } -} diff --git a/apps/infrastructure/src/tools/git/git_worktree.rs b/apps/infrastructure/src/tools/git/git_worktree.rs deleted file mode 100644 index e38dd3a..0000000 --- a/apps/infrastructure/src/tools/git/git_worktree.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Git worktree management tool. -//! -//! Supports add, list, remove, and prune actions for managing -//! multiple working trees attached to a single repository. - -use crate::tools::{execute_cmd, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Tool that manages git worktrees (add, list, remove, prune). -/// -/// Wraps `git worktree` subcommands. For `add`, requires both a -/// path and a branch name. For `remove`, requires a path. -pub struct GitWorktree; - -impl Tool for GitWorktree { - fn name(&self) -> &'static str { - "git_worktree" - } - - fn description(&self) -> &'static str { - "Manage git worktrees (add, list, remove, prune)" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["add", "list", "remove", "prune"], - "description": "Worktree action to perform" - }, - "path": { - "type": "string", - "description": "Path for the new worktree (for 'add')" - }, - "branch": { - "type": "string", - "description": "Branch name for the new worktree (for 'add')" - } - }, - "required": ["action"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let action = crate::tools::arg_str(args, "action")?; - info!(action, "git_worktree invoked"); - - match action.as_str() { - "add" => { - let path = crate::tools::arg_str(args, "path")?; - let branch = crate::tools::arg_str(args, "branch")?; - info!(path, branch, "adding worktree"); - let output = execute_cmd( - std::process::Command::new("git").args(["worktree", "add", &path, &branch]), - )?; - Ok(output) - } - "list" => { - let output = - execute_cmd(std::process::Command::new("git").args(["worktree", "list"]))?; - Ok(output) - } - "remove" => { - let path = crate::tools::arg_str(args, "path")?; - info!(path, "removing worktree"); - let output = execute_cmd( - std::process::Command::new("git").args(["worktree", "remove", &path]), - )?; - Ok(output) - } - "prune" => { - info!("pruning stale worktree metadata"); - let output = - execute_cmd(std::process::Command::new("git").args(["worktree", "prune"]))?; - Ok(output) - } - _ => anyhow::bail!("unknown action: {}", action), - } - } -} diff --git a/apps/infrastructure/src/tools/git/mod.rs b/apps/infrastructure/src/tools/git/mod.rs deleted file mode 100644 index fdbc9ed..0000000 --- a/apps/infrastructure/src/tools/git/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Git integration tools. - -pub mod git_cred; -pub mod git_operator; -pub mod git_worktree; diff --git a/apps/infrastructure/src/tools/graduated.rs b/apps/infrastructure/src/tools/graduated.rs deleted file mode 100644 index 852978b..0000000 --- a/apps/infrastructure/src/tools/graduated.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Graduated check rules: project-defined patterns that flag matching -//! file paths or content for review during write/edit operations. - -/// A project-defined rule that flags a matching file path or content pattern -/// for review. -#[derive(Debug, Clone)] -pub struct GraduatedCheck { - pub name: String, - pub pattern: String, - pub rule: String, -} - -/// Check which graduated checks apply to a given file path/content pair. -pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec { - let mut matches = Vec::new(); - for check in checks { - if path.contains(&check.pattern) || content.contains(&check.rule) { - matches.push(check.name.clone()); - } - } - matches -} diff --git a/apps/infrastructure/src/tools/memory/forget.rs b/apps/infrastructure/src/tools/memory/forget.rs deleted file mode 100644 index 4249fa1..0000000 --- a/apps/infrastructure/src/tools/memory/forget.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Delete a memory by name. -//! -//! Removes a previously-saved persistent memory file from the -//! memory directory via the `MemoryRepository`. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -use zesdex_domain::cms::MemoryRepository; - -/// Tool that deletes a saved memory by name. -/// -/// Flow: parse name → instantiate `MarkdownMemoryRepository` → call -/// `repo.delete()` with the memory directory and name → confirm deletion. -pub struct Forget; - -impl Tool for Forget { - fn name(&self) -> &'static str { - "forget" - } - - fn description(&self) -> &'static str { - "Delete a saved memory by name" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the memory to delete" - } - }, - "required": ["name"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = crate::tools::arg_str(args, "name")?; - info!(name, "forget invoked"); - let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); - let memory_dir = crate::tools::memory::resolve_memory_dir(&ctx.memory_dir); - repo.delete(&memory_dir, &name)?; - info!(name, "memory deleted"); - Ok(format!("Memory '{}' deleted", name)) - } -} diff --git a/apps/infrastructure/src/tools/memory/mod.rs b/apps/infrastructure/src/tools/memory/mod.rs deleted file mode 100644 index 1473012..0000000 --- a/apps/infrastructure/src/tools/memory/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Memory management tools — remember, recall, forget. - -use std::path::PathBuf; - -pub mod forget; -pub mod recall; -pub mod remember; - -/// Resolve the directory the memory tools should read/write. -/// -/// Prefer an explicitly-configured `ToolCtx.memory_dir`. If that is empty -/// (a `ToolCtx` is often built without setting `memory_dir`), fall back to -/// the canonical persistent memory location from `Store` so memories are not -/// silently written into the current working directory. -pub fn resolve_memory_dir(ctx_memory_dir: &std::path::Path) -> PathBuf { - if ctx_memory_dir.as_os_str().is_empty() { - zesdex_domain::core::Store::new().memory_dir - } else { - ctx_memory_dir.to_path_buf() - } -} diff --git a/apps/infrastructure/src/tools/memory/recall.rs b/apps/infrastructure/src/tools/memory/recall.rs deleted file mode 100644 index 5709e87..0000000 --- a/apps/infrastructure/src/tools/memory/recall.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Recall previously saved memories. -//! -//! Loads a specific memory by name or lists all available memories -//! from the memory directory via `MemoryRepository`. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -use zesdex_domain::cms::MemoryRepository; - -/// Tool that lists or searches saved persistent memories. -/// -/// Flow: if a `name` argument is provided, loads that specific memory -/// and returns it as pretty-printed JSON. Otherwise lists all available -/// memory names from the repository. -pub struct Recall; - -impl Tool for Recall { - fn name(&self) -> &'static str { - "recall" - } - - fn description(&self) -> &'static str { - "List or search saved memories" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Optional: specific memory name to recall" - }, - "search": { - "type": "string", - "description": "Optional: keyword to search in memory descriptions" - } - } - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); - let memory_dir = crate::tools::memory::resolve_memory_dir(&ctx.memory_dir); - - let specific_name = args.get("name").and_then(|v| v.as_str()); - let search = args.get("search").and_then(|v| v.as_str()); - - if let Some(name) = specific_name { - info!(name, "recall loading specific memory"); - let memory = repo.load(&memory_dir, name)?; - return Ok(serde_json::to_string_pretty(&memory)?); - } - - if let Some(query) = search { - let query = query.trim().to_lowercase(); - info!(search = %query, "recall searching memories"); - if query.is_empty() { - return Ok("Search query is empty".to_string()); - } - let names = repo.list(&memory_dir)?; - let mut matches: Vec = Vec::new(); - for name in &names { - // Load each memory and match against name/description/content. - if let Ok(m) = repo.load(&memory_dir, name) { - let haystack = - format!("{} {} {}", m.name, m.description, m.content).to_lowercase(); - if haystack.contains(&query) { - matches.push(m.name); - } - } - } - if matches.is_empty() { - return Ok(format!("No memories match '{query}'")); - } - return Ok(format!( - "Memories matching '{query}' ({}):\n{}", - matches.len(), - matches.join("\n") - )); - } - - info!("recall listing all memories"); - let names = repo.list(&memory_dir)?; - if names.is_empty() { - info!("no memories found"); - return Ok("No memories saved yet".to_string()); - } - Ok(format!("Available memories:\n{}", names.join("\n"))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tools::ToolCtxBuilder; - use zesdex_domain::cms::MemoryRepository; - - fn save_mem(name: &str, description: &str, content: &str, dir: &std::path::Path) { - let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); - let memory = zesdex_domain::cms::Memory { - name: name.to_string(), - description: description.to_string(), - content: content.to_string(), - kind: "reference".to_string(), - created_at: 0, - updated_at: 0, - outcome: None, - lifecycle: "active".to_string(), - scope: None, - before_snippet: None, - after_snippet: None, - provenances: Vec::new(), - }; - repo.save(dir, &memory).unwrap(); - } - - #[test] - fn search_filters_memories_by_keyword() { - let dir = std::env::temp_dir().join(format!("zdx-mem-test-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - save_mem( - "rust-concurrency", - "tokio spawn patterns", - "how to use async tasks", - &dir, - ); - save_mem( - "docker-deploy", - "deploy via compose", - "container orchestration", - &dir, - ); - - let tool_ctx = ToolCtxBuilder::default().memory_dir(dir.clone()).build(); - let args = serde_json::json!({ "search": "tokio" }); - - let out = Recall.run(&tool_ctx, &args).unwrap(); - assert!( - out.contains("rust-concurrency"), - "should match rust-concurrency, got: {out}" - ); - assert!( - !out.contains("docker-deploy"), - "docker-deploy should not match tokio" - ); - - // A query with no match reports so. - let no_match = Recall - .run(&tool_ctx, &serde_json::json!({ "search": "zzzznope" })) - .unwrap(); - assert!(no_match.contains("No memories match"), "{no_match}"); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn resolve_memory_dir_falls_back_to_store_when_empty() { - // An empty ToolCtx.memory_dir is resolved to the canonical Store path. - let resolved = crate::tools::memory::resolve_memory_dir(std::path::Path::new("")); - assert!(!resolved.as_os_str().is_empty()); - assert!( - resolved.ends_with("memory"), - "expected memory dir, got {resolved:?}" - ); - - // An explicit memory_dir is preserved. - let explicit = - crate::tools::memory::resolve_memory_dir(std::path::Path::new("/tmp/custom-memory")); - assert_eq!(explicit, std::path::Path::new("/tmp/custom-memory")); - } -} diff --git a/apps/infrastructure/src/tools/memory/remember.rs b/apps/infrastructure/src/tools/memory/remember.rs deleted file mode 100644 index 8dbc26e..0000000 --- a/apps/infrastructure/src/tools/memory/remember.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Remember a lesson or fact as persistent memory. -//! -//! Constructs a `Memory` struct from tool arguments and persists it -//! via `MarkdownMemoryRepository` to the memory directory. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -use zesdex_domain::cms::{Memory, MemoryRepository}; - -/// Tool that saves a lesson or fact to persistent memory. -/// -/// Flow: parse name/description/content/kind → construct a `Memory` struct -/// with timestamps → instantiate `MarkdownMemoryRepository` → call -/// `repo.save()` with the memory directory → confirm save. -pub struct Remember; - -impl Tool for Remember { - fn name(&self) -> &'static str { - "remember" - } - - fn description(&self) -> &'static str { - "Save a lesson or fact to persistent memory" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique name for this memory" - }, - "description": { - "type": "string", - "description": "Short summary of the memory" - }, - "content": { - "type": "string", - "description": "Full content of the memory" - }, - "kind": { - "type": "string", - "enum": ["lesson", "reference", "fact"], - "description": "Category of memory" - } - }, - "required": ["name", "description", "content"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = crate::tools::arg_str(args, "name")?; - let description = crate::tools::arg_str(args, "description")?; - let content = crate::tools::arg_str(args, "content")?; - let kind = args - .get("kind") - .and_then(|v| v.as_str()) - .unwrap_or("reference") - .to_string(); - - info!(name, kind, "remember invoked"); - - let memory = Memory { - name: name.clone(), - description, - content, - kind, - created_at: chrono::Utc::now().timestamp(), - updated_at: chrono::Utc::now().timestamp(), - outcome: None, - lifecycle: "active".to_string(), - scope: None, - before_snippet: None, - after_snippet: None, - provenances: Vec::new(), - }; - - let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); - let memory_dir = crate::tools::memory::resolve_memory_dir(&ctx.memory_dir); - repo.save(&memory_dir, &memory)?; - - info!(name, "memory saved"); - Ok(format!("Memory '{}' saved", name)) - } -} diff --git a/apps/infrastructure/src/tools/mod.rs b/apps/infrastructure/src/tools/mod.rs deleted file mode 100644 index a7064e3..0000000 --- a/apps/infrastructure/src/tools/mod.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Tool trait, execution context, and the registry of all built-in tools. -//! -//! This module defines the core `Tool` trait that every agent-invocable tool -//! must implement, the shared `ToolCtx` execution context, and utility -//! functions for path resolution, command execution, argument extraction, -//! and graduated-check rules. -//! -//! # Organisation -//! -//! ```text -//! tools/ -//! ├── mod.rs — Tool trait, re-exports -//! ├── context.rs — ToolCtx, ToolCtxBuilder -//! ├── registry.rs — all_tools(), tool_defs(), tool_is_risky() -//! ├── util.rs — arg_str(), execute_cmd(), resolve_path(), -//! │ log_write_edit_tool() -//! ├── graduated.rs — GraduatedCheck, check_graduated_checks() -//! ├── executor.rs — InfrastructureToolExecutor -//! ├── fs/ — read, write, edit, delete -//! ├── git/ — git_operator, git_worktree, git_cred -//! ├── memory/ — remember, forget, recall -//! ├── utility/ — cd, dir_list, pong, todowrite, todofinish, etc. -//! ├── shell.rs — Bash tool -//! ├── bash_tools.rs -//! ├── search.rs — Grep, Glob -//! ├── semantic_search.rs -//! ├── web_search.rs -//! ├── sequential_think.rs -//! ├── plan.rs, spawn.rs, workflow.rs -//! └── parallel_delegate.rs -//! ``` - -use anyhow::Result; -use serde_json::Value; - -pub mod bash_tools; -pub mod best_practice; -pub mod context; -pub mod executor; -pub mod fs; -pub mod git; -pub mod graduated; -pub mod memory; -pub mod parallel_delegate; -pub mod plan; -pub mod registry; -pub mod search; -pub mod semantic_search; -pub mod sequential_think; -pub mod shell; -pub mod shell_filter; -pub mod spawn; -pub mod util; -pub mod utility; -pub mod web_search; -pub mod workflow; - -// Re-export commonly used items at the `tools` root so existing imports -// like `crate::tools::{Tool, ToolCtx}` continue to work. -pub use context::{ToolCtx, ToolCtxBuilder}; -pub use graduated::{check_graduated_checks, GraduatedCheck}; -pub use registry::{all_tools, tool_defs, tool_is_parallel_safe, tool_is_risky}; -pub use util::{arg_str, execute_cmd, log_write_edit_tool, resolve_path}; - -/// Common interface every agent-invocable tool implements. -pub trait Tool: Send + Sync { - fn name(&self) -> &'static str; - fn description(&self) -> &'static str; - fn parameters(&self) -> Value; - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; -} - -pub use git::git_cred; -pub use git::git_operator; -pub use git::git_worktree; diff --git a/apps/infrastructure/src/tools/parallel_delegate.rs b/apps/infrastructure/src/tools/parallel_delegate.rs deleted file mode 100644 index ca17632..0000000 --- a/apps/infrastructure/src/tools/parallel_delegate.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Parallel delegation tool — splits a large task into sub-tasks that run -//! concurrently across multiple subagents, then synthesises results. -//! -//! Flow: receive task description + optional breakdown → LLM analyses the -//! task and splits it into parallel directives → spawn subagents for each → -//! collect results → return consolidated output. - -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{debug, info, instrument, warn}; - -use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; -use zesdex_domain::core::Store; - -use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; -use crate::subagent::context::SubagentContext; -use crate::subagent::division::AccessTier; -use crate::subagent::spawn::spawn_subagent; -use crate::tools::{Tool, ToolCtx}; - -/// Delegate a large task to multiple subagents running in parallel. -/// -/// Flow: -/// 1. If `directives` are provided explicitly, use them directly. -/// 2. Otherwise, use LLM to analyse the task and split it into directives. -/// 3. Spawn a subagent for each directive concurrently. -/// 4. Join all results and consolidate into a single response. -pub struct ParallelDelegate; - -impl Tool for ParallelDelegate { - fn name(&self) -> &'static str { - "parallel_delegate" - } - - fn description(&self) -> &'static str { - "Delegate a large task to multiple subagents running in parallel for 3x faster completion" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": "The task to be split and delegated to parallel agents" - }, - "directives": { - "type": "array", - "items": { - "type": "object", - "properties": { - "directive": {"type": "string", "description": "Directive for one parallel agent"}, - "access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this agent (default: write)"} - }, - "required": ["directive"] - }, - "description": "Optional explicit list of parallel directives (if not provided, the LLM will auto-split the task)" - }, - "synthesize": { - "type": "boolean", - "description": "Whether to synthesize results into a unified response (default: true)", - "default": true - }, - "max_parallel": { - "type": "integer", - "description": "Maximum number of parallel agents (default: 3, max: 8)", - "default": 3 - } - }, - "required": ["task"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let task = crate::tools::arg_str(args, "task")?; - let synthesize = args - .get("synthesize") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let max_parallel = args - .get("max_parallel") - .and_then(|v| v.as_u64()) - .unwrap_or(3) - .min(8) as usize; - - // Load LLM credentials - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - // Determine directives - let directives: Vec<(String, AccessTier)> = if let Some(dirs) = - args.get("directives").and_then(|v| v.as_array()) - { - // Explicit directives provided - dirs.iter() - .filter_map(|d| { - let directive = d.get("directive").and_then(|v| v.as_str())?; - let access_str = d.get("access").and_then(|v| v.as_str()).unwrap_or("write"); - let access = match access_str { - "read" => AccessTier::Read, - "full" => AccessTier::Full, - _ => AccessTier::Write, - }; - Some((directive.to_string(), access)) - }) - .collect() - } else { - // Auto-split using LLM - let rt = crate::runtime::runtime(); - let directives = rt.block_on(auto_split_task( - &task, - max_parallel, - &base_url, - &api_key, - &model, - ))?; - directives - }; - - if directives.is_empty() { - anyhow::bail!("no directives could be derived for the task"); - } - - let directive_count = directives.len(); - info!( - task = %task, - directive_count, - max_parallel, - "parallel delegation: starting subagents" - ); - - // Spawn agents in parallel — bounded: never more than `max_parallel` - // subagent threads in flight at once (Claude Code-style isolation). - let mut results: Vec<(usize, String, String)> = Vec::new(); - for batch in directives.chunks(max_parallel) { - let mut handles = Vec::with_capacity(batch.len()); - for (i, (directive, access)) in batch.iter().enumerate() { - let global_idx = results.len() + i; - let subagent_ctx = SubagentContext::new( - directive.clone(), - ctx.clone(), - format!("{access:?}"), - base_url.clone(), - api_key.clone(), - model.clone(), - ); - - debug!(agent_index = global_idx, access = ?access, "spawning parallel agent"); - let handle = spawn_subagent(subagent_ctx, directive.clone(), *access, ctx.clone()); - handles.push((global_idx, handle)); - } - - // Join this batch before spawning the next. - for (i, handle) in handles { - match handle.join() { - Ok(Ok(output)) => { - info!(agent_index = i, "parallel agent completed"); - results.push((i, directives[i].0.clone(), output)); - } - Ok(Err(e)) => { - warn!(agent_index = i, error = %e, "parallel agent failed"); - results.push((i, directives[i].0.clone(), format!("[ERROR] {e}"))); - } - Err(e) => { - warn!(agent_index = i, error = ?e, "parallel agent panicked"); - results.push(( - i, - directives[i].0.clone(), - "[ERROR] Agent panicked".to_string(), - )); - } - } - } - } - - // Consolidate results - if synthesize && results.len() > 1 { - let rt = crate::runtime::runtime(); - let consolidated = - rt.block_on(consolidate_results(&results, &base_url, &api_key, &model))?; - Ok(format!( - "## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}", - results.len(), - consolidated - )) - } else { - let mut output = format!( - "## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n", - results.len() - ); - for (i, directive, result) in &results { - output.push_str(&format!( - "---\n### Agent {}: {}\n\n{}\n", - i, directive, result - )); - } - Ok(output) - } - } -} - -/// Use LLM to analyse a task and split it into parallel directives. -async fn auto_split_task( - task: &str, - max_parallel: usize, - base_url: &str, - api_key: &str, - model: &str, -) -> Result> { - let client = crate::llm::provider::LlmClient::new( - api_key.to_string(), - model.to_string(), - Some(base_url.to_string()), - ); - - let sys_msg = zesdex_domain::core::ChatMessage::system(format!( - "You are a task decomposition expert. Split the following task into {max_parallel} \ - independent sub-tasks that can run in parallel. Each sub-task must be self-contained \ - and produce useful output independently.\n\n\ - Output your response as a JSON array of objects, each with:\n\ - - \"directive\": a clear, detailed instruction for a subagent\n\ - - \"access\": one of \"read\", \"write\", or \"full\"\n\n\ - IMPORTANT: Return ONLY valid JSON, no other text. Example:\n\ - [{{\"directive\": \"Create the User model with fields...\", \"access\": \"write\"}}]" - )); - - let user_msg = zesdex_domain::core::ChatMessage::user(format!( - "Task: {task}\n\nSplit into {max_parallel} parallel directives:" - )); - - use zesdex_application::ports::ProviderService; - match client - .chat(&[sys_msg, user_msg], None, Some(2048), Some(0.4)) - .await - { - Ok((response, _)) => { - let text = response.content.unwrap_or_default(); - parse_directives_json(&text, max_parallel) - } - Err(e) => { - warn!(error = %e, "auto-split LLM call failed, using fallback splitting"); - Ok(fallback_split(task, max_parallel)) - } - } -} - -/// Parse JSON directives from LLM response. -fn parse_directives_json(text: &str, max_parallel: usize) -> Result> { - // Find JSON array in the response - let json_start = text.find('['); - let json_end = text.rfind(']'); - - let json_str = match (json_start, json_end) { - (Some(start), Some(end)) if end > start => &text[start..=end], - _ => { - // Try parsing the whole text as JSON - text.trim() - } - }; - - let parsed: Vec = serde_json::from_str(json_str) - .map_err(|e| anyhow::anyhow!("failed to parse directives JSON: {e}"))?; - - let directives: Vec<(String, AccessTier)> = parsed - .into_iter() - .take(max_parallel) - .filter_map(|v| { - let directive = v.get("directive")?.as_str()?.to_string(); - let access_str = v.get("access").and_then(|a| a.as_str()).unwrap_or("write"); - let access = match access_str { - "read" => AccessTier::Read, - "full" => AccessTier::Full, - _ => AccessTier::Write, - }; - Some((directive, access)) - }) - .collect(); - - Ok(directives) -} - -/// Fallback splitting when LLM call fails. -fn fallback_split(task: &str, max_parallel: usize) -> Vec<(String, AccessTier)> { - // Simple heuristic split by common patterns - let mut directives = Vec::new(); - - if task.contains("backend") || task.contains("api") || task.contains("server") { - directives.push(( - format!("Implement the backend/API components for: {task}"), - AccessTier::Write, - )); - } - - if task.contains("frontend") || task.contains("ui") || task.contains("client") { - directives.push(( - format!("Implement the frontend/UI components for: {task}"), - AccessTier::Write, - )); - } - - if task.contains("test") || task.contains("unit") { - directives.push((format!("Write unit tests for: {task}"), AccessTier::Read)); - } - - if directives.is_empty() { - // Just split the task into generic chunks - for i in 0..max_parallel { - directives.push(( - format!("Part {} of parallel task: {task}", i + 1), - AccessTier::Write, - )); - } - } - - directives.truncate(max_parallel); - directives -} - -/// Use LLM to consolidate multiple agent results into a single response. -async fn consolidate_results( - results: &[(usize, String, String)], - base_url: &str, - api_key: &str, - model: &str, -) -> Result { - let client = crate::llm::provider::LlmClient::new( - api_key.to_string(), - model.to_string(), - Some(base_url.to_string()), - ); - - let mut summary = String::new(); - for (i, directive, result) in results { - summary.push_str(&format!("### Agent {}: {}\n{}\n\n", i, directive, result)); - } - - let sys_msg = zesdex_domain::core::ChatMessage::system( - "You are a synthesis expert. Consolidate the following parallel agent outputs \ - into a single coherent response. Remove duplication, reconcile conflicts, and \ - present the unified result in a well-structured format." - .to_string(), - ); - - let user_msg = zesdex_domain::core::ChatMessage::user(format!( - "Consolidate the following parallel agent outputs:\n\n{summary}" - )); - - use zesdex_application::ports::ProviderService; - match client - .chat(&[sys_msg, user_msg], None, Some(2048), Some(0.3)) - .await - { - Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())), - Err(e) => { - warn!(error = %e, "consolidation LLM call failed, using raw concatenation"); - Ok(summary) - } - } -} diff --git a/apps/infrastructure/src/tools/plan.rs b/apps/infrastructure/src/tools/plan.rs deleted file mode 100644 index 0703b26..0000000 --- a/apps/infrastructure/src/tools/plan.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! Plan management tools — enter and mark ready. -//! -//! These tools implement a two-phase planning workflow: `PlanEnter` presents a -//! structured plan to the user for approval, and `PlanReady` signals that the -//! plan is finalised and execution may begin. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument, warn}; - -/// Enter a planning phase — persist a structured plan and notify the user. -/// -/// Flow: extract plan text → write to `{session_dir}/PLAN.md` → push a -/// `PlanUpdate` turn event → return plan length summary. -pub struct PlanEnter; - -impl Tool for PlanEnter { - fn name(&self) -> &'static str { - "plan_enter" - } - - fn description(&self) -> &'static str { - "Enter a planning phase — present a structured plan for approval" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "plan": { - "type": "string", - "description": "The structured plan text" - } - }, - "required": ["plan"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let plan_text = crate::tools::arg_str(args, "plan")?; - info!(plan_len = plan_text.len(), "plan_enter called"); - - let plan_path = ctx.session_dir.join("PLAN.md"); - let _ = std::fs::write(&plan_path, &plan_text); - if let Some(events) = &ctx.turn_events { - if let Ok(mut q) = events.lock() { - q.push_back(crate::TurnEvent::PlanUpdate(plan_text.clone())); - } - } - - Ok(format!( - "Plan entered (length: {} chars). Waiting for approval...", - plan_text.len() - )) - } -} - -/// Signal that the plan is ready and execution can begin. -/// -/// Flow: extract plan content → persist to a timestamped file in -/// `{session_dir}/plans/` → overwrite `PLAN.md` → push `PlanUpdate` event. -pub struct PlanReady; - -impl Tool for PlanReady { - fn name(&self) -> &'static str { - "plan_ready" - } - - fn description(&self) -> &'static str { - "Signal that the plan is ready and execution can begin" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "plan": { - "type": "string", - "description": "The final plan content" - } - }, - "required": ["plan"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let plan_content = crate::tools::arg_str(args, "plan")?; - info!(plan_len = plan_content.len(), "plan_ready called"); - - // Persist the plan to session directory for reference - let plan_dir = ctx.session_dir.join("plans"); - if std::fs::create_dir_all(&plan_dir).is_ok() { - let filename = format!("plan-{}.md", chrono::Utc::now().format("%Y%m%d_%H%M%S")); - let path = plan_dir.join(&filename); - std::fs::write(&path, &plan_content) - .map_err(|e| anyhow::anyhow!("failed to save plan: {e}"))?; - - info!(filename = %filename, "plan persisted to disk"); - - // Also save the latest plan - let plan_path = ctx.session_dir.join("PLAN.md"); - let _ = std::fs::write(&plan_path, &plan_content); - if let Some(events) = &ctx.turn_events { - if let Ok(mut q) = events.lock() { - q.push_back(crate::TurnEvent::PlanUpdate(plan_content.clone())); - } - } - - Ok(format!("Plan saved to {filename}. Starting execution.")) - } else { - warn!("failed to create plans directory"); - Ok("Plan is ready. Starting execution.".to_string()) - } - } -} diff --git a/apps/infrastructure/src/tools/registry.rs b/apps/infrastructure/src/tools/registry.rs deleted file mode 100644 index d3a0d54..0000000 --- a/apps/infrastructure/src/tools/registry.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Tool registry: the master list of all built-in tools, plus conversion -//! utilities for generating provider-facing tool definitions. - -/// Construct one instance of every built-in tool. -pub fn all_tools() -> Vec> { - vec![ - Box::new(super::fs::read::Read), - Box::new(super::fs::write::Write), - Box::new(super::fs::edit::Edit), - Box::new(super::fs::delete::Delete), - Box::new(super::search::Grep), - Box::new(super::search::Glob), - Box::new(super::bash_tools::BashOutput), - Box::new(super::bash_tools::BashKill), - Box::new(super::shell::Bash), - Box::new(super::git::git_operator::GitOperator), - Box::new(super::git::git_worktree::GitWorktree), - Box::new(super::git::git_cred::GitCred), - Box::new(super::sequential_think::SeqThink), - Box::new(super::plan::PlanEnter), - Box::new(super::plan::PlanReady), - Box::new(super::workflow::WorkflowRun), - Box::new(super::workflow::NoteFinding), - Box::new(super::workflow::ReadFindings), - Box::new(super::workflow::HiveMind), - Box::new(super::spawn::SpawnAgents), - Box::new(super::spawn::SpawnPipeline), - Box::new(super::memory::remember::Remember), - Box::new(super::memory::forget::Forget), - Box::new(super::memory::recall::Recall), - Box::new(super::utility::cd::Cd), - Box::new(super::utility::dir_list::DirList), - Box::new(super::utility::dir_cache_update::DirCacheUpdate), - Box::new(super::utility::pong::Pong), - Box::new(super::utility::todowrite::Todowrite), - Box::new(super::utility::todofinish::Todofinish), - Box::new(super::web_search::WebSearch), - Box::new(super::semantic_search::SemanticSearch), - Box::new(super::semantic_search::RebuildIndex), - Box::new(super::semantic_search::ListSymbols), - Box::new(super::parallel_delegate::ParallelDelegate), - // ── Best-practice tools (built-in) ───────────────────────── - Box::new(super::best_practice::BestPractice), - Box::new(super::best_practice::CommitConvention), - Box::new(crate::best_practice::explore::ExploreCodebase), - ] -} - -/// Whether a tool by name can mutate the filesystem or run arbitrary shell -/// commands. -pub fn tool_is_risky(name: &str) -> bool { - matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") -} - -/// Whether a tool by name is read-only and therefore safe to run *in -/// parallel* with other tool calls within the same assistant message. -/// -/// Read-only tools only inspect the workspace (read files, grep, glob, -/// semantic search, list symbols, recall memory, web search, directory -/// listing). They have no side effects, so concurrent execution cannot -/// create data races or conflicting writes. -/// -/// Everything else (edits, writes, deletes, shell, git, planning, memory -/// writes, agent/spawn orchestration) stays sequential to preserve -/// correctness. -pub fn tool_is_parallel_safe(name: &str) -> bool { - matches!( - name, - "read" - | "grep" - | "glob" - | "semantic_search" - | "list_symbols" - | "web_search" - | "recall" - | "dir_list" - | "pong" - | "seq_think" - | "dir_cache_update" - ) -} - -/// Convert a list of tools into provider-facing `ToolDef` request schema. -pub fn tool_defs(tools: &[Box]) -> Vec { - tools - .iter() - .map(|t| zesdex_domain::core::ToolDef { - type_: "function".to_string(), - function: zesdex_domain::core::ToolFunctionDef { - name: t.name().to_string(), - description: t.description().to_string(), - parameters: t.parameters(), - }, - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn read_only_tools_are_parallel_safe() { - for name in [ - "read", - "grep", - "glob", - "semantic_search", - "list_symbols", - "web_search", - "recall", - "dir_list", - "pong", - "seq_think", - "dir_cache_update", - ] { - assert!( - tool_is_parallel_safe(name), - "{name} should be parallel-safe" - ); - } - } - - #[test] - fn mutating_and_shell_tools_are_not_parallel_safe() { - for name in [ - "edit", - "write", - "delete", - "bash", - "git_operator", - "git_worktree", - "remember", - "forget", - "todowrite", - "todofinish", - "plan_enter", - "plan_ready", - "workflow_run", - "hive_mind", - "spawn_agents", - "spawn_pipeline", - "parallel_delegate", - "explore_codebase", - ] { - assert!( - !tool_is_parallel_safe(name), - "{name} should NOT be parallel-safe" - ); - } - } -} diff --git a/apps/infrastructure/src/tools/search.rs b/apps/infrastructure/src/tools/search.rs deleted file mode 100644 index 297543d..0000000 --- a/apps/infrastructure/src/tools/search.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Text search tools: Grep (line matching) and Glob (filename pattern matching). -//! -//! `Grep` searches file contents recursively with regex or literal fallback. -//! `Glob` lists files matching a given glob pattern under a directory. - -use crate::tools::{resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use globset::{GlobBuilder, GlobSetBuilder}; -use ignore::Walk; -use serde_json::{json, Value}; -use std::fs; -use tracing::{debug, info, instrument, warn}; - -/// Search for a regex (or literal) pattern in file contents under a directory. -/// -/// Flow: resolve path → walk files → regex-match each line → collect results. -/// Falls back to substring search when the pattern is not a valid regex. -pub struct Grep; - -impl Tool for Grep { - fn name(&self) -> &'static str { - "grep" - } - - fn description(&self) -> &'static str { - "Search for a pattern in files using recursive text search" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Text pattern to search for" - }, - "path": { - "type": "string", - "description": "Path to search in (relative to workspace root)" - } - }, - "required": ["pattern", "path"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let pattern = crate::tools::arg_str(args, "pattern")?; - let rel = crate::tools::arg_str(args, "path")?; - let path = resolve_path(&ctx.workspaces, &rel)?; - - if !path.exists() { - warn!(rel = %rel, "grep path does not exist"); - anyhow::bail!("path '{rel}' does not exist"); - } - if !path.is_dir() { - warn!(rel = %rel, "grep path is not a directory"); - anyhow::bail!("path '{rel}' is not a directory"); - } - - info!(pattern = %pattern, root = %rel, "grep search starting"); - let mut results: Vec<(String, usize, String)> = Vec::new(); - for entry in Walk::new(&path).flatten() { - let file_path = entry.path(); - if !file_path.is_file() { - continue; - } - if let Ok(content) = fs::read_to_string(file_path) { - for (i, line) in content.lines().enumerate() { - let is_match = if let Ok(re) = regex::Regex::new(&pattern) { - re.is_match(line) - } else { - // Fall back to literal substring search when the - // pattern is not a valid regex. - line.contains(&pattern) - }; - if is_match { - let rel_path = file_path - .strip_prefix(&path) - .unwrap_or(file_path) - .display() - .to_string(); - results.push((rel_path, i + 1, line.to_string())); - } - } - } - } - - if results.is_empty() { - debug!(pattern = %pattern, "grep found no matches"); - return Ok(format!("no matches found for '{pattern}' in {rel}")); - } - info!(match_count = results.len(), "grep search completed"); - let output = results - .iter() - .map(|(f, line, text)| format!("{f}:{line}:{text}")) - .collect::>() - .join("\n"); - Ok(format!("found {} matches:\n{}", results.len(), output)) - } -} - -/// List files matching a glob pattern under a directory root. -/// -/// Flow: resolve root → build glob set from pattern → walk files → filter by -/// glob set → sort results. -pub struct Glob; - -impl Tool for Glob { - fn name(&self) -> &'static str { - "glob" - } - - fn description(&self) -> &'static str { - "List files matching a glob pattern" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern to match files (e.g. '**/*.rs')" - }, - "path": { - "type": "string", - "description": "Root path to search from (relative to workspace root)" - } - }, - "required": ["pattern", "path"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let pat_str = crate::tools::arg_str(args, "pattern")?; - let rel = crate::tools::arg_str(args, "path")?; - let root = resolve_path(&ctx.workspaces, &rel)?; - - if !root.exists() || !root.is_dir() { - warn!(rel = %rel, "glob root is not a valid directory"); - anyhow::bail!("path '{rel}' is not a valid directory"); - } - - info!(pattern = %pat_str, root = %rel, "glob search starting"); - let mut builder = GlobSetBuilder::new(); - let full_pattern = root.join(&pat_str).display().to_string(); - builder.add( - GlobBuilder::new(&full_pattern) - .build() - .map_err(|e| anyhow::anyhow!("invalid glob pattern '{pat_str}': {e}"))?, - ); - let glob_set = builder.build()?; - - let mut matches: Vec = Vec::new(); - for entry in Walk::new(&root).flatten() { - let p = entry.path(); - if glob_set.is_match(p) { - let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string(); - matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" })); - } - } - matches.sort(); - if matches.is_empty() { - debug!(pattern = %pat_str, "glob found no matches"); - return Ok(format!("no files match '{pat_str}' in {rel}")); - } - info!(match_count = matches.len(), "glob search completed"); - Ok(matches.join("\n")) - } -} diff --git a/apps/infrastructure/src/tools/semantic_search.rs b/apps/infrastructure/src/tools/semantic_search.rs deleted file mode 100644 index 454d5ae..0000000 --- a/apps/infrastructure/src/tools/semantic_search.rs +++ /dev/null @@ -1,1890 +0,0 @@ -//! Multi-language code symbol index — functions, classes, variables, structs, -//! enums, interfaces, traits, modules across all major programming languages. -//! -//! # Flow -//! -//! `rebuild(workspace)` → walk files by extension → dispatch to per-language -//! extractor → merge into `SymbolIndex` → `search(query)` or `list()`. -//! -//! # Supported Languages -//! -//! | Language | Extensions | Symbols extracted | -//! |-------------|-------------------|--------------------------------------------| -//! | Rust | `.rs` | fn, struct, enum, trait, mod, impl, type, const, macro | -//! | TypeScript | `.ts`, `.tsx` | function, class, interface, type, enum, const, variable | -//! | JavaScript | `.js`, `.jsx`, `.mjs` | function, class, const, variable, module exports | -//! | Python | `.py` | def, async def, class, module-level assignment | -//! | Go | `.go` | func, type, struct, interface, const, var | -//! | Generic | `.c`, `.h`, `.cpp`, `.hpp`, `.java`, `.rb`, `.rs` fallback | line-based heuristic | - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::sync::Mutex; -use tracing::{debug, info, instrument}; - -// --------------------------------------------------------------------------- -// Language & SymbolKind enums -// --------------------------------------------------------------------------- - -/// Programming language of a code symbol. -#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] -pub enum Language { - Rust, - TypeScript, - JavaScript, - Python, - Go, - Other, -} - -impl std::fmt::Display for Language { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Language::Rust => write!(f, "rust"), - Language::TypeScript => write!(f, "typescript"), - Language::JavaScript => write!(f, "javascript"), - Language::Python => write!(f, "python"), - Language::Go => write!(f, "go"), - Language::Other => write!(f, "other"), - } - } -} - -/// The kind of a code symbol. -#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] -pub enum SymbolKind { - Function, - Struct, - Enum, - Trait, - /// module, namespace, package - Module, - /// impl block (Rust-specific) - Impl, - /// type alias - Type, - /// const value - Constant, - Macro, - /// Class (TS/JS/Python/Go) - Class, - /// Interface (TS/Go) - Interface, - /// Variable (top-level let/var/assignment) - Variable, - Other, -} - -impl std::fmt::Display for SymbolKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SymbolKind::Function => write!(f, "fn"), - SymbolKind::Struct => write!(f, "struct"), - SymbolKind::Enum => write!(f, "enum"), - SymbolKind::Trait => write!(f, "trait"), - SymbolKind::Module => write!(f, "mod"), - SymbolKind::Impl => write!(f, "impl"), - SymbolKind::Type => write!(f, "type"), - SymbolKind::Constant => write!(f, "const"), - SymbolKind::Macro => write!(f, "macro"), - SymbolKind::Class => write!(f, "class"), - SymbolKind::Interface => write!(f, "interface"), - SymbolKind::Variable => write!(f, "var"), - SymbolKind::Other => write!(f, "symbol"), - } - } -} - -/// A single code symbol entry in the index. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodeSymbol { - /// Symbol name (e.g. "run_agent", "AppStateRest", "main"). - pub name: String, - /// Kind of symbol. - pub kind: SymbolKind, - /// Language this symbol was extracted from. - pub language: Language, - /// File path relative to workspace root. - pub file: String, - /// Line number (1-indexed). - pub line: usize, - /// Parent symbol (e.g. struct name for impl methods). - pub parent: Option, - /// Doc comment or leading comment text, if any. - pub doc_comment: Option, - /// Short context (the declaration line). - pub context: String, -} - -/// The in-memory symbol index, shared via a global static. -/// -/// Keyed by workspace path: each workspace owns its own `SymbolIndex`, so -/// searching one repo never leaks stale symbols from another, and a rebuild -/// triggered for workspace A cannot clobber the index of B. The lock is only -/// ever held briefly (to check/insert/look up) — never across the I/O-heavy -/// walk in `rebuild`, which runs locally and is swapped in under a short lock. -static SYMBOL_INDEX: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -/// Access the (lazily initialised) global per-workspace symbol index map. -fn symbol_index_map() -> &'static Mutex> { - SYMBOL_INDEX.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Ensure the per-workspace symbol index is built, returning the symbol count. -/// -/// If `force` is true, or the workspace has no cached (non-empty) index yet, -/// the index is rebuilt. The rebuild itself runs OUTSIDE the global lock -/// (the walk can take seconds on a large repo), then the result is stored -/// under a short lock so concurrent searches never block on the I/O. Returns -/// the number of symbols now cached for the workspace. -fn ensure_symbol_index(workspace: &str, force: bool) -> Result { - let ready = { - let map = symbol_index_map() - .lock() - .map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; - !force && map.get(workspace).is_some_and(|i| !i.is_empty()) - }; - - if !ready { - // Rebuild locally, off the global lock (I/O heavy). - let mut fresh = SymbolIndex::new(); - let count = fresh.rebuild(workspace)?; - // Swap in under a short lock; keep an existing non-empty index if a - // concurrent rebuild already populated this workspace. - let mut map = symbol_index_map() - .lock() - .map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; - if map.get(workspace).is_none_or(|i| i.is_empty()) { - map.insert(workspace.to_string(), fresh); - } - return Ok(count); - } - - let map = symbol_index_map() - .lock() - .map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; - Ok(map.get(workspace).map_or(0, |i| i.len())) -} - -/// Lock and return a borrow to the global per-workspace symbol index map. -/// -/// The caller must have called [`ensure_symbol_index`] first, then looks up -/// its workspace key; the lookup is short and in-memory, so holding the guard -/// for the search is fine. -fn symbol_index() -> Result>> { - symbol_index_map() - .lock() - .map_err(|e| anyhow::anyhow!("index lock failed: {e}")) -} - -// --------------------------------------------------------------------------- -// Language-specific regexes (lazily compiled) -// --------------------------------------------------------------------------- - -struct LangRegexes { - // Rust - rust_fn: Regex, - rust_struct: Regex, - rust_enum: Regex, - rust_trait: Regex, - rust_mod: Regex, - rust_impl: Regex, - rust_type: Regex, - rust_const: Regex, - rust_macro: Regex, - // TypeScript / JavaScript - ts_fn: Regex, - ts_class: Regex, - ts_interface: Regex, - ts_type: Regex, - ts_enum: Regex, - ts_var_export: Regex, - // Python - py_def: Regex, - py_class: Regex, - py_async_def: Regex, - // Go - go_func: Regex, - go_type: Regex, - go_struct: Regex, - go_interface: Regex, - go_const: Regex, - go_var: Regex, -} - -impl LangRegexes { - fn new() -> Self { - LangRegexes { - // Rust - rust_fn: Regex::new( - r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)", - ) - .expect("rust fn regex"), - rust_struct: Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)") - .expect("rust struct regex"), - rust_enum: Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)") - .expect("rust enum regex"), - rust_trait: Regex::new( - r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)", - ) - .expect("rust trait regex"), - rust_mod: Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)") - .expect("rust mod regex"), - rust_impl: Regex::new( - r"(?m)^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)", - ) - .expect("rust impl regex"), - rust_type: Regex::new(r"(?m)^\s*(?:pub\s+)?type\s+(\w+)") - .expect("rust type regex"), - rust_const: Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)") - .expect("rust const regex"), - rust_macro: Regex::new( - r"(?m)^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)", - ) - .expect("rust macro regex"), - - // TypeScript/JavaScript - ts_fn: Regex::new( - r"(?m)^\s*(?:export\s+)?(?:(?:async\s+)?function\s+|(?:public|private|protected)\s+)?(\w+)\s*(?:\(|=\s*(?:async\s+)?\()", - ) - .expect("ts fn regex"), - ts_class: Regex::new( - r"(?m)^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)", - ) - .expect("ts class regex"), - ts_interface: Regex::new( - r"(?m)^\s*(?:export\s+)?interface\s+(\w+)", - ) - .expect("ts interface regex"), - ts_type: Regex::new( - r"(?m)^\s*(?:export\s+)?type\s+(\w+)\s*=", - ) - .expect("ts type regex"), - ts_enum: Regex::new(r"(?m)^\s*(?:export\s+)?enum\s+(\w+)") - .expect("ts enum regex"), - ts_var_export: Regex::new( - r"(?m)^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*\w+\s*)?=", - ) - .expect("ts var regex"), - - // Python - py_def: Regex::new(r"(?m)^\s*def\s+(\w+)").expect("py def regex"), - py_class: Regex::new(r"(?m)^\s*class\s+(\w+)") - .expect("py class regex"), - py_async_def: Regex::new(r"(?m)^\s*async\s+def\s+(\w+)") - .expect("py async def regex"), - - // Go - go_func: Regex::new( - r"(?m)^\s*func\s+(?:\([^)]*\)\s+)?(\w+)", - ) - .expect("go func regex"), - go_type: Regex::new(r"(?m)^\s*type\s+(\w+)") - .expect("go type regex"), - go_struct: Regex::new(r"(?m)^\s*type\s+(\w+)\s+struct") - .expect("go struct regex"), - go_interface: Regex::new( - r"(?m)^\s*type\s+(\w+)\s+interface", - ) - .expect("go interface regex"), - go_const: Regex::new(r"(?m)^\s*const\s+(\w+)") - .expect("go const regex"), - go_var: Regex::new(r"(?m)^\s*var\s+(\w+)") - .expect("go var regex"), - } - } -} - -static LANG_REGEXES: std::sync::OnceLock = std::sync::OnceLock::new(); - -fn regexes() -> &'static LangRegexes { - LANG_REGEXES.get_or_init(LangRegexes::new) -} - -// --------------------------------------------------------------------------- -// SymbolIndex -// --------------------------------------------------------------------------- - -/// A symbol index cache. -#[derive(Debug, Clone)] -pub struct SymbolIndex { - symbols: Vec, - workspace_path: Option, -} - -impl SymbolIndex { - pub fn new() -> Self { - SymbolIndex { - symbols: Vec::new(), - workspace_path: None, - } - } - - pub fn is_empty(&self) -> bool { - self.symbols.is_empty() - } - - /// Returns true when the cached index must be rebuilt for the given - /// workspace — either because nothing has been indexed yet, or because the - /// requested workspace differs from the one the index was built for. - /// - /// Without this, searching a *different* workspace after the first one - /// silently returns stale symbols from the previously indexed repo - /// (a misleading result for a coding agent). - pub fn needs_rebuild(&self, workspace: &str) -> bool { - self.is_empty() || self.workspace_path.as_deref() != Some(workspace) - } - - pub fn len(&self) -> usize { - self.symbols.len() - } - - /// Rebuild the index by walking the workspace and extracting symbols - /// from all supported languages. - pub fn rebuild(&mut self, workspace: &str) -> Result { - let path = std::path::Path::new(workspace); - if !path.exists() { - anyhow::bail!("workspace path does not exist: {workspace}"); - } - - let mut symbols = Vec::new(); - let walker = ignore::Walk::new(path); - - // Pre-compile per-extension dispatch table. - type ExtDispatch = HashMap<&'static str, fn(&str, &str) -> Vec>; - let mut ext_dispatch = ExtDispatch::new(); - ext_dispatch.insert("rs", extract_rust); - ext_dispatch.insert("ts", extract_typescript); - ext_dispatch.insert("tsx", extract_typescript); - ext_dispatch.insert("mts", extract_typescript); - ext_dispatch.insert("js", extract_javascript); - ext_dispatch.insert("jsx", extract_javascript); - ext_dispatch.insert("mjs", extract_javascript); - ext_dispatch.insert("py", extract_python); - ext_dispatch.insert("go", extract_go); - - for entry in walker.flatten() { - let file_path = entry.path(); - if !file_path.is_file() { - continue; - } - - let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); - let extractor = match ext_dispatch.get(ext) { - Some(f) => f, - _ => continue, // unsupported extension - }; - - let rel_path = file_path - .strip_prefix(path) - .unwrap_or(file_path) - .display() - .to_string(); - - match std::fs::read_to_string(file_path) { - Ok(content) => { - let file_symbols = extractor(&content, &rel_path); - symbols.extend(file_symbols); - } - Err(e) => { - debug!(file = %rel_path, error = %e, "failed to read file for indexing"); - } - } - } - - symbols.sort_by(|a, b| a.name.cmp(&b.name)); - self.symbols = symbols; - self.workspace_path = Some(workspace.to_string()); - - let count = self.symbols.len(); - info!(symbol_count = count, workspace = %workspace, "symbol index rebuilt"); - Ok(count) - } - - /// Search indexed symbols by query. - pub fn search(&self, query: &str, max_results: usize) -> Vec<&CodeSymbol> { - if self.symbols.is_empty() { - return Vec::new(); - } - - let query_lower = query.to_lowercase(); - let query_words: Vec<&str> = query_lower.split_whitespace().collect(); - - let mut scored: Vec<(i32, &CodeSymbol)> = self - .symbols - .iter() - .filter_map(|sym| { - let score = score_symbol(sym, &query_lower, &query_words); - if score > 0 { - Some((score, sym)) - } else { - None - } - }) - .collect(); - - scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name))); - - scored - .into_iter() - .take(max_results) - .map(|(_, sym)| sym) - .collect() - } - - /// Return all indexed symbols, optionally filtered by language and/or kind. - pub fn list( - &self, - language_filter: Option, - kind_filter: Option, - file_filter: Option<&str>, - max_results: usize, - ) -> Vec<&CodeSymbol> { - let iter: Vec<&CodeSymbol> = self - .symbols - .iter() - .filter(|s| { - language_filter.as_ref().is_none_or(|l| s.language == *l) - && kind_filter.as_ref().is_none_or(|k| s.kind == *k) - && file_filter.is_none_or(|f| s.file.contains(f)) - }) - .take(max_results) - .collect(); - iter - } - - /// Count symbols by language. - pub fn count_by_language(&self) -> Vec<(Language, usize)> { - let mut counts: HashMap = HashMap::new(); - for sym in &self.symbols { - *counts.entry(sym.language.clone()).or_default() += 1; - } - let mut sorted: Vec<(Language, usize)> = counts.into_iter().collect(); - sorted.sort_by_key(|b| std::cmp::Reverse(b.1)); - sorted - } - - /// Count symbols by kind. - pub fn count_by_kind(&self) -> Vec<(SymbolKind, usize)> { - let mut counts: HashMap = HashMap::new(); - for sym in &self.symbols { - *counts.entry(sym.kind.clone()).or_default() += 1; - } - let mut sorted: Vec<(SymbolKind, usize)> = counts.into_iter().collect(); - sorted.sort_by_key(|b| std::cmp::Reverse(b.1)); - sorted - } -} - -impl Default for SymbolIndex { - fn default() -> Self { - Self::new() - } -} - -/// Score a symbol against a search query. -fn score_symbol(sym: &CodeSymbol, query_lower: &str, query_words: &[&str]) -> i32 { - let name_lower = sym.name.to_lowercase(); - let mut score: i32 = 0; - - if name_lower == *query_lower { - score += 1000; - } - if name_lower.starts_with(query_lower) { - score += 500; - } - if name_lower.contains(query_lower) { - score += 200; - } - for word in query_words { - if name_lower.contains(word) { - score += 50; - } - } - if let Some(ref doc) = sym.doc_comment { - let doc_lower = doc.to_lowercase(); - if doc_lower.contains(query_lower) { - score += 30; - } - for word in query_words { - if doc_lower.contains(word) { - score += 10; - } - } - } - let context_lower = sym.context.to_lowercase(); - if context_lower.contains(query_lower) { - score += 20; - } - score -} - -// --------------------------------------------------------------------------- -// Extract doc comments helper (Rust ///) -// --------------------------------------------------------------------------- - -fn extract_doc_comments(lines: &[&str]) -> HashMap { - let mut map = HashMap::new(); - let mut i = 0; - while i < lines.len() { - let line = lines[i].trim(); - if line.starts_with("///") { - let mut doc = String::new(); - while i < lines.len() { - let l = lines[i].trim(); - if l.starts_with("///") { - if !doc.is_empty() { - doc.push(' '); - } - doc.push_str(l.trim_start_matches("///").trim()); - i += 1; - } else { - break; - } - } - let target_line = find_next_declaration_line(lines, i); - if let Some(tl) = target_line { - map.insert(tl + 1, doc); - } - } else { - i += 1; - } - } - map -} - -fn find_next_declaration_line(lines: &[&str], start: usize) -> Option { - lines[start..] - .iter() - .position(|line| { - let trimmed = line.trim(); - !trimmed.is_empty() - && !trimmed.starts_with("///") - && !trimmed.starts_with("//!") - && !trimmed.starts_with('#') - }) - .map(|pos| start + pos) -} - -// --------------------------------------------------------------------------- -// Rust extractor -// --------------------------------------------------------------------------- - -fn extract_rust(content: &str, rel_path: &str) -> Vec { - let r = regexes(); - let mut symbols = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - let doc_comments = extract_doc_comments(&lines); - - for (i, line) in lines.iter().enumerate() { - let line_num = i + 1; - let trimmed = line.trim(); - - let entries: Vec<(Option<&str>, SymbolKind, &Regex)> = vec![ - (None, SymbolKind::Function, &r.rust_fn), - (None, SymbolKind::Struct, &r.rust_struct), - (None, SymbolKind::Enum, &r.rust_enum), - (None, SymbolKind::Trait, &r.rust_trait), - (None, SymbolKind::Module, &r.rust_mod), - (None, SymbolKind::Type, &r.rust_type), - (None, SymbolKind::Constant, &r.rust_const), - (None, SymbolKind::Macro, &r.rust_macro), - ]; - - for (_parent, kind, re) in &entries { - if let Some(caps) = re.captures(trimmed) { - let name = caps - .get(1) - .expect("capture group 1 exists by regex") - .as_str() - .to_string(); - let doc = doc_comments.get(&line_num).cloned(); - symbols.push(CodeSymbol { - name, - kind: kind.clone(), - language: Language::Rust, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc, - context: trimmed.to_string(), - }); - } - } - - // Parse impl blocks for methods - if let Some(caps) = r.rust_impl.captures(trimmed) { - let impl_for = caps - .get(1) - .expect("capture group 1 exists by regex") - .as_str() - .to_string(); - let mut brace_depth: i32 = 0; - let mut started = false; - for (j, l) in lines[i..].iter().enumerate() { - for ch in l.chars() { - match ch { - '{' => { - brace_depth += 1; - started = true; - } - '}' => { - brace_depth -= 1; - } - _ => {} - } - } - if started && brace_depth <= 0 && j > 1 { - break; - } - if j > 0 { - let inner_line = l.trim(); - if let Some(mcaps) = r.rust_fn.captures(inner_line) { - let method_name = mcaps - .get(1) - .expect("capture group 1 exists by regex") - .as_str() - .to_string(); - let abs_line = i + j + 1; - let doc = doc_comments.get(&abs_line).cloned(); - symbols.push(CodeSymbol { - name: format!("{impl_for}::{method_name}"), - kind: SymbolKind::Function, - language: Language::Rust, - file: rel_path.to_string(), - line: abs_line, - parent: Some(impl_for.clone()), - doc_comment: doc, - context: inner_line.to_string(), - }); - } - } - } - } - } - - symbols -} - -// --------------------------------------------------------------------------- -// TypeScript extractor -// --------------------------------------------------------------------------- - -fn extract_typescript(content: &str, rel_path: &str) -> Vec { - let r = regexes(); - let mut symbols = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - - // Extract leading JSDoc/TSDoc comments - let doc_comments = extract_ts_doc(&lines); - - for (i, line) in lines.iter().enumerate() { - let line_num = i + 1; - let trimmed = line.trim(); - let doc = doc_comments.get(&line_num).cloned(); - - // Functions: `function name(` or `async function name(` or `name = function(` - if let Some(caps) = r.ts_fn.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - // Skip arrow lambda captures that aren't function names - if !name.starts_with('(') && name != "function" && name != "async" { - symbols.push(CodeSymbol { - name, - kind: SymbolKind::Function, - language: Language::TypeScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc.clone(), - context: trimmed.to_string(), - }); - } - continue; - } - - // Classes - if let Some(caps) = r.ts_class.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Class, - language: Language::TypeScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc.clone(), - context: trimmed.to_string(), - }); - continue; - } - - // Interfaces - if let Some(caps) = r.ts_interface.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Interface, - language: Language::TypeScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc.clone(), - context: trimmed.to_string(), - }); - continue; - } - - // Type aliases - if let Some(caps) = r.ts_type.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Type, - language: Language::TypeScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc.clone(), - context: trimmed.to_string(), - }); - continue; - } - - // Enums - if let Some(caps) = r.ts_enum.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Enum, - language: Language::TypeScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc.clone(), - context: trimmed.to_string(), - }); - continue; - } - - // Const/let/var (module-level variables) - if let Some(caps) = r.ts_var_export.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - // Only capture top-level (indentation 0) or exported - let is_top_level = - line.starts_with(|c: char| !c.is_whitespace()) || trimmed.starts_with("export"); - if is_top_level { - let kind = if trimmed.contains("const ") { - SymbolKind::Constant - } else { - SymbolKind::Variable - }; - symbols.push(CodeSymbol { - name, - kind, - language: Language::TypeScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: doc, - context: trimmed.to_string(), - }); - } - } - } - - symbols -} - -/// Extract leading `/** ... */` JSDoc or `///` comments. -fn extract_ts_doc(lines: &[&str]) -> HashMap { - let mut map = HashMap::new(); - let mut i = 0; - while i < lines.len() { - let line = lines[i].trim(); - if line.starts_with("/**") || line.starts_with("///") { - let mut doc = String::new(); - let mut in_block = line.starts_with("/**"); - if in_block { - // Single-line /** ... */ - if line.ends_with("*/") && line.len() > 4 { - let content = line.trim_start_matches("/**").trim_end_matches("*/").trim(); - if !content.is_empty() { - doc.push_str(content); - } - in_block = false; - } - while in_block && i < lines.len() { - let l = lines[i].trim(); - let l = l.trim_start_matches('*').trim(); - if l.ends_with("*/") { - doc.push_str(l.trim_end_matches("*/").trim()); - break; - } - doc.push(' '); - doc.push_str(l); - i += 1; - } - } else { - // /// style - while i < lines.len() { - let l = lines[i].trim(); - if l.starts_with("///") { - if !doc.is_empty() { - doc.push(' '); - } - doc.push_str(l.trim_start_matches("///").trim()); - i += 1; - } else { - break; - } - } - } - let target_line = find_next_declaration_line(lines, i); - if let Some(tl) = target_line { - map.insert(tl + 1, doc); - } - } else { - i += 1; - } - } - map -} - -// --------------------------------------------------------------------------- -// JavaScript extractor (subset of TypeScript, no TS-specific syntax) -// --------------------------------------------------------------------------- - -fn extract_javascript(content: &str, rel_path: &str) -> Vec { - let r = regexes(); - let mut symbols = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - - for (i, line) in lines.iter().enumerate() { - let line_num = i + 1; - let trimmed = line.trim(); - - // Functions - if let Some(caps) = r.ts_fn.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - if !name.starts_with('(') && name != "function" && name != "async" { - symbols.push(CodeSymbol { - name, - kind: SymbolKind::Function, - language: Language::JavaScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - } - continue; - } - - // Classes - if let Some(caps) = r.ts_class.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Class, - language: Language::JavaScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Module-level const/let/var - if let Some(caps) = r.ts_var_export.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - let is_top_level = line.starts_with(|c: char| !c.is_whitespace()); - if is_top_level { - let kind = if trimmed.contains("const ") { - SymbolKind::Constant - } else { - SymbolKind::Variable - }; - symbols.push(CodeSymbol { - name, - kind, - language: Language::JavaScript, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - } - } - } - - symbols -} - -// --------------------------------------------------------------------------- -// Python extractor -// --------------------------------------------------------------------------- - -fn extract_python(content: &str, rel_path: &str) -> Vec { - let r = regexes(); - let mut symbols = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - - // Track current class for method nesting - // Reset when we see a non-indented line outside a class. - let mut current_class: Option = None; - - for (i, line) in lines.iter().enumerate() { - let line_num = i + 1; - let trimmed = line.trim(); - let indent = line.len() - trimmed.len(); - - // Reset class tracking when we leave its indentation level. - if current_class.is_some() && indent == 0 && !trimmed.is_empty() { - current_class = None; - } - - // Class - if let Some(caps) = r.py_class.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - current_class = Some(name.clone()); - symbols.push(CodeSymbol { - name, - kind: SymbolKind::Class, - language: Language::Python, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Async def - if let Some(caps) = r.py_async_def.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - let full_name = current_class - .as_ref() - .map(|c| format!("{c}.{name}")) - .unwrap_or_else(|| name.clone()); - symbols.push(CodeSymbol { - name: full_name, - kind: SymbolKind::Function, - language: Language::Python, - file: rel_path.to_string(), - line: line_num, - parent: current_class.clone(), - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Def - if let Some(caps) = r.py_def.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - // Detect __init__ or other dunder methods - let full_name = current_class - .as_ref() - .map(|c| format!("{c}.{name}")) - .unwrap_or_else(|| name.clone()); - symbols.push(CodeSymbol { - name: full_name, - kind: SymbolKind::Function, - language: Language::Python, - file: rel_path.to_string(), - line: line_num, - parent: current_class.clone(), - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Module-level variable assignment: `NAME = value` (UPPER_CASE = constant) - let is_top_level = line.starts_with(|c: char| !c.is_whitespace()) - && !trimmed.starts_with('#') - && !trimmed.starts_with("def ") - && !trimmed.starts_with("class ") - && !trimmed.starts_with("import ") - && !trimmed.starts_with("from ") - && !trimmed.starts_with("@") - && !trimmed.starts_with("return") - && !trimmed.starts_with("if ") - && !trimmed.starts_with("elif ") - && !trimmed.starts_with("else:") - && !trimmed.starts_with("for ") - && !trimmed.starts_with("while ") - && !trimmed.starts_with("try:") - && !trimmed.starts_with("except") - && !trimmed.starts_with("with ") - && !trimmed.starts_with("raise") - && !trimmed.starts_with("pass") - && !trimmed.starts_with("self.") - && !trimmed.starts_with("cls.") - && trimmed.contains(" = ") - && !trimmed.contains("=="); - if is_top_level { - let name = trimmed.split('=').next().unwrap_or("").trim().to_string(); - if !name.is_empty() && !name.starts_with('_') && !name.contains(' ') { - let kind = if name.chars().all(|c| c.is_uppercase() || c == '_') { - SymbolKind::Constant - } else { - SymbolKind::Variable - }; - symbols.push(CodeSymbol { - name, - kind, - language: Language::Python, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - } - } - } - - symbols -} - -// --------------------------------------------------------------------------- -// Go extractor -// --------------------------------------------------------------------------- - -fn extract_go(content: &str, rel_path: &str) -> Vec { - let r = regexes(); - let mut symbols = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - - for (i, line) in lines.iter().enumerate() { - let line_num = i + 1; - let trimmed = line.trim(); - - // Struct: `type Name struct {` - if let Some(caps) = r.go_struct.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Struct, - language: Language::Go, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Interface: `type Name interface {` - if let Some(caps) = r.go_interface.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Interface, - language: Language::Go, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Type alias: `type Name Xxx` - if let Some(caps) = r.go_type.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - // Skip if already captured as struct/interface - if !trimmed.contains(" struct") && !trimmed.contains(" interface") { - symbols.push(CodeSymbol { - name, - kind: SymbolKind::Type, - language: Language::Go, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - } - continue; - } - - // Func - if let Some(caps) = r.go_func.captures(trimmed) { - let name = caps.get(1).unwrap().as_str().to_string(); - symbols.push(CodeSymbol { - name, - kind: SymbolKind::Function, - language: Language::Go, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Const - if let Some(caps) = r.go_const.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Constant, - language: Language::Go, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - continue; - } - - // Var - if let Some(caps) = r.go_var.captures(trimmed) { - symbols.push(CodeSymbol { - name: caps.get(1).unwrap().as_str().to_string(), - kind: SymbolKind::Variable, - language: Language::Go, - file: rel_path.to_string(), - line: line_num, - parent: None, - doc_comment: None, - context: trimmed.to_string(), - }); - } - } - - symbols -} - -// --------------------------------------------------------------------------- -// Format indexed symbols as a system-prompt-style listing -// --------------------------------------------------------------------------- - -/// Format the entire symbol index as a compact, prompt-friendly listing. -/// -/// The output is a markdown table grouped by language then file: -/// -/// ```text -/// ## Indexed Symbols (342 total) -/// -/// ### rust (210) -/// apps/domain/src/core/message.rs -/// fn user, fn assistant, fn system, fn tool, fn tool_result -/// enum Role -/// struct ChatMessage -/// ... -/// ``` -pub fn format_symbol_listing(index: &SymbolIndex) -> String { - let mut out = String::new(); - let total = index.len(); - out.push_str(&format!("## Indexed Symbols ({total} total)\n\n")); - - let by_lang = index.count_by_language(); - if by_lang.is_empty() { - out.push_str("_No symbols indexed. Rebuild the index first._\n"); - return out; - } - - // Group by language → file → symbol - let mut by_lang_file: std::collections::BTreeMap< - String, - std::collections::BTreeMap>, - > = std::collections::BTreeMap::new(); - - for sym in &index.symbols { - let lang_str = sym.language.to_string(); - by_lang_file - .entry(lang_str) - .or_default() - .entry(sym.file.clone()) - .or_default() - .push(sym); - } - - for (lang, files) in &by_lang_file { - let count: usize = files.values().map(|v| v.len()).sum(); - out.push_str(&format!("### {lang} ({count})\n")); - - for (file, syms) in files { - out.push_str(&format!(" {file}\n")); - - // Group by kind for compact listing - let mut by_kind: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for sym in syms { - let kind_str = sym.kind.to_string(); - by_kind.entry(kind_str).or_default().push(sym.name.as_str()); - } - for (kind, names) in &by_kind { - out.push_str(&format!(" {kind}: {}\n", names.join(", "))); - } - } - out.push('\n'); - } - - out -} - -// --------------------------------------------------------------------------- -// Tool: SemanticSearch — search the symbol index -// --------------------------------------------------------------------------- - -/// Search for code symbols across all indexed languages. -/// -/// Flow: ensure index is built → search by query → return formatted results. -pub struct SemanticSearch; - -impl Tool for SemanticSearch { - fn name(&self) -> &'static str { - "semantic_search" - } - - fn description(&self) -> &'static str { - "Search for code symbols (functions, structs, classes, interfaces, variables) \ - by name, concept, or meaning across Rust, TypeScript, JavaScript, Python, and Go" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query — symbol name, concept, or meaning" - }, - "kind": { - "type": "string", - "enum": ["fn", "class", "struct", "enum", "interface", "trait", "const", "var", "mod", "all"], - "description": "Filter by symbol kind (default: all)", - "default": "all" - }, - "language": { - "type": "string", - "enum": ["rust", "typescript", "javascript", "python", "go", "all"], - "description": "Filter by language (default: all)", - "default": "all" - }, - "max_results": { - "type": "integer", - "description": "Maximum results (default 10, max 30)", - "default": 10 - }, - "rebuild_index": { - "type": "boolean", - "description": "Force rebuild the symbol index before searching (default false)", - "default": false - } - }, - "required": ["query"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let query = crate::tools::arg_str(args, "query")?; - let kind_filter = args.get("kind").and_then(|v| v.as_str()).unwrap_or("all"); - let lang_filter = args - .get("language") - .and_then(|v| v.as_str()) - .unwrap_or("all"); - let max_results = args - .get("max_results") - .and_then(|v| v.as_u64()) - .unwrap_or(10) - .min(30) as usize; - let rebuild = args - .get("rebuild_index") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let workspace = ctx - .workspaces - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| ".".to_string()); - - info!( - query = %query, - kind = %kind_filter, - lang = %lang_filter, - max_results, - rebuild, - "semantic search" - ); - - // Build (or load) the per-workspace index without holding the global - // lock across the I/O-heavy walk. - let _count = ensure_symbol_index(&workspace, rebuild)?; - let map = symbol_index()?; - let index = map.get(&workspace).expect("index should be ensured"); - - // Map kind filter to enum - let target_kind = match kind_filter { - "fn" => Some(SymbolKind::Function), - "class" => Some(SymbolKind::Class), - "struct" => Some(SymbolKind::Struct), - "enum" => Some(SymbolKind::Enum), - "interface" => Some(SymbolKind::Interface), - "trait" => Some(SymbolKind::Trait), - "const" => Some(SymbolKind::Constant), - "var" => Some(SymbolKind::Variable), - "mod" => Some(SymbolKind::Module), - _ => None, - }; - - let target_lang = match lang_filter { - "rust" => Some(Language::Rust), - "typescript" => Some(Language::TypeScript), - "javascript" => Some(Language::JavaScript), - "python" => Some(Language::Python), - "go" => Some(Language::Go), - _ => None, - }; - - let results = index.search(&query, max_results * 2); - - let filtered: Vec<&&CodeSymbol> = results - .iter() - .filter(|s| target_kind.as_ref().is_none_or(|k| s.kind == *k)) - .filter(|s| target_lang.as_ref().is_none_or(|l| s.language == *l)) - .take(max_results) - .collect(); - - if filtered.is_empty() { - return Ok(format!( - "No symbols found matching '{query}'.\n\ - Try a different query, or use `rebuild_index: true` to rebuild the index first.\n\ - Index has {} symbols across {} languages.", - index.len(), - index.count_by_language().len(), - )); - } - - let total = index.len(); - info!( - matched = filtered.len(), - total_indexed = total, - "semantic search completed" - ); - - let mut by_file: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for sym in &filtered { - by_file.entry(sym.file.clone()).or_default().push(*sym); - } - - let mut output = format!( - "## Semantic Search Results\n\n**Query:** {query}\n**Index size:** {total} symbols\n**Matches:** {}\n\n", - filtered.len() - ); - - for (file, symbols) in &by_file { - output.push_str(&format!("### `{file}`\n\n")); - for sym in symbols { - let lang_str = sym.language.to_string(); - let kind_str = sym.kind.to_string(); - let parent_str = sym - .parent - .as_ref() - .map(|p| format!(" [{p}]")) - .unwrap_or_default(); - let doc_str = sym - .doc_comment - .as_ref() - .map(|d| { - let truncated: String = d.chars().take(100).collect(); - format!(" — {truncated}") - }) - .unwrap_or_default(); - output.push_str(&format!( - "- `{kind_str}` **{}**{} `[{lang_str}]` at line {} `{}`{}{}\n", - sym.name, - parent_str, - sym.line, - sym.context.trim(), - doc_str, - if sym.context.trim().len() > 80 { - "…" - } else { - "" - } - )); - } - output.push('\n'); - } - - output.push_str(&format!( - "---\n*{} symbols indexed across {} languages. Use `rebuild_index: true` to refresh.*\n", - total, - index.count_by_language().len(), - )); - - Ok(output) - } -} - -// --------------------------------------------------------------------------- -// Tool: RebuildIndex — explicitly rebuild the symbol index -// --------------------------------------------------------------------------- - -/// Rebuild the code symbol index for all supported languages. -pub struct RebuildIndex; - -impl Tool for RebuildIndex { - fn name(&self) -> &'static str { - "rebuild_index" - } - - fn description(&self) -> &'static str { - "Rebuild the code symbol index for semantic search (supports Rust, TypeScript, JavaScript, Python, Go)" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - #[instrument(skip(self, ctx, _args))] - fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result { - let workspace = ctx - .workspaces - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| ".".to_string()); - - info!("rebuilding multi-language symbol index"); - - // Force a rebuild of this workspace's index. The walk runs off the - // global lock (via ensure_symbol_index) so it cannot stall concurrent - // searches. - let count = ensure_symbol_index(&workspace, true)?; - let map = symbol_index()?; - let by_lang = match map.get(&workspace) { - Some(i) => i.count_by_language(), - None => Vec::new(), - }; - - let mut out = format!( - "Symbol index rebuilt successfully. {} symbols indexed.\n\n", - count - ); - out.push_str("By language:\n"); - for (lang, c) in &by_lang { - out.push_str(&format!(" {lang}: {c}\n")); - } - - Ok(out) - } -} - -// --------------------------------------------------------------------------- -// Tool: ListSymbols — list all indexed symbols -// --------------------------------------------------------------------------- - -/// List all indexed symbols grouped by language and file. -/// -/// This is designed to provide a compact symbol listing for system-prompt -/// context so the AI agent knows what functions, variables, and types exist. -pub struct ListSymbols; - -impl Tool for ListSymbols { - fn name(&self) -> &'static str { - "list_symbols" - } - - fn description(&self) -> &'static str { - "List all indexed code symbols (functions, classes, variables, structs, interfaces, \ - types, constants) across Rust, TypeScript, JavaScript, Python, and Go. \ - Optionally filter by language, kind, or file path." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "language": { - "type": "string", - "enum": ["rust", "typescript", "javascript", "python", "go", "all"], - "description": "Filter by language (default: all)", - "default": "all" - }, - "kind": { - "type": "string", - "enum": ["fn", "class", "struct", "enum", "interface", "trait", "const", "var", "mod", "all"], - "description": "Filter by symbol kind (default: all)", - "default": "all" - }, - "file": { - "type": "string", - "description": "Filter by file path substring (e.g. 'auth/', 'domain/')" - }, - "max_results": { - "type": "integer", - "description": "Maximum symbols to list (default 50, max 200)", - "default": 50 - }, - "rebuild_index": { - "type": "boolean", - "description": "Force rebuild before listing (default false)", - "default": false - } - } - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let lang_filter = args - .get("language") - .and_then(|v| v.as_str()) - .unwrap_or("all"); - let kind_filter = args.get("kind").and_then(|v| v.as_str()).unwrap_or("all"); - let file_filter = args.get("file").and_then(|v| v.as_str()); - let max_results = args - .get("max_results") - .and_then(|v| v.as_u64()) - .unwrap_or(50) - .min(200) as usize; - let rebuild = args - .get("rebuild_index") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let workspace = ctx - .workspaces - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| ".".to_string()); - - // Build (or load) the per-workspace index without holding the global - // lock across the I/O-heavy walk. - let _count = ensure_symbol_index(&workspace, rebuild)?; - let map = symbol_index()?; - let index = map.get(&workspace).expect("index should be ensured"); - - let target_lang = match lang_filter { - "rust" => Some(Language::Rust), - "typescript" => Some(Language::TypeScript), - "javascript" => Some(Language::JavaScript), - "python" => Some(Language::Python), - "go" => Some(Language::Go), - _ => None, - }; - - let target_kind = match kind_filter { - "fn" => Some(SymbolKind::Function), - "class" => Some(SymbolKind::Class), - "struct" => Some(SymbolKind::Struct), - "enum" => Some(SymbolKind::Enum), - "interface" => Some(SymbolKind::Interface), - "trait" => Some(SymbolKind::Trait), - "const" => Some(SymbolKind::Constant), - "var" => Some(SymbolKind::Variable), - "mod" => Some(SymbolKind::Module), - _ => None, - }; - - let symbols = index.list(target_lang, target_kind, file_filter, max_results); - - let total = index.len(); - let by_lang = index.count_by_language(); - - if symbols.is_empty() { - return Ok(format!( - "No symbols match the filters. Index has {total} total symbols.\n\ - Languages: {}", - by_lang - .iter() - .map(|(l, c)| format!("{l}: {c}")) - .collect::>() - .join(", ") - )); - } - - let mut out = format!( - "## Indexed Symbols\n\n**Total:** {total} | **Showing:** {} | **Filter:** lang={lang_filter}, kind={kind_filter}\n\n", - symbols.len() - ); - - // Group by language - let mut by_lang_map: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for sym in &symbols { - by_lang_map - .entry(sym.language.to_string()) - .or_default() - .push(*sym); - } - - for (lang, syms) in &by_lang_map { - out.push_str(&format!("### {lang}\n\n")); - - let mut by_file: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for sym in syms { - by_file.entry(sym.file.clone()).or_default().push(*sym); - } - - for (file, file_syms) in &by_file { - out.push_str(&format!("`{file}`:\n")); - for sym in file_syms { - out.push_str(&format!(" `{}` {} L{}\n", sym.kind, sym.name, sym.line,)); - } - } - out.push('\n'); - } - - out.push_str("---\n"); - out.push_str(&format!( - "By language: {}\n", - by_lang - .iter() - .map(|(l, c)| format!("{l}: {c}")) - .collect::>() - .join(", ") - )); - out.push_str(&format!( - "By kind: {}\n", - index - .count_by_kind() - .iter() - .map(|(k, c)| format!("{k}: {c}")) - .collect::>() - .join(", ") - )); - - Ok(out) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_rust_functions() { - let content = "pub fn hello() {}\nfn world() {}\n"; - let symbols = extract_rust(content, "test.rs"); - assert_eq!(symbols.len(), 2); - assert_eq!(symbols[0].name, "hello"); - assert_eq!(symbols[1].name, "world"); - } - - #[test] - fn test_extract_rust_struct() { - let content = "pub struct MyStruct {}\nstruct Private;\n"; - let symbols = extract_rust(content, "test.rs"); - assert!(symbols.iter().any(|s| s.name == "MyStruct")); - assert!(symbols.iter().any(|s| s.name == "Private")); - } - - #[test] - fn test_extract_typescript_function_and_class() { - let content = "function hello() {}\nexport class User {}\ninterface Person {}\n"; - let symbols = extract_typescript(content, "test.ts"); - assert!(symbols - .iter() - .any(|s| s.name == "hello" && s.kind == SymbolKind::Function)); - assert!(symbols - .iter() - .any(|s| s.name == "User" && s.kind == SymbolKind::Class)); - assert!(symbols - .iter() - .any(|s| s.name == "Person" && s.kind == SymbolKind::Interface)); - } - - #[test] - fn test_extract_typescript_const() { - let content = "export const API_URL = 'http://example.com';\nconst MAX_RETRIES = 3;\n"; - let symbols = extract_typescript(content, "test.ts"); - assert!(symbols - .iter() - .any(|s| s.name == "API_URL" && s.kind == SymbolKind::Constant)); - assert!(symbols - .iter() - .any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant)); - } - - #[test] - fn test_extract_python_def_and_class() { - let content = - "class MyClass:\n def method(self):\n pass\n\ndef top_func():\n pass\n"; - let symbols = extract_python(content, "test.py"); - assert!(symbols - .iter() - .any(|s| s.name == "MyClass" && s.kind == SymbolKind::Class)); - assert!(symbols - .iter() - .any(|s| s.name == "MyClass.method" && s.kind == SymbolKind::Function)); - assert!(symbols - .iter() - .any(|s| s.name == "top_func" && s.kind == SymbolKind::Function)); - } - - #[test] - fn test_extract_python_variable() { - let content = "DATABASE_URL = 'postgres://localhost'\nconfig_path = '/etc/app'\n"; - let symbols = extract_python(content, "test.py"); - assert!(symbols - .iter() - .any(|s| s.name == "DATABASE_URL" && s.kind == SymbolKind::Constant)); - assert!(symbols - .iter() - .any(|s| s.name == "config_path" && s.kind == SymbolKind::Variable)); - } - - #[test] - fn test_extract_go_func_and_struct() { - let content = "func main() {}\nfunc (s *Server) Serve() {}\ntype Config struct {\n Name string\n}\n"; - let symbols = extract_go(content, "test.go"); - assert!(symbols - .iter() - .any(|s| s.name == "main" && s.kind == SymbolKind::Function)); - assert!(symbols - .iter() - .any(|s| s.name == "Serve" && s.kind == SymbolKind::Function)); - assert!(symbols - .iter() - .any(|s| s.name == "Config" && s.kind == SymbolKind::Struct)); - } - - #[test] - fn test_extract_go_const_and_var() { - let content = "const VERSION = \"1.0\"\nvar DefaultPort = 8080\n"; - let symbols = extract_go(content, "test.go"); - assert!(symbols - .iter() - .any(|s| s.name == "VERSION" && s.kind == SymbolKind::Constant)); - assert!(symbols - .iter() - .any(|s| s.name == "DefaultPort" && s.kind == SymbolKind::Variable)); - } - - #[test] - fn test_index_rebuild_and_search() { - let dir = std::env::temp_dir().join(format!("sstest_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("test.rs"), "pub fn search_me() {}\n").unwrap(); - std::fs::write(dir.join("test.ts"), "export const FOO = 42;\n").unwrap(); - - let mut index = SymbolIndex::new(); - let count = index - .rebuild(&dir.to_string_lossy()) - .expect("rebuild should succeed"); - assert!(count >= 2, "should index at least 2 symbols, got {count}"); - - let results = index.search("search_me", 10); - assert!(!results.is_empty(), "should find search_me"); - - let results = index.search("FOO", 10); - assert!(!results.is_empty(), "should find FOO"); - - let by_lang = index.count_by_language(); - assert!( - by_lang.iter().any(|(l, _)| *l == Language::Rust), - "should have Rust symbols" - ); - assert!( - by_lang.iter().any(|(l, _)| *l == Language::TypeScript), - "should have TypeScript symbols" - ); - } - - #[test] - fn test_empty_extraction() { - let symbols = extract_rust("// just a comment\n", "empty.rs"); - assert!(symbols.is_empty()); - } - - #[test] - fn test_search_empty_index() { - let index = SymbolIndex::new(); - assert!(index.search("anything", 10).is_empty()); - } - - #[test] - fn test_needs_rebuild_workspace_aware() { - let ws_a = std::env::temp_dir().join(format!("ws_a_{}", uuid::Uuid::new_v4())); - let ws_b = std::env::temp_dir().join(format!("ws_b_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&ws_a).unwrap(); - std::fs::create_dir_all(&ws_b).unwrap(); - std::fs::write(ws_a.join("a.rs"), "pub fn fn_in_a() {}\n").unwrap(); - std::fs::write(ws_b.join("b.rs"), "pub fn fn_in_b() {}\n").unwrap(); - - let mut index = SymbolIndex::new(); - let a = ws_a.to_string_lossy().to_string(); - let b = ws_b.to_string_lossy().to_string(); - - // Fresh index: needs rebuild for any workspace. - assert!(index.needs_rebuild(&a)); - - // After rebuilding A, searching A needs no rebuild... - index.rebuild(&a).unwrap(); - assert!(!index.needs_rebuild(&a)); - // ...but searching B DOES (stale index otherwise). - assert!( - index.needs_rebuild(&b), - "workspace switch must trigger rebuild" - ); - - // Rebuilding B flips the cached workspace. - index.rebuild(&b).unwrap(); - assert!(!index.needs_rebuild(&b)); - assert!(index.needs_rebuild(&a)); - - std::fs::remove_dir_all(&ws_a).ok(); - std::fs::remove_dir_all(&ws_b).ok(); - } - - #[test] - fn test_ensure_symbol_index_per_workspace_isolation() { - let ws_a = std::env::temp_dir().join(format!("iso_a_{}", uuid::Uuid::new_v4())); - let ws_b = std::env::temp_dir().join(format!("iso_b_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&ws_a).unwrap(); - std::fs::create_dir_all(&ws_b).unwrap(); - std::fs::write(ws_a.join("a.rs"), "pub fn only_in_a() {}\n").unwrap(); - std::fs::write(ws_b.join("b.rs"), "pub fn only_in_b() {}\n").unwrap(); - - let a = ws_a.to_string_lossy().to_string(); - let b = ws_b.to_string_lossy().to_string(); - - // Build A and B independently through the shared global helper. - let count_a = ensure_symbol_index(&a, false).unwrap(); - assert!( - count_a >= 1, - "workspace A should index its fn, got {count_a}" - ); - let count_b = ensure_symbol_index(&b, false).unwrap(); - assert!( - count_b >= 1, - "workspace B should index its fn, got {count_b}" - ); - - // Rebuilding A must not have clobbered B and vice-versa. - let count_a_again = ensure_symbol_index(&a, true).unwrap(); - assert!(count_a_again >= 1); - - // Each workspace's cached index is independently correct. - { - let map = symbol_index().unwrap(); - let idx_a = map.get(&a).unwrap(); - assert!(!idx_a.search("only_in_a", 5).is_empty()); - assert!(idx_a.search("only_in_b", 5).is_empty()); - let idx_b = map.get(&b).unwrap(); - assert!(!idx_b.search("only_in_b", 5).is_empty()); - } - - std::fs::remove_dir_all(&ws_a).ok(); - std::fs::remove_dir_all(&ws_b).ok(); - } -} diff --git a/apps/infrastructure/src/tools/sequential_think.rs b/apps/infrastructure/src/tools/sequential_think.rs deleted file mode 100644 index 036b9ba..0000000 --- a/apps/infrastructure/src/tools/sequential_think.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Sequential thinking tool — step-by-step reasoning. -//! -//! Allows the agent to record one step of a chain-of-thought reasoning process, -//! tracking progress through a planned number of steps. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Perform sequential / step-by-step reasoning (chain-of-thought). -/// -/// Flow: extract thought, step number, total steps, and continuation flag from -/// args → format into a reasoning step response. -pub struct SeqThink; - -impl Tool for SeqThink { - fn name(&self) -> &'static str { - "sequential_think" - } - - fn description(&self) -> &'static str { - "Perform sequential / step-by-step reasoning (chain-of-thought)" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "thought": { - "type": "string", - "description": "The current step of reasoning" - }, - "step_number": { - "type": "integer", - "description": "Current step number" - }, - "total_steps": { - "type": "integer", - "description": "Total number of steps planned" - }, - "next_thought_needed": { - "type": "boolean", - "description": "Whether another thinking step is needed" - } - }, - "required": ["thought"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let thought = crate::tools::arg_str(args, "thought")?; - let step = args - .get("step_number") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let total = args - .get("total_steps") - .and_then(|v| v.as_i64()) - .unwrap_or(1); - let next_needed = args - .get("next_thought_needed") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - info!(step, total, next_needed, "sequential think step"); - - Ok(format!( - "Step {}/{}: {}\n{}", - step, - total, - thought, - if next_needed { - "Continuing reasoning..." - } else { - "Reasoning complete." - } - )) - } -} diff --git a/apps/infrastructure/src/tools/shell.rs b/apps/infrastructure/src/tools/shell.rs deleted file mode 100644 index f228d6c..0000000 --- a/apps/infrastructure/src/tools/shell.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Bash-shell execution tool with safety filters and optional timeout. -//! -//! Executes shell commands via `bash -c`. Supports a timeout, background -//! execution, and safety filters that block destructive git operations. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::process::Command; -use std::time::Duration; -use tracing::{info, instrument, warn}; - -/// Execute a shell command via `bash -c`. -/// -/// Flow: parse command + timeout + background flag → check git safety filter -/// → either spawn background job or run synchronously with timeout loop → -/// capture output → return result. -pub struct Bash; - -impl Tool for Bash { - fn name(&self) -> &'static str { - "bash" - } - - fn description(&self) -> &'static str { - "Execute a shell command via bash -c" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Shell command to execute" - }, - "description": { - "type": "string", - "description": "Human-readable description of what the command does" - }, - "timeout": { - "type": "integer", - "description": "Timeout in milliseconds (default 120000, max 600000)" - }, - "run_in_background": { - "type": "boolean", - "description": "Run the command in the background" - } - }, - "required": ["command"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let cmd = crate::tools::arg_str(args, "command")?; - let timeout_ms = args - .get("timeout") - .and_then(serde_json::Value::as_u64) - .unwrap_or(120_000) - .min(600_000); - - info!(cmd_len = cmd.len(), timeout_ms, "bash execution starting"); - - // Safety filter: block destructive git operations - crate::tools::shell_filter::git::check_git_destructive(&cmd) - .map_err(|e| anyhow::anyhow!("blocked: {e}"))?; - - let run_in_background = args - .get("run_in_background") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - if run_in_background { - let job = crate::bgbash::job::spawn_bash_job(cmd); - crate::bgbash::control::bash_control().register(job.clone()); - info!(job_id = %job.id, "bash spawned in background"); - return Ok(format!("Background job: {}", job.id)); - } - - let mut child = Command::new("bash") - .arg("-c") - .arg(&cmd) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| anyhow::anyhow!("failed to spawn bash: {e}"))?; - - let start = std::time::Instant::now(); - let timeout = Duration::from_millis(timeout_ms); - - let mut child_stdout = child - .stdout - .take() - .ok_or_else(|| anyhow::anyhow!("failed to capture stdout"))?; - let mut child_stderr = child - .stderr - .take() - .ok_or_else(|| anyhow::anyhow!("failed to capture stderr"))?; - - let stdout_handle = std::thread::spawn(move || -> std::io::Result> { - use std::io::Read; - let mut buf = Vec::new(); - child_stdout.read_to_end(&mut buf)?; - Ok(buf) - }); - let stderr_handle = std::thread::spawn(move || -> std::io::Result> { - use std::io::Read; - let mut buf = Vec::new(); - child_stderr.read_to_end(&mut buf)?; - Ok(buf) - }); - - loop { - match child.try_wait() { - Ok(Some(status)) => { - let stdout = stdout_handle.join().unwrap_or(Ok(Vec::new()))?; - let stderr = stderr_handle.join().unwrap_or(Ok(Vec::new()))?; - - let elapsed = start.elapsed().as_secs_f64(); - let stdout_str = String::from_utf8_lossy(&stdout).to_string(); - let stderr_str = String::from_utf8_lossy(&stderr).to_string(); - let combined = if stderr_str.is_empty() { - stdout_str - } else { - format!("{stdout_str}\n{stderr_str}") - }; - let trimmed = combined.trim().to_string(); - - if status.success() { - return Ok(if trimmed.is_empty() { - format!("Command completed in {elapsed:.2}s (exit code 0)") - } else { - format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)") - }); - } - return Ok(format!( - "{}\n\nExit code: {} ({:.2}s)", - trimmed, - status.code().unwrap_or(-1), - elapsed - )); - } - Ok(None) => { - if start.elapsed() > timeout { - let _ = child.kill(); - let _ = child.wait(); - anyhow::bail!("command timed out after {timeout_ms}ms"); - } - std::thread::sleep(Duration::from_millis(10)); - } - Err(e) => { - anyhow::bail!("failed to wait for command: {e}"); - } - } - } - } -} diff --git a/apps/infrastructure/src/tools/shell_filter/credentials.rs b/apps/infrastructure/src/tools/shell_filter/credentials.rs deleted file mode 100644 index f8a760c..0000000 --- a/apps/infrastructure/src/tools/shell_filter/credentials.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Credential read detection — detects commands that might exfiltrate secrets. -//! -//! NOTE: This filter is intentionally NOT wired into the bash tool by default. -//! See the module doc for rationale. - -use regex::Regex; -use tracing::instrument; - -/// Paths that are likely to contain credentials. -/// -/// Checks against common credential file locations: SSH keys, `.netrc`, -/// cloud provider credentials (AWS, Azure, GCP), Docker config, etc. -pub fn is_credential_path(path: &str) -> bool { - let patterns = [ - r"~/.ssh/", - r"\.netrc", - r"\.aws/credentials", - r"\.aws/config", - r"\.azure/", - r"\.gcp/", - r"\.docker/config\.json", - r"id_rsa", - r"id_ed25519", - r"known_hosts", - ]; - patterns.iter().any(|p| path.contains(p)) -} - -/// Check whether a command reads credential files. -/// -/// Flow: regex-match command for common read commands (`cat`, `head`, etc.) -/// with file paths → check each matched path against `is_credential_path` → -/// return list of suspected credential reads. -#[instrument(skip(cmd))] -pub fn check_credential_read(cmd: &str) -> Vec { - let re = Regex::new( - r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#, - ) - .expect("hardcoded credential-read regex is valid"); - let mut findings = Vec::new(); - for cap in re.captures_iter(cmd) { - let path = cap.get(1).map(|m| m.as_str()).unwrap_or(""); - if is_credential_path(path) { - findings.push(format!("potential credential read: '{}'", path)); - } - } - findings -} diff --git a/apps/infrastructure/src/tools/shell_filter/git.rs b/apps/infrastructure/src/tools/shell_filter/git.rs deleted file mode 100644 index e809a01..0000000 --- a/apps/infrastructure/src/tools/shell_filter/git.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Git operation safety filter — blocks destructive git commands. -//! -//! Used by the bash tool to prevent accidental or malicious git operations -//! that could destroy work (force-push, hard reset, rebase, branch deletion, etc.). - -use tracing::instrument; - -/// Check whether a shell command contains a destructive git operation. -/// -/// Blocks: `git push --force`, `git reset --hard`, `git rebase`, etc. -/// -/// Returns `Ok(())` if the command is safe, or `Err(message)` if a destructive -/// pattern was detected. -#[instrument(skip(cmd))] -pub fn check_git_destructive(cmd: &str) -> Result<(), String> { - let cmd_lower = cmd.to_lowercase(); - - let destructive_patterns = [ - "git push --force", - "git push -f", - "git reset --hard", - "git rebase", - "git branch -d", - "git branch -D", - "git tag -d", - "git tag --delete", - ]; - - for pattern in &destructive_patterns { - if cmd_lower.contains(pattern) { - return Err(format!("destructive git operation blocked: '{}'", pattern)); - } - } - - Ok(()) -} diff --git a/apps/infrastructure/src/tools/shell_filter/mod.rs b/apps/infrastructure/src/tools/shell_filter/mod.rs deleted file mode 100644 index 1cfc40f..0000000 --- a/apps/infrastructure/src/tools/shell_filter/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Safety filters for bash command execution. - -pub mod credentials; -pub mod git; diff --git a/apps/infrastructure/src/tools/spawn.rs b/apps/infrastructure/src/tools/spawn.rs deleted file mode 100644 index 15692fb..0000000 --- a/apps/infrastructure/src/tools/spawn.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Agent spawning tools — launch subagents and pipelines. -//! -//! `SpawnAgents` runs multiple subagents in parallel threads. `SpawnPipeline` -//! runs a sequence of agent stages one after another. - -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{debug, info, instrument, warn}; - -use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; -use zesdex_domain::core::Store; - -use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; -use crate::subagent::context::SubagentContext; -use crate::subagent::division::AccessTier; -use crate::subagent::engine::run_agent; -use crate::subagent::spawn::spawn_subagent; -use crate::tools::{Tool, ToolCtx}; - -/// Spawn multiple agent instances to work in parallel on subtasks. -/// -/// Flow: parse agents array → load settings → for each agent, build a -/// SubagentContext and call spawn_subagent → join all threads → collect results. -pub struct SpawnAgents; - -impl Tool for SpawnAgents { - fn name(&self) -> &'static str { - "spawn_agents" - } - - fn description(&self) -> &'static str { - "Spawn multiple agent instances to work in parallel on subtasks" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "agents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "directive": {"type": "string", "description": "Directive for the agent"}, - "access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier"} - }, - "required": ["directive"] - }, - "description": "List of agents to spawn" - } - }, - "required": ["agents"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let agents = args - .get("agents") - .and_then(|v| v.as_array()) - .ok_or_else(|| anyhow::anyhow!("missing 'agents' array"))?; - - info!("Spawning {} agents", agents.len()); - debug!(agent_count = agents.len(), "parsing agents array"); - - // Load LLM credentials once for all agents - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - let mut handles = Vec::new(); - for (i, agent) in agents.iter().enumerate() { - let directive = agent - .get("directive") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let access_str = agent - .get("access") - .and_then(|v| v.as_str()) - .unwrap_or("full"); - - let access = match access_str { - "read" => AccessTier::Read, - "write" => AccessTier::Write, - _ => AccessTier::Full, - }; - - let subagent_ctx = SubagentContext::new( - directive.clone(), - ctx.clone(), - access_str.to_string(), - base_url.clone(), - api_key.clone(), - model.clone(), - ); - - debug!(agent_index = i, access = %access_str, "spawning subagent"); - let handle = spawn_subagent(subagent_ctx, directive.clone(), access, ctx.clone()); - handles.push((i, handle)); - } - - // Join all handles and collect results - let mut results = Vec::new(); - for (i, handle) in handles { - let result = handle - .join() - .map_err(|e| anyhow::anyhow!("subagent {i} panicked: {e:?}"))??; - results.push(format!("Agent {i}: {result}")); - info!(agent_index = i, "subagent completed"); - } - - info!("All {} subagents completed", agents.len()); - Ok(format!( - "Spawned {} agents.\n\nResults:\n{}", - agents.len(), - results.join("\n") - )) - } -} - -/// Spawn a sequential pipeline of agent stages. -/// -/// Flow: parse stages → load settings → for each stage, build a -/// SubagentContext and call run_agent sequentially → collect results. -pub struct SpawnPipeline; - -impl Tool for SpawnPipeline { - fn name(&self) -> &'static str { - "spawn_pipeline" - } - - fn description(&self) -> &'static str { - "Spawn a sequential pipeline of agent stages" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "stages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "directive": {"type": "string", "description": "Directive for this pipeline stage"}, - "access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this stage"} - }, - "required": ["directive"] - }, - "description": "Pipeline stages in order" - } - }, - "required": ["stages"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let stages = args - .get("stages") - .and_then(|v| v.as_array()) - .ok_or_else(|| anyhow::anyhow!("missing 'stages' array"))?; - - info!("Spawning pipeline with {} stages", stages.len()); - - // Load LLM credentials once for all stages - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - let rt = tokio::runtime::Runtime::new()?; - let mut pipeline_result = String::new(); - - for (i, stage) in stages.iter().enumerate() { - let directive = stage - .get("directive") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let access_str = stage - .get("access") - .and_then(|v| v.as_str()) - .unwrap_or("full"); - - let access = match access_str { - "read" => AccessTier::Read, - "write" => AccessTier::Write, - _ => AccessTier::Full, - }; - - let subagent_ctx = SubagentContext::new( - directive.clone(), - ctx.clone(), - access_str.to_string(), - base_url.clone(), - api_key.clone(), - model.clone(), - ); - - debug!(stage_index = i, access = %access_str, "running pipeline stage"); - let result = rt.block_on(async { - run_agent(subagent_ctx, &directive, access, ctx.clone()).await - })?; - - pipeline_result.push_str(&format!("Stage {}: {}\n", i, result)); - info!(stage_index = i, "pipeline stage completed"); - } - - info!("Pipeline with {} stages completed", stages.len()); - Ok(format!( - "Pipeline with {} stages completed.\n\n{}", - stages.len(), - pipeline_result - )) - } -} diff --git a/apps/infrastructure/src/tools/util.rs b/apps/infrastructure/src/tools/util.rs deleted file mode 100644 index 740ad85..0000000 --- a/apps/infrastructure/src/tools/util.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Shared utility functions used by tool implementations: JSON argument -//! extraction, command execution, path resolution, and edit-log persistence. - -use crate::utils::CastOr; -use anyhow::Result; -use serde_json::Value; -use sha2::Digest; -use std::path::PathBuf; -use tracing::{debug, info, instrument, warn}; - -/// Extract a required string argument from a JSON args map. -pub fn arg_str(args: &Value, name: &str) -> Result { - args.get(name) - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) - .ok_or_else(|| anyhow::anyhow!("missing required argument: {name}")) -} - -/// Execute a `std::process::Command` and return its combined stdout/stderr. -/// -/// Flow: spawn \u{2192} collect stdout + stderr \u{2192} check exit code \u{2192} return -/// combined output or bail with the error message. -#[instrument(skip(cmd))] -pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { - let output = cmd - .output() - .map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout_len = stdout.len(); - let stderr_len = stderr.len(); - let combined = if stderr.is_empty() { - stdout - } else { - format!("{}\n{}", stdout, stderr).trim().to_string() - }; - let code = output.status.code().unwrap_or(-1); - if output.status.success() { - info!(exit_code = code, stdout_len, "command succeeded"); - Ok(combined) - } else { - warn!(exit_code = code, stderr_len, "command failed"); - anyhow::bail!("command failed with exit code {code}:\n{combined}") - } -} - -/// Resolve a tool-supplied relative path to an absolute path within a workspace -/// root, rejecting escapes. -/// -/// Flow: parse optional `[idx]` prefix \u{2192} join with workspace root \u{2192} -/// canonicalize \u{2192} verify result is inside one of the workspace roots. -#[instrument(skip(workspaces))] -pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { - let (ws_idx, path) = if rel.starts_with('[') { - let close = rel - .find(']') - .ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; - let idx: usize = rel[1..close] - .parse() - .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; - (idx, &rel[close + 1..]) - } else { - (0, rel) - }; - let base = workspaces - .get(ws_idx) - .ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; - let abs = if path.is_empty() { - base.clone() - } else { - base.join(path) - }; - let canon = if let Ok(c) = abs.canonicalize() { - c - } else { - let base_canon = workspaces - .iter() - .find_map(|w| w.canonicalize().ok()) - .unwrap_or_else(|| base.clone()); - let mut resolved = base_canon.clone(); - if let Ok(rel_components) = abs.strip_prefix(&base_canon) { - for comp in rel_components.components() { - match comp { - std::path::Component::ParentDir => { - resolved.pop(); - } - std::path::Component::CurDir => {} - c => resolved.push(c), - } - } - } - resolved - }; - debug!(resolved = %canon.display(), "path resolved within workspace"); - if workspaces.iter().any(|w| canon.starts_with(w)) { - Ok(canon) - } else { - warn!(path = %canon.display(), rel = rel, "path is outside all workspace roots"); - anyhow::bail!("path '{rel}' is outside all workspace roots") - } -} - -/// After a successful write/edit tool run, compute content hash and byte -/// delta, then persist an `EditLogEntry` to the session's edit log. -/// -/// Flow: extract path/content/reason from args \u{2192} compute SHA-256 of content -/// \u{2192} compute byte delta \u{2192} build `EditLogEntry` \u{2192} open repo \u{2192} append entry. -#[instrument(skip(args, session_dir))] -pub fn log_write_edit_tool( - args: &serde_json::Value, - tool_name: &str, - origin_tag: &str, - session_dir: &std::path::Path, - session_id: &str, -) { - let reason = args - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("unnamed"); - let path = args - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let content = args.get("content").or_else(|| args.get("new")); - let content_str = content.and_then(|v| v.as_str()).unwrap_or(""); - let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); - let bytes_delta = if tool_name == "write" { - content_str.len().cast_or(0i64) - } else { - let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); - let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); - let new_len: i64 = new.len().cast_or(0i64); - let old_len: i64 = old.len().cast_or(0i64); - (new_len - old_len).abs() - }; - let entry = zesdex_domain::cms::EditLogEntry { - ts: chrono::Utc::now().timestamp_millis(), - tool: tool_name.to_string(), - path: path.to_string(), - reason: reason.to_string(), - content_sha256, - bytes_delta, - origin: origin_tag.to_string(), - session_id: session_id.to_string(), - }; - use zesdex_domain::cms::repository::EditLogRepository; - let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new(); - if let Ok(mut el) = repo.open(session_dir) { - let _ = repo.append(session_dir, &mut el, entry); - debug!(tool = tool_name, path = path, "edit-log entry persisted"); - } else { - warn!( - tool = tool_name, - path = path, - "failed to open edit-log repository" - ); - } -} diff --git a/apps/infrastructure/src/tools/utility/cd.rs b/apps/infrastructure/src/tools/utility/cd.rs deleted file mode 100644 index 363769a..0000000 --- a/apps/infrastructure/src/tools/utility/cd.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Change the working directory for subsequent commands. -//! -//! Resolves the requested directory against the workspace list and -//! sets the process-wide current directory via `std::env::set_current_dir`. - -use crate::tools::{resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Tool that sets the working directory for subsequent tool calls. -/// -/// Flow: parse directory argument → resolve against configured workspaces -/// → call `std::env::set_current_dir` → confirm the new directory. -pub struct Cd; - -impl Tool for Cd { - fn name(&self) -> &'static str { - "cd" - } - - fn description(&self) -> &'static str { - "Set the working directory for subsequent tool calls" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "directory": { - "type": "string", - "description": "Directory path to change to (relative to workspace root)" - } - }, - "required": ["directory"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let dir = crate::tools::arg_str(args, "directory")?; - let resolved = resolve_path(&ctx.workspaces, &dir)?; - info!(from = %std::env::current_dir().unwrap_or_default().display(), to = %resolved.display(), "cd invoked"); - std::env::set_current_dir(&resolved)?; - Ok(format!("Changed directory to '{}'", resolved.display())) - } -} diff --git a/apps/infrastructure/src/tools/utility/dir_cache_update.rs b/apps/infrastructure/src/tools/utility/dir_cache_update.rs deleted file mode 100644 index 66ad4f4..0000000 --- a/apps/infrastructure/src/tools/utility/dir_cache_update.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Update the shared directory cache by resolving each path against -//! workspaces and storing the resolved paths in `ctx.dir_cache`. -//! -//! The cache is an `Arc>` shared with the TUI and -//! other components so they can read the cached listing without -//! re-scanning the filesystem. - -use crate::tools::{resolve_path, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use std::path::PathBuf; -use tracing::{info, instrument}; - -/// Tool that updates the cached directory listing. -/// -/// Flow: parse `paths` array → resolve each against workspaces → -/// persist resolved paths into the shared `DirCache` via an -/// async write → confirm with the entry count. -pub struct DirCacheUpdate; - -impl crate::tools::Tool for DirCacheUpdate { - fn name(&self) -> &'static str { - "dir_cache_update" - } - - fn description(&self) -> &'static str { - "Update the cached directory listing" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": {"type": "string"}, - "description": "New list of paths for the cache" - } - } - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let paths: Vec = args - .get("paths") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let resolved: Vec = paths - .iter() - .map(|p| resolve_path(&ctx.workspaces, p)) - .collect::>>()?; - - let count = resolved.len(); - info!(count, "directory cache update requested"); - - // Persist the resolved paths into the shared DirCache so the TUI - // and other tools can read the cached listing without re-scanning. - let dc = ctx.dir_cache.clone(); - let rt = crate::runtime::runtime(); - rt.block_on(async { dc.write().await.set(resolved).await }); - - info!(count, "directory cache updated"); - Ok(format!("Directory cache updated with {} entries", count)) - } -} diff --git a/apps/infrastructure/src/tools/utility/dir_list.rs b/apps/infrastructure/src/tools/utility/dir_list.rs deleted file mode 100644 index 7e2afef..0000000 --- a/apps/infrastructure/src/tools/utility/dir_list.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! List directory contents. -//! -//! Resolves a relative path against the workspace list, validates -//! it exists and is a directory, then reads and returns sorted entries. - -use crate::tools::{resolve_path, Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Tool that lists files and directories in a given path. -/// -/// Flow: parse path → resolve against workspaces → validate existence -/// and type → read directory entries → format with trailing `/` for -/// subdirectories → return sorted, newline-separated listing. -pub struct DirList; - -impl Tool for DirList { - fn name(&self) -> &'static str { - "dir_list" - } - - fn description(&self) -> &'static str { - "List files and directories in a given path" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to list (relative to workspace root)" - } - }, - "required": ["path"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = crate::tools::arg_str(args, "path")?; - let path = resolve_path(&ctx.workspaces, &rel)?; - - info!(?path, "dir_list invoked"); - - if !path.exists() { - anyhow::bail!("path '{rel}' does not exist"); - } - if !path.is_dir() { - anyhow::bail!("'{rel}' is not a directory"); - } - - let entries = std::fs::read_dir(&path)?; - let mut items: Vec = entries - .filter_map(|e| e.ok()) - .map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - if e.path().is_dir() { - format!("{name}/") - } else { - name - } - }) - .collect(); - items.sort(); - Ok(items.join("\n")) - } -} diff --git a/apps/infrastructure/src/tools/utility/mod.rs b/apps/infrastructure/src/tools/utility/mod.rs deleted file mode 100644 index 251468c..0000000 --- a/apps/infrastructure/src/tools/utility/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Utility tools — cd, dir_list, dir_cache_update, pong, todowrite, todofinish. - -pub mod cd; -pub mod dir_cache_update; -pub mod dir_list; -pub mod pong; -pub mod todofinish; -pub mod todowrite; diff --git a/apps/infrastructure/src/tools/utility/pong.rs b/apps/infrastructure/src/tools/utility/pong.rs deleted file mode 100644 index 00b04de..0000000 --- a/apps/infrastructure/src/tools/utility/pong.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Simple ping/pong tool for connectivity testing. -//! -//! Always returns the string `"pong"`. Used by LLM agents to verify -//! that the tool harness is reachable and responsive. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Tool that responds to a ping — useful for testing connectivity. -/// -/// Accepts no parameters and always returns `"pong"`. -pub struct Pong; - -impl Tool for Pong { - fn name(&self) -> &'static str { - "pong" - } - - fn description(&self) -> &'static str { - "Ping the agent — useful for testing connectivity" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - #[instrument(skip(self, _ctx, _args))] - fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result { - info!("pong invoked — responding with 'pong'"); - Ok("pong".to_string()) - } -} diff --git a/apps/infrastructure/src/tools/utility/todofinish.rs b/apps/infrastructure/src/tools/utility/todofinish.rs deleted file mode 100644 index af4a51a..0000000 --- a/apps/infrastructure/src/tools/utility/todofinish.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Mark a TODO item as finished. -//! -//! Reads the session's `TODO.md`, replaces the matching unchecked -//! item with a checked `[x]` entry, writes the file back, and emits -//! a `TurnEvent::TodoUpdate` for the TUI. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Tool that marks a TODO item as completed in the session TODO file. -/// -/// Flow: parse item text → read `TODO.md` → find matching line → -/// replace `[ ]` / `[high]` / `[medium]` / `[low]` with `[x]` → -/// write file → push `TurnEvent::TodoUpdate` if events channel exists. -pub struct Todofinish; - -impl Tool for Todofinish { - fn name(&self) -> &'static str { - "todofinish" - } - - fn description(&self) -> &'static str { - "Mark a TODO item as completed" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "item": { - "type": "string", - "description": "TODO item text that was completed" - } - }, - "required": ["item"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let item = crate::tools::arg_str(args, "item")?; - info!(item, "todofinish invoked"); - - let todo_path = ctx.session_dir.join("TODO.md"); - let mut content = std::fs::read_to_string(&todo_path).unwrap_or_default(); - - // Very basic replace to mark as finished - let mut replaced = false; - let lines: Vec<&str> = content.lines().collect(); - let mut new_lines = Vec::new(); - for line in lines { - if line.contains(&item) && line.starts_with("- [") { - let s = line - .replacen("- [high]", "- [x]", 1) - .replacen("- [medium]", "- [x]", 1) - .replacen("- [low]", "- [x]", 1); - // if it didn't match those, just replace the first `[` - let s = if s == line { - line.replacen("[ ]", "[x]", 1) - } else { - s - }; - new_lines.push(s); - replaced = true; - } else { - new_lines.push(line.to_string()); - } - } - - if replaced { - content = new_lines.join("\n") + "\n"; - let _ = std::fs::write(&todo_path, &content); - if let Some(events) = &ctx.turn_events { - if let Ok(mut q) = events.lock() { - q.push_back(crate::TurnEvent::TodoUpdate(content)); - } - } - info!(item, "TODO item completed and written back"); - } else { - info!(item, "TODO item not found in TODO.md — nothing to mark"); - } - - Ok(format!("TODO completed: {}", item)) - } -} diff --git a/apps/infrastructure/src/tools/utility/todowrite.rs b/apps/infrastructure/src/tools/utility/todowrite.rs deleted file mode 100644 index cb322d5..0000000 --- a/apps/infrastructure/src/tools/utility/todowrite.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Write a TODO item. -//! -//! Appends a new unchecked TODO entry to the session's `TODO.md` -//! file with an optional priority marker and emits a -//! `TurnEvent::TodoUpdate` for the TUI. - -use crate::tools::{Tool, ToolCtx}; -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{info, instrument}; - -/// Tool that adds an item to the session TODO list. -/// -/// Flow: parse item + optional priority → append `- [priority] item\n` -/// to `TODO.md` → write file → push `TurnEvent::TodoUpdate` if events -/// channel exists → confirm addition. -pub struct Todowrite; - -impl Tool for Todowrite { - fn name(&self) -> &'static str { - "todowrite" - } - - fn description(&self) -> &'static str { - "Add an item to the TODO list" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "item": { - "type": "string", - "description": "TODO item text" - }, - "priority": { - "type": "string", - "enum": ["high", "medium", "low"], - "description": "Priority level" - } - }, - "required": ["item"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let item = crate::tools::arg_str(args, "item")?; - let priority = args - .get("priority") - .and_then(|v| v.as_str()) - .unwrap_or("medium"); - - info!(item, priority, "todowrite invoked"); - - let todo_line = format!("- [{}] {}\n", priority, item); - - let todo_path = ctx.session_dir.join("TODO.md"); - let mut content = std::fs::read_to_string(&todo_path).unwrap_or_default(); - content.push_str(&todo_line); - - let _ = std::fs::write(&todo_path, &content); - - if let Some(events) = &ctx.turn_events { - if let Ok(mut q) = events.lock() { - q.push_back(crate::TurnEvent::TodoUpdate(content)); - } - } - - info!(item, priority, "TODO item added"); - Ok(format!("[{}] TODO added: {}", priority, item)) - } -} diff --git a/apps/infrastructure/src/tools/web_search.rs b/apps/infrastructure/src/tools/web_search.rs deleted file mode 100644 index c1d1e64..0000000 --- a/apps/infrastructure/src/tools/web_search.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Web search & documentation scraper tool — search the web for docs, APIs, -//! and troubleshooting information using a SearXNG instance. -//! -//! Flow: take a search query → call SearXNG JSON API → parse results → -//! optionally fetch full page content → return formatted markdown. - -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{debug, info, instrument, warn}; - -use crate::tools::{arg_str, Tool, ToolCtx}; - -/// Default SearXNG instance URL. -const DEFAULT_SEARXNG_URL: &str = "https://searxng.imrnes.team"; - -/// Search the web for documentation, APIs, and technical information. -/// -/// Flow: build query → call SearXNG JSON endpoint → parse results → -/// optionally scrape full page content → return formatted result. -pub struct WebSearch; - -impl Tool for WebSearch { - fn name(&self) -> &'static str { - "web_search" - } - - fn description(&self) -> &'static str { - "Search the web for documentation, APIs, and technical information. Uses SearXNG instance." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query (e.g. 'Next.js 15 app router documentation')" - }, - "max_results": { - "type": "integer", - "description": "Maximum number of results to return (default 5, max 10)", - "default": 5 - }, - "fetch_content": { - "type": "boolean", - "description": "Whether to fetch full page content from each result (default false)", - "default": false - }, - "categories": { - "type": "string", - "description": "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general", - "default": "general" - } - }, - "required": ["query"] - }) - } - - #[instrument(skip(self, _ctx, args))] - fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let query = arg_str(args, "query")?; - let max_results = args - .get("max_results") - .and_then(|v| v.as_u64()) - .unwrap_or(5) - .min(10) as usize; - let fetch_content = args - .get("fetch_content") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let categories = args - .get("categories") - .and_then(|v| v.as_str()) - .unwrap_or("general"); - - info!(query = %query, max_results, fetch_content, categories = %categories, "web search starting"); - - let searxng_url = - std::env::var("SEARXNG_URL").unwrap_or_else(|_| DEFAULT_SEARXNG_URL.to_string()); - - // Build the SearXNG JSON search URL - let search_url = format!( - "{}/search?format=json&q={}&categories={}&language=en-US", - searxng_url, - urlencoding(&query), - categories - ); - - debug!(search_url = %search_url, "calling SearXNG API"); - - // Make the HTTP request - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .user_agent("Zesdex/1.0 (AI Coding Agent)") - .build() - .map_err(|e| anyhow::anyhow!("failed to create HTTP client: {e}"))?; - - let response = client - .get(&search_url) - .send() - .map_err(|e| anyhow::anyhow!("search request failed: {e}"))?; - - if !response.status().is_success() { - warn!(status = %response.status(), "SearXNG returned non-success status"); - anyhow::bail!("SearXNG returned HTTP {}", response.status()); - } - - let body: Value = response - .json() - .map_err(|e| anyhow::anyhow!("failed to parse SearXNG JSON response: {e}"))?; - - let results = body - .get("results") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter(|r| { - r.get("url").and_then(|u| u.as_str()).is_some() - && r.get("title").and_then(|t| t.as_str()).is_some() - }) - .take(max_results) - .collect::>() - }) - .unwrap_or_default(); - - let result_count = results.len(); - if results.is_empty() { - info!("no search results found"); - return Ok(format!("No search results found for '{query}'.")); - } - - info!(result_count, "web search completed"); - - let mut output = format!("## Web Search Results for: {query}\n\n"); - - for (i, result) in results.iter().enumerate() { - let title = result - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("Untitled"); - let url = result.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let snippet = result - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - - output.push_str(&format!("### {}. [{title}]({url})\n\n", i + 1)); - - if !snippet.is_empty() { - output.push_str(&format!("> {snippet}\n\n")); - } - - // Optionally fetch full page content - if fetch_content && !url.is_empty() { - match fetch_page_content(url, &client) { - Ok(content) => { - let truncated = limit_lines(&content, 80); - output.push_str(&format!("**Content:**\n```\n{truncated}\n```\n\n")); - } - Err(e) => { - debug!(url = %url, error = %e, "failed to fetch page content"); - output.push_str(&format!("*(Content fetch failed: {e})*\n\n")); - } - } - } - - // Show engine info if available - if let Some(engine) = result.get("engine").and_then(|v| v.as_str()) { - output.push_str(&format!("*Source: {engine}*\n\n")); - } - } - - // Add suggestion for more specific queries if very few results - if result_count < 3 { - output.push_str( - "---\n*Few results. Try a more specific query or different categories.*\n", - ); - } - - Ok(output) - } -} - -/// URL-encode a string for use in query parameters. -fn urlencoding(input: &str) -> String { - input - .chars() - .map(|c| match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), - ' ' => '+'.to_string(), - _ => { - let bytes = c.to_string().into_bytes(); - bytes - .iter() - .map(|&b| format!("%{:02X}", b)) - .collect::() - } - }) - .collect() -} - -/// Fetch and extract readable text content from a URL. -fn fetch_page_content(url: &str, client: &reqwest::blocking::Client) -> Result { - let resp = client - .get(url) - .timeout(std::time::Duration::from_secs(15)) - .send() - .map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?; - - let html = resp - .text() - .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?; - - // Parse HTML and extract text - let document = scraper::Html::parse_document(&html); - - // Remove script and style elements - let selector = scraper::Selector::parse("script, style, nav, footer, header") - .map_err(|e| anyhow::anyhow!("invalid selector: {e}"))?; - let cleaned = document.clone(); - for element in cleaned.select(&selector) { - let inner = element.inner_html(); - let _ = inner; - } - - // Extract text from main content areas - let content_selectors = [ - "article", - "main", - ".content", - ".documentation", - ".doc-content", - ".post-content", - ".entry-content", - "#content", - "body", - ]; - - let mut text = String::new(); - for sel_str in &content_selectors { - if let Ok(sel) = scraper::Selector::parse(sel_str) { - if let Some(element) = cleaned.select(&sel).next() { - text = element.text().collect::>().join(" "); - if text.len() > 100 { - break; - } - } - } - } - - if text.is_empty() { - // Fallback: just get all body text - if let Ok(body_sel) = scraper::Selector::parse("body") { - text = cleaned - .select(&body_sel) - .next() - .map(|e| e.text().collect::>().join(" ")) - .unwrap_or_default(); - } - } - - // Clean up whitespace - let cleaned_text = text.split_whitespace().collect::>().join(" "); - - if cleaned_text.is_empty() { - anyhow::bail!("no readable content found on page"); - } - - Ok(cleaned_text) -} - -/// Limit a string to at most `max_lines` lines. -fn limit_lines(s: &str, max_lines: usize) -> String { - s.lines().take(max_lines).collect::>().join("\n") -} diff --git a/apps/infrastructure/src/tools/workflow.rs b/apps/infrastructure/src/tools/workflow.rs deleted file mode 100644 index d644577..0000000 --- a/apps/infrastructure/src/tools/workflow.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Workflow tools — orchestrate multi-step agent workflows and hive-mind convergence. -//! -//! `WorkflowRun` executes a YAML-defined multi-step workflow. `NoteFinding` and -//! `ReadFindings` record and retrieve findings during execution. `HiveMind` -//! orchestrates a convergence — multiple parallel agent cycles followed by -//! consensus synthesis. - -use anyhow::Result; -use serde_json::{json, Value}; -use tracing::{debug, info, instrument, warn}; - -use crate::tools::{arg_str, Tool, ToolCtx}; -use crate::workflow::engine::execution::execute_workflow; -use crate::workflow::hive_mind::cycle::execute_cycle; -use crate::workflow::hive_mind::synthesis::synthesize_consensus; -use zesdex_domain::workflow::{CognitiveCycle, NodeDirective, NodeOutput}; - -/// Execute a multi-step workflow defined in YAML. -/// -/// Flow: parse YAML → build plan from script phases → execute via workflow engine. -pub struct WorkflowRun; - -impl Tool for WorkflowRun { - fn name(&self) -> &'static str { - "workflow_run" - } - - fn description(&self) -> &'static str { - "Execute a multi-step workflow defined in YAML" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "workflow_yaml": { - "type": "string", - "description": "YAML workflow definition" - } - }, - "required": ["workflow_yaml"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let yaml = arg_str(args, "workflow_yaml")?; - let script = crate::workflow::script::parse_workflow_script(&yaml)?; - info!( - "Workflow started: {} ({} phases)", - script.name, - script.phases.len() - ); - - let phase_names: Vec<&str> = script.phases.iter().map(|p| p.name.as_str()).collect(); - info!( - "Workflow '{}' phases: {}", - script.name, - phase_names.join(", ") - ); - - let rt = crate::runtime::runtime(); - let result: Vec = rt.block_on(async { execute_workflow(&script, ctx).await })?; - - info!(phase_count = result.len(), "Workflow completed"); - Ok(format!( - "Workflow '{}' completed.\n\n{}", - script.name, - result.join("\n---\n") - )) - } -} - -/// Record a finding during workflow or hive-mind execution. -/// -/// Flow: extract finding text and optional category → prepend `[category]` tag -/// → push onto `ctx.workflow_findings` shared list. -pub struct NoteFinding; - -impl Tool for NoteFinding { - fn name(&self) -> &'static str { - "note_finding" - } - - fn description(&self) -> &'static str { - "Record a finding during workflow or hive-mind execution" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "finding": { - "type": "string", - "description": "The finding text" - }, - "category": { - "type": "string", - "description": "Category for the finding" - } - }, - "required": ["finding"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let finding = crate::tools::arg_str(args, "finding")?; - let category = args - .get("category") - .and_then(|v| v.as_str()) - .unwrap_or("general"); - - let tagged = format!("[{category}] {finding}"); - - if let Some(ref findings) = ctx.workflow_findings { - if let Ok(mut guard) = findings.lock() { - guard.push(tagged); - info!(finding_count = guard.len(), category = %category, "finding recorded"); - } - } else { - debug!("no workflow_findings channel available — finding not persisted"); - } - - Ok(format!("Finding recorded: {finding}")) - } -} - -/// Read all findings recorded so far in the current workflow. -/// -/// Flow: lock `ctx.workflow_findings` → clone the list → format as numbered output. -pub struct ReadFindings; - -impl Tool for ReadFindings { - fn name(&self) -> &'static str { - "read_findings" - } - - fn description(&self) -> &'static str { - "Read all findings recorded so far in the current workflow" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - #[instrument(skip(self, ctx, _args))] - fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result { - let findings = ctx - .workflow_findings - .as_ref() - .and_then(|f| f.lock().ok()) - .map(|guard| { - debug!(finding_count = guard.len(), "reading findings"); - guard.clone() - }) - .unwrap_or_default(); - - if findings.is_empty() { - return Ok("No findings recorded yet.".to_string()); - } - - Ok(format!( - "Findings ({}):\n{}", - findings.len(), - findings.join("\n") - )) - } -} - -/// Orchestrate a hive-mind convergence — multiple agents across parallel cycles. -/// -/// Flow: parse cycles from args → execute each cycle via `execute_cycle` → -/// collect all node outputs → synthesize consensus → return report. -pub struct HiveMind; - -impl Tool for HiveMind { - fn name(&self) -> &'static str { - "hive_mind" - } - - fn description(&self) -> &'static str { - "Run a hive-mind convergence with multiple nodes across sequential cycles" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "cycles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "directives": { - "type": "array", - "items": { - "type": "object", - "properties": { - "directive": {"type": "string"}, - "access": {"type": "string", "enum": ["read", "write", "full"]} - } - } - } - } - }, - "description": "Array of cycles, each with an array of node directives" - } - }, - "required": ["cycles"] - }) - } - - #[instrument(skip(self, ctx, args))] - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let cycles_val = args - .get("cycles") - .and_then(|v| v.as_array()) - .ok_or_else(|| anyhow::anyhow!("missing 'cycles' array"))?; - - info!("Hive mind starting with {} cycles", cycles_val.len()); - let rt = crate::runtime::runtime(); - let mut all_node_outputs = Vec::new(); - - for (cycle_idx, cycle_val) in cycles_val.iter().enumerate() { - let directives: Vec = cycle_val - .get("directives") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|d| { - let directive = d.get("directive").and_then(|v| v.as_str())?; - let access = d.get("access").and_then(|v| v.as_str()).unwrap_or("read"); - Some(NodeDirective { - directive: directive.to_string(), - access_tier: access.to_string(), - }) - }) - .collect() - }) - .unwrap_or_default(); - - info!( - cycle_index = cycle_idx, - node_count = directives.len(), - "executing hive-mind cycle" - ); - let cycle = CognitiveCycle { - index: cycle_idx as u32, - directives, - }; - - let nodes: Vec = rt.block_on(async { execute_cycle(&cycle, ctx).await })?; - all_node_outputs.extend(nodes); - } - - let node_count = all_node_outputs.len(); - info!(node_count, "all cycles completed, synthesizing consensus"); - let consensus = - rt.block_on(async { synthesize_consensus(&all_node_outputs, ctx).await })?; - - let report = format!( - "Hive mind convergence completed.\nNodes executed: {}\n\nConsensus:\n{}", - node_count, consensus - ); - Ok(report) - } -} diff --git a/apps/infrastructure/src/utils.rs b/apps/infrastructure/src/utils.rs deleted file mode 100644 index fff7d3f..0000000 --- a/apps/infrastructure/src/utils.rs +++ /dev/null @@ -1,400 +0,0 @@ -//! Utility helpers inlined from `zesdex-utils` — safe integer casts, -//! atomic JSON file writing, and slugification. -//! -//! # Inlining rationale -//! -//! These helpers are ported from `zesdex-utils` to avoid a hard -//! dependency on that crate during the clean-architecture migration. -//! Once `zesdex-utils` is fully migrated, these can be re-exported -//! or removed. - -use serde::Serialize; -use std::io::Write; -use std::path::Path; - -// --------------------------------------------------------------------------- -// CastOr — safe integer narrowing -// --------------------------------------------------------------------------- - -/// Extension trait for checked integer narrowing with a fallback default. -/// -/// Implementations use `U::try_from(self).unwrap_or(default)` so overflow -/// never panics. -pub trait CastOr { - fn cast_or(self, default: U) -> U; -} - -macro_rules! impl_cast_or { - ($from:ty => $($to:ty),+ $(,)?) => { - $( - impl CastOr<$to> for $from { - #[inline] - fn cast_or(self, default: $to) -> $to { - <$to as TryFrom<$from>>::try_from(self).unwrap_or(default) - } - } - )+ - }; -} - -impl_cast_or!(usize => u64, i64, u32, i32, u16); -impl_cast_or!(u64 => i64, u32, i32, u16, u8); -impl_cast_or!(i64 => u64, i32, u16, u8); -impl_cast_or!(u32 => i32, u16, u8); - -impl CastOr for u128 { - #[inline] - fn cast_or(self, default: u64) -> u64 { - u64::try_from(self).unwrap_or(default) - } -} - -impl CastOr for u128 { - #[inline] - fn cast_or(self, default: i64) -> i64 { - i64::try_from(self).unwrap_or(default) - } -} - -impl CastOr for u128 { - #[inline] - fn cast_or(self, default: u32) -> u32 { - u32::try_from(self).unwrap_or(default) - } -} - -// --------------------------------------------------------------------------- -// Atomic JSON write -// --------------------------------------------------------------------------- - -/// Atomically write serializable `data` to `path`. -/// -/// Flow: serialize to pretty JSON -> write to `path.tmp.` -> fsync -> rename -/// -> fsync parent. If `mode` is `Some`, set permissions before rename (Unix only). -/// -/// A UUID-based temporary filename avoids collisions from concurrent writes. -/// Orphaned tmp files are cleaned up on any error after creation. -pub fn write_json_atomic( - path: &Path, - data: &T, - mode: Option, -) -> std::io::Result<()> { - let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4())); - let result = (|| -> std::io::Result<()> { - let bytes = serde_json::to_vec_pretty(data) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - { - let mut f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp)?; - f.write_all(&bytes)?; - f.sync_all()?; - } - if let Some(m) = mode { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?; - } - #[cfg(not(unix))] - { - let _ = m; - } - } - std::fs::rename(&tmp, path)?; - Ok(()) - })(); - // Clean up orphaned temp file on error after creation - if let Err(e) = result { - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - if let Some(parent) = path.parent() { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Slugify -// --------------------------------------------------------------------------- - -/// Convert an arbitrary string into a filesystem-safe slug. -pub fn slugify(s: &str) -> Option { - let slug: String = s - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); - let slug: String = slug - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - if slug.is_empty() || slug.len() > 80 { - return None; - } - Some(slug) -} - -// --------------------------------------------------------------------------- -// Clipboard (OSC-52) -// --------------------------------------------------------------------------- - -/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence. -/// -/// OSC-52 (`\x1b]52;c;\x1b\\`) is supported by many terminal emulators -/// (iTerm2, Kitty, tmux, etc.) and allows writing to the system clipboard -/// without external binaries. -pub fn write_osc52(output: &mut impl Write, text: &str) -> std::io::Result<()> { - use base64::Engine as _; - let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - write!(output, "\x1b]52;c;{encoded}\x1b\\")?; - output.flush()?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// Workspace Tree Builder -// --------------------------------------------------------------------------- - -/// Build a string representing the directory tree of `root`, up to `max_files` limits. -/// Automatically respects `.gitignore` and `.ignore` via the `ignore` crate. -pub fn build_workspace_tree(root: &Path, max_files: usize) -> String { - use ignore::WalkBuilder; - let mut tree = String::new(); - let mut count = 0; - - // hidden(false) allows files like .github to be seen, but it still respects .gitignore - // and ignores .git directories by default. - for entry in WalkBuilder::new(root).hidden(false).build().flatten() { - if count >= max_files { - tree.push_str("\n... (truncated)"); - break; - } - - let path = entry.path(); - if let Ok(rel) = path.strip_prefix(root) { - let name = rel.to_string_lossy(); - if name.is_empty() { - tree.push_str(".\n"); - } else { - let depth = entry.depth(); - let indent = " ".repeat(depth.saturating_sub(1)); - let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); - let suffix = if is_dir { "/" } else { "" }; - - let filename = entry.file_name().to_string_lossy(); - tree.push_str(&format!("{indent}{filename}{suffix}\n")); - count += 1; - } - } - } - - tree.trim_end().to_string() -} - -// --------------------------------------------------------------------------- -// Rich Context Builder -// --------------------------------------------------------------------------- - -/// Gathers essential project context (OS, Time, PWD, Git, Tech Stack, Rules) into a string. -pub fn build_rich_context(root: &Path) -> String { - use std::process::Command; - let mut ctx = String::new(); - - // 0. Current working directory (PWD) - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); - ctx.push_str(&format!("### Current Directory (PWD)\n`{cwd}`\n\n")); - - // 1. Time and OS - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - let time = chrono::Local::now().to_rfc3339(); - ctx.push_str(&format!( - "Environment: {} ({}), Current Time: {}\n\n", - os, arch, time - )); - - // 2. Custom Rules - let rule_files = [ - "AGENTS.md", - "CLAUDE.md", - ".cursorrules", - ".zesdexrules", - "claude.md", - "agent.md", - ]; - for file in rule_files { - let p = root.join(file); - if let Ok(content) = std::fs::read_to_string(&p) { - ctx.push_str(&format!( - "### Project Rules ({})\n```\n{}\n```\n\n", - file, - content.trim() - )); - } - } - - // 3. Git Status - if root.join(".git").exists() { - let branch = Command::new("git") - .arg("branch") - .arg("--show-current") - .current_dir(root) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - let status = Command::new("git") - .arg("status") - .arg("-s") - .current_dir(root) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - if !branch.is_empty() { - ctx.push_str(&format!("### Git State (branch: {})\n", branch)); - if !status.is_empty() { - ctx.push_str(&format!("Uncommitted changes:\n```\n{}\n```\n", status)); - } else { - ctx.push_str("No uncommitted changes.\n"); - } - ctx.push('\n'); - } - } - - // 4. Tech Stack (manifests) - let manifests = ["Cargo.toml", "package.json", "go.mod"]; - for file in manifests { - let p = root.join(file); - if let Ok(content) = std::fs::read_to_string(&p) { - let snippet = if content.len() > 1500 { - format!("{}\n... (truncated)", truncate_chars(&content, 1500)) - } else { - content - }; - ctx.push_str(&format!( - "### Tech Stack Manifest ({})\n```\n{}\n```\n\n", - file, - snippet.trim() - )); - } - } - - // 5. Active Background Jobs - let jobs = crate::bgbash::control::bash_control().list(); - if !jobs.is_empty() { - ctx.push_str("### Active Background Jobs\n```\n"); - for (id, cmd, _) in jobs { - ctx.push_str(&format!("[{}] {}\n", id, cmd)); - } - ctx.push_str("```\n\n"); - } - - // 6. Project Purpose (README) - let readme_path = root.join("README.md"); - if let Ok(content) = std::fs::read_to_string(&readme_path) { - let snippet = if content.len() > 1000 { - format!("{}\n... (truncated)", truncate_chars(&content, 1000)) - } else { - content - }; - ctx.push_str(&format!( - "### Project Purpose (README.md)\n```\n{}\n```\n\n", - snippet.trim() - )); - } - - // 7. Recent Git History & Momentum - if root.join(".git").exists() { - let commits = Command::new("git") - .arg("log") - .arg("-n") - .arg("3") - .arg("--oneline") - .current_dir(root) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - if !commits.is_empty() { - ctx.push_str(&format!( - "### Recent Commits (Momentum)\n```\n{}\n```\n\n", - commits - )); - } - - let diff = Command::new("git") - .arg("diff") - .arg("--name-only") - .current_dir(root) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - if !diff.is_empty() { - ctx.push_str(&format!( - "### Recently Modified Files (Uncommitted)\n```\n{}\n```\n\n", - diff - )); - } - } - - ctx.trim_end().to_string() -} - -/// Truncate a string to at most `max_chars` **characters**, never cutting a -/// multi-byte UTF-8 code point in half. -/// -/// `&s[..n]` with `n` a raw byte index panics when `n` lands inside a -/// multi-byte character (e.g. an emoji, `→`, or CJK in a README/diff). This -/// helper slices on character boundaries so content is safely capped at a -/// byte budget while remaining valid UTF-8. -pub fn truncate_chars(s: &str, max_chars: usize) -> String { - if s.chars().count() <= max_chars { - return s.to_string(); - } - s.chars().take(max_chars).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn truncate_chars_leaves_short_strings_unchanged() { - assert_eq!(truncate_chars("short", 100), "short"); - assert_eq!(truncate_chars("", 5), ""); - } - - #[test] - fn truncate_chars_cuts_to_max_chars() { - assert_eq!(truncate_chars("hello world", 5), "hello"); - } - - #[test] - fn truncate_chars_never_splits_multibyte_utf8() { - // 4 chars each: '→' is 3 bytes. Byte-slicing at 5 would panic; char - // slicing must not. - let s = "a→b→c→d"; - let t = truncate_chars(s, 5); - assert_eq!(t, "a→b→c"); - assert!(t.chars().count() <= 5); - // No replacement char must appear (valid UTF-8 preserved). - assert!(!t.contains('\u{FFFD}')); - } - - #[test] - fn truncate_chars_handles_emoji() { - let s = "🚀🚀🚀🚀"; - let t = truncate_chars(s, 2); - assert_eq!(t, "🚀🚀"); - assert!(t.chars().count() == 2); - } -} diff --git a/apps/infrastructure/src/workflow/docs.rs b/apps/infrastructure/src/workflow/docs.rs deleted file mode 100644 index 7e483a0..0000000 --- a/apps/infrastructure/src/workflow/docs.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Hive-mind convergence documentation — writes deterministic audit trail. - -use std::path::{Path, PathBuf}; - -use anyhow::Result; -use tracing::{info, instrument}; - -use zesdex_domain::workflow::NodeOutput; - -/// Write a deterministic audit trail for a hive-mind convergence. -/// -/// Flow: create docs/runs/ dir → build markdown content → write file. -/// This is deterministic (not an LLM step) and never skippable. -#[instrument(skip(nodes))] -pub fn write_hive_mind_convergence( - run_dir: &Path, - nodes: &[NodeOutput], - consensus: &str, -) -> Result { - let docs_dir = run_dir.join("docs/runs"); - std::fs::create_dir_all(&docs_dir)?; - - let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); - let filename = format!("{timestamp}-hive-mind-convergence.md"); - let filepath = docs_dir.join(&filename); - - let mut content = String::new(); - content.push_str(&format!("# Hive Mind Convergence — {timestamp}\n\n")); - - content.push_str("## Node Outputs\n\n"); - for node in nodes { - content.push_str(&format!("### {} — {}\n\n", node.id, node.directive)); - content.push_str(&format!("{}\n\n", node.output)); - } - - content.push_str("## Consensus\n\n"); - content.push_str(consensus); - content.push('\n'); - - std::fs::write(&filepath, content)?; - info!("Hive mind convergence written to {:?}", filepath); - Ok(filepath) -} diff --git a/apps/infrastructure/src/workflow/engine/execution.rs b/apps/infrastructure/src/workflow/engine/execution.rs deleted file mode 100644 index 24ce44f..0000000 --- a/apps/infrastructure/src/workflow/engine/execution.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Workflow execution — runs a parsed workflow script phase by phase. - -use anyhow::Result; -use tracing::{info, instrument}; - -use crate::tools::ToolCtx; -use crate::workflow::engine::primitives::execute_primitive; -use zesdex_domain::workflow::WorkflowScript; - -/// Execute each phase of a workflow script sequentially. -/// -/// Flow: for each phase → execute_primitive → collect result. -#[instrument(skip(tool_ctx))] -pub async fn execute_workflow(script: &WorkflowScript, tool_ctx: &ToolCtx) -> Result> { - info!( - "Executing workflow: {} ({} phases)", - script.name, - script.phases.len() - ); - let mut results = Vec::new(); - for phase in &script.phases { - info!("Executing phase: {}", phase.name); - let result = execute_primitive(&phase.directive, tool_ctx).await?; - results.push(result); - } - Ok(results) -} diff --git a/apps/infrastructure/src/workflow/engine/mod.rs b/apps/infrastructure/src/workflow/engine/mod.rs deleted file mode 100644 index e73a272..0000000 --- a/apps/infrastructure/src/workflow/engine/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Workflow execution engine — runs phases, primitives, and manages agent -//! lifecycle during workflow runs. - -pub mod execution; -pub mod phases; -pub mod primitives; diff --git a/apps/infrastructure/src/workflow/engine/phases.rs b/apps/infrastructure/src/workflow/engine/phases.rs deleted file mode 100644 index 8fa80d1..0000000 --- a/apps/infrastructure/src/workflow/engine/phases.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Workflow phases — individual stages of a multi-phase workflow. - -use serde::{Deserialize, Serialize}; - -/// A single phase in a multi-phase workflow. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkflowPhase { - pub name: String, - pub directive: String, - pub parallel_agents: usize, -} diff --git a/apps/infrastructure/src/workflow/engine/primitives.rs b/apps/infrastructure/src/workflow/engine/primitives.rs deleted file mode 100644 index 4f8978c..0000000 --- a/apps/infrastructure/src/workflow/engine/primitives.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Primitive execution — runs a single directive as a subagent. -//! -//! Flow: load settings → resolve LLM credentials → build SubagentContext → -//! run_agent with Full access tier → return output. - -use anyhow::Result; -use tracing::info; - -use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; -use zesdex_domain::core::Store; - -use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; -use crate::subagent::context::SubagentContext; -use crate::subagent::division::AccessTier; -use crate::subagent::engine::run_agent; -use crate::tools::ToolCtx; - -/// Execute a single directive by spawning a subagent. -/// -/// Flow: -/// 1. Load `Settings` and `AppConfig` from the store directory. -/// 2. Resolve provider, model, base_url, and api_key. -/// 3. Build a `SubagentContext` with all resolved params. -/// 4. Call `run_agent` with Full access (all tools available). -/// 5. Return the agent's text output. -pub async fn execute_primitive(directive: &str, tool_ctx: &ToolCtx) -> Result { - info!("Executing primitive: {directive}"); - - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - let ctx = SubagentContext::new( - directive.to_string(), - tool_ctx.clone(), - "full".to_string(), - base_url, - api_key, - model, - ); - - run_agent(ctx, directive, AccessTier::Full, tool_ctx.clone()).await -} diff --git a/apps/infrastructure/src/workflow/hive_mind/complexity.rs b/apps/infrastructure/src/workflow/hive_mind/complexity.rs deleted file mode 100644 index 9bfa777..0000000 --- a/apps/infrastructure/src/workflow/hive_mind/complexity.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Complexity heuristics — determine whether a request is complex enough to -//! warrant hive-mind orchestration. - -/// Heuristics to determine if a request is complex enough for hive-mind. -pub fn is_complex_request(task: &str) -> bool { - let complexity_indicators = [ - "refactor", - "redesign", - "multiple files", - "architecture", - "migration", - "comprehensive", - "end-to-end", - "full-stack", - ]; - - let task_lower = task.to_lowercase(); - complexity_indicators - .iter() - .any(|&indicator| task_lower.contains(indicator)) -} diff --git a/apps/infrastructure/src/workflow/hive_mind/cycle.rs b/apps/infrastructure/src/workflow/hive_mind/cycle.rs deleted file mode 100644 index d682691..0000000 --- a/apps/infrastructure/src/workflow/hive_mind/cycle.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Hive-mind cycle execution — run one cycle of parallel nodes. -//! -//! Flow: load settings → resolve LLM credentials → run all directives in the -//! cycle concurrently via a BOUNDED buffer (`buffer_unordered(MAX)`) → collect -//! `Vec`. Unlike `try_join_all`, a single failing node does NOT -//! fail the whole cycle — failed nodes are logged and replaced with an -//! `[ERROR]` output so the remaining results are preserved (like Claude -//! Code's isolated subagents). - -use anyhow::Result; -use futures_util::stream::StreamExt; -use tracing::{info, warn}; - -use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; -use zesdex_domain::core::Store; - -use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; -use crate::subagent::context::SubagentContext; -use crate::subagent::division::AccessTier; -use crate::subagent::engine::run_agent; -use crate::tools::ToolCtx; -use zesdex_domain::workflow::{CognitiveCycle, NodeDirective, NodeOutput}; - -/// Maximum number of hive-mind nodes running concurrently per cycle. -/// Keeps thread/runtime pressure bounded (Claude Code-style). -const MAX_CONCURRENT_NODES: usize = 8; - -/// Execute one cycle: run each node directive and collect outputs. -/// -/// Flow: -/// 1. Load `Settings` and `AppConfig` from the store directory. -/// 2. Resolve provider, model, base_url, and api_key. -/// 3. Spawn directives with bounded concurrency — each builds a -/// `SubagentContext` and calls `run_agent`. -/// 4. Collect `NodeOutput`s; failed nodes are logged and replaced with an -/// `[ERROR]` placeholder so the cycle still completes. -pub async fn execute_cycle(cycle: &CognitiveCycle, tool_ctx: &ToolCtx) -> Result> { - info!( - "Executing cycle {} with {} directives", - cycle.index, - cycle.directives.len() - ); - - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - let cycle_index = cycle.index; - - // Run all directives with bounded concurrency. Each node is its own - // future; failures are collected, not propagated (isolated errors). - let tasks: Vec<_> = cycle - .directives - .iter() - .enumerate() - .map(|(i, node_dir): (usize, &NodeDirective)| { - let dir = node_dir.directive.clone(); - let access_tier = node_dir.access_tier.clone(); - let ctx = SubagentContext::new( - dir.clone(), - tool_ctx.clone(), - access_tier.clone(), - base_url.clone(), - api_key.clone(), - model.clone(), - ); - let tc = tool_ctx.clone(); - - let access = match access_tier.as_str() { - "write" => AccessTier::Write, - "full" => AccessTier::Full, - _ => AccessTier::Read, - }; - - let node_id = format!("Node-{}-{}", cycle_index, i); - async move { - match run_agent(ctx, &dir, access, tc).await { - Ok(output) => Ok::(NodeOutput { - id: node_id.clone(), - directive: dir, - output, - }), - Err(e) => { - warn!(node = %node_id, error = %e, "hive-mind node failed (isolated)"); - Ok::(NodeOutput { - id: node_id, - directive: dir, - output: format!("[ERROR] {e}"), - }) - } - } - } - }) - .collect(); - - // Bounded concurrency: run at most MAX_CONCURRENT_NODES futures at once. - let mut stream = futures_util::stream::iter(tasks).buffer_unordered(MAX_CONCURRENT_NODES); - let mut results = Vec::with_capacity(cycle.directives.len()); - while let Some(node) = stream.next().await { - results.push(node?); - } - - Ok(results) -} diff --git a/apps/infrastructure/src/workflow/hive_mind/live.rs b/apps/infrastructure/src/workflow/hive_mind/live.rs deleted file mode 100644 index 53025ca..0000000 --- a/apps/infrastructure/src/workflow/hive_mind/live.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Hive-mind live tracking — track running node statuses in real time. - -use std::collections::HashMap; -use std::sync::Mutex; - -/// Real-time status of all running hive-mind nodes. -pub struct LiveHiveMind { - nodes: Mutex>, -} - -impl LiveHiveMind { - pub fn new() -> Self { - LiveHiveMind { - nodes: Mutex::new(HashMap::new()), - } - } -} - -impl Default for LiveHiveMind { - fn default() -> Self { - Self::new() - } -} - -impl LiveHiveMind { - pub fn set_status(&self, agent_id: &str, status: &str) { - if let Ok(mut guard) = self.nodes.lock() { - guard.insert(agent_id.to_string(), status.to_string()); - } - } - - pub fn get_statuses(&self) -> HashMap { - self.nodes.lock().map(|g| g.clone()).unwrap_or_default() - } -} diff --git a/apps/infrastructure/src/workflow/hive_mind/mod.rs b/apps/infrastructure/src/workflow/hive_mind/mod.rs deleted file mode 100644 index bbdb199..0000000 --- a/apps/infrastructure/src/workflow/hive_mind/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Hive-mind orchestration — multi-agent parallel convergence cycles. - -pub mod complexity; -pub mod cycle; -pub mod live; -pub mod synthesis; -pub mod types; diff --git a/apps/infrastructure/src/workflow/hive_mind/synthesis.rs b/apps/infrastructure/src/workflow/hive_mind/synthesis.rs deleted file mode 100644 index 318e60a..0000000 --- a/apps/infrastructure/src/workflow/hive_mind/synthesis.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Consensus synthesis — reconciles multiple node outputs into one assessment. -//! -//! Flow: load settings → resolve LLM credentials → ask the model to distill the -//! node outputs into a single consensus (conflicts, agreements, key findings) -//! → return the synthesized text. If the LLM call fails for any reason, we -//! degrade gracefully to a concatenation-based summary so consensus synthesis -//! never breaks the surrounding hive-mind cycle (mirrors the isolated-errors -//! philosophy used for the nodes themselves). - -use anyhow::Result; -use tracing::{info, warn}; - -use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; -use zesdex_domain::core::message::ChatMessage; -use zesdex_domain::core::Store; -use zesdex_domain::workflow::NodeOutput; - -use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; -use crate::tools::ToolCtx; - -/// Maximum characters of node output to feed into the synthesis prompt per node. -/// Keeps the prompt bounded so a huge/talkative node cannot blow up the request. -const MAX_NODE_OUTPUT_CHARS: usize = 4000; - -/// Synthesize a consensus from all node outputs using the LLM. -/// -/// Flow: combine node outputs → ask the model to reconcile them into a single -/// consensus → return the synthesized text. Falls back to a plain -/// concatenation summary if the LLM is unreachable or the call fails. -pub async fn synthesize_consensus(nodes: &[NodeOutput], _tool_ctx: &ToolCtx) -> Result { - info!("Synthesizing consensus from {} nodes", nodes.len()); - - let combined = build_combined_body(nodes); - - // 1. Resolve LLM credentials (same source of truth as execute_cycle). - let store = Store::new(); - let settings = JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let (provider, model) = - crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); - let base_url = app_config - .providers - .get(&provider) - .map(|p| p.api_base.clone()) - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); - let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); - - let client = crate::llm::provider::LlmClient::new(api_key, model, Some(base_url)); - - // 2. Build the synthesis prompt. - let system_msg = ChatMessage::system( - "You are a consensus synthesizer for a multi-agent hive mind. \ - Several independent nodes analysed a problem and produced the outputs \ - below. Distill them into ONE coherent consensus report with these \ - sections:\n\ - - AGREEMENTS: points multiple nodes converge on.\n\ - - CONFLICTS: contradictory conclusions, with which node(s) support each side.\n\ - - KEY FINDINGS: the most important, actionable takeaways.\n\ - - RECOMMENDATION: a single recommended next action, or 'no clear consensus' \ - if the outputs are too divergent.\n\ - Be concise and factual. If a node errored, note it and ignore its content.\n\ - Do not invent facts not present in the node outputs.", - ); - let user_msg = ChatMessage::user(format!( - "Consolidate these {} node outputs into a single consensus:\n\n{}", - nodes.len(), - combined - )); - - // 3. Call the model and gracefully degrade on failure. - match call_consensus(&client, &[system_msg, user_msg]).await { - Ok(text) => { - let trimmed = text.trim(); - if trimmed.is_empty() { - warn!("consensus LLM returned empty output; falling back to concat summary"); - Ok(concat_summary(nodes)) - } else { - Ok(format!( - "# Consensus Synthesis\n\n\ - Nodes synthesized: {}\n\n{}", - nodes.len(), - trimmed - )) - } - } - Err(e) => { - warn!(error = %e, "consensus LLM call failed; falling back to concat summary"); - Ok(concat_summary(nodes)) - } - } -} - -/// Run the LLM consensus call and return the assistant text. -async fn call_consensus( - client: &crate::llm::provider::LlmClient, - messages: &[ChatMessage], -) -> Result { - use zesdex_application::ports::ProviderService; - let (msg, _) = client.chat(messages, None, Some(1024), Some(0.3)).await?; - Ok(msg.content.unwrap_or_default()) -} - -/// Build the concatenated node-output body for the prompt. -fn build_combined_body(nodes: &[NodeOutput]) -> String { - let mut combined = String::new(); - for node in nodes { - let output = crate::utils::truncate_chars(&node.output, MAX_NODE_OUTPUT_CHARS); - combined.push_str(&format!( - "\n## {} — {}\n{}\n", - node.id, node.directive, output - )); - } - combined -} - -/// Fallback: a plain concatenation summary (the behaviour of the original stub). -fn concat_summary(nodes: &[NodeOutput]) -> String { - let combined = build_combined_body(nodes); - format!( - "# Consensus Synthesis\n\n\ - Nodes synthesized: {}\n\n\ - ## Summary\n\ - The following node outputs were collected:\n\ - {}\n\n\ - ## Key Findings\n\ - Review the individual node outputs above for detailed findings.", - nodes.len(), - combined - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use zesdex_domain::workflow::NodeOutput; - - fn node(id: &str, output: &str) -> NodeOutput { - NodeOutput { - id: id.to_string(), - directive: "directive".to_string(), - output: output.to_string(), - } - } - - #[test] - fn build_combined_body_truncates_oversized_output() { - let long = "é".repeat(MAX_NODE_OUTPUT_CHARS + 500); - let body = build_combined_body(&[node("n1", &long)]); - // Must contain the header and a char-truncated (<= cap) payload without - // panicking on a multi-byte boundary. - assert!(body.contains("## n1")); - // Header "## n1 — directive\n" ~= 20 chars, so char count stays just above cap. - let char_count = body.chars().count(); - assert!( - char_count <= MAX_NODE_OUTPUT_CHARS + 50, - "expected body near {MAX_NODE_OUTPUT_CHARS} chars, got {char_count}" - ); - assert!( - char_count > 1000, - "expected a many-node output, got small: {char_count}" - ); - } - - #[test] - fn concat_summary_includes_all_node_ids() { - let nodes = vec![node("node-a", "a out"), node("node-b", "b out")]; - let summary = concat_summary(&nodes); - assert!(summary.contains("node-a")); - assert!(summary.contains("node-b")); - assert!(summary.contains("Nodes synthesized: 2")); - } -} diff --git a/apps/infrastructure/src/workflow/hive_mind/types.rs b/apps/infrastructure/src/workflow/hive_mind/types.rs deleted file mode 100644 index 78f6191..0000000 --- a/apps/infrastructure/src/workflow/hive_mind/types.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Hive-mind shared types — node directives, cycle plans, and node outputs. - -// Types have been moved to zesdex_domain::workflow diff --git a/apps/infrastructure/src/workflow/mod.rs b/apps/infrastructure/src/workflow/mod.rs deleted file mode 100644 index 9370c5c..0000000 --- a/apps/infrastructure/src/workflow/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Workflow engine — hive-mind orchestration and script execution. - -pub mod docs; -pub mod engine; -pub mod hive_mind; -pub mod script; diff --git a/apps/infrastructure/src/workflow/script.rs b/apps/infrastructure/src/workflow/script.rs deleted file mode 100644 index caa5dc1..0000000 --- a/apps/infrastructure/src/workflow/script.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Workflow script — parse and execute user-defined workflow scripts. - -use anyhow::Result; -use tracing::{info, instrument}; - -use zesdex_domain::workflow::{WorkflowPhase, WorkflowScript}; - -/// Parse a YAML string into a WorkflowScript. -/// -/// Expected format: -/// ```yaml -/// name: my-workflow -/// phases: -/// - name: research -/// directive: "Explore the codebase..." -/// - name: implement -/// directive: "Implement the changes..." -/// ``` -#[instrument] -pub fn parse_workflow_script(yaml: &str) -> Result { - let parsed: serde_json::Value = serde_yaml_ng::from_str(yaml) - .map_err(|e| anyhow::anyhow!("Failed to parse workflow YAML: {e}"))?; - - let name = parsed - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("unnamed") - .to_string(); - - let mut phases = Vec::new(); - if let Some(phases_arr) = parsed.get("phases").and_then(|v| v.as_array()) { - for phase_val in phases_arr { - let phase_name = phase_val - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("phase") - .to_string(); - let directive = phase_val - .get("directive") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - phases.push(WorkflowPhase { - name: phase_name, - directive, - }); - } - } - - info!("Parsed workflow script: {name} ({} phases)", phases.len()); - Ok(WorkflowScript { name, phases }) -} diff --git a/apps/interfaces/api/src/dto/auth.rs b/apps/interfaces/api/src/dto/auth.rs deleted file mode 100644 index 448e9b2..0000000 --- a/apps/interfaces/api/src/dto/auth.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Authentication DTOs — login, register, and token refresh payloads. - -use serde::{Deserialize, Serialize}; - -/// Request body for `POST /auth/login`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoginRequest { - /// Username or email identifier. - pub username: String, - /// Plaintext password. - pub password: String, -} - -/// Request body for `POST /auth/register`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegisterRequest { - /// Desired username. - pub username: String, - /// Plaintext password (will be hashed server-side). - pub password: String, - /// Optional display name. - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, -} - -/// Request body for `POST /auth/refresh`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RefreshRequest { - /// The refresh token issued during login. - pub refresh_token: String, -} - -/// Response body for auth endpoints (login, register, refresh). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthResponse { - /// JWT access token (short-lived, typically 1 hour). - pub access_token: String, - /// JWT refresh token (long-lived, typically 7 days). - pub refresh_token: String, - /// Token type (always `"Bearer"`). - pub token_type: String, - /// Expiry of the access token in seconds. - pub expires_in: u64, -} - -/// Claims exposed in the JWT payload, returned from introspection. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClaimsResponse { - /// Subject identifier (username). - pub sub: String, - /// Issued-at timestamp (epoch seconds). - pub iat: u64, - /// Expiry timestamp (epoch seconds). - pub exp: u64, -} diff --git a/apps/interfaces/api/src/dto/conversation.rs b/apps/interfaces/api/src/dto/conversation.rs deleted file mode 100644 index 940ccda..0000000 --- a/apps/interfaces/api/src/dto/conversation.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Conversation DTOs — message history read/write payloads. - -use serde::{Deserialize, Serialize}; -use zesdex_domain::core::{ChatMessage, Conversation}; - -/// Request body for appending a message to a conversation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddMessageRequest { - /// Message role: `"user"` or `"assistant"`. - pub role: String, - /// Message content text. - pub content: String, -} - -/// Response body for a single conversation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConversationResponse { - /// Session ID this conversation belongs to. - pub session_id: String, - /// Messages in the conversation. - pub messages: Vec, - /// Total message count. - pub message_count: usize, - /// Model identifier used for this conversation. - pub model: String, - /// System prompt in effect. - pub system_prompt: String, - /// Max tokens configuration. - pub max_tokens: Option, - /// Temperature configuration. - pub temperature: Option, -} - -impl From for ConversationResponse { - fn from(c: Conversation) -> Self { - let message_count = c.len(); - let messages: Vec = - c.messages.into_iter().map(MessageResponse::from).collect(); - ConversationResponse { - session_id: c.session_id, - messages, - message_count, - model: c.model, - system_prompt: c.system_prompt, - max_tokens: c.max_tokens, - temperature: c.temperature, - } - } -} - -/// A single message in a conversation response. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageResponse { - /// Message role. - pub role: String, - /// Message content (None for assistant messages with only tool calls). - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - /// Optional tool call information. - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - /// Optional tool call result identifier. - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, -} - -impl From for MessageResponse { - fn from(m: ChatMessage) -> Self { - let tool_calls = m.tool_calls.map(|calls| { - calls - .into_iter() - .map(|tc| ToolCallResponse { - id: tc.id, - function: ToolFunctionResponse { - name: tc.function.name, - arguments: tc.function.arguments.to_string(), - }, - }) - .collect() - }); - - MessageResponse { - role: m.role.to_string(), - content: m.content, - tool_calls, - tool_call_id: m.tool_call_id, - } - } -} - -/// A tool call reference in a message. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallResponse { - /// Tool call ID. - pub id: String, - /// Function details. - pub function: ToolFunctionResponse, -} - -/// A function invocation in a tool call. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolFunctionResponse { - /// Function name. - pub name: String, - /// JSON-encoded arguments. - pub arguments: String, -} - -/// Request body for `POST /chat/completions`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatCompletionRequest { - /// The session ID to attach this completion to. - pub session_id: String, - /// Message content (user message). - pub message: String, - /// Optional model override. - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, - /// Optional max tokens override. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - /// Optional temperature override. - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, -} - -/// Response body for `POST /chat/completions`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatCompletionResponse { - /// The assistant's reply. - pub reply: String, - /// Total prompt tokens consumed. - pub prompt_tokens: u64, - /// Total completion tokens generated. - pub completion_tokens: u64, -} diff --git a/apps/interfaces/api/src/dto/error.rs b/apps/interfaces/api/src/dto/error.rs deleted file mode 100644 index 36dd338..0000000 --- a/apps/interfaces/api/src/dto/error.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Error response DTO — JSON body returned for all API errors. - -use serde::Serialize; - -/// Standardised error response body. -/// -/// Returned for all non-successful API responses. Contains a human-readable -/// `message` and the HTTP status `code` for machine parsing. -#[derive(Debug, Clone, Serialize)] -pub struct ErrorResponse { - /// Human-readable error description. - pub message: String, - /// HTTP status code (mirrors the response status). - pub code: u16, -} diff --git a/apps/interfaces/api/src/dto/mod.rs b/apps/interfaces/api/src/dto/mod.rs deleted file mode 100644 index 7b9670a..0000000 --- a/apps/interfaces/api/src/dto/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Data Transfer Objects for the REST API. -//! -//! These types define the wire format for request bodies and response bodies. -//! They are intentionally independent of domain entities so the API contract -//! can evolve without coupling to the domain model. - -pub mod auth; -pub mod conversation; -pub mod error; -pub mod session; diff --git a/apps/interfaces/api/src/dto/session.rs b/apps/interfaces/api/src/dto/session.rs deleted file mode 100644 index 6c278fa..0000000 --- a/apps/interfaces/api/src/dto/session.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Session DTOs — create, list, and delete session payloads. - -use serde::{Deserialize, Serialize}; -use zesdex_domain::auth::Session; - -/// Request body for `POST /sessions`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateSessionRequest { - /// Human-readable session title. - pub title: String, -} - -/// Response body for a single session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionResponse { - /// Unique session identifier. - pub id: String, - /// Epoch-millis timestamp of creation. - pub created_at: i64, - /// Epoch-millis timestamp of last update. - pub updated_at: i64, - /// Human-readable title. - pub title: String, - /// Model identifier string. - pub model: String, - /// Number of messages in this session. - pub message_count: u32, - /// Whether the session has been archived. - pub archived: bool, - /// Optional AI-generated summary. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, -} - -impl From for SessionResponse { - fn from(s: Session) -> Self { - SessionResponse { - id: s.id, - created_at: s.created_at, - updated_at: s.updated_at, - title: s.title, - model: s.model, - message_count: s.message_count, - archived: s.archived, - summary: s.summary, - } - } -} - -/// Response body for `GET /sessions`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionListResponse { - /// All (non-archived) sessions. - pub sessions: Vec, - /// Total count of sessions returned. - pub total: usize, -} diff --git a/apps/interfaces/api/src/error.rs b/apps/interfaces/api/src/error.rs deleted file mode 100644 index a530ffa..0000000 --- a/apps/interfaces/api/src/error.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Typed API error type with automatic HTTP response conversion. -//! -//! `ApiError` represents all possible failure modes of the REST API. -//! Each variant maps to an appropriate HTTP status code via `IntoResponse`, -//! producing a JSON body with a `message` field and an optional `code`. -//! -//! # Flow -//! -//! Handler returns `Result` → Axum calls `IntoResponse` → -//! HTTP response with appropriate status code and JSON error body. -//! -//! # Error mapping -//! -//! - `BadRequest` → 400 -//! - `Unauthorized` → 401 -//! - `NotFound` → 404 -//! - `Conflict` → 409 -//! - `Internal` → 500 (with `tracing::error!` log) -//! - `ChatProxy` → 502 (upstream LLM error) - -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use serde_json::json; - -use crate::dto::error::ErrorResponse; - -/// Typed API error with HTTP status code mapping. -#[derive(Debug, thiserror::Error)] -pub enum ApiError { - /// The request was malformed or contained invalid data. - #[error("Bad request: {0}")] - BadRequest(String), - - /// Authentication failed or credentials are missing/invalid. - #[error("Unauthorized: {0}")] - Unauthorized(String), - - /// The requested resource was not found. - #[error("Not found: {0}")] - NotFound(String), - - /// The request conflicts with the current server state. - #[error("Conflict: {0}")] - Conflict(String), - - /// The client has sent too many requests in a given time window. - #[error("Too many requests: {0}")] - TooManyRequests(String), - - /// An unexpected internal error occurred. - #[error("Internal error: {0}")] - Internal(String), - - /// The upstream LLM provider returned an error. - #[error("Chat proxy error: {0}")] - ChatProxy(String), -} - -impl IntoResponse for ApiError { - /// Convert `ApiError` into an HTTP response with an appropriate status - /// code and a structured JSON body. - /// - /// Internal errors are logged at `error` level before returning a generic - /// 500 response (to avoid leaking internal details). - fn into_response(self) -> Response { - let (status, user_message) = match &self { - ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), - ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), - ApiError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg.clone()), - ApiError::Internal(msg) => { - tracing::error!(error = %msg, "Internal server error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "An internal error occurred".to_string(), - ) - } - ApiError::ChatProxy(msg) => { - tracing::error!(error = %msg, "Chat proxy error"); - ( - StatusCode::BAD_GATEWAY, - format!("Upstream LLM error: {msg}"), - ) - } - }; - - let body = ErrorResponse { - message: user_message, - code: status.as_u16(), - }; - - (status, Json(json!(body))).into_response() - } -} - -// --------------------------------------------------------------------------- -// From impls — convert domain/infrastructure errors into ApiError -// --------------------------------------------------------------------------- - -impl From for ApiError { - /// Map domain repository errors to API errors. - /// - /// - `NotFound` → `ApiError::NotFound` - /// - `Conflict` → `ApiError::Conflict` - /// - `InvalidId` → `ApiError::BadRequest` - /// - All others → `ApiError::Internal` - fn from(e: zesdex_domain::error::DomainError) -> Self { - match e { - zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg), - zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg), - zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg), - _ => ApiError::Internal(e.to_string()), - } - } -} - -impl From for ApiError { - /// Map IAM service errors to API errors. - fn from(e: zesdex_domain::auth::ServiceError) -> Self { - match e { - zesdex_domain::auth::ServiceError::Repository(repo_err) => match repo_err { - zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg), - zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg), - zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg), - _ => ApiError::Internal(repo_err.to_string()), - }, - zesdex_domain::auth::ServiceError::InvalidConfig(msg) => ApiError::BadRequest(msg), - zesdex_domain::auth::ServiceError::StateMismatch => { - ApiError::Unauthorized("OAuth state mismatch — possible CSRF attack".into()) - } - zesdex_domain::auth::ServiceError::OAuthProvider(msg) => ApiError::ChatProxy(msg), - zesdex_domain::auth::ServiceError::Other(msg) => ApiError::Internal(msg), - } - } -} - -impl From for ApiError { - /// Map CMS service errors to API errors. - fn from(e: zesdex_domain::cms::ServiceError) -> Self { - match e { - zesdex_domain::cms::ServiceError::Repository(repo_err) => match repo_err { - zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg), - zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg), - zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg), - _ => ApiError::Internal(repo_err.to_string()), - }, - zesdex_domain::cms::ServiceError::InvalidInput(msg) => ApiError::BadRequest(msg), - zesdex_domain::cms::ServiceError::Other(msg) => ApiError::Internal(msg), - } - } -} - -impl From for ApiError { - /// Fallback conversion: log the error and return a generic internal error. - fn from(e: anyhow::Error) -> Self { - tracing::error!(error = %e, "Unhandled error"); - ApiError::Internal(e.to_string()) - } -} - -impl From for ApiError { - fn from(e: jsonwebtoken::errors::Error) -> Self { - ApiError::Unauthorized(format!("Invalid token: {e}")) - } -} diff --git a/apps/interfaces/api/src/handlers/auth.rs b/apps/interfaces/api/src/handlers/auth.rs deleted file mode 100644 index 2c76fb3..0000000 --- a/apps/interfaces/api/src/handlers/auth.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Authentication handlers — login, register, and token refresh. -//! -//! # Endpoints -//! -//! - `POST /auth/login` — authenticate with username/password, returns JWT -//! - `POST /auth/register` — create a new user account -//! - `POST /auth/refresh` — exchange a refresh token for a new access token -//! -//! # Flow -//! -//! Login: validate input → verify password → generate token pair → return. -//! Register: validate input → check uniqueness → hash password → persist → login. -//! Refresh: decode refresh token → verify → generate new token pair. - -use std::sync::Arc; - -use axum::extract::State; -use axum::http::HeaderMap; -use axum::routing::post; -use axum::{Json, Router}; - -use zesdex_application::ports::{PasswordService, TokenService}; - -use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterRequest}; -use crate::error::ApiError; -use crate::state::ApiState; - -/// Login/register brute-force protection: 20 attempts per 10-minute window -/// per client IP. -const AUTH_RATE_LIMIT_MAX: u32 = 20; -const AUTH_RATE_LIMIT_WINDOW_SECS: u64 = 600; - -/// Build the auth sub-router (`/auth/*`). -pub fn router() -> Router> { - Router::new() - .route("/login", post(login_handler)) - .route("/register", post(register_handler)) - .route("/refresh", post(refresh_handler)) -} - -/// Extract a coarse client identity from the request headers (IP via -/// X-Forwarded-For fallback). Used as the rate-limit key. -fn client_id(headers: &HeaderMap) -> String { - headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()) -} - -/// Enforce the auth rate limit, returning `true` if the request is allowed. -fn rate_limited(state: &ApiState, headers: &HeaderMap) -> bool { - !state - .auth_rate_limiter - .check_rate_limit( - &client_id(headers), - AUTH_RATE_LIMIT_MAX, - AUTH_RATE_LIMIT_WINDOW_SECS, - ) - .unwrap_or(true) -} - -/// POST /auth/login — authenticate and issue JWT tokens. -/// -/// ## Flow -/// -/// 1. Deserialize `LoginRequest`. -/// 2. Load the stored user credentials from the users store. -/// 3. Verify the password against the stored hash. -/// 4. Generate an access + refresh token pair. -/// 5. Return `AuthResponse`. -/// -/// ## Errors -/// -/// - `400 Bad Request` — missing or empty fields. -/// - `401 Unauthorized` — invalid username or password. -/// - `500 Internal Server Error` — unexpected failure. -#[tracing::instrument(skip(state))] -pub async fn login_handler( - State(state): State>, - headers: HeaderMap, - Json(req): Json, -) -> Result, ApiError> { - // Rate-limit login attempts (brute-force protection). - if rate_limited(&state, &headers) { - return Err(ApiError::TooManyRequests( - "Too many login attempts, try again later".into(), - )); - } - - // Validate input - if req.username.is_empty() || req.password.is_empty() { - return Err(ApiError::BadRequest( - "Username and password are required".into(), - )); - } - - // Load the users database from the store - let users_path = state.store_base_dir.join("users.json"); - let users: std::collections::HashMap = if users_path.exists() { - let content = std::fs::read_to_string(&users_path) - .map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?; - serde_json::from_str(&content) - .map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))? - } else { - return Err(ApiError::Unauthorized( - "Invalid username or password".into(), - )); - }; - - // Look up the user - let stored_hash = users - .get(&req.username) - .ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?; - - // Verify password - let valid = state - .password_service - .verify(&req.password, stored_hash) - .await - .map_err(|e| ApiError::Internal(format!("Password verification failed: {e}")))?; - - if !valid { - return Err(ApiError::Unauthorized( - "Invalid username or password".into(), - )); - } - - // Generate tokens - let (access_token, refresh_token) = state - .token_service - .generate_tokens(&req.username) - .map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?; - - Ok(Json(AuthResponse { - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: state.token_service.access_token_expiry_secs, - })) -} - -/// POST /auth/register — create a new user account. -/// -/// ## Flow -/// -/// 1. Deserialize `RegisterRequest`. -/// 2. Check username availability (load users, reject if exists). -/// 3. Hash the password using Argon2id. -/// 4. Persist the new username + hash. -/// 5. Generate an access + refresh token pair. -/// 6. Return `AuthResponse`. -/// -/// ## Errors -/// -/// - `400 Bad Request` — missing or invalid fields. -/// - `409 Conflict` — username already taken. -/// - `500 Internal Server Error` — unexpected failure. -#[tracing::instrument(skip(state))] -pub async fn register_handler( - State(state): State>, - headers: HeaderMap, - Json(req): Json, -) -> Result, ApiError> { - // Rate-limit registration (abuse protection). - if rate_limited(&state, &headers) { - return Err(ApiError::TooManyRequests( - "Too many registration attempts, try again later".into(), - )); - } - - // Validate input - if req.username.is_empty() { - return Err(ApiError::BadRequest("Username is required".into())); - } - if req.password.len() < 6 { - return Err(ApiError::BadRequest( - "Password must be at least 6 characters".into(), - )); - } - - // Compute the password hash first (async, no lock held). - let hash = state - .password_service - .hash(&req.password) - .await - .map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?; - - // Serialize read-modify-write of users.json to avoid losing concurrent - // registers (TOCTOU race). The lock is held only across the sync - // file operations — never across an `.await` (keeps the future `Send`). - let _users_guard = state - .users_lock - .lock() - .map_err(|_| ApiError::Internal("users lock poisoned".into()))?; - - // Load existing users - let users_path = state.store_base_dir.join("users.json"); - let mut users: std::collections::HashMap = if users_path.exists() { - let content = std::fs::read_to_string(&users_path) - .map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?; - serde_json::from_str(&content) - .map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))? - } else { - std::collections::HashMap::new() - }; - - // Check uniqueness - if users.contains_key(&req.username) { - return Err(ApiError::Conflict("Username already exists".into())); - } - - // Persist - users.insert(req.username.clone(), hash); - let content = serde_json::to_string_pretty(&users) - .map_err(|e| ApiError::Internal(format!("Failed to serialize users: {e}")))?; - std::fs::write(&users_path, &content) - .map_err(|e| ApiError::Internal(format!("Failed to write users: {e}")))?; - - // Generate tokens - let (access_token, refresh_token) = state - .token_service - .generate_tokens(&req.username) - .map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?; - - Ok(Json(AuthResponse { - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: state.token_service.access_token_expiry_secs, - })) -} - -/// POST /auth/refresh — exchange a refresh token for a new access token. -/// -/// ## Flow -/// -/// 1. Deserialize `RefreshRequest`. -/// 2. Verify the refresh token's signature and extract the subject. -/// 3. Generate a fresh access + refresh token pair. -/// 4. Return `AuthResponse`. -/// -/// ## Errors -/// -/// - `400 Bad Request` — missing refresh token. -/// - `401 Unauthorized` — invalid or expired refresh token. -/// - `500 Internal Server Error` — unexpected failure. -#[tracing::instrument(skip(state))] -pub async fn refresh_handler( - State(state): State>, - headers: HeaderMap, - Json(req): Json, -) -> Result, ApiError> { - // Rate-limit refresh attempts. - if rate_limited(&state, &headers) { - return Err(ApiError::TooManyRequests( - "Too many requests, try again later".into(), - )); - } - - if req.refresh_token.is_empty() { - return Err(ApiError::BadRequest("Refresh token is required".into())); - } - - // Verify the refresh token and extract the subject - let sub = state - .token_service - .verify_refresh_token(&req.refresh_token) - .map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?; - - // Generate a fresh token pair - let (access_token, new_refresh_token) = state - .token_service - .generate_tokens(&sub) - .map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?; - - Ok(Json(AuthResponse { - access_token, - refresh_token: new_refresh_token, - token_type: "Bearer".to_string(), - expires_in: state.token_service.access_token_expiry_secs, - })) -} diff --git a/apps/interfaces/api/src/handlers/chat.rs b/apps/interfaces/api/src/handlers/chat.rs deleted file mode 100644 index 0cd44b8..0000000 --- a/apps/interfaces/api/src/handlers/chat.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! LLM chat completion proxy handler. -//! -//! # Endpoints -//! -//! - `POST /chat/completions` — proxy a chat completion request to the LLM -//! provider, optionally persisting the conversation. -//! -//! # Flow -//! -//! 1. Deserialize `ChatCompletionRequest`. -//! 2. Load the existing conversation for the given session (create if absent). -//! 3. Append the user's message to the conversation. -//! 4. Call the LLM provider via `LlmClient`. -//! 5. Append the assistant's reply to the conversation. -//! 6. Persist the updated conversation. -//! 7. Return `ChatCompletionResponse`. - -use std::sync::Arc; - -use axum::extract::State; -use axum::routing::post; -use axum::{Json, Router}; - -use zesdex_domain::cms::ConversationService; -use zesdex_domain::core::{ChatMessage, Role}; - -use crate::dto::conversation::{ChatCompletionRequest, ChatCompletionResponse}; -use crate::error::ApiError; -use crate::state::ApiState; - -/// Build the chat sub-router (`/chat/*`). -pub fn router() -> Router> { - Router::new().route("/completions", post(chat_completions_handler)) -} - -/// POST /chat/completions — proxy to LLM provider. -/// -/// ## Flow -/// -/// 1. Deserialize the request body. -/// 2. Load the conversation for the given `session_id`. -/// 3. Append the user's message to the conversation. -/// 4. Call the LLM (non-streaming) using `LlmClient`. -/// 5. Append the assistant's response. -/// 6. Persist the conversation. -/// 7. Return the assistant's reply and token usage. -/// -/// ## Errors -/// -/// - `400 Bad Request` — missing session_id or message. -/// - `502 Bad Gateway` — upstream LLM provider error. -/// - `500 Internal Server Error` — unexpected failure. -#[tracing::instrument(skip(state))] -pub async fn chat_completions_handler( - State(state): State>, - Json(req): Json, -) -> Result, ApiError> { - // Validate input - if req.session_id.is_empty() { - return Err(ApiError::BadRequest("session_id is required".into())); - } - if req.message.is_empty() { - return Err(ApiError::BadRequest("message is required".into())); - } - - // Load or create the conversation - let mut conversation = state - .conversation_service - .load_conversation(&req.session_id) - .unwrap_or_else(|_| { - // Create a new empty conversation - zesdex_domain::core::Conversation { - session_id: req.session_id.clone(), - messages: Vec::new(), - model: req - .model - .clone() - .unwrap_or_else(|| state.llm_client.model.clone()), - system_prompt: String::new(), - max_tokens: None, - temperature: None, - } - }); - - // Set model if overridden - if let Some(ref model) = req.model { - conversation.model.clone_from(model); - } - - // Append the user's message - let user_msg = ChatMessage { - role: Role::User, - content: Some(req.message.clone()), - tool_calls: None, - tool_call_id: None, - name: None, - }; - conversation.push(user_msg.clone()); - - // Build message history for the LLM - let messages: Vec = conversation.messages.clone(); - - // Get model from conversation - let model = if conversation.model.is_empty() { - state.llm_client.model.clone() - } else { - conversation.model.clone() - }; - - // Call the LLM provider (non-streaming) - // - // When the requested model matches the shared client we reuse it to - // avoid allocating a new HTTP connection. Otherwise we create a - // temporary LlmClient with the requested model — the ownership - // lives on the stack via a match binding. - // - // We use an uninitialized let-binding for `temp_client` so the - // compiler can see it's assigned at most once, avoiding - // `#[allow(unused_assignments)]`. - let temp_client; - let llm_client = if model == state.llm_client.model { - &state.llm_client - } else { - temp_client = zesdex_infrastructure::llm::provider::LlmClient::new( - state.llm_client.api_key.clone(), - model, - Some(state.llm_client.base_url.clone()), - ); - &temp_client - }; - - use zesdex_application::ports::ProviderService; - let (response, usage) = llm_client - .chat( - &messages, - None, // No tool definitions for basic chat - req.max_tokens, - req.temperature, - ) - .await - .map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?; - - let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0)); - - // The response content may be None if only tool calls were returned - let reply_text = response.content.unwrap_or_default(); - - // Append the assistant's reply - let assistant_msg = ChatMessage { - role: Role::Assistant, - content: Some(reply_text.clone()), - tool_calls: response.tool_calls, - tool_call_id: response.tool_call_id, - name: None, - }; - state - .conversation_service - .add_message(&mut conversation, assistant_msg) - .map_err(|e| ApiError::Internal(format!("Failed to persist conversation: {e}")))?; - - Ok(Json(ChatCompletionResponse { - reply: reply_text, - prompt_tokens, - completion_tokens, - })) -} diff --git a/apps/interfaces/api/src/handlers/conversations.rs b/apps/interfaces/api/src/handlers/conversations.rs deleted file mode 100644 index 9808b83..0000000 --- a/apps/interfaces/api/src/handlers/conversations.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Conversation message-history handlers. -//! -//! # Endpoints -//! -//! - `GET /sessions/:id/conversations` — get conversation for a session -//! - `POST /sessions/:id/conversations` — append a message to a session -//! - `DELETE /sessions/:id/conversations/:cid` — delete a conversation message -//! -//! # Flow -//! -//! Each handler extracts the session ID from the path, delegates to the -//! `ConversationServiceImpl`, and maps results to HTTP responses. - -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::Json; - -use zesdex_domain::cms::ConversationService; -use zesdex_domain::core::ChatMessage; - -use crate::dto::conversation::{AddMessageRequest, ConversationResponse}; -use crate::error::ApiError; -use crate::state::ApiState; - -/// GET /sessions/:id/conversations — fetch the full conversation for a session. -/// -/// ## Flow -/// -/// 1. Extract session ID from the path. -/// 2. Load the conversation via `ConversationServiceImpl`. -/// 3. Return the conversation with all messages. -/// -/// ## Errors -/// -/// - `404 Not Found` — no conversation exists for this session. -#[tracing::instrument(skip(state))] -pub async fn get_conversation_handler( - State(state): State>, - Path(id): Path, -) -> Result, ApiError> { - if id.is_empty() { - return Err(ApiError::BadRequest("Session ID is required".into())); - } - - let conversation = state.conversation_service.load_conversation(&id)?; - - Ok(Json(ConversationResponse::from(conversation))) -} - -/// POST /sessions/:id/conversations — add a message to a session conversation. -/// -/// ## Flow -/// -/// 1. Extract session ID from the path. -/// 2. Deserialize `AddMessageRequest`. -/// 3. Build a `ChatMessage` from the request. -/// 4. Load the conversation, append the message, persist. -/// 5. Return the updated conversation. -/// -/// ## Errors -/// -/// - `400 Bad Request` — invalid message format. -/// - `404 Not Found` — session not found. -#[tracing::instrument(skip(state))] -pub async fn add_message_handler( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> Result<(axum::http::StatusCode, Json), ApiError> { - if id.is_empty() { - return Err(ApiError::BadRequest("Session ID is required".into())); - } - if req.content.is_empty() { - return Err(ApiError::BadRequest("Message content is required".into())); - } - - // Parse role - let role = match req.role.to_lowercase().as_str() { - "user" => zesdex_domain::core::Role::User, - "assistant" => zesdex_domain::core::Role::Assistant, - _ => return Err(ApiError::BadRequest(format!("Invalid role: {}", req.role))), - }; - - let msg = ChatMessage { - role, - content: Some(req.content), - tool_calls: None, - tool_call_id: None, - name: None, - }; - - // Load conversation and add message - let mut conversation = state.conversation_service.load_conversation(&id)?; - - state - .conversation_service - .add_message(&mut conversation, msg)?; - - Ok(( - axum::http::StatusCode::OK, - Json(ConversationResponse::from(conversation)), - )) -} - -/// DELETE /sessions/:id/conversations/:cid — delete a single message by index. -/// -/// ## Flow -/// -/// 1. Extract session ID and message index from the path. -/// 2. Load the conversation. -/// 3. Remove the message at `cid` (zero-based index). -/// 4. Persist the updated conversation. -/// -/// ## Errors -/// -/// - `400 Bad Request` — session ID is empty or `cid` is not a valid integer. -/// - `404 Not Found` — conversation or message index not found. -#[tracing::instrument(skip(state))] -pub async fn delete_message_handler( - State(state): State>, - Path((id, cid)): Path<(String, String)>, -) -> Result { - if id.is_empty() { - return Err(ApiError::BadRequest("Session ID is required".into())); - } - - // Parse the message index from the path - let index: usize = cid - .parse() - .map_err(|_| ApiError::BadRequest(format!("Invalid message index: {cid}")))?; - - // Load conversation and remove the specific message by index - let mut conversation = state.conversation_service.load_conversation(&id)?; - - if index >= conversation.messages.len() { - return Err(ApiError::NotFound(format!( - "Message index {index} out of bounds (max: {})", - conversation.messages.len().saturating_sub(1) - ))); - } - - conversation.messages.remove(index); - state - .conversation_service - .save_conversation(&conversation)?; - - Ok(axum::http::StatusCode::NO_CONTENT) -} diff --git a/apps/interfaces/api/src/handlers/health.rs b/apps/interfaces/api/src/handlers/health.rs deleted file mode 100644 index a5e75fb..0000000 --- a/apps/interfaces/api/src/handlers/health.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Health-check endpoint. -//! -//! `GET /health` — returns a simple `{"status": "ok"}` response used by -//! load balancers, orchestrators, and monitoring tools to verify the API -//! server is running. - -use axum::Json; -use serde_json::{json, Value}; - -/// Handle `GET /health`. -/// -/// Returns a 200 OK response with `{"status": "ok"}`. -/// -/// This endpoint requires no authentication and has no side effects. -#[tracing::instrument(skip_all)] -pub async fn health() -> Json { - Json(json!({"status": "ok"})) -} diff --git a/apps/interfaces/api/src/handlers/mod.rs b/apps/interfaces/api/src/handlers/mod.rs deleted file mode 100644 index a2f14e1..0000000 --- a/apps/interfaces/api/src/handlers/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! API route handler modules. -//! -//! Each sub-module corresponds to a resource group and exposes a `router()` -//! function that returns an `axum::Router` scoped to that resource's prefix. - -pub mod auth; -pub mod chat; -pub mod conversations; -pub mod health; -pub mod sessions; diff --git a/apps/interfaces/api/src/handlers/sessions.rs b/apps/interfaces/api/src/handlers/sessions.rs deleted file mode 100644 index 275e8de..0000000 --- a/apps/interfaces/api/src/handlers/sessions.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Session management handlers. -//! -//! # Endpoints -//! -//! - `GET /sessions` — list all sessions (optionally filtered) -//! - `POST /sessions` — create a new session -//! - `DELETE /sessions/:id` — archive/close a session -//! -//! # Flow -//! -//! Each handler extracts the shared `ApiState`, delegates to the -//! `SessionServiceImpl`, and maps results to HTTP responses with DTOs. - -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::routing::{delete, get, post}; -use axum::{Json, Router}; - -use zesdex_domain::auth::{SessionId, SessionService}; - -use crate::dto::session::{CreateSessionRequest, SessionListResponse, SessionResponse}; -use crate::error::ApiError; -use crate::state::ApiState; - -/// Build the sessions sub-router (`/sessions/*`). -pub fn router() -> Router> { - Router::new() - .route("/", get(list_sessions_handler)) - .route("/", post(create_session_handler)) - .route("/{id}", delete(delete_session_handler)) -} - -/// GET /sessions — list all sessions. -/// -/// Returns a list of non-archived sessions sorted by creation time. -#[tracing::instrument(skip(state))] -pub async fn list_sessions_handler( - State(state): State>, -) -> Result, ApiError> { - let sessions = state.session_service.list_all()?; - - let session_responses: Vec = - sessions.into_iter().map(SessionResponse::from).collect(); - let total = session_responses.len(); - - Ok(Json(SessionListResponse { - sessions: session_responses, - total, - })) -} - -/// POST /sessions — create a new session. -/// -/// ## Flow -/// -/// 1. Deserialize `CreateSessionRequest`. -/// 2. Delegate to `SessionServiceImpl::create_session`. -/// 3. Return the created session as `SessionResponse` with 201 Created. -#[tracing::instrument(skip(state))] -pub async fn create_session_handler( - State(state): State>, - Json(req): Json, -) -> Result<(axum::http::StatusCode, Json), ApiError> { - if req.title.trim().is_empty() { - return Err(ApiError::BadRequest("Session title is required".into())); - } - - let session = state.session_service.create_session(&req.title)?; - - Ok(( - axum::http::StatusCode::CREATED, - Json(SessionResponse::from(session)), - )) -} - -/// DELETE /sessions/:id — archive/close a session. -/// -/// ## Flow -/// -/// 1. Extract the session ID from the path. -/// 2. Validate the ID format. -/// 3. Delegate to `SessionServiceImpl::archive_session`. -/// 4. Return 204 No Content. -#[tracing::instrument(skip(state))] -pub async fn delete_session_handler( - State(state): State>, - Path(id): Path, -) -> Result { - if id.is_empty() { - return Err(ApiError::BadRequest("Session ID is required".into())); - } - - let session_id = SessionId::new(&id) - .map_err(|e| ApiError::BadRequest(format!("Invalid session ID: {e}")))?; - - state.session_service.archive_session(session_id)?; - - Ok(axum::http::StatusCode::NO_CONTENT) -} diff --git a/apps/interfaces/api/src/lib.rs b/apps/interfaces/api/src/lib.rs deleted file mode 100644 index 3b77f1f..0000000 --- a/apps/interfaces/api/src/lib.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! # Zesdex REST API — Axum HTTP server -//! -//! Provides RESTful endpoints for the Zesdex application, enabling -//! web clients, mobile apps, and third-party integrations. -//! -//! ## Architecture -//! -//! ```text -//! src/ -//! ├── lib.rs — Module declarations, re-exports, router builder -//! ├── state.rs — ApiState with concrete service implementations -//! ├── error.rs — ApiError enum + IntoResponse -//! ├── dto/ — Request/response DTOs (serde) -//! ├── handlers/ — Axum route handlers -//! └── middleware/ — Tower layers (JWT auth, etc.) -//! ``` -//! -//! ## Flow -//! -//! 1. `build_router()` constructs an Axum `Router` with all routes nested. -//! 2. Each handler receives `State>` or direct extractors. -//! 3. Handlers delegate to application-layer service implementations. -//! 4. Domain/infrastructure errors are mapped to `ApiError` → HTTP status codes. - -pub mod dto; -pub mod error; -pub mod handlers; -pub mod middleware; -pub mod state; - -pub use error::ApiError; -pub use state::ApiState; - -use axum::Router; -use std::sync::Arc; -use tower_http::cors::CorsLayer; - -/// Build the API router with all routes registered. -/// -/// Flow: create CORS layer → build sub-routers for each resource → nest -/// them under `/api/v1` → attach shared state → return. -/// -/// ## Arguments -/// * `state` — shared application state (wrapped in `Arc` for clone-free sharing) -/// -/// ## Example -/// ```ignore -/// let state = ApiState::new("/path/to/data"); -/// let app = build_router(state); -/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); -/// axum::serve(listener, app).await.unwrap(); -/// ``` -pub fn build_router(state: ApiState) -> Router { - let shared_state: Arc = Arc::new(state); - - // CORS layer — permissive for local daemon / development use - let cors = CorsLayer::permissive(); - - // JWT auth middleware — protects session/chat routes. - // Auth (login/register/refresh) and health endpoints stay public. - let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone()); - - // Combine all sub-routers under a versioned prefix - Router::new() - .nest("/api/v1/auth", auth_router()) - .nest("/api/v1/health", health_router()) - .nest("/api/v1", protected_router().layer(jwt_auth)) - .layer(cors) - .with_state(shared_state) -} - -/// Auth + health sub-routers — publicly accessible (no JWT required). -fn auth_router() -> Router> { - handlers::auth::router() -} - -fn health_router() -> Router> { - Router::new().route("/", axum::routing::get(handlers::health::health)) -} - -/// Protected sub-router — sessions + chat, guarded by JWT auth layer. -fn protected_router() -> Router> { - use handlers::{chat, conversations, sessions}; - - // Sessions router combines session CRUD + nested conversations - let sessions_router = Router::new() - .route("/", axum::routing::get(sessions::list_sessions_handler)) - .route("/", axum::routing::post(sessions::create_session_handler)) - .route( - "/{id}", - axum::routing::delete(sessions::delete_session_handler), - ) - // Conversations are sub-resources of sessions - .route( - "/{id}/conversations", - axum::routing::get(conversations::get_conversation_handler), - ) - .route( - "/{id}/conversations", - axum::routing::post(conversations::add_message_handler), - ) - .route( - "/{id}/conversations/{cid}", - axum::routing::delete(conversations::delete_message_handler), - ); - - Router::new() - .nest("/sessions", sessions_router) - .nest("/chat", chat::router()) -} - -// Re-export commonly used types at the crate root for ergonomic access. -pub use axum::http::StatusCode; diff --git a/apps/interfaces/api/src/middleware/auth.rs b/apps/interfaces/api/src/middleware/auth.rs deleted file mode 100644 index 0517e74..0000000 --- a/apps/interfaces/api/src/middleware/auth.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! JWT authentication middleware for Axum. -//! -//! Validates the `Authorization: Bearer ` header on every protected -//! request. Injects the validated subject claim into request extensions for -//! downstream handlers to consume. -//! -//! # Flow -//! -//! ```text -//! Request → JwtAuthLayer → extract Bearer token → verify JWT → inject claims -//! → inner service → Response -//! ``` -//! -//! If the token is missing, expired, or has an invalid signature the request -//! is rejected with 401 Unauthorized before reaching any handler. - -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use axum::body::Body; -use axum::http::{Request, Response, StatusCode}; -use axum::response::IntoResponse; -use axum::Json; -use serde::Serialize; -use serde_json::json; -use tower::{Layer, Service}; - -use crate::state::ApiState; - -/// Claims extracted from a valid JWT, injected into request extensions. -#[derive(Debug, Clone, Serialize)] -pub struct JwtClaims { - /// Subject identifier (username/user ID). - pub sub: String, -} - -/// Tower Layer that produces `JwtAuthMiddleware` services. -#[derive(Debug, Clone)] -pub struct JwtAuthLayer { - /// HMAC secret used to verify JWT signatures (reference into `ApiState`). - state: Arc, -} - -impl JwtAuthLayer { - /// Create a new JWT auth layer with the given shared API state. - pub fn new(state: Arc) -> Self { - Self { state } - } -} - -impl Layer for JwtAuthLayer { - type Service = JwtAuthMiddleware; - - fn layer(&self, inner: S) -> Self::Service { - JwtAuthMiddleware { - inner, - state: self.state.clone(), - } - } -} - -/// Tower Service that validates JWT Bearer tokens before forwarding. -#[derive(Debug, Clone)] -pub struct JwtAuthMiddleware { - inner: S, - state: Arc, -} - -impl Service> for JwtAuthMiddleware -where - S: Service, Response = Response> + Send + 'static, - S::Future: Send + 'static, - ReqBody: Send + 'static, -{ - type Response = S::Response; - type Error = S::Error; - type Future = - Pin> + Send + 'static>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, req: Request) -> Self::Future { - // Extract the Authorization header - let auth_header = req - .headers() - .get("Authorization") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - let secret = self.state.jwt_secret.clone(); - - if let Some(auth_value) = auth_header { - // Expect "Bearer " - if let Some(token) = auth_value.strip_prefix("Bearer ") { - match zesdex_infrastructure::auth::jwt::verify_token(&secret, token) { - Ok(claims) => { - // Reject refresh tokens on protected routes — only access - // tokens are acceptable here. - if claims.token_type != zesdex_infrastructure::auth::jwt::TokenType::Access - { - let response = ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "Invalid token", - "detail": "refresh tokens are not accepted on protected routes" - })), - ) - .into_response(); - return Box::pin(async move { Ok(response) }); - } - // Inject claims as extension for downstream handlers - let mut req = req; - req.extensions_mut().insert(JwtClaims { sub: claims.sub }); - let fut = self.inner.call(req); - return Box::pin(fut); - } - Err(e) => { - let response = ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "Invalid token", - "detail": e.to_string() - })), - ) - .into_response(); - return Box::pin(async move { Ok(response) }); - } - } - } - } - - // No valid Authorization header - let response = ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "Missing or invalid Authorization header"})), - ) - .into_response(); - Box::pin(async move { Ok(response) }) - } -} - -/// Helper: check if a request has a valid JWT in its Authorization header. -/// -/// Intended for use in middleware layers or route guards that need quick -/// authentication verification without extracting the full claims. -pub fn is_authenticated(req: &Request) -> bool { - req.extensions().get::().is_some() -} diff --git a/apps/interfaces/api/src/middleware/mod.rs b/apps/interfaces/api/src/middleware/mod.rs deleted file mode 100644 index df062ab..0000000 --- a/apps/interfaces/api/src/middleware/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Axum middleware layers for the REST API. -//! -//! Provides tower `Layer` implementations for cross-cutting concerns: -//! - `auth` — JWT-based authentication layer - -pub mod auth; diff --git a/apps/interfaces/api/src/state.rs b/apps/interfaces/api/src/state.rs deleted file mode 100644 index b0ca92c..0000000 --- a/apps/interfaces/api/src/state.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Shared application state for the REST API server. -//! -//! `ApiState` holds concrete service implementations wired to infrastructure -//! adapters. It is constructed at the composition root and shared across all -//! handlers via Axum's `State` extractor (wrapped in `Arc`). -//! -//! # Flow -//! -//! 1. `ApiState::new(base_dir)` creates all services with their concrete repos. -//! 2. `build_router()` wraps it in `Arc` and passes it to the Axum `Router`. -//! 3. Handlers extract `State>` and delegate to the services. -//! -//! # Port trait implementations -//! -//! This module also provides simple wrapper types that implement the -//! application-layer port traits using infrastructure functions: -//! -//! - `Argon2PasswordService` — implements `PasswordService` via -//! `infrastructure::auth::password` -//! - `JwtTokenService` — implements `TokenService` via -//! `infrastructure::auth::jwt` - -use std::fmt; -use std::future::Future; -use std::path::PathBuf; -use std::sync::Arc; - -use zesdex_application::ports::{PasswordService, TokenService}; - -// --------------------------------------------------------------------------- -// Port trait implementations (wrap infrastructure free functions) -// --------------------------------------------------------------------------- - -/// Password-hashing service backed by Argon2id (infrastructure). -/// -/// Delegates to `zesdex_infrastructure::auth::password::{hash_password, verify_password}`. -#[derive(Clone)] -pub struct Argon2PasswordService; - -impl fmt::Debug for Argon2PasswordService { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Argon2PasswordService").finish() - } -} - -impl PasswordService for Argon2PasswordService { - /// Hash a plaintext password using Argon2id with a random salt. - fn hash(&self, password: &str) -> impl Future> + Send { - zesdex_infrastructure::auth::password::hash_password(password) - } - - /// Verify a plaintext password against a stored PHC string. - fn verify( - &self, - password: &str, - hash: &str, - ) -> impl Future> + Send { - zesdex_infrastructure::auth::password::verify_password(password, hash) - } -} - -/// JWT token service backed by HS256 (infrastructure). -/// -/// Delegates to `zesdex_infrastructure::auth::jwt::{create_token, verify_token}`. -#[derive(Clone)] -pub struct JwtTokenService { - /// HMAC secret key used for signing and verification. - pub secret: String, - /// Token expiry in seconds (default: 3600 = 1 hour). - pub access_token_expiry_secs: u64, - /// Refresh token expiry in seconds (default: 604800 = 7 days). - pub refresh_token_expiry_secs: u64, -} - -impl fmt::Debug for JwtTokenService { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("JwtTokenService") - .field("access_token_expiry_secs", &self.access_token_expiry_secs) - .field("refresh_token_expiry_secs", &self.refresh_token_expiry_secs) - .finish_non_exhaustive() - } -} - -impl JwtTokenService { - /// Create a new JWT service with the given HMAC secret. - pub fn new(secret: impl Into) -> Self { - JwtTokenService { - secret: secret.into(), - access_token_expiry_secs: 3600, - refresh_token_expiry_secs: 604800, - } - } -} - -impl TokenService for JwtTokenService { - /// Generate an access + refresh token pair for the given subject. - fn generate_tokens(&self, sub: &str) -> anyhow::Result<(String, String)> { - use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims, TokenType}; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // Access token - let access_claims = JwtClaims::new( - sub.to_string(), - now + self.access_token_expiry_secs, - TokenType::Access, - None, - ); - let access_token = create_token(&self.secret, access_claims)?; - - // Refresh token (longer-lived) - let refresh_claims = JwtClaims::new( - sub.to_string(), - now + self.refresh_token_expiry_secs, - TokenType::Refresh, - None, - ); - let refresh_token = create_token(&self.secret, refresh_claims)?; - - Ok((access_token, refresh_token)) - } - - /// Verify an access token and return the subject claim. - fn verify_access_token(&self, token: &str) -> anyhow::Result { - use zesdex_infrastructure::auth::jwt::verify_token; - - let claims = verify_token(&self.secret, token)?; - Ok(claims.sub) - } - - /// Verify a refresh token and return the subject claim. - /// - /// Enforces that the presented token is a **refresh** token (`typ = - /// "refresh"`) — an access token presented here is rejected, closing - /// the replay-window escalation where a stolen 1-hour access token - /// could otherwise be exchanged for a fresh 7-day credential. - fn verify_refresh_token(&self, token: &str) -> anyhow::Result { - use zesdex_infrastructure::auth::jwt::{verify_token, TokenType}; - - let claims = verify_token(&self.secret, token)?; - if claims.token_type != TokenType::Refresh { - anyhow::bail!("token is not a refresh token"); - } - Ok(claims.sub) - } -} - -// --------------------------------------------------------------------------- -// ApiState -// --------------------------------------------------------------------------- - -/// Shared application state for the REST API server. -/// -/// Holds all service implementations, repository instances, and configuration -/// needed by the HTTP handlers. Constructed once at startup and shared -/// across all requests via `Arc`. -/// -/// `ApiState` does NOT derive `Clone` or `Debug` because the inner service -/// types may not implement those traits. It is always wrapped in `Arc`. -pub struct ApiState { - /// Base directory for all Zesdex data stores (sessions, settings, etc.). - pub store_base_dir: PathBuf, - /// JWT secret key for token signing/verification. - pub jwt_secret: String, - - // ----------------------------------------------------------------------- - // Service implementations (application-layer use cases) - // ----------------------------------------------------------------------- - /// Session lifecycle management (create, list, archive). - pub session_service: zesdex_application::auth::SessionServiceImpl< - zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository, - zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository, - >, - - /// Conversation message history CRUD. - pub conversation_service: zesdex_application::cms::ConversationServiceImpl< - zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository, - >, - - /// Settings load/save. - pub settings_service: zesdex_application::cms::SettingsServiceImpl< - zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository, - zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository, - >, - - /// Long-term memory CRUD. - pub memory_service: zesdex_application::cms::MemoryServiceImpl< - zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository, - >, - - // ----------------------------------------------------------------------- - // Port trait implementations (infrastructure wrappers) - // ----------------------------------------------------------------------- - /// Argon2id password hashing and verification. - pub password_service: Argon2PasswordService, - - /// HS256 JWT token generation and verification. - pub token_service: JwtTokenService, - - /// Shared sliding-window limiter for auth endpoints (login/register/refresh). - pub auth_rate_limiter: zesdex_infrastructure::middleware::rate_limit::RateLimiter, - - /// Serializes read-modify-write of `users.json` so concurrent register - /// requests cannot lose writes (TOCTOU race). - pub users_lock: Arc>, - - /// LLM provider client for chat completions. - pub llm_client: zesdex_infrastructure::llm::provider::LlmClient, -} - -impl fmt::Debug for ApiState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ApiState") - .field("store_base_dir", &self.store_base_dir) - .field("jwt_secret", &"**redacted**") - .field("session_service", &"SessionServiceImpl { .. }") - .field("conversation_service", &"ConversationServiceImpl { .. }") - .field("settings_service", &"SettingsServiceImpl { .. }") - .field("memory_service", &"MemoryServiceImpl { .. }") - .field("password_service", &self.password_service) - .field("token_service", &self.token_service) - .field("llm_client", &"LlmClient { .. }") - .finish() - } -} - -impl ApiState { - /// Construct a new API state with all services wired to their default - /// infrastructure implementations. - /// - /// ## Arguments - /// * `base_dir` — the Zesdex data store root directory (sessions, settings, etc.) - /// * `jwt_secret` — HMAC secret for JWT signing/verification - /// * `llm_api_key` — API key for the LLM provider - /// * `llm_model` — model identifier string - /// * `llm_base_url` — optional custom API base URL - /// - /// ## Flow - /// - /// Creates concrete repository instances → wraps them in application-layer - /// service implementations → stores everything in `ApiState`. - pub fn new( - base_dir: PathBuf, - jwt_secret: impl Into, - llm_api_key: impl Into, - llm_model: impl Into, - llm_base_url: Option, - ) -> Self { - let jwt_secret = jwt_secret.into(); - let sessions_dir = base_dir.join("sessions"); - let memory_dir = base_dir.join("memories"); - - // IAM repositories - let session_repo = - zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository; - let lock_repo = - zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository; - - // CMS repositories - let conversation_repo = - zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository; - let settings_repo = - zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository; - let app_config_repo = - zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository; - let memory_repo = - zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository; - - // Application-layer services - let session_service = zesdex_application::auth::SessionServiceImpl::new( - session_repo, - lock_repo, - base_dir.clone(), - ); - let conversation_service = - zesdex_application::cms::ConversationServiceImpl::new(conversation_repo, sessions_dir); - let settings_service = zesdex_application::cms::SettingsServiceImpl::new( - settings_repo, - app_config_repo, - base_dir.clone(), - ); - let memory_service = - zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir); - - let token_service = JwtTokenService::new(&jwt_secret); - let auth_rate_limiter = zesdex_infrastructure::middleware::rate_limit::RateLimiter::new(); - let users_lock = Arc::new(std::sync::Mutex::new(())); - let llm_client = zesdex_infrastructure::llm::provider::LlmClient::new( - llm_api_key.into(), - llm_model.into(), - llm_base_url, - ); - - ApiState { - store_base_dir: base_dir, - jwt_secret, - session_service, - conversation_service, - settings_service, - memory_service, - password_service: Argon2PasswordService, - token_service, - auth_rate_limiter, - users_lock, - llm_client, - } - } -} diff --git a/apps/interfaces/daemon/src/client.rs b/apps/interfaces/daemon/src/client.rs deleted file mode 100644 index 5e5ce57..0000000 --- a/apps/interfaces/daemon/src/client.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Attach mode — TUI-only client that connects to an existing daemon session -//! over a Unix socket, forwarding key events and rendering state updates. -//! -//! Flow: `run_attach(session_id)` resolves the daemon's socket path from -//! the store → `setup_attach_client()` connects and enters raw mode → -//! enters a render loop: polls for local terminal events (key/resize/paste/ -//! scroll) → forwards them as `ClientRequest`s to the daemon via IPC → -//! receives a `DaemonFrame` reply → `handle_daemon_frame()` / -//! `apply_client_update()` applies the state snapshot onto a local -//! `AppStateRest` mirror → `draw()` renders the TUI → on quit, -//! sends `ClientRequest::Close`, cleans up terminal, and saves settings. -//! -//! The client has no agent logic — it is a pure render frontend. - -use std::io::{self}; - -use anyhow::Result; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; -use ratatui::Terminal; -use zesdex_domain::SettingsRepository; -use zesdex_infrastructure::ipc::client::IpcClient; -use zesdex_infrastructure::ipc::protocol::{ClientRequest, DaemonFrame, StatePayload}; -use zesdex_infrastructure::Toast; -use zesdex_infrastructure::ToastKind; - -use crate::key_code::key_code_to_action; -use crate::state::{AppStateRest, ChatMessageDisplay, Overlay, RoleWrapper}; - -// --------------------------------------------------------------------------- -// apply_client_update — apply a StatePayload onto the local AppStateRest -// --------------------------------------------------------------------------- - -/// Apply a `StatePayload` received from the daemon onto the client's -/// local `AppStateRest`, so the attach-mode TUI can render it. -/// -/// Flow: copy scalar fields directly → rebuild the transcript cache from -/// `MessageEntry`s (mapping role strings back to role variants) → -/// resolve the overlay name string to an `Overlay` variant → rebuild -/// toasts from `ToastEntry`s. -/// -/// Why: unrecognised role/overlay/toast-kind strings fall back to a safe -/// default (`User`, `Overlay::None`, `ToastKind::Info`) rather than -/// panicking, so a protocol/version mismatch degrades gracefully. -fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) { - tracing::debug!("applying state update from daemon"); - state.session_id = payload.session_id; - state.dirty = payload.dirty; - - state.transcript_cache.messages = payload - .messages - .into_iter() - .map(|m| ChatMessageDisplay { - role: match m.role.as_str() { - "Assistant" => RoleWrapper::Assistant, - "System" => RoleWrapper::System, - "Tool" => RoleWrapper::Tool, - _ => RoleWrapper::User, - }, - content: m.content, - timestamp: m.timestamp, - }) - .collect(); - state.transcript_cache.dirty = true; - - state.misc.overlay = match payload.overlay.as_deref() { - Some("Help") => Overlay::Help, - Some("Settings") => Overlay::Settings, - Some("Bash") => Overlay::Bash, - Some("QuitConfirm") => Overlay::QuitConfirm, - Some("KeyInput") => Overlay::KeyInput, - Some("Editor") => Overlay::Editor, - Some("Effort") => Overlay::Effort, - Some("Mcp") => Overlay::Mcp, - Some("Todo") => Overlay::Todo, - Some("Rewind") => Overlay::Rewind, - Some("Learning") => Overlay::Learning, - Some("Usage") => Overlay::Usage, - Some("Loading") => Overlay::Loading, - Some("ModelSelector") => Overlay::ModelSelector, - Some("ClearConfirm") => Overlay::ClearConfirm, - _ => Overlay::None, - }; - - state.misc.toasts = payload - .toasts - .into_iter() - .map(|t| Toast { - kind: match t.kind.as_str() { - "Success" => ToastKind::Success, - "Warning" => ToastKind::Warning, - "Error" => ToastKind::Error, - "Lesson" => ToastKind::Lesson, - _ => ToastKind::Info, - }, - message: t.message, - created_at: t.created_at, - lifetime_ms: t.lifetime_ms, - }) - .collect(); - - state.input.buffer = payload.input_buffer; - state.input.cursor = payload.input_cursor; -} - -// --------------------------------------------------------------------------- -// setup_attach_client — connect to daemon and set up terminal -// --------------------------------------------------------------------------- - -/// Set up the IPC client connection, terminal, and initial state for attach mode. -/// -/// Flow: resolve socket path → connect → enable raw/alt mode → create state. -/// -/// Return: (client, terminal, `client_state`) on success. -fn setup_attach_client( - session_id: &str, -) -> Result<( - IpcClient, - Terminal>, - AppStateRest, -)> { - tracing::debug!("setting up attach client for session {session_id}"); - let store = zesdex_infrastructure::Store::new(); - let socket_path = store - .base_dir - .join("run") - .join(format!("{session_id}.sock")); - let addr = socket_path.to_string_lossy().to_string(); - let client = IpcClient::connect_unix(&addr)?; - - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - execute!(stdout, crossterm::event::EnableBracketedPaste)?; - execute!(stdout, crossterm::event::EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - terminal.clear()?; - - let workspace_roots = vec![std::env::current_dir()?]; - let session_dir = store.base_dir.join("sessions").join(session_id); - std::fs::create_dir_all(&session_dir)?; - let mut client_state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir); - client_state.session_id = session_id.to_string(); - - Ok((client, terminal, client_state)) -} - -// --------------------------------------------------------------------------- -// handle_daemon_frame — process a single DaemonFrame from the daemon -// --------------------------------------------------------------------------- - -/// Process a single daemon frame from the IPC channel, updating state accordingly. -fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option) { - tracing::debug!("received daemon frame"); - match frame { - Some(DaemonFrame::StateUpdate(payload)) => { - apply_client_update(client_state, *payload); - } - Some(DaemonFrame::StreamToken(_token)) => {} - Some(DaemonFrame::SystemNote { kind: _, message }) => { - client_state.push_toast(Toast::new(ToastKind::Info, message)); - } - Some(DaemonFrame::ClipboardCopy(text)) => { - let _ = zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text); - client_state.push_toast(Toast::new( - ToastKind::Success, - "Copied to clipboard".to_string(), - )); - } - Some(DaemonFrame::Closed) | None => { - client_state.quit = true; - } - } -} - -// --------------------------------------------------------------------------- -// draw — minimal TUI render function -// --------------------------------------------------------------------------- - -/// Render the current application state onto the terminal. -/// -/// Layout: -/// - Top: title bar with session ID and overlay status -/// - Middle: transcript message list (scrollable) -/// - Bottom: input line with cursor -/// - Overlay name shown when an overlay is active -fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) { - let area = frame.area(); - - // Vertical layout: main content + input line - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), // main content (transcript + toasts) - Constraint::Length(3), // input line - ]) - .split(area); - - // ── Main content area ─────────────────────────────────────────────── - let title = format!( - " Zesdex — {} {}", - &state.session_id[..state.session_id.len().min(8)], - if state.misc.overlay.is_active() { - format!("[{}]", state.misc.overlay) - } else { - String::new() - } - ); - - let mut content_lines: Vec = Vec::new(); - - // Show toasts at the top if present. - for toast in &state.misc.toasts { - content_lines.push(format!("[{:?}] {}", toast.kind, toast.message)); - } - - // Show active overlay name. - if state.misc.overlay.is_active() { - content_lines.push(String::new()); - content_lines.push(format!("=== {} ===", state.misc.overlay)); - content_lines.push(String::new()); - } - - // Transcript messages. - for msg in &state.transcript_cache.messages { - let prefix = match msg.role { - RoleWrapper::User => "You", - RoleWrapper::Assistant => "AI", - RoleWrapper::System => "System", - RoleWrapper::Tool => "Tool", - }; - content_lines.push(format!("{}: {}", prefix, msg.content)); - } - - // Scroll offset indicator. - if state.scroll.offset > 0 { - content_lines.push(format!("--- scrolled up {} lines ---", state.scroll.offset)); - } - - let content = content_lines.join("\n"); - - let main_block = Block::default().title(title).borders(Borders::TOP); - let paragraph = Paragraph::new(content) - .block(main_block) - .wrap(Wrap { trim: false }) - .scroll((state.scroll.offset as u16, 0)); - frame.render_widget(paragraph, chunks[0]); - - // ── Input line ────────────────────────────────────────────────────── - let input_block = Block::default().borders(Borders::TOP); - let input_display = if state.input.buffer.is_empty() { - "Type a message...".to_string() - } else { - state.input.buffer.clone() - }; - let input_paragraph = Paragraph::new(input_display).block(input_block); - frame.render_widget(input_paragraph, chunks[1]); - - // Set cursor position for the input line. - use ratatui::layout::Position; - frame.set_cursor_position(Position::new( - chunks[1].x + state.input.cursor as u16 + 1, - chunks[1].y + 1, - )); -} - -// --------------------------------------------------------------------------- -// run_attach — main attach-mode entry point -// --------------------------------------------------------------------------- - -/// Run zesdex as a TUI-only client attached to an existing daemon session. -/// -/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate -/// screen → build a local `AppStateRest` mirror (only used for rendering -/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a -/// terminal event (key/resize) and forward it as a `ClientRequest`, or -/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and -/// apply it via `apply_client_update` → redraw → exit when the daemon -/// closes or the user quits (sending `ClientRequest::Close` first). -/// -/// Why: Ctrl+C is intercepted locally to quit the client without going -/// through the daemon, since the daemon has no notion of "this client -/// wants to leave" beyond the explicit `Close` request. -pub fn run_attach(session_id: &str) -> Result<()> { - use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; - - let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?; - let _rt = tokio::runtime::Runtime::new()?; - - loop { - if client_state.quit { - let _ = client.send(&ClientRequest::Close); - break; - } - - let now_ms = chrono::Utc::now().timestamp_millis(); - client_state.misc.drain_expired_toasts(now_ms); - - if crossterm::event::poll(std::time::Duration::from_millis(50))? { - match crossterm::event::read()? { - Event::Key(key) => { - if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let alt = key.modifiers.contains(KeyModifiers::ALT); - let shift = key.modifiers.contains(KeyModifiers::SHIFT); - - if key.code == KeyCode::Char('c') && ctrl { - client_state.quit = true; - continue; - } - - if let Some(key_action) = key_code_to_action(key.code) { - client.send(&ClientRequest::KeyPress { - key: key_action, - ctrl, - alt, - shift, - })?; - } - } - } - Event::Paste(text) => { - client.send(&ClientRequest::Paste(text))?; - } - Event::Resize(w, h) => { - client.send(&ClientRequest::Resize(w, h))?; - } - Event::Mouse(mouse_event) => { - if mouse_event.kind == MouseEventKind::ScrollUp { - client.send(&ClientRequest::ScrollUp)?; - } else if mouse_event.kind == MouseEventKind::ScrollDown { - client.send(&ClientRequest::ScrollDown)?; - } - } - _ => {} - } - } else { - client.send(&ClientRequest::Tick)?; - } - - handle_daemon_frame(&mut client_state, client.receive::()?); - - terminal.draw(|f| { - draw(f, &client_state); - })?; - } - - let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste); - let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - let _ = disable_raw_mode(); - - let _ = zesdex_infrastructure::persistence::JsonSettingsRepository::new() - .save(&client_state.store_base_dir(), &client_state.settings); - - Ok(()) -} diff --git a/apps/interfaces/daemon/src/handler.rs b/apps/interfaces/daemon/src/handler.rs deleted file mode 100644 index 7a01236..0000000 --- a/apps/interfaces/daemon/src/handler.rs +++ /dev/null @@ -1,882 +0,0 @@ -//! Daemon request handler — processes IPC `ClientRequest` messages, -//! applies `Action`s to application state, and pushes state updates back -//! to the attached client. -//! -//! Also defines the [`Action`] enum and the [`apply_action`] dispatcher, -//! as well as the [`handle_key`] function that translates `crossterm` -//! key events into actions — adapting `controller::input::handle_key` -//! from the legacy single-process backend. -//! -//! Flow: -//! 1. `handle_daemon_client(conn, state)` loops reading `ClientRequest`s -//! 2. Each request is translated into `Action`(s) via `handle_key` / -//! direct action invocation -//! 3. `apply_action` mutates `AppStateRest` in place -//! 4. After each request, `send_daemon_update` pushes a full state -//! snapshot back to the client - -use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use tracing::info; -use webbrowser; -use zesdex_infrastructure::ipc::conn::Connection; -use zesdex_infrastructure::ipc::protocol::{ - ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry, -}; -use zesdex_infrastructure::utils::CastOr; - -use crate::key_code::key_action_to_code; -use crate::state::{AppStateRest, AutocompleteKind, ChatMessageDisplay, Overlay, RoleWrapper}; - -// --------------------------------------------------------------------------- -// Action enum -// --------------------------------------------------------------------------- - -/// A single well-typed event that mutates `AppStateRest` when applied via -/// [`apply_action`]. -/// -/// Produced by `handle_key` (key event → actions) or directly by the -/// daemon's IPC handler. -#[derive(Debug, Clone)] -pub enum Action { - /// Hard exit — immediately terminates the process. - ForceQuit, - /// Submit a user message to the LLM, starting a new agent turn. - SubmitInput(String), - /// Delete one character before the cursor in the input buffer. - DeleteChar, - /// Delete one character after the cursor in the input buffer. - DeleteCharRight, - /// Move the cursor one position left in the input buffer. - CursorLeft, - /// Move the cursor one position right in the input buffer. - CursorRight, - /// Navigate up through command history. - HistoryUp, - /// Navigate down through command history. - HistoryDown, - /// Scroll the transcript pane up. - ScrollUp, - /// Scroll the transcript pane down. - ScrollDown, - /// Open a named overlay. - OpenOverlay(Overlay), - /// Close the currently active overlay. - CloseOverlay, - /// Insert a system-generated note into the transcript. - SystemNote { - /// Note category: "error", "info", "clear", etc. - kind: String, - /// The message text to display. - message: String, - }, - /// Show the quit-confirmation overlay. - QuitConfirm, - /// Terminal resize event — carries the new dimensions. - Resize(u16, u16), - /// Periodic timer tick — drains queued events and runs side jobs. - Tick, - /// Accept a lesson by name. - LessonAccept { name: String }, - /// Reject a lesson by name. - LessonReject { name: String }, - /// Delete a previously stored lesson by name. - LessonDelete { name: String }, - /// Start the OAuth device-code login flow for a named provider. - StartOAuth { provider: String }, - /// Open the inline file editor for `path`. - OpenEditor { path: String }, - /// Register a new MCP server by name and shell command. - McpAdd { name: String, command: String }, - /// Open the model-picker overlay. - ModelList, - /// Set the abort flag on the currently running turn. - AbortTurn, - /// Request AI-summary compaction of the conversation history. - Compact, -} - -// --------------------------------------------------------------------------- -// apply_action -// --------------------------------------------------------------------------- - -/// Apply an `Action` to the application state. -/// -/// Flow: pattern-match the variant → delegate to the corresponding handler -/// → handler mutates `state` (input buffer, scroll position, overlay, -/// transcript, toasts, dirty flag, etc.). -/// -/// Why: the single chokepoint that turns every typed key and async event -/// into a state change. -pub fn apply_action(state: &mut AppStateRest, action: Action) { - tracing::debug!("apply_action: {:?}", action); - match action { - // ── Lifecycle ───────────────────────────────────────────────── - Action::ForceQuit => handle_force_quit(state), - Action::QuitConfirm => handle_quit_confirm(state), - Action::Resize(w, _h) => handle_resize(state, w), - Action::Tick => handle_tick(state), - - // ── Input / editing ─────────────────────────────────────────── - Action::SubmitInput(text) => handle_submit_input(state, text), - Action::DeleteChar => handle_delete_char(state), - Action::DeleteCharRight => handle_delete_char_right(state), - Action::CursorLeft => handle_cursor_left(state), - Action::CursorRight => handle_cursor_right(state), - Action::HistoryUp => handle_history_up(state), - Action::HistoryDown => handle_history_down(state), - - // ── Scroll / navigation ─────────────────────────────────────── - Action::ScrollUp => handle_scroll_up(state), - Action::ScrollDown => handle_scroll_down(state), - Action::OpenOverlay(overlay) => handle_open_overlay(state, overlay), - Action::CloseOverlay => handle_close_overlay(state), - - // ── System / info ───────────────────────────────────────────── - Action::SystemNote { - kind: _kind, - message, - } => handle_system_note(state, message), - Action::ModelList => handle_model_list(state), - Action::AbortTurn => handle_abort_turn(state), - Action::Compact => handle_compact(state), - - // ── Editor / MCP / OAuth ────────────────────────────────────── - Action::OpenEditor { path } => handle_open_editor(state, path), - Action::McpAdd { name, command } => handle_mcp_add(state, name, command), - Action::StartOAuth { provider } => handle_start_oauth(state, provider), - - // ── Lessons ─────────────────────────────────────────────────── - Action::LessonAccept { name } => handle_lesson_accept(state, name), - Action::LessonReject { name } => handle_lesson_reject(state, name), - Action::LessonDelete { name } => handle_lesson_delete(state, name), - } -} - -// --------------------------------------------------------------------------- -// Action handlers -// --------------------------------------------------------------------------- - -fn handle_force_quit(state: &mut AppStateRest) { - state.quit = true; -} - -fn handle_quit_confirm(state: &mut AppStateRest) { - state.misc.overlay = Overlay::QuitConfirm; - state.dirty = true; -} - -fn handle_resize(state: &mut AppStateRest, w: u16) { - tracing::debug!("terminal resize to width={}", w); - state.dirty = true; -} - -fn handle_tick(state: &mut AppStateRest) { - let now_ms = chrono::Utc::now().timestamp_millis(); - state.misc.drain_expired_toasts(now_ms); - - // Drain queued turn events FIRST (while holding the lock), then release - // the lock and process events with mutable state access. - let drained: Vec<_> = state - .turn_events - .lock() - .map(|mut events| events.drain(..).collect()) - .unwrap_or_else(|e| { - tracing::error!("turn_events mutex poisoned, recovering"); - e.into_inner().drain(..).collect() - }); - - for event in drained { - use zesdex_infrastructure::TurnEvent; - match event { - TurnEvent::SystemNote { kind, message } => { - if kind == "hive_mind_converged" { - if let Some(ref mut rt) = state.session_runtime { - rt.hive_mind_converged = true; - } - } - handle_system_note(state, message); - } - TurnEvent::AssistantMessage(_msg) => { - // AssistantMessage is handled via StreamDone to avoid duplicates. - state.dirty = true; - } - TurnEvent::StreamToken(_token) => { - state.dirty = true; - } - TurnEvent::StreamDone(msg) => { - state.push_transcript(ChatMessageDisplay::new( - RoleWrapper::Assistant, - msg.content.unwrap_or_default(), - )); - } - TurnEvent::Error(e) => { - state.toast_error(e); - } - TurnEvent::Usage { - tokens_in, - tokens_out, - } => { - if let Some(ref mut rt) = state.session_runtime { - rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in); - rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out); - rt.usage.last_tokens_in = tokens_in; - rt.usage.last_tokens_out = tokens_out; - } - } - TurnEvent::ReviewUsage { - tokens_in, - tokens_out, - } => { - if let Some(ref mut rt) = state.session_runtime { - rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in); - rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out); - } - } - TurnEvent::Done => { - if let Ok(mut in_flight) = state.turn_in_flight.lock() { - *in_flight = false; - } - state.dirty = true; - } - TurnEvent::ToolResult { .. } => { - // Tool result events are logged but the content is already in - // the session runtime messages — no transcript push needed here. - state.dirty = true; - } - _ => { - state.dirty = true; - } - } - } - - state.misc.tick_count += 1; - state.dirty = true; -} - -fn handle_submit_input(state: &mut AppStateRest, text: String) { - // Push the user message to the transcript. - state.push_transcript(ChatMessageDisplay::new(RoleWrapper::User, text.clone())); - - // Save the input to history. - if !text.is_empty() { - state.input.history.push(text.clone()); - if let Some(ref path) = state.input.history_file { - let _ = std::fs::write(path, state.input.history.join("\n")); - } - } - - // Set up the session runtime for the turn. - if let Some(ref mut rt) = state.session_runtime { - rt.push_message(zesdex_infrastructure::ChatMessage { - role: zesdex_infrastructure::Role::User, - content: Some(text.clone()), - tool_calls: None, - tool_call_id: None, - name: None, - }); - } - - // Clear the input buffer. - state.input.buffer.clear(); - state.input.cursor = 0; - state.input.history_idx = None; - state.dirty = true; - - // Resolve LLM provider credentials - let provider_name = &state.settings.provider; - let provider_cfg = state.app_config.providers.get(provider_name).cloned(); - let mut api_key = String::new(); - if let Some(key) = state.settings.api_keys.get(provider_name) { - api_key = key.clone(); - } else if let Some(ref cfg) = provider_cfg { - if let Some(ref default_key) = cfg.default_api_key { - api_key = default_key.clone(); - } - if api_key.is_empty() { - if let Some(ref env_name) = cfg.api_key_env { - if let Ok(val) = std::env::var(env_name) { - api_key = val; - } - } - } - } - - let messages = state - .session_runtime - .as_ref() - .map(|rt| rt.messages.clone()) - .unwrap_or_default(); - - let params = zesdex_domain::agent::AgentTurnParams { - messages, - session_dir: state.session_dir.clone(), - workspace_roots: state.workspace_roots.clone(), - turn_events: state.turn_events.clone(), - in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - abort: state.abort_flag.clone(), - api_key: api_key.clone(), - model: zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config), - api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()), - }; - - // Capture state for the optional background auto-review so it can run - // with the same resolved provider. Done here, before `api_key` / - // `provider_cfg` are moved into the client below. The reviewer is - // fire-and-forget and skips itself when the workspace has no diff. - let review_enabled = state.settings.flags.review_enabled; - let review_key = api_key.clone(); - let review_model = - zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config); - let review_base = provider_cfg.as_ref().map(|c| c.api_base.clone()); - let review_ws = params.workspace_roots.clone(); - let review_events = params.turn_events.clone(); - - let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new( - api_key, - zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config), - provider_cfg.map(|cfg| cfg.api_base.clone()), - )); - - let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder() - .session_dir(state.session_dir.clone()) - .workspaces(state.workspace_roots.clone()) - .turn_events(state.turn_events.clone()) - .build(); - - let tool_executor = std::sync::Arc::new( - zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx), - ); - - let tools = zesdex_infrastructure::tools::all_tools(); - let defs = zesdex_infrastructure::tools::tool_defs(&tools); - - let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new( - client, - tool_executor, - defs, - ); - - use zesdex_application::agent::AgentTurnService; - tokio::spawn(async move { - let _ = turn_service.run_turn(params).await; - if review_enabled { - zesdex_infrastructure::subagent::auto::engine::spawn_background_review( - review_ws, - review_events, - review_key, - review_model, - review_base, - ); - } - }); -} - -fn handle_delete_char(state: &mut AppStateRest) { - if state.input.cursor > 0 { - state.input.buffer.remove(state.input.cursor - 1); - state.input.cursor -= 1; - state.dirty = true; - } -} - -fn handle_delete_char_right(state: &mut AppStateRest) { - if state.input.cursor < state.input.buffer.len() { - state.input.buffer.remove(state.input.cursor); - state.dirty = true; - } -} - -fn handle_cursor_left(state: &mut AppStateRest) { - if state.input.cursor > 0 { - state.input.cursor = state.input.cursor.saturating_sub(1); - state.dirty = true; - } -} - -fn handle_cursor_right(state: &mut AppStateRest) { - if state.input.cursor < state.input.buffer.len() { - state.input.cursor += 1; - state.dirty = true; - } -} - -fn handle_history_up(state: &mut AppStateRest) { - if state.input.history.is_empty() { - return; - } - let idx = match state.input.history_idx { - Some(i) if i > 0 => i - 1, - None => state.input.history.len() - 1, - _ => return, - }; - state.input.history_idx = Some(idx); - state.input.buffer = state.input.history[idx].clone(); - state.input.cursor = state.input.buffer.len(); - state.dirty = true; -} - -fn handle_history_down(state: &mut AppStateRest) { - match state.input.history_idx { - Some(i) if i + 1 < state.input.history.len() => { - state.input.history_idx = Some(i + 1); - state.input.buffer = state.input.history[i + 1].clone(); - state.input.cursor = state.input.buffer.len(); - state.dirty = true; - } - Some(_) => { - state.input.history_idx = None; - state.input.buffer.clear(); - state.input.cursor = 0; - state.dirty = true; - } - None => {} - } -} - -fn handle_scroll_up(state: &mut AppStateRest) { - state.scroll.scroll_up(1); - state.dirty = true; -} - -fn handle_scroll_down(state: &mut AppStateRest) { - state.scroll.scroll_down(1); - state.dirty = true; -} - -fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) { - state.misc.overlay = overlay; - state.dirty = true; -} - -fn handle_close_overlay(state: &mut AppStateRest) { - if state.misc.overlay.is_active() { - state.misc.overlay = Overlay::None; - state.dirty = true; - } -} - -fn handle_system_note(state: &mut AppStateRest, message: String) { - state.push_transcript(ChatMessageDisplay::new(RoleWrapper::System, message)); -} - -fn handle_model_list(state: &mut AppStateRest) { - info!("opening model selector"); - state.misc.overlay = Overlay::ModelSelector; - state.dirty = true; -} - -fn handle_abort_turn(state: &mut AppStateRest) { - state - .abort_flag - .store(true, std::sync::atomic::Ordering::SeqCst); - if let Ok(mut in_flight) = state.turn_in_flight.lock() { - *in_flight = false; - } - state.dirty = true; -} - -fn handle_compact(state: &mut AppStateRest) { - tracing::info!("compacting conversation with AI summarization"); - let provider_name = &state.settings.provider; - let provider_cfg = state.app_config.providers.get(provider_name).cloned(); - let api_key = state - .settings - .api_keys - .get(provider_name) - .cloned() - .unwrap_or_default(); - let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config); - let api_base = provider_cfg.map(|cfg| cfg.api_base.clone()); - - let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base); - - if let Some(ref mut rt) = state.session_runtime { - let tokio_rt = zesdex_infrastructure::runtime::runtime(); - if let Ok(()) = tokio_rt.block_on( - zesdex_application::agent::turn_service::compact_messages_with_ai( - &mut rt.messages, - &client, - ), - ) { - let msg_count = rt.messages.len(); - state.push_transcript(ChatMessageDisplay::new( - RoleWrapper::System, - format!("Conversation compacted via AI Summarizer to {msg_count} messages."), - )); - } - } - state.dirty = true; -} - -fn handle_open_editor(state: &mut AppStateRest, path: String) { - tracing::info!("opening editor for: {path}"); - state.misc.editor = Some(crate::state::EditorState::new( - std::path::PathBuf::from(&path), - std::fs::read_to_string(&path).unwrap_or_default(), - )); - handle_open_overlay(state, Overlay::Editor); -} - -fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) { - tracing::info!("adding MCP server: {name}"); - match state.mcp_manager.register(&name, &command) { - Ok(()) => { - state.toast_success(format!("MCP server '{name}' added with command: {command}")); - state.dirty = true; - } - Err(e) => { - state.toast_error(format!("Failed to add MCP server '{name}': {e}")); - } - } -} - -fn handle_start_oauth(state: &mut AppStateRest, provider: String) { - tracing::info!("starting OAuth for provider: {provider}"); - state.toast_info(format!("OAuth flow started for {provider}...")); - // Construct provider-specific OAuth authorization URL. - let auth_url_owned; - let url_to_open = match provider.as_str() { - "github" => "https://github.com/login/oauth/authorize", - "google" => "https://accounts.google.com/o/oauth2/v2/auth", - "anthropic" => "https://anthropic.com/api/oauth/authorize", - other => { - auth_url_owned = format!("https://{other}.com/auth"); - &auth_url_owned - } - }; - if let Err(e) = webbrowser::open(url_to_open) { - tracing::warn!("Failed to open browser for OAuth: {e}"); - state.toast_error(format!("Failed to open browser: {e}")); - } - state.dirty = true; -} - -fn handle_lesson_accept(state: &mut AppStateRest, name: String) { - tracing::info!("lesson accepted: {name}"); - state.toast_success(format!("Lesson accepted: {name}")); - state.dirty = true; -} - -fn handle_lesson_reject(state: &mut AppStateRest, name: String) { - tracing::info!("lesson rejected: {name}"); - state.toast_info(format!("Lesson rejected: {name}")); - state.dirty = true; -} - -fn handle_lesson_delete(state: &mut AppStateRest, name: String) { - tracing::info!("lesson deleted: {name}"); - state.toast_warning(format!("Lesson deleted: {name}")); - state.dirty = true; -} - -// --------------------------------------------------------------------------- -// handle_key — translate crossterm KeyEvent into Vec -// --------------------------------------------------------------------------- - -/// Handle a crossterm key event and produce a list of actions. -/// -/// Maps key codes + modifiers to Action variants. Mirrors the same -/// dispatch logic used by the single-process TUI controller. -pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { - tracing::debug!( - code = ?key.code, - mods = ?key.modifiers, - overlay = ?state.misc.overlay, - "handle_key" - ); - - // ── Editor overlay ─────────────────────────────────────────────────── - if state.misc.overlay == Overlay::Editor { - return match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - vec![Action::QuitConfirm] - } - KeyCode::Esc => { - vec![Action::CloseOverlay] - } - _ => vec![], - }; - } - - // ── Learning overlay ────────────────────────────────────────────────── - if state.misc.overlay == Overlay::Learning { - return match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - vec![Action::QuitConfirm] - } - KeyCode::Esc => vec![Action::CloseOverlay], - KeyCode::Up => { - state.misc.selected_index = state.misc.selected_index.saturating_sub(1); - state.dirty = true; - vec![] - } - KeyCode::Down => { - state.misc.selected_index = state.misc.selected_index.saturating_add(1); - state.dirty = true; - vec![] - } - _ => vec![], - }; - } - - // ── Normal mode ─────────────────────────────────────────────────────── - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Always show quit-confirm, regardless of overlay state. - vec![Action::QuitConfirm] - } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - vec![Action::CloseOverlay] - } - KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Yank: handled separately via clipboard — produce no action - state.dirty = true; - vec![] - } - KeyCode::Tab => { - // Cycle autocomplete - if !state.input.autocomplete_visible { - state.input.open_autocomplete(); - state.dirty = true; - } else { - state.input.autocomplete_idx = (state.input.autocomplete_idx + 1) - % state.input.autocomplete_candidates.len().max(1); - state.dirty = true; - } - vec![] - } - KeyCode::Enter => { - if state.misc.overlay.is_active() { - match state.misc.overlay { - Overlay::QuitConfirm => vec![Action::ForceQuit], - Overlay::ClearConfirm => vec![Action::SystemNote { - kind: "clear".to_string(), - message: "cleared".to_string(), - }], - _ => vec![Action::CloseOverlay], - } - } else if state.input.autocomplete_visible { - // Select the current autocomplete candidate - if !state.input.autocomplete_candidates.is_empty() { - let idx = state - .input - .autocomplete_idx - .min(state.input.autocomplete_candidates.len().saturating_sub(1)); - if state.input.autocomplete_kind == AutocompleteKind::Command { - state.input.buffer = state.input.autocomplete_candidates[idx].clone(); - state.input.cursor = state.input.buffer.len(); - } - state.input.close_autocomplete(); - state.dirty = true; - } - vec![] - } else if !state.input.buffer.is_empty() { - vec![Action::SubmitInput(state.input.buffer.clone())] - } else { - vec![] - } - } - KeyCode::Esc => { - if state.misc.overlay.is_active() { - vec![Action::CloseOverlay] - } else if state.input.autocomplete_visible { - state.input.close_autocomplete(); - state.dirty = true; - vec![] - } else { - vec![Action::AbortTurn] - } - } - KeyCode::Up => { - if state.misc.overlay.is_active() { - vec![Action::ScrollUp] - } else { - vec![Action::HistoryUp] - } - } - KeyCode::Down => { - if state.misc.overlay.is_active() { - vec![Action::ScrollDown] - } else { - vec![Action::HistoryDown] - } - } - KeyCode::PageUp => vec![Action::ScrollUp], - KeyCode::PageDown => vec![Action::ScrollDown], - KeyCode::Home => { - state.scroll.offset = 0; - state.dirty = true; - vec![] - } - KeyCode::End => { - state.scroll.offset = usize::MAX; - state.dirty = true; - vec![] - } - KeyCode::Backspace => vec![Action::DeleteChar], - KeyCode::Delete => vec![Action::DeleteCharRight], - KeyCode::Left => vec![Action::CursorLeft], - KeyCode::Right => vec![Action::CursorRight], - KeyCode::Char(c) => { - // Regular character input - if state.input.autocomplete_visible { - state.input.close_autocomplete(); - } - state.input.buffer.insert(state.input.cursor, c); - state.input.cursor += c.len_utf8(); - state.dirty = true; - vec![] - } - _ => { - // Unhandled key - vec![] - } - } -} - -// --------------------------------------------------------------------------- -// send_daemon_update — push full state snapshot to the client -// --------------------------------------------------------------------------- - -/// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it -/// to the attached client as a `DaemonFrame::StateUpdate`. -/// -/// Flow: map transcript messages/toasts to their wire DTOs → derive the -/// active overlay name (or `None` if no overlay is active) → build and -/// send one `DaemonFrame`. -pub fn send_daemon_update(conn: &mut Connection, state: &AppStateRest) -> Result<()> { - tracing::debug!("sending state update to attached client"); - - let messages: Vec = state - .transcript_cache - .messages - .iter() - .map(|m| MessageEntry { - role: format!("{:?}", m.role), - content: m.content.clone(), - timestamp: m.timestamp, - }) - .collect(); - - let toasts: Vec = state - .misc - .toasts - .iter() - .map(|t| ToastEntry { - kind: format!("{:?}", t.kind), - message: t.message.clone(), - created_at: t.created_at, - lifetime_ms: t.lifetime_ms, - }) - .collect(); - - let overlay = if state.misc.overlay.is_active() { - Some(format!("{:?}", state.misc.overlay)) - } else { - None - }; - - let frame = DaemonFrame::StateUpdate(Box::new(StatePayload { - session_id: state.session_id.clone(), - messages, - edit_count: state.edit_log.len().cast_or(0u32), - message_count: state.transcript_cache.messages.len(), - overlay, - toasts, - dirty: state.dirty, - input_buffer: state.input.buffer.clone(), - input_cursor: state.input.cursor, - })); - - conn.send(&frame)?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// handle_daemon_client — process an attached client's IPC messages -// --------------------------------------------------------------------------- - -/// Handle an incoming client connection for the daemon. -/// -/// Flow: loop reading requests, modifying state, and sending updates back. -pub fn handle_daemon_client(mut conn: Connection, state: &mut AppStateRest) -> Result<()> { - tracing::debug!("handling daemon client connection"); - let mut running = true; - while running { - match conn.receive::()? { - Some(req) => { - match req { - ClientRequest::Tick => { - apply_action(state, Action::Tick); - } - ClientRequest::KeyPress { - key, - ctrl, - alt, - shift, - } => { - let mut modifiers = KeyModifiers::NONE; - if ctrl { - modifiers |= KeyModifiers::CONTROL; - } - if alt { - modifiers |= KeyModifiers::ALT; - } - if shift { - modifiers |= KeyModifiers::SHIFT; - } - let key_event = KeyEvent::new(key_action_to_code(&key), modifiers); - let actions = handle_key(key_event, state); - for action in actions { - apply_action(state, action); - } - apply_action(state, Action::Tick); - } - ClientRequest::Submit(text) => { - state.input.buffer = text; - let enter_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); - let actions = handle_key(enter_event, state); - for action in actions { - apply_action(state, action); - } - apply_action(state, Action::Tick); - } - ClientRequest::Paste(text) => { - state.input.buffer.insert_str(state.input.cursor, &text); - state.input.cursor += text.len(); - state.dirty = true; - apply_action(state, Action::Tick); - } - ClientRequest::Resize(w, h) => { - apply_action(state, Action::Resize(w, h)); - apply_action(state, Action::Tick); - } - ClientRequest::ScrollUp => { - apply_action(state, Action::ScrollUp); - apply_action(state, Action::Tick); - } - ClientRequest::ScrollDown => { - apply_action(state, Action::ScrollDown); - apply_action(state, Action::Tick); - } - ClientRequest::Close => { - running = false; - } - } - if let Some(text) = state.misc.pending_clipboard_copy.take() { - conn.send(&DaemonFrame::ClipboardCopy(text))?; - } - // Only send state update for valid requests, not on EOF/disconnect. - if running { - send_daemon_update(&mut conn, state)?; - } - } - None => { - running = false; - // Don't call send_daemon_update — connection is dead. - } - } - } - Ok(()) -} diff --git a/apps/interfaces/daemon/src/key_code.rs b/apps/interfaces/daemon/src/key_code.rs deleted file mode 100644 index 05bb59a..0000000 --- a/apps/interfaces/daemon/src/key_code.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Key code <-> wire-serializable `KeyAction` conversion functions. -//! -//! Map `crossterm::event::KeyCode` to and from the IPC protocol's `KeyAction` -//! enum. Both directions are total (every `KeyAction` has a `KeyCode`), but -//! `key_code_to_action` returns `None` for key codes with no IPC equivalent -//! (e.g. media keys), which are silently dropped by the caller. -//! -//! Flow: daemon receives `KeyAction` over IPC → `key_action_to_code` → -//! reconstructs `crossterm::KeyEvent` → feeds into `controller::input::handle_key`. -//! The inverse (`key_code_to_action`) is used by the attach-mode client to -//! serialise a local terminal key press before sending it over the socket. - -use crossterm::event::KeyCode; -use zesdex_infrastructure::ipc::protocol::KeyAction; - -/// Map a `crossterm` key code to the wire-serializable `KeyAction`, for -/// sending key input from an attached client to the daemon. -/// -/// Return: `None` for key codes with no `KeyAction` equivalent (e.g. -/// media keys), which are silently dropped. -pub fn key_code_to_action(code: KeyCode) -> Option { - tracing::debug!("converting key code to action: {:?}", code); - match code { - KeyCode::Char(c) => Some(KeyAction::Char(c)), - KeyCode::Enter => Some(KeyAction::Enter), - KeyCode::Esc => Some(KeyAction::Escape), - KeyCode::Backspace => Some(KeyAction::Backspace), - KeyCode::Delete => Some(KeyAction::Delete), - KeyCode::Tab => Some(KeyAction::Tab), - KeyCode::Up => Some(KeyAction::Up), - KeyCode::Down => Some(KeyAction::Down), - KeyCode::Left => Some(KeyAction::Left), - KeyCode::Right => Some(KeyAction::Right), - KeyCode::Home => Some(KeyAction::Home), - KeyCode::End => Some(KeyAction::End), - KeyCode::PageUp => Some(KeyAction::PageUp), - KeyCode::PageDown => Some(KeyAction::PageDown), - KeyCode::F(n) => Some(KeyAction::Function(n)), - _ => None, - } -} - -/// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode` -/// from a `KeyAction` received over IPC, for replaying it into the -/// daemon's normal key-handling path. -pub fn key_action_to_code(action: &KeyAction) -> KeyCode { - tracing::debug!("converting key action to code: {:?}", action); - match action { - KeyAction::Char(c) => KeyCode::Char(*c), - KeyAction::Enter => KeyCode::Enter, - KeyAction::Escape => KeyCode::Esc, - KeyAction::Backspace => KeyCode::Backspace, - KeyAction::Delete => KeyCode::Delete, - KeyAction::Tab => KeyCode::Tab, - KeyAction::Up => KeyCode::Up, - KeyAction::Down => KeyCode::Down, - KeyAction::Left => KeyCode::Left, - KeyAction::Right => KeyCode::Right, - KeyAction::Home => KeyCode::Home, - KeyAction::End => KeyCode::End, - KeyAction::PageUp => KeyCode::PageUp, - KeyAction::PageDown => KeyCode::PageDown, - KeyAction::Function(n) => KeyCode::F(*n), - } -} diff --git a/apps/interfaces/daemon/src/lib.rs b/apps/interfaces/daemon/src/lib.rs deleted file mode 100644 index 258d027..0000000 --- a/apps/interfaces/daemon/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! # Daemon Interface -//! -//! Background process that owns agent state and communicates with TUI -//! clients over a Unix-socket IPC protocol. -//! -//! ## Architecture -//! -//! ```text -//! src/ -//! ├── lib.rs — Crate root, module declarations, re-exports -//! ├── server.rs — Daemon server: session creation, socket bind, client loop -//! ├── client.rs — IPC client for attaching to daemon (moved from attach mode) -//! ├── key_code.rs — KeyCode <-> KeyAction conversions -//! ├── state.rs — AppStateRest, DaemonState, supporting types, create_session -//! └── handler.rs — Action, apply_action, handle_key, IPC request handler -//! ``` -//! -//! ## Modes -//! -//! - **Server** (`run_daemon`): creates a session, binds a Unix socket, accepts -//! incoming clients, and processes their IPC `ClientRequest`s. -//! - **Client** (`run_attach`): connects to a running daemon over its Unix socket, -//! enters raw TUI mode, forwards keystrokes, and renders state updates. - -pub mod client; -pub mod handler; -pub mod key_code; -pub mod server; -pub mod state; - -// Re-export key types for convenience. -pub use handler::{apply_action, Action}; -pub use state::{AppStateRest, DaemonState, Overlay}; diff --git a/apps/interfaces/daemon/src/server.rs b/apps/interfaces/daemon/src/server.rs deleted file mode 100644 index 38e7f09..0000000 --- a/apps/interfaces/daemon/src/server.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Daemon server — owns the agent state, listens on a per-session Unix -//! socket, and drives one attached client at a time. -//! -//! Flow: `run_daemon()` creates a session + lock → binds a Unix socket -//! under `/run/.sock` → blocks for a single client to -//! `accept()` → loops reading `ClientRequest`s, translating each into -//! `Action`(s) via the same `handle_key`/`apply_action` path the -//! single-process mode uses, then pushes a full state update back → -//! on `Close` or client disconnect, cleans up the socket file, saves -//! settings, and releases the lock. -//! -//! Why: reuses `crate::handler::handle_key` by synthesising a -//! `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and -//! single-process modes share identical key-handling logic. - -use anyhow::Result; -use zesdex_infrastructure::ipc::server::IpcServer; - -use crate::handler::handle_daemon_client; -use crate::state::create_session; - -/// Run zesdex as a background daemon: owns the agent state, listens on a -/// per-session Unix socket, and drives one attached client. -/// -/// Flow: create session + lock it → bind a Unix socket under -/// `/run/.sock` → block for a single client to -/// `accept()` → loop reading `ClientRequest`s, translating each into -/// `Action`(s) → on `Close` or client disconnect, clean up the socket file, -/// save settings, and release the lock. -pub fn run_daemon() -> Result<()> { - tracing::info!("starting daemon process"); - let (store, _session_lock_guard, mut state, rt) = create_session()?; - let _guard = rt.enter(); - - let run_dir = store.base_dir.join("run"); - std::fs::create_dir_all(&run_dir)?; - let socket_path = run_dir.join(format!("{}.sock", state.session_id)); - let addr = socket_path.to_string_lossy().to_string(); - - let server = IpcServer::bind_unix(&addr)?; - tracing::info!("daemon listening on {addr}"); - - loop { - let conn = match server.accept() { - Ok(c) => c, - Err(e) => { - tracing::error!("daemon accept error: {e}"); - break; - } - }; - tracing::info!("daemon client connected"); - - if let Err(e) = handle_daemon_client(conn, &mut state) { - tracing::error!("daemon error handling client: {e}"); - } - - tracing::info!("daemon client disconnected"); - state.save_settings(); - } - - let _ = std::fs::remove_file(&socket_path); - - Ok(()) -} diff --git a/apps/interfaces/daemon/src/state.rs b/apps/interfaces/daemon/src/state.rs deleted file mode 100644 index 870aa19..0000000 --- a/apps/interfaces/daemon/src/state.rs +++ /dev/null @@ -1,800 +0,0 @@ -//! Daemon state types — `AppStateRest`, `DaemonState`, and all supporting -//! data structures for the background daemon session. -//! -//! `AppStateRest` is the single source-of-truth struct mutated in-place from -//! [`handler::apply_action`](crate::handler::apply_action) and the IPC handler. -//! `DaemonState` wraps it with IPC socket metadata. -//! -//! Also contains [`create_session()`] adapted from the legacy `main.rs`. - -use std::collections::VecDeque; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::{Arc, Mutex}; - -use anyhow::Result; -use tokio::sync::RwLock; - -use zesdex_domain::cms::EditLog; -use zesdex_domain::AppConfigRepository; -use zesdex_domain::EditLogRepository; -use zesdex_domain::Session; -use zesdex_domain::SessionLockRepository; -use zesdex_domain::SessionRepository; -use zesdex_domain::Settings; -use zesdex_domain::SettingsRepository; -use zesdex_infrastructure::mcp::manager::McpManager; -use zesdex_infrastructure::persistence::FileSystemSessionLockRepository; -use zesdex_infrastructure::persistence::JsonAppConfigRepository; -use zesdex_infrastructure::persistence::JsonSettingsRepository; -use zesdex_infrastructure::persistence::JsonlEditLogRepository; -use zesdex_infrastructure::AppConfig; -use zesdex_infrastructure::DirCache; -use zesdex_infrastructure::MentionIndex; -use zesdex_infrastructure::SessionRuntime; -use zesdex_infrastructure::Toast; -use zesdex_infrastructure::ToastKind; -use zesdex_infrastructure::TurnEvent; - -// --------------------------------------------------------------------------- -// Supporting types -// --------------------------------------------------------------------------- - -/// A single transcript entry rendered in the TUI chat pane. -#[derive(Debug, Clone, PartialEq)] -pub struct ChatMessageDisplay { - /// Message author: User, Assistant, System, or Tool. - pub role: RoleWrapper, - /// Rendered text content (plain text, no markdown). - pub content: String, - /// Millisecond timestamp when this display entry was created. - pub timestamp: i64, -} - -/// Simple string-backed role wrapper for transcript display (avoids a direct -/// dependency on the domain's `Role` enum which may not round-trip all wire -/// strings). -#[derive(Debug, Clone, PartialEq)] -pub enum RoleWrapper { - User, - Assistant, - System, - Tool, -} - -impl ChatMessageDisplay { - /// Build a display entry, stamping it with the current time. - pub fn new(role: RoleWrapper, content: String) -> Self { - tracing::debug!( - "ChatMessageDisplay::new — role={:?}, content_len={}", - role, - content.len() - ); - ChatMessageDisplay { - role, - content, - timestamp: chrono::Utc::now().timestamp_millis(), - } - } -} - -/// Which modal overlay, if any, is currently shown over the main TUI view. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Overlay { - /// No overlay; the main chat view is shown. - None, - /// Key bindings help screen. - Help, - /// Settings/configuration panel. - Settings, - /// Background bash job viewer. - Bash, - /// "Are you sure you want to quit?" confirmation. - QuitConfirm, - /// Raw key-code input capture (for binding custom keys). - KeyInput, - /// Inline editor (opened via `/edit`). - Editor, - /// Reasoning effort level selector. - Effort, - /// MCP server management panel. - Mcp, - /// Task list overlay. - Todo, - /// Session rewind / history scrubber. - Rewind, - /// Learning / lesson management panel. - Learning, - /// Token usage statistics panel. - Usage, - /// Generic loading spinner overlay. - Loading, - /// Model selector dropdown. - ModelSelector, - /// "Clear conversation?" confirmation (distinct from QuitConfirm). - ClearConfirm, -} - -impl Overlay { - /// Human-readable name for this overlay variant. - pub fn as_str(self) -> &'static str { - match self { - Overlay::None => "none", - Overlay::Help => "help", - Overlay::Settings => "settings", - Overlay::Bash => "bash", - Overlay::QuitConfirm => "quit_confirm", - Overlay::KeyInput => "key_input", - Overlay::Editor => "editor", - Overlay::Effort => "effort", - Overlay::Mcp => "mcp", - Overlay::Todo => "todo", - Overlay::Rewind => "rewind", - Overlay::Learning => "learning", - Overlay::Usage => "usage", - Overlay::Loading => "loading", - Overlay::ModelSelector => "model_selector", - Overlay::ClearConfirm => "clear_confirm", - } - } - - /// Whether any overlay (i.e. anything other than `None`) is active. - pub fn is_active(self) -> bool { - !matches!(self, Overlay::None) - } -} - -impl std::fmt::Display for Overlay { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Bounded ring of recent chat messages used to render the transcript view. -#[derive(Debug, Clone)] -pub struct TranscriptCache { - /// Ordered display messages (newest appended, oldest evicted when full). - pub messages: Vec, - /// Maximum messages to retain before evicting the oldest. - pub max_lines: usize, - /// Whether the cache has changed since the last render sweep. - pub dirty: bool, -} - -impl TranscriptCache { - /// Create an empty transcript cache holding at most `max_lines` messages. - pub fn new(max_lines: usize) -> Self { - TranscriptCache { - messages: Vec::new(), - max_lines, - dirty: true, - } - } -} - -/// Which source populated the autocomplete dropdown. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutocompleteKind { - /// Builtin slash-command (e.g. `/model`, `/help`). - Command, - /// `@file` mention from the workspace file index. - FileMention, -} - -/// The user's input buffer, cursor position, history, and autocomplete state. -#[derive(Debug, Clone)] -pub struct InputState { - /// Raw UTF-8 input buffer content. - pub buffer: String, - /// Byte offset of the cursor within `buffer`. - pub cursor: usize, - /// Previously submitted input lines, oldest-first. - pub history: Vec, - /// Index into `history` when browsing (None = at the current input). - pub history_idx: Option, - /// The prefix string used to filter candidates for autocomplete. - pub autocomplete_prefix: String, - /// Current autocomplete candidate list. - pub autocomplete_candidates: Vec, - /// Focused index within `autocomplete_candidates`. - pub autocomplete_idx: usize, - /// Whether the autocomplete dropdown is visible. - pub autocomplete_visible: bool, - /// Which kind of autocomplete is active. - pub autocomplete_kind: AutocompleteKind, - /// Byte offset of the `@` character that triggered file mention autocomplete. - pub mention_start: usize, - /// Optional path to a persistent history file. - pub history_file: Option, -} - -impl InputState { - /// Create an empty input state with no buffer, no history, and no autocomplete. - pub fn new() -> Self { - InputState { - buffer: String::new(), - cursor: 0, - history: Vec::new(), - history_idx: None, - autocomplete_prefix: String::new(), - autocomplete_candidates: Vec::new(), - autocomplete_idx: 0, - autocomplete_visible: false, - autocomplete_kind: AutocompleteKind::Command, - mention_start: 0, - history_file: None, - } - } - - /// Close the autocomplete dropdown. - pub fn close_autocomplete(&mut self) { - self.autocomplete_visible = false; - self.autocomplete_candidates.clear(); - self.autocomplete_prefix.clear(); - } - - /// Open the command-autocomplete dropdown. - pub fn open_autocomplete(&mut self) { - self.autocomplete_kind = AutocompleteKind::Command; - self.autocomplete_visible = true; - } -} - -impl Default for InputState { - fn default() -> Self { - Self::new() - } -} - -/// Viewport scroll state: current offset and visible-line count. -#[derive(Debug, Clone)] -pub struct ScrollState { - /// Current scroll offset (how many lines have been scrolled past). - pub offset: usize, - /// Maximum number of lines that fit in the visible viewport area. - pub max_visible: usize, -} - -impl ScrollState { - /// Create a `ScrollState` with zero offset and 30 rows visible. - pub fn new() -> Self { - ScrollState { - offset: 0, - max_visible: 30, - } - } - - /// Scroll the viewport up by `amount` lines (increasing the offset). - pub fn scroll_up(&mut self, amount: usize) { - self.offset = self.offset.saturating_add(amount); - } - - /// Scroll the viewport down by `amount` lines (decreasing the offset). - pub fn scroll_down(&mut self, amount: usize) { - self.offset = self.offset.saturating_sub(amount); - } - - /// Update the maximum number of visible lines in the viewport. - pub fn set_max_visible(&mut self, max: usize) { - self.max_visible = max; - } -} - -impl Default for ScrollState { - fn default() -> Self { - Self::new() - } -} - -/// The "miscellaneous" slice of app state: which overlay is showing, -/// toasts, thinking flags, editor state, and tick. -#[derive(Debug, Clone)] -pub struct MiscState { - /// Currently active modal overlay (None = main chat view). - pub overlay: Overlay, - /// Active toast notifications (expired ones removed on each tick). - pub toasts: Vec, - /// Whether the agent is currently "thinking". - pub thinking: bool, - /// Current LLM reasoning effort level (1-5). - pub effort_level: usize, - /// Whether the API connection is established. - pub api_connected: bool, - /// Currently focused index in list-type overlays. - pub selected_index: usize, - /// Monotonically increasing tick count, incremented each render frame. - pub tick_count: u64, - /// Cached content of the TODO file, shown in the overlay. - pub todo_content: String, - /// Whether a lesson background task is currently running. - pub lesson_running: bool, - /// Text waiting to be written to the system clipboard. - pub pending_clipboard_copy: Option, - /// Inline editor state, if the editor overlay is active. - pub editor: Option, -} - -impl MiscState { - /// Create a fresh `MiscState` with no overlay, no toasts, and default effort level 1. - pub fn new() -> Self { - MiscState { - overlay: Overlay::None, - toasts: Vec::new(), - thinking: false, - effort_level: 1, - api_connected: false, - selected_index: 0, - tick_count: 0, - todo_content: String::new(), - lesson_running: false, - pending_clipboard_copy: None, - editor: None, - } - } - - /// Append a toast notification to the active list. - pub fn push_toast(&mut self, toast: Toast) { - self.toasts.push(toast); - } - - /// Remove and return all toasts whose lifetime has expired at `now_ms`. - pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { - let expired: Vec<_> = self - .toasts - .iter() - .filter(|t| t.expired(now_ms)) - .cloned() - .collect(); - self.toasts.retain(|t| !t.expired(now_ms)); - expired - } -} - -impl Default for MiscState { - fn default() -> Self { - Self::new() - } -} - -/// State of a single workflow agent. -#[derive(Debug, Clone, Default)] -pub struct AgentState { - pub id: String, - pub status: String, - pub current_tool: String, -} - -/// Workflow engine state tracking agents in hive-mind orchestration. -#[derive(Debug, Clone, Default)] -pub struct WorkflowEngine { - /// List of running workflow agent states. - pub agents: Vec, -} - -impl WorkflowEngine { - /// Create an empty workflow engine. - pub fn new() -> Self { - Self { agents: Vec::new() } - } -} - -/// Simple inline editor state for the TUI. -#[derive(Debug, Clone)] -pub struct EditorState { - /// Path to the file being edited. - pub path: PathBuf, - /// Current buffer content. - pub content: String, - /// Cursor position (byte offset). - pub cursor: usize, -} - -impl EditorState { - /// Create a new editor state for the given path. - pub fn new(path: PathBuf, content: String) -> Self { - let cursor = content.len(); - EditorState { - path, - content, - cursor, - } - } - - /// Return the full buffer content. - pub fn as_string(&self) -> String { - self.content.clone() - } -} - -// --------------------------------------------------------------------------- -// AppStateRest — single source-of-truth application state -// --------------------------------------------------------------------------- - -/// The single source-of-truth state struct for the daemon. -/// -/// Mutated in-place from two locations: `handler::apply_action` -/// and the IPC client handler in `handler::handle_daemon_client`. -/// Read-only from every other module. -#[derive(Clone)] -pub struct AppStateRest { - /// Persistent user settings (loaded from JSON store at startup). - pub settings: Settings, - /// Per-project app configuration (loaded from JSON store at startup). - pub app_config: AppConfig, - /// Absolute paths to each open workspace root directory. - pub workspace_roots: Vec, - /// Unique session identifier. - pub session_id: String, - /// Path to the session's data directory. - pub session_dir: PathBuf, - /// Path to the session memory directory (lessons, review history). - pub memory_dir: PathBuf, - /// Path to the git worktrees directory (for sandboxed agent experiments). - pub worktrees_dir: PathBuf, - /// Shared async cache of directory listings. - pub dir_cache: Arc>, - /// Shared workspace file-path index for `@file` mention autocomplete. - pub mention_index: MentionIndex, - /// Persistent edit history log (appended on every tool write). - pub edit_log: EditLog, - /// Optional per-session runtime state. - pub session_runtime: Option, - /// Active IAM sessions linked to this app instance. - pub sessions: Vec, - /// Ring buffer of recent chat messages for the TUI transcript pane. - pub transcript_cache: TranscriptCache, - /// Viewport scroll offset tracker. - pub scroll: ScrollState, - /// Chat input buffer, cursor, history, and autocomplete. - pub input: InputState, - /// Miscellaneous state: overlay, toasts, flags, tick. - pub misc: MiscState, - /// Queue of events emitted by the running agent turn. - pub turn_events: Arc>>, - /// Whether an agent turn is currently in flight. - pub turn_in_flight: Arc>, - /// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape). - pub abort_flag: Arc, - /// Workflow engine state for multi-agent hive-mind orchestration. - pub workflow_engine: WorkflowEngine, - /// MCP server manager. - pub mcp_manager: McpManager, - /// Whether the state has been modified since the last render sweep. - pub dirty: bool, - /// Whether the application has been requested to quit. - pub quit: bool, -} - -impl AppStateRest { - /// Construct the initial application state for a session. - /// - /// Flow: load settings/config → derive download/worktree dirs from - /// `memory_dir`'s parent → derive `session_id` from the session dir's - /// file name → build the sub-state structs. - /// - /// Why: falls back to `memory_dir` itself (with a warning) when it has - /// no parent, and to an empty session id when the dir name can't be - /// read, so construction never fails. - pub fn new( - workspace_roots: Vec, - session_dir: &std::path::Path, - memory_dir: PathBuf, - ) -> Self { - let store_base_dir = zesdex_infrastructure::Store::new().base_dir; - let settings = JsonSettingsRepository::new() - .load(&store_base_dir) - .unwrap_or_default(); - let app_config = JsonAppConfigRepository::new() - .load(&store_base_dir) - .unwrap_or_default(); - let worktrees_dir = memory_dir - .parent() - .unwrap_or_else(|| { - tracing::warn!( - "[state] memory_dir '{}' has no parent, using it for worktrees", - memory_dir.display() - ); - &memory_dir - }) - .join("worktrees"); - let dir_cache = DirCache::new(); - let session_id = session_dir.file_name().map_or_else( - || { - tracing::warn!( - "[state] session_dir has no file_name component, using empty session_id" - ); - String::new() - }, - |n| n.to_string_lossy().to_string(), - ); - let mut state = AppStateRest { - settings, - app_config, - workspace_roots, - session_id, - session_dir: session_dir.to_path_buf(), - memory_dir: memory_dir.clone(), - worktrees_dir, - turn_events: Arc::new(Mutex::new(VecDeque::new())), - turn_in_flight: Arc::new(Mutex::new(false)), - abort_flag: Arc::new(AtomicBool::new(false)), - dir_cache: Arc::new(RwLock::new(dir_cache)), - mention_index: MentionIndex::new(), - edit_log: JsonlEditLogRepository::new() - .open(session_dir) - .unwrap_or_else(|e| { - tracing::warn!( - "[state] failed to open edit log at '{}': {e}", - session_dir.display() - ); - EditLog::new() - }), - session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())), - workflow_engine: WorkflowEngine::new(), - mcp_manager: McpManager::new(), - sessions: Vec::new(), - transcript_cache: TranscriptCache::new(200), - scroll: ScrollState::new(), - input: InputState::new(), - misc: MiscState::new(), - dirty: true, - quit: false, - }; - - // Load project-specific input-line history from a file keyed by - // the first workspace root's SHA256 hash. - let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir); - if let Some(root) = state.workspace_roots.first() { - if let Ok(abs_root) = std::fs::canonicalize(root) { - use sha2::Digest; - let mut hasher = sha2::Sha256::new(); - hasher.update(abs_root.to_string_lossy().as_bytes()); - let hash_hex = hex::encode(hasher.finalize()); - let folder_name = abs_root - .file_name() - .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); - let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); - let history_dir = base_dir.join("history"); - let _ = std::fs::create_dir_all(&history_dir); - let history_file = history_dir.join(history_filename); - - if let Ok(content) = std::fs::read_to_string(&history_file) { - let history: Vec = content - .lines() - .map(std::string::ToString::to_string) - .filter(|s| !s.is_empty()) - .collect(); - state.input.history = history; - } - state.input.history_file = Some(history_file); - } - } - - state - } - - /// Spawn the background thread that walks every workspace root and - /// populates `mention_index` for `@file` mention autocomplete. - /// - /// Callers that DO need the index (single-process mode, the daemon) - /// call this explicitly after construction. - pub fn spawn_mention_index_build(&self) { - let mention_index = self.mention_index.clone(); - let roots = self.workspace_roots.clone(); - std::thread::spawn(move || { - const MAX_MENTION_ENTRIES: usize = 50_000; - let mut paths = Vec::new(); - 'roots: for (i, root) in roots.iter().enumerate() { - for entry in ignore::Walk::new(root).flatten() { - if !entry.path().is_file() { - continue; - } - let rel = entry.path().strip_prefix(root).unwrap_or(entry.path()); - let rel_str = rel.display().to_string(); - let formatted = if i == 0 { - rel_str - } else { - format!("[{i}]{rel_str}") - }; - paths.push(formatted); - if paths.len() >= MAX_MENTION_ENTRIES { - break 'roots; - } - } - } - mention_index.set(paths); - }); - } - - /// Whether an agent turn is currently running. - pub fn turn_in_flight(&self) -> bool { - self.turn_in_flight.lock().map_or_else( - |_| { - tracing::warn!("[state] turn_in_flight mutex poisoned"); - false - }, - |g| *g, - ) - } - - /// Append a message to the transcript, evicting the oldest entry once - /// `max_lines` is exceeded. - pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { - self.transcript_cache.messages.push(msg); - if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { - self.transcript_cache.messages.remove(0); - } - self.transcript_cache.dirty = true; - self.dirty = true; - } - - /// Mark the app state as dirty, triggering a TUI re-render on the next frame. - pub fn mark_dirty(&mut self) { - self.dirty = true; - } - - /// Queue a toast notification for display and mark the app dirty. - pub fn push_toast(&mut self, toast: Toast) { - self.misc.push_toast(toast); - self.mark_dirty(); - } - - /// Push an info toast with the given message. - pub fn toast_info(&mut self, msg: impl Into) { - self.push_toast(Toast::new(ToastKind::Info, msg.into())); - } - - /// Push a success toast with the given message. - pub fn toast_success(&mut self, msg: impl Into) { - self.push_toast(Toast::new(ToastKind::Success, msg.into())); - } - - /// Push a warning toast with the given message. - pub fn toast_warning(&mut self, msg: impl Into) { - self.push_toast(Toast::new(ToastKind::Warning, msg.into())); - } - - /// Push an error toast with the given message. - pub fn toast_error(&mut self, msg: impl Into) { - self.push_toast(Toast::new(ToastKind::Error, msg.into())); - } - - /// Resolve the base directory that stores this session (grandparent of - /// `session_dir`). - pub fn store_base_dir(&self) -> PathBuf { - self.session_dir - .parent() - .and_then(|p| p.parent()) - .map_or_else( - || { - tracing::warn!( - "[state] session_dir '{}' has no grandparent, using parent", - self.session_dir.display() - ); - self.session_dir.parent().map_or_else( - || { - tracing::warn!( - "[state] session_dir '{}' has no parent at all, using itself", - self.session_dir.display() - ); - self.session_dir.clone() - }, - std::path::Path::to_path_buf, - ) - }, - std::path::Path::to_path_buf, - ) - } - - /// Persist the current settings to the store and swallow any error. - pub fn save_settings(&self) { - let _ = JsonSettingsRepository::new().save(&self.store_base_dir(), &self.settings); - } -} - -// --------------------------------------------------------------------------- -// SessionLockGuard — RAII guard that releases a session lock on drop -// --------------------------------------------------------------------------- - -/// RAII guard that releases a session lock on drop. -pub struct SessionLockGuard { - lock_repo: FileSystemSessionLockRepository, - session_dir: PathBuf, -} - -impl SessionLockGuard { - /// Create a new guard. Caller must have already acquired the lock. - pub fn new(lock_repo: FileSystemSessionLockRepository, session_dir: PathBuf) -> Self { - tracing::debug!("acquired session lock for {:?}", session_dir); - Self { - lock_repo, - session_dir, - } - } -} - -impl Drop for SessionLockGuard { - fn drop(&mut self) { - tracing::debug!("releasing session lock for {:?}", self.session_dir); - let _ = self.lock_repo.unlock(&self.session_dir); - } -} - -// --------------------------------------------------------------------------- -// DaemonState — wraps AppStateRest with IPC socket metadata -// --------------------------------------------------------------------------- - -/// The daemon's overall state: owns the application state and the IPC socket -/// metadata for client connections. -pub struct DaemonState { - /// The canonical application state for this daemon session. - pub app_state: AppStateRest, - /// The daemon session's unique identifier (same as `app_state.session_id`). - pub session_id: String, - /// Path to the bound Unix socket, if any. - pub socket_path: Option, -} - -impl DaemonState { - /// Wrap an `AppStateRest` into a `DaemonState`. - pub fn new(app_state: AppStateRest) -> Self { - let session_id = app_state.session_id.clone(); - DaemonState { - app_state, - session_id, - socket_path: None, - } - } - - /// Set the socket path after binding. - pub fn set_socket_path(&mut self, path: String) { - self.socket_path = Some(path); - } -} - -// --------------------------------------------------------------------------- -// Session creation -// --------------------------------------------------------------------------- - -/// Create a new daemon session: store, session directory, exclusive lock, -/// application state, and tokio runtime. -/// -/// Flow: create the store → create a new session directory → attempt an -/// exclusive lock → build `AppStateRest` → spawn mention-index builder → -/// load session list → start a tokio runtime. -/// -/// Return: (store, lock guard, app_state, tokio_runtime). -pub fn create_session() -> Result<( - zesdex_infrastructure::Store, - SessionLockGuard, - AppStateRest, - tokio::runtime::Runtime, -)> { - tracing::info!("creating new daemon session"); - let store = zesdex_infrastructure::Store::new(); - store.ensure_dirs()?; - - let session_id = uuid::Uuid::new_v4().to_string(); - let session_dir = store.base_dir.join("sessions").join(&session_id); - std::fs::create_dir_all(&session_dir)?; - - let lock_repo = FileSystemSessionLockRepository::new(); - if !lock_repo.try_lock(&session_dir)? { - anyhow::bail!( - "session already active (another zesdex process holds the lock for this session directory)" - ); - } - let session_lock_guard = SessionLockGuard::new(lock_repo, session_dir.clone()); - - let workspace_roots = vec![std::env::current_dir()?]; - let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone()); - state.spawn_mention_index_build(); - let session_repo = zesdex_infrastructure::persistence::FileSystemSessionRepository::new(); - state.sessions = session_repo - .list_sessions(&store.base_dir) - .unwrap_or_default(); - - let rt = tokio::runtime::Runtime::new()?; - - Ok((store, session_lock_guard, state, rt)) -} diff --git a/apps/interfaces/grpc/src/lib.rs b/apps/interfaces/grpc/src/lib.rs deleted file mode 100644 index 3f94af7..0000000 --- a/apps/interfaces/grpc/src/lib.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! gRPC interface — high-performance RPC with protobuf. -//! -//! Uses tonic + prost for gRPC code generation. To enable: -//! 1. Add `tonic` and `prost` to Cargo.toml -//! 2. Create proto/ directory with service definitions -//! 3. Generate code via build.rs -//! 4. Implement the generated service traits -//! -//! Example service: -//! ```protobuf -//! service Zesdex { -//! rpc Chat(ChatRequest) returns (ChatResponse); -//! rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); -//! rpc StreamChat(ChatRequest) returns (stream ChatResponse); -//! } -//! ``` -//! -//! For now, this crate provides a minimal HTTP health-check endpoint -//! so consumers can verify the gRPC server is reachable. - -use axum::routing::get; -use axum::Router; -use std::net::SocketAddr; -use std::sync::Arc; -use tracing::info; - -/// gRPC server state (minimal for health checks). -pub struct GrpcState { - pub version: String, -} - -/// Build the gRPC server router. -pub fn build_router(state: Arc) -> Router { - Router::new() - .route("/grpc/health", get(health_check)) - .with_state(state) -} - -/// Health check endpoint. -async fn health_check( - axum::extract::State(_state): axum::extract::State>, -) -> &'static str { - "gRPC server is running" -} - -/// Run the gRPC server (currently HTTP health only; replace with tonic when ready). -pub async fn run_server(port: u16) -> anyhow::Result<()> { - let state = Arc::new(GrpcState { - version: env!("CARGO_PKG_VERSION").to_string(), - }); - let app = build_router(state); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); - info!("gRPC server listening on {addr}"); - info!("Note: gRPC currently runs HTTP health endpoint. Add tonic+prost for full gRPC."); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - Ok(()) -} diff --git a/apps/interfaces/tui/src/action/git.rs b/apps/interfaces/tui/src/action/git.rs deleted file mode 100644 index 8f18277..0000000 --- a/apps/interfaces/tui/src/action/git.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Git diff output helper for the diff overlay. -//! -//! Extracted to its own module so the action dispatcher stays focused on -//! state mutation logic rather than shell-out I/O. - -use std::process::Command; - -/// Run `git diff` and return the output for display in the diff overlay. -/// -/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back -/// to `git diff` if HEAD has no commits yet. Returns a summary of what -/// changed with coloured +/- markers. -pub fn git_diff_output(workspace_roots: &[std::path::PathBuf]) -> String { - let root = workspace_roots - .first() - .cloned() - .unwrap_or_else(|| std::path::PathBuf::from(".")); - - let mut output = String::new(); - - // Try diff against HEAD - let head_result = Command::new("git") - .args(["diff", "HEAD"]) - .current_dir(&root) - .output(); - - match head_result { - Ok(out) => { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - output.push_str("=== Changes (against HEAD) ===\n"); - output.push_str(&stdout); - output.push('\n'); - } - } - Err(_) => { - // Fallback: no HEAD yet (new repo) - let diff_result = Command::new("git") - .args(["diff"]) - .current_dir(&root) - .output(); - if let Ok(out) = diff_result { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - output.push_str("=== Unstaged Changes ===\n"); - output.push_str(&stdout); - output.push('\n'); - } - } - } - } - - // Get staged changes too - let staged_result = Command::new("git") - .args(["diff", "--cached"]) - .current_dir(&root) - .output(); - - if let Ok(out) = staged_result { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - output.push_str("=== Staged Changes ===\n"); - output.push_str(&stdout); - output.push('\n'); - } - } - - // Also get status summary - let status_result = Command::new("git") - .args(["status", "--short"]) - .current_dir(&root) - .output(); - - if let Ok(out) = status_result { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - output.push_str("=== Summary ===\n"); - output.push_str(&stdout); - output.push('\n'); - } - } - - if output.is_empty() { - output = "No changes detected in the working tree.".to_string(); - } - - output -} diff --git a/apps/interfaces/tui/src/action/handlers.rs b/apps/interfaces/tui/src/action/handlers.rs deleted file mode 100644 index 539c3bf..0000000 --- a/apps/interfaces/tui/src/action/handlers.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Extracted action handlers for complex `Action` variants. -//! -//! The `apply_action` dispatcher delegates to these functions for variants -//! whose logic exceeds a few lines, keeping the match statement compact -//! and readable. - -use crate::state::{AppStateRest, ChatMessageDisplay}; -use tracing::debug; -use zesdex_domain::core::Role; - -// --------------------------------------------------------------------------- -// Tick handler — drains turn events from the shared queue -// --------------------------------------------------------------------------- - -/// Process the `Action::Tick` event: increment tick counter, drain all -/// queued `TurnEvent` values from the shared queue, and apply each one -/// to the application state. -pub fn handle_tick(state: &mut AppStateRest) { - state.misc.tick_count = state.misc.tick_count.wrapping_add(1); - - // Drain turn events — collect into a local Vec first to avoid holding - // the mutex guard across the entire dispatch loop. - let events: Vec = state - .turn_events - .lock() - .map(|mut q| q.drain(..).collect()) - .unwrap_or_default(); - - let had_events = !events.is_empty(); - - for event in events { - apply_turn_event(state, event); - } - - // Mark dirty when there were events OR a turn is in-flight (spinner - // animation); skip the flag when idle to avoid useless re-renders. - if had_events || state.turn_in_flight() { - state.mark_dirty(); - } -} - -// --------------------------------------------------------------------------- -// TurnEvent dispatcher -// --------------------------------------------------------------------------- - -/// Apply a single `TurnEvent` to `AppStateRest`. Extracted from the -/// `Tick` handler so each event variant's logic is self-contained. -fn apply_turn_event(state: &mut AppStateRest, event: zesdex_infrastructure::TurnEvent) { - match event { - zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => { - if kind == "hive_mind_converged" { - if let Some(ref mut rt) = state.session_runtime { - rt.hive_mind_converged = true; - } - } else { - state.push_transcript(ChatMessageDisplay::new(Role::System, message)); - } - } - zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => { - state.push_transcript(ChatMessageDisplay::new( - msg.role, - msg.content.unwrap_or_default(), - )); - } - zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => { - state.push_transcript(ChatMessageDisplay::new(Role::Tool, output)); - } - zesdex_infrastructure::TurnEvent::Usage { - tokens_in, - tokens_out, - } => { - if let Some(ref mut rt) = state.session_runtime { - rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in); - rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out); - rt.usage.last_tokens_in = tokens_in; - rt.usage.last_tokens_out = tokens_out; - rt.usage.api_calls = rt.usage.api_calls.saturating_add(1); - } - } - zesdex_infrastructure::TurnEvent::Error(msg) => { - state.toast_error(msg); - } - zesdex_infrastructure::TurnEvent::StreamStart => { - state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new())); - } - zesdex_infrastructure::TurnEvent::StreamToken(text) => { - state.append_to_last_transcript(&text, false); - } - zesdex_infrastructure::TurnEvent::StreamReasoning(text) => { - state.append_to_last_transcript(&text, true); - } - zesdex_infrastructure::TurnEvent::StreamDone(_msg) => { - state.dirty = true; - } - zesdex_infrastructure::TurnEvent::Compacted(msgs) => { - if let Some(ref mut rt) = state.session_runtime { - rt.messages = msgs; - } - } - zesdex_infrastructure::TurnEvent::TodoUpdate(content) => { - state.misc.todo_content = content; - state.toast_success("TODO list updated."); - } - zesdex_infrastructure::TurnEvent::PlanUpdate(content) => { - state.misc.plan_content = content; - state.toast_success("Project plan updated."); - } - zesdex_infrastructure::TurnEvent::Done => { - state - .turn_in_flight_flag - .store(false, std::sync::atomic::Ordering::SeqCst); - state.dirty = true; - } - zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status, - } => { - apply_workflow_update(state, agent_id, agent_name, status); - } - _ => { - debug!("unhandled turn event variant"); - state.dirty = true; - } - } -} - -// --------------------------------------------------------------------------- -// Workflow-agent update handler -// --------------------------------------------------------------------------- - -/// Update the workflow sidebar state from a `WorkflowAgentUpdate` event. -fn apply_workflow_update( - state: &mut AppStateRest, - agent_id: String, - agent_name: String, - status: zesdex_infrastructure::AgentStatus, -) { - let idx = state - .workflow_engine - .agents - .iter() - .position(|a| a.name == agent_id); - - match status { - zesdex_infrastructure::AgentStatus::Pending => { - if idx.is_none() { - state - .workflow_engine - .agents - .push(crate::state::SimpleAgent::with_display( - agent_id, agent_name, - )); - } - } - zesdex_infrastructure::AgentStatus::Running => { - let now = chrono::Utc::now().timestamp_millis(); - if let Some(i) = idx { - state.workflow_engine.agents[i].state = crate::state::AgentState::Running; - state.workflow_engine.agents[i].display_name = agent_name; - state.workflow_engine.agents[i].started_at = Some(now); - } else { - let mut agent = crate::state::SimpleAgent::with_display(agent_id, agent_name); - agent.state = crate::state::AgentState::Running; - agent.started_at = Some(now); - state.workflow_engine.agents.push(agent); - } - } - zesdex_infrastructure::AgentStatus::Completed => { - if let Some(i) = idx { - state.workflow_engine.agents[i].state = crate::state::AgentState::Completed; - state.workflow_engine.agents[i].display_name = agent_name; - state.workflow_engine.agents[i].completed_at = - Some(chrono::Utc::now().timestamp_millis()); - } - } - zesdex_infrastructure::AgentStatus::Failed(msg) => { - if let Some(i) = idx { - state.workflow_engine.agents[i].state = crate::state::AgentState::Failed; - state.workflow_engine.agents[i].display_name = agent_name; - state.workflow_engine.agents[i].error = Some(msg); - } - } - zesdex_infrastructure::AgentStatus::Cancelled => { - if let Some(i) = idx { - state.workflow_engine.agents.remove(i); - } - } - } - state.dirty = true; -} diff --git a/apps/interfaces/tui/src/action/mod.rs b/apps/interfaces/tui/src/action/mod.rs deleted file mode 100644 index 476ef8f..0000000 --- a/apps/interfaces/tui/src/action/mod.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! The `Action` enum — a single well-typed event in the TUI, produced by -//! key input and applied to `AppStateRest` by the event loop. -//! -//! # Flow -//! `controller::input::handle_key` returns `Vec` \u{2192} the event loop -//! calls `apply_action(&mut state, action)` for each one \u{2192} state is mutated -//! in place. -//! -//! # Organisation -//! -//! ```text -//! action/ -//! ├── mod.rs — Action enum + apply_action dispatcher -//! ├── handlers.rs — Extracted handlers for complex action variants -//! └── git.rs — git diff output helper (shell-out I/O) -//! ``` -//! -//! # Design -//! Every state mutation funnels through this single chokepoint so the view -//! layer never mutates state directly and the controller never needs to know -//! *how* state is updated \u{2014} only *what* action to produce. - -mod git; -mod handlers; - -use tracing::debug; - -use crate::state::Overlay; -use crate::state::{AppStateRest, ChatMessageDisplay}; -use zesdex_domain::core::Role; - -/// A single well-typed event in the TUI that mutates `AppStateRest`. -#[derive(Debug, Clone)] -pub enum Action { - /// Hard exit — immediately terminates the process. - ForceQuit, - /// Submit a user message to the LLM, starting a new agent turn. - SubmitInput(String), - /// Delete one character before the cursor in the input buffer. - DeleteChar, - /// Delete one character after the cursor in the input buffer. - DeleteCharRight, - /// Move the cursor one position left in the input buffer. - CursorLeft, - /// Move the cursor one position right in the input buffer. - CursorRight, - /// Navigate up through command history. - HistoryUp, - /// Navigate down through command history. - HistoryDown, - /// Scroll the transcript pane up. - ScrollUp, - /// Scroll the transcript pane down. - ScrollDown, - /// Open a named overlay. - OpenOverlay(Overlay), - /// Close the currently active overlay. - CloseOverlay, - /// Insert a system-generated note into the transcript. - SystemNote { - /// Note category: "error", "info", "clear", "hive_mind_converged", etc. - kind: String, - /// The message text to display. - message: String, - }, - /// Show the quit-confirmation overlay. - QuitConfirm, - /// Terminal resize event. - Resize(u16, u16), - /// Periodic timer tick — drains queued `TurnEvent`s. - Tick, - /// Accept a lesson (learned behaviour pattern) by name. - LessonAccept { name: String }, - /// Reject a lesson by name. - LessonReject { name: String }, - /// Delete a previously stored lesson by name. - LessonDelete { name: String }, - /// Start the OAuth device-code login flow for a named provider. - StartOAuth { provider: String }, - /// Open the inline file editor for `path`. - OpenEditor { path: String }, - /// Register a new MCP server by name and shell command. - McpAdd { name: String, command: String }, - /// Open the model-picker overlay. - ModelList, - /// Set the abort flag on the currently running turn. - AbortTurn, - /// Request AI-summary compaction of the conversation history. - Compact, - /// Show the git diff preview overlay. - ShowDiff, - /// Scroll the diff overlay by `amount` lines (negative = up, positive = down). - DiffScroll(i32), -} - -/// Apply an `Action` to `AppStateRest`. -/// -/// Flow: pattern-match the variant \u{2192} mutate state in place. -/// This is the single chokepoint for all state mutations. -/// -/// Return: nothing; `state` is mutated in place. -#[tracing::instrument(skip(state))] -pub fn apply_action(state: &mut AppStateRest, action: Action) { - debug!("apply_action: {:?}", action); - match action { - // ── Lifecycle ───────────────────────────────────────────────── - Action::ForceQuit => state.quit = true, - Action::QuitConfirm => { - state.misc.overlay = Overlay::QuitConfirm; - state.mark_dirty(); - } - Action::Resize(w, _h) => { - if state.last_render_width != w { - state.transcript_cache.dirty = true; - state.last_render_width = w; - } - state.mark_dirty(); - } - - // ── Timer tick (delegates to extracted handler) ──────────────── - Action::Tick => handlers::handle_tick(state), - - // ── Input ────────────────────────────────────────────────────── - Action::SubmitInput(text) => { - state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone())); - state.input.submit(); - crate::turn::spawn_agent_turn(state, text); - state.token_count_dirty = true; - state.mark_dirty(); - } - Action::DeleteChar => { - state.input.delete_left(); - state.mark_dirty(); - } - Action::DeleteCharRight => { - state.input.delete_right(); - state.mark_dirty(); - } - Action::CursorLeft => { - state.input.cursor = state.input.cursor.saturating_sub(1); - state.mark_dirty(); - } - Action::CursorRight => { - if state.input.cursor < state.input.buffer.len() { - state.input.cursor += 1; - } - state.mark_dirty(); - } - Action::HistoryUp => { - state.input.history_up(); - state.mark_dirty(); - } - Action::HistoryDown => { - state.input.history_down(); - state.mark_dirty(); - } - - // ── Scroll ───────────────────────────────────────────────────── - Action::ScrollUp => { - state.scroll.scroll_up(3); - state.mark_dirty(); - } - Action::ScrollDown => { - state.scroll.scroll_down(3); - state.mark_dirty(); - } - - // ── Overlay ──────────────────────────────────────────────────── - Action::OpenOverlay(overlay) => { - state.misc.overlay = overlay; - state.mark_dirty(); - } - Action::CloseOverlay => { - state.misc.overlay = Overlay::None; - state.mark_dirty(); - } - Action::SystemNote { kind: _, message } => { - state.push_transcript(ChatMessageDisplay::new(Role::System, message)); - } - Action::ModelList => { - state.misc.overlay = Overlay::ModelSelector; - state.mark_dirty(); - } - Action::OpenEditor { path } => { - let content = std::fs::read_to_string(&path).unwrap_or_default(); - state.misc.editor = Some(crate::state::EditorState::new( - std::path::PathBuf::from(&path), - content, - )); - state.misc.overlay = Overlay::Editor; - state.mark_dirty(); - } - - // ── Lessons ──────────────────────────────────────────────────── - Action::LessonAccept { name } => state.toast_info(format!("Lesson accepted: {name}")), - Action::LessonReject { name } => state.toast_info(format!("Lesson rejected: {name}")), - Action::LessonDelete { name } => state.toast_info(format!("Lesson deleted: {name}")), - - // ── Auth ─────────────────────────────────────────────────────── - Action::StartOAuth { provider } => { - state.toast_info(format!("OAuth login started for {provider}")) - } - - // ── MCP ──────────────────────────────────────────────────────── - Action::McpAdd { name, command } => { - state.toast_info(format!("MCP server added: {name} ({command})")) - } - - // ── Turn control ──────────────────────────────────────────────── - Action::AbortTurn => { - state - .abort_flag - .store(true, std::sync::atomic::Ordering::SeqCst); - state.toast_info("Aborting current turn...".to_string()); - } - Action::Compact => state.toast_info("Compacting conversation...".to_string()), - - // ── Diff ─────────────────────────────────────────────────────── - Action::ShowDiff => { - let diff_output = git::git_diff_output(&state.workspace_roots); - state.misc.diff_content = diff_output; - state.misc.diff_scroll = 0; - state.misc.overlay = Overlay::Diff; - state.mark_dirty(); - } - Action::DiffScroll(amount) => { - let max_scroll = state.misc.diff_content.lines().count().saturating_sub(1); - let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize; - state.misc.diff_scroll = new_scroll.min(max_scroll); - state.mark_dirty(); - } - } -} diff --git a/apps/interfaces/tui/src/components/component.rs b/apps/interfaces/tui/src/components/component.rs deleted file mode 100644 index 9fb8867..0000000 --- a/apps/interfaces/tui/src/components/component.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Base trait for all TUI components. - -use crate::action::Action; -use crate::state::AppStateRest; -use crossterm::event::Event; -use ratatui::layout::Rect; -use ratatui::Frame; - -/// Base trait for modular UI components in the TUI. -pub trait Component { - /// Draw the component to the screen. - fn draw(&mut self, f: &mut Frame, area: Rect, state: &AppStateRest); - - /// Optional: Pre-render caching phase called before draw. - fn pre_render(&mut self, _state: &mut AppStateRest) {} - - /// Handle an input event. Return an optional Action to dispatch. - fn handle_event(&mut self, _event: Event, _state: &mut AppStateRest) -> Option { - None - } -} diff --git a/apps/interfaces/tui/src/components/mod.rs b/apps/interfaces/tui/src/components/mod.rs deleted file mode 100644 index 07ec57a..0000000 --- a/apps/interfaces/tui/src/components/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Reusable UI components for the TUI. -//! -//! This module will grow as shared widgets (buttons, input fields, etc.) -//! are extracted from individual overlay and view modules. - -pub mod component; -pub use component::Component; diff --git a/apps/interfaces/tui/src/controller/command.rs b/apps/interfaces/tui/src/controller/command.rs deleted file mode 100644 index 154cd7d..0000000 --- a/apps/interfaces/tui/src/controller/command.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Slash-command parser that maps TUI `/foo` input lines into `Command` -//! variants for the action dispatch system. -//! -//! Flow: the TUI input handler in `controller::input` calls `parse_command` -//! on every `/`-prefixed line, then maps the resulting `Command` to an -//! `Action` for the event loop to apply to `AppStateRest`. - -use tracing::debug; - -/// A parsed slash command from the TUI input buffer. -#[derive(Debug, Clone, PartialEq)] -pub enum Command { - /// `/help` — show keybindings / help overlay. - Help, - /// `/quit` — exit the application. - Quit, - /// `/mcp` (no args) — open MCP configuration panel. - McpOpen, - /// `/clear` (with args) — clear with a specific scope. - Clear, - /// `/clear` (no args) — show confirmation prompt before clearing. - ClearConfirm, - /// `/login ` — trigger OAuth login for the given provider. - Login { provider: String }, - /// `/edit ` — open the given file for review/inline editing. - Edit(String), - /// `/mcp add ` — add a new MCP server definition. - McpAdd { name: String, command: String }, - /// `/model` — list available LLM models. - ModelList, - /// `/compact` — trigger conversation compaction. - Compact, - /// `/todo` — open the todo-list overlay. - TodoOpen, - /// `/plan` — open the project plan overlay. - PlanOpen, - /// `/usage` — open the usage-stats overlay. - UsageOpen, - /// `/diff` — show git diff preview. - Diff, - /// Catch-all: unrecognised or non-slash input. - Unknown(String), -} - -/// Parse a slash-prefixed input line into a `Command` value. -/// -/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) -> -/// match the first token against known commands -> extract arguments from -/// the remaining parts. -#[tracing::instrument] -pub fn parse_command(text: &str) -> Command { - let text = text.trim(); - if !text.starts_with('/') { - return Command::Unknown(text.to_string()); - } - - let parts: Vec<&str> = text.splitn(3, ' ').collect(); - let cmd = parts[0]; - let arg1 = parts.get(1).copied().unwrap_or(""); - let arg2 = parts.get(2).copied().unwrap_or(""); - - let result = match cmd { - "/help" => Command::Help, - "/quit" => Command::Quit, - "/clear" if arg1.is_empty() => Command::ClearConfirm, - "/clear" => Command::Clear, - "/login" if arg1.is_empty() => Command::Login { - provider: String::new(), - }, - "/login" if !arg1.is_empty() => Command::Login { - provider: arg1.to_string(), - }, - "/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()), - "/edit" => Command::Edit(".".to_string()), - "/mcp" if arg1.is_empty() => Command::McpOpen, - "/mcp" if arg1 == "add" && !arg2.is_empty() => { - let rest = arg2.trim(); - if let Some(space) = rest.find(' ') { - let name = rest[..space].to_string(); - let command = rest[space + 1..].trim().to_string(); - Command::McpAdd { name, command } - } else { - Command::McpAdd { - name: rest.to_string(), - command: String::new(), - } - } - } - "/model" => Command::ModelList, - "/compact" => Command::Compact, - "/todo" => Command::TodoOpen, - "/plan" => Command::PlanOpen, - "/usage" => Command::UsageOpen, - "/diff" => Command::Diff, - _ => Command::Unknown(cmd.to_string()), - }; - - debug!(%text, command = ?result, "parse_command"); - result -} - -/// Map a parsed `Command` into `Action` values for the event loop. -#[tracing::instrument] -pub fn apply_command(cmd: Command) -> Vec { - match cmd { - Command::Help => { - vec![crate::action::Action::OpenOverlay( - crate::state::Overlay::Help, - )] - } - Command::Quit => { - vec![crate::action::Action::QuitConfirm] - } - Command::McpOpen => { - vec![crate::action::Action::OpenOverlay( - crate::state::Overlay::Mcp, - )] - } - Command::Clear => { - vec![crate::action::Action::SystemNote { - kind: "clear".to_string(), - message: "Transcript cleared.".to_string(), - }] - } - Command::ClearConfirm => { - vec![crate::action::Action::OpenOverlay( - crate::state::Overlay::ClearConfirm, - )] - } - Command::Login { provider } => { - vec![crate::action::Action::StartOAuth { provider }] - } - Command::Edit(path) => { - vec![crate::action::Action::OpenEditor { path }] - } - Command::McpAdd { name, command } => { - vec![crate::action::Action::McpAdd { name, command }] - } - Command::ModelList => { - vec![crate::action::Action::ModelList] - } - Command::Compact => { - vec![crate::action::Action::Compact] - } - Command::TodoOpen => { - vec![crate::action::Action::OpenOverlay( - crate::state::Overlay::Todo, - )] - } - Command::PlanOpen => { - vec![crate::action::Action::OpenOverlay( - crate::state::Overlay::Plan, - )] - } - Command::UsageOpen => { - vec![crate::action::Action::OpenOverlay( - crate::state::Overlay::Usage, - )] - } - Command::Diff => { - vec![crate::action::Action::ShowDiff] - } - Command::Unknown(text) => { - if text.starts_with('/') { - vec![crate::action::Action::SystemNote { - kind: "error".to_string(), - message: format!("Unknown command: {text}"), - }] - } else { - vec![crate::action::Action::SubmitInput(text)] - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Verify that `parse_command` recognises the `/todo` command. - #[test] - fn parses_todo_open() { - assert_eq!(parse_command("/todo"), Command::TodoOpen); - } - - /// Verify that `parse_command` recognises the `/usage` command. - #[test] - fn parses_usage_open() { - assert_eq!(parse_command("/usage"), Command::UsageOpen); - } -} diff --git a/apps/interfaces/tui/src/controller/input.rs b/apps/interfaces/tui/src/controller/input.rs deleted file mode 100644 index 039747e..0000000 --- a/apps/interfaces/tui/src/controller/input.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action` -//! variants, with special handling for overlays, auto-complete, and the -//! inline editor. -//! -//! Flow: -//! 1. `handle_key` is called on each key press. -//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys -//! before the main match. -//! 3. The main match handles navigation, auto-complete, editing, and shortcuts. -//! 4. Multi-key actions return `Vec`. - -use tracing::debug; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use crate::action::Action; -use crate::controller::command::{apply_command, parse_command}; -use crate::controller::overlay_enter::handle_overlay_enter; -use crate::state::{AppStateRest, AutocompleteKind, Overlay}; - -/// Mark state dirty and return an empty action list. -fn mark(state: &mut AppStateRest) -> Vec { - state.mark_dirty(); - Vec::new() -} - -/// Translate a terminal `KeyEvent` into zero or more `Action` values -/// based on the current application state. -/// -/// Return: `Vec` so a single key can produce multiple queued actions. -#[tracing::instrument(skip(state))] -pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { - debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key"); - - // ── Editor overlay ─────────────────────────────────────────────────── - if state.misc.overlay == Overlay::Editor { - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return vec![Action::QuitConfirm]; - } - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(ref ed) = state.misc.editor.clone() { - let content = ed.as_string(); - if let Err(e) = std::fs::write(&ed.path, &content) { - state.toast_error(format!("Save failed: {e}")); - } else { - state.toast_success(format!("Saved {}", ed.path.display())); - } - state.mark_dirty(); - } - return vec![]; - } - KeyCode::Esc => { - // Dismiss editor - state.misc.editor = None; - state.misc.overlay = Overlay::None; - return vec![]; - } - KeyCode::Backspace => { - if let Some(ref mut ed) = state.misc.editor { - ed.delete_left(); - state.mark_dirty(); - } - return vec![]; - } - KeyCode::Enter => { - if let Some(ref mut ed) = state.misc.editor { - ed.content.insert(ed.cursor, '\n'); - ed.cursor += 1; - state.mark_dirty(); - } - return vec![]; - } - KeyCode::Char(c) => { - if let Some(ref mut ed) = state.misc.editor { - ed.content.insert(ed.cursor, c); - ed.cursor += c.len_utf8(); - state.mark_dirty(); - } - return vec![]; - } - _ => return vec![], - } - } - - // ── Diff overlay ────────────────────────────────────────────────────── - if state.misc.overlay == Overlay::Diff { - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return vec![Action::QuitConfirm]; - } - KeyCode::Up | KeyCode::Char('k') => { - return vec![Action::DiffScroll(-1)]; - } - KeyCode::Down | KeyCode::Char('j') => { - return vec![Action::DiffScroll(1)]; - } - KeyCode::PageUp => { - return vec![Action::DiffScroll(-20)]; - } - KeyCode::PageDown => { - return vec![Action::DiffScroll(20)]; - } - KeyCode::Esc => { - return vec![Action::CloseOverlay]; - } - _ => {} - } - } - - // ── Learning overlay ────────────────────────────────────────────────── - if state.misc.overlay == Overlay::Learning { - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return vec![Action::QuitConfirm]; - } - KeyCode::Esc => { - return vec![Action::CloseOverlay]; - } - KeyCode::Up => { - let items = crate::state::get_learning_items(state); - let n = items.len(); - state.misc.selected_index = - crate::state::cycle_selected_index(state.misc.selected_index, n, false); - return mark(state); - } - KeyCode::Down => { - let items = crate::state::get_learning_items(state); - let n = items.len(); - state.misc.selected_index = - crate::state::cycle_selected_index(state.misc.selected_index, n, true); - return mark(state); - } - KeyCode::Enter | KeyCode::Char('a') => { - let items = crate::state::get_learning_items(state); - if let Some(crate::state::LearningItem::Pending { name, .. }) = - items.get(state.misc.selected_index) - { - return vec![Action::LessonAccept { name: name.clone() }]; - } - return vec![]; - } - KeyCode::Char('r') => { - let items = crate::state::get_learning_items(state); - if let Some(crate::state::LearningItem::Pending { name, .. }) = - items.get(state.misc.selected_index) - { - return vec![Action::LessonReject { name: name.clone() }]; - } - return vec![]; - } - KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => { - let items = crate::state::get_learning_items(state); - if let Some(item) = items.get(state.misc.selected_index) { - match item { - crate::state::LearningItem::Pending { name, .. } => { - return vec![Action::LessonReject { name: name.clone() }]; - } - crate::state::LearningItem::Stored { name, .. } => { - return vec![Action::LessonDelete { name: name.clone() }]; - } - } - } - return vec![]; - } - _ => return vec![], - } - } - - // ── Normal (non-overlay) dispatch ──────────────────────────────────── - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - vec![Action::QuitConfirm] - } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - vec![Action::CloseOverlay] - } - KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let last_assistant = state - .transcript_cache - .messages - .iter() - .rev() - .find(|m| m.role == zesdex_domain::core::Role::Assistant); - match last_assistant { - Some(msg) => { - state.misc.pending_clipboard_copy = Some(msg.content.clone()); - } - None => { - state.toast_info("No assistant message to copy yet".to_string()); - } - } - Vec::new() - } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => { - vec![crate::action::Action::ShowDiff] - } - KeyCode::Enter => { - if state.input.autocomplete_visible { - state.input.select_autocomplete(); - return mark(state); - } - if state.misc.overlay.is_active() { - return handle_overlay_enter(state); - } - let text = state.input.buffer.clone(); - if text.starts_with('/') { - return apply_command(parse_command(&text)); - } - vec![Action::SubmitInput(text)] - } - KeyCode::Backspace => { - if state.input.autocomplete_visible { - state.input.close_autocomplete(); - return mark(state); - } - vec![Action::DeleteChar] - } - KeyCode::Delete => { - if state.input.autocomplete_visible { - state.input.close_autocomplete(); - return mark(state); - } - vec![Action::DeleteCharRight] - } - KeyCode::Left => { - vec![Action::CursorLeft] - } - KeyCode::Right => { - vec![Action::CursorRight] - } - KeyCode::Up => { - if state.input.autocomplete_visible { - state.input.cycle_autocomplete(false); - state.mark_dirty(); - Vec::new() - } else if state.misc.overlay == Overlay::Effort { - crate::state::cycle_effort(state, false); - Vec::new() - } else if state.misc.overlay == Overlay::Rewind { - let n = crate::state::rewind_count(state); - state.misc.selected_index = - crate::state::cycle_selected_index(state.misc.selected_index, n, false); - state.mark_dirty(); - Vec::new() - } else if state.misc.overlay == Overlay::ModelSelector { - let n = state.app_config.providers.len(); - state.misc.selected_index = - crate::state::cycle_selected_index(state.misc.selected_index, n, false); - state.mark_dirty(); - Vec::new() - } else if key.modifiers.contains(KeyModifiers::CONTROL) { - vec![Action::ScrollUp] - } else { - vec![Action::HistoryUp] - } - } - KeyCode::Down => { - if state.input.autocomplete_visible { - state.input.cycle_autocomplete(true); - state.mark_dirty(); - Vec::new() - } else if state.misc.overlay == Overlay::Effort { - crate::state::cycle_effort(state, true); - Vec::new() - } else if state.misc.overlay == Overlay::Rewind { - let n = crate::state::rewind_count(state); - state.misc.selected_index = - crate::state::cycle_selected_index(state.misc.selected_index, n, true); - state.mark_dirty(); - Vec::new() - } else if state.misc.overlay == Overlay::ModelSelector { - let n = state.app_config.providers.len(); - state.misc.selected_index = - crate::state::cycle_selected_index(state.misc.selected_index, n, true); - state.mark_dirty(); - Vec::new() - } else if key.modifiers.contains(KeyModifiers::CONTROL) { - vec![Action::ScrollDown] - } else { - vec![Action::HistoryDown] - } - } - KeyCode::PageUp => { - vec![Action::ScrollUp] - } - KeyCode::PageDown => { - vec![Action::ScrollDown] - } - KeyCode::Esc => { - if state.turn_in_flight() { - vec![Action::AbortTurn] - } else if state.input.autocomplete_visible { - state.input.close_autocomplete(); - state.mark_dirty(); - Vec::new() - } else if state.misc.overlay.is_active() { - vec![Action::CloseOverlay] - } else { - Vec::new() - } - } - KeyCode::Tab => { - if state.input.buffer.starts_with('/') { - if state.input.autocomplete_visible { - state.input.cycle_autocomplete(true); - } else { - state.input.tab_complete(); - } - state.mark_dirty(); - } else if state.input.autocomplete_kind == AutocompleteKind::FileMention - && state.input.autocomplete_visible - { - state.input.cycle_autocomplete(true); - state.mark_dirty(); - } - Vec::new() - } - KeyCode::Char(c) => { - if state.input.autocomplete_visible { - state.input.close_autocomplete(); - state.mark_dirty(); - } - state.input.insert(c); - state.mark_dirty(); - if state.input.buffer.starts_with('/') { - state.input.open_autocomplete(); - } else if state.input.mention_query_at_cursor().is_some() { - state - .input - .open_mention_autocomplete(&state.mention_index.snapshot()); - } - Vec::new() - } - _ => Vec::new(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build a minimal `AppStateRest` in a temp directory for test isolation. - fn test_state() -> AppStateRest { - let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&tmp).unwrap(); - AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")) - } - - /// Verify that Ctrl+Y sets `pending_clipboard_copy` to the most - /// recent assistant message (skipping non-assistant roles like Tool). - #[test] - fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() { - let mut state = test_state(); - state.push_transcript(crate::state::ChatMessageDisplay::new( - zesdex_domain::core::Role::User, - "hi".to_string(), - )); - state.push_transcript(crate::state::ChatMessageDisplay::new( - zesdex_domain::core::Role::Assistant, - "first reply".to_string(), - )); - state.push_transcript(crate::state::ChatMessageDisplay::new( - zesdex_domain::core::Role::Tool, - "tool output".to_string(), - )); - state.push_transcript(crate::state::ChatMessageDisplay::new( - zesdex_domain::core::Role::Assistant, - "second reply".to_string(), - )); - handle_key( - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), - &mut state, - ); - assert_eq!( - state.misc.pending_clipboard_copy, - Some("second reply".to_string()) - ); - } - - /// Verify that Ctrl+Y with no assistant messages in the transcript - /// pushes an info toast instead of setting `pending_clipboard_copy`. - #[test] - fn ctrl_y_with_no_assistant_message_pushes_info_toast() { - let mut state = test_state(); - handle_key( - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), - &mut state, - ); - assert!(state.misc.pending_clipboard_copy.is_none()); - assert_eq!(state.misc.toasts.len(), 1); - } -} diff --git a/apps/interfaces/tui/src/controller/mod.rs b/apps/interfaces/tui/src/controller/mod.rs deleted file mode 100644 index fa8dcde..0000000 --- a/apps/interfaces/tui/src/controller/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Keyboard input handling and command parsing for the TUI. -//! -//! The controller layer bridges raw terminal key events (from `crossterm`) to -//! application actions. It contains two sub-modules: -//! -//! - `input` — key-event dispatch, prompt-line editing, history navigation, -//! tab-completion, and action invocation. -//! - `command` — the `/slash` command parser that translates user-typed -//! commands into structured `Action` variants. -pub mod command; -pub mod input; -pub mod overlay_enter; diff --git a/apps/interfaces/tui/src/controller/overlay_enter.rs b/apps/interfaces/tui/src/controller/overlay_enter.rs deleted file mode 100644 index 58a5c1e..0000000 --- a/apps/interfaces/tui/src/controller/overlay_enter.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Overlay-specific Enter-key handlers. -//! -//! Each overlay variant has its own Enter semantics. Extracted from the -//! monolithic `input.rs` so each handler is self-contained. - -use tracing::debug; - -use crate::action::Action; -use crate::state::{AppStateRest, Overlay}; - -/// Handle pressing Enter while a modal overlay is active. -/// -/// Each overlay variant has its own Enter semantics: -/// - `QuitConfirm` → set `quit = true` -/// - `KeyInput` → save API key from buffer -/// - `ModelSelector` → switch provider/model from selected index -/// - `ClearConfirm` → clear transcript cache -/// - `Rewind` → rewind to selected message index -/// - `Bash` / `Settings` / `Todo` / `Mcp` → no-ops (placeholder) -#[tracing::instrument(skip(state))] -pub fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { - debug!(overlay = ?state.misc.overlay, "handle_overlay_enter"); - match state.misc.overlay { - Overlay::Bash => handle_bash_enter(state), - Overlay::Settings | Overlay::Todo => { - state.mark_dirty(); - Vec::new() - } - Overlay::QuitConfirm => { - state.quit = true; - state.mark_dirty(); - Vec::new() - } - Overlay::KeyInput => handle_keyinput_enter(state), - Overlay::Mcp => { - state.toast_info("Connecting MCP...".to_string()); - Vec::new() - } - Overlay::Rewind => handle_rewind_enter(state), - Overlay::ModelSelector => handle_model_selector_enter(state), - Overlay::ClearConfirm => handle_clear_confirm(state), - _ => Vec::new(), - } -} - -fn handle_bash_enter(state: &mut AppStateRest) -> Vec { - let command = state.input.buffer.clone(); - state.toast_info(format!("Submitting bash command: {command}")); - state.input.buffer.clear(); - state.input.cursor = 0; - state.mark_dirty(); - Vec::new() -} - -fn handle_keyinput_enter(state: &mut AppStateRest) -> Vec { - let text = state.input.buffer.clone(); - if !text.is_empty() { - state - .settings - .api_keys - .insert(state.settings.provider.clone(), text); - } - state.toast_success("API key saved".to_string()); - state.input.buffer.clear(); - state.input.cursor = 0; - state.misc.overlay = Overlay::None; - state.save_settings(); - state.mark_dirty(); - Vec::new() -} - -fn handle_rewind_enter(state: &mut AppStateRest) -> Vec { - let idx = state.misc.selected_index; - let n = state.transcript_cache.messages.len(); - if idx < n { - let rewind_to = n - idx - 1; - state.push_transcript(crate::state::ChatMessageDisplay::new( - zesdex_domain::core::Role::System, - format!("Rewound to message {rewind_to}"), - )); - } - state.misc.overlay = Overlay::None; - state.mark_dirty(); - Vec::new() -} - -fn handle_model_selector_enter(state: &mut AppStateRest) -> Vec { - let providers: Vec = state.app_config.providers.keys().cloned().collect(); - if let Some(provider) = providers.get(state.misc.selected_index) { - if let Some(cfg) = state.app_config.providers.get(provider) { - let model = cfg - .default_model - .clone() - .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string()); - state.settings.provider.clone_from(provider); - state.settings.model.clone_from(&model); - if let Some(ref key) = cfg.default_api_key { - state - .settings - .api_keys - .insert(provider.clone(), key.clone()); - } else if let Some(env_key) = cfg - .api_key_env - .as_ref() - .and_then(|env| std::env::var(env).ok()) - { - state.settings.api_keys.insert(provider.clone(), env_key); - } - state.save_settings(); - state.toast_success(format!("Switched to {provider} / {model}")); - } - } - state.misc.overlay = Overlay::None; - state.mark_dirty(); - Vec::new() -} - -fn handle_clear_confirm(state: &mut AppStateRest) -> Vec { - state.toast_info("Transcript cleared".to_string()); - state.transcript_cache.messages.clear(); - state.transcript_cache.dirty = true; - state.misc.overlay = Overlay::None; - state.mark_dirty(); - Vec::new() -} diff --git a/apps/interfaces/tui/src/lib.rs b/apps/interfaces/tui/src/lib.rs deleted file mode 100644 index ca6bbfa..0000000 --- a/apps/interfaces/tui/src/lib.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! # Zesdex TUI (Terminal User Interface) -//! -//! This crate provides the terminal UI interface for the Zesdex application, -//! built on `ratatui` with `crossterm` for terminal interaction. -//! -//! It is one of MANY possible user interfaces — others include the HTTP API -//! gateway, CLI batch commands, and daemon-mode background processing. -//! -//! ## Architecture -//! -//! ```text -//! apps/interfaces/tui/src/ -//! ├── lib.rs — Crate root: module declarations + re-exports -//! ├── state.rs — AppStateRest + all TUI-perspective state types -//! ├── action.rs — Action enum + apply_action dispatcher -//! ├── view/ — TUI rendering (ratatui widgets) -//! │ ├── mod.rs — Main draw function (layered layout) -//! │ ├── chat.rs — Chat transcript panel -//! │ ├── markdown.rs — Markdown-to-styled-spans renderer -//! │ ├── sidebar.rs — Right-hand dashboard sidebar -//! │ ├── status.rs — Bottom status bar -//! │ ├── theme.rs — Tokyo Night colour palette -//! │ ├── workflow.rs — Workflow agent status panel -//! │ └── overlays/ — 15 modal overlay panels -//! ├── controller/ — Input handling + command parsing -//! │ ├── mod.rs -//! │ ├── command.rs — /slash command parser -//! │ └── input.rs — Key event → Action dispatch -//! ├── model/ — Data persistence layer -//! │ ├── store.rs — Store path configuration (re-export) -//! │ ├── msglog/ — SQLite message-log (schema, insert, blobs) -//! │ └── agent_def/ — Agent definitions (builtin/global/session) -//! └── components/ — Reusable UI widgets (extensible) -//! ``` -//! -//! ## Dependencies -//! -//! - `zesdex-domain` — Domain entities (Role, ChatMessage, Settings, AppConfig) -//! - `zesdex-application` — Application port traits and use-cases -//! - `zesdex-infrastructure` — Shared concrete infrastructure types -//! (SessionRuntime, Toast, DirCache, TurnEvent, etc.) -//! - `ratatui` / `crossterm` — Terminal rendering and raw-key input -//! - `pulldown-cmark` — Markdown parsing for message rendering -//! -//! ## State Flow -//! -//! 1. `state::AppStateRest` is constructed in the application's main/entry point -//! 2. The TUI event loop calls `controller::input::handle_key` on each key press -//! 3. `handle_key` returns `Vec` which the loop applies via -//! `action::apply_action` -//! 4. After each action batch, `view::draw` re-renders the terminal -//! -//! The state types defined here (`AppStateRest`, `InputState`, `MiscState`, -//! `Overlay`, etc.) are TUI-perspective — they represent what the interface -//! needs to render, not the full application state. - -// Module declarations -pub mod action; -pub mod components; -pub mod controller; -pub mod model; -pub mod run; -pub mod state; -pub mod turn; -pub mod view; - -// --------------------------------------------------------------------------- -// Re-exports for convenient access by consumers (main.rs / bin entry points) -// --------------------------------------------------------------------------- - -pub use action::{apply_action, Action}; -pub use run::run_single_process; -pub use state::{ - AgentState, AppStateRest, AutocompleteKind, ChatMessageDisplay, EditorState, InputState, - MiscState, Overlay, ScrollState, SimpleAgent, SimpleWorkflowEngine, TranscriptCache, -}; - -/// Convenience: initialise a `Store` for data directory resolution. -pub use zesdex_domain::core::Store; diff --git a/apps/interfaces/tui/src/model/agent_def/builtin.rs b/apps/interfaces/tui/src/model/agent_def/builtin.rs deleted file mode 100644 index 9c39a98..0000000 --- a/apps/interfaces/tui/src/model/agent_def/builtin.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner). -//! -//! These agents are always available regardless of user or session config. -//! They provide the default set of roles shipped with the application. -//! -//! ## Available agents -//! | Agent | Purpose | Key tools | -//! |-------|---------|-----------| -//! | coder | Write/edit code | read, write, edit, bash, git_operator | -//! | reviewer | Review code for correctness/safety | read, grep, recall | -//! | researcher | Search and summarise | read, grep, bash, search_web | -//! | planner | Break down tasks into steps | read, write, edit, bash, todo_* | - -use serde::{Deserialize, Serialize}; - -/// Declarative specification for instantiating a subagent. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentDefinition { - /// Human-readable name (e.g. `"quick-reviewer"`). - pub name: String, - /// Functional role (e.g. `"reviewer"`, `"coder"`). - pub role: String, - /// Optional system prompt override. - #[serde(skip_serializing_if = "Option::is_none")] - pub system_prompt: Option, - /// Optional tool allowlist. `None` means role-based defaults. - #[serde(skip_serializing_if = "Option::is_none")] - pub allowed_tools: Option>, - /// Optional step budget. `None` means no limit. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_steps: Option, - /// Optional temperature override. - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, -} - -impl AgentDefinition { - /// Create an agent definition with the required name and role. - pub fn new(name: String, role: String) -> Self { - AgentDefinition { - name, - role, - system_prompt: None, - allowed_tools: None, - max_steps: None, - temperature: None, - } - } - - /// Builder: set the system prompt. - pub fn with_system_prompt(mut self, prompt: String) -> Self { - self.system_prompt = Some(prompt); - self - } - - /// Builder: set the allowed tool list. - pub fn with_allowed_tools(mut self, tools: Vec) -> Self { - self.allowed_tools = Some(tools); - self - } - - /// Builder: set the maximum step count. - pub fn with_max_steps(mut self, steps: usize) -> Self { - self.max_steps = Some(steps); - self - } -} - -/// Build the fixed list of built-in agent definitions shipped with zesdex. -pub fn builtin_agents() -> Vec { - vec![ - AgentDefinition::new("coder".to_string(), "coder".to_string()) - .with_system_prompt( - "You are a coding agent. Write correct, idiomatic Rust code.".to_string(), - ) - .with_allowed_tools(vec![ - "read".to_string(), - "write".to_string(), - "edit".to_string(), - "bash".to_string(), - "grep".to_string(), - "glob".to_string(), - "git_operator".to_string(), - ]) - .with_max_steps(usize::MAX), - AgentDefinition::new("reviewer".to_string(), "reviewer".to_string()) - .with_system_prompt( - "You are a code reviewer. Focus on correctness, safety, and performance." - .to_string(), - ) - .with_allowed_tools(vec![ - "read".to_string(), - "grep".to_string(), - "glob".to_string(), - "recall".to_string(), - "remember".to_string(), - ]) - .with_max_steps(usize::MAX), - AgentDefinition::new("researcher".to_string(), "researcher".to_string()) - .with_system_prompt( - "You are a research agent. Search for information and summarize findings." - .to_string(), - ) - .with_allowed_tools(vec![ - "read".to_string(), - "grep".to_string(), - "glob".to_string(), - "bash".to_string(), - "search_web".to_string(), - "fetch_url".to_string(), - ]) - .with_max_steps(usize::MAX), - AgentDefinition::new("planner".to_string(), "planner".to_string()) - .with_system_prompt( - "You are a planning agent. Break down tasks into clear steps.".to_string(), - ) - .with_allowed_tools(vec![ - "read".to_string(), - "write".to_string(), - "edit".to_string(), - "bash".to_string(), - "todo_write".to_string(), - "todo_finish".to_string(), - ]) - .with_max_steps(usize::MAX), - ] -} diff --git a/apps/interfaces/tui/src/model/agent_def/global.rs b/apps/interfaces/tui/src/model/agent_def/global.rs deleted file mode 100644 index 998655c..0000000 --- a/apps/interfaces/tui/src/model/agent_def/global.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Load, save, and remove user-defined agent definitions stored globally -//! (under the store's `agents/` directory), independent of any session. -use super::builtin::AgentDefinition; - -/// Load all globally-registered agent definitions from disk. -/// -/// Flow: resolve `/agents/` -> read directory -> parse each `*.json` -/// file into an `AgentDefinition`, skipping any that fail to read or parse. -pub fn load_global_agents() -> Vec { - let store = crate::model::store::Store::new(); - let agents_dir = store.base_dir.join("agents"); - tracing::debug!(dir = %agents_dir.display(), "load_global_agents"); - - if !agents_dir.exists() { - tracing::debug!("load_global_agents — agents dir does not exist"); - return Vec::new(); - } - let mut agents = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|e| e == "json") { - if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(def) = serde_json::from_str::(&content) { - tracing::debug!(agent = %def.name, "load_global_agents — loaded"); - agents.push(def); - } else { - tracing::warn!(file = %path.display(), "load_global_agents — failed to parse JSON"); - } - } else { - tracing::warn!(file = %path.display(), "load_global_agents — failed to read file"); - } - } - } - } - tracing::info!(count = agents.len(), "load_global_agents — done"); - agents -} - -/// Persist a global agent definition as `/agents/.json`. -pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { - let store = crate::model::store::Store::new(); - let agents_dir = store.base_dir.join("agents"); - std::fs::create_dir_all(&agents_dir)?; - let path = agents_dir.join(format!("{}.json", def.name)); - let tmp = agents_dir.join(format!("{}.json.tmp", def.name)); - let content = serde_json::to_string_pretty(def)?; - tracing::debug!(agent = %def.name, "save_global_agent — writing"); - std::fs::write(&tmp, content)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, path)?; - if let Some(parent) = agents_dir.parent() { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } - tracing::info!(agent = %def.name, "save_global_agent — saved"); - Ok(()) -} - -/// Remove a global agent definition by name. -pub fn remove_global_agent(name: &str) -> anyhow::Result { - let store = crate::model::store::Store::new(); - let path = store.base_dir.join("agents").join(format!("{name}.json")); - tracing::debug!(%name, path = %path.display(), "remove_global_agent"); - match std::fs::remove_file(&path) { - Ok(_) => { - tracing::info!(%name, "remove_global_agent — removed"); - Ok(true) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - tracing::debug!(%name, "remove_global_agent — not found"); - Ok(false) - } - Err(e) => { - tracing::error!(%name, error = %e, "remove_global_agent — failed"); - Err(e.into()) - } - } -} diff --git a/apps/interfaces/tui/src/model/agent_def/mod.rs b/apps/interfaces/tui/src/model/agent_def/mod.rs deleted file mode 100644 index b2dca25..0000000 --- a/apps/interfaces/tui/src/model/agent_def/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Agent definition sources: built-in defaults, global (user-wide), and -//! per-session overrides. -//! -//! Agent definitions control the system prompt, tool set, and configuration -//! for each agent. The resolution order (lowest to highest priority) is: -//! -//! 1. `builtin` — hardcoded default agent shipped with the application. -//! 2. `global` — user-wide overrides stored in the config directory. -//! 3. `session` — per-session overrides stored in the session directory. -pub mod builtin; -pub mod global; -pub mod session; diff --git a/apps/interfaces/tui/src/model/agent_def/session.rs b/apps/interfaces/tui/src/model/agent_def/session.rs deleted file mode 100644 index 6eab996..0000000 --- a/apps/interfaces/tui/src/model/agent_def/session.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Load, save, add, and remove agent definitions scoped to a single -//! session (`/agents.json`). -use super::builtin::AgentDefinition; -use std::path::Path; - -/// Load agent definitions saved for a specific session. -pub fn load_session_agents(session_dir: &Path) -> Vec { - let agents_file = session_dir.join("agents.json"); - tracing::debug!(file = %agents_file.display(), "load_session_agents"); - - if !agents_file.exists() { - tracing::debug!("load_session_agents — file does not exist"); - return Vec::new(); - } - match std::fs::read_to_string(&agents_file) { - Ok(content) => { - let agents: Vec = serde_json::from_str(&content).unwrap_or_else(|e| { - tracing::warn!("load_session_agents — failed to parse agents.json: {}", e); - Vec::new() - }); - tracing::debug!(count = agents.len(), "load_session_agents — loaded"); - agents - } - Err(e) => { - tracing::warn!(error = %e, "load_session_agents — failed to read"); - Vec::new() - } - } -} - -/// Overwrite `/agents.json` with the given agent list. -pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> { - let agents_file = session_dir.join("agents.json"); - let tmp = session_dir.join("agents.json.tmp"); - let content = serde_json::to_string_pretty(agents)?; - tracing::debug!(count = agents.len(), "save_session_agents — writing"); - - std::fs::write(&tmp, content)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, agents_file)?; - let _ = std::fs::File::open(session_dir).and_then(|d| d.sync_all()); - - tracing::info!(count = agents.len(), "save_session_agents — saved"); - Ok(()) -} - -/// Add or replace a session agent definition by name. -pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> { - tracing::debug!(agent = %def.name, "add_session_agent"); - let mut agents = load_session_agents(session_dir); - agents.retain(|a| a.name != def.name); - agents.push(def.clone()); - save_session_agents(session_dir, &agents) -} - -/// Remove a session agent definition by name. -pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result { - tracing::debug!(%name, "remove_session_agent"); - let mut agents = load_session_agents(session_dir); - let before = agents.len(); - agents.retain(|a| a.name != name); - if agents.len() == before { - tracing::debug!(%name, "remove_session_agent — not found"); - return Ok(false); - } - save_session_agents(session_dir, &agents)?; - tracing::info!(%name, "remove_session_agent — removed"); - Ok(true) -} diff --git a/apps/interfaces/tui/src/model/mod.rs b/apps/interfaces/tui/src/model/mod.rs deleted file mode 100644 index be8b2e1..0000000 --- a/apps/interfaces/tui/src/model/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Data-model layer for the TUI interface. -//! -//! This module contains: -//! - `store` — Store path configuration -//! - `agent_def` — Agent definition model (built-in, global, session scopes) -//! - `msglog` — SQLite-backed message-log persistence (schema, insert, blobs) -//! -//! The `Store` type is re-exported from `zesdex_domain::core::store`. - -pub mod store { - //! Re-export `Store` from the domain layer for path resolution. - pub use zesdex_domain::core::Store; -} - -pub mod agent_def; -pub mod msglog; diff --git a/apps/interfaces/tui/src/model/msglog/blobs.rs b/apps/interfaces/tui/src/model/msglog/blobs.rs deleted file mode 100644 index 6d8330c..0000000 --- a/apps/interfaces/tui/src/model/msglog/blobs.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Binary blob storage in the message-log `SQLite` database (e.g. images, -//! attachments), keyed by session id and an arbitrary blob key. -use anyhow::Result; -use rusqlite::{params, Connection}; - -/// Insert or overwrite a blob for a session under `blob_key`. -/// -/// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs` -/// keyed on `(session_id, blob_key)`. -pub fn store_blob( - conn: &Connection, - session_id: &str, - blob_key: &str, - data: &[u8], - mime_type: Option<&str>, -) -> Result<()> { - let created_at = chrono::Utc::now().timestamp_millis(); - tracing::debug!(%session_id, %blob_key, size = data.len(), "store_blob"); - conn.execute( - "INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - params![session_id, blob_key, data, mime_type, created_at], - )?; - tracing::info!(%session_id, %blob_key, "store_blob — stored"); - Ok(()) -} - -/// Fetch a blob's bytes for a session by key. -pub fn retrieve_blob( - conn: &Connection, - session_id: &str, - blob_key: &str, -) -> Result>> { - tracing::debug!(%session_id, %blob_key, "retrieve_blob"); - let result = conn.query_row( - "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", - params![session_id, blob_key], - |row| row.get::<_, Vec>(0), - ); - match result { - Ok(data) => { - tracing::debug!(%session_id, %blob_key, size = data.len(), "retrieve_blob — found"); - Ok(Some(data)) - } - Err(rusqlite::Error::QueryReturnedNoRows) => { - tracing::debug!(%session_id, %blob_key, "retrieve_blob — not found"); - Ok(None) - } - Err(e) => { - tracing::error!(%session_id, %blob_key, error = %e, "retrieve_blob — query failed"); - Err(e.into()) - } - } -} - -/// List all blob keys stored for a session, oldest first. -pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result> { - tracing::debug!(%session_id, "list_blob_keys"); - let mut stmt = - conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?; - let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?; - let mut keys = Vec::new(); - for row in rows { - keys.push(row?); - } - tracing::debug!(%session_id, count = keys.len(), "list_blob_keys — done"); - Ok(keys) -} diff --git a/apps/interfaces/tui/src/model/msglog/insert.rs b/apps/interfaces/tui/src/model/msglog/insert.rs deleted file mode 100644 index ab08400..0000000 --- a/apps/interfaces/tui/src/model/msglog/insert.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Insert queries against the message log's `messages` table. -use anyhow::Result; -use rusqlite::{params, Connection}; -use zesdex_domain::core::{ChatMessage, Role}; - -/// Insert a chat message into the session's message log. -/// -/// Flow: extract optional content/tool_call_id/tool_name -> serialize -/// `tool_calls` to a JSON string if present -> map `Role` to its string -/// column value -> `INSERT` the row with the current timestamp. -/// -/// Return: the new row's `rowid` on success, or the underlying error. -pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result { - let content = msg.content.as_deref(); - let tool_call_id = msg.tool_call_id.as_deref(); - let tool_name = msg.name.as_deref(); - let tool_arguments = msg - .tool_calls - .as_ref() - .map(|calls| serde_json::to_string(calls).unwrap_or_default()); - let created_at = chrono::Utc::now().timestamp_millis(); - let role_str = match msg.role { - Role::User => "user", - Role::Assistant => "assistant", - Role::System => "system", - Role::Tool => "tool", - }; - - tracing::debug!(%session_id, %role_str, content_len = content.map_or(0, str::len), "insert_message"); - conn.execute( - "INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at], - )?; - let rowid = conn.last_insert_rowid(); - tracing::info!(%session_id, %role_str, rowid, "insert_message — inserted"); - Ok(rowid) -} diff --git a/apps/interfaces/tui/src/model/msglog/mod.rs b/apps/interfaces/tui/src/model/msglog/mod.rs deleted file mode 100644 index 4a9ca04..0000000 --- a/apps/interfaces/tui/src/model/msglog/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! SQLite-backed message log: per-session `messages.sqlite` storing chat -//! messages, blobs, and archive/summary metadata. -//! -//! ## Tables -//! | Table | Purpose | -//! |-------|---------| -//! | `messages` | Individual chat messages (role, content, tool calls) | -//! | `archives` | Session archive metadata (title, model, summary) | -//! | `blobs` | Binary attachments keyed by `(session_id, blob_key)` | -//! -//! All writes use WAL mode for concurrent reads without blocking. -pub mod blobs; -pub mod insert; -pub mod schema; - -pub use blobs::store_blob; -pub use insert::insert_message; - -/// Open (creating if needed) a session's `messages.sqlite` and ensure its -/// schema is initialized. -/// -/// Flow: resolve `/messages.sqlite` -> create parent dirs -> -/// open a `SQLite` connection -> run `schema::init_schema`. -/// -/// Return: an open, schema-ready `Connection`, or an error if any step fails. -pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result { - let path = session_dir.join("messages.sqlite"); - tracing::debug!(?path, "open_or_create"); - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let conn = rusqlite::Connection::open(&path)?; - conn.execute_batch("PRAGMA journal_mode = WAL;")?; - conn.execute_batch("PRAGMA busy_timeout = 5000;")?; - schema::init_schema(&conn)?; - tracing::info!("open_or_create — database ready"); - Ok(conn) -} diff --git a/apps/interfaces/tui/src/model/msglog/schema.rs b/apps/interfaces/tui/src/model/msglog/schema.rs deleted file mode 100644 index 8e1d406..0000000 --- a/apps/interfaces/tui/src/model/msglog/schema.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! `SQLite` schema definition for the message log database. -use anyhow::Result; -use rusqlite::Connection; - -/// Create the message log's tables and indexes if they don't already -/// exist (`messages`, `archives`, `blobs`). -pub fn init_schema(conn: &Connection) -> Result<()> { - tracing::debug!("init_schema — creating tables if not exists"); - conn.execute_batch("PRAGMA foreign_keys = ON;")?; - conn.execute_batch( - " - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT, - tool_call_id TEXT, - tool_name TEXT, - tool_arguments TEXT, - created_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS archives ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL UNIQUE, - title TEXT, - model TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - message_count INTEGER DEFAULT 0, - token_count INTEGER DEFAULT 0, - summary TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); - CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); - CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at); - - CREATE TABLE IF NOT EXISTS blobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - blob_key TEXT NOT NULL, - data BLOB NOT NULL, - mime_type TEXT, - created_at INTEGER NOT NULL, - UNIQUE(session_id, blob_key) - ); - ", - )?; - tracing::info!("init_schema — schema ready"); - Ok(()) -} diff --git a/apps/interfaces/tui/src/run.rs b/apps/interfaces/tui/src/run.rs deleted file mode 100644 index c98df52..0000000 --- a/apps/interfaces/tui/src/run.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! TUI event loop — single-process mode entry point. -//! -//! Provides `run_single_process()` which sets up the terminal, -//! creates a session, and enters the render/input loop. -//! -//! Flow: create session + lock → enable raw mode + alternate screen → -//! run_loop (render → poll events → handle key → tick) → -//! restore terminal → save settings → release lock. - -use anyhow::Result; -use crossterm::event::{ - DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind, -}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use ratatui::backend::CrosstermBackend; -use ratatui::Terminal; -use std::io::{self, Write}; -use std::time::Duration; - -use tracing::{debug, info}; - -use crate::action::{apply_action, Action}; -use crate::controller::input::handle_key; -use crate::state::AppStateRest; -use crate::view; - -/// Run zesdex as a self-contained TUI + agent loop in one process. -/// -/// Flow: build `AppStateRest` → enter raw mode / alternate screen → -/// run the event loop → always restore the terminal (even on error) → -/// save settings. -#[tracing::instrument] -pub fn run_single_process() -> Result<()> { - let rt = tokio::runtime::Runtime::new()?; - let _guard = rt.enter(); - - // Create session state - info!("starting single-process TUI"); - let (_store, mut state) = create_local_session()?; - - // Enter raw mode and alternate screen for the TUI - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - execute!(stdout, crossterm::event::EnableBracketedPaste)?; - execute!(stdout, crossterm::event::EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - terminal.clear()?; - - let run_result = run_loop(&mut state, &mut terminal); - - let mut restore_stdout = io::stdout(); - let _ = execute!(restore_stdout, DisableBracketedPaste); - let _ = execute!(restore_stdout, DisableMouseCapture); - let _ = execute!(restore_stdout, LeaveAlternateScreen); - let _ = disable_raw_mode(); - - if let Err(e) = run_result { - let _ = writeln!(restore_stdout, "error: {e}"); - let _ = restore_stdout.flush(); - } - - // Save settings - state.save_settings(); - - Ok(()) -} - -/// Run the event loop, guaranteeing terminal restoration on error. -/// -/// Wraps `run_loop_inner` so that if it panics or returns an error the -/// terminal is restored to a usable state before propagating the error. -#[tracing::instrument(skip(state, terminal))] -fn run_loop( - state: &mut AppStateRest, - terminal: &mut Terminal>, -) -> Result<()> { - debug!("entering run_loop"); - let result = run_loop_inner(state, terminal); - if let Err(ref _e) = result { - let _ = terminal.clear(); - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), DisableBracketedPaste); - let _ = execute!(io::stdout(), DisableMouseCapture); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - } - result -} - -/// The core single-process render/input loop. -/// -/// Performance optimizations applied here: -/// - Skip `terminal.draw()` when `state.dirty == false` (nothing changed) -/// - Adaptive poll timeout: 50ms when a turn is in-flight (spinner needs -/// smooth updates), 200ms when idle (no reason to busy-loop) -/// - Toast expiry is drained once here instead of in two places -/// - Chat display lines are cached and rebuilt only when content changes -#[tracing::instrument(skip(state, terminal))] -fn run_loop_inner( - state: &mut AppStateRest, - terminal: &mut Terminal>, -) -> Result<()> { - info!("TUI event loop started"); - loop { - if state.quit { - info!("TUI event loop exiting (quit=true)"); - break; - } - - // Drain expired toasts exactly once per loop iteration (was being - // done here AND in Action::Tick before this fix). Skip the clock - // syscall + Vec scan when there are no toasts at all. - if !state.misc.toasts.is_empty() { - let now_ms = chrono::Utc::now().timestamp_millis(); - state.misc.drain_expired_toasts(now_ms); - } - - // Skip render when nothing has changed — avoids expensive markdown - // re-parse and layout recalculation every cycle while idle. - if state.dirty { - // Pre-warm display caches before entering the terminal.draw closure. - // This lets view::draw work with &AppStateRest (no mutation inside draw). - view::pre_render(state); - terminal.draw(|f| { - view::draw(f, state); - state.dirty = false; - })?; - } - - // Adaptive poll: fast when animating, slow when idle. - let poll_timeout = if state.turn_in_flight() { - Duration::from_millis(50) // smooth spinner @ ~20fps - } else { - Duration::from_millis(200) // idle: 5fps, saves CPU - }; - - if crossterm::event::poll(poll_timeout)? { - match crossterm::event::read()? { - Event::Key(key) => { - if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { - let actions = handle_key(key, state); - for action in actions { - apply_action(state, action); - } - if let Some(text) = state.misc.pending_clipboard_copy.take() { - let _ = - zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text); - state.push_toast(zesdex_infrastructure::Toast::new( - zesdex_infrastructure::ToastKind::Success, - "Copied to clipboard".to_string(), - )); - } - } - } - Event::Paste(text) => { - if state.input.autocomplete_visible { - state.input.close_autocomplete(); - } - state.input.buffer.insert_str(state.input.cursor, &text); - state.input.cursor += text.len(); - if state.input.buffer.starts_with('/') { - state.input.open_autocomplete(); - } - state.dirty = true; - } - Event::Resize(w, h) => { - apply_action(state, Action::Resize(w, h)); - } - Event::Mouse(mouse_event) => { - if mouse_event.kind == MouseEventKind::ScrollUp { - apply_action(state, Action::ScrollUp); - } else if mouse_event.kind == MouseEventKind::ScrollDown { - apply_action(state, Action::ScrollDown); - } - } - _ => {} - } - } - // Tick always fires each iteration - apply_action(state, Action::Tick); - } - terminal.clear()?; - Ok(()) -} - -/// Create session state with real infrastructure wired in. -/// -/// Flow: initialise `Store` → ensure data directories → generate session ID → -/// load `Settings` and `AppConfig` from disk → construct `AppStateRest`. -#[tracing::instrument] -fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> { - let store = zesdex_domain::core::Store::new(); - debug!("store created at {:?}", store.base_dir); - store.ensure_dirs()?; - - let session_id = uuid::Uuid::new_v4().to_string(); - let session_dir = store.base_dir.join("sessions").join(&session_id); - std::fs::create_dir_all(&session_dir)?; - - // Load real settings from disk - use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; - let settings = - zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let app_config = - zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository::new() - .load(&store.base_dir) - .unwrap_or_default(); - - let workspace_roots = vec![std::env::current_dir()?]; - let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone()); - state.settings = settings; - state.app_config = app_config; - - Ok((store, state)) -} diff --git a/apps/interfaces/tui/src/state/helpers.rs b/apps/interfaces/tui/src/state/helpers.rs deleted file mode 100644 index 1439527..0000000 --- a/apps/interfaces/tui/src/state/helpers.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Standalone helper functions that operate on AppStateRest. -//! -//! These are kept separate from the AppStateRest impl block to keep that -//! file focused on state definition and direct mutations. Helpers that -//! aggregate, compute, or read from the filesystem live here. - -use crate::state::AppStateRest; - -// --------------------------------------------------------------------------- -// Effort levels -// --------------------------------------------------------------------------- - -/// Name of each reasoning-effort tier. -pub const EFFORT_LEVELS: &[&str] = &[ - "Auto \u{2014} let the provider decide", - "Low \u{2014} fast, minimal reasoning", - "Medium \u{2014} balanced speed & reasoning", - "High \u{2014} thorough reasoning", - "Maximum \u{2014} deep analysis", -]; - -/// Return the current effort index from state. -pub fn current_effort(state: &AppStateRest) -> usize { - state - .misc - .effort_level - .saturating_sub(1) - .min(EFFORT_LEVELS.len().saturating_sub(1)) -} - -/// Cycle effort level up or down. -pub fn cycle_effort(state: &mut AppStateRest, forward: bool) { - let n = EFFORT_LEVELS.len(); - if forward { - state.misc.effort_level = (state.misc.effort_level % n) + 1; - } else { - state.misc.effort_level = if state.misc.effort_level <= 1 { - n - } else { - state.misc.effort_level - 1 - }; - } - state.mark_dirty(); -} - -// --------------------------------------------------------------------------- -// Learning items -// --------------------------------------------------------------------------- - -/// A lesson entry displayed in the Learning overlay. -#[derive(Debug, Clone)] -pub enum LearningItem { - /// A newly-generated lesson pending user approval. - Pending { - name: String, - content: String, - scope: String, - confidence: f64, - }, - /// A lesson that has been accepted and stored. - Stored { - name: String, - content: String, - lifecycle: String, - scope: String, - description: String, - }, -} - -/// Read lesson markdown files from the `lessons/` subdirectory under the -/// memory directory. -#[tracing::instrument(skip(state))] -pub fn get_learning_items(state: &AppStateRest) -> Vec { - let lessons_dir = state.memory_dir.join("lessons"); - if !lessons_dir.exists() { - return Vec::new(); - } - let mut items = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&lessons_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("md") { - if let Ok(content) = std::fs::read_to_string(&path) { - items.push(LearningItem::Stored { - name: path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(), - content, - lifecycle: "filesystem".to_string(), - scope: "filesystem".to_string(), - description: String::new(), - }); - } - } - } - } - items -} - -// --------------------------------------------------------------------------- -// Generic helpers -// --------------------------------------------------------------------------- - -/// Cycle the selected index within bounds. -pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize { - if n == 0 { - return 0; - } - if forward { - (current + 1) % n - } else if current == 0 { - n - 1 - } else { - current - 1 - } -} - -/// Return the number of rewind points available. -pub fn rewind_count(state: &AppStateRest) -> usize { - state.transcript_cache.messages.len() -} - -// --------------------------------------------------------------------------- -// Token counting -// --------------------------------------------------------------------------- - -/// Resolve the window size for context window management. -/// -/// Uses the model name from settings to determine max context window, -/// falling back to settings-configured max or 128k default. -#[tracing::instrument(skip(_app_config, settings))] -pub fn resolve_context_window( - _app_config: &zesdex_domain::cms::AppConfig, - settings: &zesdex_domain::cms::Settings, -) -> usize { - if let Some(max) = settings.max_tokens { - if max > 0 { - return max as usize; - } - } - 256_000 -} - -/// Count tokens using tiktoken, fall back to character estimation. -/// -/// Flow: try tiktoken-rs `cl100k_base` BPE encoding \u{2192} return accurate count. -/// On failure (~4 chars per token heuristic), fall back to character-based -/// estimation so the UI never blocks on an unavailable tokeniser. -#[tracing::instrument] -pub fn count_tokens(text: &str) -> usize { - if let Ok(bpe) = tiktoken_rs::cl100k_base() { - return bpe.encode_with_special_tokens(text).len(); - } - // Fallback: ~4 chars per token - text.len().div_ceil(4) -} diff --git a/apps/interfaces/tui/src/state/input.rs b/apps/interfaces/tui/src/state/input.rs deleted file mode 100644 index c2088b9..0000000 --- a/apps/interfaces/tui/src/state/input.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Chat input buffer, cursor, history, and autocomplete state. - -use std::path::PathBuf; - -/// Which source populated the autocomplete dropdown. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutocompleteKind { - /// Builtin slash-command (e.g. `/model`, `/help`). - Command, - /// `@file` mention from the workspace file index. - FileMention, -} - -/// Builtin slash-commands recognised by the chat input autocomplete. -const COMMANDS: &[&str] = &[ - "/help", - "/quit", - "/clear", - "/login", - "/login zen", - "/login openai", - "/edit", - "/mcp add", - "/model", - "/model ls", - "/model add", - "/todo", - "/usage", - "/compact", -]; - -/// The user's input buffer, cursor position, history, and autocomplete -/// state for the chat prompt. -#[derive(Debug, Clone)] -pub struct InputState { - /// Raw UTF-8 input buffer content. - pub buffer: String, - /// Byte offset of the cursor within `buffer`. - pub cursor: usize, - /// Previously submitted input lines, oldest-first. - pub history: Vec, - /// Index into `history` when browsing (None = at the current input). - pub history_idx: Option, - /// Current autocomplete candidate list. - pub autocomplete_candidates: Vec, - /// Focused index within `autocomplete_candidates`. - pub autocomplete_idx: usize, - /// Whether the autocomplete dropdown is visible. - pub autocomplete_visible: bool, - /// Which kind of autocomplete is active. - pub autocomplete_kind: AutocompleteKind, - /// Byte offset of the `@` character that triggered file mention autocomplete. - pub mention_start: usize, - /// Optional path to a persistent history file. - pub history_file: Option, -} - -impl InputState { - /// Create an empty input state. - pub fn new() -> Self { - InputState { - buffer: String::new(), - cursor: 0, - history: Vec::new(), - history_idx: None, - autocomplete_candidates: Vec::new(), - autocomplete_idx: 0, - autocomplete_visible: false, - autocomplete_kind: AutocompleteKind::Command, - mention_start: 0, - history_file: None, - } - } - - /// Hide the autocomplete dropdown and clear its state. - pub fn close_autocomplete(&mut self) { - self.autocomplete_visible = false; - self.autocomplete_candidates.clear(); - self.autocomplete_idx = 0; - self.autocomplete_kind = AutocompleteKind::Command; - self.mention_start = 0; - } - - /// Open or refresh the autocomplete dropdown by filtering `COMMANDS`. - pub fn open_autocomplete(&mut self) { - let trimmed = self.buffer.trim().to_string(); - if trimmed.is_empty() || !trimmed.starts_with('/') { - self.close_autocomplete(); - return; - } - let prefix = trimmed.to_lowercase(); - self.autocomplete_candidates = COMMANDS - .iter() - .filter(|c| c.starts_with(&prefix)) - .map(std::string::ToString::to_string) - .collect(); - self.autocomplete_kind = AutocompleteKind::Command; - self.autocomplete_idx = 0; - self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); - } - - /// Find the `@mention` token (if any) immediately before the cursor. - pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> { - let before_cursor = &self.buffer[..self.cursor]; - let at_pos = before_cursor.rfind('@')?; - let between = &before_cursor[at_pos + 1..]; - if between.chars().any(char::is_whitespace) { - return None; - } - let boundary_ok = at_pos == 0 - || before_cursor[..at_pos] - .chars() - .next_back() - .is_some_and(char::is_whitespace); - if !boundary_ok { - return None; - } - Some((at_pos, between.to_string())) - } - - /// Open or refresh the `@file` mention dropdown from `files`. - pub fn open_mention_autocomplete(&mut self, files: &[String]) { - use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; - use nucleo_matcher::{Config, Matcher}; - let Some((start, query)) = self.mention_query_at_cursor() else { - self.close_autocomplete(); - return; - }; - let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); - let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart); - let matched_files = pattern.match_list(files.iter(), &mut matcher); - self.autocomplete_candidates = matched_files - .into_iter() - .take(10) - .map(|(f, _)| f.clone()) - .collect(); - self.autocomplete_kind = AutocompleteKind::FileMention; - self.mention_start = start; - self.autocomplete_idx = 0; - self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); - } - - /// Move the autocomplete selection up (forward=false) or down (forward=true). - pub fn cycle_autocomplete(&mut self, forward: bool) { - let n = self.autocomplete_candidates.len(); - if n == 0 { - return; - } - if forward { - self.autocomplete_idx = (self.autocomplete_idx + 1) % n; - } else { - self.autocomplete_idx = if self.autocomplete_idx == 0 { - n - 1 - } else { - self.autocomplete_idx - 1 - }; - } - } - - /// Accept the currently selected autocomplete candidate. - pub fn select_autocomplete(&mut self) -> bool { - let Some(candidate) = self - .autocomplete_candidates - .get(self.autocomplete_idx) - .cloned() - else { - return false; - }; - match self.autocomplete_kind { - AutocompleteKind::Command => { - self.buffer = candidate; - self.cursor = self.buffer.len(); - } - AutocompleteKind::FileMention => { - if self.cursor < self.mention_start || self.mention_start > self.buffer.len() { - self.close_autocomplete(); - return false; - } - let replacement = format!("@{candidate} "); - self.buffer - .replace_range(self.mention_start..self.cursor, &replacement); - self.cursor = self.mention_start + replacement.len(); - } - } - self.close_autocomplete(); - true - } - - /// Tab-complete: open dropdown or cycle forward. - pub fn tab_complete(&mut self) { - if self.autocomplete_visible { - self.cycle_autocomplete(true); - } else { - self.open_autocomplete(); - } - } - - /// Insert a character at the cursor position. - pub fn insert(&mut self, c: char) { - self.buffer.insert(self.cursor, c); - self.cursor += c.len_utf8(); - } - - /// Delete the character to the left of the cursor (backspace). - pub fn delete_left(&mut self) { - if self.cursor > 0 { - self.cursor -= 1; - self.buffer.remove(self.cursor); - } - } - - /// Delete the character at the cursor position (forward delete). - pub fn delete_right(&mut self) { - if self.cursor < self.buffer.len() { - self.buffer.remove(self.cursor); - } - } - - /// Submit the current buffer and return the submitted text. - pub fn submit(&mut self) -> String { - let result = self.buffer.clone(); - if !result.is_empty() { - if self.history.last() != Some(&result) { - self.history.push(result.clone()); - if let Some(ref path) = self.history_file { - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - use std::io::Write; - let _ = writeln!(file, "{result}"); - } - } - } - self.history_idx = None; - } - self.buffer.clear(); - self.cursor = 0; - result - } - - /// Navigate backward through input history. - pub fn history_up(&mut self) { - if self.history.is_empty() { - return; - } - let idx = match self.history_idx { - Some(i) if i > 0 => i - 1, - None => self.history.len() - 1, - Some(_) => return, - }; - self.history_idx = Some(idx); - self.buffer = self.history[idx].clone(); - self.cursor = self.buffer.len(); - } - - /// Navigate forward through input history. - pub fn history_down(&mut self) { - match self.history_idx { - Some(i) if i < self.history.len() - 1 => { - let idx = i + 1; - self.history_idx = Some(idx); - self.buffer = self.history[idx].clone(); - self.cursor = self.buffer.len(); - } - Some(_) => { - self.history_idx = None; - self.buffer.clear(); - self.cursor = 0; - } - None => {} - } - } -} - -impl Default for InputState { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/interfaces/tui/src/state/misc.rs b/apps/interfaces/tui/src/state/misc.rs deleted file mode 100644 index 8154e68..0000000 --- a/apps/interfaces/tui/src/state/misc.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Miscellaneous state: overlay enum, misc state bag, editor state. - -use std::path::PathBuf; -use zesdex_infrastructure::Toast; - -/// Which modal overlay, if any, is currently shown over the main TUI view. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Overlay { - /// No overlay; the main chat view is shown. - None, - /// Key bindings help screen. - Help, - /// Settings/configuration panel. - Settings, - /// Background bash job viewer. - Bash, - /// "Are you sure you want to quit?" confirmation. - QuitConfirm, - /// Raw key-code input capture (for binding custom keys). - KeyInput, - /// Inline editor (opened via `/edit`). - Editor, - /// Reasoning effort level selector. - Effort, - /// MCP server management panel. - Mcp, - /// Task list overlay. - Todo, - /// Project plan overlay. - Plan, - /// Session rewind / history scrubber. - Rewind, - /// Learning / lesson management panel. - Learning, - /// Token usage statistics panel. - Usage, - /// Generic loading spinner overlay. - Loading, - /// Model selector dropdown. - ModelSelector, - /// "Clear conversation?" confirmation. - ClearConfirm, - /// Git diff preview overlay. - Diff, -} - -impl Overlay { - /// Human-readable name for this overlay variant. - pub fn as_str(&self) -> &'static str { - match self { - Overlay::None => "none", - Overlay::Help => "help", - Overlay::Settings => "settings", - Overlay::Bash => "bash", - Overlay::QuitConfirm => "quit_confirm", - Overlay::KeyInput => "key_input", - Overlay::Editor => "editor", - Overlay::Effort => "effort", - Overlay::Mcp => "mcp", - Overlay::Todo => "todo", - Overlay::Plan => "plan", - Overlay::Rewind => "rewind", - Overlay::Learning => "learning", - Overlay::Usage => "usage", - Overlay::Loading => "loading", - Overlay::ModelSelector => "model_selector", - Overlay::ClearConfirm => "clear_confirm", - Overlay::Diff => "diff", - } - } - - /// Whether any overlay (i.e. anything other than `None`) is active. - pub fn is_active(self) -> bool { - !matches!(self, Overlay::None) - } -} - -impl std::fmt::Display for Overlay { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Simple inline editor state for the TUI. -#[derive(Debug, Clone)] -pub struct EditorState { - /// Path to the file being edited. - pub path: PathBuf, - /// Current buffer content. - pub content: String, - /// Cursor position (byte offset). - pub cursor: usize, -} - -impl EditorState { - /// Create a new editor state for the given path. - pub fn new(path: PathBuf, content: String) -> Self { - let cursor = content.len(); - EditorState { - path, - content, - cursor, - } - } - - /// Return the full buffer content. - pub fn as_string(&self) -> String { - self.content.clone() - } - - /// Delete one character to the left of the cursor. - pub fn delete_left(&mut self) { - if self.cursor > 0 { - self.cursor -= 1; - self.content.remove(self.cursor); - } - } -} - -/// The "miscellaneous" slice of app state. -#[derive(Debug, Clone)] -pub struct MiscState { - /// Currently active modal overlay (None = main chat view). - pub overlay: Overlay, - /// Active toast notifications. - pub toasts: Vec, - /// Timestamp (ms) of the last staleness sweep for lesson cache. - pub last_staleness_sweep_ms: i64, - /// Whether the agent is currently "thinking". - pub thinking: bool, - /// Current LLM reasoning effort level (1-5). - pub effort_level: usize, - /// Currently focused index in list-type overlays. - pub selected_index: usize, - /// Optional inline editor state. - pub editor: Option, - /// Whether the API connection is established. - pub api_connected: bool, - /// Monotonically increasing tick count, incremented each render frame. - pub tick_count: u64, - /// Cached content of the TODO file. - pub todo_content: String, - /// Cached content of the PLAN file. - pub plan_content: String, - /// Whether a lesson background task is currently running. - pub lesson_running: bool, - /// Text waiting to be written to the system clipboard. - pub pending_clipboard_copy: Option, - /// Cached git diff content for the preview overlay. - pub diff_content: String, - /// Scroll offset for the diff overlay. - pub diff_scroll: usize, -} - -impl MiscState { - /// Create a fresh `MiscState` with no overlay, no toasts. - pub fn new() -> Self { - MiscState { - overlay: Overlay::None, - toasts: Vec::new(), - last_staleness_sweep_ms: 0, - thinking: false, - effort_level: 1, - selected_index: 0, - editor: None, - api_connected: false, - tick_count: 0, - todo_content: String::new(), - plan_content: String::new(), - lesson_running: false, - pending_clipboard_copy: None, - diff_content: String::new(), - diff_scroll: 0, - } - } - - /// Append a toast notification to the active list. - pub fn push_toast(&mut self, toast: Toast) { - self.toasts.push(toast); - } - - /// Remove all toasts whose lifetime has expired at `now_ms`. - pub fn drain_expired_toasts(&mut self, now_ms: i64) { - self.toasts.retain(|t| !t.expired(now_ms)); - } -} - -impl Default for MiscState { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/interfaces/tui/src/state/mod.rs b/apps/interfaces/tui/src/state/mod.rs deleted file mode 100644 index 4066e90..0000000 --- a/apps/interfaces/tui/src/state/mod.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! TUI-perspective application state: `AppStateRest` and all the types it -//! owns. This is the single source-of-truth struct for the TUI interface, -//! mutated from `action::apply_action` and read by `view/` every render frame. -//! -//! # Organisation -//! -//! ```text -//! state/ -//! ├── mod.rs — AppStateRest (the central struct) + re-exports -//! ├── input.rs — InputState, AutocompleteKind -//! ├── transcript.rs — TranscriptCache, ChatMessageDisplay -//! ├── scroll.rs — ScrollState -//! ├── misc.rs — MiscState, EditorState, Overlay -//! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine -//! └── helpers.rs — Standalone functions operating on AppStateRest -//! ``` -//! -//! # Flow -//! Construction in `run.rs::create_local_session` \u{2192} mutated by -//! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function. - -use std::collections::VecDeque; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use tracing::warn; - -use ratatui::text::Line; -use zesdex_domain::cms::{AppConfig, Settings}; -use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent}; - -pub mod helpers; -pub mod input; -pub mod misc; -pub mod scroll; -pub mod transcript; -pub mod workflow; - -// Re-export all public types from sub-modules at the `state` level so -// consumers that previously used `crate::state::InputState` etc. still work. -pub use input::{AutocompleteKind, InputState}; -pub use misc::{EditorState, MiscState, Overlay}; -pub use scroll::ScrollState; -pub use transcript::{ChatMessageDisplay, TranscriptCache}; -pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine}; - -// Re-export the most commonly used helpers at the `state` level. -pub use helpers::{ - count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items, - resolve_context_window, rewind_count, LearningItem, EFFORT_LEVELS, -}; - -// --------------------------------------------------------------------------- -// AppStateRest — the single source-of-truth TUI state -// --------------------------------------------------------------------------- - -/// The single source-of-truth state struct for the TUI interface. -/// -/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`). -/// Read-only from every `view/*` render function. -#[derive(Clone)] -pub struct AppStateRest { - /// Persistent user settings. - pub settings: Settings, - /// Per-project app configuration. - pub app_config: AppConfig, - /// Absolute paths to each open workspace root directory. - pub workspace_roots: Vec, - /// Unique session identifier. - pub session_id: String, - /// Path to the session's data directory. - pub session_dir: PathBuf, - /// Path to the session memory directory. - pub memory_dir: PathBuf, - /// Path to the git worktrees directory. - pub worktrees_dir: PathBuf, - /// Shared async cache of directory listings. - pub dir_cache: Arc>, - /// Shared workspace file-path index for `@file` mention autocomplete. - pub mention_index: MentionIndex, - /// Optional per-session runtime state. - pub session_runtime: Option, - /// Ring buffer of recent chat messages for the transcript pane. - pub transcript_cache: TranscriptCache, - /// Viewport scroll offset tracker. - pub scroll: ScrollState, - /// Chat input buffer, cursor, history, and autocomplete. - pub input: InputState, - /// Miscellaneous state: overlay, toasts, flags, editor, tick. - pub misc: MiscState, - /// Queue of events emitted by the running agent turn. - pub turn_events: Arc>>, - /// Whether an agent turn is currently in flight. - /// Uses AtomicBool for lock-free check from render loop. - pub turn_in_flight_flag: Arc, - /// Cached display lines for the chat transcript panel. - pub display_lines_cache: Vec>, - /// Cached token count for the current message history. - pub cached_token_count: usize, - /// Whether the token count cache is stale and needs recalculation. - pub token_count_dirty: bool, - /// Terminal width at the time of the last display_lines_cache rebuild. - pub last_render_width: u16, - /// Number of messages that were in the cache when it was last built. - pub cached_msg_count: usize, - /// Cache index where the last message's rendered lines begin. Used to - /// splice streaming updates (only re-render the trailing message). - pub cached_last_start: usize, - /// Content+reasoning byte length of the last message when it was last - /// rendered. Guards the streaming branch from re-rendering on ticks - /// where no new token arrived (spinner-only frames). - pub cached_last_len: usize, - /// Terminal width at the time of the last full cache build. - pub render_width_at_cache: u16, - /// Atomic flag set when the user aborts the current turn. - pub abort_flag: Arc, - /// Simplified workflow engine state for display. - pub workflow_engine: SimpleWorkflowEngine, - /// Whether the state has been modified since the last render sweep. - pub dirty: bool, - /// Whether the application has been requested to quit. - pub quit: bool, - /// Cached help text content. - pub help_text: &'static str, -} - -/// Default help text shown in the Help overlay. -pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI \u{2014} Keyboard Shortcuts - - \u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500} - Ctrl+C Quit confirm - Ctrl+D Close overlay - Ctrl+Y Copy last assistant message - Esc Abort turn / Close overlay - Tab Autocomplete - - \u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500} - \u{2191} / \u{2193} History browse / Overlay navigate - Ctrl+\u{2191}/\u{2193} Scroll transcript - PgUp / PgDown Scroll transcript - Enter Submit / Select autocomplete - - \u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500} - /help Show this help - /settings Open settings overlay - /todo Open tasks (todo) overlay - /usage Open usage statistics - /bash Open bash jobs overlay - /mcp Open MCP server management - /model Open model selector - /compact Compact conversation - /clear Clear transcript - /rewind Rewind conversation history - - \u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500} - /edit Open file for inline editing - Ctrl+S Save changes - Esc Dismiss editor -"#; - -impl Default for AppStateRest { - fn default() -> Self { - AppStateRest { - settings: Settings::default(), - app_config: AppConfig::default(), - workspace_roots: Vec::new(), - session_id: String::new(), - session_dir: PathBuf::new(), - memory_dir: PathBuf::new(), - worktrees_dir: PathBuf::new(), - dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), - mention_index: MentionIndex::new(), - session_runtime: None, - transcript_cache: TranscriptCache::new(200), - scroll: ScrollState::new(), - input: InputState::new(), - misc: MiscState::new(), - turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())), - turn_in_flight_flag: Arc::new(AtomicBool::new(false)), - abort_flag: Arc::new(AtomicBool::new(false)), - workflow_engine: SimpleWorkflowEngine::new(), - dirty: true, - quit: false, - help_text: DEFAULT_HELP_TEXT, - display_lines_cache: Vec::new(), - cached_token_count: 0, - token_count_dirty: true, - last_render_width: 0, - cached_msg_count: 0, - cached_last_start: 0, - cached_last_len: 0, - render_width_at_cache: 0, - } - } -} - -impl AppStateRest { - /// Construct initial TUI state. - pub fn new( - workspace_roots: Vec, - session_dir: &std::path::Path, - memory_dir: PathBuf, - ) -> Self { - let settings = Settings::default(); - let app_config = AppConfig::default(); - let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees"); - let session_id = session_dir.file_name().map_or_else( - || { - warn!("[state] session_dir has no file_name, using empty session_id"); - String::new() - }, - |n| n.to_string_lossy().to_string(), - ); - - AppStateRest { - settings, - app_config, - workspace_roots, - session_id, - session_dir: session_dir.to_path_buf(), - memory_dir: memory_dir.clone(), - worktrees_dir, - turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())), - turn_in_flight_flag: Arc::new(AtomicBool::new(false)), - abort_flag: Arc::new(AtomicBool::new(false)), - dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), - mention_index: MentionIndex::new(), - session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())), - workflow_engine: SimpleWorkflowEngine::new(), - transcript_cache: TranscriptCache::new(200), - scroll: ScrollState::new(), - input: InputState::new(), - misc: MiscState::new(), - dirty: true, - quit: false, - help_text: DEFAULT_HELP_TEXT, - display_lines_cache: Vec::new(), - cached_token_count: 0, - token_count_dirty: true, - last_render_width: 0, - cached_msg_count: 0, - cached_last_start: 0, - cached_last_len: 0, - render_width_at_cache: 0, - } - } - - /// Whether an agent turn is currently running. - /// Uses lock-free AtomicBool load \u{2014} safe to call every render frame. - pub fn turn_in_flight(&self) -> bool { - self.turn_in_flight_flag.load(Ordering::Relaxed) - } - - /// Append a message to the transcript. - /// Eviction is O(1) via VecDeque::pop_front. - pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { - self.transcript_cache.push(msg); - self.token_count_dirty = true; - self.dirty = true; - } - - /// Append text to the last assistant message in the transcript, if one exists. - pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) { - self.transcript_cache.append_to_last(text, is_reasoning); - if self.transcript_cache.dirty { - self.dirty = true; - } - } - - /// Mark the app state as dirty, triggering a TUI re-render. - pub fn mark_dirty(&mut self) { - self.dirty = true; - } - - /// Queue a toast notification. - pub fn push_toast(&mut self, toast: Toast) { - self.misc.push_toast(toast); - self.mark_dirty(); - } - - /// Push an info toast. - pub fn toast_info(&mut self, msg: impl Into) { - self.push_toast(Toast::new( - zesdex_infrastructure::ToastKind::Info, - msg.into(), - )); - } - - /// Push a success toast. - pub fn toast_success(&mut self, msg: impl Into) { - self.push_toast(Toast::new( - zesdex_infrastructure::ToastKind::Success, - msg.into(), - )); - } - - /// Push a warning toast. - pub fn toast_warning(&mut self, msg: impl Into) { - self.push_toast(Toast::new( - zesdex_infrastructure::ToastKind::Warning, - msg.into(), - )); - } - - /// Push an error toast. - pub fn toast_error(&mut self, msg: impl Into) { - self.push_toast(Toast::new( - zesdex_infrastructure::ToastKind::Error, - msg.into(), - )); - } - - /// Persist settings to disk. - pub fn save_settings(&self) { - if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) { - let repo = - zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new( - ); - use zesdex_domain::SettingsRepository; - if let Err(e) = repo.save(&store_dir, &self.settings) { - tracing::warn!("Failed to save settings: {e}"); - } - } - } - - /// Resolve the base directory for session stores. - pub fn store_base_dir(&self) -> PathBuf { - self.session_dir - .parent() - .and_then(|p| p.parent()) - .map_or_else( - || { - warn!("[state] no grandparent, using session_dir"); - self.session_dir.clone() - }, - std::path::Path::to_path_buf, - ) - } -} diff --git a/apps/interfaces/tui/src/state/scroll.rs b/apps/interfaces/tui/src/state/scroll.rs deleted file mode 100644 index f07f82e..0000000 --- a/apps/interfaces/tui/src/state/scroll.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Viewport scroll state: current offset and visible-line count. - -/// Viewport scroll state: current offset and visible-line count. -#[derive(Debug, Clone)] -pub struct ScrollState { - /// Current scroll offset (how many lines have been scrolled past). - pub offset: usize, - /// Maximum number of lines that fit in the visible viewport area. - pub max_visible: usize, -} - -impl ScrollState { - /// Create a `ScrollState` with zero offset and 30 rows visible. - pub fn new() -> Self { - ScrollState { - offset: 0, - max_visible: 30, - } - } - - /// Scroll the viewport up by `amount` lines (increasing the offset). - pub fn scroll_up(&mut self, amount: usize) { - self.offset = self.offset.saturating_add(amount); - } - - /// Scroll the viewport down by `amount` lines (decreasing the offset). - pub fn scroll_down(&mut self, amount: usize) { - self.offset = self.offset.saturating_sub(amount); - } -} - -impl Default for ScrollState { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/interfaces/tui/src/state/transcript.rs b/apps/interfaces/tui/src/state/transcript.rs deleted file mode 100644 index a48b2e5..0000000 --- a/apps/interfaces/tui/src/state/transcript.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Transcript display types: bounded ring-buffer cache of rendered messages. - -use std::collections::VecDeque; -use zesdex_domain::core::Role; - -/// A single transcript entry rendered in the TUI chat pane. -#[derive(Debug, Clone, PartialEq)] -pub struct ChatMessageDisplay { - /// Message author: User or Assistant. - pub role: Role, - /// Rendered text content (plain text, no markdown). - pub content: String, - /// Model reasoning (e.g. from DeepSeek-R1 block). - pub reasoning: String, - /// Millisecond timestamp when this display entry was created. - pub timestamp: i64, -} - -impl ChatMessageDisplay { - /// Build a display entry, stamping it with the current time. - pub fn new(role: Role, content: String) -> Self { - ChatMessageDisplay { - role, - content, - reasoning: String::new(), - timestamp: chrono::Utc::now().timestamp_millis(), - } - } -} - -/// Bounded ring of recent chat messages used to render the transcript view. -#[derive(Debug, Clone)] -pub struct TranscriptCache { - /// Ordered display messages (newest appended, oldest evicted when full). - /// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0). - pub messages: VecDeque, - /// Maximum messages to retain before evicting the oldest. - pub max_lines: usize, - /// Whether the cache has changed since the last render sweep. - pub dirty: bool, -} - -impl TranscriptCache { - /// Create an empty transcript cache holding at most `max_lines` messages. - pub fn new(max_lines: usize) -> Self { - TranscriptCache { - messages: VecDeque::new(), - max_lines, - dirty: true, - } - } - - /// Append a message, evicting the oldest if at capacity. - /// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0). - pub fn push(&mut self, msg: ChatMessageDisplay) { - self.messages.push_back(msg); - while self.messages.len() > self.max_lines { - self.messages.pop_front(); - } - self.dirty = true; - } - - /// Append text to the last assistant message, if one exists. - pub fn append_to_last(&mut self, text: &str, is_reasoning: bool) { - if let Some(msg) = self.messages.back_mut() { - if msg.role == Role::Assistant { - if is_reasoning { - msg.reasoning.push_str(text); - } else { - msg.content.push_str(text); - } - self.dirty = true; - } - } - } -} diff --git a/apps/interfaces/tui/src/state/workflow.rs b/apps/interfaces/tui/src/state/workflow.rs deleted file mode 100644 index f7e1a93..0000000 --- a/apps/interfaces/tui/src/state/workflow.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Simplified agent lifecycle display types for the workflow sidebar. - -/// Simplified agent lifecycle state for TUI display. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AgentState { - Idle, - Running, - Completed, - Failed, -} - -/// A single agent entry in the workflow sidebar. -#[derive(Debug, Clone)] -pub struct SimpleAgent { - /// Agent unique ID (e.g. "auto-review", "Node-0-1"). - pub name: String, - /// Human-readable display label (e.g. "Auto-Review", "Backend API Agent"). - pub display_name: String, - /// Current lifecycle state. - pub state: AgentState, - /// Millisecond timestamp when the agent started. - pub started_at: Option, - /// Millisecond timestamp when the agent completed. - pub completed_at: Option, - /// Optional error message if the agent failed. - pub error: Option, - /// Optional progress text (current tool, step description). - pub progress: Option, -} - -impl SimpleAgent { - /// Create a new agent with the given name (used as both ID and display name). - pub fn new(name: String) -> Self { - SimpleAgent { - display_name: name.clone(), - name, - state: AgentState::Idle, - started_at: None, - completed_at: None, - error: None, - progress: None, - } - } - - /// Create a new agent with separate ID and display label. - pub fn with_display(name: String, display_name: String) -> Self { - SimpleAgent { - name, - display_name, - state: AgentState::Idle, - started_at: None, - completed_at: None, - error: None, - progress: None, - } - } -} - -/// Simplified workflow engine state for TUI display. -#[derive(Debug, Clone)] -pub struct SimpleWorkflowEngine { - /// Active agents in the workflow. - pub agents: Vec, - /// Summary findings produced by completed agents. - pub findings: Vec, -} - -impl SimpleWorkflowEngine { - /// Create an empty workflow engine state. - pub fn new() -> Self { - SimpleWorkflowEngine { - agents: Vec::new(), - findings: Vec::new(), - } - } -} - -impl Default for SimpleWorkflowEngine { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs deleted file mode 100644 index 7e1e091..0000000 --- a/apps/interfaces/tui/src/turn.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! TUI agent turn interface adapter — resolves LLM provider configuration, -//! builds the tool context, and spawns the agent turn on a background task. - -use std::sync::atomic::Ordering; -use tracing::info; - -use zesdex_application::agent::turn_service::AgentTurnServiceImpl; -use zesdex_application::agent::AgentTurnService; -use zesdex_domain::agent::AgentTurnParams; -use zesdex_domain::core::ChatMessage; -use zesdex_infrastructure::llm::provider::LlmClient; -use zesdex_infrastructure::tools::executor::InfrastructureToolExecutor; -use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx}; - -use crate::state::AppStateRest; - -// --------------------------------------------------------------------------- -// Provider resolution -// --------------------------------------------------------------------------- - -/// Resolve the API key from settings or environment for the given provider. -fn resolve_api_key(state: &AppStateRest, provider_name: &str) -> String { - if let Some(key) = state.settings.api_keys.get(provider_name) { - return key.clone(); - } - if let Some(cfg) = state.app_config.providers.get(provider_name) { - if let Some(ref default_key) = cfg.default_api_key { - if !default_key.is_empty() { - return default_key.clone(); - } - } - if let Some(ref env_name) = cfg.api_key_env { - if let Ok(val) = std::env::var(env_name) { - return val; - } - } - } - String::new() -} - -/// Resolve the API base URL from the provider config. -fn resolve_api_base(state: &AppStateRest, provider_name: &str) -> Option { - state - .app_config - .providers - .get(provider_name) - .map(|cfg| cfg.api_base.clone()) -} - -// --------------------------------------------------------------------------- -// Turn spawning -// --------------------------------------------------------------------------- - -/// Spawn an agent turn on a background Tokio task. -/// -/// Flow: -/// 1. Compare-exchange the in-flight flag (no-op if already running). -/// 2. Resolve provider (API key, model, base URL) from settings. -/// 3. Build messages including the user's input text. -/// 4. Construct `AgentTurnParams` with the turn-event queue and abort flag. -/// 5. Create `LlmClient`, `ToolCtx`, and `InfrastructureToolExecutor`. -/// 6. Assemble `AgentTurnServiceImpl` and spawn it via `tokio::spawn`. -#[tracing::instrument(skip(state))] -pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { - // Only one turn at a time — compare_exchange is lock-free - if state - .turn_in_flight_flag - .compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed) - .is_err() - { - return; - } - - let turn_events = state.turn_events.clone(); - let in_flight = state.turn_in_flight_flag.clone(); - let abort = state.abort_flag.clone(); - let session_dir = state.session_dir.clone(); - let workspace_roots = state.workspace_roots.clone(); - - // ── Resolve provider configuration ───────────────────────────────── - let provider_name = &state.settings.provider; - let api_key = resolve_api_key(state, provider_name); - let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config); - let api_base = resolve_api_base(state, provider_name); - - // ── Build message list ───────────────────────────────────────────── - let mut messages: Vec = state - .session_runtime - .as_ref() - .map(|rt| rt.messages.clone()) - .unwrap_or_default(); - messages.push(ChatMessage::user(text)); - - if let Some(ref mut rt) = state.session_runtime { - rt.messages = messages.clone(); - } - - info!("Spawning agent turn (model: {model})"); - - // ── Assemble dependencies (composition root) ────────────────────── - let params = AgentTurnParams { - messages, - session_dir: session_dir.clone(), - workspace_roots: workspace_roots.clone(), - turn_events: turn_events.clone(), - in_flight: in_flight.clone(), - abort: abort.clone(), - api_key: api_key.clone(), - model: model.clone(), - api_base: api_base.clone(), - }; - - // Capture for the optional background auto-review before `api_key` / - // `model` / `api_base` move into the client below. The reviewer is - // fire-and-forget and skips itself when the workspace has no diff. - let review_enabled = state.settings.flags.review_enabled; - let review_key = api_key.clone(); - let review_model = model.clone(); - let review_base = api_base.clone(); - let review_ws = workspace_roots.clone(); - let review_events = turn_events.clone(); - - let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base)); - - let tool_ctx = ToolCtx::builder() - .session_dir(session_dir) - .workspaces(workspace_roots) - .turn_events(turn_events) - .build(); - - let tool_executor = std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx)); - - let tools = all_tools(); - let defs = tool_defs(&tools); - - let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs); - - tokio::spawn(async move { - let _ = turn_service.run_turn(params).await; - if review_enabled { - zesdex_infrastructure::subagent::auto::engine::spawn_background_review( - review_ws, - review_events, - review_key, - review_model, - review_base, - ); - } - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_spawn_agent_turn_with_tokio_runtime() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let _guard = rt.enter(); - - let temp_dir = std::env::temp_dir().join(format!("zesdex_test_{}", uuid::Uuid::new_v4())); - let session_dir = temp_dir.join("session"); - let memory_dir = temp_dir.join("memory"); - std::fs::create_dir_all(&session_dir).unwrap(); - std::fs::create_dir_all(&memory_dir).unwrap(); - - let workspace_roots = vec![temp_dir.clone()]; - let mut state = AppStateRest::new(workspace_roots, &session_dir, memory_dir); - - spawn_agent_turn(&mut state, "hello".to_string()); - assert!(state.turn_in_flight()); - - let _ = std::fs::remove_dir_all(&temp_dir); - } -} diff --git a/apps/interfaces/tui/src/view/chat.rs b/apps/interfaces/tui/src/view/chat.rs deleted file mode 100644 index 490e39b..0000000 --- a/apps/interfaces/tui/src/view/chat.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Chat transcript panel rendering — tight inline log style. -//! -//! Performance design: -//! - `display_lines_cache` menyimpan Vec per pesan (indexed by message position) -//! - Saat pesan baru masuk, hanya pesan BARU yang di-render, bukan rebuild seluruh history -//! - `draw_chat` tidak pernah clone seluruh cache — hanya slice window visible yang di-ambil -//! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan - -use super::theme::Theme; -use crate::components::Component; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; -use ratatui::Frame; -use zesdex_domain::core::Role; - -#[derive(Default)] -pub struct ChatComponent; - -const PREFIX_WIDTH: usize = 15; - -fn role_accent_color(role: &Role) -> Color { - match role { - Role::User => Theme::ROLE_USER, - Role::Assistant => Theme::ROLE_ASSISTANT, - Role::System => Theme::ROLE_SYSTEM, - Role::Tool => Theme::ROLE_TOOL, - } -} - -fn format_role_label(role: &Role) -> &'static str { - match role { - Role::User => "👤 you ", - Role::Assistant => "🤖 ai ", - Role::System => "💻 sys ", - Role::Tool => "🔧 tool", - } -} - -fn format_timestamp(ts: i64) -> String { - if ts <= 0 { - return String::new(); - } - let secs = ts / 1000; - let mins = (secs / 60) % 60; - let hrs = (secs / 3600) % 24; - format!("{hrs:02}:{mins:02}") -} - -/// Render satu pesan menjadi Vec>. -/// Dipanggil incremental — hanya untuk pesan baru, bukan seluruh history. -fn render_one_message( - msg: &crate::state::ChatMessageDisplay, - content_width: u16, -) -> Vec> { - let mut lines: Vec> = Vec::new(); - - if msg.role == Role::Tool { - let content = if msg.content.trim().is_empty() { - "(tool execution)".to_string() - } else { - msg.content.clone() - }; - let dim = Style::default().fg(Theme::TEXT_DIM); - let content_spans = super::markdown::render_markdown(&content, content_width, true); - let content_lines = split_spans_into_lines(content_spans); - let mut lines_iter = content_lines.into_iter(); - let first_spans = lines_iter.next().map_or_else(Vec::new, |l| l.spans); - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)]; - spans.extend(first_spans); - lines.push(Line::from(spans)); - for line in lines_iter { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(line.spans); - lines.push(Line::from(spans)); - } - return lines; - } - - let accent = role_accent_color(&msg.role); - let label = format_role_label(&msg.role); - let ts_str = format_timestamp(msg.timestamp); - let header_prefix = vec![ - Span::styled( - format!("{label} "), - Style::default().fg(accent).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{ts_str:<5} "), - Style::default().fg(Theme::TEXT_DIM), - ), - ]; - - let mut first_line_handled = false; - - if !msg.reasoning.trim().is_empty() { - let reasoning_str = format!("[Thinking...]\n{}", msg.reasoning.trim()); - let reasoning_spans = - super::markdown::render_markdown(&reasoning_str, content_width, false); - // Dim the reasoning text - let dimmed_spans: Vec = reasoning_spans - .into_iter() - .map(|mut s| { - s.style = s.style.fg(Theme::TEXT_DIM); - s - }) - .collect(); - let reasoning_lines = split_spans_into_lines(dimmed_spans); - let mut lines_iter = reasoning_lines.into_iter(); - - if let Some(first) = lines_iter.next() { - let mut spans = header_prefix.clone(); - spans.extend(first.spans); - lines.push(Line::from(spans)); - first_line_handled = true; - } - - for line in lines_iter { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(line.spans); - lines.push(Line::from(spans)); - } - } - - let content_str = if msg.content.trim().is_empty() { - if msg.reasoning.trim().is_empty() { - "(tool execution)".to_string() - } else { - String::new() - } - } else { - msg.content.clone() - }; - - if !content_str.is_empty() { - let content_spans = super::markdown::render_markdown(&content_str, content_width, false); - let content_lines = split_spans_into_lines(content_spans); - let mut lines_iter = content_lines.into_iter(); - - if !first_line_handled { - if let Some(first) = lines_iter.next() { - let mut spans = header_prefix.clone(); - spans.extend(first.spans); - lines.push(Line::from(spans)); - first_line_handled = true; - } - } else { - // Header already printed by reasoning, just indent - if let Some(first) = lines_iter.next() { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(first.spans); - lines.push(Line::from(spans)); - } - } - - for line in lines_iter { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(line.spans); - lines.push(Line::from(spans)); - } - } - - if !first_line_handled { - lines.push(Line::from(header_prefix)); - } - - lines -} - -impl Component for ChatComponent { - fn pre_render(&mut self, state: &mut crate::state::AppStateRest) { - let content_width = state - .last_render_width - .saturating_sub(PREFIX_WIDTH as u16 + 2); - let msg_count = state.transcript_cache.messages.len(); - let cached_count = state.cached_msg_count; - - let needs_full_rebuild = state.transcript_cache.dirty - && (state.last_render_width == 0 - || msg_count < cached_count // pesan di-evict dari depan - || state.render_width_at_cache != state.last_render_width); // resize - - if needs_full_rebuild { - // Full rebuild: parse semua pesan dari nol - let mut all_lines: Vec> = Vec::new(); - for msg in &state.transcript_cache.messages { - let msg_lines = render_one_message(msg, content_width); - all_lines.extend(msg_lines); - } - state.display_lines_cache = all_lines; - state.cached_msg_count = msg_count; - state.cached_last_start = state.display_lines_cache.len(); - state.cached_last_len = last_msg_len(state); - state.render_width_at_cache = state.last_render_width; - state.transcript_cache.dirty = false; - } else if state.transcript_cache.dirty && msg_count > cached_count { - // Incremental: hanya render pesan baru yang belum ada di cache - let new_msgs: Vec<_> = state - .transcript_cache - .messages - .iter() - .skip(cached_count) - .collect(); - for msg in new_msgs { - let msg_lines = render_one_message(msg, content_width); - state.display_lines_cache.extend(msg_lines); - } - state.cached_msg_count = msg_count; - state.cached_last_start = state.display_lines_cache.len(); - state.cached_last_len = last_msg_len(state); - state.transcript_cache.dirty = false; - } else if state.transcript_cache.dirty { - // Streaming update: message count unchanged, but the last - // assistant message's content grew (StreamToken). Re-render only - // that message and splice its lines back into the cache — this - // keeps updates O(new content), not O(whole history) per token. - // Without this branch, streamed tokens would never appear (the - // cache is only rebuilt on new-message or resize paths). - let cur_len = last_msg_len(state); - if cur_len != state.cached_last_len { - if let Some(last_msg) = state.transcript_cache.messages.back() { - if state.cached_last_start <= state.display_lines_cache.len() { - let new_lines = render_one_message(last_msg, content_width); - state.display_lines_cache.truncate(state.cached_last_start); - state.display_lines_cache.extend(new_lines); - } - } - state.cached_last_len = cur_len; - } - state.transcript_cache.dirty = false; - } - - // Lazily recompute token count — hanya saat ada pesan baru - if state.token_count_dirty { - if let Some(ref rt) = state.session_runtime { - state.cached_token_count = rt - .messages - .iter() - .filter_map(|m| m.content.as_deref()) - .map(crate::state::count_tokens) - .sum(); - } else { - state.cached_token_count = 0; - } - state.token_count_dirty = false; - } - } - - fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { - let messages = &state.transcript_cache.messages; - let scroll_offset = state.scroll.offset; - let max_visible = (area.height as usize).saturating_sub(3); - let cache = &state.display_lines_cache; - - // Hitung window tanpa clone - let total = cache.len(); - let spinner_extra = usize::from(state.turn_in_flight()); - let total_with_spinner = total + spinner_extra; - let max_offset = total_with_spinner.saturating_sub(max_visible); - let offset = scroll_offset.min(max_offset); - - let end_idx = total_with_spinner.saturating_sub(offset); - let start_idx = end_idx.saturating_sub(max_visible); - - // Kumpulkan hanya baris yang visible — tidak clone semua - let mut visible: Vec = Vec::with_capacity(max_visible); - let cache_end = end_idx.min(total); - let cache_start = start_idx.min(cache_end); - if cache_start < cache_end { - visible.extend_from_slice(&cache[cache_start..cache_end]); - } - - // Spinner hanya ditambahkan jika visible window mencakup posisi terakhir - if state.turn_in_flight() && end_idx > total { - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); - let spinner = spinner_frames[frame_idx]; - visible.push(Line::from(vec![ - Span::styled( - format!("{} ", format_role_label(&Role::Assistant)), - Style::default() - .fg(Theme::ROLE_ASSISTANT) - .add_modifier(Modifier::BOLD), - ), - Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)), - Span::styled( - "generating...", - Style::default() - .fg(Theme::TEXT_MUTED) - .add_modifier(Modifier::ITALIC), - ), - ])); - } - - let scroll_pct = if total_with_spinner > max_visible && max_offset > 0 { - ((offset as f64 / max_offset as f64) * 100.0) as u8 - } else { - 0 - }; - - let title = if scroll_pct > 0 { - format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct) - } else if messages.is_empty() { - String::from(" 💬 Chat ") - } else { - format!(" 💬 Chat [{} msgs] ", messages.len()) - }; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled( - title, - Style::default() - .fg(Theme::TEXT_MUTED) - .add_modifier(Modifier::BOLD), - )); - - let paragraph = Paragraph::new(visible) - .block(block) - .style(Style::default().bg(Theme::BG)); - - frame.render_widget(paragraph, area); - } -} - -fn split_spans_into_lines(spans: Vec>) -> Vec> { - let mut lines = Vec::new(); - let mut current_spans = Vec::new(); - for span in spans { - let text = span.content.as_ref(); - let mut parts = text.split('\n').peekable(); - while let Some(part) = parts.next() { - if !part.is_empty() { - current_spans.push(Span::styled(part.to_string(), span.style)); - } - if parts.peek().is_some() { - lines.push(Line::from(std::mem::take(&mut current_spans))); - } - } - } - if !current_spans.is_empty() { - lines.push(Line::from(current_spans)); - } - if lines.is_empty() { - lines.push(Line::from(vec![])); - } - lines -} - -/// Byte length of the last message's content + reasoning. Used as a cheap -/// change detector for the streaming render branch — avoids re-rendering on -/// spinner-only ticks where no new token arrived. -fn last_msg_len(state: &crate::state::AppStateRest) -> usize { - state - .transcript_cache - .messages - .back() - .map(|m| m.content.len() + m.reasoning.len()) - .unwrap_or(0) -} diff --git a/apps/interfaces/tui/src/view/input.rs b/apps/interfaces/tui/src/view/input.rs deleted file mode 100644 index 9e338a9..0000000 --- a/apps/interfaces/tui/src/view/input.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Chat input box and autocomplete dropdown rendering. -//! Implements the Component trait. - -use super::theme::Theme; -use crate::components::Component; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; -use ratatui::Frame; - -#[derive(Default)] -pub struct InputComponent; - -impl Component for InputComponent { - fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { - if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { - let n = state.input.autocomplete_candidates.len().min(10) as u16; - let dropdown_height = n + 2; - let dropdown_area = Rect { - x: area.x, - y: area.y.saturating_sub(dropdown_height), - width: area.width.min(45), - height: dropdown_height, - }; - let dropdown_title = match state.input.autocomplete_kind { - crate::state::AutocompleteKind::Command => " ⌘ Commands ", - crate::state::AutocompleteKind::FileMention => " 📁 Files ", - }; - let dropdown_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled( - dropdown_title, - Style::default() - .fg(Theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )) - .style(Style::default().bg(Theme::SURFACE_ELEVATED)); - - let mut lines: Vec = Vec::new(); - let selected = state.input.autocomplete_idx; - for (i, candidate) in state - .input - .autocomplete_candidates - .iter() - .enumerate() - .take(10) - { - let prefix = if i == selected { " ▸ " } else { " " }; - let style = if i == selected { - Style::default() - .fg(Theme::TEXT) - .bg(Theme::HIGHLIGHT_DIM) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::TEXT) - }; - let label = format!("{prefix}{candidate}"); - lines.push(Line::from(Span::styled(label, style))); - } - let dropdown = Paragraph::new(lines).block(dropdown_block); - frame.render_widget(ratatui::widgets::Clear, dropdown_area); - frame.render_widget(dropdown, dropdown_area); - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Theme::BORDER_FOCUSED)) - .style(Style::default().bg(Theme::BG)); - - let input_text = &state.input.buffer; - let cursor_pos = state.input.cursor; - - let prompt = Span::styled( - " ❯ ", - Style::default() - .fg(Theme::PRIMARY) - .add_modifier(Modifier::BOLD), - ); - - let mut spans = vec![prompt]; - - if input_text.is_empty() { - spans.push(Span::styled( - "Type a message, @file, or /command...", - Style::default() - .fg(Theme::TEXT_DIM) - .add_modifier(Modifier::ITALIC), - )); - } else { - let (before, after) = input_text.split_at(cursor_pos); - spans.push(Span::raw(before.to_string())); - let (cursor_char, after_char) = if after.is_empty() { - (" ".to_string(), "") - } else { - let c = after.chars().next().unwrap(); - let char_len = c.len_utf8(); - (c.to_string(), &after[char_len..]) - }; - - // Blinking cursor logic based on tick count - let cursor_style = if state.misc.tick_count % 10 < 5 { - Style::default() - .bg(Theme::PRIMARY) - .fg(Theme::BG) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .bg(Theme::TEXT_DIM) - .fg(Theme::BG) - .add_modifier(Modifier::BOLD) - }; - - spans.push(Span::styled(cursor_char, cursor_style)); - if !after_char.is_empty() { - spans.push(Span::raw(after_char.to_string())); - } - } - - let line = Line::from(spans); - let paragraph = Paragraph::new(line).block(block); - frame.render_widget(paragraph, area); - } -} diff --git a/apps/interfaces/tui/src/view/markdown.rs b/apps/interfaces/tui/src/view/markdown.rs deleted file mode 100644 index ceacdce..0000000 --- a/apps/interfaces/tui/src/view/markdown.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! Markdown-to-styled-spans rendering for the chat transcript. -//! -//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and -//! translates each markdown construct into styled `ratatui::text::Span`s, -//! then re-wraps the flat span list to a target column width. - -use super::theme::Theme; -use ratatui::style::{Modifier, Style}; -use ratatui::text::Span; - -/// Apply the "tool output" dim/italic style, or pass `style` through -/// unchanged, depending on `dim`. -fn apply_dim(style: Style, dim: bool) -> Style { - if dim { - Style::default() - .fg(Theme::TEXT_DIM) - .add_modifier(Modifier::ITALIC) - } else { - style - } -} - -/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff -/// prefix, returning the color it should always render with. -fn diff_line_style(line: &str) -> Option - -

Zesdex Web

-

Web interface is ready.

-

To connect the frontend:

-
    -
  1. Build the frontend: cd apps/interfaces/web && npm install && npm run build
  2. -
  3. Restart with --web-dir apps/interfaces/web/dist
  4. -
-"# - .to_string(), - )) - } - } -} - -/// Serve static files from the configured directory. -async fn static_handler( - axum::extract::State(state): axum::extract::State>, - path: axum::extract::Path, -) -> Response { - let file_path = state.static_dir.join(path.0); - // Security: prevent directory traversal - let canonical = match file_path.canonicalize() { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), - }; - if !canonical.starts_with(&state.static_dir) { - return (StatusCode::FORBIDDEN, "Forbidden").into_response(); - } - - match fs::read(&canonical).await { - Ok(data) => { - let mime = mime_guess::from_path(&canonical).first_or_octet_stream(); - Response::builder() - .status(200) - .header("Content-Type", mime.to_string()) - .body(axum::body::Body::from(data)) - .expect("Response builder with known-valid status and header") - .into_response() - } - Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), - } -} - -/// Run the web frontend server. -pub async fn run_server(port: u16, static_dir: Option) -> anyhow::Result<()> { - let dir = static_dir.unwrap_or_else(|| { - let p = PathBuf::from("apps/interfaces/web/dist"); - if p.exists() { - p - } else { - warn!("No static dir found at {:?}, using current dir", p); - PathBuf::from(".") - } - }); - let state = Arc::new(WebState { static_dir: dir }); - let app = build_router(state); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); - info!("Web frontend server listening on http://{addr}"); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - Ok(()) -} diff --git a/apps/interfaces/ws/src/lib.rs b/apps/interfaces/ws/src/lib.rs deleted file mode 100644 index 2857a26..0000000 --- a/apps/interfaces/ws/src/lib.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! WebSocket interface — real-time bidirectional communication. -//! -//! Enables web clients and other WS-capable consumers to connect -//! and participate in sessions. Built on Axum's WebSocket support. -//! -//! # Security -//! -//! The WS endpoint accepts an optional `?token=` query parameter. When a -//! `ZEESDEX_WS_TOKEN` env var is set, connections MUST present a matching -//! token — otherwise the connection is rejected. This prevents the endpoint -//! from being used as an open LLM proxy (anyone who can reach the port would -//! otherwise run prompts at the server's API cost). - -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::Query; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::Router; -use futures_util::stream::StreamExt; -use futures_util::SinkExt; -use serde::Deserialize; -use std::sync::Arc; -use tracing::{info, warn}; - -/// Shared application state for the WS server. -pub struct WsState { - pub store_base_dir: std::path::PathBuf, - pub session_id: Option, -} - -/// Query parameters accepted on the `/ws` upgrade. -#[derive(Debug, Deserialize)] -struct WsQuery { - token: Option, -} - -/// Build the WebSocket router. -pub fn build_router(state: Arc) -> Router { - Router::new() - .route("/ws", get(ws_handler)) - .with_state(state) -} - -/// WebSocket upgrade handler. -async fn ws_handler( - ws: WebSocketUpgrade, - Query(query): Query, - axum::extract::State(state): axum::extract::State>, -) -> impl IntoResponse { - let configured = std::env::var("ZESDEX_WS_TOKEN") - .ok() - .filter(|s| !s.is_empty()); - match configured { - Some(expected) if query.token.as_deref() != Some(expected.as_str()) => { - warn!("rejecting WS connection: missing/invalid token"); - // 401 Unauthorized — client did not present the required token. - ( - axum::http::StatusCode::UNAUTHORIZED, - "missing or invalid token", - ) - .into_response() - } - _ => ws.on_upgrade(move |socket| handle_socket(socket, state)), - } -} - -/// Handle an established WebSocket connection. -async fn handle_socket(mut socket: WebSocket, state: Arc) { - // Channel for sending text messages to the WebSocket send task. - // The receiver side runs in a spawned task that forwards each - // string as a `Message::Text` to the client. - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - - info!("WebSocket client connected"); - - // Send a welcome message - let welcome = serde_json::json!({ - "type": "connected", - "session": state.session_id, - "message": "Connected to Zesdex WebSocket server" - }); - // axum 0.8 Message::Text wraps Utf8Bytes; convert via .into() - let _ = socket.send(Message::Text(welcome.to_string().into())).await; - - // Split the socket into sender and receiver halves - let (mut sender, mut receiver) = socket.split(); - - // Spawn task to forward messages from channel to WebSocket sender - let send_task = tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - if sender.send(Message::Text(msg.into())).await.is_err() { - break; - } - } - }); - - // Receive messages from the client - // receiver is SplitStream — use StreamExt::next() - while let Some(Ok(msg)) = receiver.next().await { - match msg { - Message::Text(text) => { - let text_str = text.to_string(); - info!("Received WS message: {text_str}"); - - if let Ok(val) = serde_json::from_str::(&text_str) { - if val.get("type").and_then(|v| v.as_str()) == Some("prompt") { - if let Some(prompt) = val.get("message").and_then(|v| v.as_str()) { - let session_dir = std::env::current_dir().unwrap_or_default(); - let workspace_roots = vec![session_dir.clone()]; - let turn_events = - Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())); - let in_flight = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let abort = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default(); - let model = val - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("gpt-4o") - .to_string(); - - let params = zesdex_domain::agent::AgentTurnParams { - messages: vec![zesdex_domain::core::ChatMessage::user(prompt)], - session_dir: session_dir.clone(), - workspace_roots: workspace_roots.clone(), - turn_events: turn_events.clone(), - in_flight, - abort, - api_key: api_key.clone(), - model: model.clone(), - api_base: None, - }; - - // Capture for the optional background auto-review - // before `api_key`/`model` move into the client. - // This minimal WS channel has no settings toggle, so - // review fires whenever a prompt runs (consistent - // with the default review_enabled=true). - let review_key = api_key.clone(); - let review_model = model.clone(); - let review_base = None; - let review_ws = workspace_roots.clone(); - let review_events = turn_events.clone(); - - let client = std::sync::Arc::new( - zesdex_infrastructure::llm::provider::LlmClient::new( - api_key, model, None, - ), - ); - - let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder() - .session_dir(session_dir) - .workspaces(workspace_roots) - .turn_events(turn_events.clone()) - .build(); - - let tool_executor = std::sync::Arc::new( - zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx), - ); - - let tools = zesdex_infrastructure::tools::all_tools(); - let defs = zesdex_infrastructure::tools::tool_defs(&tools); - - let turn_service = - zesdex_application::agent::turn_service::AgentTurnServiceImpl::new( - client, - tool_executor, - defs, - ); - - use zesdex_application::agent::AgentTurnService; - tokio::spawn(async move { - let _ = turn_service.run_turn(params).await; - zesdex_infrastructure::subagent::auto::engine::spawn_background_review( - review_ws, - review_events, - review_key, - review_model, - review_base, - ); - }); - - let tx_clone = tx.clone(); - tokio::spawn(async move { - let mut done = false; - while !done { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let events: Vec<_> = { - if let Ok(mut q) = turn_events.lock() { - q.drain(..).collect() - } else { - vec![] - } - }; - for ev in events { - match ev { - zesdex_infrastructure::TurnEvent::StreamToken(tok) => { - let json = serde_json::json!({ "type": "token", "content": tok }); - let _ = tx_clone.send(json.to_string()); - } - zesdex_infrastructure::TurnEvent::Done => { - let json = serde_json::json!({ "type": "done" }); - let _ = tx_clone.send(json.to_string()); - done = true; - } - zesdex_infrastructure::TurnEvent::Error(err) => { - let json = serde_json::json!({ "type": "error", "message": err }); - let _ = tx_clone.send(json.to_string()); - done = true; - } - _ => {} - } - } - } - }); - continue; - } - } - } - - // Fallback echo - let response = serde_json::json!({ - "type": "echo", - "data": text_str - }); - let _ = tx.send(response.to_string()); - } - Message::Close(_) => break, - _ => {} - } - } - - send_task.abort(); - info!("WebSocket client disconnected"); -} - -/// Run the WebSocket server standalone. -pub async fn run_server(port: u16) -> anyhow::Result<()> { - let state = Arc::new(WsState { - store_base_dir: std::path::PathBuf::from("."), - session_id: None, - }); - let app = build_router(state); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); - info!("WebSocket server listening on ws://{addr}"); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - Ok(()) -}