diff --git a/.gitignore b/.gitignore index 1003988..eb77fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ output/ codex-profiles/ profiles/ state/ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dc609b4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,727 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "constant" +version = "0.1.0" +dependencies = [ + "crossterm", + "ratatui", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "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 = "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", +] + +[[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", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[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", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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_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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1a19bd2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "constant" +version = "0.1.0" +edition = "2024" +description = "Rust front-controller for the Constant AI cockpit" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +crossterm = "0.28" +ratatui = "0.29" + +[[bin]] +name = "constant" +path = "src/main.rs" diff --git a/README.md b/README.md index 2ff2499..3cad1db 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ The vibe is somewhere between: Current build status in this repo: +- a Rust front-controller for the canonical `constant` entrypoint - a 4-pane host-local `tmux` session per machine - a fleet cockpit with one window per machine plus a central `Constant` window - `Constant`, the orchestration CLI on top of the cockpit @@ -51,6 +52,24 @@ Current build status in this repo: The canonical script surface is `constant-*`. Legacy `zellij-ai-*` aliases remain only as compatibility shims. +Migration note: + +- the entrypoint is now moving to Rust +- `doctor`, `agents`, `skills`, `tui`, `cockpit`, `mission create`, `mission plan`, `mission run`, `mission status`, `mission tail`, `mission verify`, `mission retry`, `mission summarize`, and `delegate` now run from the Rust binary +- Rust now prefers `fleet.toml`, `models.toml`, and `memory.toml`, while keeping JSON mirrors for compatibility during migration +- `memory`, `buddy`, and `fleet` still hand off to the existing Python core during migration +- the public `./scripts/Constant` wrapper now tries the Rust binary first, but runs a fast startup probe before handing control over +- if macOS stalls in code-signing / policy evaluation for freshly built binaries, the wrapper falls back automatically to the stable Python path +- when `constant` is launched from inside the Codex runner itself, the wrapper skips the Rust path entirely and goes straight to Python to avoid the known macOS startup stall in that environment +- use `CONSTANT_USE_RUST=1 ./scripts/Constant ...` to force the raw Rust path, or `CONSTANT_USE_PYTHON=1 ./scripts/Constant ...` to force Python +- after changing macOS security settings, use `CONSTANT_RUST_RECHECK=1 ./scripts/Constant ...` to force a fresh Rust startup probe instead of waiting for the fallback cache TTL + +macOS note: + +- if Rust keeps falling back from a normal terminal app, check `System Settings -> Privacy & Security -> Developer Tools` +- allow your terminal app there so it can run locally built binaries without extra security-policy stalls +- then rerun with `CONSTANT_RUST_RECHECK=1 ./scripts/Constant --help` + ## Why This Exists Most AI tooling is designed like a vending machine: @@ -117,6 +136,49 @@ That gives you: - cockpit handoff - SSH discovery and fleet deployment bootstrap +### 3.5. Workflow skills + +`Constant` now has a first-class workflow skill layer. + +Public workflow skills: + +- `spec-planner` +- `architecture-brainstorm` +- `repo-onboarding` +- `task-decomposer` +- `pr-review-prep` + +Recommended minimal stack: + +- `spec-planner` +- `architecture-brainstorm` +- `task-decomposer` + +Recommended workflow: + +1. `spec-planner` +2. `architecture-brainstorm` +3. `task-decomposer` +4. implementation on the selected agent / machine / CLI +5. `pr-review-prep` + +Inspect them with: + +```bash +./scripts/Constant skills --json +./scripts/Constant skills --public-only --json +./scripts/Constant agents --json +``` + +Create and route missions through these skills today with the Rust CLI: + +```bash +./scripts/Constant mission create "Je veux ajouter un systeme de notifications" --workspace "$PWD" +./scripts/Constant delegate --skill architecture-brainstorm --json +``` + +The richer conversation-first chat surface is still part of the Python-side migration path. + ### 4. Durable memory The memory layer is being built to support: @@ -204,7 +266,30 @@ If `Constant` is on your `PATH`, you can also just run: Constant ``` -In interactive mode, `Constant` with no arguments now opens the TUI by default. +In interactive mode, `Constant` with no arguments now opens or attaches the full fleet cockpit by default. + +That means: + +- a top tmux tab bar for `Constant`, the local machine, and each remote machine +- the active tab is forced back to `Constant` when you launch `constant` +- the `Constant` TUI inside the central `Constant` window +- one machine window per host in the fleet + +For now, the central `Constant` window is intentionally forced onto the richer Python chat-first TUI so the UX stays closer to a Claude Code-style conversation surface while the Rust TUI is still catching up. + +The current interaction model is: + +- chat-first by default +- prompt focused on launch +- `/` opens the workflow skill palette +- `Esc` drops into cockpit command mode +- `f` toggles between `chat-focus` and `cockpit-detail` + +If you want only the standalone TUI without attaching the fleet session, use: + +```bash +./scripts/Constant tui --workspace "$PWD" +``` ### Discover and deploy a fleet @@ -216,7 +301,7 @@ In interactive mode, `Constant` with no arguments now opens the TUI by default. - validates candidates with a short SSH probe - lets you select targets interactively - asks for SSH user and machine labels -- writes `~/.config/constant/fleet.json` +- writes `~/.config/constant/fleet.toml` and a compatibility `fleet.json` mirror - can immediately deploy the runtime to the selected machines Examples: @@ -237,7 +322,7 @@ You can pass raw SSH seeds such as: - `root@192.168.0.119` - `lab-a` -If a `fleet.json` contains `repo_dir`, the shell launchers and fleet installer will reuse it automatically. +If `fleet.toml` or `fleet.json` contains `repo_dir`, the shell launchers and fleet installer will reuse it automatically. For a fully non-interactive run, pass explicit hosts and `--yes`: @@ -253,24 +338,25 @@ Constant fleet configure \ ### TUI keys -`Constant tui` gives you: +`Constant tui` now gives you: -- a mission deck -- a mission board -- a runtime view of machine windows and pane states +- a chat-first central conversation surface +- a thread sidebar +- a chat-focus mode that behaves closer to Claude Code +- a detailed cockpit view you can toggle back in when needed +- a capture popup for pane output - a `hexapus` buddy rail -- a bottom timeline mixed with memory echoes +- a bottom status / key strip Useful keys: - `j` / `k`: move between missions +- `f`: toggle `chat-focus` / `cockpit-detail` - `[` / `]`: move between machines in the runtime view -- `1`..`4`: focus `claude`, `codex`, `copilot`, `vibe` on the selected machine -- `o`: jump to the selected machine window +- `1`..`4`: select `claude`, `codex`, `copilot`, `vibe` on the selected machine +- `o`: focus the selected machine + pane in the real cockpit - `r`: restart the selected pane -- `x`: capture a short preview of the selected pane -- `e`: rebuild memory for the current workspace -- `s`: summarize the selected mission into durable memory +- `x`: open a capture view of the selected pane - `z`: open the full fleet cockpit - `q`: quit the TUI @@ -301,11 +387,11 @@ Public example config: ```bash mkdir -p ~/.config/constant -cp examples/fleet.example.json ~/.config/constant/fleet.json +cp examples/fleet.example.toml ~/.config/constant/fleet.toml ``` -Legacy `~/.config/constant/fleet.yaml` is still read for compatibility, but the public-facing format is now JSON. -The shell launchers also read `fleet.json` now, so fleet tabs, install/check, and bridge helpers stay aligned with the same config. +Legacy `~/.config/constant/fleet.json` and `fleet.yaml` are still read for compatibility, but the native format is now TOML. +The shell launchers read both `fleet.toml` and `fleet.json`, so fleet tabs, install/check, and bridge helpers stay aligned during the migration. ## Messaging @@ -383,7 +469,9 @@ Planned layers for the public `Constant` repo: ```text constant/ Python orchestration core +src/ Rust CLI, missions, cockpit, and native TUI examples/fleet.example.json public fleet template +examples/fleet.example.toml public fleet template in the native config format scripts/Constant canonical CLI entrypoint scripts/constant-deploy.sh discovery + selection + fleet deployment CLI scripts/constant-machine.sh canonical single-machine cockpit entrypoint diff --git a/constant/capabilities.py b/constant/capabilities.py new file mode 100644 index 0000000..4c31ecb --- /dev/null +++ b/constant/capabilities.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +from typing import Any + + +DEFAULT_AGENTS: list[dict[str, Any]] = [ + { + "id": "planner", + "label": "Planner", + "role": "planner", + "primary_cli": "claude", + "capabilities": ["spec", "planning", "review", "summarization", "delegation"], + "preferred_layers": ["reflection", "execution-prep"], + "manual_only": False, + }, + { + "id": "executor", + "label": "Executor", + "role": "executor", + "primary_cli": "codex", + "capabilities": ["implementation", "debugging", "refactor", "tooling", "ops"], + "preferred_layers": ["execution"], + "manual_only": False, + }, + { + "id": "analyst", + "label": "Analyst", + "role": "analyst", + "primary_cli": "vibe", + "capabilities": ["brainstorm", "alternatives", "research", "comparison"], + "preferred_layers": ["reflection"], + "manual_only": False, + }, + { + "id": "assistant", + "label": "Assistant", + "role": "assistant", + "primary_cli": "copilot", + "capabilities": ["interactive-help", "inline-suggestions"], + "preferred_layers": ["manual"], + "manual_only": True, + }, +] + + +DEFAULT_SKILLS: list[dict[str, Any]] = [ + { + "id": "spec-planner", + "label": "Spec Planner", + "layer": "reflection", + "visibility": "public", + "summary": "Transformer une demande floue en spec exploitable avant de coder.", + "when_to_use": [ + "quand on part d'une idee brute", + "quand il faut cadrer avant implementation", + "quand on veut eviter de coder trop tot", + ], + "input_examples": [ + "Je veux ajouter un systeme de notifications", + "On doit refondre l'auth OAuth", + ], + "output_expected": [ + "objectif", + "perimetre", + "hors_perimetre", + "contraintes", + "hypotheses", + "criteres_acceptation", + "risques", + "questions_ouvertes", + "plan_minimal", + ], + "usage_prompt": ( + "Fais une spec executable a partir de cette demande. " + "Identifie les zones floues, propose des hypotheses raisonnables, " + "liste les criteres d'acceptation et termine par un plan d'implementation minimal." + ), + "keywords": [ + "spec planner", + "spec-planner", + "spec", + "cadrer", + "clarifier", + "clarify", + "requirements", + "scope", + "perimetre", + "oauth", + "notifications", + "idea brute", + "je veux", + "i want", + "on doit", + "we need", + "refondre", + "before coding", + ], + "aliases": ["summarize", "summary", "brief"], + "preferred_cli": "claude", + "preferred_agent": "planner", + }, + { + "id": "architecture-brainstorm", + "label": "Architecture Brainstorm", + "layer": "reflection", + "visibility": "public", + "summary": "Comparer plusieurs options d'architecture avant de choisir.", + "when_to_use": [ + "quand plusieurs approches sont possibles", + "quand on veut comparer les trade-offs", + "quand il faut une vraie discussion d'architecture", + ], + "input_examples": [ + "Doit-on faire un backend event-driven ou un monolithe modulaire ?", + "Quel runtime pour le cockpit interactif ?", + ], + "output_expected": [ + "options", + "avantages", + "inconvenients", + "complexite", + "impacts_perf", + "impacts_securite", + "impacts_dx", + "recommendation", + "raisons_du_choix", + ], + "usage_prompt": ( + "Analyse ce probleme comme un review partner senior. " + "Propose plusieurs architectures possibles, compare les compromis, " + "signale les pieges, puis recommande une option avec justification." + ), + "keywords": [ + "architecture", + "brainstorm", + "architecture-brainstorm", + "trade-off", + "tradeoff", + "option", + "compare", + "alternatives", + "design choice", + "choix technique", + ], + "aliases": ["brainstorm"], + "preferred_cli": "vibe", + "preferred_agent": "analyst", + }, + { + "id": "repo-onboarding", + "label": "Repo Onboarding", + "layer": "execution-prep", + "visibility": "public", + "summary": "Comprendre rapidement un repo avant la premiere intervention.", + "when_to_use": [ + "quand on arrive sur un nouveau projet", + "quand il faut cartographier une codebase", + "quand on veut savoir ou modifier sans casser le reste", + ], + "input_examples": [ + "Explore ce repo comme si tu preparais une premiere intervention", + "Dis-moi ou intervenir pour cette tache", + ], + "output_expected": [ + "structure_repo", + "points_entree", + "modules_cles", + "conventions", + "commandes_utiles", + "zones_a_risque", + "strategie_modification", + ], + "usage_prompt": ( + "Explore ce repo comme si tu preparais une premiere intervention. " + "Resume sa structure, les modules critiques, les conventions implicites, " + "les commandes de dev/test, puis dis ou intervenir pour cette tache." + ), + "keywords": [ + "repo onboarding", + "repo-onboarding", + "onboarding", + "explore repo", + "new repo", + "codebase", + "structure du repo", + "points d'entree", + "where to change", + "cartographier", + ], + "aliases": [], + "preferred_cli": "claude", + "preferred_agent": "planner", + }, + { + "id": "task-decomposer", + "label": "Task Decomposer", + "layer": "execution-prep", + "visibility": "public", + "summary": "Transformer une spec ou direction choisie en plan d'execution operationnel.", + "when_to_use": [ + "quand la direction est choisie", + "quand on veut passer du brainstorming a l'action", + "quand il faut un ordre d'execution clair", + ], + "input_examples": [ + "Decoupe cette refonte en sous-taches concretement executables", + "Fais-moi un plan d'implementation directement actionnable", + ], + "output_expected": [ + "etapes_ordonnees", + "dependances", + "quick_wins", + "parallelisation", + "tests", + "definition_of_done", + ], + "usage_prompt": ( + "Decoupe cette tache en sous-taches concretes, ordonnees, avec dependances, " + "risques, validations et tests associes. " + "Le plan doit etre directement executable par un agent de code." + ), + "keywords": [ + "task decomposer", + "task-decomposer", + "decompose", + "plan d'execution", + "execution plan", + "break down", + "subtasks", + "steps", + "roadmap", + "implementation plan", + ], + "aliases": [], + "preferred_cli": "claude", + "preferred_agent": "planner", + }, + { + "id": "pr-review-prep", + "label": "PR Review Prep", + "layer": "execution-prep", + "visibility": "public", + "summary": "Preparer une PR review-ready et anticiper les objections du reviewer.", + "when_to_use": [ + "quand l'implementation est terminee", + "quand il faut une bonne PR", + "quand on veut preparer la review", + ], + "input_examples": [ + "Prepare une PR clean a partir de ces changements", + "Resume le pourquoi, le quoi et les risques pour le reviewer", + ], + "output_expected": [ + "resume_du_changement", + "impact_fonctionnel", + "impact_technique", + "migrations", + "tests", + "points_attention_reviewer", + "message_de_pr", + ], + "usage_prompt": ( + "Prepare une PR review-ready a partir de ces changements. " + "Resume le pourquoi, le quoi, les impacts, les risques, " + "les tests effectues et les points a surveiller pour le reviewer." + ), + "keywords": [ + "pr review prep", + "pr-review-prep", + "pr", + "pull request", + "review prep", + "release notes", + "review-ready", + "reviewer", + ], + "aliases": ["review"], + "preferred_cli": "claude", + "preferred_agent": "planner", + }, + { + "id": "implementation", + "label": "Implementation", + "layer": "execution", + "visibility": "internal", + "summary": "Ship code changes and concrete fixes.", + "when_to_use": [ + "quand il faut modifier du code maintenant", + "quand la spec est deja suffisamment claire", + ], + "input_examples": ["Fix the flaky test", "Implement the API endpoint"], + "output_expected": ["code_changes", "validation", "tests"], + "usage_prompt": "Implement the requested change directly and validate it.", + "keywords": ["fix", "implement", "build", "write", "patch", "feature", "code"], + "aliases": [], + "preferred_cli": "codex", + "preferred_agent": "executor", + }, + { + "id": "debug-restoration", + "label": "Debug Restoration", + "layer": "execution", + "visibility": "internal", + "summary": "Investigate failures and restore a working state.", + "when_to_use": [ + "quand quelque chose casse", + "quand il faut comprendre puis restaurer un etat sain", + ], + "input_examples": ["The pane dies instantly", "This build started failing today"], + "output_expected": ["root_cause", "fix", "validation"], + "usage_prompt": "Investigate the failure, isolate the cause, and restore a working state.", + "keywords": ["bug", "debug", "failure", "broken", "error", "crash", "repair"], + "aliases": ["debug"], + "preferred_cli": "codex", + "preferred_agent": "executor", + }, + { + "id": "ops-deployment", + "label": "Ops Deployment", + "layer": "execution", + "visibility": "internal", + "summary": "Handle shell, fleet, infra, network, and remote machine work.", + "when_to_use": [ + "quand il faut deployer, scanner, installer, diagnostiquer des machines", + "quand la tache est principalement shell/infra", + ], + "input_examples": ["Deploy the runtime on all machines", "Scan SSH hosts and configure the fleet"], + "output_expected": ["ops_actions", "machine_state", "validation"], + "usage_prompt": "Handle the shell, infra, fleet, and remote machine operations safely.", + "keywords": ["ssh", "shell", "fleet", "ops", "network", "infra", "deploy"], + "aliases": ["ops"], + "preferred_cli": "codex", + "preferred_agent": "executor", + }, +] + + +def _normalize_skill_id(value: str) -> str: + return value.strip().lower() + + +def _skill_entries(include_internal: bool = True) -> list[dict[str, Any]]: + if include_internal: + return DEFAULT_SKILLS + return [skill for skill in DEFAULT_SKILLS if skill.get("visibility") != "internal"] + + +def list_agents() -> list[dict[str, Any]]: + return [dict(item) for item in DEFAULT_AGENTS] + + +def list_skills(include_internal: bool = True) -> list[dict[str, Any]]: + return [dict(item) for item in _skill_entries(include_internal=include_internal)] + + +def skill_catalog(include_internal: bool = False) -> dict[str, list[dict[str, Any]]]: + payload = {"reflection": [], "execution-prep": [], "execution": [], "manual": []} + for skill in _skill_entries(include_internal=include_internal): + payload.setdefault(str(skill["layer"]), []).append(dict(skill)) + return payload + + +def skill_catalog_brief(include_internal: bool = False) -> list[dict[str, Any]]: + return [ + { + "id": skill["id"], + "layer": skill["layer"], + "summary": skill["summary"], + "preferred_cli": skill["preferred_cli"], + "preferred_agent": skill["preferred_agent"], + } + for skill in _skill_entries(include_internal=include_internal) + ] + + +def recommended_skill_stack() -> dict[str, Any]: + return { + "minimal": ["spec-planner", "architecture-brainstorm", "task-decomposer"], + "workflow": [ + "spec-planner", + "architecture-brainstorm", + "repo-onboarding", + "task-decomposer", + "pr-review-prep", + ], + "layers": { + "reflection": ["spec-planner", "architecture-brainstorm"], + "execution": ["repo-onboarding", "task-decomposer", "pr-review-prep"], + }, + } + + +def agent_by_id(agent_id: str) -> dict[str, Any]: + for agent in DEFAULT_AGENTS: + if agent["id"] == agent_id: + return dict(agent) + raise KeyError(f"Unknown agent: {agent_id}") + + +def agent_for_cli(cli: str) -> dict[str, Any]: + for agent in DEFAULT_AGENTS: + if agent["primary_cli"] == cli: + return dict(agent) + return agent_by_id("executor") + + +def agent_hint(agent_id: str) -> dict[str, Any]: + return agent_by_id(agent_id) + + +def skill_by_id(skill_id: str, include_internal: bool = True) -> dict[str, Any]: + normalized = _normalize_skill_id(skill_id) + for skill in _skill_entries(include_internal=include_internal): + aliases = [_normalize_skill_id(alias) for alias in skill.get("aliases", [])] + if normalized == skill["id"] or normalized in aliases: + return dict(skill) + raise KeyError(f"Unknown skill: {skill_id}") + + +def _score_keywords(goal_l: str, skill: dict[str, Any]) -> int: + score = 0 + for keyword in skill.get("keywords", []): + if keyword in goal_l: + score += 3 if " " in keyword or "-" in keyword else 1 + if skill["id"] in goal_l: + score += 5 + for alias in skill.get("aliases", []): + if alias in goal_l: + score += 3 + return score + + +def match_skill(goal: str, include_internal: bool = True) -> dict[str, Any]: + goal_l = goal.lower() + best = skill_by_id("implementation", include_internal=include_internal) if include_internal else skill_by_id("spec-planner", include_internal=False) + best_score = -1 + for skill in _skill_entries(include_internal=include_internal): + score = _score_keywords(goal_l, skill) + if score > best_score: + best_score = score + best = skill + if best_score <= 0: + if any(token in goal_l for token in ("repo", "codebase", "onboarding", "where to change", "first intervention")): + return skill_by_id("repo-onboarding", include_internal=include_internal) + if any(token in goal_l for token in ("je veux", "i want", "on doit", "we need", "idea brute", "high level idea")): + return skill_by_id("spec-planner", include_internal=include_internal) + if any(token in goal_l for token in ("architecture", "trade-off", "tradeoff", "option", "compare")): + return skill_by_id("architecture-brainstorm", include_internal=include_internal) + if any(token in goal_l for token in ("plan", "sub-task", "subtask", "decompose", "step-by-step", "roadmap")): + return skill_by_id("task-decomposer", include_internal=include_internal) + if any(token in goal_l for token in ("pr", "pull request", "reviewer", "release notes")): + return skill_by_id("pr-review-prep", include_internal=include_internal) + if include_internal and any(token in goal_l for token in ("bug", "crash", "failure", "broken", "error")): + return skill_by_id("debug-restoration", include_internal=True) + if include_internal and any(token in goal_l for token in ("ssh", "fleet", "deploy", "network", "machine", "infra")): + return skill_by_id("ops-deployment", include_internal=True) + return best + return dict(best) + + +def resolve_skill_and_agent( + *, + goal: str | None = None, + skill_id: str | None = None, + agent_id: str | None = None, + cli: str | None = None, +) -> dict[str, Any]: + skill = skill_by_id(skill_id) if skill_id else match_skill(goal or "") + if cli: + agent = agent_for_cli(cli) + elif agent_id: + agent = agent_by_id(agent_id) + cli = str(agent["primary_cli"]) + else: + agent = agent_by_id(str(skill["preferred_agent"])) + cli = str(skill["preferred_cli"]) + + return { + "skill": skill, + "agent": agent, + "cli": cli, + } diff --git a/constant/cli.py b/constant/cli.py index 61f449b..4f40f59 100644 --- a/constant/cli.py +++ b/constant/cli.py @@ -3,17 +3,28 @@ from __future__ import annotations import argparse import json import os +import subprocess import sys import time from pathlib import Path from typing import Any from . import __version__ +from .capabilities import ( + agent_for_cli, + list_agents, + list_skills, + recommended_skill_stack, + resolve_skill_and_agent, + skill_catalog, + skill_by_id, +) from .cockpit import capture_pane, cockpit_doctor, focus_machine, restart_pane, runtime_status, send_to_pane from .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background from .executors import bridge_sync, execute_step, fleet_check from .memory import ( enroll_workspace, + instruction_skill_sources, list_decisions, memory_status, persona_markdown, @@ -56,6 +67,96 @@ def _command_exists(binary: str) -> bool: return False +def _read_wrapper_stamp(path: Path) -> dict[str, Any] | None: + try: + raw = path.read_text(encoding="utf-8").strip() + except OSError: + return None + if not raw: + return None + parts = raw.split() + if len(parts) < 2: + return {"raw": raw} + signature, epoch_text = parts[0], parts[1] + payload: dict[str, Any] = {"signature": signature, "epoch": epoch_text} + try: + payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(epoch_text))) + except ValueError: + pass + return payload + + +def _read_wrapper_mode(path: Path) -> dict[str, Any] | None: + try: + raw = path.read_text(encoding="utf-8").strip() + except OSError: + return None + if not raw: + return None + parts = raw.split() + if len(parts) < 3: + return {"raw": raw} + mode, reason, epoch_text = parts[0], parts[1], parts[2] + payload: dict[str, Any] = {"mode": mode, "reason": reason, "epoch": epoch_text} + try: + payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(epoch_text))) + except ValueError: + pass + return payload + + +def _run_quick_command(command: list[str]) -> dict[str, Any]: + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=3, + check=False, + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": str(exc), "command": command} + return { + "ok": completed.returncode == 0, + "returncode": completed.returncode, + "stdout": completed.stdout.strip(), + "stderr": completed.stderr.strip(), + "command": command, + } + + +def _wrapper_status() -> dict[str, Any]: + wrapper_dir = cache_root() / "wrapper" + rust_bin = repo_root() / "target" / "debug" / "constant" + payload: dict[str, Any] = { + "wrapper_dir": str(wrapper_dir), + "rust_bin": str(rust_bin), + "rust_bin_exists": rust_bin.exists(), + "forced_python": os.environ.get("CONSTANT_USE_PYTHON") == "1", + "forced_rust": os.environ.get("CONSTANT_USE_RUST") == "1", + "force_recheck": os.environ.get("CONSTANT_RUST_RECHECK") == "1", + "last_mode": _read_wrapper_mode(wrapper_dir / "last-mode"), + "probe_timeout_sec": os.environ.get("CONSTANT_RUST_PROBE_TIMEOUT_SEC", "2"), + "probe_fail_ttl_sec": os.environ.get("CONSTANT_RUST_PROBE_FAIL_TTL_SEC", "300"), + "cache": { + "rust_ok": _read_wrapper_stamp(wrapper_dir / "rust-ok"), + "rust_fail": _read_wrapper_stamp(wrapper_dir / "rust-fail"), + }, + } + if rust_bin.exists(): + payload["codesign_verify"] = _run_quick_command( + ["codesign", "--verify", "--verbose=2", str(rust_bin)] + ) + payload["spctl_assess"] = _run_quick_command(["spctl", "--assess", "-vv", str(rust_bin)]) + payload["xattr_provenance"] = _run_quick_command(["xattr", "-p", "com.apple.provenance", str(rust_bin)]) + payload["hint"] = ( + "If spctl reports a Code Signing subsystem error from a normal terminal, allow that terminal in " + "System Settings -> Privacy & Security -> Developer Tools. Then rerun with CONSTANT_RUST_RECHECK=1 " + "to force a fresh startup probe." + ) + return payload + + def _warm_memory(workspace: str) -> dict[str, Any] | None: try: return rebuild_workspace_memory(workspace, enroll=True) @@ -99,6 +200,9 @@ def _mission_summary(mission: dict[str, Any]) -> dict[str, Any]: "backend": step["backend"], "cli": step["cli"], "agent": step["agent"], + "agent_role": step.get("agent_role"), + "skill": step.get("skill"), + "skill_summary": step.get("skill_summary"), "attempt": step["attempt"], } for step in mission["steps"] @@ -133,6 +237,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: "memory": memory_status(), "fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]], "cockpit": cockpit, + "wrapper": _wrapper_status(), } _print(report, args.json) return 0 @@ -178,6 +283,24 @@ def cmd_models_status(args: argparse.Namespace) -> int: return 0 +def cmd_agents(args: argparse.Namespace) -> int: + payload = {"agents": list_agents(), "recommended_skill_stack": recommended_skill_stack()} + _print(payload, args.json) + return 0 + + +def cmd_skills(args: argparse.Namespace) -> int: + payload = { + "skills": list_skills(include_internal=not getattr(args, "public_only", False)), + "catalog": skill_catalog(include_internal=not getattr(args, "public_only", False)), + "recommended_skill_stack": recommended_skill_stack(), + } + if getattr(args, "workspace", None): + payload["instruction_skill_sources"] = instruction_skill_sources(args.workspace) + _print(payload, args.json) + return 0 + + def cmd_fleet_status(args: argparse.Namespace) -> int: payload = fleet_check() _print(payload, args.json) @@ -491,14 +614,38 @@ def cmd_delegate(args: argparse.Namespace) -> int: step["machine"] = args.machine if args.backend: step["backend"] = args.backend - if args.cli: - step["cli"] = args.cli - if args.agent: - step["agent"] = args.agent + if args.skill or args.agent or args.cli: + resolved_cli = args.cli + resolved_agent = args.agent + if not args.skill and not resolved_cli: + resolved_cli = step.get("cli") + if not args.skill and not resolved_agent: + resolved_agent = step.get("agent") + resolved = resolve_skill_and_agent( + goal=step.get("prompt") or mission.get("goal", ""), + skill_id=args.skill or step.get("skill"), + agent_id=resolved_agent, + cli=resolved_cli, + ) + step["skill"] = resolved["skill"]["id"] + step["skill_summary"] = resolved["skill"]["summary"] + step["agent"] = resolved["agent"]["id"] + step["agent_role"] = resolved["agent"]["role"] + step["cli"] = resolved["cli"] + elif step.get("cli") and not step.get("agent"): + agent = agent_for_cli(step["cli"]) + step["agent"] = agent["id"] + step["agent_role"] = agent["role"] + + if step.get("skill"): + try: + step["skill_summary"] = skill_by_id(step["skill"])["summary"] + except KeyError: + pass step["status"] = "pending" mission["status"] = "planned" save_mission(mission) - append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"]}) + append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"], "skill": step.get("skill")}) _print(_mission_summary(mission), args.json) return 0 @@ -557,7 +704,7 @@ def cmd_mission_summarize(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="Constant") + parser = argparse.ArgumentParser(prog=os.environ.get("CONSTANT_PROG_NAME", "Constant")) parser.add_argument("-V", "--version", action="version", version=__version__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -589,6 +736,16 @@ def build_parser() -> argparse.ArgumentParser: models_status.add_argument("--json", action="store_true") models_status.set_defaults(func=cmd_models_status) + agents = subparsers.add_parser("agents") + agents.add_argument("--json", action="store_true") + agents.set_defaults(func=cmd_agents) + + skills = subparsers.add_parser("skills") + skills.add_argument("--workspace") + skills.add_argument("--public-only", action="store_true") + skills.add_argument("--json", action="store_true") + skills.set_defaults(func=cmd_skills) + fleet = subparsers.add_parser("fleet") fleet_sub = fleet.add_subparsers(dest="fleet_command", required=True) fleet_status_cmd = fleet_sub.add_parser("status") @@ -715,6 +872,7 @@ def build_parser() -> argparse.ArgumentParser: delegate.add_argument("--backend") delegate.add_argument("--cli") delegate.add_argument("--agent") + delegate.add_argument("--skill") delegate.add_argument("--json", action="store_true") delegate.set_defaults(func=cmd_delegate) @@ -773,7 +931,7 @@ def main(argv: list[str] | None = None) -> int: argv = sys.argv[1:] if not argv: if sys.stdin.isatty() and sys.stdout.isatty(): - argv = ["tui", "--workspace", os.getcwd()] + argv = ["cockpit", "open", "--workspace", os.getcwd()] else: argv = ["doctor"] parser = build_parser() diff --git a/constant/cockpit.py b/constant/cockpit.py index 358cf54..40264e5 100644 --- a/constant/cockpit.py +++ b/constant/cockpit.py @@ -112,14 +112,34 @@ def _machine_tmux_status(machine: dict[str, Any], session: str) -> dict[str, Any def runtime_status(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: fleet = load_fleet_config() - local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}"]) - fleet_windows = local_tmux["stdout"].splitlines() if local_tmux["returncode"] == 0 else [] + local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}\t#{window_active}"]) + fleet_windows: list[str] = [] + focused_machine = None + if local_tmux["returncode"] == 0: + for raw in local_tmux["stdout"].splitlines(): + window_name, _, *rest = raw.split("\t") + [""] + active = raw.split("\t")[1] if "\t" in raw else "0" + fleet_windows.append(window_name) + if active == "1": + focused_machine = window_name machines = [_machine_tmux_status(machine, machine_session) for machine in fleet["machines"]] + focused_role = None + for machine in machines: + if machine["label"] != focused_machine: + continue + for role in ROLES: + pane = machine["roles"].get(role) + if pane and pane.get("active"): + focused_role = role + break + break return { "local_session": local_session, "machine_session": machine_session, "fleet_session_exists": local_tmux["returncode"] == 0, "fleet_windows": fleet_windows, + "focused_machine": focused_machine, + "focused_role": focused_role, "machines": machines, "fleet_stderr": local_tmux["stderr"].strip(), } @@ -142,13 +162,18 @@ def _machine_control_script(machine_label: str, machine_session: str) -> tuple[d return machine, script +def _machine_command_args(machine: dict[str, Any], args: list[str]) -> list[str]: + return ["env", f"ZELLIJ_AI_MACHINE_NAME={machine['label']}", *args] + + def _run_machine_command(machine: dict[str, Any], args: list[str]) -> dict[str, Any]: + command_args = _machine_command_args(machine, args) target = machine["target"] local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"} if target in local_names: - return _run(args) + return _run(command_args) - quoted = shlex.join(args) + quoted = shlex.join(command_args) return _run(_ssh_command(target, quoted)) diff --git a/constant/daemon.py b/constant/daemon.py index 255e084..0319c31 100644 --- a/constant/daemon.py +++ b/constant/daemon.py @@ -76,6 +76,15 @@ class _Handler(socketserver.StreamRequestHandler): payload = self.engine.verify_step(request["mission"], request["step"], request["execution"]) elif op == "buddy": payload = self.engine.buddy_ask(request.get("mission"), request["prompt"]) + elif op == "chat": + payload = self.engine.chat( + request["message"], + request.get("mission"), + request["workspace"], + request.get("selected_machine"), + request.get("selected_role"), + request.get("chat_history"), + ) else: raise KeyError(f"Unsupported operation: {op}") response = {"ok": True, "payload": payload} @@ -188,6 +197,15 @@ def _direct_request(op: str, payload: dict[str, Any] | None = None) -> dict[str, request_payload.get("mission"), request_payload["prompt"], ) + if op == "chat": + return _INLINE_ENGINE.chat( + request_payload["message"], + request_payload.get("mission"), + request_payload["workspace"], + request_payload.get("selected_machine"), + request_payload.get("selected_role"), + request_payload.get("chat_history"), + ) raise RuntimeError(f"Unsupported operation: {op}") diff --git a/constant/memory.py b/constant/memory.py index 8d4aa24..bbeba20 100644 --- a/constant/memory.py +++ b/constant/memory.py @@ -256,8 +256,11 @@ def _detect_repo_root(workspace: Path) -> Path: def _sanitize_fts_query(query: str) -> str: - tokens = re.findall(r"[A-Za-z0-9_.:/-]+", query.lower()) - return " ".join(tokens[:12]) or "constant" + raw_tokens = re.findall(r"[A-Za-z0-9_]+", query.lower()) + tokens = [token for token in raw_tokens if len(token) >= 2][:12] + if not tokens: + return "\"constant\"" + return " ".join(f"\"{token}\"" for token in tokens) def _tokenize(text: str) -> list[str]: @@ -820,7 +823,11 @@ def search_memory(query: str, workspace: str | None = None, limit: int | None = params.append(workspace_path) chunk_sql += " order by rank limit ?" params.append(max_hits * 3) - for row in connection.execute(chunk_sql, tuple(params)).fetchall(): + try: + rows = connection.execute(chunk_sql, tuple(params)).fetchall() + except sqlite3.OperationalError: + rows = [] + for row in rows: hits.append( { "kind": "repo", @@ -918,6 +925,47 @@ def search_memory(query: str, workspace: str | None = None, limit: int | None = connection.close() +def instruction_skill_sources(workspace: str | Path, query: str | None = None, limit: int = 6) -> list[dict[str, Any]]: + workspace_path = str(_normalize_workspace(workspace)) + connection = _connect() + try: + rows = connection.execute( + """ + select path, scope, source_kind, weight, content, updated_at + from instruction_sources + where workspace = ? or scope = 'user' + order by weight desc, updated_at desc + limit 64 + """, + (workspace_path,), + ).fetchall() + hits: list[dict[str, Any]] = [] + query_l = query.lower() if query else "" + query_tokens = _tokenize(query)[:6] if query else [] + for row in rows: + path = row["path"] + score = float(row["weight"]) + haystack = row["content"].lower() + if query: + matched = query_l in haystack or any(token in haystack for token in query_tokens) or any(token in path.lower() for token in query_tokens) + if not matched: + continue + score += 1.0 + hits.append( + { + "path": path, + "scope": row["scope"], + "source_kind": row["source_kind"], + "weight": score, + "snippet": _snippet(row["content"], 160), + } + ) + hits.sort(key=lambda item: item["weight"], reverse=True) + return hits[:limit] + finally: + connection.close() + + def persona_markdown() -> str: if persona_path().exists(): return persona_path().read_text(encoding="utf-8") diff --git a/constant/paths.py b/constant/paths.py index 907273f..87e36fc 100644 --- a/constant/paths.py +++ b/constant/paths.py @@ -31,6 +31,10 @@ def missions_dir() -> Path: return cache_root() / "missions" +def chat_root() -> Path: + return cache_root() / "chat" + + def fleet_config_path() -> Path: return config_root() / "fleet.json" diff --git a/constant/planner.py b/constant/planner.py index 6eab5e8..de3f81c 100644 --- a/constant/planner.py +++ b/constant/planner.py @@ -3,12 +3,27 @@ from __future__ import annotations import importlib.util import json import re +import subprocess +import sys import threading from dataclasses import dataclass from typing import Any +from .capabilities import ( + agent_for_cli, + list_agents, + list_skills, + match_skill, + recommended_skill_stack, + resolve_skill_and_agent, + skill_by_id, + skill_catalog_brief, +) +from .memory import instruction_skill_sources, search_memory from .state import load_fleet_config, load_models_config +CHAT_ROLES = ("claude", "codex", "copilot", "vibe") + def _fleet_labels() -> dict[str, str]: fleet = load_fleet_config() @@ -47,9 +62,72 @@ def _lower(value: str) -> str: return value.lower() -def _route_machine(goal: str) -> str: +def _public_skill_ids() -> list[str]: + return [item["id"] for item in list_skills(include_internal=False)] + + +def _extract_explicit_skill(message: str) -> tuple[str, dict[str, Any] | None]: + raw = message.strip() + lowered = raw.lower() + + if lowered.startswith("skill:"): + payload = raw[len("skill:") :].strip() + if payload: + parts = payload.split(maxsplit=1) + try: + skill = skill_by_id(parts[0], include_internal=False) + except KeyError: + skill = None + if skill: + remainder = parts[1].strip() if len(parts) > 1 else "" + return remainder or raw, skill + + if lowered.startswith("/skill "): + payload = raw[len("/skill ") :].strip() + if payload: + parts = payload.split(maxsplit=1) + try: + skill = skill_by_id(parts[0], include_internal=False) + except KeyError: + skill = None + if skill: + remainder = parts[1].strip() if len(parts) > 1 else "" + return remainder or raw, skill + + if raw.startswith("/"): + parts = raw[1:].split(maxsplit=1) + if parts: + try: + skill = skill_by_id(parts[0], include_internal=False) + except KeyError: + skill = None + if skill: + remainder = parts[1].strip() if len(parts) > 1 else "" + return remainder or raw, skill + + for skill_id in _public_skill_ids(): + token = skill_id.lower() + if token in lowered: + try: + return raw, skill_by_id(skill_id, include_internal=False) + except KeyError: + continue + return raw, None + + +def _route_machine(goal: str, skill_id: str | None = None) -> str: labels = _fleet_labels() goal_l = _lower(goal) + if skill_id in {"spec-planner", "repo-onboarding", "task-decomposer"}: + return labels["local"] + if skill_id == "architecture-brainstorm": + return labels["lab_a"] + if skill_id == "pr-review-prep": + return labels["builder_a"] + if skill_id == "ops-deployment": + return labels["edge_a"] + if skill_id == "debug-restoration" and any(token in goal_l for token in ("performance", "deep", "benchmark", "compiler", "cuda")): + return labels["builder_b"] if any(token in goal_l for token in ("ssh", "shell", "fleet", "ops", "network", "infra")): return labels["edge_a"] if any(token in goal_l for token in ("refactor", "performance", "deep", "cuda", "compiler", "benchmark")): @@ -62,6 +140,9 @@ def _route_machine(goal: str) -> str: def _route_cli(goal: str) -> str: + skill = match_skill(goal) + if skill.get("preferred_cli"): + return str(skill["preferred_cli"]) goal_l = _lower(goal) if any(token in goal_l for token in ("brainstorm", "idea", "alternative", "explore", "compare")): return "vibe" @@ -81,21 +162,26 @@ def _route_backend(machine: str, cli: str, goal: str) -> str: def _route_agent(cli: str) -> str: - return { - "claude": "planner", - "codex": "executor", - "vibe": "analyst", - "copilot": "assistant", - }.get(cli, "executor") + return agent_for_cli(cli)["id"] -def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any]: - machine = _route_machine(goal) - cli = _route_cli(goal) - backend = _route_backend(machine, cli, goal) +def _heuristic_plan(goal: str, workspace: str, mission_id: str, overrides: dict[str, Any] | None = None) -> dict[str, Any]: + overrides = overrides or {} + resolved = resolve_skill_and_agent( + goal=goal, + skill_id=overrides.get("skill"), + agent_id=overrides.get("agent"), + cli=overrides.get("cli"), + ) + skill = resolved["skill"] + cli = str(resolved["cli"]) + agent = resolved["agent"] + machine = str(overrides.get("machine") or _route_machine(goal, str(skill["id"]))) + backend = str(overrides.get("backend") or _route_backend(machine, cli, goal)) + skill_sources = instruction_skill_sources(workspace, query=goal, limit=4) if workspace else [] return { "title": goal.strip().splitlines()[0][:80] or mission_id, - "summary": f"Route the mission to {machine} using {cli} via {backend}.", + "summary": f"Route the mission to {machine} using {cli} via {backend} for skill {skill['id']}.", "steps": [ { "step_id": "step-1", @@ -105,7 +191,11 @@ def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any "machine": machine, "backend": backend, "cli": cli, - "agent": _route_agent(cli), + "agent": agent["id"], + "agent_role": agent["role"], + "skill": skill["id"], + "skill_summary": skill["summary"], + "skill_sources": [item["path"] for item in skill_sources], "status": "pending", "attempt": 0, "depends_on": [], @@ -187,12 +277,158 @@ def _heuristic_verify(step: dict[str, Any], execution: dict[str, Any]) -> dict[s def _heuristic_buddy_answer(prompt: str, mission: dict[str, Any] | None) -> dict[str, Any]: title = mission["title"] if mission else "mission" + skill = match_skill(prompt) return { - "answer": f"Qwen buddy heuristic view for {title}: focus on route correctness, CLI fit, and whether codex should own the technical step. Prompt: {prompt}", + "answer": ( + f"Qwen buddy heuristic view for {title}: " + f"skill={skill['id']}, focus on route correctness, CLI fit, and whether the selected agent matches the workflow stage. " + f"Prompt: {prompt}" + ), "mode": "heuristic", } +def _match_machine_label(message: str) -> str | None: + message_l = _lower(message) + for machine in load_fleet_config()["machines"]: + label = machine["label"] + if label.lower() in message_l: + return label + return None + + +def _match_role(message: str) -> str | None: + message_l = _lower(message) + for role in CHAT_ROLES: + if role in message_l: + return role + return None + + +def _heuristic_chat( + message: str, + mission: dict[str, Any] | None, + workspace: str, + selected_machine: str | None, + selected_role: str | None, +) -> dict[str, Any]: + prompt, explicit_skill = _extract_explicit_skill(message) + prompt = prompt.strip() + prompt_l = _lower(prompt) + memory_hits = search_memory(prompt, workspace=workspace, limit=4).get("hits", []) if workspace else [] + skill_sources = instruction_skill_sources(workspace, query=prompt, limit=4) if workspace else [] + memory_lines = [f"{hit['kind']} {hit['path']} :: {hit['snippet']}" for hit in memory_hits[:3]] + buddy_note = None + cockpit_action: dict[str, Any] | None = None + intent = "plain_chat" + + target_machine = _match_machine_label(prompt) or selected_machine or _fleet_labels()["local"] + target_role = _match_role(prompt) or selected_role or "codex" + matched_skill = explicit_skill or (match_skill(prompt) if prompt else None) + + if any(token in prompt_l for token in ("open cockpit", "attach cockpit", "show cockpit")): + intent = "cockpit_open" + cockpit_action = {"type": "open"} + reply = "I can hand off to the full cockpit now." + elif any(token in prompt_l for token in ("restart", "relance", "respawn")): + intent = "cockpit_restart" + cockpit_action = {"type": "restart", "machine": target_machine, "pane": target_role} + reply = f"I'll restart {target_machine}:{target_role}." + elif any(token in prompt_l for token in ("capture", "log", "logs", "show pane", "see pane")): + intent = "cockpit_capture" + cockpit_action = {"type": "capture", "machine": target_machine, "pane": target_role} + reply = f"I'll capture {target_machine}:{target_role}." + elif any(token in prompt_l for token in ("focus", "jump", "go to", "ouvre", "open machine")): + intent = "cockpit_focus" + cockpit_action = {"type": "focus", "machine": target_machine, "pane": target_role} + reply = f"I'll focus {target_machine}:{target_role}." + elif any(token in prompt_l for token in ("memory", "remember", "decision", "persona", "what do we know", "qu'est-ce qu", "souviens")): + intent = "memory_lookup" + reply = "Memory lookup ready." + elif explicit_skill is None and prompt.endswith("?") and not any( + token in prompt_l for token in ("fix", "build", "implement", "write", "create", "deploy", "restart", "capture", "focus") + ): + intent = "plain_chat" + reply = "Here's the operator view." + else: + intent = "mission_create" + routing_overrides = { + "skill": matched_skill["id"] if matched_skill else None, + "agent": matched_skill["preferred_agent"] if matched_skill else None, + "cli": matched_skill["preferred_cli"] if matched_skill else None, + } + preview = _heuristic_plan(prompt, workspace, "chat-preview", routing_overrides) + review = _heuristic_buddy_review(prompt, preview) + step = preview["steps"][0] + buddy_note = { + "answer": review["summary"], + "mode": "heuristic", + } + matched_skill_id = preview["steps"][0]["skill"] + reply = ( + f"I turned that into a mission. Route preview: " + f"{step['machine']}/{step['cli']}/{step['backend']} " + f"skill={matched_skill_id} agent={step['agent']}." + ) + + if intent != "mission_create" and any( + token in prompt_l for token in ("route", "reroute", "which machine", "which cli", "codex", "claude", "vibe", "copilot") + ): + buddy_note = _heuristic_buddy_answer(prompt, mission) + + if intent == "memory_lookup": + if memory_lines: + reply = "Memory echoes:\n- " + "\n- ".join(memory_lines[:3]) + else: + reply = "No strong memory hits for that query yet." + elif intent == "plain_chat": + title = mission["title"] if mission else "global cockpit" + route_hint = f" selected={selected_machine or '-'}:{selected_role or '-'}" + reply = f"Constant view for {title}.{route_hint}" + if memory_lines: + reply += "\nMemory echoes:\n- " + "\n- ".join(memory_lines[:2]) + + return { + "intent": intent, + "reply": reply, + "message": prompt, + "mode": "heuristic", + "cockpit_action": cockpit_action, + "buddy_note": buddy_note, + "memory_hits": memory_hits, + "skill_sources": skill_sources, + "workspace": workspace, + "mission_goal": prompt if intent == "mission_create" else None, + "skill": matched_skill, + "routing_overrides": { + "skill": matched_skill["id"] if matched_skill else None, + "agent": matched_skill["preferred_agent"] if matched_skill else None, + "cli": matched_skill["preferred_cli"] if matched_skill else None, + } + if intent == "mission_create" and matched_skill + else {}, + } + + +def _budget_chat_history(chat_history: list[dict[str, Any]] | None, limit: int = 8) -> list[dict[str, Any]]: + if not chat_history: + return [] + important = [ + entry for entry in chat_history + if entry.get("intent") in {"mission_create", "cockpit_error", "buddy_answer", "memory_lookup"} + ] + recent = list(chat_history[-limit:]) + merged: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for entry in [*important[-3:], *recent]: + key = (str(entry.get("timestamp", "")), str(entry.get("content", ""))) + if key in seen: + continue + seen.add(key) + merged.append(entry) + return merged[-limit:] + + @dataclass class ModelHealth: role: str @@ -207,7 +443,30 @@ class PlannerEngine: self._models = load_models_config() self._loaded: dict[str, tuple[Any, Any]] = {} self._lock = threading.Lock() - self._mlx_python = importlib.util.find_spec("mlx_lm") is not None + self._mlx_probe = self._probe_mlx() + self._mlx_python = self._mlx_probe["available"] + + def _probe_mlx(self) -> dict[str, Any]: + enable_setting = self._models.get("enable_mlx", "auto") + requested = str(enable_setting).lower() not in {"0", "false", "off", "no"} + package_present = importlib.util.find_spec("mlx_lm") is not None + if not requested: + return {"requested": False, "package_present": package_present, "available": False, "reason": "disabled"} + if not package_present: + return {"requested": True, "package_present": False, "available": False, "reason": "mlx_lm not installed"} + probe = subprocess.run( + [sys.executable, "-c", "import mlx.core as mx; print(mx.default_device())"], + capture_output=True, + text=True, + ) + return { + "requested": True, + "package_present": True, + "available": probe.returncode == 0, + "reason": probe.stderr.strip() or ("ok" if probe.returncode == 0 else "mlx probe failed"), + "stdout": probe.stdout.strip(), + "returncode": probe.returncode, + } def health(self) -> dict[str, Any]: health = {} @@ -222,8 +481,12 @@ class PlannerEngine: ).__dict__ return { "mlx_python": self._mlx_python, + "mlx_probe": self._mlx_probe, "models": health, "fallback_mode": self._models.get("fallback_mode", "heuristic"), + "agents": list_agents(), + "skills": list_skills(), + "recommended_skill_stack": recommended_skill_stack(), } def _load_model(self, role: str) -> tuple[Any, Any]: @@ -285,7 +548,8 @@ class PlannerEngine: ).strip() def plan_mission(self, mission: dict[str, Any]) -> dict[str, Any]: - plan = _heuristic_plan(mission["goal"], mission["workspace"], mission["mission_id"]) + overrides = mission.get("routing_overrides") or {} + plan = _heuristic_plan(mission["goal"], mission["workspace"], mission["mission_id"], overrides) review = _heuristic_buddy_review(mission["goal"], plan) fleet = load_fleet_config() machine_labels = [machine["label"] for machine in fleet["machines"]] @@ -302,9 +566,11 @@ class PlannerEngine: "mission_id": mission["mission_id"], "goal": mission["goal"], "workspace": mission["workspace"], + "routing_overrides": overrides, "machines": machine_labels, "allowed_backends": ["omc", "cli-local", "cli-ssh", "cockpit"], "allowed_clis": ["claude", "codex", "vibe"], + "skill_catalog": skill_catalog_brief(include_internal=True), }, indent=2, ) @@ -317,16 +583,30 @@ class PlannerEngine: "steps": [], } for index, step in enumerate(llama_plan["steps"], start=1): + resolved = resolve_skill_and_agent( + goal=step.get("prompt", mission["goal"]), + skill_id=step.get("skill") or overrides.get("skill"), + agent_id=step.get("agent") or overrides.get("agent"), + cli=step.get("cli") or overrides.get("cli"), + ) + resolved_skill = resolved["skill"] + resolved_agent = resolved["agent"] + resolved_cli = str(resolved["cli"]) + resolved_machine = step.get("machine") or overrides.get("machine") or _route_machine(step.get("prompt", mission["goal"]), resolved_skill["id"]) plan["steps"].append( { "step_id": step.get("step_id", f"step-{index}"), "kind": step.get("kind", "task"), "title": step.get("title", f"Step {index}"), "prompt": step.get("prompt", mission["goal"]), - "machine": step.get("machine", _route_machine(mission["goal"])), - "backend": step.get("backend", _route_backend(step.get("machine", local_machine), step.get("cli", "codex"), mission["goal"])), - "cli": step.get("cli", _route_cli(mission["goal"])), - "agent": step.get("agent", _route_agent(step.get("cli", "codex"))), + "machine": resolved_machine, + "backend": step.get("backend", _route_backend(resolved_machine, resolved_cli, mission["goal"])), + "cli": resolved_cli, + "agent": resolved_agent["id"], + "agent_role": resolved_agent.get("role"), + "skill": resolved_skill["id"], + "skill_summary": step.get("skill_summary", resolved_skill["summary"]), + "skill_sources": step.get("skill_sources", [item["path"] for item in instruction_skill_sources(mission["workspace"], query=step.get("prompt", mission["goal"]), limit=4)]), "status": "pending", "attempt": 0, "depends_on": step.get("depends_on", []), @@ -354,7 +634,9 @@ class PlannerEngine: if not review.get("agrees", True): if review.get("suggested_cli") and step["cli"] == "claude": step["cli"] = review["suggested_cli"] - step["agent"] = _route_agent(step["cli"]) + agent = agent_for_cli(step["cli"]) + step["agent"] = agent["id"] + step["agent_role"] = agent["role"] if review.get("suggested_backend"): step["backend"] = review["suggested_backend"] @@ -405,3 +687,53 @@ class PlannerEngine: pass return result + + def chat( + self, + message: str, + mission: dict[str, Any] | None, + workspace: str, + selected_machine: str | None, + selected_role: str | None, + chat_history: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + result = _heuristic_chat(message, mission, workspace, selected_machine, selected_role) + + if self._mlx_python: + system = ( + "You are Constant, the main cockpit operator for a multi-machine coding fleet. " + "Return strict JSON with keys: intent, reply. " + "Valid intents: mission_create, cockpit_focus, cockpit_capture, cockpit_restart, cockpit_open, " + "buddy_answer, memory_lookup, plain_chat." + ) + user = json.dumps( + { + "message": message, + "workspace": workspace, + "mission": mission, + "selected_machine": selected_machine, + "selected_role": selected_role, + "chat_history": _budget_chat_history(chat_history), + "memory_hits": result.get("memory_hits", [])[:4], + "buddy_note": result.get("buddy_note"), + "skills": list_skills(), + "skill_catalog": skill_catalog_brief(include_internal=True), + "recommended_skill_stack": recommended_skill_stack(), + "agents": list_agents(), + }, + indent=2, + ) + try: + model_result = self._call_model_json("planner", system, user) + result["intent"] = str(model_result.get("intent", result["intent"])) + result["reply"] = str(model_result.get("reply", result["reply"])) + result["mode"] = "mlx" + except Exception: + pass + + if result["intent"] in {"plain_chat", "memory_lookup"} and result.get("buddy_note") is None: + prompt_l = _lower(message) + if any(token in prompt_l for token in ("route", "reroute", "machine", "cli", "pane", "codex", "claude", "vibe", "copilot")): + result["buddy_note"] = self.buddy_ask(mission, message) + + return result diff --git a/constant/state.py b/constant/state.py index 1ba2bf5..ebf5229 100644 --- a/constant/state.py +++ b/constant/state.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import hashlib import uuid from copy import deepcopy from datetime import UTC, datetime @@ -10,6 +11,7 @@ from typing import Any from . import __version__ from .paths import ( cache_root, + chat_root, config_root, data_root, fleet_config_path, @@ -67,6 +69,7 @@ DEFAULT_FLEET: dict[str, Any] = { DEFAULT_MODELS: dict[str, Any] = { "version": 1, + "enable_mlx": "auto", "planner": { "role": "planner", "model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit", @@ -109,7 +112,7 @@ def now_utc() -> str: def ensure_runtime_dirs() -> None: - for path in (cache_root(), config_root(), data_root(), missions_dir(), indexes_dir(), memory_sources_dir()): + for path in (cache_root(), chat_root(), config_root(), data_root(), missions_dir(), indexes_dir(), memory_sources_dir()): path.mkdir(parents=True, exist_ok=True) @@ -150,7 +153,14 @@ def load_fleet_config() -> dict[str, Any]: def load_models_config() -> dict[str, Any]: - return _read_json_yaml(models_config_path(), DEFAULT_MODELS) + payload = _read_json_yaml(models_config_path(), DEFAULT_MODELS) + merged = deepcopy(DEFAULT_MODELS) + for key, value in payload.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key].update(value) + else: + merged[key] = value + return merged def load_memory_config() -> dict[str, Any]: @@ -185,6 +195,63 @@ def mission_artifacts_dir(mission_id: str) -> Path: return mission_dir(mission_id) / "artifacts" +def _workspace_chat_slug(workspace: str) -> str: + normalized = str(Path(workspace).expanduser().resolve()) + name = Path(normalized).name or "workspace" + digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10] + return f"{name}-{digest}" + + +def chat_file(workspace: str, mission_id: str | None = None) -> Path: + ensure_runtime_dirs() + if mission_id: + return chat_root() / "missions" / f"{mission_id}.ndjson" + return chat_root() / "workspaces" / f"{_workspace_chat_slug(workspace)}.ndjson" + + +def read_chat_history(workspace: str, mission_id: str | None = None, limit: int = 80) -> list[dict[str, Any]]: + path = chat_file(workspace, mission_id=mission_id) + if not path.exists(): + return [] + + entries: list[dict[str, Any]] = [] + for raw in path.read_text(encoding="utf-8").splitlines()[-limit:]: + try: + entries.append(json.loads(raw)) + except json.JSONDecodeError: + continue + return entries + + +def append_chat_message( + role: str, + content: str, + *, + workspace: str, + mission_id: str | None = None, + intent: str | None = None, + machine: str | None = None, + pane: str | None = None, + meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + path = chat_file(workspace, mission_id=mission_id) + path.parent.mkdir(parents=True, exist_ok=True) + entry = { + "timestamp": now_utc(), + "role": role, + "content": content, + "intent": intent or "plain_chat", + "workspace": str(Path(workspace).expanduser().resolve()), + "mission_id": mission_id, + "machine": machine, + "pane": pane, + "meta": meta or {}, + } + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, sort_keys=True) + "\n") + return entry + + def create_mission(goal: str, workspace: str, routing_overrides: dict[str, Any] | None = None) -> dict[str, Any]: mission_id = uuid.uuid4().hex[:12] mission = { diff --git a/constant/tui.py b/constant/tui.py index 292f45b..81aaeab 100644 --- a/constant/tui.py +++ b/constant/tui.py @@ -4,13 +4,139 @@ import curses import json import textwrap import time +from dataclasses import dataclass from pathlib import Path from typing import Any +from .capabilities import list_skills from .cockpit import ROLES, capture_pane, focus_machine, restart_pane, runtime_status from .daemon import request as daemon_request from .memory import list_decisions, memory_status, persona_markdown, prime_workspace_memory, rebuild_workspace_memory, summarize_mission -from .state import list_missions, load_fleet_config, mission_events_file +from .state import ( + append_chat_message, + append_event, + create_mission, + list_missions, + load_fleet_config, + mission_events_file, + read_chat_history, + save_mission, +) + + +CHAT_LABELS = { + "user": "YOU", + "constant": "CONSTANT", + "buddy": "BUDDY", + "system": "SYSTEM", +} + +HEXAPUS_FRAMES = ( + [ + " .-====-.", + " .-' .--. `-.", + " / ( oo ) \\", + " | \\/\\/ |", + " | .-____-. |", + " \\__/_/ || \\_\\__/", + " /_ /||\\ _\\", + " .-' \\/ || \\/ `-.", + " <_____/ || \\_____>", + " /___/\\___\\", + ], + [ + " .-====-.", + " .-' .--. `-.", + " / ( oo ) \\", + " | \\/\\/ |", + " | .-____-. |", + " _/\\__/ / || \\ \\__/\\_", + " /_ _\\/ || \\/_ _\\", + " `-. / || \\ .-'", + " <_____/ || \\_____>", + " /___/\\___\\", + ], + [ + " .-====-.", + " .-' .--. `-.", + " / ( -- ) \\", + " | \\/\\/ |", + " | .-____-. |", + " \\__/_/ || \\_\\__/", + " /_ /||\\ _\\", + " .--' \\/ || \\/ `--.", + " <_____/ || \\_____>", + " /___/\\___\\", + ], + [ + " .-====-.", + " .-' .--. `-.", + " / ( oo ) \\", + " | \\/\\/ |", + " | .-____-. |", + " _/\\__/_/ || \\_\\__ /\\_", + " /_ _/ /||\\ \\_ _\\", + " `-. / || \\ .-'", + " <_____/ || \\_____>", + " /___/\\___\\", + ], + [ + " .-====-.", + " .-' .--. `-.", + " / ( xx ) \\", + " | \\/\\/ |", + " | .-____-. |", + " \\__/__/||\\__\\__/", + " _\\_ /||\\ _/_", + " .--' \\/ || \\/ `--.", + " <_____/ || \\_____>", + " /___/\\___\\", + ], +) + + +@dataclass +class TuiLayout: + height: int + width: int + top: int + footer_height: int + timeline_height: int + main_height: int + left_width: int + right_width: int + center_width: int + conversation_height: int + runtime_height: int + + +@dataclass +class TuiState: + selected_index: int = 0 + selected_machine: int = 0 + selected_role_index: int = 1 + flash: str = "" + flash_until: float = 0.0 + runtime_cache: dict[str, Any] | None = None + runtime_refresh_at: float = 0.0 + input_mode: bool = True + input_buffer: str = "" + slash_menu_index: int = 0 + capture_state: dict[str, Any] | None = None + pending_select_mission_id: str | None = None + chat_focus: bool = True + stream_text: str = "" + stream_started_at: float = 0.0 + stream_duration: float = 0.0 + stream_mission_id: str | None = None + + @property + def selected_role(self) -> str: + return ROLES[self.selected_role_index] + + def set_flash(self, message: str, duration: float = 2.5) -> None: + self.flash = message + self.flash_until = time.time() + duration def _error_line(prefix: str, exc: Exception) -> str: @@ -56,6 +182,8 @@ def _safe_runtime_status(local_session: str, machine_session: str) -> dict[str, "machines": [], "fleet_windows": [], "fleet_stderr": _error_line("cockpit", exc), + "focused_machine": None, + "focused_role": None, } @@ -167,23 +295,29 @@ def _normalize_buddy(review: dict[str, Any] | None) -> dict[str, str]: } -def _hexapus_lines() -> list[str]: - return [ - " .-====-.", - " .-' .--. `-.", - " / ( oo ) \\", - " | \\/\\/ |", - " | .-____-. |", - " \\__/_/ || \\_\\__/", - " /_ /||\\ _\\", - " .-' \\/ || \\/ `-.", - " <_____/ || \\_____>", - " /___/\\___\\", - ] +def _hexapus_lines(verdict: str, tick: int) -> list[str]: + if verdict in {"block", "failed"}: + return HEXAPUS_FRAMES[4] + if verdict == "reroute": + return HEXAPUS_FRAMES[tick % 4] + if verdict == "warn": + return HEXAPUS_FRAMES[(tick // 2) % 4] + return HEXAPUS_FRAMES[(tick // 3) % 2] def _init_colors() -> dict[str, int]: - palette = {"base": 0, "accent": 0, "muted": 0, "warn": 0, "good": 0, "bad": 0, "hot": 0} + palette = { + "base": 0, + "accent": 0, + "muted": 0, + "warn": 0, + "good": 0, + "bad": 0, + "hot": 0, + "user": 0, + "system": 0, + "buddy": 0, + } if not curses.has_colors(): return palette curses.start_color() @@ -197,6 +331,7 @@ def _init_colors() -> dict[str, int]: curses.init_pair(4, curses.COLOR_GREEN, -1) curses.init_pair(5, curses.COLOR_RED, -1) curses.init_pair(6, curses.COLOR_MAGENTA, -1) + curses.init_pair(7, curses.COLOR_WHITE, -1) palette = { "base": curses.color_pair(1), "accent": curses.color_pair(6) | curses.A_BOLD, @@ -205,6 +340,9 @@ def _init_colors() -> dict[str, int]: "good": curses.color_pair(4) | curses.A_BOLD, "bad": curses.color_pair(5) | curses.A_BOLD, "hot": curses.color_pair(6) | curses.A_BOLD, + "user": curses.color_pair(7) | curses.A_BOLD, + "system": curses.color_pair(3), + "buddy": curses.color_pair(4), } return palette @@ -243,18 +381,159 @@ def _role_state(machine: dict[str, Any], role: str) -> str: return "ok" -def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: int, runtime: dict[str, Any], memory: dict[str, Any], health: dict[str, Any], colors: dict[str, int]) -> None: +def _render_role_token(state: str, selected: bool, focused: bool) -> str: + if selected and focused: + return f"*{state}*" + if selected: + return f"<{state}>" + if focused: + return f"{{{state}}}" + return f"[{state}]" + + +def _message_attr(role: str, colors: dict[str, int]) -> int: + if role == "user": + return colors["user"] + if role == "system": + return colors["system"] + if role == "buddy": + return colors["buddy"] + if role == "constant": + return colors["accent"] + return colors["base"] + + +def _thread_preview(workspace: str, mission_id: str | None = None) -> str: + history = read_chat_history(workspace=workspace, mission_id=mission_id, limit=1) + if not history: + return "Start a conversation" + last = history[-1] + content = str(last.get("content", "")).strip() or "(empty)" + role = CHAT_LABELS.get(str(last.get("role", "system")), str(last.get("role", "system")).upper()) + return f"{role}: {content}" + + +def _streamed_text(state: TuiState, mission_id: str | None, text: str) -> str: + if not state.stream_text or state.stream_text != text: + return text + if state.stream_mission_id != mission_id: + return text + elapsed = max(0.0, time.time() - state.stream_started_at) + if elapsed >= state.stream_duration: + return text + total = max(1, len(text)) + progress = max(1, min(total, int(total * (elapsed / max(0.1, state.stream_duration))))) + return text[:progress] + + +def _public_skill_menu() -> list[dict[str, Any]]: + return list_skills(include_internal=False) + + +def _skill_menu_candidates(input_buffer: str) -> list[dict[str, Any]]: + if not input_buffer.startswith("/"): + return [] + payload = input_buffer[1:] + if not payload: + return _public_skill_menu() + if payload.startswith("skill "): + query = payload[len("skill ") :].strip().lower() + if " " in query: + return [] + else: + stripped = payload.strip().lower() + if " " in stripped: + return [] + query = stripped + skills = _public_skill_menu() + if not query: + return skills + ranked: list[tuple[int, dict[str, Any]]] = [] + for skill in skills: + score = 0 + skill_id = str(skill["id"]).lower() + label = str(skill["label"]).lower() + summary = str(skill["summary"]).lower() + aliases = " ".join(str(alias).lower() for alias in skill.get("aliases", [])) + if skill_id.startswith(query): + score += 5 + if query in skill_id: + score += 3 + if query in label: + score += 2 + if query in aliases: + score += 2 + if query in summary: + score += 1 + if score > 0: + ranked.append((score, skill)) + ranked.sort(key=lambda item: (-item[0], item[1]["id"])) + return [item[1] for item in ranked] or skills + + +def _slash_menu_pending(input_buffer: str) -> bool: + return input_buffer.startswith("/") and bool(_skill_menu_candidates(input_buffer)) + + +def _complete_slash_skill(input_buffer: str, selection: dict[str, Any]) -> str: + if input_buffer.startswith("/skill "): + return f"/skill {selection['id']} " + return f"/{selection['id']} " + + +def _draw_skill_menu( + stdscr: Any, + y: int, + x: int, + width: int, + candidates: list[dict[str, Any]], + selected_index: int, + colors: dict[str, int], +) -> None: + if not candidates or width < 28: + return + visible = candidates[: min(5, len(candidates))] + height = len(visible) + 3 + _draw_box(stdscr, y, x, height, width, "Skill Menu", colors["accent"]) + _safe_addstr(stdscr, y + 1, x + 2, "Tab/↑↓ browse | Enter complete", colors["muted"]) + row = y + 2 + selected = max(0, min(selected_index, len(visible) - 1)) + for index, skill in enumerate(visible): + marker = ">" if index == selected else " " + skill_id = str(skill["id"]) + summary = str(skill["summary"]) + line = f"{marker} /{skill_id:<24} {summary}" + attr = colors["accent"] if index == selected else colors["base"] + _safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr) + row += 1 + + +def _draw_header( + stdscr: Any, + workspace: str, + mission_count: int, + fleet_count: int, + runtime: dict[str, Any], + memory: dict[str, Any], + health: dict[str, Any], + colors: dict[str, int], + chat_scope_label: str, + selected_machine_label: str | None, + selected_role: str, +) -> None: memory_counts = memory.get("counts", {}) planner_backend = health.get("models", {}).get("planner", {}).get("backend", "heuristic") buddy_backend = health.get("models", {}).get("buddy", {}).get("backend", "heuristic") cockpit_state = "up" if runtime.get("fleet_session_exists") else "down" - _safe_addstr(stdscr, 0, 2, "########## CONSTANT::DEMOSCENE::TUI ##########", colors["accent"]) + focused = f"{runtime.get('focused_machine') or '-'}:{runtime.get('focused_role') or '-'}" + selected = f"{selected_machine_label or '-'}:{selected_role}" + _safe_addstr(stdscr, 0, 2, "Constant :: chat-first fleet cockpit", colors["accent"]) _safe_addstr( stdscr, 1, 2, _clip( - f"workspace={workspace} fleet={fleet_count} cockpit={cockpit_state} missions={mission_count} docs={memory_counts.get('documents', 0)} chunks={memory_counts.get('chunks', 0)} decisions={memory_counts.get('decisions', 0)}", + f"workspace={workspace} scope={chat_scope_label} cockpit={cockpit_state} fleet={fleet_count} threads={mission_count}", max(20, stdscr.getmaxyx()[1] - 4), ), colors["base"], @@ -264,7 +543,7 @@ def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: i 2, 2, _clip( - f"planner={planner_backend} buddy={buddy_backend} keys: j/k mission [/ ] machine 1..4 pane o jump r restart z cockpit q quit", + f"planner={planner_backend} buddy={buddy_backend} selected={selected} focused={focused} docs={memory_counts.get('documents', 0)} decisions={memory_counts.get('decisions', 0)}", max(20, stdscr.getmaxyx()[1] - 4), ), colors["muted"], @@ -281,10 +560,10 @@ def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: i def _draw_missions(stdscr: Any, y: int, x: int, height: int, width: int, missions: list[dict[str, Any]], selected: int, colors: dict[str, int]) -> None: - _draw_box(stdscr, y, x, height, width, "Mission Deck", colors["accent"]) + _draw_box(stdscr, y, x, height, width, "Threads", colors["accent"]) row = y + 1 if not missions: - _safe_addstr(stdscr, row, x + 2, "No missions yet. Use `Constant mission create`.", colors["muted"]) + _safe_addstr(stdscr, row, x + 2, "No missions yet. Start typing.", colors["muted"]) return for index, mission in enumerate(missions[: max(1, height - 2)]): marker = ">" if index == selected else " " @@ -297,38 +576,113 @@ def _draw_missions(stdscr: Any, y: int, x: int, height: int, width: int, mission break -def _draw_board(stdscr: Any, y: int, x: int, height: int, width: int, mission: dict[str, Any] | None, colors: dict[str, int]) -> None: - _draw_box(stdscr, y, x, height, width, "Mission Board", colors["accent"]) - row = y + 1 - if not mission: - lines = [ - "No active mission selected.", - "", - "Try:", - "Constant mission create \"audit this repo\" --workspace \"$PWD\"", - ] - _write_wrapped(stdscr, row, x + 2, width - 4, lines, colors["muted"]) - return - - row = _write_wrapped(stdscr, row, x + 2, width - 4, [mission["title"]], colors["hot"]) +def _draw_threads_focus( + stdscr: Any, + y: int, + x: int, + height: int, + width: int, + workspace: str, + missions: list[dict[str, Any]], + selected: int, + colors: dict[str, int], +) -> None: + _safe_addstr(stdscr, y, x + 1, "Chats", colors["accent"]) + row = y + 2 + _safe_addstr(stdscr, row, x + 1, _clip("global", width - 2), colors["hot"]) row += 1 - row = _write_wrapped(stdscr, row, x + 2, width - 4, [mission.get("goal", "")], colors["base"]) - row += 1 - _safe_addstr(stdscr, row, x + 2, f"workspace: {mission.get('workspace', '-')}", colors["muted"]) + _safe_addstr(stdscr, row, x + 2, _clip(_thread_preview(workspace), width - 3), colors["muted"]) row += 2 - _safe_addstr(stdscr, row, x + 2, "steps:", colors["accent"]) - row += 1 - for step in mission.get("steps", [])[: max(1, height - (row - y) - 2)]: - line = f"[{_status_tag(step['status'])}] {step['step_id']} {step['machine']}/{step['cli']}/{step['backend']}" - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"]) + if not missions: + _safe_addstr(stdscr, row, x + 1, "No mission threads yet.", colors["muted"]) + return + for index, mission in enumerate(missions[: max(1, height - 6)]): + marker = "›" if index == selected else " " + title = f"{marker} {mission['title']}" + attr = colors["accent"] if index == selected else colors["base"] + _safe_addstr(stdscr, row, x + 1, _clip(title, width - 2), attr) row += 1 - details = step.get("result_summary") or step.get("qwen_review") or step.get("title", "") - if details and row < y + height - 1: - row = _write_wrapped(stdscr, row, x + 4, width - 6, [details], colors["muted"]) + _safe_addstr( + stdscr, + row, + x + 2, + _clip(_thread_preview(mission.get("workspace", workspace), mission["mission_id"]), width - 3), + colors["muted"], + ) + row += 2 if row >= y + height - 1: break +def _draw_conversation( + stdscr: Any, + y: int, + x: int, + height: int, + width: int, + entries: list[dict[str, Any]], + mission: dict[str, Any] | None, + state: TuiState, + colors: dict[str, int], + focus_mode: bool = False, +) -> None: + if mission and mission.get("steps"): + step = next((entry for entry in mission["steps"] if entry.get("status") not in {"done", "failed"}), mission["steps"][0]) + scope = f"{mission['title']} :: {step.get('skill', '-')} / {step.get('agent', '-')}" + compact_scope = f"{mission['title']} · {step.get('skill', '-')} / {step.get('agent', '-')}" + else: + scope = f"mission {mission['mission_id'][:6]}" if mission else "workspace" + compact_scope = scope + if focus_mode: + _safe_addstr(stdscr, y, x + 1, "Constant", colors["accent"]) + _safe_addstr(stdscr, y + 1, x + 1, _clip(compact_scope, width - 2), colors["muted"]) + row = y + 3 + else: + _draw_box(stdscr, y, x, height, width, f"Constant :: {scope}", colors["accent"]) + row = y + 1 + if not entries: + hints = [ + "No conversation yet.", + "", + "Type directly to talk to Constant.", + "Use / for workflow skills.", + "Actionable prompts create missions immediately.", + ] + _write_wrapped(stdscr, row, x + 2, width - 4, hints, colors["muted"]) + return + + rendered: list[tuple[str, int]] = [] + mission_id = mission.get("mission_id") if mission else None + for entry in entries: + role = str(entry.get("role", "system")) + label = CHAT_LABELS.get(role, role.upper()) + timestamp = str(entry.get("timestamp", ""))[-9:-1] + meta = entry.get("meta") or {} + skill_tag = str(meta.get("skill", "")).strip() + badge = f"[{skill_tag}] " if skill_tag else "" + prefix = f"{timestamp} {label:<8}> {badge}" if not focus_mode else f"{label.lower():>8} " + content = str(entry.get("content", "")).strip() or "(empty)" + if role == "constant": + content = _streamed_text(state, mission_id, content) + wrapped = textwrap.wrap(content, max(10, width - 4 - len(prefix)), subsequent_indent=" " * len(prefix)) or [content] + rendered.append((prefix + wrapped[0], _message_attr(role, colors))) + for line in wrapped[1:]: + rendered.append((" " * len(prefix) + line.lstrip(), _message_attr(role, colors))) + if meta.get("created_mission_id"): + rendered.append((f"{' ' * len(prefix)}-> mission {meta['created_mission_id']}", colors["system"])) + route_bits = [str(meta.get("agent", "")).strip(), str(meta.get("cli", "")).strip()] + route = " / ".join(part for part in route_bits if part) + if route: + rendered.append((f"{' ' * len(prefix)}-> route {route}", colors["muted"])) + + visible = rendered[-max(1, height - 2):] + for line, attr in visible: + if row >= y + height - 1: + break + _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), attr) + row += 1 + + def _draw_runtime( stdscr: Any, y: int, @@ -336,44 +690,101 @@ def _draw_runtime( height: int, width: int, runtime: dict[str, Any], + mission: dict[str, Any] | None, selected_machine: int, selected_role: str, colors: dict[str, int], ) -> None: - _draw_box(stdscr, y, x, height, width, "Cockpit Runtime", colors["accent"]) + _draw_box(stdscr, y, x, height, width, "Fleet Context", colors["accent"]) row = y + 1 machines = runtime.get("machines", []) if not machines: _safe_addstr(stdscr, row, x + 2, "No runtime data yet. Open the cockpit with z.", colors["muted"]) return - header = "machine claude codex copilot vibe session target" + header = "sel live machine claude codex copilot vibe state target" _safe_addstr(stdscr, row, x + 2, _clip(header, width - 4), colors["hot"]) row += 1 + mission_steps = mission.get("steps", []) if mission else [] for index, machine in enumerate(machines[: max(1, height - 3)]): - marker = ">" if index == selected_machine else " " - attr = colors["accent"] if index == selected_machine else colors["base"] + selected = index == selected_machine + focused = machine["label"] == runtime.get("focused_machine") + marker = ">" if selected else " " + live = "*" if focused else " " + attr = colors["accent"] if selected else (colors["hot"] if focused else colors["base"]) status = "up" if machine.get("session_exists") else "down" role_bits = [] for role in ROLES: state = _role_state(machine, role) - token = f"[{state}]" - if role == selected_role and index == selected_machine: - token = f"<{state}>" + token = _render_role_token(state, selected and role == selected_role, focused and role == runtime.get("focused_role")) role_bits.append(token) - line = f"{marker} {machine['label']:<16} {' '.join(role_bits)} {status:<7} {machine['target']}" + line = f"{marker} {live} {machine['label']:<16} {' '.join(role_bits)} {status:<7} {machine['target']}" _safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr) row += 1 + matching = sorted( + (step for step in mission_steps if step.get("machine") == machine["label"]), + key=lambda step: (step.get("status") in {"done", "failed"}, step.get("attempt", 0)), + ) + for step in matching[:1]: + route_line = ( + f" route {step.get('cli', '-')}/{step.get('backend', '-')} " + f"skill={step.get('skill', '-')} agent={step.get('agent', '-')}" + ) + route_attr = colors["muted"] if not selected else colors["good"] + if row < y + height - 1: + _safe_addstr(stdscr, row, x + 1, _clip(route_line, width - 3), route_attr) + row += 1 if row >= y + height - 1: break -def _draw_buddy(stdscr: Any, y: int, x: int, height: int, width: int, review: dict[str, str], memory: dict[str, Any], colors: dict[str, int]) -> None: - _draw_box(stdscr, y, x, height, width, "Hexapus Buddy Rail", colors["accent"]) +def _draw_capture_view( + stdscr: Any, + y: int, + x: int, + height: int, + width: int, + capture_state: dict[str, Any], + colors: dict[str, int], +) -> None: + title = f"Capture View :: {capture_state['machine']}:{capture_state['pane']}" + _draw_box(stdscr, y, x, height, width, title, colors["accent"]) + lines = capture_state.get("lines", []) or ["(empty capture)"] + row = y + 1 + body_height = max(1, height - 2) + max_scroll = max(0, len(lines) - body_height) + scroll = max(0, min(capture_state.get("scroll", 0), max_scroll)) + capture_state["scroll"] = scroll + status_bits = [f"scroll {scroll + 1}/{max(1, max_scroll + 1)}"] + if capture_state.get("returncode"): + status_bits.append(f"rc={capture_state['returncode']}") + _safe_addstr(stdscr, row, x + 2, _clip(" | ".join(status_bits), width - 4), colors["muted"]) + row += 1 + visible = lines[scroll : scroll + max(1, body_height - 1)] + for line in visible: + if row >= y + height - 1: + break + attr = colors["bad"] if capture_state.get("returncode") else colors["base"] + _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), attr) + row += 1 + + +def _draw_buddy( + stdscr: Any, + y: int, + x: int, + height: int, + width: int, + review: dict[str, str], + memory: dict[str, Any], + colors: dict[str, int], + tick: int, +) -> None: + _draw_box(stdscr, y, x, height, width, "Buddy", colors["accent"]) row = y + 1 verdict_attr = _verdict_attr(review, colors) - for line in _hexapus_lines(): + for line in _hexapus_lines(review["verdict"], tick): if row >= y + height - 1: return _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), verdict_attr) @@ -404,6 +815,53 @@ def _draw_buddy(stdscr: Any, y: int, x: int, height: int, width: int, review: di row += 1 +def _draw_context_rail( + stdscr: Any, + y: int, + x: int, + height: int, + width: int, + review: dict[str, str], + runtime: dict[str, Any], + mission: dict[str, Any] | None, + selected_machine_label: str | None, + selected_role: str, + colors: dict[str, int], + tick: int, +) -> None: + _draw_box(stdscr, y, x, height, width, "Live", colors["accent"]) + row = y + 1 + verdict_attr = _verdict_attr(review, colors) + for line in _hexapus_lines(review["verdict"], tick)[:8]: + if row >= y + height - 1: + return + _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), verdict_attr) + row += 1 + row += 1 + lines = [ + f"buddy {review['verdict']} / {review['confidence']}", + f"selected {selected_machine_label or '-'}:{selected_role}", + f"focused {runtime.get('focused_machine') or '-'}:{runtime.get('focused_role') or '-'}", + ] + if mission and mission.get("steps"): + step = next((entry for entry in mission["steps"] if entry.get("status") not in {"done", "failed"}), mission["steps"][0]) + lines.extend( + [ + f"skill {step.get('skill', '-')}", + f"agent {step.get('agent', '-')}", + f"route {step.get('cli', '-')}/{step.get('backend', '-')}", + ] + ) + for line in lines: + if row >= y + height - 1: + return + _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"]) + row += 1 + row += 1 + if row < y + height - 1: + _write_wrapped(stdscr, row, x + 2, width - 4, [review["why"]], colors["muted"]) + + def _draw_timeline(stdscr: Any, y: int, x: int, height: int, width: int, events: list[str], persona_lines: list[str], decision_lines: list[str], colors: dict[str, int]) -> None: _draw_box(stdscr, y, x, height, width, "Timeline + Memory", colors["accent"]) half = max(20, width // 2) @@ -424,6 +882,35 @@ def _draw_timeline(stdscr: Any, y: int, x: int, height: int, width: int, events: break +def _compute_layout(height: int, width: int, chat_focus: bool) -> TuiLayout: + top = 4 + footer_height = 2 + timeline_height = 0 if chat_focus else min(6, max(4, height // 8)) + main_height = max(12, height - top - timeline_height - footer_height) + if chat_focus: + left_width = max(20, min(24, width // 6)) + right_width = max(22, min(28, width // 5)) + else: + left_width = max(22, min(28, width // 5)) + right_width = max(24, min(30, width // 4)) + center_width = max(28, width - left_width - right_width - 4) + conversation_height = main_height if chat_focus else max(10, int(main_height * 0.74)) + runtime_height = 0 if chat_focus else max(6, main_height - conversation_height) + return TuiLayout( + height=height, + width=width, + top=top, + footer_height=footer_height, + timeline_height=timeline_height, + main_height=main_height, + left_width=left_width, + right_width=right_width, + center_width=center_width, + conversation_height=conversation_height, + runtime_height=runtime_height, + ) + + def _collect_snapshot(default_workspace: str, selected_index: int, local_session: str, machine_session: str, runtime_snapshot: dict[str, Any] | None = None) -> dict[str, Any]: missions = sorted(list_missions(), key=lambda item: item.get("updated_at", ""), reverse=True) if missions: @@ -442,6 +929,8 @@ def _collect_snapshot(default_workspace: str, selected_index: int, local_session events = _recent_events(selected_mission["mission_id"] if selected_mission else None) fleet = load_fleet_config() runtime = runtime_snapshot if runtime_snapshot is not None else _safe_runtime_status(local_session, machine_session) + chat_history = read_chat_history(workspace=workspace, mission_id=selected_mission["mission_id"] if selected_mission else None, limit=80) + chat_scope_label = f"mission:{selected_mission['mission_id'][:6]}" if selected_mission else "workspace" return { "missions": missions, "selected_index": selected_index, @@ -455,168 +944,777 @@ def _collect_snapshot(default_workspace: str, selected_index: int, local_session "events": events, "fleet_count": len(fleet["machines"]), "runtime": runtime, + "chat_history": chat_history, + "chat_scope_label": chat_scope_label, } +def _ensure_mission_planned(mission: dict[str, Any]) -> dict[str, Any]: + if mission["steps"]: + return mission + payload = daemon_request("plan", {"mission": mission}) + mission["steps"] = payload["plan"]["steps"] + mission["title"] = payload["plan"]["title"] + mission["status"] = "planned" + mission["planner_summary"] = payload["plan"]["summary"] + mission["buddy_review"] = payload["buddy_review"] + save_mission(mission) + append_event(mission["mission_id"], "mission.planned", payload) + return mission + + +def _selected_machine_label(snapshot: dict[str, Any], selected_machine: int) -> str | None: + machines = snapshot["runtime"].get("machines", []) + if not machines: + return None + return machines[selected_machine]["label"] + + +def _open_capture(machine: str, pane: str, machine_session: str, current_height: int, current_scroll: int | None = None) -> dict[str, Any]: + capture = capture_pane(machine, pane, lines=200, machine_session=machine_session) + if capture.get("returncode", 0) != 0: + error_text = capture.get("stderr") or capture.get("stdout") or "capture command failed" + lines = [line for line in error_text.splitlines() if line] or ["capture command failed"] + else: + text = capture.get("stdout") or "" + lines = text.splitlines() or ["(empty capture)"] + view_height = max(8, current_height - 2) + scroll = max(0, len(lines) - view_height) if current_scroll is None else min(max(0, current_scroll), max(0, len(lines) - view_height)) + return { + "machine": machine, + "pane": pane, + "lines": lines, + "scroll": scroll, + "returncode": int(capture.get("returncode", 0)), + "raw": capture, + } + + +def _process_chat_submission( + text: str, + snapshot: dict[str, Any], + selected_machine: int, + selected_role: str, + local_session: str, + machine_session: str, + runtime_height: int, +) -> dict[str, Any]: + message = text.strip() + if not message: + return { + "flash": "empty prompt", + "force_select_mission_id": None, + "capture_state": None, + "invalidate_runtime": False, + "next_action": None, + } + + mission = snapshot["selected_mission"] + mission_id = mission["mission_id"] if mission else None + workspace = snapshot["workspace"] + machine_label = _selected_machine_label(snapshot, selected_machine) + + append_chat_message( + "user", + message, + workspace=workspace, + mission_id=mission_id, + machine=machine_label, + pane=selected_role, + ) + if mission_id: + append_event(mission_id, "chat.user", {"content": message, "machine": machine_label, "pane": selected_role}) + + payload = daemon_request( + "chat", + { + "message": message, + "mission": mission, + "workspace": workspace, + "selected_machine": machine_label, + "selected_role": selected_role, + "chat_history": snapshot["chat_history"][-12:], + }, + ) + + reply = str(payload.get("reply", "")).strip() or "No reply." + buddy_note = payload.get("buddy_note") + intent = payload.get("intent", "plain_chat") + action = payload.get("cockpit_action") + requested_skill = payload.get("skill") or {} + requested_skill_id = str(requested_skill.get("id", "")).strip() + routing_overrides = payload.get("routing_overrides") or {} + + capture_state = None + next_action = None + force_select_mission_id = None + thread_mission_id = mission_id + + if intent == "mission_create": + mission = create_mission( + payload.get("mission_goal") or message, + workspace, + routing_overrides=routing_overrides, + ) + mission = _ensure_mission_planned(mission) + force_select_mission_id = mission["mission_id"] + step = mission["steps"][0] if mission.get("steps") else {} + step_skill = step.get("skill", requested_skill_id) + append_chat_message( + "system", + f"Promoted to mission {mission['mission_id']} :: {mission['title']}", + workspace=workspace, + mission_id=mission_id, + intent="mission_create", + machine=machine_label, + pane=selected_role, + meta={ + "created_mission_id": mission["mission_id"], + "skill": step_skill, + "agent": step.get("agent"), + "cli": step.get("cli"), + }, + ) + append_chat_message( + "user", + message, + workspace=workspace, + mission_id=mission["mission_id"], + intent="mission_create", + machine=machine_label, + pane=selected_role, + meta={"skill": step_skill}, + ) + append_chat_message( + "constant", + reply, + workspace=workspace, + mission_id=mission["mission_id"], + intent="mission_create", + machine=machine_label, + pane=selected_role, + meta={ + "created_mission_id": mission["mission_id"], + "skill": step_skill, + "agent": step.get("agent"), + "cli": step.get("cli"), + }, + ) + if buddy_note: + append_chat_message( + "buddy", + str(buddy_note.get("answer", "")), + workspace=workspace, + mission_id=mission["mission_id"], + intent="buddy_answer", + meta={"skill": step_skill}, + ) + append_event(mission["mission_id"], "chat.constant", {"intent": intent, "reply": reply}) + return { + "flash": f"mission created {mission['mission_id']} -> {mission['title']} [{step.get('skill', '-')} / {step.get('agent', '-')}]", + "force_select_mission_id": force_select_mission_id, + "capture_state": None, + "invalidate_runtime": False, + "stream_text": reply, + "stream_mission_id": mission["mission_id"], + "next_action": None, + } + + append_chat_message( + "constant", + reply, + workspace=workspace, + mission_id=thread_mission_id, + intent=intent, + machine=machine_label, + pane=selected_role, + meta={ + "memory_hits": payload.get("memory_hits", [])[:3], + "skill": requested_skill_id, + "agent": routing_overrides.get("agent"), + "cli": routing_overrides.get("cli"), + }, + ) + if thread_mission_id: + append_event(thread_mission_id, "chat.constant", {"intent": intent, "reply": reply}) + + if buddy_note: + append_chat_message( + "buddy", + str(buddy_note.get("answer", "")), + workspace=workspace, + mission_id=thread_mission_id, + intent="buddy_answer", + meta={"skill": requested_skill_id}, + ) + + if action: + action_type = action.get("type") + action_machine = action.get("machine") or machine_label + action_pane = action.get("pane") or selected_role + try: + if action_type == "focus" and action_machine: + focus_machine(action_machine, action_pane, local_session=local_session, machine_session=machine_session) + append_chat_message("system", f"Focused {action_machine}:{action_pane}", workspace=workspace, mission_id=thread_mission_id, intent=intent) + return { + "flash": f"focused {action_machine}:{action_pane}", + "force_select_mission_id": None, + "capture_state": None, + "invalidate_runtime": True, + "next_action": None, + } + if action_type == "restart" and action_machine: + restart_pane(action_machine, action_pane, machine_session=machine_session) + append_chat_message("system", f"Restart sent to {action_machine}:{action_pane}", workspace=workspace, mission_id=thread_mission_id, intent=intent) + return { + "flash": f"restart sent to {action_machine}:{action_pane}", + "force_select_mission_id": None, + "capture_state": None, + "invalidate_runtime": True, + "next_action": None, + } + if action_type == "capture" and action_machine: + capture_state = _open_capture(action_machine, action_pane, machine_session, runtime_height) + append_chat_message("system", f"Captured {action_machine}:{action_pane}", workspace=workspace, mission_id=thread_mission_id, intent=intent) + return { + "flash": f"capture loaded for {action_machine}:{action_pane}", + "force_select_mission_id": None, + "capture_state": capture_state, + "invalidate_runtime": False, + "next_action": None, + } + if action_type == "open": + append_chat_message("system", "Opening full cockpit view", workspace=workspace, mission_id=thread_mission_id, intent=intent) + return { + "flash": "opening full cockpit", + "force_select_mission_id": None, + "capture_state": None, + "invalidate_runtime": False, + "next_action": {"action": "cockpit", "workspace": workspace}, + } + except Exception as exc: # noqa: BLE001 + append_chat_message("system", _error_line("cockpit action failed", exc), workspace=workspace, mission_id=thread_mission_id, intent="cockpit_error") + return { + "flash": _error_line("cockpit action failed", exc), + "force_select_mission_id": None, + "capture_state": None, + "invalidate_runtime": False, + "next_action": None, + } + + return { + "flash": "message routed through Constant", + "force_select_mission_id": None, + "capture_state": None, + "invalidate_runtime": False, + "stream_text": reply, + "stream_mission_id": thread_mission_id, + "next_action": next_action, + } + + +def _draw_status_and_prompt( + stdscr: Any, + height: int, + width: int, + *, + flash: str, + flash_until: float, + input_mode: bool, + input_buffer: str, + capture_state: dict[str, Any] | None, + snapshot: dict[str, Any], + selected_machine_label: str | None, + selected_role: str, + colors: dict[str, int], +) -> None: + now = time.time() + if flash and now < flash_until: + status = flash + status_attr = colors["warn"] + elif capture_state: + status = "capture | j/k scroll | PgUp/PgDn page | c refresh | x close | q quit" + status_attr = colors["muted"] + elif input_mode: + status = "chat | Enter send | / skills | Tab menu | Esc cockpit | Ctrl-U clear" + status_attr = colors["muted"] + else: + focused = f"{snapshot['runtime'].get('focused_machine') or '-'}:{snapshot['runtime'].get('focused_role') or '-'}" + selected = f"{selected_machine_label or '-'}:{selected_role}" + view = "chat-focus" if snapshot.get("chat_focus") else "cockpit-detail" + status = f"cockpit | type to chat | f view | [/] machine | 1..4 pane | o focus | x capture | r restart | z tabs | q quit | {view} | sel={selected} | live={focused}" + status_attr = colors["muted"] + + prompt_prefix = "› " + prompt_text = prompt_prefix + input_buffer + if input_mode: + prompt_text += "_" + + _safe_addstr(stdscr, height - 2, 2, _clip(status, width - 4), status_attr) + _safe_addstr(stdscr, height - 1, 2, _clip(prompt_text, width - 4), colors["hot"] if input_mode else colors["base"]) + if input_mode: + try: + cursor_x = min(width - 2, 2 + len(prompt_prefix) + len(input_buffer)) + stdscr.move(height - 1, max(2, cursor_x)) + except curses.error: + pass + + +def _refresh_snapshot(state: TuiState, workspace: str, local_session: str, machine_session: str) -> dict[str, Any]: + now = time.time() + if state.runtime_cache is None or now >= state.runtime_refresh_at: + state.runtime_cache = _safe_runtime_status(local_session, machine_session) + state.runtime_refresh_at = now + 2.0 + + snapshot = _collect_snapshot(workspace, state.selected_index, local_session, machine_session, runtime_snapshot=state.runtime_cache) + if state.pending_select_mission_id: + for index, mission in enumerate(snapshot["missions"]): + if mission["mission_id"] == state.pending_select_mission_id: + state.selected_index = index + snapshot = _collect_snapshot(workspace, state.selected_index, local_session, machine_session, runtime_snapshot=state.runtime_cache) + break + state.pending_select_mission_id = None + + state.selected_index = snapshot["selected_index"] + runtime_machines = snapshot["runtime"].get("machines", []) + if runtime_machines: + state.selected_machine = max(0, min(state.selected_machine, len(runtime_machines) - 1)) + else: + state.selected_machine = 0 + return snapshot + + +def _draw_frame(stdscr: Any, snapshot: dict[str, Any], state: TuiState, colors: dict[str, int]) -> TuiLayout: + stdscr.erase() + height, width = stdscr.getmaxyx() + layout = _compute_layout(height, width, state.chat_focus and not state.capture_state) + snapshot["chat_focus"] = state.chat_focus and not state.capture_state + machine_label = _selected_machine_label(snapshot, state.selected_machine) + + _draw_header( + stdscr, + snapshot["workspace"], + len(snapshot["missions"]), + snapshot["fleet_count"], + snapshot["runtime"], + snapshot["memory"], + snapshot["health"], + colors, + snapshot["chat_scope_label"], + machine_label, + state.selected_role, + ) + if state.chat_focus and not state.capture_state: + _draw_threads_focus( + stdscr, + layout.top, + 0, + layout.main_height, + layout.left_width, + snapshot["workspace"], + snapshot["missions"], + state.selected_index, + colors, + ) + else: + _draw_missions(stdscr, layout.top, 0, layout.main_height, layout.left_width, snapshot["missions"], state.selected_index, colors) + _draw_conversation( + stdscr, + layout.top, + layout.left_width + 1, + layout.conversation_height, + layout.center_width, + snapshot["chat_history"], + snapshot["selected_mission"], + state, + colors, + state.chat_focus and not state.capture_state, + ) + skill_candidates = _skill_menu_candidates(state.input_buffer) if state.input_mode else [] + if skill_candidates: + menu_width = min(max(44, layout.center_width - 4), layout.center_width) + _draw_skill_menu( + stdscr, + layout.top + 2, + layout.left_width + 3, + menu_width, + skill_candidates, + state.slash_menu_index, + colors, + ) + if state.capture_state: + _draw_capture_view( + stdscr, + layout.top + layout.conversation_height, + layout.left_width + 1, + layout.runtime_height, + layout.center_width, + state.capture_state, + colors, + ) + elif not state.chat_focus: + _draw_runtime( + stdscr, + layout.top + layout.conversation_height, + layout.left_width + 1, + layout.runtime_height, + layout.center_width, + snapshot["runtime"], + snapshot["selected_mission"], + state.selected_machine, + state.selected_role, + colors, + ) + if state.chat_focus and not state.capture_state: + _draw_context_rail( + stdscr, + layout.top, + layout.left_width + layout.center_width + 2, + layout.main_height, + layout.right_width, + snapshot["review"], + snapshot["runtime"], + snapshot["selected_mission"], + machine_label, + state.selected_role, + colors, + int(time.time() * 4), + ) + else: + _draw_buddy( + stdscr, + layout.top, + layout.left_width + layout.center_width + 2, + layout.main_height, + layout.right_width, + snapshot["review"], + snapshot["memory"], + colors, + int(time.time() * 4), + ) + if layout.timeline_height > 0: + _draw_timeline( + stdscr, + layout.top + layout.main_height, + 0, + layout.timeline_height, + layout.width, + snapshot["events"], + snapshot["persona_lines"], + snapshot["decision_lines"], + colors, + ) + _draw_status_and_prompt( + stdscr, + layout.height, + layout.width, + flash=state.flash, + flash_until=state.flash_until, + input_mode=state.input_mode, + input_buffer=state.input_buffer, + capture_state=state.capture_state, + snapshot=snapshot, + selected_machine_label=machine_label, + selected_role=state.selected_role, + colors=colors, + ) + stdscr.refresh() + return layout + + +def _handle_input_mode_key( + key: int, + state: TuiState, + snapshot: dict[str, Any], + local_session: str, + machine_session: str, + runtime_height: int, +) -> dict[str, Any] | None: + if key in (10, 13, curses.KEY_ENTER): + candidates = _skill_menu_candidates(state.input_buffer) + if _slash_menu_pending(state.input_buffer) and candidates: + selected = candidates[max(0, min(state.slash_menu_index, len(candidates) - 1))] + state.input_buffer = _complete_slash_skill(state.input_buffer, selected) + state.slash_menu_index = 0 + state.set_flash(f"selected /{selected['id']}", 1.5) + return None + outcome = _process_chat_submission( + state.input_buffer, + snapshot, + state.selected_machine, + state.selected_role, + local_session, + machine_session, + runtime_height, + ) + state.input_mode = True + state.input_buffer = "" + stream_text = outcome.get("stream_text", "") + if stream_text: + state.stream_text = stream_text + state.stream_started_at = time.time() + state.stream_duration = min(3.8, max(0.7, len(stream_text) / 72.0)) + state.stream_mission_id = outcome.get("stream_mission_id") + else: + state.stream_text = "" + state.stream_duration = 0.0 + state.stream_mission_id = None + if outcome["capture_state"] is not None: + state.capture_state = outcome["capture_state"] + if outcome["invalidate_runtime"]: + state.runtime_cache = None + if outcome["force_select_mission_id"]: + state.pending_select_mission_id = outcome["force_select_mission_id"] + state.set_flash(outcome["flash"], 3.0) + return outcome["next_action"] + if key == 27: + state.input_mode = False + state.input_buffer = "" + state.slash_menu_index = 0 + state.set_flash("chat canceled", 1.5) + return None + candidates = _skill_menu_candidates(state.input_buffer) + if candidates and key in (9, curses.KEY_DOWN): + state.slash_menu_index = (state.slash_menu_index + 1) % len(candidates) + return None + if candidates and key == curses.KEY_UP: + state.slash_menu_index = (state.slash_menu_index - 1) % len(candidates) + return None + if key in (curses.KEY_BACKSPACE, 127, 8): + state.input_buffer = state.input_buffer[:-1] + state.slash_menu_index = 0 + return None + if key == 21: + state.input_buffer = "" + state.slash_menu_index = 0 + return None + if 32 <= key <= 126: + state.input_buffer += chr(key) + state.slash_menu_index = 0 + return None + + +def _handle_capture_key(key: int, state: TuiState, machine_session: str, runtime_height: int) -> bool: + if not state.capture_state: + return False + if key == ord("x"): + state.capture_state = None + state.set_flash("capture view closed", 1.5) + return True + if key == ord("c"): + try: + state.capture_state = _open_capture( + state.capture_state["machine"], + state.capture_state["pane"], + machine_session, + runtime_height, + state.capture_state.get("scroll"), + ) + if state.capture_state.get("returncode"): + state.set_flash( + f"capture error for {state.capture_state['machine']}:{state.capture_state['pane']}", + 2.5, + ) + else: + state.set_flash( + f"capture refreshed for {state.capture_state['machine']}:{state.capture_state['pane']}", + 2.5, + ) + except Exception as exc: # noqa: BLE001 + state.set_flash(_error_line("capture refresh failed", exc), 2.5) + return True + if key in (ord("j"), curses.KEY_DOWN): + max_scroll = max(0, len(state.capture_state.get("lines", [])) - max(1, runtime_height - 3)) + state.capture_state["scroll"] = min(max_scroll, state.capture_state.get("scroll", 0) + 1) + return True + if key in (ord("k"), curses.KEY_UP): + state.capture_state["scroll"] = max(0, state.capture_state.get("scroll", 0) - 1) + return True + if key == curses.KEY_NPAGE: + max_scroll = max(0, len(state.capture_state.get("lines", [])) - max(1, runtime_height - 3)) + state.capture_state["scroll"] = min(max_scroll, state.capture_state.get("scroll", 0) + max(4, runtime_height // 2)) + return True + if key == curses.KEY_PPAGE: + state.capture_state["scroll"] = max(0, state.capture_state.get("scroll", 0) - max(4, runtime_height // 2)) + return True + if key == ord("/"): + state.input_mode = True + state.input_buffer = "/" + state.slash_menu_index = 0 + return True + return False + + +def _handle_normal_key( + key: int, + state: TuiState, + snapshot: dict[str, Any], + local_session: str, + machine_session: str, + runtime_height: int, +) -> dict[str, Any] | None: + if key in (ord("q"), 27): + return {"action": "quit"} + if key == ord("/"): + state.input_mode = True + state.input_buffer = "/" + state.slash_menu_index = 0 + return None + if key == ord("f"): + state.chat_focus = not state.chat_focus + state.set_flash("chat-focus enabled" if state.chat_focus else "cockpit-detail enabled", 1.5) + return None + if key == ord("p"): + state.input_mode = True + state.input_buffer = "/spec-planner " + state.slash_menu_index = 0 + state.set_flash("spec-planner primed", 1.5) + return None + if key == ord("b"): + state.input_mode = True + state.input_buffer = "/architecture-brainstorm " + state.slash_menu_index = 0 + state.set_flash("architecture-brainstorm primed", 1.5) + return None + if key == ord("t"): + state.input_mode = True + state.input_buffer = "/task-decomposer " + state.slash_menu_index = 0 + state.set_flash("task-decomposer primed", 1.5) + return None + if key == ord("P"): + state.input_mode = True + state.input_buffer = "/pr-review-prep " + state.slash_menu_index = 0 + state.set_flash("pr-review-prep primed", 1.5) + return None + if 32 <= key <= 126 and chr(key) not in {"[", "]", "1", "2", "3", "4", "o", "r", "x", "z", "j", "k", "q", "e", "s", "c", "/", "p", "b", "t", "P", "f"}: + state.input_mode = True + state.input_buffer = chr(key) + return None + + machines = snapshot["runtime"].get("machines", []) + machine_label = _selected_machine_label(snapshot, state.selected_machine) + + if key in (ord("j"), curses.KEY_DOWN): + if snapshot["missions"]: + state.selected_index = min(len(snapshot["missions"]) - 1, state.selected_index + 1) + return None + if key in (ord("k"), curses.KEY_UP): + if snapshot["missions"]: + state.selected_index = max(0, state.selected_index - 1) + return None + if key == ord("["): + if machines: + state.selected_machine = max(0, state.selected_machine - 1) + return None + if key == ord("]"): + if machines: + state.selected_machine = min(len(machines) - 1, state.selected_machine + 1) + return None + if key in (ord("1"), ord("2"), ord("3"), ord("4")) and machines: + state.selected_role_index = int(chr(key)) - 1 + state.set_flash(f"selected {machine_label}:{state.selected_role}", 1.5) + return None + if key == ord("o") and machines: + machine = machines[state.selected_machine] + try: + focus_machine( + machine["label"], + state.selected_role, + local_session=local_session, + machine_session=machine_session, + ) + state.runtime_cache = None + state.set_flash(f"focused {machine['label']}:{state.selected_role}", 2.5) + except Exception as exc: # noqa: BLE001 + state.set_flash(_error_line("focus failed", exc), 2.5) + return None + if key == ord("r") and machines: + machine = machines[state.selected_machine] + try: + restart_pane(machine["label"], state.selected_role, machine_session=machine_session) + state.runtime_cache = None + state.set_flash(f"restart sent to {machine['label']}:{state.selected_role}", 2.5) + except Exception as exc: # noqa: BLE001 + state.set_flash(_error_line("restart failed", exc), 2.5) + return None + if key == ord("x") and machines: + machine = machines[state.selected_machine] + try: + state.capture_state = _open_capture(machine["label"], state.selected_role, machine_session, runtime_height) + if state.capture_state.get("returncode"): + state.set_flash(f"capture error for {machine['label']}:{state.selected_role}", 2.5) + else: + state.set_flash(f"capture loaded for {machine['label']}:{state.selected_role}", 2.5) + except Exception as exc: # noqa: BLE001 + state.set_flash(_error_line("capture failed", exc), 2.5) + return None + if key == ord("z"): + return {"action": "cockpit", "workspace": snapshot["workspace"]} + if key == ord("e"): + try: + rebuild_workspace_memory(snapshot["workspace"], enroll=True) + state.set_flash(f"memory rebuilt for {snapshot['workspace']}", 2.5) + except Exception as exc: # noqa: BLE001 + state.set_flash(_error_line("memory rebuild failed", exc), 2.5) + return None + if key == ord("s") and snapshot["selected_mission"]: + try: + summarize_mission(snapshot["selected_mission"]["mission_id"]) + state.set_flash(f"mission summarized: {snapshot['selected_mission']['mission_id']}", 2.5) + except Exception as exc: # noqa: BLE001 + state.set_flash(_error_line("summary failed", exc), 2.5) + return None + return None + + def _run(stdscr: Any, workspace: str, local_session: str, machine_session: str) -> dict[str, Any] | None: try: - curses.curs_set(0) + curses.curs_set(1) except curses.error: pass stdscr.nodelay(False) stdscr.timeout(250) colors = _init_colors() - selected_index = 0 - selected_machine = 0 - selected_role_index = 1 - flash = "" - flash_until = 0.0 - runtime_cache: dict[str, Any] | None = None - runtime_refresh_at = 0.0 + state = TuiState() while True: - now = time.time() - if runtime_cache is None or now >= runtime_refresh_at: - runtime_cache = _safe_runtime_status(local_session, machine_session) - runtime_refresh_at = now + 2.0 - snapshot = _collect_snapshot(workspace, selected_index, local_session, machine_session, runtime_snapshot=runtime_cache) - selected_index = snapshot["selected_index"] - runtime_machines = snapshot["runtime"].get("machines", []) - if runtime_machines: - selected_machine = max(0, min(selected_machine, len(runtime_machines) - 1)) - else: - selected_machine = 0 - stdscr.erase() - height, width = stdscr.getmaxyx() + snapshot = _refresh_snapshot(state, workspace, local_session, machine_session) + layout = _draw_frame(stdscr, snapshot, state, colors) - _draw_header( - stdscr, - snapshot["workspace"], - len(snapshot["missions"]), - snapshot["fleet_count"], - snapshot["runtime"], - snapshot["memory"], - snapshot["health"], - colors, - ) - - top = 4 - timeline_height = min(10, max(7, height // 4)) - main_height = max(10, height - top - timeline_height - 1) - left_width = max(28, min(34, width // 4)) - right_width = max(34, min(42, width // 3)) - center_width = max(28, width - left_width - right_width - 4) - board_height = max(8, int(main_height * 0.52)) - runtime_height = max(6, main_height - board_height) - - _draw_missions(stdscr, top, 0, main_height, left_width, snapshot["missions"], selected_index, colors) - _draw_board(stdscr, top, left_width + 1, board_height, center_width, snapshot["selected_mission"], colors) - _draw_runtime( - stdscr, - top + board_height, - left_width + 1, - runtime_height, - center_width, - snapshot["runtime"], - selected_machine, - ROLES[selected_role_index], - colors, - ) - _draw_buddy(stdscr, top, left_width + center_width + 2, main_height, right_width, snapshot["review"], snapshot["memory"], colors) - _draw_timeline(stdscr, top + main_height, 0, timeline_height, width, snapshot["events"], snapshot["persona_lines"], snapshot["decision_lines"], colors) - - if flash and time.time() < flash_until: - _safe_addstr(stdscr, height - 1, 2, _clip(flash, width - 4), colors["warn"]) - else: - flash = "" - _safe_addstr(stdscr, height - 1, 2, "q quit | j/k mission | [/] machine | 1..4 pane | o jump | r restart | x capture | z open cockpit", colors["muted"]) - - stdscr.refresh() key = stdscr.getch() - if key == -1: + if key in (-1, curses.KEY_RESIZE): continue - if key in (ord("q"), 27): + + if state.input_mode: + next_action = _handle_input_mode_key( + key, + state, + snapshot, + local_session, + machine_session, + layout.runtime_height, + ) + if next_action: + return next_action + continue + + if _handle_capture_key(key, state, machine_session, layout.runtime_height): + continue + + action = _handle_normal_key( + key, + state, + snapshot, + local_session, + machine_session, + layout.runtime_height, + ) + if action and action.get("action") == "quit": return None - if key in (ord("j"), curses.KEY_DOWN): - if snapshot["missions"]: - selected_index = min(len(snapshot["missions"]) - 1, selected_index + 1) - continue - if key in (ord("k"), curses.KEY_UP): - if snapshot["missions"]: - selected_index = max(0, selected_index - 1) - continue - if key == ord("["): - if snapshot["runtime"]["machines"]: - selected_machine = max(0, selected_machine - 1) - continue - if key == ord("]"): - if snapshot["runtime"]["machines"]: - selected_machine = min(len(snapshot["runtime"]["machines"]) - 1, selected_machine + 1) - continue - if key in (ord("1"), ord("2"), ord("3"), ord("4")) and snapshot["runtime"]["machines"]: - selected_role_index = int(chr(key)) - 1 - machine = snapshot["runtime"]["machines"][selected_machine] - try: - focus_machine( - machine["label"], - ROLES[selected_role_index], - local_session=local_session, - machine_session=machine_session, - ) - runtime_cache = None - flash = f"focused {machine['label']}:{ROLES[selected_role_index]}" - except Exception as exc: # noqa: BLE001 - flash = _error_line("focus failed", exc) - flash_until = time.time() + 2.5 - continue - if key == ord("o") and snapshot["runtime"]["machines"]: - machine = snapshot["runtime"]["machines"][selected_machine] - try: - focus_machine(machine["label"], None, local_session=local_session, machine_session=machine_session) - runtime_cache = None - flash = f"jumped to {machine['label']}" - except Exception as exc: # noqa: BLE001 - flash = _error_line("jump failed", exc) - flash_until = time.time() + 2.5 - continue - if key == ord("r") and snapshot["runtime"]["machines"]: - machine = snapshot["runtime"]["machines"][selected_machine] - try: - restart_pane(machine["label"], ROLES[selected_role_index], machine_session=machine_session) - runtime_cache = None - flash = f"restarted {machine['label']}:{ROLES[selected_role_index]}" - except Exception as exc: # noqa: BLE001 - flash = _error_line("restart failed", exc) - flash_until = time.time() + 2.5 - continue - if key == ord("x") and snapshot["runtime"]["machines"]: - machine = snapshot["runtime"]["machines"][selected_machine] - try: - capture = capture_pane(machine["label"], ROLES[selected_role_index], lines=12, machine_session=machine_session) - preview = (capture.get("stdout") or "").splitlines()[-1:] or ["(empty capture)"] - flash = f"{machine['label']}:{ROLES[selected_role_index]} :: {preview[0]}" - except Exception as exc: # noqa: BLE001 - flash = _error_line("capture failed", exc) - flash_until = time.time() + 3.5 - continue - if key == ord("z"): - return {"action": "cockpit", "workspace": snapshot["workspace"]} - if key == ord("e"): - try: - rebuild_workspace_memory(snapshot["workspace"], enroll=True) - flash = f"memory rebuilt for {snapshot['workspace']}" - except Exception as exc: # noqa: BLE001 - flash = _error_line("memory rebuild failed", exc) - flash_until = time.time() + 2.5 - continue - if key == ord("s") and snapshot["selected_mission"]: - try: - summarize_mission(snapshot["selected_mission"]["mission_id"]) - flash = f"mission summarized: {snapshot['selected_mission']['mission_id']}" - except Exception as exc: # noqa: BLE001 - flash = _error_line("summary failed", exc) - flash_until = time.time() + 2.5 - continue + if action: + return action def run_tui(workspace: str, local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any] | None: diff --git a/examples/fleet.example.toml b/examples/fleet.example.toml new file mode 100644 index 0000000..b12a7b5 --- /dev/null +++ b/examples/fleet.example.toml @@ -0,0 +1,38 @@ +version = 1 +local_machine = "command-center" +repo_dir = "$HOME/constant" + +[[machines]] +label = "command-center" +target = "local" +auto_clis = ["codex", "vibe", "claude"] +manual_clis = ["copilot"] +backends = ["omc", "cli-local", "cockpit"] + +[[machines]] +label = "builder-a" +target = "dev@builder-a" +auto_clis = ["codex", "vibe", "claude"] +manual_clis = ["copilot"] +backends = ["cli-ssh", "cockpit"] + +[[machines]] +label = "builder-b" +target = "dev@builder-b" +auto_clis = ["codex", "vibe", "claude"] +manual_clis = ["copilot"] +backends = ["cli-ssh", "cockpit"] + +[[machines]] +label = "edge-a" +target = "dev@edge-a" +auto_clis = ["codex", "vibe", "claude"] +manual_clis = ["copilot"] +backends = ["cli-ssh", "cockpit"] + +[[machines]] +label = "lab-a" +target = "dev@lab-a" +auto_clis = ["codex", "vibe", "claude"] +manual_clis = ["copilot"] +backends = ["cli-ssh", "cockpit"] diff --git a/scripts/Constant b/scripts/Constant index a9864d9..4495792 100755 --- a/scripts/Constant +++ b/scripts/Constant @@ -10,6 +10,135 @@ done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" repo_dir="$(cd "$script_dir/.." && pwd -P)" default_python="$HOME/.local/share/constant/venv/bin/python3" +rust_bin="$repo_dir/target/debug/constant" +cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/constant/wrapper" +probe_timeout_sec="${CONSTANT_RUST_PROBE_TIMEOUT_SEC:-2}" +probe_fail_ttl_sec="${CONSTANT_RUST_PROBE_FAIL_TTL_SEC:-300}" +force_rust_recheck="${CONSTANT_RUST_RECHECK:-0}" + +ancestor_comm_contains() { + local needle="$1" + local pid="${PPID:-}" + local depth=0 + while [[ -n "$pid" && "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 && $depth -lt 12 ]]; do + local comm="" + comm="$(ps -o comm= -p "$pid" 2>/dev/null | tr -d '\n')" || break + [[ -z "$comm" ]] && break + if [[ "$comm" == *"$needle"* ]]; then + return 0 + fi + pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" || break + (( depth += 1 )) + done + return 1 +} + +stat_signature() { + local target="$1" + /usr/bin/stat -f '%m:%z' "$target" 2>/dev/null || printf 'missing\n' +} + +record_wrapper_mode() { + local mode="$1" + local reason="$2" + mkdir -p "$cache_root" + printf '%s %s %s\n' "$mode" "$reason" "$(date +%s)" > "$cache_root/last-mode" + export CONSTANT_WRAPPER_LAST_REASON="$reason" +} + +cache_matches() { + local cache_file="$1" + local expected_signature="$2" + [[ -f "$cache_file" ]] || return 1 + local cached_signature="" + local cached_epoch="" + IFS=' ' read -r cached_signature cached_epoch < "$cache_file" || return 1 + [[ "$cached_signature" == "$expected_signature" ]] || return 1 + printf '%s\n' "${cached_epoch:-0}" +} + +run_rust_probe() { + local probe_target="$1" + local timeout_sec="$2" + local probe_log="$cache_root/rust-probe.log" + mkdir -p "$cache_root" + + ("$probe_target" --version >"$probe_log" 2>&1) & + local probe_pid=$! + local waited=0 + local deadline=$(( timeout_sec * 10 )) + + while (( waited < deadline )); do + if ! kill -0 "$probe_pid" 2>/dev/null; then + local status=0 + wait "$probe_pid" || status=$? + [[ $status -eq 0 ]] + return + fi + sleep 0.1 + (( waited += 1 )) + done + + kill "$probe_pid" 2>/dev/null || true + wait "$probe_pid" 2>/dev/null || true + return 1 +} + +warn_rust_fallback() { + [[ -t 2 ]] || return 0 + printf '%s\n' "constant: Rust startup probe failed in this macOS session, falling back to Python." >&2 +} + +warn_codex_rust_bypass() { + [[ -t 2 ]] || return 0 + printf '%s\n' "constant: running under Codex runner, skipping Rust startup path and using Python fallback." >&2 +} + +if [[ "${CONSTANT_USE_RUST:-0}" == "1" && "${CONSTANT_USE_PYTHON:-0}" != "1" ]]; then + cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet + record_wrapper_mode "rust" "forced-rust" + export CONSTANT_REPO_DIR="$repo_dir" + export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" + exec "$rust_bin" "$@" +fi + +if [[ "${CONSTANT_USE_PYTHON:-0}" != "1" ]]; then + if ancestor_comm_contains "codex-aarch64-apple-darwin"; then + record_wrapper_mode "python" "codex-runner" + warn_codex_rust_bypass + else + cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet + rust_signature="$(stat_signature "$rust_bin")" + mkdir -p "$cache_root" + + ok_epoch="$(cache_matches "$cache_root/rust-ok" "$rust_signature" || true)" + if [[ "$force_rust_recheck" != "1" && -n "$ok_epoch" ]]; then + record_wrapper_mode "rust" "cached-rust-ok" + export CONSTANT_REPO_DIR="$repo_dir" + export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" + exec "$rust_bin" "$@" + fi + + fail_epoch="$(cache_matches "$cache_root/rust-fail" "$rust_signature" || true)" + now_epoch="$(date +%s)" + if [[ "$force_rust_recheck" != "1" && -n "$fail_epoch" ]] && (( now_epoch - fail_epoch < probe_fail_ttl_sec )); then + record_wrapper_mode "python" "cached-rust-fail" + : + elif run_rust_probe "$rust_bin" "$probe_timeout_sec"; then + printf '%s %s\n' "$rust_signature" "$now_epoch" > "$cache_root/rust-ok" + rm -f "$cache_root/rust-fail" + record_wrapper_mode "rust" "probe-ok" + export CONSTANT_REPO_DIR="$repo_dir" + export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" + exec "$rust_bin" "$@" + else + printf '%s %s\n' "$rust_signature" "$now_epoch" > "$cache_root/rust-fail" + rm -f "$cache_root/rust-ok" + record_wrapper_mode "python" "probe-failed" + warn_rust_fallback + fi + fi +fi python_bin="${CONSTANT_PYTHON:-}" if [[ -z "$python_bin" ]]; then @@ -21,4 +150,8 @@ if [[ -z "$python_bin" ]]; then fi export PYTHONPATH="$repo_dir${PYTHONPATH+:$PYTHONPATH}" +export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" +if [[ "${CONSTANT_USE_PYTHON:-0}" == "1" ]]; then + record_wrapper_mode "python" "forced-python" +fi exec "$python_bin" -m constant "$@" diff --git a/scripts/constant-bootstrap.sh b/scripts/constant-bootstrap.sh old mode 100755 new mode 100644 index 29bb658..77b46f1 --- a/scripts/constant-bootstrap.sh +++ b/scripts/constant-bootstrap.sh @@ -8,6 +8,36 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" +bootstrap_file="${ZELLIJ_AI_BOOTSTRAP_FILE:-}" +workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}" -export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" -exec "$script_dir/zellij-ai-bootstrap.sh" "$@" +if [[ -n "$bootstrap_file" ]]; then + mkdir -p "$(dirname "$bootstrap_file")" +fi + +if [[ -n "$bootstrap_file" && ! -f "$bootstrap_file" ]]; then + : >"$bootstrap_file" + + printf -v codex_cmd '%q' "$script_dir/constant-codex-pane.sh" + printf -v copilot_cmd '%q' "$script_dir/constant-copilot-pane.sh" + printf -v vibe_cmd '%q' "$script_dir/constant-vibe-pane.sh" + + zellij action new-pane -d right -n "${ZELLIJ_AI_CODEX_LABEL:-codex}" --cwd "$workspace" + zellij action move-focus right + zellij action write-chars "$codex_cmd" + zellij action write 10 + + zellij action new-pane -d down -n "${ZELLIJ_AI_COPILOT_LABEL:-copilot}" --cwd "$workspace" + zellij action move-focus down + zellij action write-chars "$copilot_cmd" + zellij action write 10 + + zellij action new-pane -d down -n "${ZELLIJ_AI_VIBE_LABEL:-vibe}" --cwd "$workspace" + zellij action move-focus down + zellij action write-chars "$vibe_cmd" + zellij action write 10 + + zellij action move-focus left +fi + +exec "$script_dir/constant-claude-pane.sh" diff --git a/scripts/constant-copilot-pane.sh b/scripts/constant-copilot-pane.sh old mode 100755 new mode 100644 index 11cce7b..2be3b57 --- a/scripts/constant-copilot-pane.sh +++ b/scripts/constant-copilot-pane.sh @@ -8,6 +8,58 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" +source "$script_dir/constant-common.sh" -export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" -exec "$script_dir/zellij-ai-copilot-pane.sh" "$@" +workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}" +machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}" +session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}" +repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}" +bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}" +label="${ZELLIJ_AI_COPILOT_LABEL:-copilot}" + +if [[ ! -d "$workspace" ]]; then + echo "Workspace not found: $workspace" >&2 + exit 1 +fi + +export PATH="$(zellij_ai_agent_path)" + +if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then + export PATH="$repo_dir/scripts:$PATH" +fi + +if ! command -v copilot >/dev/null 2>&1; then + echo "GitHub Copilot CLI not found on the host." >&2 + echo "Install it with: npm install -g @github/copilot" >&2 + exit 1 +fi + +mkdir -p "$bus_dir/messages" + +export ZELLIJ_AI_ROLE="$label" +export ZELLIJ_AI_MACHINE_NAME="$machine_name" +export ZELLIJ_AI_SESSION="$session_name" +export ZELLIJ_AI_BUS_DIR="$bus_dir" + +cd "$workspace" + +echo "Copilot pane" +echo "machine: $machine_name" +echo "workspace: $workspace" +echo "bus: $bus_dir" +if [[ -n "$repo_dir" ]]; then + echo "helper: $repo_dir/scripts/ai-msg.sh" +fi +echo "login: start copilot and use /login if needed" +echo + +copilot +status=$? + +if [[ $status -ne 0 ]]; then + echo + echo "Copilot exited with status $status." + echo "If macOS reports 'SecItemCopyMatching failed -50', open copilot manually once and re-run /login." +fi + +exit "$status" diff --git a/scripts/constant-fleet-bootstrap.sh b/scripts/constant-fleet-bootstrap.sh old mode 100755 new mode 100644 index ea4af5a..e116efb --- a/scripts/constant-fleet-bootstrap.sh +++ b/scripts/constant-fleet-bootstrap.sh @@ -1,13 +1,42 @@ #!/usr/bin/env bash set -euo pipefail -script_source="${BASH_SOURCE[0]:-$0}" -while [[ -L "$script_source" ]]; do - script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" - script_source="$(readlink "$script_source")" - [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" -done -script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" +tabs_file="${ZELLIJ_AI_FLEET_TABS_FILE:-}" +tabs_created_file="${ZELLIJ_AI_FLEET_CREATED_FILE:-}" +current_label="${ZELLIJ_AI_FLEET_CURRENT_LABEL:-}" +current_wrapper="${ZELLIJ_AI_FLEET_CURRENT_WRAPPER:-}" -export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" -exec "$script_dir/zellij-ai-fleet-bootstrap.sh" "$@" +if [[ -z "$current_wrapper" || ! -x "$current_wrapper" ]]; then + echo "Fleet bootstrap wrapper not found: $current_wrapper" >&2 + exit 1 +fi + +if [[ -n "$current_label" ]]; then + zellij action rename-tab "$current_label" +fi + +if [[ -n "$tabs_created_file" ]]; then + mkdir -p "$(dirname "$tabs_created_file")" +fi + +if [[ -n "$tabs_file" && -f "$tabs_file" && -n "$tabs_created_file" && ! -f "$tabs_created_file" ]]; then + : >"$tabs_created_file" + + while IFS=$'\t' read -r tab_label layout_file tab_cwd; do + if [[ -z "$tab_label" || -z "$layout_file" ]]; then + continue + fi + + if [[ -n "$tab_cwd" ]]; then + zellij action new-tab -n "$tab_label" -l "$layout_file" --cwd "$tab_cwd" + else + zellij action new-tab -n "$tab_label" -l "$layout_file" + fi + done <"$tabs_file" + + if [[ -n "$current_label" ]]; then + zellij action go-to-tab-name "$current_label" + fi +fi + +exec "$current_wrapper" diff --git a/scripts/constant-tmux-fleet.sh b/scripts/constant-tmux-fleet.sh index fc0c56c..b76aba0 100755 --- a/scripts/constant-tmux-fleet.sh +++ b/scripts/constant-tmux-fleet.sh @@ -45,21 +45,23 @@ require_arg() { } build_constant_window_command() { - local inner - printf -v inner 'cd %q && export PATH=%q; exec %q tui --workspace %q --local-session %q --session %q' \ - "$workspace" \ - "$(zellij_ai_agent_path)" \ - "$repo_dir/scripts/Constant" \ - "$workspace" \ - "$local_session" \ - "$remote_session" - printf 'bash -lc %q' "$inner" + local cmd=( + env + "PATH=$(zellij_ai_agent_path)" + "CONSTANT_USE_PYTHON=1" + "$repo_dir/scripts/Constant" + tui + --workspace "$workspace" + --local-session "$local_session" + --session "$remote_session" + ) + printf '%q ' "${cmd[@]}" } build_remote_window_command() { local label="$1" local target="$2" - local inner remote_script cmd cmd_joined + local remote_script cmd remote_script="$repo_dir/scripts/constant-tmux-remote-window.sh" cmd=( @@ -77,12 +79,7 @@ build_remote_window_command() { cmd+=(--recreate) fi - printf -v cmd_joined '%q ' "${cmd[@]}" - printf -v inner 'cd %q && export PATH=%q; exec %s' \ - "$repo_dir" \ - "$(zellij_ai_agent_path)" \ - "$cmd_joined" - printf 'bash -lc %q' "$inner" + printf '%q ' "${cmd[@]}" } link_local_window() { @@ -111,6 +108,91 @@ link_local_window() { fi } +configure_fleet_session() { + local session_name="$1" + tmux rename-window -t "${session_name}:0" Constant >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g set-clipboard on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g remain-on-exit on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g mouse on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-position top >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-justify left >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-left-length 24 >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-right-length 48 >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-left '#[fg=black,bg=green,bold] Constant #[default] ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-right '#[fg=colour250]#S #[fg=colour244]| #[fg=colour252]%H:%M #[fg=colour244]%d-%b' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g status-style 'bg=colour234,fg=colour252' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g window-status-separator ' ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g window-status-format '#[fg=colour245,bg=colour236] #I:#W ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g window-status-current-format '#[fg=colour16,bg=colour46,bold] #I:#W ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g window-status-current-style 'fg=colour16,bg=colour46,bold' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g allow-rename off >/dev/null 2>&1 || true + tmux set-option -t "$session_name" -g automatic-rename off >/dev/null 2>&1 || true +} + +attach_fleet_session() { + local session_name="$1" + tmux select-window -t "${session_name}:Constant" >/dev/null 2>&1 || true + if [[ -n "${TMUX:-}" ]]; then + exec tmux switch-client -t "$session_name" + fi + exec tmux attach-session -t "$session_name" +} + +window_pane_dead() { + local session_name="$1" + local window_name="$2" + local dead + dead="$(tmux display-message -p -t "${session_name}:${window_name}" '#{pane_dead}' 2>/dev/null || true)" + [[ "$dead" == "1" ]] +} + +ensure_constant_window() { + local command_string + command_string="$(build_constant_window_command)" + if constant_tmux_window_exists "$local_session" "Constant"; then + if window_pane_dead "$local_session" "Constant"; then + tmux respawn-pane -k -t "${local_session}:Constant" "$command_string" + fi + else + tmux new-window -d -t "${local_session}:" -n Constant "$command_string" + fi +} + +ensure_remote_window() { + local label="$1" + local target="$2" + local command_string + command_string="$(build_remote_window_command "$label" "$target")" + if constant_tmux_window_exists "$local_session" "$label"; then + if window_pane_dead "$local_session" "$label"; then + tmux respawn-pane -k -t "${local_session}:${label}" "$command_string" + fi + else + tmux new-window -d -t "${local_session}:" -n "$label" "$command_string" + fi +} + +ensure_fleet_windows() { + local machine_spec parsed_spec label target + + ensure_constant_window + for machine_spec in "${machines[@]}"; do + parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")" + IFS=$'\t' read -r label target <<<"$parsed_spec" + + if [[ "$label" == "Constant" ]]; then + continue + fi + + if zellij_ai_is_local_target "$target"; then + link_local_window "$label" + else + ensure_remote_window "$label" "$target" + fi + done +} + constant_tmux_require zellij_ai_require_command ssh @@ -190,10 +272,7 @@ while [[ $# -gt 0 ]]; do done if $attach_only; then - if [[ -n "${TMUX:-}" ]]; then - exec tmux switch-client -t "$local_session" - fi - exec tmux attach-session -t "$local_session" + attach_fleet_session "$local_session" fi if [[ -z "$workspace" ]]; then @@ -214,17 +293,18 @@ if $recreate && constant_tmux_session_exists "$local_session"; then fi if constant_tmux_session_exists "$local_session"; then - if [[ -n "${TMUX:-}" ]]; then - exec tmux switch-client -t "$local_session" + configure_fleet_session "$local_session" + ensure_fleet_windows + tmux select-window -t "${local_session}:Constant" >/dev/null 2>&1 || true + if $ensure_only; then + exit 0 fi - exec tmux attach-session -t "$local_session" + attach_fleet_session "$local_session" fi mkdir -p "$(zellij_ai_fleet_state_dir "$local_session")" tmux new-session -d -s "$local_session" -n Constant "$(build_constant_window_command)" -tmux set-option -t "$local_session" -g set-clipboard on >/dev/null 2>&1 || true -tmux set-option -t "$local_session" -g remain-on-exit on >/dev/null 2>&1 || true -tmux set-option -t "$local_session" -g mouse on >/dev/null 2>&1 || true +configure_fleet_session "$local_session" for machine_spec in "${machines[@]}"; do parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")" @@ -247,8 +327,4 @@ if $ensure_only; then exit 0 fi -if [[ -n "${TMUX:-}" ]]; then - exec tmux switch-client -t "$local_session" -else - exec tmux attach-session -t "$local_session" -fi +attach_fleet_session "$local_session" diff --git a/scripts/constant-vibe-pane.sh b/scripts/constant-vibe-pane.sh old mode 100755 new mode 100644 index 8ebaa43..8a14e19 --- a/scripts/constant-vibe-pane.sh +++ b/scripts/constant-vibe-pane.sh @@ -8,6 +8,48 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" +source "$script_dir/constant-common.sh" -export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" -exec "$script_dir/zellij-ai-vibe-pane.sh" "$@" +workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}" +machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}" +session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}" +repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}" +bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}" +label="${ZELLIJ_AI_VIBE_LABEL:-vibe}" + +if [[ ! -d "$workspace" ]]; then + echo "Workspace not found: $workspace" >&2 + exit 1 +fi + +export PATH="$(zellij_ai_agent_path)" + +if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then + export PATH="$repo_dir/scripts:$PATH" +fi + +if ! command -v vibe >/dev/null 2>&1; then + echo "Mistral Vibe CLI not found on the host." >&2 + echo "Install it with: uv tool install mistral-vibe" >&2 + exit 1 +fi + +mkdir -p "$bus_dir/messages" + +export ZELLIJ_AI_ROLE="$label" +export ZELLIJ_AI_MACHINE_NAME="$machine_name" +export ZELLIJ_AI_SESSION="$session_name" +export ZELLIJ_AI_BUS_DIR="$bus_dir" + +cd "$workspace" + +echo "Vibe pane" +echo "machine: $machine_name" +echo "workspace: $workspace" +echo "bus: $bus_dir" +if [[ -n "$repo_dir" ]]; then + echo "helper: $repo_dir/scripts/ai-msg.sh" +fi +echo + +exec vibe diff --git a/scripts/zellij-ai-claude-pane.sh b/scripts/zellij-ai-claude-pane.sh index 26f2750..3c9760f 100755 --- a/scripts/zellij-ai-claude-pane.sh +++ b/scripts/zellij-ai-claude-pane.sh @@ -10,6 +10,17 @@ done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" source "$script_dir/constant-common.sh" +shell_fallback() { + local reason="${1:-Claude pane ended.}" + local shell_bin="${SHELL:-/bin/bash}" + echo + echo "$reason" + echo "Keeping the pane alive in an interactive shell." + echo "Workspace: $workspace" + echo + exec "$shell_bin" -il +} + workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}" machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}" session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}" @@ -50,4 +61,11 @@ if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then fi echo -exec claude +if ! command -v claude >/dev/null 2>&1; then + shell_fallback "Claude CLI not found on the host." +fi + +if ! claude; then + rc=$? + shell_fallback "Claude exited with status ${rc}." +fi diff --git a/scripts/zellij-ai-codex-pane.sh b/scripts/zellij-ai-codex-pane.sh index d558370..0dc5f86 100755 --- a/scripts/zellij-ai-codex-pane.sh +++ b/scripts/zellij-ai-codex-pane.sh @@ -21,6 +21,19 @@ slot: EOF } +shell_fallback() { + local reason="${1:-Codex pane ended.}" + local shell_bin="${SHELL:-/bin/bash}" + echo + echo "$reason" + echo "Keeping the pane alive in an interactive shell." + echo "Workspace: $workspace" + echo "CODEX_HOME: $CODEX_HOME" + echo "You can retry manually with: codex login --device-auth" + echo + exec "$shell_bin" -il +} + warn_deprecated() { printf 'Warning: %s\n' "$*" >&2 } @@ -105,8 +118,18 @@ if [[ ! -f "$CODEX_HOME/auth.json" ]]; then echo "No auth.json found for $label." echo "Starting: codex login --device-auth" echo - codex login --device-auth + if ! codex login --device-auth; then + echo + echo "Codex login did not complete successfully." + fi echo fi -exec codex --no-alt-screen -C "$workspace" +if [[ ! -f "$CODEX_HOME/auth.json" ]]; then + shell_fallback "Codex auth is still missing." +fi + +if ! codex --no-alt-screen -C "$workspace"; then + rc=$? + shell_fallback "Codex exited with status ${rc}." +fi diff --git a/scripts/zellij-ai-common.sh b/scripts/zellij-ai-common.sh index 5995705..fc23072 100755 --- a/scripts/zellij-ai-common.sh +++ b/scripts/zellij-ai-common.sh @@ -8,6 +8,7 @@ zellij_ai_fleet_config_candidates() { if [[ -n "${CONSTANT_FLEET_CONFIG:-}" ]]; then printf '%s\n' "$CONSTANT_FLEET_CONFIG" fi + printf '%s\n' "$(zellij_ai_config_root)/fleet.toml" printf '%s\n' "$(zellij_ai_config_root)/fleet.json" printf '%s\n' "$(zellij_ai_config_root)/fleet.yaml" } @@ -32,16 +33,26 @@ zellij_ai_fleet_config_query() { [[ -n "$config_path" ]] || return 1 command -v python3 >/dev/null 2>&1 || return 1 - python3 - "$config_path" "$expr" <<'PY' +python3 - "$config_path" "$expr" <<'PY' import json import sys from pathlib import Path +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover + tomllib = None + path = Path(sys.argv[1]) expr = sys.argv[2] try: - payload = json.loads(path.read_text(encoding="utf-8")) + if path.suffix == ".toml": + if tomllib is None: + raise SystemExit(1) + payload = tomllib.loads(path.read_text(encoding="utf-8")) + else: + payload = json.loads(path.read_text(encoding="utf-8")) except Exception: raise SystemExit(1) diff --git a/scripts/zellij-ai-machine-install.sh b/scripts/zellij-ai-machine-install.sh index fd5b72a..ec1b6ab 100755 --- a/scripts/zellij-ai-machine-install.sh +++ b/scripts/zellij-ai-machine-install.sh @@ -196,6 +196,18 @@ ensure_uv_tool() { ensure_command "$binary_name" || fail "Failed to install ${package_name} with uv." } +ensure_constant_wrappers() { + local bin_dir="$HOME/.local/bin" + mkdir -p "$bin_dir" + + ln -snf "$repo_dir/scripts/Constant" "$bin_dir/Constant" + ln -snf "$repo_dir/scripts/Constant" "$bin_dir/constant" + ln -snf "$repo_dir/scripts/constant-fleet.sh" "$bin_dir/constant-fleet" + ln -snf "$repo_dir/scripts/constant-machine.sh" "$bin_dir/constant-machine" + ln -snf "$repo_dir/scripts/constant-fleet-install.sh" "$bin_dir/constant-fleet-install" + ln -snf "$repo_dir/scripts/constant-machine-install.sh" "$bin_dir/constant-machine-install" +} + os_name="$(uname -s)" linux_id="" repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")" @@ -273,7 +285,7 @@ run_check() { print_status repo_dir "$repo_dir" print_status codex_home "$codex_home" - for binary in bash git tmux node npm uv claude codex copilot vibe; do + for binary in bash git tmux node npm uv claude codex copilot vibe constant; do if command -v "$binary" >/dev/null 2>&1; then print_status "$binary" "$(command -v "$binary")" else @@ -394,6 +406,8 @@ run_install() { ensure_npm_global "@openai/codex" codex ensure_npm_global "@github/copilot" copilot ensure_uv_tool "mistral-vibe" vibe + ensure_constant_wrappers + refresh_path run_check } diff --git a/src/buddy.rs b/src/buddy.rs new file mode 100644 index 0000000..c23afbe --- /dev/null +++ b/src/buddy.rs @@ -0,0 +1,41 @@ +use serde_json::{Value, json}; + +use crate::capabilities::match_skill; +use crate::state::Mission; + +pub fn buddy_ask( + mission: Option<&Mission>, + prompt: &str, + _workspace: Option<&str>, +) -> Result { + let skill = match_skill(prompt, true); + let title = mission + .map(|entry| entry.title.as_str()) + .unwrap_or("global cockpit"); + let route_note = if prompt.to_lowercase().contains("route") + || prompt.to_lowercase().contains("machine") + || prompt.to_lowercase().contains("cli") + { + format!( + "Treat this as a routing discussion first, with skill={} as the default lens.", + skill.id + ) + } else { + format!( + "Treat this as a {} discussion before execution.", + skill.summary + ) + }; + let answer = format!("Qwen buddy heuristic view for {title}: {route_note}"); + Ok(json!({ + "answer": answer, + "mode": "rust-heuristic", + "skill": { + "id": skill.id, + "summary": skill.summary, + "preferred_cli": skill.preferred_cli, + "preferred_agent": skill.preferred_agent, + }, + "memory_hits": [], + })) +} diff --git a/src/capabilities.rs b/src/capabilities.rs new file mode 100644 index 0000000..5323412 --- /dev/null +++ b/src/capabilities.rs @@ -0,0 +1,558 @@ +use serde::Serialize; + +pub const VERSION: &str = "0.1.0"; + +#[derive(Clone, Copy, Serialize)] +pub struct Agent { + pub id: &'static str, + pub label: &'static str, + pub role: &'static str, + pub primary_cli: &'static str, + pub manual_only: bool, + pub capabilities: &'static [&'static str], + pub preferred_layers: &'static [&'static str], +} + +#[derive(Clone, Copy, Serialize)] +pub struct Skill { + pub id: &'static str, + pub label: &'static str, + pub layer: &'static str, + pub visibility: &'static str, + pub summary: &'static str, + pub when_to_use: &'static [&'static str], + pub input_examples: &'static [&'static str], + pub output_expected: &'static [&'static str], + pub usage_prompt: &'static str, + pub keywords: &'static [&'static str], + pub aliases: &'static [&'static str], + pub preferred_cli: &'static str, + pub preferred_agent: &'static str, +} + +pub const AGENTS: &[Agent] = &[ + Agent { + id: "planner", + label: "Planner", + role: "planner", + primary_cli: "claude", + manual_only: false, + capabilities: &["spec", "planning", "review", "summarization", "delegation"], + preferred_layers: &["reflection", "execution-prep"], + }, + Agent { + id: "executor", + label: "Executor", + role: "executor", + primary_cli: "codex", + manual_only: false, + capabilities: &["implementation", "debugging", "refactor", "tooling", "ops"], + preferred_layers: &["execution"], + }, + Agent { + id: "analyst", + label: "Analyst", + role: "analyst", + primary_cli: "vibe", + manual_only: false, + capabilities: &["brainstorm", "alternatives", "research", "comparison"], + preferred_layers: &["reflection"], + }, + Agent { + id: "assistant", + label: "Assistant", + role: "assistant", + primary_cli: "copilot", + manual_only: true, + capabilities: &["interactive-help", "inline-suggestions"], + preferred_layers: &["manual"], + }, +]; + +pub const SKILLS: &[Skill] = &[ + Skill { + id: "spec-planner", + label: "Spec Planner", + layer: "reflection", + visibility: "public", + summary: "Transformer une demande floue en spec exploitable avant de coder.", + when_to_use: &[ + "quand on part d'une idee brute", + "quand il faut cadrer avant implementation", + "quand on veut eviter de coder trop tot", + ], + input_examples: &[ + "Je veux ajouter un systeme de notifications", + "On doit refondre l'auth OAuth", + ], + output_expected: &[ + "objectif", + "perimetre", + "hors_perimetre", + "contraintes", + "hypotheses", + "criteres_acceptation", + "risques", + "questions_ouvertes", + "plan_minimal", + ], + usage_prompt: "Fais une spec executable a partir de cette demande. Identifie les zones floues, propose des hypotheses raisonnables, liste les criteres d'acceptation et termine par un plan d'implementation minimal.", + keywords: &[ + "spec planner", + "spec-planner", + "spec", + "cadrer", + "clarifier", + "clarify", + "requirements", + "scope", + "perimetre", + "oauth", + "notifications", + "idea brute", + "je veux", + "i want", + "on doit", + "we need", + "refondre", + "before coding", + ], + aliases: &["summarize", "summary", "brief"], + preferred_cli: "claude", + preferred_agent: "planner", + }, + Skill { + id: "architecture-brainstorm", + label: "Architecture Brainstorm", + layer: "reflection", + visibility: "public", + summary: "Comparer plusieurs options d'architecture avant de choisir.", + when_to_use: &[ + "quand plusieurs approches sont possibles", + "quand on veut comparer les trade-offs", + "quand il faut une vraie discussion d'architecture", + ], + input_examples: &[ + "Doit-on faire un backend event-driven ou un monolithe modulaire ?", + "Quel runtime pour le cockpit interactif ?", + ], + output_expected: &[ + "options", + "avantages", + "inconvenients", + "complexite", + "impacts_perf", + "impacts_securite", + "impacts_dx", + "recommendation", + "raisons_du_choix", + ], + usage_prompt: "Analyse ce probleme comme un review partner senior. Propose plusieurs architectures possibles, compare les compromis, signale les pieges, puis recommande une option avec justification.", + keywords: &[ + "architecture", + "brainstorm", + "architecture-brainstorm", + "trade-off", + "tradeoff", + "option", + "compare", + "alternatives", + "design choice", + "choix technique", + ], + aliases: &["brainstorm"], + preferred_cli: "vibe", + preferred_agent: "analyst", + }, + Skill { + id: "repo-onboarding", + label: "Repo Onboarding", + layer: "execution-prep", + visibility: "public", + summary: "Comprendre rapidement un repo avant la premiere intervention.", + when_to_use: &[ + "quand on arrive sur un nouveau projet", + "quand il faut cartographier une codebase", + "quand on veut savoir ou modifier sans casser le reste", + ], + input_examples: &[ + "Explore ce repo comme si tu preparais une premiere intervention", + "Dis-moi ou intervenir pour cette tache", + ], + output_expected: &[ + "structure_repo", + "points_entree", + "modules_cles", + "conventions", + "commandes_utiles", + "zones_a_risque", + "strategie_modification", + ], + usage_prompt: "Explore ce repo comme si tu preparais une premiere intervention. Resume sa structure, les modules critiques, les conventions implicites, les commandes de dev/test, puis dis ou intervenir pour cette tache.", + keywords: &[ + "repo onboarding", + "repo-onboarding", + "onboarding", + "explore repo", + "new repo", + "codebase", + "structure du repo", + "points d'entree", + "where to change", + "cartographier", + ], + aliases: &[], + preferred_cli: "claude", + preferred_agent: "planner", + }, + Skill { + id: "task-decomposer", + label: "Task Decomposer", + layer: "execution-prep", + visibility: "public", + summary: "Transformer une spec ou direction choisie en plan d'execution operationnel.", + when_to_use: &[ + "quand la direction est choisie", + "quand on veut passer du brainstorming a l'action", + "quand il faut un ordre d'execution clair", + ], + input_examples: &[ + "Decoupe cette refonte en sous-taches concretement executables", + "Fais-moi un plan d'implementation directement actionnable", + ], + output_expected: &[ + "etapes_ordonnees", + "dependances", + "quick_wins", + "parallelisation", + "tests", + "definition_of_done", + ], + usage_prompt: "Decoupe cette tache en sous-taches concretes, ordonnees, avec dependances, risques, validations et tests associes. Le plan doit etre directement executable par un agent de code.", + keywords: &[ + "task decomposer", + "task-decomposer", + "decompose", + "plan d'execution", + "execution plan", + "break down", + "subtasks", + "steps", + "roadmap", + "implementation plan", + ], + aliases: &[], + preferred_cli: "claude", + preferred_agent: "planner", + }, + Skill { + id: "pr-review-prep", + label: "PR Review Prep", + layer: "execution-prep", + visibility: "public", + summary: "Preparer une PR review-ready et anticiper les objections du reviewer.", + when_to_use: &[ + "quand l'implementation est terminee", + "quand il faut une bonne PR", + "quand on veut preparer la review", + ], + input_examples: &[ + "Prepare une PR clean a partir de ces changements", + "Resume le pourquoi, le quoi et les risques pour le reviewer", + ], + output_expected: &[ + "resume_du_changement", + "impact_fonctionnel", + "impact_technique", + "migrations", + "tests", + "points_attention_reviewer", + "message_de_pr", + ], + usage_prompt: "Prepare une PR review-ready a partir de ces changements. Resume le pourquoi, le quoi, les impacts, les risques, les tests effectues et les points a surveiller pour le reviewer.", + keywords: &[ + "pr review prep", + "pr-review-prep", + "pr", + "pull request", + "review prep", + "release notes", + "review-ready", + "reviewer", + ], + aliases: &["review"], + preferred_cli: "claude", + preferred_agent: "planner", + }, + Skill { + id: "implementation", + label: "Implementation", + layer: "execution", + visibility: "internal", + summary: "Ship code changes and concrete fixes.", + when_to_use: &[ + "quand il faut modifier du code maintenant", + "quand la spec est deja suffisamment claire", + ], + input_examples: &["Fix the flaky test", "Implement the API endpoint"], + output_expected: &["code_changes", "validation", "tests"], + usage_prompt: "Implement the requested change directly and validate it.", + keywords: &[ + "fix", + "implement", + "build", + "write", + "patch", + "feature", + "code", + ], + aliases: &[], + preferred_cli: "codex", + preferred_agent: "executor", + }, + Skill { + id: "debug-restoration", + label: "Debug Restoration", + layer: "execution", + visibility: "internal", + summary: "Investigate failures and restore a working state.", + when_to_use: &[ + "quand quelque chose casse", + "quand il faut comprendre puis restaurer un etat sain", + ], + input_examples: &[ + "The pane dies instantly", + "This build started failing today", + ], + output_expected: &["root_cause", "fix", "validation"], + usage_prompt: "Investigate the failure, isolate the cause, and restore a working state.", + keywords: &[ + "bug", "debug", "failure", "broken", "error", "crash", "repair", + ], + aliases: &["debug"], + preferred_cli: "codex", + preferred_agent: "executor", + }, + Skill { + id: "ops-deployment", + label: "Ops Deployment", + layer: "execution", + visibility: "internal", + summary: "Handle shell, fleet, infra, network, and remote machine work.", + when_to_use: &[ + "quand il faut deployer, scanner, installer, diagnostiquer des machines", + "quand la tache est principalement shell/infra", + ], + input_examples: &[ + "Deploy the runtime on all machines", + "Scan SSH hosts and configure the fleet", + ], + output_expected: &["ops_actions", "machine_state", "validation"], + usage_prompt: "Handle the shell, infra, fleet, and remote machine operations safely.", + keywords: &["ssh", "shell", "fleet", "ops", "network", "infra", "deploy"], + aliases: &["ops"], + preferred_cli: "codex", + preferred_agent: "executor", + }, +]; + +#[derive(Clone, Copy)] +pub struct SkillResolution { + pub skill: &'static Skill, + pub agent: &'static Agent, + pub cli: &'static str, +} + +pub fn agent_by_id(agent_id: &str) -> Option<&'static Agent> { + AGENTS.iter().find(|agent| agent.id == agent_id) +} + +pub fn agent_for_cli(cli: &str) -> &'static Agent { + AGENTS + .iter() + .find(|agent| agent.primary_cli == cli) + .or_else(|| agent_by_id("executor")) + .expect("executor agent must exist") +} + +pub fn skill_by_id(skill_id: &str, include_internal: bool) -> Option<&'static Skill> { + let normalized = normalize_skill_id(skill_id); + SKILLS.iter().find(|skill| { + (include_internal || skill.visibility != "internal") + && (normalized == skill.id + || skill + .aliases + .iter() + .any(|alias| normalized == normalize_skill_id(alias))) + }) +} + +pub fn resolve_skill_and_agent( + goal: Option<&str>, + skill_id: Option<&str>, + agent_id: Option<&str>, + cli: Option<&str>, +) -> SkillResolution { + let skill = skill_id + .and_then(|value| skill_by_id(value, true)) + .unwrap_or_else(|| match_skill(goal.unwrap_or(""), true)); + + if let Some(selected_cli) = cli { + let agent = agent_for_cli(selected_cli); + return SkillResolution { + skill, + agent, + cli: agent.primary_cli, + }; + } + + if let Some(selected_agent) = agent_id { + if let Some(agent) = agent_by_id(selected_agent) { + return SkillResolution { + skill, + agent, + cli: agent.primary_cli, + }; + } + } + + let agent = agent_by_id(skill.preferred_agent).expect("preferred agent must exist"); + SkillResolution { + skill, + agent, + cli: skill.preferred_cli, + } +} + +pub fn match_skill(goal: &str, include_internal: bool) -> &'static Skill { + let goal_l = goal.to_lowercase(); + let mut best = if include_internal { + skill_by_id("implementation", true).expect("implementation skill must exist") + } else { + skill_by_id("spec-planner", false).expect("spec-planner must exist") + }; + let mut best_score = 0_i32; + + for skill in SKILLS + .iter() + .filter(|skill| include_internal || skill.visibility != "internal") + { + let score = score_keywords(&goal_l, skill); + if score > best_score { + best_score = score; + best = skill; + } + } + + if best_score == 0 { + if contains_any( + &goal_l, + &[ + "repo", + "codebase", + "onboarding", + "where to change", + "first intervention", + ], + ) { + return skill_by_id("repo-onboarding", include_internal) + .expect("repo-onboarding must exist"); + } + if contains_any( + &goal_l, + &[ + "je veux", + "i want", + "on doit", + "we need", + "idea brute", + "high level idea", + ], + ) { + return skill_by_id("spec-planner", include_internal).expect("spec-planner must exist"); + } + if contains_any( + &goal_l, + &["architecture", "trade-off", "tradeoff", "option", "compare"], + ) { + return skill_by_id("architecture-brainstorm", include_internal) + .expect("architecture-brainstorm must exist"); + } + if contains_any( + &goal_l, + &[ + "plan", + "sub-task", + "subtask", + "decompose", + "step-by-step", + "roadmap", + ], + ) { + return skill_by_id("task-decomposer", include_internal) + .expect("task-decomposer must exist"); + } + if contains_any( + &goal_l, + &["pr", "pull request", "reviewer", "release notes"], + ) { + return skill_by_id("pr-review-prep", include_internal) + .expect("pr-review-prep must exist"); + } + if include_internal + && contains_any(&goal_l, &["bug", "crash", "failure", "broken", "error"]) + { + return skill_by_id("debug-restoration", true).expect("debug-restoration must exist"); + } + if include_internal + && contains_any( + &goal_l, + &["ssh", "fleet", "deploy", "network", "machine", "infra"], + ) + { + return skill_by_id("ops-deployment", true).expect("ops-deployment must exist"); + } + } + + best +} + +fn normalize_skill_id(value: &str) -> String { + value.trim().to_lowercase() +} + +fn score_keywords(goal: &str, skill: &Skill) -> i32 { + let mut score = 0_i32; + for keyword in skill.keywords { + if goal.contains(keyword) { + score += if keyword.contains(' ') || keyword.contains('-') { + 3 + } else { + 1 + }; + } + } + if goal.contains(skill.id) { + score += 5; + } + for alias in skill.aliases { + if goal.contains(alias) { + score += 3; + } + } + score +} + +fn contains_any(goal: &str, tokens: &[&str]) -> bool { + tokens.iter().any(|token| goal.contains(token)) +} + +pub const MINIMAL_STACK: &[&str] = &["spec-planner", "architecture-brainstorm", "task-decomposer"]; + +pub const WORKFLOW_STACK: &[&str] = &[ + "spec-planner", + "architecture-brainstorm", + "repo-onboarding", + "task-decomposer", + "pr-review-prep", +]; diff --git a/src/cockpit.rs b/src/cockpit.rs new file mode 100644 index 0000000..b48ca70 --- /dev/null +++ b/src/cockpit.rs @@ -0,0 +1,449 @@ +use std::process::{Command, ExitStatus}; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::config::{MachineConfig, fleet_machine, load_fleet_config}; +use crate::paths; + +pub const ROLES: [&str; 4] = ["claude", "codex", "copilot", "vibe"]; + +#[derive(Debug, Clone, Serialize)] +pub struct PaneStatus { + pub session_name: String, + pub window_name: String, + pub pane_id: String, + pub pane_index: u32, + pub role: String, + pub pane_role: String, + pub pane_title: String, + pub pane_command: String, + pub active: bool, + pub dead: bool, + pub dead_status: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MachineRuntimeStatus { + pub label: String, + pub target: String, + pub session: String, + pub reachable: bool, + pub attached_window: String, + pub session_exists: bool, + pub panes: Vec, + pub roles: std::collections::BTreeMap>, + pub stderr: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FleetRuntimeStatus { + pub local_session: String, + pub machine_session: String, + pub fleet_session_exists: bool, + pub fleet_windows: Vec, + pub focused_machine: Option, + pub focused_role: Option, + pub machines: Vec, + pub fleet_stderr: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CommandResult { + pub argv: Vec, + pub returncode: i32, + pub stdout: String, + pub stderr: String, +} + +pub fn runtime_status( + local_session: &str, + machine_session: &str, +) -> Result { + let fleet = load_fleet_config()?; + let local_tmux = run_command( + &[ + "tmux".to_string(), + "list-windows".to_string(), + "-t".to_string(), + local_session.to_string(), + "-F".to_string(), + "#{window_name}\t#{window_active}".to_string(), + ], + None, + )?; + let mut fleet_windows = Vec::new(); + let mut focused_machine = None; + + if local_tmux.returncode == 0 { + for raw in local_tmux.stdout.lines() { + let mut parts = raw.split('\t'); + let window_name = parts.next().unwrap_or_default().to_string(); + let active = parts.next().unwrap_or("0"); + if !window_name.is_empty() { + if active == "1" { + focused_machine = Some(window_name.clone()); + } + fleet_windows.push(window_name); + } + } + } + + let machines = fleet + .machines + .iter() + .map(|machine| machine_tmux_status(machine, machine_session)) + .collect::, _>>()?; + + let mut focused_role = None; + if let Some(machine_name) = focused_machine.as_deref() { + if let Some(machine) = machines + .iter() + .find(|machine| machine.label == machine_name) + { + for role in ROLES { + if machine + .roles + .get(role) + .and_then(|pane| pane.as_ref()) + .map(|pane| pane.active) + .unwrap_or(false) + { + focused_role = Some(role.to_string()); + break; + } + } + } + } + + Ok(FleetRuntimeStatus { + local_session: local_session.to_string(), + machine_session: machine_session.to_string(), + fleet_session_exists: local_tmux.returncode == 0, + fleet_windows, + focused_machine, + focused_role, + machines, + fleet_stderr: local_tmux.stderr.trim().to_string(), + }) +} + +pub fn cockpit_doctor(local_session: &str, machine_session: &str) -> Result { + let tmux = run_command(&["tmux".to_string(), "-V".to_string()], None)?; + let payload = runtime_status(local_session, machine_session)?; + Ok(json!({ + "tmux": { + "available": tmux.returncode == 0, + "stdout": tmux.stdout.trim(), + "stderr": tmux.stderr.trim(), + }, + "status": payload, + })) +} + +pub fn cockpit_open( + workspace: &str, + local_session: &str, + machine_session: &str, + recreate: bool, + remote_recreate: bool, +) -> Result { + let script = paths::repo_root().join("scripts/constant-fleet.sh"); + let mut args = vec![ + script.display().to_string(), + "--workspace".to_string(), + workspace.to_string(), + "--local-session".to_string(), + local_session.to_string(), + "--session".to_string(), + machine_session.to_string(), + ]; + if recreate { + args.push("--recreate".to_string()); + } + if remote_recreate { + args.push("--remote-recreate".to_string()); + } + Ok(run_status(&args, None)?.code().unwrap_or(1)) +} + +pub fn cockpit_attach(local_session: &str) -> Result { + let script = paths::repo_root().join("scripts/constant-fleet.sh"); + let args = vec![ + script.display().to_string(), + "--attach-only".to_string(), + "--local-session".to_string(), + local_session.to_string(), + ]; + Ok(run_status(&args, None)?.code().unwrap_or(1)) +} + +pub fn focus_machine( + machine_label: &str, + pane_role: Option<&str>, + local_session: &str, + machine_session: &str, +) -> Result { + let fleet = load_fleet_config()?; + let machine = fleet_machine(&fleet, machine_label)?; + let script = paths::repo_root().join("scripts/constant-machine.sh"); + + if let Some(role) = pane_role { + let result = run_machine_command( + machine, + &[ + script.display().to_string(), + "--session".to_string(), + machine_session.to_string(), + "--focus-pane".to_string(), + role.to_string(), + ], + )?; + if result.returncode != 0 { + return Ok(result); + } + } + + run_command( + &[ + "tmux".to_string(), + "select-window".to_string(), + "-t".to_string(), + format!("{local_session}:{machine_label}"), + ], + None, + ) +} + +pub fn send_to_pane( + machine_label: &str, + pane_role: &str, + command: &str, + machine_session: &str, +) -> Result { + let fleet = load_fleet_config()?; + let machine = fleet_machine(&fleet, machine_label)?; + let script = paths::repo_root().join("scripts/constant-machine.sh"); + run_machine_command( + machine, + &[ + script.display().to_string(), + "--session".to_string(), + machine_session.to_string(), + "--send-pane".to_string(), + pane_role.to_string(), + "--command".to_string(), + command.to_string(), + ], + ) +} + +pub fn capture_pane( + machine_label: &str, + pane_role: &str, + lines: u32, + machine_session: &str, +) -> Result { + let fleet = load_fleet_config()?; + let machine = fleet_machine(&fleet, machine_label)?; + let script = paths::repo_root().join("scripts/constant-machine.sh"); + run_machine_command( + machine, + &[ + script.display().to_string(), + "--session".to_string(), + machine_session.to_string(), + "--capture-pane".to_string(), + pane_role.to_string(), + "--lines".to_string(), + lines.to_string(), + ], + ) +} + +pub fn restart_pane( + machine_label: &str, + pane_role: &str, + machine_session: &str, +) -> Result { + let fleet = load_fleet_config()?; + let machine = fleet_machine(&fleet, machine_label)?; + let script = paths::repo_root().join("scripts/constant-machine.sh"); + run_machine_command( + machine, + &[ + script.display().to_string(), + "--session".to_string(), + machine_session.to_string(), + "--restart-pane".to_string(), + pane_role.to_string(), + ], + ) +} + +fn machine_tmux_status( + machine: &MachineConfig, + session: &str, +) -> Result { + let label = machine.label.clone(); + let session_target = format!("{session}:{label}"); + let result = if is_local_target(&machine.target) { + run_command(&tmux_list_command(&session_target), None)? + } else { + let inner = shlex_join(&tmux_list_command(&session_target)); + run_command(&ssh_command(&machine.target, &inner), None)? + }; + + let panes = if result.returncode == 0 { + parse_panes(&result.stdout) + } else { + Vec::new() + }; + let mut roles = std::collections::BTreeMap::new(); + for role in ROLES { + roles.insert( + role.to_string(), + panes.iter().find(|pane| pane.role == role).cloned(), + ); + } + + Ok(MachineRuntimeStatus { + label: machine.label.clone(), + target: machine.target.clone(), + session: session_target, + reachable: result.returncode == 0 || is_local_target(&machine.target), + attached_window: machine.label.clone(), + session_exists: result.returncode == 0, + panes, + roles, + stderr: result.stderr.trim().to_string(), + }) +} + +fn parse_panes(stdout: &str) -> Vec { + let mut panes = stdout + .lines() + .filter_map(|raw| { + let parts = raw.split('\t').collect::>(); + if parts.len() != 10 { + return None; + } + let role = if !parts[4].is_empty() { + parts[4].to_string() + } else if ROLES.contains(&parts[5]) { + parts[5].to_string() + } else { + parts[6].to_string() + }; + Some(PaneStatus { + session_name: parts[0].to_string(), + window_name: parts[1].to_string(), + pane_id: parts[2].to_string(), + pane_index: parts[3].parse().unwrap_or(0), + role, + pane_role: parts[4].to_string(), + pane_title: parts[5].to_string(), + pane_command: parts[6].to_string(), + active: parts[7] == "1", + dead: parts[8] == "1", + dead_status: parts[9].parse().unwrap_or(0), + }) + }) + .collect::>(); + panes.sort_by_key(|pane| pane.pane_index); + panes +} + +fn tmux_list_command(session_name: &str) -> Vec { + vec![ + "tmux".to_string(), + "list-panes".to_string(), + "-t".to_string(), + session_name.to_string(), + "-F".to_string(), + "#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{@constant_role}\t#{pane_title}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}\t#{pane_dead_status}".to_string(), + ] +} + +fn ssh_command(target: &str, inner: &str) -> Vec { + vec![ + "ssh".to_string(), + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + "ConnectTimeout=1".to_string(), + target.to_string(), + format!( + "PATH=\"$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\"; export PATH; {inner}" + ), + ] +} + +fn run_machine_command(machine: &MachineConfig, args: &[String]) -> Result { + let mut with_env = vec![ + "env".to_string(), + format!("ZELLIJ_AI_MACHINE_NAME={}", machine.label), + ]; + with_env.extend(args.iter().cloned()); + if is_local_target(&machine.target) { + return run_command(&with_env, None); + } + let quoted = shlex_join(&with_env); + run_command(&ssh_command(&machine.target, "ed), None) +} + +fn run_command(args: &[String], cwd: Option<&str>) -> Result { + let mut command = Command::new(&args[0]); + command.args(&args[1..]); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let output = command + .output() + .map_err(|err| format!("command failed to start {}: {err}", args[0]))?; + Ok(CommandResult { + argv: args.to_vec(), + returncode: output.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +fn run_status(args: &[String], cwd: Option<&str>) -> Result { + let mut command = Command::new(&args[0]); + command.args(&args[1..]); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command + .status() + .map_err(|err| format!("command failed to start {}: {err}", args[0])) +} + +fn is_local_target(target: &str) -> bool { + if matches!(target, "local" | "localhost" | "127.0.0.1" | "::1") { + return true; + } + let hostname = std::env::var("HOSTNAME").unwrap_or_default(); + let short = hostname.split('.').next().unwrap_or_default(); + target == hostname || target == short +} + +fn shlex_join(args: &[String]) -> String { + args.iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" ") +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | ':' | '@' | '=') + }) { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cc2f9b3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,308 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::paths; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineConfig { + pub label: String, + pub target: String, + #[serde(default)] + pub auto_clis: Vec, + #[serde(default)] + pub manual_clis: Vec, + #[serde(default)] + pub backends: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetConfig { + pub version: u32, + pub local_machine: String, + pub repo_dir: String, + pub machines: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EnableMlx { + Bool(bool), + String(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelRoleConfig { + pub role: String, + pub model_id: String, + pub max_tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelsConfig { + pub version: u32, + pub enable_mlx: EnableMlx, + pub planner: ModelRoleConfig, + pub buddy: ModelRoleConfig, + pub verify: ModelRoleConfig, + pub fallback_mode: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryConfig { + pub version: u32, + pub local_store_path: String, + pub qdrant_url: String, + pub qdrant_collection: String, + pub workspace_enrollments: Vec, + pub instruction_weights: BTreeMap, + pub max_chunks_per_query: u32, + pub vector_dimensions: u32, +} + +impl Default for FleetConfig { + fn default() -> Self { + Self { + version: 1, + local_machine: "command-center".to_string(), + repo_dir: "$HOME/constant".to_string(), + machines: vec![ + MachineConfig { + label: "command-center".to_string(), + target: "local".to_string(), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["omc".into(), "cli-local".into(), "cockpit".into()], + }, + MachineConfig { + label: "builder-a".to_string(), + target: "dev@builder-a".to_string(), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["cli-ssh".into(), "cockpit".into()], + }, + MachineConfig { + label: "builder-b".to_string(), + target: "dev@builder-b".to_string(), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["cli-ssh".into(), "cockpit".into()], + }, + MachineConfig { + label: "edge-a".to_string(), + target: "dev@edge-a".to_string(), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["cli-ssh".into(), "cockpit".into()], + }, + MachineConfig { + label: "lab-a".to_string(), + target: "dev@lab-a".to_string(), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["cli-ssh".into(), "cockpit".into()], + }, + ], + } + } +} + +impl Default for ModelsConfig { + fn default() -> Self { + Self { + version: 1, + enable_mlx: EnableMlx::String("auto".to_string()), + planner: ModelRoleConfig { + role: "planner".to_string(), + model_id: "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit".to_string(), + max_tokens: 900, + }, + buddy: ModelRoleConfig { + role: "buddy".to_string(), + model_id: "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit".to_string(), + max_tokens: 900, + }, + verify: ModelRoleConfig { + role: "verify".to_string(), + model_id: "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit".to_string(), + max_tokens: 700, + }, + fallback_mode: "heuristic".to_string(), + } + } +} + +impl Default for MemoryConfig { + fn default() -> Self { + let mut weights = BTreeMap::new(); + weights.insert("workspace".to_string(), 1.0); + weights.insert("repo".to_string(), 0.85); + weights.insert("ancestor".to_string(), 0.65); + weights.insert("user".to_string(), 0.45); + weights.insert("default".to_string(), 0.2); + + Self { + version: 1, + local_store_path: paths::memory_store_path().display().to_string(), + qdrant_url: String::new(), + qdrant_collection: "constant_memory".to_string(), + workspace_enrollments: Vec::new(), + instruction_weights: weights, + max_chunks_per_query: 8, + vector_dimensions: 96, + } + } +} + +pub fn load_fleet_config() -> Result { + let mut config = load_with_legacy( + &paths::fleet_toml_path(), + &paths::fleet_json_path(), + &paths::fleet_yaml_path(), + FleetConfig::default(), + )?; + normalize_fleet(&mut config); + save_with_mirror( + &paths::fleet_toml_path(), + &paths::fleet_json_path(), + &config, + )?; + Ok(config) +} + +pub fn load_models_config() -> Result { + let config = load_with_legacy( + &paths::models_toml_path(), + &paths::models_json_path(), + &paths::models_yaml_path(), + ModelsConfig::default(), + )?; + save_with_mirror( + &paths::models_toml_path(), + &paths::models_json_path(), + &config, + )?; + Ok(config) +} + +pub fn load_memory_config() -> Result { + let config = load_with_legacy( + &paths::memory_toml_path(), + &paths::memory_json_path(), + &paths::memory_yaml_path(), + MemoryConfig::default(), + )?; + save_with_mirror( + &paths::memory_toml_path(), + &paths::memory_json_path(), + &config, + )?; + Ok(config) +} + +#[allow(dead_code)] +pub fn save_memory_config(config: &MemoryConfig) -> Result<(), String> { + save_with_mirror( + &paths::memory_toml_path(), + &paths::memory_json_path(), + config, + ) +} + +pub fn fleet_machine<'a>( + fleet: &'a FleetConfig, + needle: &str, +) -> Result<&'a MachineConfig, String> { + fleet + .machines + .iter() + .find(|machine| machine.label == needle || machine.target == needle) + .ok_or_else(|| format!("Unknown machine: {needle}")) +} + +fn normalize_fleet(config: &mut FleetConfig) { + for machine in &mut config.machines { + machine.backends = machine + .backends + .iter() + .map(|backend| { + if backend == "zellij" { + "cockpit".to_string() + } else { + backend.clone() + } + }) + .collect(); + } +} + +fn load_with_legacy( + toml_path: &Path, + json_path: &Path, + legacy_yaml_path: &Path, + default: T, +) -> Result +where + T: Serialize + DeserializeOwned + Clone, +{ + paths::ensure_runtime_dirs()?; + + if toml_path.exists() { + return parse_toml_file(toml_path); + } + if json_path.exists() { + return parse_json_file(json_path); + } + if legacy_yaml_path.exists() { + return parse_json_file(legacy_yaml_path); + } + + save_with_mirror(toml_path, json_path, &default)?; + Ok(default) +} + +fn parse_toml_file(path: &Path) -> Result +where + T: DeserializeOwned, +{ + let text = + fs::read_to_string(path).map_err(|err| format!("cannot read {}: {err}", path.display()))?; + toml::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display())) +} + +fn parse_json_file(path: &Path) -> Result +where + T: DeserializeOwned, +{ + let text = + fs::read_to_string(path).map_err(|err| format!("cannot read {}: {err}", path.display()))?; + serde_json::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display())) +} + +fn save_with_mirror(toml_path: &Path, json_path: &Path, payload: &T) -> Result<(), String> +where + T: Serialize, +{ + let toml_text = + toml::to_string_pretty(payload).map_err(|err| format!("cannot encode TOML: {err}"))?; + let json_text = serde_json::to_string_pretty(payload) + .map_err(|err| format!("cannot encode JSON: {err}"))?; + + if let Some(parent) = toml_path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + if let Some(parent) = json_path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + + fs::write(toml_path, format!("{toml_text}\n")) + .map_err(|err| format!("cannot write {}: {err}", toml_path.display()))?; + fs::write(json_path, format!("{json_text}\n")) + .map_err(|err| format!("cannot write {}: {err}", json_path.display()))?; + Ok(()) +} diff --git a/src/executor.rs b/src/executor.rs new file mode 100644 index 0000000..75503a8 --- /dev/null +++ b/src/executor.rs @@ -0,0 +1,560 @@ +use std::process::{Command, Stdio}; +use std::time::Instant; + +use serde_json::{Value, json}; + +use crate::config::{FleetConfig, fleet_machine}; +use crate::memory::summarize_mission_to_memory; +use crate::mission::plan_mission; +use crate::state::{ + Mission, MissionStep, append_event, load_mission, mission_summary_value, save_mission, +}; + +pub fn run_mission(mission: &mut Mission, fleet: &FleetConfig) -> Result<(Value, bool), String> { + if mission.steps.is_empty() { + plan_mission(mission, fleet)?; + save_mission(mission)?; + append_event( + &mission.mission_id, + "mission.planned", + json!({ + "plan": { + "title": mission.title, + "summary": mission.planner_summary, + "steps": mission.steps, + }, + "buddy_review": mission.buddy_review, + }), + )?; + } + + loop { + let Some(step_index) = first_active_step_index(mission) else { + mission.status = "done".to_string(); + save_mission(mission)?; + append_event( + &mission.mission_id, + "mission.done", + json!({"mission_id": mission.mission_id}), + )?; + return Ok((mission_summary_value(mission), true)); + }; + + let step_snapshot = mission + .steps + .get(step_index) + .cloned() + .ok_or_else(|| "active step disappeared".to_string())?; + + { + let step = mission + .steps + .get_mut(step_index) + .ok_or_else(|| "active step disappeared".to_string())?; + step.status = "running".to_string(); + step.attempt += 1; + } + mission.status = "running".to_string(); + save_mission(mission)?; + append_event( + &mission.mission_id, + "step.started", + json!({ + "step_id": step_snapshot.step_id, + "machine": step_snapshot.machine, + "backend": step_snapshot.backend, + "cli": step_snapshot.cli, + }), + )?; + + let execution = execute_step(&step_snapshot, mission, fleet)?; + let artifact_path = artifact_for_attempt(mission, step_index, &execution)?; + append_event( + &mission.mission_id, + "step.executed", + json!({ + "step_id": step_snapshot.step_id, + "artifact": artifact_path, + "returncode": execution["returncode"], + }), + )?; + + let verdict = verify_execution(mission, &step_snapshot, &execution); + let decision = verdict + .get("decision") + .and_then(Value::as_str) + .unwrap_or("failed"); + + { + let step = mission + .steps + .get_mut(step_index) + .ok_or_else(|| "active step disappeared".to_string())?; + step.artifact_refs.push(artifact_path.clone()); + step.result_summary = verdict + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + step.extra + .insert("verification".to_string(), verdict.clone()); + + match decision { + "done" => step.status = "done".to_string(), + "retry" if step.attempt < 2 => step.status = "pending".to_string(), + "needs_human" => step.status = "needs_human".to_string(), + _ => step.status = "failed".to_string(), + } + } + + match decision { + "done" => { + save_mission(mission)?; + append_event( + &mission.mission_id, + "step.verified", + json!({"step_id": step_snapshot.step_id, "verdict": verdict}), + )?; + } + "retry" => { + mission.status = "planned".to_string(); + save_mission(mission)?; + append_event( + &mission.mission_id, + "step.verified", + json!({"step_id": step_snapshot.step_id, "verdict": verdict}), + )?; + } + "needs_human" => { + mission.status = "needs_human".to_string(); + save_mission(mission)?; + append_event( + &mission.mission_id, + "step.needs_human", + json!({"step_id": step_snapshot.step_id, "summary": verdict["summary"]}), + )?; + let summary = summarize_mission(mission)?; + return Ok((json!({"summary": summary, "verdict": verdict}), false)); + } + _ => { + mission.status = "failed".to_string(); + save_mission(mission)?; + append_event( + &mission.mission_id, + "step.failed", + json!({"step_id": step_snapshot.step_id, "summary": verdict["summary"]}), + )?; + let summary = summarize_mission(mission)?; + return Ok((json!({"summary": summary, "verdict": verdict}), false)); + } + } + } +} + +pub fn verify_mission( + mission: &mut Mission, + step_id: Option<&str>, +) -> Result<(Value, bool), String> { + let step_index = if let Some(step_id) = step_id { + mission + .steps + .iter() + .position(|entry| entry.step_id == step_id) + } else if mission.steps.is_empty() { + None + } else { + Some(mission.steps.len() - 1) + }; + + let Some(step_index) = step_index else { + return Err("No step artifact available to verify.".to_string()); + }; + + let step_snapshot = mission + .steps + .get(step_index) + .cloned() + .ok_or_else(|| "step not found".to_string())?; + + let artifact_path = step_snapshot + .artifact_refs + .last() + .cloned() + .ok_or_else(|| "No step artifact available to verify.".to_string())?; + let artifact_text = std::fs::read_to_string(&artifact_path) + .map_err(|err| format!("cannot read {artifact_path}: {err}"))?; + let execution: Value = serde_json::from_str(&artifact_text) + .map_err(|err| format!("cannot parse {artifact_path}: {err}"))?; + let verdict = verify_execution(mission, &step_snapshot, &execution); + + if let Some(step) = mission.steps.get_mut(step_index) { + step.result_summary = verdict + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + step.extra + .insert("verification".to_string(), verdict.clone()); + } + save_mission(mission)?; + append_event( + &mission.mission_id, + "mission.verify", + json!({"step_id": step_snapshot.step_id, "verdict": verdict}), + )?; + let done = verdict + .get("decision") + .and_then(Value::as_str) + .map(|value| value == "done") + .unwrap_or(false); + Ok(( + json!({"step_id": step_snapshot.step_id, "verdict": verdict}), + done, + )) +} + +pub fn summarize_mission(mission: &mut Mission) -> Result { + let status_mix = status_mix(mission); + let status_mix_text = status_mix + .iter() + .map(|(key, value)| format!("{key}={}", value.as_u64().unwrap_or(0))) + .collect::>() + .join(", "); + let routes = mission + .steps + .iter() + .map(|step| { + format!( + "{}={}/{}/{}:{}", + step.step_id, step.machine, step.cli, step.backend, step.status + ) + }) + .collect::>(); + let summary = format!( + "Mission {} ended as {}. Steps={}. Status mix={}. Routes: {}.", + mission.title, + mission.status, + mission.steps.len(), + status_mix_text, + if routes.is_empty() { + "none".to_string() + } else { + routes.join("; ") + } + ); + + let artifact_name = format!("summary-{}.json", mission.mission_id); + let artifact_payload = json!({ + "mission_id": mission.mission_id, + "title": mission.title, + "status": mission.status, + "summary": summary, + "status_mix": status_mix, + "routes": routes, + }); + let artifact_path = summary_artifact_path(mission, &artifact_name, &artifact_payload)?; + mission.artifacts.push(artifact_path.clone()); + let memory_summary = summarize_mission_to_memory(mission)?; + save_mission(mission)?; + append_event( + &mission.mission_id, + "mission.summarized", + json!({ + "artifact": artifact_payload.clone(), + "memory": memory_summary, + }), + )?; + + Ok(json!({ + "mission_id": mission.mission_id, + "title": mission.title, + "status": mission.status, + "summary": summary, + "artifact": artifact_path, + "status_mix": status_mix, + "routes": routes, + "memory": memory_summary, + })) +} + +pub fn execute_step( + step: &MissionStep, + mission: &Mission, + fleet: &FleetConfig, +) -> Result { + let machine = fleet_machine(fleet, &step.machine)?; + if step.cli == "copilot" { + return Ok(json!({ + "argv": [], + "returncode": 1, + "stdout": "", + "stderr": "copilot is manual-only in Constant v1", + "duration_s": 0.0, + })); + } + + if matches!(step.backend.as_str(), "cockpit" | "zellij") { + let cockpit = crate::paths::repo_root().join("scripts/constant-fleet.sh"); + return Ok(json!({ + "argv": [cockpit.display().to_string(), "--workspace", mission.workspace], + "returncode": 0, + "stdout": format!("Open cockpit manually with: {} --workspace {}", cockpit.display(), mission.workspace), + "stderr": "", + "duration_s": 0.0, + })); + } + + let command = build_local_command(step, &mission.workspace)?; + if matches!(step.backend.as_str(), "cli-local" | "omc") { + return run_command(&command, Some(&mission.workspace)); + } + if step.backend == "cli-ssh" { + let quoted = shlex_join(&command); + let remote_shell = format!( + "export PATH=\"$HOME/.local/bin:$HOME/.npm-global/bin:$PATH\"; cd {} && {}", + shell_quote(&mission.workspace), + quoted + ); + let ssh_cmd = vec![ + "ssh".to_string(), + machine.target.clone(), + "bash".to_string(), + "-lc".to_string(), + remote_shell, + ]; + return run_command(&ssh_cmd, None); + } + + Err(format!("Unsupported backend: {}", step.backend)) +} + +pub fn verify_execution(mission: &Mission, step: &MissionStep, execution: &Value) -> Value { + let returncode = execution + .get("returncode") + .and_then(Value::as_i64) + .unwrap_or(1); + let stdout = execution + .get("stdout") + .and_then(Value::as_str) + .unwrap_or_default(); + let stderr = execution + .get("stderr") + .and_then(Value::as_str) + .unwrap_or_default(); + let combined = format!("{stdout}\n{stderr}").to_lowercase(); + + let (decision, summary, confidence) = if returncode == 0 + && !combined.contains("open cockpit manually") + && !combined.contains("manual-only") + { + ( + "done", + format!( + "Step {} completed successfully on {} with {}.", + step.step_id, step.machine, step.cli + ), + "medium", + ) + } else if combined.contains("open cockpit manually") + || combined.contains("manual-only") + || combined.contains("device-auth") + || combined.contains("login") + || combined.contains("auth") + { + ( + "needs_human", + format!( + "Step {} needs human attention: {}", + step.step_id, + first_nonempty_line(stderr, stdout) + ), + "high", + ) + } else if step.attempt < 1 { + ( + "retry", + format!( + "Step {} failed once and should retry: {}", + step.step_id, + first_nonempty_line(stderr, stdout) + ), + "medium", + ) + } else { + ( + "failed", + format!( + "Step {} failed after retry budget: {}", + step.step_id, + first_nonempty_line(stderr, stdout) + ), + "high", + ) + }; + + json!({ + "decision": decision, + "summary": summary, + "confidence": confidence, + "mission_status": mission.status, + "machine": step.machine, + "cli": step.cli, + "backend": step.backend, + }) +} + +fn build_local_command(step: &MissionStep, workspace: &str) -> Result, String> { + match step.backend.as_str() { + "omc" => { + if !matches!(step.cli.as_str(), "claude" | "codex") { + return Err(format!( + "OMC backend only supports claude/codex in v1, not {}", + step.cli + )); + } + Ok(vec![ + "omc".to_string(), + "ask".to_string(), + step.cli.clone(), + "--print".to_string(), + step.prompt.clone(), + ]) + } + _ if step.cli == "claude" => Ok(vec![ + "claude".to_string(), + "-p".to_string(), + "--output-format".to_string(), + "json".to_string(), + "--permission-mode".to_string(), + "acceptEdits".to_string(), + step.prompt.clone(), + ]), + _ if step.cli == "codex" => Ok(vec![ + "codex".to_string(), + "exec".to_string(), + "--json".to_string(), + "--full-auto".to_string(), + "--skip-git-repo-check".to_string(), + "-C".to_string(), + workspace.to_string(), + step.prompt.clone(), + ]), + _ if step.cli == "vibe" => Ok(vec![ + "vibe".to_string(), + "-p".to_string(), + step.prompt.clone(), + "--output".to_string(), + "json".to_string(), + "--workdir".to_string(), + workspace.to_string(), + ]), + _ => Err(format!("Unsupported auto CLI: {}", step.cli)), + } +} + +fn run_command(args: &[String], cwd: Option<&str>) -> Result { + let started = Instant::now(); + let mut command = Command::new(&args[0]); + command.args(&args[1..]); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.stdin(Stdio::null()); + let output = command + .output() + .map_err(|err| format!("command failed to start {}: {err}", args[0]))?; + Ok(json!({ + "argv": args, + "returncode": output.status.code().unwrap_or(1), + "stdout": String::from_utf8_lossy(&output.stdout).to_string(), + "stderr": String::from_utf8_lossy(&output.stderr).to_string(), + "duration_s": started.elapsed().as_secs_f64(), + })) +} + +fn artifact_for_attempt( + mission: &mut Mission, + step_index: usize, + execution: &Value, +) -> Result { + let step = mission + .steps + .get(step_index) + .ok_or_else(|| "step not found".to_string())?; + let artifact_dir = crate::paths::mission_artifacts_dir(&mission.mission_id); + std::fs::create_dir_all(&artifact_dir) + .map_err(|err| format!("cannot create {}: {err}", artifact_dir.display()))?; + let path = artifact_dir.join(format!("{}-attempt-{}.json", step.step_id, step.attempt)); + let text = serde_json::to_string_pretty(execution) + .map_err(|err| format!("cannot encode artifact: {err}"))?; + std::fs::write(&path, format!("{text}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display()))?; + let artifact_path = path.display().to_string(); + mission.artifacts.push(artifact_path.clone()); + Ok(artifact_path) +} + +fn summary_artifact_path(mission: &Mission, name: &str, payload: &Value) -> Result { + let artifact_dir = crate::paths::mission_artifacts_dir(&mission.mission_id); + std::fs::create_dir_all(&artifact_dir) + .map_err(|err| format!("cannot create {}: {err}", artifact_dir.display()))?; + let path = artifact_dir.join(name); + let text = serde_json::to_string_pretty(payload) + .map_err(|err| format!("cannot encode summary artifact: {err}"))?; + std::fs::write(&path, format!("{text}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display()))?; + Ok(path.display().to_string()) +} + +fn first_active_step_index(mission: &Mission) -> Option { + mission + .steps + .iter() + .position(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human")) +} + +fn status_mix(mission: &Mission) -> serde_json::Map { + let mut counts = std::collections::BTreeMap::::new(); + for step in &mission.steps { + *counts.entry(step.status.clone()).or_insert(0) += 1; + } + counts + .into_iter() + .map(|(key, value)| (key, json!(value))) + .collect() +} + +fn shlex_join(args: &[String]) -> String { + args.iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" ") +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | ':' | '@')) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn first_nonempty_line(stderr: &str, stdout: &str) -> String { + stderr + .lines() + .chain(stdout.lines()) + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("no details") + .to_string() +} + +pub fn summarize_mission_command(mission_id: &str) -> Result { + let mut mission = load_mission(mission_id)?; + summarize_mission(&mut mission) +} diff --git a/src/fleet.rs b/src/fleet.rs new file mode 100644 index 0000000..196e565 --- /dev/null +++ b/src/fleet.rs @@ -0,0 +1,56 @@ +use serde_json::{Value, json}; + +use crate::paths; + +pub fn fleet_check() -> Result { + let script = paths::repo_root().join("scripts/constant-fleet-install.sh"); + let output = std::process::Command::new(script) + .arg("check") + .output() + .map_err(|err| format!("cannot run fleet check: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let mut machines = Vec::new(); + let mut current = serde_json::Map::new(); + for raw_line in stdout.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + if line.starts_with("===") && line.contains('(') { + if !current.is_empty() { + machines.push(Value::Object(current.clone())); + current.clear(); + } + let label = line.split_whitespace().nth(1).unwrap_or_default(); + current.insert("label".to_string(), Value::String(label.to_string())); + continue; + } + if let Some((key, value)) = line.split_once('=') { + current.insert(key.to_string(), Value::String(value.to_string())); + } + } + if !current.is_empty() { + machines.push(Value::Object(current)); + } + + Ok(json!({ + "returncode": output.status.code().unwrap_or(1), + "machines": machines, + "stderr": stderr.trim(), + })) +} + +pub fn bridge_sync() -> Result { + let script = paths::repo_root().join("scripts/ai-bridge.sh"); + let output = std::process::Command::new(script) + .arg("sync") + .output() + .map_err(|err| format!("cannot run bridge sync: {err}"))?; + Ok(json!({ + "returncode": output.status.code().unwrap_or(1), + "stdout": String::from_utf8_lossy(&output.stdout), + "stderr": String::from_utf8_lossy(&output.stderr), + })) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..eaea9ea --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1419 @@ +mod buddy; +mod capabilities; +mod cockpit; +mod config; +mod executor; +mod fleet; +mod memory; +mod mission; +mod paths; +mod state; +mod tui; + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::{thread, time::Duration}; + +use serde::Serialize; +use serde_json::{Value, json}; + +use buddy::buddy_ask; +use capabilities::{AGENTS, MINIMAL_STACK, SKILLS, VERSION, WORKFLOW_STACK}; +use cockpit::{ + capture_pane, cockpit_attach, cockpit_doctor, cockpit_open, focus_machine, restart_pane, + runtime_status, send_to_pane, +}; +use config::{fleet_machine, load_fleet_config, load_memory_config, load_models_config}; +use executor::{run_mission, summarize_mission_command, verify_mission}; +use fleet::{bridge_sync, fleet_check}; +use memory::summarize_mission_to_memory; +use mission::{delegate_step, plan_mission, retry_mission}; +use state::{ + append_event, list_missions, load_mission, mission_events_text, mission_summary_value, + new_mission, save_mission, +}; +use tui::{TuiAction, run_tui}; + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(message) => { + eprintln!("{message}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result { + let args: Vec = env::args().skip(1).collect(); + if args.is_empty() { + return default_behavior(); + } + + match args[0].as_str() { + "-V" | "--version" => { + println!("{VERSION}"); + Ok(ExitCode::SUCCESS) + } + "-h" | "--help" | "help" => { + print_help(); + Ok(ExitCode::SUCCESS) + } + "skills" => handle_skills(&args[1..]), + "agents" => handle_agents(&args[1..]), + "doctor" => handle_doctor(&args[1..]), + "tui" => handle_tui(&args[1..]), + "cockpit" => handle_cockpit(&args[1..]), + "mission" => handle_mission(&args[1..]), + "delegate" => handle_delegate(&args[1..]), + "buddy" => handle_buddy(&args[1..]), + "memory" => handoff_to_python(&args), + "fleet" => handle_fleet(&args[1..]), + _ => handoff_to_python(&args), + } +} + +fn default_behavior() -> Result { + if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { + let cwd = + env::current_dir().map_err(|err| format!("cannot resolve current directory: {err}"))?; + let code = cockpit_open( + &cwd.to_string_lossy(), + "constant-fleet", + "constant", + false, + false, + )?; + Ok(exit_code_from_status(Some(code))) + } else { + print_doctor(false)?; + Ok(ExitCode::SUCCESS) + } +} + +fn print_help() { + let prog = program_name(); + println!( + "{prog} {VERSION} + +Rust front-controller for Constant. + +Commands handled in Rust: + doctor [--json] + agents [--json] + skills [--json] [--public-only] + tui [--workspace DIR] [--local-session NAME] [--session NAME] + cockpit open|attach|status|doctor|focus|send|capture|restart + mission create [--workspace DIR] [--json] + mission plan [--json] + mission run [--json] + mission status [mission_id] [--verbose] [--json] + mission tail [--follow] + mission verify [--step-id ID] [--json] + mission retry [--step-id ID] [--json] + mission summarize [--json] + delegate [--step-id ID] [--machine LABEL] [--backend NAME] [--cli NAME] [--agent ID] [--skill ID] [--json] + +Other commands still hand off to the existing Python runtime during migration. +Running `{prog}` with no arguments opens or attaches the full fleet cockpit. +Use `{prog} tui --workspace DIR` for the standalone TUI." + ); +} + +fn program_name() -> String { + env::var("CONSTANT_PROG_NAME").unwrap_or_else(|_| "constant".to_string()) +} + +fn handle_skills(args: &[String]) -> Result { + let mut as_json = false; + let mut public_only = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + "--public-only" => public_only = true, + _ => return handoff_to_python_with_prefix("skills", args), + } + } + print_skills(as_json, public_only)?; + Ok(ExitCode::SUCCESS) +} + +fn handle_agents(args: &[String]) -> Result { + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + _ => return handoff_to_python_with_prefix("agents", args), + } + } + print_agents(as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn handle_doctor(args: &[String]) -> Result { + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + _ => return handoff_to_python_with_prefix("doctor", args), + } + } + print_doctor(as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn handle_tui(args: &[String]) -> Result { + let mut workspace = env::current_dir() + .map_err(|err| format!("cannot resolve current directory: {err}"))? + .to_string_lossy() + .into_owned(); + let mut local_session = "constant-fleet".to_string(); + let mut machine_session = "constant".to_string(); + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = args + .get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?; + } + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + _ => return handoff_to_python_with_prefix("tui", args), + } + index += 1; + } + + let workspace = canonical_workspace(&workspace)?; + match run_tui( + workspace.clone(), + local_session.clone(), + machine_session.clone(), + )? { + TuiAction::Exit => Ok(ExitCode::SUCCESS), + TuiAction::OpenCockpit => Ok(exit_code_from_status(Some(cockpit_open( + &workspace, + &local_session, + &machine_session, + false, + false, + )?))), + } +} + +fn handle_cockpit(args: &[String]) -> Result { + if args.is_empty() { + return Err("cockpit requires a subcommand".to_string()); + } + match args[0].as_str() { + "open" => cockpit_open_cmd(&args[1..]), + "attach" => cockpit_attach_cmd(&args[1..]), + "status" => cockpit_status_cmd(&args[1..]), + "doctor" => cockpit_doctor_cmd(&args[1..]), + "focus" => cockpit_focus_cmd(&args[1..]), + "send" => cockpit_send_cmd(&args[1..]), + "capture" => cockpit_capture_cmd(&args[1..]), + "restart" => cockpit_restart_cmd(&args[1..]), + _ => { + let mut forwarded = vec!["cockpit".to_string()]; + forwarded.extend(args.iter().cloned()); + handoff_to_python(&forwarded) + } + } +} + +fn handle_mission(args: &[String]) -> Result { + if args.is_empty() { + return Err("mission requires a subcommand".to_string()); + } + + match args[0].as_str() { + "create" => mission_create(&args[1..]), + "plan" => mission_plan(&args[1..]), + "run" => mission_run_cmd(&args[1..]), + "status" => mission_status(&args[1..]), + "tail" => mission_tail(&args[1..]), + "verify" => mission_verify_cmd(&args[1..]), + "retry" => mission_retry_cmd(&args[1..]), + "summarize" => mission_summarize_cmd(&args[1..]), + _ => { + let mut forwarded = vec!["mission".to_string()]; + forwarded.extend(args.iter().cloned()); + handoff_to_python(&forwarded) + } + } +} + +fn handle_buddy(args: &[String]) -> Result { + if args.is_empty() { + return Err("buddy requires a subcommand".to_string()); + } + match args[0].as_str() { + "ask" => buddy_ask_cmd(&args[1..]), + _ => { + let mut forwarded = vec!["buddy".to_string()]; + forwarded.extend(args.iter().cloned()); + handoff_to_python(&forwarded) + } + } +} + +fn handle_fleet(args: &[String]) -> Result { + if args.is_empty() { + return Err("fleet requires a subcommand".to_string()); + } + match args[0].as_str() { + "status" => fleet_status_cmd(&args[1..]), + "sync" => fleet_sync_cmd(&args[1..]), + _ => { + let mut forwarded = vec!["fleet".to_string()]; + forwarded.extend(args.iter().cloned()); + handoff_to_python(&forwarded) + } + } +} + +fn mission_create(args: &[String]) -> Result { + let mut prompt: Option = None; + let mut workspace = env::current_dir() + .map_err(|err| format!("cannot resolve current directory: {err}"))? + .to_string_lossy() + .into_owned(); + let mut as_json = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = args + .get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?; + } + "--json" => as_json = true, + value if value.starts_with("--") => { + return handoff_mission_subcommand("create", args); + } + value => { + if prompt.is_some() { + return handoff_mission_subcommand("create", args); + } + prompt = Some(value.to_string()); + } + } + index += 1; + } + + let prompt = prompt.ok_or_else(|| "mission create requires a prompt".to_string())?; + let models = load_models_config()?; + let fleet = load_fleet_config()?; + let workspace = canonical_workspace(&workspace)?; + + let mut mission = new_mission(&prompt, &workspace, None, &models); + save_mission(&mut mission)?; + append_event( + &mission.mission_id, + "mission.created", + json!({ "goal": prompt, "workspace": workspace }), + )?; + let buddy_review = plan_mission(&mut mission, &fleet)?; + save_mission(&mut mission)?; + append_event( + &mission.mission_id, + "mission.planned", + json!({ + "plan": { + "title": mission.title, + "summary": mission.planner_summary, + "steps": mission.steps, + }, + "buddy_review": buddy_review, + }), + )?; + print_value(&mission_summary_value(&mission), as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn mission_plan(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + value if value.starts_with("--") => return handoff_mission_subcommand("plan", args), + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("plan", args); + } + mission_id = Some(value.to_string()); + } + } + } + let mission_id = mission_id.ok_or_else(|| "mission plan requires a mission_id".to_string())?; + let fleet = load_fleet_config()?; + let mut mission = load_mission(&mission_id)?; + let buddy_review = plan_mission(&mut mission, &fleet)?; + save_mission(&mut mission)?; + append_event( + &mission.mission_id, + "mission.replanned", + json!({ + "plan": { + "title": mission.title, + "summary": mission.planner_summary, + "steps": mission.steps, + }, + "buddy_review": buddy_review, + }), + )?; + print_value( + &json!({ + "summary": mission_summary_value(&mission), + "buddy_review": mission.buddy_review, + }), + as_json, + )?; + Ok(ExitCode::SUCCESS) +} + +fn mission_run_cmd(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + value if value.starts_with("--") => return handoff_mission_subcommand("run", args), + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("run", args); + } + mission_id = Some(value.to_string()); + } + } + } + let mission_id = mission_id.ok_or_else(|| "mission run requires a mission_id".to_string())?; + let fleet = load_fleet_config()?; + let mut mission = load_mission(&mission_id)?; + let (payload, ok) = run_mission(&mut mission, &fleet)?; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(if ok { 0 } else { 1 }))) +} + +fn mission_status(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut verbose = false; + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--verbose" => verbose = true, + "--json" => as_json = true, + value if value.starts_with("--") => return handoff_mission_subcommand("status", args), + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("status", args); + } + mission_id = Some(value.to_string()); + } + } + } + + if let Some(mission_id) = mission_id { + let mission = load_mission(&mission_id)?; + if verbose { + print_value( + &serde_json::to_value(&mission).map_err(|err| err.to_string())?, + as_json, + )?; + } else { + print_value(&mission_summary_value(&mission), as_json)?; + } + return Ok(ExitCode::SUCCESS); + } + + let summaries = list_missions()? + .into_iter() + .map(|mission| mission_summary_value(&mission)) + .collect::>(); + print_value(&json!({ "missions": summaries }), as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn mission_tail(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut follow = false; + for arg in args { + match arg.as_str() { + "--follow" => follow = true, + value if value.starts_with("--") => return handoff_mission_subcommand("tail", args), + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("tail", args); + } + mission_id = Some(value.to_string()); + } + } + } + + let mission_id = mission_id.ok_or_else(|| "mission tail requires a mission_id".to_string())?; + if !follow { + println!("{}", mission_events_text(&mission_id)?); + return Ok(ExitCode::SUCCESS); + } + + let path = paths::mission_events_file(&mission_id); + if !path.exists() { + return Err(format!("No events for mission {mission_id}")); + } + + let mut seen = 0_usize; + loop { + let text = mission_events_text(&mission_id)?; + if text.len() > seen { + print!("{}", &text[seen..]); + seen = text.len(); + } + thread::sleep(Duration::from_secs(1)); + } +} + +fn mission_verify_cmd(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut step_id: Option = None; + let mut as_json = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--step-id" => { + index += 1; + step_id = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--step-id requires a value".to_string())?, + ); + } + "--json" => as_json = true, + value if value.starts_with("--") => return handoff_mission_subcommand("verify", args), + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("verify", args); + } + mission_id = Some(value.to_string()); + } + } + index += 1; + } + + let mission_id = + mission_id.ok_or_else(|| "mission verify requires a mission_id".to_string())?; + let mut mission = load_mission(&mission_id)?; + let (payload, ok) = verify_mission(&mut mission, step_id.as_deref())?; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(if ok { 0 } else { 1 }))) +} + +fn mission_retry_cmd(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut step_id: Option = None; + let mut as_json = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--step-id" => { + index += 1; + step_id = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--step-id requires a value".to_string())?, + ); + } + "--json" => as_json = true, + value if value.starts_with("--") => return handoff_mission_subcommand("retry", args), + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("retry", args); + } + mission_id = Some(value.to_string()); + } + } + index += 1; + } + + let mission_id = mission_id.ok_or_else(|| "mission retry requires a mission_id".to_string())?; + let mut mission = load_mission(&mission_id)?; + let payload = retry_mission(&mut mission, step_id.as_deref())?; + save_mission(&mut mission)?; + append_event(&mission_id, "step.retry_requested", payload)?; + print_value(&mission_summary_value(&mission), as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn mission_summarize_cmd(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + value if value.starts_with("--") => { + return handoff_mission_subcommand("summarize", args); + } + value => { + if mission_id.is_some() { + return handoff_mission_subcommand("summarize", args); + } + mission_id = Some(value.to_string()); + } + } + } + let mission_id = + mission_id.ok_or_else(|| "mission summarize requires a mission_id".to_string())?; + let payload = summarize_mission_command(&mission_id)?; + let mission = load_mission(&mission_id)?; + let memory = summarize_mission_to_memory(&mission)?; + print_value( + &json!({ "artifact_summary": payload, "memory_summary": memory }), + as_json, + )?; + Ok(ExitCode::SUCCESS) +} + +fn handle_delegate(args: &[String]) -> Result { + if args.is_empty() { + return Err("delegate requires a mission_id".to_string()); + } + let mission_id = args[0].clone(); + let mut step_id: Option = None; + let mut machine: Option = None; + let mut backend: Option = None; + let mut cli: Option = None; + let mut agent: Option = None; + let mut skill: Option = None; + let mut as_json = false; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--step-id" => { + index += 1; + step_id = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--step-id requires a value".to_string())?, + ); + } + "--machine" => { + index += 1; + machine = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--machine requires a value".to_string())?, + ); + } + "--backend" => { + index += 1; + backend = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--backend requires a value".to_string())?, + ); + } + "--cli" => { + index += 1; + cli = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--cli requires a value".to_string())?, + ); + } + "--agent" => { + index += 1; + agent = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--agent requires a value".to_string())?, + ); + } + "--skill" => { + index += 1; + skill = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--skill requires a value".to_string())?, + ); + } + "--json" => as_json = true, + value if value.starts_with("--") => { + return handoff_to_python_with_prefix("delegate", args); + } + _ => return handoff_to_python_with_prefix("delegate", args), + } + index += 1; + } + + let mut mission = load_mission(&mission_id)?; + if let Some(machine) = machine.as_deref() { + let fleet = load_fleet_config()?; + fleet_machine(&fleet, machine)?; + } + let payload = delegate_step( + &mut mission, + step_id.as_deref(), + machine.as_deref(), + backend.as_deref(), + cli.as_deref(), + agent.as_deref(), + skill.as_deref(), + )?; + save_mission(&mut mission)?; + append_event(&mission_id, "step.delegated", payload)?; + print_value(&mission_summary_value(&mission), as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn buddy_ask_cmd(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut prompt: Option = None; + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--mission-id" => { + index += 1; + mission_id = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--mission-id requires a value".to_string())?, + ); + } + "--json" => as_json = true, + value if value.starts_with("--") => { + let mut forwarded = vec!["buddy".to_string(), "ask".to_string()]; + forwarded.extend(args.iter().cloned()); + return handoff_to_python(&forwarded); + } + value => { + if prompt.is_some() { + let mut forwarded = vec!["buddy".to_string(), "ask".to_string()]; + forwarded.extend(args.iter().cloned()); + return handoff_to_python(&forwarded); + } + prompt = Some(value.to_string()); + } + } + index += 1; + } + let prompt = prompt.ok_or_else(|| "buddy ask requires a prompt".to_string())?; + let mission = mission_id.as_deref().map(load_mission).transpose()?; + let workspace = mission.as_ref().map(|entry| entry.workspace.as_str()); + let payload = buddy_ask(mission.as_ref(), &prompt, workspace)?; + print_value(&payload, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn fleet_status_cmd(args: &[String]) -> Result { + let as_json = args.iter().any(|arg| arg == "--json"); + let payload = fleet_check()?; + let ok = payload + .get("returncode") + .and_then(Value::as_i64) + .unwrap_or(1) + == 0; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(if ok { 0 } else { 1 }))) +} + +fn fleet_sync_cmd(args: &[String]) -> Result { + let as_json = args.iter().any(|arg| arg == "--json"); + let payload = bridge_sync()?; + let ok = payload + .get("returncode") + .and_then(Value::as_i64) + .unwrap_or(1) + == 0; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(if ok { 0 } else { 1 }))) +} + +fn handoff_mission_subcommand(prefix: &str, tail: &[String]) -> Result { + let mut args = vec!["mission".to_string(), prefix.to_string()]; + args.extend(tail.iter().cloned()); + handoff_to_python(&args) +} + +fn handoff_cockpit_subcommand(prefix: &str, tail: &[String]) -> Result { + let mut args = vec!["cockpit".to_string(), prefix.to_string()]; + args.extend(tail.iter().cloned()); + handoff_to_python(&args) +} + +fn cockpit_open_cmd(args: &[String]) -> Result { + let mut workspace = env::current_dir() + .map_err(|err| format!("cannot resolve current directory: {err}"))? + .to_string_lossy() + .into_owned(); + let mut local_session = "constant-fleet".to_string(); + let mut machine_session = "constant".to_string(); + let mut recreate = false; + let mut remote_recreate = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = args + .get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?; + } + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--recreate" => recreate = true, + "--remote-recreate" => remote_recreate = true, + _ => return handoff_cockpit_subcommand("open", args), + } + index += 1; + } + let code = cockpit_open( + &workspace, + &local_session, + &machine_session, + recreate, + remote_recreate, + )?; + Ok(exit_code_from_status(Some(code))) +} + +fn cockpit_attach_cmd(args: &[String]) -> Result { + let mut local_session = "constant-fleet".to_string(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + _ => return handoff_cockpit_subcommand("attach", args), + } + index += 1; + } + let code = cockpit_attach(&local_session)?; + Ok(exit_code_from_status(Some(code))) +} + +fn cockpit_status_cmd(args: &[String]) -> Result { + let mut local_session = "constant-fleet".to_string(); + let mut machine_session = "constant".to_string(); + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--json" => as_json = true, + _ => return handoff_cockpit_subcommand("status", args), + } + index += 1; + } + let payload = runtime_status(&local_session, &machine_session)?; + print_value(&payload, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn cockpit_doctor_cmd(args: &[String]) -> Result { + let mut local_session = "constant-fleet".to_string(); + let mut machine_session = "constant".to_string(); + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--json" => as_json = true, + _ => return handoff_cockpit_subcommand("doctor", args), + } + index += 1; + } + let payload = cockpit_doctor(&local_session, &machine_session)?; + print_value(&payload, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn cockpit_focus_cmd(args: &[String]) -> Result { + let mut machine: Option = None; + let mut pane: Option = None; + let mut local_session = "constant-fleet".to_string(); + let mut machine_session = "constant".to_string(); + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--machine" => { + index += 1; + machine = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--machine requires a value".to_string())?, + ); + } + "--pane" => { + index += 1; + pane = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--pane requires a value".to_string())?, + ); + } + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--json" => as_json = true, + _ => return handoff_cockpit_subcommand("focus", args), + } + index += 1; + } + let machine = machine.ok_or_else(|| "--machine is required".to_string())?; + let payload = focus_machine(&machine, pane.as_deref(), &local_session, &machine_session)?; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(payload.returncode))) +} + +fn cockpit_send_cmd(args: &[String]) -> Result { + let mut machine: Option = None; + let mut pane: Option = None; + let mut command: Option = None; + let mut machine_session = "constant".to_string(); + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--machine" => { + index += 1; + machine = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--machine requires a value".to_string())?, + ); + } + "--pane" => { + index += 1; + pane = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--pane requires a value".to_string())?, + ); + } + "--command" => { + index += 1; + command = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--command requires a value".to_string())?, + ); + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--json" => as_json = true, + _ => return handoff_cockpit_subcommand("send", args), + } + index += 1; + } + let payload = send_to_pane( + &machine.ok_or_else(|| "--machine is required".to_string())?, + &pane.ok_or_else(|| "--pane is required".to_string())?, + &command.ok_or_else(|| "--command is required".to_string())?, + &machine_session, + )?; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(payload.returncode))) +} + +fn cockpit_capture_cmd(args: &[String]) -> Result { + let mut machine: Option = None; + let mut pane: Option = None; + let mut lines: u32 = 120; + let mut machine_session = "constant".to_string(); + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--machine" => { + index += 1; + machine = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--machine requires a value".to_string())?, + ); + } + "--pane" => { + index += 1; + pane = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--pane requires a value".to_string())?, + ); + } + "--lines" => { + index += 1; + lines = args + .get(index) + .ok_or_else(|| "--lines requires a value".to_string())? + .parse() + .map_err(|_| "--lines must be an integer".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--json" => as_json = true, + _ => return handoff_cockpit_subcommand("capture", args), + } + index += 1; + } + let payload = capture_pane( + &machine.ok_or_else(|| "--machine is required".to_string())?, + &pane.ok_or_else(|| "--pane is required".to_string())?, + lines, + &machine_session, + )?; + if as_json { + print_value(&payload, true)?; + } else { + println!("{}", payload.stdout); + } + Ok(exit_code_from_status(Some(payload.returncode))) +} + +fn cockpit_restart_cmd(args: &[String]) -> Result { + let mut machine: Option = None; + let mut pane: Option = None; + let mut machine_session = "constant".to_string(); + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--machine" => { + index += 1; + machine = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--machine requires a value".to_string())?, + ); + } + "--pane" => { + index += 1; + pane = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--pane requires a value".to_string())?, + ); + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + "--json" => as_json = true, + _ => return handoff_cockpit_subcommand("restart", args), + } + index += 1; + } + let payload = restart_pane( + &machine.ok_or_else(|| "--machine is required".to_string())?, + &pane.ok_or_else(|| "--pane is required".to_string())?, + &machine_session, + )?; + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(payload.returncode))) +} + +fn handoff_to_python_with_prefix(prefix: &str, tail: &[String]) -> Result { + let mut args = vec![prefix.to_string()]; + args.extend(tail.iter().cloned()); + handoff_to_python(&args) +} + +fn handoff_to_python(args: &[String]) -> Result { + let repo_root = paths::repo_root(); + let default_python = paths::home_dir() + .map(|home| home.join(".local/share/constant/venv/bin/python3")) + .filter(|path| path.exists()); + let python_bin = env::var_os("CONSTANT_PYTHON") + .or_else(|| default_python.map(Into::into)) + .unwrap_or_else(|| OsString::from("python3")); + + let mut command = Command::new(python_bin); + command.arg("-m").arg("constant"); + command.args(args); + command.env("CONSTANT_PROG_NAME", program_name()); + command.env("CONSTANT_RUST_HANDOFF", "1"); + command.env("PYTHONPATH", python_path_with_repo(&repo_root)); + let status = command + .status() + .map_err(|err| format!("python handoff failed: {err}"))?; + + Ok(exit_code_from_status(status.code())) +} + +fn python_path_with_repo(repo_root: &Path) -> OsString { + let mut value = OsString::from(repo_root.as_os_str()); + if let Some(existing) = env::var_os("PYTHONPATH") { + value.push(":"); + value.push(existing); + } + value +} + +fn canonical_workspace(input: &str) -> Result { + let path = PathBuf::from(input) + .expand_home() + .canonicalize() + .or_else(|_| Ok(PathBuf::from(input).expand_home())) + .map_err(|err: std::io::Error| format!("cannot resolve workspace {input}: {err}"))?; + Ok(path.display().to_string()) +} + +fn print_agents(as_json: bool) -> Result<(), String> { + let payload = json!({ + "agents": AGENTS, + "recommended_skill_stack": recommended_skill_stack_value(), + }); + if as_json { + print_value(&payload, true)?; + } else { + println!("Agents"); + for agent in AGENTS { + println!( + "- {} ({}) :: cli={} layer={} manual_only={}", + agent.id, + agent.label, + agent.primary_cli, + agent.preferred_layers.join(","), + agent.manual_only + ); + } + } + Ok(()) +} + +fn print_skills(as_json: bool, public_only: bool) -> Result<(), String> { + let skills = SKILLS + .iter() + .filter(|skill| !public_only || skill.visibility == "public") + .collect::>(); + + let payload = json!({ + "skills": skills, + "catalog": { + "reflection": skills.iter().filter(|skill| skill.layer == "reflection").collect::>(), + "execution-prep": skills.iter().filter(|skill| skill.layer == "execution-prep").collect::>(), + "execution": skills.iter().filter(|skill| skill.layer == "execution").collect::>(), + "manual": [], + }, + "recommended_skill_stack": recommended_skill_stack_value(), + }); + + if as_json { + print_value(&payload, true)?; + } else { + println!("Skills"); + for skill in skills { + println!( + "- {} [{}] :: {} -> {}/{}", + skill.id, skill.layer, skill.summary, skill.preferred_agent, skill.preferred_cli + ); + } + } + Ok(()) +} + +fn print_doctor(as_json: bool) -> Result<(), String> { + let payload = doctor_value()?; + print_value(&payload, as_json) +} + +fn doctor_value() -> Result { + let fleet = load_fleet_config()?; + let models = load_models_config()?; + let memory = load_memory_config()?; + + Ok(json!({ + "version": VERSION, + "repo_root": paths::repo_root().display().to_string(), + "cache_root": paths::cache_root().display().to_string(), + "config_root": paths::config_root().display().to_string(), + "data_root": paths::data_root().display().to_string(), + "rust": { + "front_controller": true, + "python_handoff": command_exists("python3"), + }, + "commands": { + "python3": command_exists("python3"), + "tmux": command_exists("tmux"), + "cargo": command_exists("cargo"), + "claude": command_exists("claude"), + "codex": command_exists("codex"), + "copilot": command_exists("copilot"), + "vibe": command_exists("vibe"), + }, + "config": { + "fleet": paths::fleet_toml_path().display().to_string(), + "models": paths::models_toml_path().display().to_string(), + "memory": paths::memory_toml_path().display().to_string(), + }, + "fleet": { + "local_machine": fleet.local_machine, + "repo_dir": fleet.repo_dir, + "machines": fleet.machines, + }, + "models": models, + "memory": memory, + "wrapper": wrapper_status_value(), + })) +} + +fn wrapper_status_value() -> Value { + let wrapper_dir = paths::cache_root().join("wrapper"); + let rust_bin = paths::repo_root().join("target/debug/constant"); + json!({ + "wrapper_dir": wrapper_dir.display().to_string(), + "rust_bin": rust_bin.display().to_string(), + "rust_bin_exists": rust_bin.exists(), + "forced_python": env::var("CONSTANT_USE_PYTHON").ok().as_deref() == Some("1"), + "forced_rust": env::var("CONSTANT_USE_RUST").ok().as_deref() == Some("1"), + "force_recheck": env::var("CONSTANT_RUST_RECHECK").ok().as_deref() == Some("1"), + "last_mode": read_wrapper_mode(&wrapper_dir.join("last-mode")), + "cache": { + "rust_ok": read_wrapper_stamp(&wrapper_dir.join("rust-ok")), + "rust_fail": read_wrapper_stamp(&wrapper_dir.join("rust-fail")), + }, + "codesign_verify": if rust_bin.exists() { Some(run_quick_command(&["codesign", "--verify", "--verbose=2", &rust_bin.display().to_string()])) } else { None }, + "spctl_assess": if rust_bin.exists() { Some(run_quick_command(&["spctl", "--assess", "-vv", &rust_bin.display().to_string()])) } else { None }, + "xattr_provenance": if rust_bin.exists() { Some(run_quick_command(&["xattr", "-p", "com.apple.provenance", &rust_bin.display().to_string()])) } else { None }, + "hint": "If spctl rejects the Rust binary from a normal terminal, allow that terminal in System Settings -> Privacy & Security -> Developer Tools, then rerun with CONSTANT_RUST_RECHECK=1 to force a fresh startup probe.", + }) +} + +fn read_wrapper_stamp(path: &Path) -> Value { + let Ok(raw) = fs::read_to_string(path) else { + return Value::Null; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Value::Null; + } + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 2 { + return json!({ "raw": trimmed }); + } + let mut payload = json!({ + "signature": parts[0], + "epoch": parts[1], + }); + if let Ok(epoch) = parts[1].parse::() { + if let Some(object) = payload.as_object_mut() { + object.insert("timestamp".to_string(), json!(epoch)); + } + } + payload +} + +fn read_wrapper_mode(path: &Path) -> Value { + let Ok(raw) = fs::read_to_string(path) else { + return Value::Null; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Value::Null; + } + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 3 { + return json!({ "raw": trimmed }); + } + json!({ + "mode": parts[0], + "reason": parts[1], + "epoch": parts[2], + }) +} + +fn run_quick_command(command: &[&str]) -> Value { + if command.is_empty() { + return json!({ "ok": false, "error": "empty command" }); + } + let mut cmd = Command::new(command[0]); + if command.len() > 1 { + cmd.args(&command[1..]); + } + match cmd.output() { + Ok(output) => json!({ + "ok": output.status.success(), + "returncode": output.status.code(), + "stdout": String::from_utf8_lossy(&output.stdout).trim(), + "stderr": String::from_utf8_lossy(&output.stderr).trim(), + "command": command, + }), + Err(err) => json!({ + "ok": false, + "error": err.to_string(), + "command": command, + }), + } +} + +fn recommended_skill_stack_value() -> Value { + json!({ + "minimal": MINIMAL_STACK, + "workflow": WORKFLOW_STACK, + "layers": { + "reflection": ["spec-planner", "architecture-brainstorm"], + "execution": ["repo-onboarding", "task-decomposer", "pr-review-prep"], + } + }) +} + +fn print_value(value: &T, as_json: bool) -> Result<(), String> +where + T: Serialize, +{ + if as_json { + println!( + "{}", + serde_json::to_string_pretty(value) + .map_err(|err| format!("cannot encode JSON: {err}"))? + ); + } else { + println!( + "{}", + serde_json::to_string_pretty(value) + .map_err(|err| format!("cannot encode JSON: {err}"))? + ); + } + Ok(()) +} + +fn command_exists(binary: &str) -> bool { + let Some(path) = env::var_os("PATH") else { + return false; + }; + env::split_paths(&path).any(|dir| dir.join(binary).exists()) +} + +fn exit_code_from_status(code: Option) -> ExitCode { + match code { + Some(value) if (0..=255).contains(&value) => ExitCode::from(value as u8), + Some(_) => ExitCode::from(1), + None => ExitCode::from(1), + } +} + +trait ExpandHomePath { + fn expand_home(self) -> PathBuf; +} + +impl ExpandHomePath for PathBuf { + fn expand_home(self) -> PathBuf { + let value = self.to_string_lossy().to_string(); + expand_home_string(&value) + } +} + +fn expand_home_string(value: &str) -> PathBuf { + if value == "~" { + return paths::home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("~/") { + return paths::home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("$HOME/") { + return paths::home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(value)); + } + if value == "$HOME" { + return paths::home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + PathBuf::from(value) +} diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 0000000..3360c98 --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,1302 @@ +#![allow(dead_code)] + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use crate::config::{MemoryConfig, load_memory_config, save_memory_config}; +use crate::paths; +use crate::state::{Mission, MissionStep, now_utc}; + +const TEXT_SUFFIXES: &[&str] = &[ + ".c", ".cc", ".cfg", ".conf", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", + ".json", ".jsx", ".md", ".prompt", ".py", ".rb", ".rs", ".sh", ".sql", ".swift", ".toml", + ".ts", ".tsx", ".txt", ".yaml", ".yml", ".zsh", +]; + +const IGNORED_DIR_NAMES: &[&str] = &[ + ".git", + ".hg", + ".idea", + ".next", + ".venv", + "__pycache__", + "build", + "cache", + "dist", + "ide", + "logs", + "node_modules", + "output", + "session-state", + "target", + "tmp", + "venv", +]; + +const INSTRUCTION_DIRS: &[&str] = &[".claude", ".copilot", ".agents", ".agent"]; +const INSTRUCTION_FILES: &[&str] = &["CLAUDE.md", "AGENTS.md"]; +const INSTRUCTION_SUFFIXES: &[&str] = + &[".json", ".md", ".prompt", ".toml", ".txt", ".yaml", ".yml"]; +const MAX_TEXT_BYTES: u64 = 256 * 1024; +const MAX_INSTRUCTION_BYTES: u64 = 128 * 1024; + +const SCHEMA_SQL: &str = r#" +create table if not exists workspaces ( + path text primary key, + repo_root text not null, + enrolled_at text not null, + last_indexed_at text, + last_summary_at text +); + +create table if not exists documents ( + id integer primary key, + workspace text not null, + path text not null unique, + rel_path text not null, + kind text not null, + language text, + source_type text not null, + content_hash text not null, + mtime real not null, + size integer not null, + weight real not null default 0, + updated_at text not null +); + +create table if not exists chunks ( + id integer primary key, + document_id integer not null, + workspace text not null, + path text not null, + chunk_index integer not null, + content text not null, + content_hash text not null, + token_count integer not null, + updated_at text not null +); + +create table if not exists instruction_sources ( + id integer primary key, + workspace text, + path text not null unique, + scope text not null, + source_kind text not null, + weight real not null, + content text not null, + content_hash text not null, + updated_at text not null +); + +create table if not exists persona_facts ( + id integer primary key, + fact text not null unique, + weight real not null, + source_path text, + updated_at text not null +); + +create table if not exists mission_summaries ( + mission_id text primary key, + workspace text not null, + title text not null, + status text not null, + summary text not null, + keywords_json text not null, + updated_at text not null +); + +create table if not exists decisions ( + decision_id text primary key, + mission_id text not null, + step_id text, + workspace text not null, + title text not null, + summary text not null, + status text not null, + weight real not null, + updated_at text not null +); + +create table if not exists decision_edges ( + id integer primary key, + from_decision_id text not null, + to_decision_id text not null, + edge_type text not null, + created_at text not null, + unique(from_decision_id, to_decision_id, edge_type) +); +"#; + +pub fn memory_status(workspace: Option<&str>) -> Result { + ensure_schema()?; + let config = load_memory_config()?; + let payload = json!({ + "store_path": paths::memory_store_path().display().to_string(), + "persona_path": paths::persona_path().display().to_string(), + "enrollments": config.workspace_enrollments, + "counts": { + "workspaces": query_count("select count(*) as count from workspaces")?, + "documents": query_count("select count(*) as count from documents")?, + "chunks": query_count("select count(*) as count from chunks")?, + "instruction_sources": query_count("select count(*) as count from instruction_sources")?, + "persona_facts": query_count("select count(*) as count from persona_facts")?, + "mission_summaries": query_count("select count(*) as count from mission_summaries")?, + "decisions": query_count("select count(*) as count from decisions")?, + "decision_edges": query_count("select count(*) as count from decision_edges")?, + } + }); + if let Some(workspace) = workspace { + let workspace = normalize_workspace(workspace)?; + let workspace_str = workspace.display().to_string(); + let workspace_counts = json!({ + "documents": query_count(&format!( + "select count(*) as count from documents where workspace = {}", + sql_quote(&workspace_str) + ))?, + "mission_summaries": query_count(&format!( + "select count(*) as count from mission_summaries where workspace = {}", + sql_quote(&workspace_str) + ))?, + "decisions": query_count(&format!( + "select count(*) as count from decisions where workspace = {}", + sql_quote(&workspace_str) + ))?, + }); + return Ok(json!({ + "store_path": payload["store_path"], + "persona_path": payload["persona_path"], + "enrollments": payload["enrollments"], + "counts": payload["counts"], + "workspace": workspace_str, + "workspace_counts": workspace_counts, + })); + } + Ok(payload) +} + +pub fn enroll_workspace(workspace: &str) -> Result { + let workspace = normalize_workspace(workspace)?; + let mut config = load_memory_config()?; + let workspace_str = workspace.display().to_string(); + if !config + .workspace_enrollments + .iter() + .any(|entry| entry == &workspace_str) + { + config.workspace_enrollments.push(workspace_str.clone()); + config.workspace_enrollments.sort(); + config.workspace_enrollments.dedup(); + save_memory_config(&config)?; + } + rebuild_workspace_memory(&workspace_str, false) +} + +pub fn rebuild_workspace_memory(workspace: &str, enroll: bool) -> Result { + ensure_schema()?; + let workspace = normalize_workspace(workspace)?; + let workspace_str = workspace.display().to_string(); + let repo_root = detect_repo_root(&workspace); + let repo_root_str = repo_root.display().to_string(); + let mut config = load_memory_config()?; + if enroll + && !config + .workspace_enrollments + .iter() + .any(|entry| entry == &workspace_str) + { + config.workspace_enrollments.push(workspace_str.clone()); + config.workspace_enrollments.sort(); + config.workspace_enrollments.dedup(); + save_memory_config(&config)?; + } + + let files = walk_repo_files(&workspace)?; + let discovered_instructions = discover_instruction_files(&workspace, &repo_root, &config)?; + let now = now_utc(); + + let mut sql = String::new(); + sql.push_str(SCHEMA_SQL); + sql.push_str("begin;\n"); + sql.push_str(&format!( + "insert into workspaces(path, repo_root, enrolled_at, last_indexed_at, last_summary_at) values ({}, {}, {}, {}, coalesce((select last_summary_at from workspaces where path = {}), null)) on conflict(path) do update set repo_root=excluded.repo_root, last_indexed_at=excluded.last_indexed_at;\n", + sql_quote(&workspace_str), + sql_quote(&repo_root_str), + sql_quote(&now), + sql_quote(&now), + sql_quote(&workspace_str), + )); + sql.push_str(&format!( + "delete from chunks where workspace = {};\n", + sql_quote(&workspace_str) + )); + sql.push_str(&format!( + "delete from documents where workspace = {};\n", + sql_quote(&workspace_str) + )); + sql.push_str(&format!( + "delete from instruction_sources where workspace = {} and scope != 'user';\n", + sql_quote(&workspace_str) + )); + + let mut indexed = 0_u64; + let mut skipped = 0_u64; + let mut chunk_count = 0_u64; + + for path in files { + let Some(content) = read_text(&path, MAX_TEXT_BYTES)? else { + skipped += 1; + continue; + }; + if content.trim().is_empty() { + skipped += 1; + continue; + } + indexed += 1; + let stat = + fs::metadata(&path).map_err(|err| format!("cannot stat {}: {err}", path.display()))?; + let resolved = path.display().to_string(); + let rel_path = relative_path(&path, &workspace); + let language = language_for(&path); + let digest = sha256_text(&content); + sql.push_str(&format!( + "insert into documents(workspace, path, rel_path, kind, language, source_type, content_hash, mtime, size, weight, updated_at) values ({}, {}, {}, 'repo', {}, 'repo', {}, {}, {}, 1.0, {});\n", + sql_quote(&workspace_str), + sql_quote(&resolved), + sql_quote(&rel_path), + sql_quote(&language), + sql_quote(&digest), + stat.modified().ok().and_then(|v| v.duration_since(std::time::UNIX_EPOCH).ok()).map(|v| v.as_secs_f64()).unwrap_or(0.0), + stat.len(), + sql_quote(&now), + )); + for (idx, chunk) in chunk_text(&content).into_iter().enumerate() { + let token_count = tokenize(&chunk).len(); + let chunk_hash = sha256_text(&chunk); + chunk_count += 1; + sql.push_str(&format!( + "insert into chunks(document_id, workspace, path, chunk_index, content, content_hash, token_count, updated_at) values ((select id from documents where path = {}), {}, {}, {}, {}, {}, {}, {});\n", + sql_quote(&resolved), + sql_quote(&workspace_str), + sql_quote(&resolved), + idx + 1, + sql_quote(&chunk), + sql_quote(&chunk_hash), + token_count, + sql_quote(&now), + )); + } + } + + let mut source_count = 0_u64; + let mut refreshed = 0_u64; + let mut persona_facts = 0_u64; + let discovered_paths = discovered_instructions + .iter() + .map(|entry| entry.path.display().to_string()) + .collect::>(); + if !discovered_paths.is_empty() { + sql.push_str("delete from instruction_sources where path in ("); + sql.push_str( + &discovered_paths + .iter() + .map(|path| sql_quote(path)) + .collect::>() + .join(", "), + ); + sql.push_str(");\n"); + } + for entry in &discovered_instructions { + source_count += 1; + refreshed += 1; + let path = entry.path.display().to_string(); + let digest = sha256_text(&entry.content); + sql.push_str(&format!( + "insert into instruction_sources(workspace, path, scope, source_kind, weight, content, content_hash, updated_at) values ({}, {}, {}, {}, {}, {}, {}, {});\n", + sql_quote(&workspace_str), + sql_quote(&path), + sql_quote(entry.scope), + sql_quote(entry.kind.as_str()), + entry.weight, + sql_quote(&entry.content), + sql_quote(&digest), + sql_quote(&now), + )); + sql.push_str(&format!( + "delete from persona_facts where source_path = {};\n", + sql_quote(&path) + )); + for fact in extract_persona_facts(&entry.content) { + persona_facts += 1; + sql.push_str(&format!( + "insert into persona_facts(fact, weight, source_path, updated_at) values ({}, {}, {}, {}) on conflict(fact) do update set weight=max(weight, excluded.weight), source_path=excluded.source_path, updated_at=excluded.updated_at;\n", + sql_quote(&fact), + entry.weight, + sql_quote(&path), + sql_quote(&now), + )); + } + } + sql.push_str("commit;\n"); + sqlite_exec_script(&sql)?; + + let persona = render_persona()?; + fs::create_dir_all( + paths::persona_path() + .parent() + .ok_or_else(|| "invalid persona directory".to_string())?, + ) + .map_err(|err| format!("cannot create persona dir: {err}"))?; + fs::write(paths::persona_path(), &persona) + .map_err(|err| format!("cannot write {}: {err}", paths::persona_path().display()))?; + + Ok(json!({ + "workspace": workspace_str, + "repo_root": repo_root_str, + "repo": { + "indexed": indexed, + "skipped": skipped, + "pruned": 0, + "chunks": chunk_count, + }, + "instructions": { + "sources": source_count, + "refreshed": refreshed, + "removed": 0, + "persona_facts": persona_facts, + }, + "persona_path": paths::persona_path().display().to_string(), + "store_path": paths::memory_store_path().display().to_string(), + })) +} + +pub fn search_memory( + query: &str, + workspace: Option<&str>, + limit: Option, +) -> Result { + ensure_schema()?; + let max_hits = limit.unwrap_or(load_memory_config()?.max_chunks_per_query as usize); + let workspace = workspace.map(normalize_workspace).transpose()?; + let workspace_sql = workspace.as_ref().map(|path| path.display().to_string()); + let conditions = like_conditions("content", query); + let workspace_clause = workspace_sql + .as_ref() + .map(|value| format!(" and workspace = {}", sql_quote(value))) + .unwrap_or_default(); + let mut hits = Vec::new(); + + let chunk_rows = sqlite_query_json(&format!( + "select path, content from chunks where {}{} limit {};", + conditions, + workspace_clause, + max_hits * 3 + ))?; + for row in chunk_rows { + let path = row.get("path").and_then(Value::as_str).unwrap_or_default(); + let content = row + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + hits.push(json!({ + "kind": "repo", + "path": path, + "score": lexical_score(content, query), + "snippet": snippet(content, 220), + })); + } + + let instruction_rows = sqlite_query_json(&format!( + "select path, scope, weight, content from instruction_sources where {} order by weight desc limit {};", + like_conditions("content", query), + max_hits * 2 + ))?; + for row in instruction_rows { + let path = row.get("path").and_then(Value::as_str).unwrap_or_default(); + let content = row + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + let weight = row.get("weight").and_then(Value::as_f64).unwrap_or(0.0); + hits.push(json!({ + "kind": "instruction", + "path": path, + "score": lexical_score(content, query) + weight + 1.0, + "snippet": snippet(content, 220), + "scope": row.get("scope").cloned().unwrap_or(Value::Null), + })); + } + + let mission_clause = workspace_sql + .as_ref() + .map(|value| format!(" and workspace = {}", sql_quote(value))) + .unwrap_or_default(); + let mission_rows = sqlite_query_json(&format!( + "select mission_id, title, summary from mission_summaries where ({}){} order by updated_at desc limit {};", + like_conditions("title || ' ' || summary", query), + mission_clause, + max_hits * 2 + ))?; + for row in mission_rows { + let mission_id = row + .get("mission_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let summary = row + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default(); + hits.push(json!({ + "kind": "mission", + "path": mission_id, + "score": lexical_score(summary, query) + 1.25, + "snippet": snippet(summary, 220), + })); + } + + let decision_rows = sqlite_query_json(&format!( + "select decision_id, summary, status from decisions where ({}){} order by updated_at desc limit {};", + like_conditions("title || ' ' || summary", query), + mission_clause, + max_hits * 2 + ))?; + for row in decision_rows { + let decision_id = row + .get("decision_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let summary = row + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default(); + hits.push(json!({ + "kind": "decision", + "path": decision_id, + "score": lexical_score(summary, query) + 1.1, + "snippet": snippet(summary, 220), + "status": row.get("status").cloned().unwrap_or(Value::Null), + })); + } + + hits.sort_by(|a, b| { + b.get("score") + .and_then(Value::as_f64) + .partial_cmp(&a.get("score").and_then(Value::as_f64)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut deduped = Vec::new(); + let mut seen = HashSet::new(); + for hit in hits { + let key = format!( + "{}:{}", + hit.get("kind").and_then(Value::as_str).unwrap_or(""), + hit.get("path").and_then(Value::as_str).unwrap_or("") + ); + if seen.insert(key) { + deduped.push(hit); + if deduped.len() >= max_hits { + break; + } + } + } + + Ok(json!({ + "query": query, + "workspace": workspace_sql, + "hits": deduped, + })) +} + +#[allow(dead_code)] +pub fn instruction_skill_sources( + workspace: &str, + query: Option<&str>, + limit: usize, +) -> Result { + ensure_schema()?; + let workspace = normalize_workspace(workspace)?; + let workspace_str = workspace.display().to_string(); + let rows = sqlite_query_json(&format!( + "select path, scope, source_kind, weight, content, updated_at from instruction_sources where workspace = {} or scope = 'user' order by weight desc, updated_at desc limit 64;", + sql_quote(&workspace_str) + ))?; + let query_tokens = query.map(tokenize).unwrap_or_default(); + let query_lower = query.unwrap_or("").to_lowercase(); + let mut hits = Vec::new(); + for row in rows { + let path = row.get("path").and_then(Value::as_str).unwrap_or_default(); + let content = row + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + let mut score = row.get("weight").and_then(Value::as_f64).unwrap_or(0.0); + if let Some(_) = query { + let haystack = content.to_lowercase(); + let path_lower = path.to_lowercase(); + let matched = haystack.contains(&query_lower) + || query_tokens + .iter() + .any(|token| haystack.contains(token) || path_lower.contains(token)); + if !matched { + continue; + } + score += 1.0; + } + hits.push(json!({ + "path": path, + "scope": row.get("scope").cloned().unwrap_or(Value::Null), + "source_kind": row.get("source_kind").cloned().unwrap_or(Value::Null), + "weight": score, + "snippet": snippet(content, 160), + })); + } + hits.sort_by(|a, b| { + b.get("weight") + .and_then(Value::as_f64) + .partial_cmp(&a.get("weight").and_then(Value::as_f64)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(json!(hits.into_iter().take(limit).collect::>())) +} + +pub fn persona_markdown() -> Result { + if paths::persona_path().exists() { + return fs::read_to_string(paths::persona_path()) + .map_err(|err| format!("cannot read {}: {err}", paths::persona_path().display())); + } + render_persona() +} + +pub fn list_decisions(workspace: Option<&str>, mission_id: Option<&str>) -> Result { + ensure_schema()?; + let mut clauses = Vec::new(); + if let Some(workspace) = workspace { + clauses.push(format!( + "workspace = {}", + sql_quote(&normalize_workspace(workspace)?.display().to_string()) + )); + } + if let Some(mission_id) = mission_id { + clauses.push(format!("mission_id = {}", sql_quote(mission_id))); + } + let mut sql = "select decision_id, mission_id, step_id, workspace, title, summary, status, weight, updated_at from decisions".to_string(); + if !clauses.is_empty() { + sql.push_str(" where "); + sql.push_str(&clauses.join(" and ")); + } + sql.push_str(" order by updated_at desc, decision_id asc;"); + Ok(json!({ "decisions": sqlite_query_json(&sql)? })) +} + +pub fn summarize_mission_to_memory(mission: &Mission) -> Result { + ensure_schema()?; + let workspace = normalize_workspace(&mission.workspace)?; + let workspace_str = workspace.display().to_string(); + let repo_root = detect_repo_root(&workspace).display().to_string(); + let status_mix = status_mix(mission); + let status_mix_text = status_mix + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(", "); + let routes = mission + .steps + .iter() + .map(|step| { + format!( + "{}={}/{}/{}:{}", + step.step_id, step.machine, step.cli, step.backend, step.status + ) + }) + .collect::>(); + let summary = format!( + "Mission {} ended as {}. Steps={}. Status mix={}. Routes: {}.", + mission.title, + mission.status, + mission.steps.len(), + status_mix_text, + if routes.is_empty() { + "none".to_string() + } else { + routes.join("; ") + } + ); + let keywords = mission_keywords(mission); + let now = now_utc(); + + let mut sql = String::new(); + sql.push_str(SCHEMA_SQL); + sql.push_str("begin;\n"); + sql.push_str(&format!( + "insert into workspaces(path, repo_root, enrolled_at, last_indexed_at, last_summary_at) values ({}, {}, {}, coalesce((select last_indexed_at from workspaces where path = {}), null), {}) on conflict(path) do update set repo_root=excluded.repo_root, last_summary_at=excluded.last_summary_at;\n", + sql_quote(&workspace_str), + sql_quote(&repo_root), + sql_quote(&now), + sql_quote(&workspace_str), + sql_quote(&now), + )); + sql.push_str(&format!( + "insert into mission_summaries(mission_id, workspace, title, status, summary, keywords_json, updated_at) values ({}, {}, {}, {}, {}, {}, {}) on conflict(mission_id) do update set workspace=excluded.workspace, title=excluded.title, status=excluded.status, summary=excluded.summary, keywords_json=excluded.keywords_json, updated_at=excluded.updated_at;\n", + sql_quote(&mission.mission_id), + sql_quote(&workspace_str), + sql_quote(&mission.title), + sql_quote(&mission.status), + sql_quote(&summary), + sql_quote(&serde_json::to_string(&keywords).map_err(|err| format!("cannot encode keywords: {err}"))?), + sql_quote(&now), + )); + let mut previous_decision_id: Option = None; + let mut decision_count = 0_u64; + for step in &mission.steps { + let decision_id = format!("{}:{}", mission.mission_id, step.step_id); + let title = step_title(step); + let decision_summary = format!( + "Route {}/{}/{} ended as {}. {}", + step.machine, step.cli, step.backend, step.status, step.result_summary + ) + .trim() + .to_string(); + sql.push_str(&format!( + "insert into decisions(decision_id, mission_id, step_id, workspace, title, summary, status, weight, updated_at) values ({}, {}, {}, {}, {}, {}, {}, {}, {}) on conflict(decision_id) do update set mission_id=excluded.mission_id, step_id=excluded.step_id, workspace=excluded.workspace, title=excluded.title, summary=excluded.summary, status=excluded.status, weight=excluded.weight, updated_at=excluded.updated_at;\n", + sql_quote(&decision_id), + sql_quote(&mission.mission_id), + sql_quote(&step.step_id), + sql_quote(&workspace_str), + sql_quote(&title), + sql_quote(&decision_summary), + sql_quote(&step.status), + if step.status == "done" { 1.0 } else { 0.7 }, + sql_quote(&now), + )); + if let Some(previous) = previous_decision_id.as_deref() { + sql.push_str(&format!( + "insert or ignore into decision_edges(from_decision_id, to_decision_id, edge_type, created_at) values ({}, {}, 'depends_on', {});\n", + sql_quote(previous), + sql_quote(&decision_id), + sql_quote(&now), + )); + } + previous_decision_id = Some(decision_id); + decision_count += 1; + } + sql.push_str("commit;\n"); + sqlite_exec_script(&sql)?; + + let persona = render_persona()?; + fs::create_dir_all( + paths::persona_path() + .parent() + .ok_or_else(|| "invalid persona directory".to_string())?, + ) + .map_err(|err| format!("cannot create persona dir: {err}"))?; + fs::write(paths::persona_path(), persona) + .map_err(|err| format!("cannot write {}: {err}", paths::persona_path().display()))?; + + Ok(json!({ + "mission_id": mission.mission_id, + "summary": summary, + "keywords": keywords, + "decisions": decision_count, + "persona_path": paths::persona_path().display().to_string(), + })) +} + +fn ensure_schema() -> Result<(), String> { + let db = paths::memory_store_path(); + if let Some(parent) = db.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + sqlite_exec_script(SCHEMA_SQL) +} + +fn sqlite_exec_script(sql: &str) -> Result<(), String> { + let tmp = std::env::temp_dir().join(format!( + "constant-memory-{}-{}.sql", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default() + )); + fs::write(&tmp, sql).map_err(|err| format!("cannot write {}: {err}", tmp.display()))?; + let input = + fs::File::open(&tmp).map_err(|err| format!("cannot open {}: {err}", tmp.display()))?; + let output = Command::new("sqlite3") + .arg(paths::memory_store_path()) + .stdin(Stdio::from(input)) + .output() + .map_err(|err| format!("cannot start sqlite3: {err}"))?; + let _ = fs::remove_file(&tmp); + if !output.status.success() { + return Err(format!( + "sqlite3 failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(()) +} + +fn sqlite_query_json(sql: &str) -> Result, String> { + let output = Command::new("sqlite3") + .arg("-json") + .arg(paths::memory_store_path()) + .arg(sql) + .output() + .map_err(|err| format!("cannot query sqlite3: {err}"))?; + if !output.status.success() { + return Err(format!( + "sqlite3 query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + return Ok(Vec::new()); + } + serde_json::from_str(&stdout).map_err(|err| format!("cannot parse sqlite json: {err}")) +} + +fn query_count(sql: &str) -> Result { + let rows = sqlite_query_json(sql)?; + Ok(rows + .first() + .and_then(|row| row.get("count")) + .and_then(Value::as_u64) + .unwrap_or(0)) +} + +fn normalize_workspace(path: &str) -> Result { + let candidate = PathBuf::from(path).expand_home(); + candidate + .canonicalize() + .or_else(|_| Ok::(candidate)) + .map_err(|err| format!("cannot resolve workspace {path}: {err}")) +} + +fn detect_repo_root(workspace: &Path) -> PathBuf { + let mut current = Some(workspace); + while let Some(path) = current { + if path.join(".git").exists() { + return path.to_path_buf(); + } + current = path.parent(); + } + workspace.to_path_buf() +} + +fn walk_repo_files(workspace: &Path) -> Result, String> { + let mut files = Vec::new(); + walk_dir_recursive(workspace, &mut files)?; + files.sort(); + Ok(files) +} + +fn walk_dir_recursive(root: &Path, files: &mut Vec) -> Result<(), String> { + for entry in + fs::read_dir(root).map_err(|err| format!("cannot read {}: {err}", root.display()))? + { + let entry = entry.map_err(|err| format!("cannot iterate {}: {err}", root.display()))?; + let path = entry.path(); + let file_name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + if path.is_dir() { + if IGNORED_DIR_NAMES.contains(&file_name) { + continue; + } + walk_dir_recursive(&path, files)?; + continue; + } + if should_index_repo_file(&path) { + files.push(path); + } + } + Ok(()) +} + +fn should_index_repo_file(path: &Path) -> bool { + let file_name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + if file_name.starts_with('.') && !matches!(file_name, ".gitignore" | ".env.example") { + return INSTRUCTION_FILES.contains(&file_name); + } + let suffix = path + .extension() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + let suffix = if suffix.is_empty() { + String::new() + } else { + format!(".{suffix}") + }; + if TEXT_SUFFIXES.iter().any(|candidate| *candidate == suffix) { + return true; + } + matches!(file_name, "Dockerfile" | "Makefile" | "justfile") +} + +fn read_text(path: &Path, max_bytes: u64) -> Result, String> { + let metadata = + fs::metadata(path).map_err(|err| format!("cannot stat {}: {err}", path.display()))?; + if metadata.len() > max_bytes { + return Ok(None); + } + let bytes = fs::read(path).map_err(|err| format!("cannot read {}: {err}", path.display()))?; + if bytes.contains(&0) { + return Ok(None); + } + let text = String::from_utf8_lossy(&bytes).to_string(); + Ok(Some(text)) +} + +#[derive(Clone)] +struct InstructionSource { + path: PathBuf, + scope: &'static str, + weight: f64, + kind: String, + content: String, +} + +fn discover_instruction_files( + workspace: &Path, + repo_root: &Path, + config: &MemoryConfig, +) -> Result, String> { + let mut seen = HashSet::new(); + let mut bases = vec![workspace.to_path_buf(), repo_root.to_path_buf()]; + for parent in repo_root.ancestors().skip(1) { + bases.push(parent.to_path_buf()); + } + if let Some(home) = paths::home_dir() { + if !bases.iter().any(|entry| entry == &home) { + bases.push(home); + } + } + + let home = paths::home_dir(); + let mut entries = Vec::new(); + for base in bases { + if let Some(home) = &home { + if !base.starts_with(home) && base != *workspace && base != *repo_root { + continue; + } + } + for candidate in instruction_candidates(&base)? { + let resolved = candidate + .canonicalize() + .unwrap_or_else(|_| candidate.clone()); + let key = resolved.display().to_string(); + if !seen.insert(key.clone()) { + continue; + } + let Some(content) = read_text(&resolved, MAX_INSTRUCTION_BYTES)? else { + continue; + }; + if content.trim().is_empty() { + continue; + } + let (scope, weight) = instruction_scope(&resolved, workspace, repo_root, config); + let kind = resolved + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|value| value.to_str()) + .unwrap_or_else(|| { + resolved + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("instruction") + }) + .to_string(); + entries.push(InstructionSource { + path: resolved, + scope, + weight, + kind, + content, + }); + } + } + entries.sort_by(|a, b| { + b.weight + .partial_cmp(&a.weight) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.path.cmp(&b.path)) + }); + Ok(entries) +} + +fn instruction_candidates(base: &Path) -> Result, String> { + let mut candidates = Vec::new(); + for name in INSTRUCTION_FILES { + let path = base.join(name); + if path.exists() { + candidates.push(path); + } + } + for dirname in INSTRUCTION_DIRS { + let directory = base.join(dirname); + if !directory.is_dir() { + continue; + } + walk_instruction_dir(&directory, &directory, &mut candidates)?; + } + Ok(candidates) +} + +fn walk_instruction_dir(root: &Path, current: &Path, out: &mut Vec) -> Result<(), String> { + let depth = current + .strip_prefix(root) + .ok() + .map(|value| value.components().count()) + .unwrap_or(0); + if depth > 3 { + return Ok(()); + } + for entry in + fs::read_dir(current).map_err(|err| format!("cannot read {}: {err}", current.display()))? + { + let entry = entry.map_err(|err| format!("cannot iterate {}: {err}", current.display()))?; + let path = entry.path(); + let name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + if path.is_dir() { + if IGNORED_DIR_NAMES.contains(&name) { + continue; + } + walk_instruction_dir(root, &path, out)?; + } else { + let suffix = path + .extension() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + let suffix = if suffix.is_empty() { + String::new() + } else { + format!(".{suffix}") + }; + if INSTRUCTION_SUFFIXES + .iter() + .any(|candidate| *candidate == suffix) + || matches!(name, "config.json" | "settings.json") + { + out.push(path); + } + } + } + Ok(()) +} + +fn instruction_scope( + path: &Path, + workspace: &Path, + repo_root: &Path, + config: &MemoryConfig, +) -> (&'static str, f64) { + let weights = &config.instruction_weights; + let home = paths::home_dir(); + if path == workspace || path.starts_with(workspace) { + return ("workspace", *weights.get("workspace").unwrap_or(&1.0)); + } + if path == repo_root || path.starts_with(repo_root) { + return ("repo", *weights.get("repo").unwrap_or(&0.85)); + } + if let Some(home) = home { + if path.parent() == Some(home.as_path()) || path.starts_with(&home) { + return ("user", *weights.get("user").unwrap_or(&0.45)); + } + } + ("ancestor", *weights.get("ancestor").unwrap_or(&0.65)) +} + +fn render_persona() -> Result { + ensure_schema()?; + let facts = sqlite_query_json( + "select fact, source_path from persona_facts order by weight desc, fact asc limit 32;", + )?; + let missions = sqlite_query_json( + "select mission_id, title, summary from mission_summaries order by updated_at desc limit 6;", + )?; + let decisions = sqlite_query_json( + "select decision_id, title, summary from decisions order by updated_at desc limit 8;", + )?; + let mut lines = vec![ + "# Constant Persona".to_string(), + String::new(), + "## Durable Rules".to_string(), + ]; + if facts.is_empty() { + lines.push("- No durable rules extracted yet.".to_string()); + } else { + for row in facts { + let fact = row.get("fact").and_then(Value::as_str).unwrap_or_default(); + let source_name = row + .get("source_path") + .and_then(Value::as_str) + .map(Path::new) + .and_then(|path| path.file_name()) + .and_then(|value| value.to_str()) + .unwrap_or("source"); + lines.push(format!("- {fact} ({source_name})")); + } + } + lines.push(String::new()); + lines.push("## Recent Mission Summaries".to_string()); + if missions.is_empty() { + lines.push("- No mission summaries yet.".to_string()); + } else { + for row in missions { + lines.push(format!( + "- `{}` {}: {}", + row.get("mission_id") + .and_then(Value::as_str) + .unwrap_or_default(), + row.get("title").and_then(Value::as_str).unwrap_or_default(), + row.get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + )); + } + } + lines.push(String::new()); + lines.push("## Decision Graph Snapshot".to_string()); + if decisions.is_empty() { + lines.push("- No decisions captured yet.".to_string()); + } else { + for row in decisions { + lines.push(format!( + "- `{}` {}: {}", + row.get("decision_id") + .and_then(Value::as_str) + .unwrap_or_default(), + row.get("title").and_then(Value::as_str).unwrap_or_default(), + row.get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + )); + } + } + Ok(lines.join("\n") + "\n") +} + +fn extract_persona_facts(text: &str) -> Vec { + let mut facts = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with("```") + || line.starts_with('{') + || line.starts_with('}') + || line.starts_with('[') + || line.starts_with(']') + { + continue; + } + if !(12..=220).contains(&line.len()) { + continue; + } + let cleaned = line + .trim_start_matches("- ") + .trim_start_matches("* ") + .trim() + .to_string(); + if !cleaned.is_empty() && !facts.contains(&cleaned) { + facts.push(cleaned); + } + if facts.len() >= 24 { + break; + } + } + facts +} + +fn mission_keywords(mission: &Mission) -> Vec { + let mut keywords = BTreeSet::new(); + for token in tokenize(&format!("{} {}", mission.title, mission.goal)) { + keywords.insert(token); + } + for step in &mission.steps { + for token in [ + step.machine.as_str(), + step.cli.as_str(), + step.backend.as_str(), + step.status.as_str(), + ] { + if !token.is_empty() { + keywords.insert(token.to_lowercase()); + } + } + } + keywords.into_iter().take(24).collect() +} + +fn status_mix(mission: &Mission) -> BTreeMap { + let mut map = BTreeMap::new(); + for step in &mission.steps { + *map.entry(step.status.clone()).or_insert(0) += 1; + } + map +} + +fn step_title(step: &MissionStep) -> String { + step.extra + .get("title") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| step.step_id.clone()) +} + +fn sha256_text(text: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn tokenize(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + for ch in text.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '/' | ':' | '-') { + current.push(ch.to_ascii_lowercase()); + } else { + if current.len() >= 2 { + tokens.push(current.clone()); + } + current.clear(); + } + } + if current.len() >= 2 { + tokens.push(current); + } + tokens +} + +fn lexical_score(text: &str, query: &str) -> f64 { + let haystack = text.to_lowercase(); + tokenize(query) + .into_iter() + .filter(|token| haystack.contains(token)) + .count() as f64 +} + +fn chunk_text(text: &str) -> Vec { + let lines = text.lines().collect::>(); + if lines.is_empty() { + return Vec::new(); + } + let mut chunks = Vec::new(); + let mut start = 0usize; + while start < lines.len() { + let mut total = 0usize; + let mut end = start; + while end < lines.len() && total < 1200 { + total += lines[end].len() + 1; + end += 1; + } + let chunk = lines[start..end].join("\n").trim().to_string(); + if !chunk.is_empty() { + chunks.push(chunk); + } + if end >= lines.len() { + break; + } + start = end.saturating_sub(3).max(start + 1); + } + chunks +} + +fn relative_path(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn language_for(path: &Path) -> String { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + { + "py" => "python", + "sh" | "zsh" => "shell", + "js" | "jsx" => "javascript", + "ts" | "tsx" => "typescript", + "json" => "json", + "yaml" | "yml" => "yaml", + "md" => "markdown", + "toml" => "toml", + "rs" => "rust", + "go" => "go", + "swift" => "swift", + "html" => "html", + "css" => "css", + other if !other.is_empty() => other, + _ => "text", + } + .to_string() +} + +fn snippet(text: &str, limit: usize) -> String { + let compact = text.split_whitespace().collect::>().join(" "); + if compact.len() > limit { + format!("{}...", &compact[..limit]) + } else { + compact + } +} + +fn like_conditions(column: &str, query: &str) -> String { + let tokens = tokenize(query); + if tokens.is_empty() { + return format!("{column} like '%constant%'"); + } + tokens + .into_iter() + .take(4) + .map(|token| format!("{column} like {}", sql_quote(&format!("%{token}%")))) + .collect::>() + .join(" or ") +} + +fn sql_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +trait ExpandHomePath { + fn expand_home(self) -> PathBuf; +} + +impl ExpandHomePath for PathBuf { + fn expand_home(self) -> PathBuf { + let value = self.to_string_lossy().to_string(); + if value == "~" { + return paths::home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("~/") { + return paths::home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("$HOME/") { + return paths::home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(value)); + } + PathBuf::from(value) + } +} diff --git a/src/mission.rs b/src/mission.rs new file mode 100644 index 0000000..321735a --- /dev/null +++ b/src/mission.rs @@ -0,0 +1,228 @@ +use serde_json::{Map, Value, json}; + +use crate::capabilities::{agent_for_cli, resolve_skill_and_agent, skill_by_id}; +use crate::config::{FleetConfig, MachineConfig, fleet_machine}; +use crate::state::{Mission, MissionStep}; + +pub fn plan_mission(mission: &mut Mission, fleet: &FleetConfig) -> Result { + let overrides = &mission.routing_overrides; + let override_skill = string_override(overrides, "skill"); + let override_agent = string_override(overrides, "agent"); + let override_cli = string_override(overrides, "cli"); + let resolution = resolve_skill_and_agent( + Some(&mission.goal), + override_skill.as_deref(), + override_agent.as_deref(), + override_cli.as_deref(), + ); + + let machine_label = + string_override(overrides, "machine").unwrap_or_else(|| fleet.local_machine.clone()); + let machine_cfg = fleet_machine(fleet, &machine_label)?; + let backend = + string_override(overrides, "backend").unwrap_or_else(|| default_backend(machine_cfg)); + + let step = MissionStep { + step_id: "step-1".to_string(), + kind: "single-shot".to_string(), + prompt: mission.goal.clone(), + machine: machine_cfg.label.clone(), + backend, + cli: resolution.cli.to_string(), + agent: resolution.agent.id.to_string(), + agent_role: Some(resolution.agent.role.to_string()), + skill: Some(resolution.skill.id.to_string()), + skill_summary: Some(resolution.skill.summary.to_string()), + status: "pending".to_string(), + attempt: 0, + depends_on: Vec::new(), + artifact_refs: Vec::new(), + result_summary: String::new(), + extra: Map::new(), + }; + + mission.steps = vec![step]; + mission.status = "planned".to_string(); + mission.title = planned_title(&mission.goal, &mission.title); + mission.planner_summary = Some(format!( + "Rust heuristic planner selected {} / {} on {} via {}.", + resolution.skill.id, resolution.cli, machine_cfg.label, mission.steps[0].backend + )); + let buddy_review = json!({ + "mode": "rust-heuristic", + "verdict": "agree", + "confidence": "medium", + "why": "Matched the goal against the skill registry and routed to the preferred agent/CLI stack.", + "change": { + "machine": mission.steps[0].machine, + "cli": mission.steps[0].cli, + "backend": mission.steps[0].backend, + }, + "memory": { + "store": false, + "kind": "plan" + } + }); + mission.buddy_review = Some(buddy_review.clone()); + Ok(buddy_review) +} + +pub fn retry_mission(mission: &mut Mission, step_id: Option<&str>) -> Result { + let step_index = if let Some(target) = step_id { + mission + .steps + .iter() + .position(|entry| entry.step_id == target) + } else { + first_active_step_index(mission).or_else(|| mission.steps.len().checked_sub(1)) + }; + + let Some(step_index) = step_index else { + return Err("No step to retry.".to_string()); + }; + + let step = mission + .steps + .get_mut(step_index) + .ok_or_else(|| "No step to retry.".to_string())?; + step.status = "pending".to_string(); + let step_id_value = step.step_id.clone(); + mission.status = "planned".to_string(); + Ok(json!({ "step_id": step_id_value })) +} + +pub fn delegate_step( + mission: &mut Mission, + step_id: Option<&str>, + machine: Option<&str>, + backend: Option<&str>, + cli: Option<&str>, + agent: Option<&str>, + skill: Option<&str>, +) -> Result { + let step_index = if let Some(target) = step_id { + mission + .steps + .iter() + .position(|entry| entry.step_id == target) + } else { + first_active_step_index(mission) + }; + let Some(step_index) = step_index else { + return Err("No active step to delegate.".to_string()); + }; + + let goal_fallback = mission.goal.clone(); + let step = mission + .steps + .get_mut(step_index) + .ok_or_else(|| "No active step to delegate.".to_string())?; + + if let Some(machine) = machine { + step.machine = machine.to_string(); + } + if let Some(backend) = backend { + step.backend = backend.to_string(); + } + + if skill.is_some() || agent.is_some() || cli.is_some() { + let resolved_cli = if skill.is_none() { + cli.or_else(|| (!step.cli.is_empty()).then_some(step.cli.as_str())) + } else { + cli + }; + let resolved_agent = if skill.is_none() { + agent.or_else(|| (!step.agent.is_empty()).then_some(step.agent.as_str())) + } else { + agent + }; + let resolved = resolve_skill_and_agent( + Some(if step.prompt.is_empty() { + &goal_fallback + } else { + &step.prompt + }), + skill.or(step.skill.as_deref()), + resolved_agent, + resolved_cli, + ); + step.skill = Some(resolved.skill.id.to_string()); + step.skill_summary = Some(resolved.skill.summary.to_string()); + step.agent = resolved.agent.id.to_string(); + step.agent_role = Some(resolved.agent.role.to_string()); + step.cli = resolved.cli.to_string(); + } else if !step.cli.is_empty() && step.agent.is_empty() { + let mapped = agent_for_cli(&step.cli); + step.agent = mapped.id.to_string(); + step.agent_role = Some(mapped.role.to_string()); + } + + if let Some(skill_id) = step.skill.clone() { + if let Some(mapped_skill) = skill_by_id(&skill_id, true) { + step.skill_summary = Some(mapped_skill.summary.to_string()); + } + } + + step.status = "pending".to_string(); + let payload = json!({ + "step_id": step.step_id, + "machine": step.machine, + "backend": step.backend, + "cli": step.cli, + "agent": step.agent, + "skill": step.skill, + }); + mission.status = "planned".to_string(); + + Ok(payload) +} + +fn string_override(payload: &Map, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn default_backend(machine: &MachineConfig) -> String { + let local = matches!( + machine.target.as_str(), + "local" | "localhost" | "127.0.0.1" | "::1" + ); + let preferred = if local { + ["cli-local", "omc", "cockpit"] + } else { + ["cli-ssh", "cockpit", "cli-local"] + }; + + for backend in preferred { + if machine + .backends + .iter() + .any(|candidate| candidate == backend) + { + return backend.to_string(); + } + } + machine + .backends + .first() + .cloned() + .unwrap_or_else(|| if local { "cli-local" } else { "cli-ssh" }.to_string()) +} + +fn first_active_step_index(mission: &Mission) -> Option { + mission + .steps + .iter() + .position(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human")) +} + +fn planned_title(goal: &str, fallback: &str) -> String { + let head = goal.trim().lines().next().unwrap_or_default().trim(); + if head.is_empty() { + fallback.to_string() + } else { + head.chars().take(80).collect() + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..42d247c --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,131 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +pub fn repo_root() -> PathBuf { + if let Some(explicit) = env::var_os("CONSTANT_REPO_DIR") { + return PathBuf::from(explicit); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +pub fn home_dir() -> Option { + env::var_os("HOME").map(PathBuf::from) +} + +pub fn cache_root() -> PathBuf { + home_dir() + .map(|home| home.join(".cache/constant")) + .unwrap_or_else(|| PathBuf::from(".cache/constant")) +} + +pub fn config_root() -> PathBuf { + home_dir() + .map(|home| home.join(".config/constant")) + .unwrap_or_else(|| PathBuf::from(".config/constant")) +} + +pub fn data_root() -> PathBuf { + home_dir() + .map(|home| home.join(".local/share/constant")) + .unwrap_or_else(|| PathBuf::from(".local/share/constant")) +} + +pub fn planner_dir() -> PathBuf { + cache_root().join("planner") +} + +pub fn missions_dir() -> PathBuf { + cache_root().join("missions") +} + +pub fn chat_root() -> PathBuf { + cache_root().join("chat") +} + +pub fn fleet_toml_path() -> PathBuf { + config_root().join("fleet.toml") +} + +pub fn models_toml_path() -> PathBuf { + config_root().join("models.toml") +} + +pub fn memory_toml_path() -> PathBuf { + config_root().join("memory.toml") +} + +pub fn fleet_json_path() -> PathBuf { + config_root().join("fleet.json") +} + +pub fn models_json_path() -> PathBuf { + config_root().join("models.json") +} + +pub fn memory_json_path() -> PathBuf { + config_root().join("memory.json") +} + +pub fn fleet_yaml_path() -> PathBuf { + config_root().join("fleet.yaml") +} + +pub fn models_yaml_path() -> PathBuf { + config_root().join("models.yaml") +} + +pub fn memory_yaml_path() -> PathBuf { + config_root().join("memory.yaml") +} + +pub fn memory_store_path() -> PathBuf { + data_root().join("memory.sqlite") +} + +#[allow(dead_code)] +pub fn persona_path() -> PathBuf { + data_root().join("persona.md") +} + +pub fn indexes_dir() -> PathBuf { + data_root().join("indexes") +} + +pub fn memory_sources_dir() -> PathBuf { + data_root().join("sources") +} + +pub fn mission_dir(mission_id: &str) -> PathBuf { + missions_dir().join(mission_id) +} + +pub fn mission_file(mission_id: &str) -> PathBuf { + mission_dir(mission_id).join("mission.json") +} + +pub fn mission_events_file(mission_id: &str) -> PathBuf { + mission_dir(mission_id).join("events.ndjson") +} + +#[allow(dead_code)] +pub fn mission_artifacts_dir(mission_id: &str) -> PathBuf { + mission_dir(mission_id).join("artifacts") +} + +pub fn ensure_runtime_dirs() -> Result<(), String> { + for path in [ + cache_root(), + chat_root(), + config_root(), + data_root(), + missions_dir(), + indexes_dir(), + memory_sources_dir(), + planner_dir(), + ] { + fs::create_dir_all(&path) + .map_err(|err| format!("cannot create {}: {err}", path.display()))?; + } + Ok(()) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..5e3aff5 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,281 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::config::ModelsConfig; +use crate::paths; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MissionStep { + #[serde(default)] + pub step_id: String, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub prompt: String, + #[serde(default)] + pub machine: String, + #[serde(default)] + pub backend: String, + #[serde(default)] + pub cli: String, + #[serde(default)] + pub agent: String, + #[serde(default)] + pub agent_role: Option, + #[serde(default)] + pub skill: Option, + #[serde(default)] + pub skill_summary: Option, + #[serde(default)] + pub status: String, + #[serde(default)] + pub attempt: u32, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub artifact_refs: Vec, + #[serde(default)] + pub result_summary: String, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Mission { + pub mission_id: String, + pub title: String, + pub goal: String, + pub workspace: String, + pub status: String, + #[serde(default = "default_priority")] + pub priority: String, + pub created_at: String, + pub updated_at: String, + pub planner_model: String, + pub buddy_model: String, + pub verify_model: String, + #[serde(default = "default_owner")] + pub owner: String, + #[serde(default)] + pub routing_overrides: Map, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub meta: Map, + #[serde(default)] + pub planner_summary: Option, + #[serde(default)] + pub buddy_review: Option, + #[serde(flatten)] + pub extra: Map, +} + +fn default_priority() -> String { + "normal".to_string() +} + +fn default_owner() -> String { + "Constant".to_string() +} + +pub fn now_utc() -> String { + let output = Command::new("date") + .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) + .output(); + match output { + Ok(result) if result.status.success() => { + String::from_utf8_lossy(&result.stdout).trim().to_string() + } + _ => "1970-01-01T00:00:00Z".to_string(), + } +} + +pub fn new_mission( + goal: &str, + workspace: &str, + routing_overrides: Option>, + models: &ModelsConfig, +) -> Mission { + let mission_id = mission_id(); + let timestamp = now_utc(); + let title_line = goal.trim().lines().next().unwrap_or_default().trim(); + let title = if title_line.is_empty() { + format!("mission-{mission_id}") + } else { + title_line.chars().take(80).collect() + }; + + let mut meta = Map::new(); + meta.insert("schema_version".to_string(), json!(1)); + meta.insert("tool_version".to_string(), json!(env!("CARGO_PKG_VERSION"))); + + Mission { + mission_id, + title, + goal: goal.to_string(), + workspace: workspace.to_string(), + status: "draft".to_string(), + priority: default_priority(), + created_at: timestamp.clone(), + updated_at: timestamp, + planner_model: models.planner.model_id.clone(), + buddy_model: models.buddy.model_id.clone(), + verify_model: models.verify.model_id.clone(), + owner: default_owner(), + routing_overrides: routing_overrides.unwrap_or_default(), + steps: Vec::new(), + artifacts: Vec::new(), + meta, + planner_summary: None, + buddy_review: None, + extra: Map::new(), + } +} + +pub fn mission_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default(); + let pid = std::process::id() as u128; + format!("{:012x}", (nanos ^ pid) & 0xffff_ffff_ffff) +} + +pub fn save_mission(mission: &mut Mission) -> Result<(), String> { + paths::ensure_runtime_dirs()?; + mission.updated_at = now_utc(); + let path = paths::mission_file(&mission.mission_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + let text = serde_json::to_string_pretty(mission) + .map_err(|err| format!("cannot encode mission {}: {err}", mission.mission_id))?; + fs::write(&path, format!("{text}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display())) +} + +pub fn load_mission(mission_id: &str) -> Result { + let path = paths::mission_file(mission_id); + let text = fs::read_to_string(&path) + .map_err(|err| format!("cannot read {}: {err}", path.display()))?; + serde_json::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display())) +} + +pub fn list_missions() -> Result, String> { + paths::ensure_runtime_dirs()?; + let mut paths_list = Vec::new(); + for entry in fs::read_dir(paths::missions_dir()) + .map_err(|err| format!("cannot read {}: {err}", paths::missions_dir().display()))? + { + let entry = entry.map_err(|err| format!("cannot iterate missions dir: {err}"))?; + let mission_file = entry.path().join("mission.json"); + if mission_file.exists() { + paths_list.push(mission_file); + } + } + paths_list.sort(); + + let mut missions = Vec::new(); + for path in paths_list { + let text = fs::read_to_string(&path) + .map_err(|err| format!("cannot read {}: {err}", path.display()))?; + let mission: Mission = serde_json::from_str(&text) + .map_err(|err| format!("cannot parse {}: {err}", path.display()))?; + missions.push(mission); + } + Ok(missions) +} + +pub fn append_event(mission_id: &str, event_type: &str, payload: Value) -> Result<(), String> { + let path = paths::mission_events_file(mission_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + let event = json!({ + "timestamp": now_utc(), + "type": event_type, + "payload": payload, + }); + append_json_line(&path, &event) +} + +#[allow(dead_code)] +pub fn write_artifact(mission_id: &str, name: &str, payload: &Value) -> Result { + let artifact_dir = paths::mission_artifacts_dir(mission_id); + fs::create_dir_all(&artifact_dir) + .map_err(|err| format!("cannot create {}: {err}", artifact_dir.display()))?; + let path = artifact_dir.join(name); + let text = serde_json::to_string_pretty(payload) + .map_err(|err| format!("cannot encode artifact {name}: {err}"))?; + fs::write(&path, format!("{text}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display()))?; + Ok(path.display().to_string()) +} + +pub fn mission_summary_value(mission: &Mission) -> Value { + json!({ + "mission_id": mission.mission_id, + "title": mission.title, + "status": mission.status, + "workspace": mission.workspace, + "steps": mission.steps.iter().map(step_summary_value).collect::>(), + }) +} + +pub fn step_summary_value(step: &MissionStep) -> Value { + json!({ + "step_id": step.step_id, + "status": step.status, + "machine": step.machine, + "backend": step.backend, + "cli": step.cli, + "agent": step.agent, + "agent_role": step.agent_role, + "skill": step.skill, + "skill_summary": step.skill_summary, + "attempt": step.attempt, + }) +} + +#[allow(dead_code)] +pub fn first_active_step(mission: &Mission) -> Option<&MissionStep> { + mission + .steps + .iter() + .find(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human")) +} + +#[allow(dead_code)] +pub fn first_active_step_mut(mission: &mut Mission) -> Option<&mut MissionStep> { + mission + .steps + .iter_mut() + .find(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human")) +} + +pub fn mission_events_text(mission_id: &str) -> Result { + let path = paths::mission_events_file(mission_id); + fs::read_to_string(&path).map_err(|err| format!("cannot read {}: {err}", path.display())) +} + +fn append_json_line(path: &std::path::Path, value: &Value) -> Result<(), String> { + let mut handle = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|err| format!("cannot open {}: {err}", path.display()))?; + let line = + serde_json::to_string(value).map_err(|err| format!("cannot encode JSON line: {err}"))?; + handle + .write_all(format!("{line}\n").as_bytes()) + .map_err(|err| format!("cannot append {}: {err}", path.display())) +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..06a6916 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,490 @@ +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; +use serde_json::Value; + +use crate::cockpit::{ROLES, capture_pane, focus_machine, restart_pane, runtime_status}; +use crate::state::{list_missions, mission_summary_value}; + +pub enum TuiAction { + Exit, + OpenCockpit, +} + +struct App { + workspace: String, + local_session: String, + machine_session: String, + selected_mission: usize, + selected_machine: usize, + selected_role: usize, + missions: Vec, + runtime: Option, + flash: String, + flash_until: Instant, + capture: Option, + capture_title: String, + last_refresh: Instant, +} + +const HEXAPUS_FRAMES: &[&[&str]] = &[ + &[ + " .-====-.", + " .-' .--. `-.", + " / ( oo ) \\", + " | \\/\\/ |", + " | .-____-. |", + " \\__/_/ || \\_\\__/", + " /_ /||\\ _\\", + " <_____/ || \\_____>", + ], + &[ + " .-====-.", + " .-' .--. `-.", + " / ( -- ) \\", + " | \\/\\/ |", + " | .-____-. |", + " _/\\_/ / || \\ \\_/\\_", + "/_ _\\/ || \\/_ _\\", + " <_____/||\\_____>", + ], + &[ + " .-====-.", + " .-' .--. `-.", + " / ( xx ) \\", + " | \\/\\/ |", + " | .-____-. |", + " \\__/__/||\\__\\__/", + " _\\_ /||\\ _/_", + " <_____/ || \\_____>", + ], +]; + +impl App { + fn new(workspace: String, local_session: String, machine_session: String) -> Self { + Self { + workspace, + local_session, + machine_session, + selected_mission: 0, + selected_machine: 0, + selected_role: 1, + missions: Vec::new(), + runtime: None, + flash: String::new(), + flash_until: Instant::now(), + capture: None, + capture_title: String::new(), + last_refresh: Instant::now() - Duration::from_secs(10), + } + } + + fn refresh(&mut self) { + if self.last_refresh.elapsed() < Duration::from_millis(700) { + return; + } + self.missions = list_missions() + .unwrap_or_default() + .into_iter() + .map(|mission| mission_summary_value(&mission)) + .collect(); + self.runtime = runtime_status(&self.local_session, &self.machine_session).ok(); + if self.selected_mission >= self.missions.len() { + self.selected_mission = self.missions.len().saturating_sub(1); + } + if let Some(runtime) = &self.runtime { + if self.selected_machine >= runtime.machines.len() { + self.selected_machine = runtime.machines.len().saturating_sub(1); + } + } else { + self.selected_machine = 0; + } + self.last_refresh = Instant::now(); + } + + fn flash(&mut self, message: impl Into) { + self.flash = message.into(); + self.flash_until = Instant::now() + Duration::from_secs(3); + } + + fn clear_flash_if_needed(&mut self) { + if Instant::now() >= self.flash_until { + self.flash.clear(); + } + } +} + +pub fn run_tui( + workspace: String, + local_session: String, + machine_session: String, +) -> Result { + enable_raw_mode().map_err(|err| format!("cannot enable raw mode: {err}"))?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen) + .map_err(|err| format!("cannot enter alternate screen: {err}"))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = + Terminal::new(backend).map_err(|err| format!("cannot create terminal: {err}"))?; + + let mut app = App::new(workspace, local_session, machine_session); + let result = run_loop(&mut terminal, &mut app); + + disable_raw_mode().ok(); + execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); + terminal.show_cursor().ok(); + + result +} + +fn run_loop( + terminal: &mut Terminal>, + app: &mut App, +) -> Result { + loop { + app.refresh(); + app.clear_flash_if_needed(); + terminal + .draw(|frame| draw(frame, app)) + .map_err(|err| format!("cannot draw TUI: {err}"))?; + + if event::poll(Duration::from_millis(120)) + .map_err(|err| format!("event poll failed: {err}"))? + { + let Event::Key(key) = + event::read().map_err(|err| format!("event read failed: {err}"))? + else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') => return Ok(TuiAction::Exit), + KeyCode::Char('j') => { + if app.selected_mission + 1 < app.missions.len() { + app.selected_mission += 1; + } + } + KeyCode::Char('k') => { + app.selected_mission = app.selected_mission.saturating_sub(1); + } + KeyCode::Char(']') => { + if let Some(runtime) = &app.runtime { + if app.selected_machine + 1 < runtime.machines.len() { + app.selected_machine += 1; + } + } + } + KeyCode::Char('[') => { + app.selected_machine = app.selected_machine.saturating_sub(1); + } + KeyCode::Char('1') => app.selected_role = 0, + KeyCode::Char('2') => app.selected_role = 1, + KeyCode::Char('3') => app.selected_role = 2, + KeyCode::Char('4') => app.selected_role = 3, + KeyCode::Char('o') => { + if let Some(machine) = selected_machine(app) { + let role = ROLES[app.selected_role]; + match focus_machine( + &machine.label, + Some(role), + &app.local_session, + &app.machine_session, + ) { + Ok(_) => app.flash(format!("focused {}:{}", machine.label, role)), + Err(err) => app.flash(format!("focus failed: {err}")), + } + } + } + KeyCode::Char('r') => { + if let Some(machine) = selected_machine(app) { + let role = ROLES[app.selected_role]; + match restart_pane(&machine.label, role, &app.machine_session) { + Ok(result) if result.returncode == 0 => { + app.flash(format!("restart sent to {}:{}", machine.label, role)) + } + Ok(result) => app + .flash(format!("restart failed: {}", compact_err(&result.stderr))), + Err(err) => app.flash(format!("restart failed: {err}")), + } + } + } + KeyCode::Char('x') => { + if app.capture.is_some() { + app.capture = None; + app.capture_title.clear(); + } else if let Some(machine) = selected_machine(app) { + let machine_label = machine.label.clone(); + let role = ROLES[app.selected_role]; + match capture_pane(&machine_label, role, 80, &app.machine_session) { + Ok(result) if result.returncode == 0 => { + app.capture = Some(result.stdout); + app.capture_title = format!("{}:{}", machine_label, role); + } + Ok(result) => app + .flash(format!("capture failed: {}", compact_err(&result.stderr))), + Err(err) => app.flash(format!("capture failed: {err}")), + } + } + } + KeyCode::Esc => { + app.capture = None; + app.capture_title.clear(); + } + KeyCode::Char('z') => return Ok(TuiAction::OpenCockpit), + _ => {} + } + } + } +} + +fn selected_machine(app: &App) -> Option<&crate::cockpit::MachineRuntimeStatus> { + app.runtime.as_ref()?.machines.get(app.selected_machine) +} + +fn draw(frame: &mut ratatui::Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(frame.area()); + + draw_header(frame, chunks[0], app); + + let main = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(28), + Constraint::Percentage(48), + Constraint::Percentage(24), + ]) + .split(chunks[1]); + + draw_missions(frame, main[0], app); + if app.capture.is_some() { + draw_capture(frame, main[1], app); + } else { + draw_runtime(frame, main[1], app); + } + draw_hexapus(frame, main[2], app); + draw_footer(frame, chunks[2], app); +} + +fn draw_header(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let machine_text = if let Some(machine) = selected_machine(app) { + format!("{} / {}", machine.label, ROLES[app.selected_role]) + } else { + "no machine".to_string() + }; + let text = Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + " CONSTANT ", + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(app.workspace.as_str(), Style::default().fg(Color::Yellow)), + ]), + Line::from(vec![ + Span::raw("fleet="), + Span::styled( + app.local_session.as_str(), + Style::default().fg(Color::Green), + ), + Span::raw(" machine="), + Span::styled(machine_text, Style::default().fg(Color::Magenta)), + ]), + ]) + .block(Block::default().borders(Borders::ALL).title("Rust Cockpit")); + frame.render_widget(text, area); +} + +fn draw_missions(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let items = app + .missions + .iter() + .enumerate() + .map(|(index, mission)| { + let selected = index == app.selected_mission; + let title = mission + .get("title") + .and_then(Value::as_str) + .unwrap_or("mission"); + let status = mission + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let style = if selected { + Style::default().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{title} "), style), + Span::styled(format!("[{status}]"), style.fg(Color::Cyan)), + ])) + }) + .collect::>(); + let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Missions")); + frame.render_widget(list, area); +} + +fn draw_runtime(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let mut lines = Vec::new(); + if let Some(runtime) = &app.runtime { + for (index, machine) in runtime.machines.iter().enumerate() { + let selected = index == app.selected_machine; + let prefix = if selected { ">" } else { " " }; + lines.push(Line::from(Span::styled( + format!( + "{prefix} {} [{}]", + machine.label, + if machine.session_exists { "up" } else { "down" } + ), + if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ))); + for (role_index, role) in ROLES.iter().enumerate() { + let marker = if selected && role_index == app.selected_role { + "*" + } else { + " " + }; + let pane = machine.roles.get(*role).and_then(|pane| pane.as_ref()); + let status = match pane { + Some(pane) if pane.dead => "dead", + Some(_) => "live", + None => "missing", + }; + lines.push(Line::from(format!(" {marker} {} {}", role, status))); + } + } + } else { + lines.push(Line::from("No runtime status.")); + } + let paragraph = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Runtime")) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, area); +} + +fn draw_capture(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let capture = app.capture.as_deref().unwrap_or(""); + let paragraph = Paragraph::new(capture) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Capture {}", app.capture_title)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn draw_hexapus(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let tick = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or(0); + let frame_index = (tick / 220 % HEXAPUS_FRAMES.len() as u128) as usize; + let frame_lines = HEXAPUS_FRAMES[frame_index]; + let mut lines = frame_lines + .iter() + .map(|line| Line::from(Span::styled(*line, Style::default().fg(Color::Cyan)))) + .collect::>(); + lines.push(Line::from("")); + if let Some(machine) = selected_machine(app) { + lines.push(Line::from(format!("selected {}", machine.label))); + lines.push(Line::from(format!( + "pane {}", + ROLES[app.selected_role] + ))); + lines.push(Line::from(format!( + "focused {}", + app.runtime + .as_ref() + .and_then(|runtime| runtime.focused_machine.clone()) + .unwrap_or_else(|| "-".to_string()) + ))); + } + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Hexapus Buddy"), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn draw_footer(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let message = if app.flash.is_empty() { + "j/k mission | [/] machine | 1..4 pane | o focus | x capture | r restart | z cockpit | q quit".to_string() + } else { + app.flash.clone() + }; + let paragraph = + Paragraph::new(message).block(Block::default().borders(Borders::ALL).title("Keys")); + frame.render_widget(paragraph, area); + + if let Some(capture) = &app.capture { + let popup = centered_rect(80, 70, area); + frame.render_widget(Clear, popup); + let block = Paragraph::new(capture.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title("Capture view (Esc/x close)"), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(block, popup); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +fn compact_err(text: &str) -> String { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("no details") + .to_string() +}