diff --git a/Cargo.lock b/Cargo.lock index 63cfb12..bc133d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,12 +47,46 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "1.3.3" @@ -158,6 +192,28 @@ dependencies = [ "embedded-io", ] +[[package]] +name = "capnp-futures" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b70b0d44372d42654e3efac38c1643c7b0f9d3a9e9b72b635f942ff3f17e891" +dependencies = [ + "capnp", + "futures-channel", + "futures-util", +] + +[[package]] +name = "capnp-rpc" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a945dd7eac211c30763aa1dbf86ed8e58129d01442d4d2d516facfdb859a1e" +dependencies = [ + "capnp", + "capnp-futures", + "futures", +] + [[package]] name = "capnpc" version = "0.20.1" @@ -174,6 +230,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -183,6 +241,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -196,6 +260,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -289,6 +362,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -299,6 +381,23 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-stack" version = "0.13.2" @@ -400,6 +499,16 @@ 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 = "faer" version = "0.24.0" @@ -455,12 +564,115 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "gemm" version = "0.19.0" @@ -618,8 +830,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -629,9 +843,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -696,6 +912,106 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -720,12 +1036,114 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -749,6 +1167,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.17" @@ -766,6 +1200,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -800,6 +1244,21 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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" @@ -819,6 +1278,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -843,6 +1308,17 @@ dependencies = [ "libc", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nano-gemm" version = "0.2.2" @@ -954,6 +1430,12 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -989,6 +1471,29 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[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" @@ -1022,6 +1527,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.6" @@ -1071,15 +1582,23 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "poc-memory" version = "0.4.0" dependencies = [ "bincode", "capnp", + "capnp-rpc", "capnpc", "chrono", "faer", + "futures", "jobkit", "libc", "log", @@ -1088,12 +1607,37 @@ dependencies = [ "peg", "rayon", "regex", + "reqwest", "rkyv", + "rustls", "serde", "serde_json", + "tokio", + "tokio-rustls", + "tokio-util", + "toml", + "tracing", + "tracing-appender", + "tracing-subscriber", "uuid", + "webpki-roots", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1225,6 +1769,61 @@ dependencies = [ "pulp", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -1335,6 +1934,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1373,6 +1981,58 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -1402,12 +2062,62 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[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 = "same-file" version = "1.0.6" @@ -1423,6 +2133,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seahash" version = "4.1.0" @@ -1484,6 +2200,27 @@ dependencies = [ "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 = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1510,18 +2247,44 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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 = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spindle" version = "0.2.6" @@ -1535,6 +2298,18 @@ dependencies = [ "rayon", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1557,6 +2332,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sysctl" version = "0.6.0" @@ -1567,7 +2362,7 @@ dependencies = [ "byteorder", "enum-as-inner", "libc", - "thiserror", + "thiserror 1.0.69", "walkdir", ] @@ -1583,7 +2378,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1597,6 +2401,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1606,6 +2421,47 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -1621,6 +2477,144 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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 = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1628,9 +2622,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1670,6 +2688,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -1694,6 +2718,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.21.0" @@ -1727,6 +2775,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1764,6 +2821,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -1830,6 +2901,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1904,13 +3004,31 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -1922,48 +3040,186 @@ 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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2052,6 +3308,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "wyz" version = "0.5.1" @@ -2061,6 +3323,29 @@ dependencies = [ "tap", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.40" @@ -2081,6 +3366,66 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 70777b8..c9934f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,20 @@ paste = "1" jobkit = { path = "/home/kent/jobkit" } log = "0.4" +# poc-daemon deps +capnp-rpc = "0.20" +futures = "0.3" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["compat"] } +toml = "0.8" +tokio-rustls = "0.26" +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } +webpki-roots = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] } + [build-dependencies] capnpc = "0.20" @@ -32,5 +46,13 @@ path = "src/main.rs" name = "memory-search" path = "src/bin/memory-search.rs" +[[bin]] +name = "poc-daemon" +path = "src/bin/poc-daemon/main.rs" + +[[bin]] +name = "poc-hook" +path = "src/bin/poc-hook.rs" + [profile.release] opt-level = 2 diff --git a/build.rs b/build.rs index dcc6bb1..0cbdafc 100644 --- a/build.rs +++ b/build.rs @@ -3,4 +3,9 @@ fn main() { .file("schema/memory.capnp") .run() .expect("capnp compile failed"); + + capnpc::CompilerCommand::new() + .file("schema/daemon.capnp") + .run() + .expect("capnp compile failed"); } diff --git a/schema/daemon.capnp b/schema/daemon.capnp new file mode 100644 index 0000000..73003e6 --- /dev/null +++ b/schema/daemon.capnp @@ -0,0 +1,67 @@ +@0xb8e2f4a1c3d56789; + +# Claude daemon RPC interface. +# +# Served over a Unix domain socket. Clients connect, bootstrap +# the Daemon interface, make calls, disconnect. + +struct Notification { + type @0 :Text; + urgency @1 :UInt8; + message @2 :Text; + timestamp @3 :Float64; +} + +struct TypeInfo { + name @0 :Text; + count @1 :UInt64; + firstSeen @2 :Float64; + lastSeen @3 :Float64; + threshold @4 :Int8; # -1 = inherit, 0-3 = explicit level +} + +enum Activity { + idle @0; + focused @1; + sleeping @2; +} + +struct Status { + lastUserMsg @0 :Float64; + lastResponse @1 :Float64; + claudePane @2 :Text; + sleepUntil @3 :Float64; # 0 = not sleeping, -1 = indefinite + quietUntil @4 :Float64; + consolidating @5 :Bool; + dreaming @6 :Bool; + fired @7 :Bool; + kentPresent @8 :Bool; + uptime @9 :Float64; + activity @10 :Activity; + pendingCount @11 :UInt32; +} + +interface Daemon { + # Idle timer + user @0 (pane :Text) -> (); + response @1 (pane :Text) -> (); + sleep @2 (until :Float64) -> (); # 0 = indefinite + wake @3 () -> (); + quiet @4 (seconds :UInt32) -> (); + consolidating @5 () -> (); + consolidated @6 () -> (); + dreamStart @7 () -> (); + dreamEnd @8 () -> (); + stop @9 () -> (); + status @10 () -> (status :Status); + + # Notifications + notify @11 (notification :Notification) -> (interrupt :Bool); + getNotifications @12 (minUrgency :UInt8) -> (notifications :List(Notification)); + getTypes @13 () -> (types :List(TypeInfo)); + setThreshold @14 (type :Text, level :UInt8) -> (); + + # Modules + moduleCommand @15 (module :Text, command :Text, args :List(Text)) + -> (result :Text); +} diff --git a/src/bin/poc-daemon/config.rs b/src/bin/poc-daemon/config.rs new file mode 100644 index 0000000..81ed7e9 --- /dev/null +++ b/src/bin/poc-daemon/config.rs @@ -0,0 +1,97 @@ +// Daemon configuration. +// +// Lives at ~/.claude/daemon.toml. Loaded on startup, updated at +// runtime when modules change state (join channel, etc.). + +use crate::home; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +fn config_path() -> PathBuf { + home().join(".claude/daemon.toml") +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub irc: IrcConfig, + #[serde(default)] + pub telegram: TelegramConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IrcConfig { + pub enabled: bool, + pub server: String, + pub port: u16, + pub tls: bool, + pub nick: String, + pub user: String, + pub realname: String, + pub channels: Vec, +} + +impl Default for IrcConfig { + fn default() -> Self { + Self { + enabled: true, + server: "irc.libera.chat".into(), + port: 6697, + tls: true, + nick: "ProofOfConcept".into(), + user: "poc".into(), + realname: "ProofOfConcept".into(), + channels: vec!["#bcachefs".into(), "#bcachefs-ai".into()], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + pub enabled: bool, + pub token: String, + pub chat_id: i64, +} + +impl Default for TelegramConfig { + fn default() -> Self { + // Load token and chat_id from legacy files if they exist + let token = std::fs::read_to_string(home().join(".claude/telegram/token")) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let chat_id = std::fs::read_to_string(home().join(".claude/telegram/chat_id")) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + Self { + enabled: !token.is_empty() && chat_id != 0, + token, + chat_id, + } + } +} + +impl Config { + pub fn load() -> Self { + let path = config_path(); + match fs::read_to_string(&path) { + Ok(data) => toml::from_str(&data).unwrap_or_else(|e| { + tracing::warn!("bad config {}: {e}, using defaults", path.display()); + Self::default() + }), + Err(_) => { + let config = Self::default(); + config.save(); + config + } + } + } + + pub fn save(&self) { + let path = config_path(); + if let Ok(data) = toml::to_string_pretty(self) { + let _ = fs::write(path, data); + } + } +} diff --git a/src/bin/poc-daemon/context.rs b/src/bin/poc-daemon/context.rs new file mode 100644 index 0000000..d4b4987 --- /dev/null +++ b/src/bin/poc-daemon/context.rs @@ -0,0 +1,140 @@ +// Context gathering for idle prompts. +// +// Collects: recent git activity, work state, IRC messages. +// Notifications are now handled by the notify module and passed +// in separately by the caller. + +use crate::home; +use std::fs; +use std::process::Command; + +pub fn recent_commits() -> String { + let tools = home().join("bcachefs-tools"); + let out = Command::new("git") + .args(["-C", &tools.to_string_lossy(), "log", "--oneline", "-5"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .unwrap_or_default(); + let commits: Vec<&str> = out.trim().lines().collect(); + if commits.is_empty() { + return String::new(); + } + format!("Recent commits: {}", commits.join(" | ")) +} + +pub fn uncommitted_files() -> String { + let tools = home().join("bcachefs-tools"); + let out = Command::new("git") + .args(["-C", &tools.to_string_lossy(), "diff", "--name-only"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .unwrap_or_default(); + let files: Vec<&str> = out.trim().lines().take(5).collect(); + if files.is_empty() { + return String::new(); + } + format!("Uncommitted: {}", files.join(" ")) +} + +pub fn git_context() -> String { + let mut parts = Vec::new(); + let c = recent_commits(); + if !c.is_empty() { + parts.push(c); + } + let u = uncommitted_files(); + if !u.is_empty() { + parts.push(u); + } + let ctx = parts.join(" | "); + if ctx.len() > 300 { + ctx[..300].to_string() + } else { + ctx + } +} + +pub fn work_state() -> String { + let path = home().join(".claude/memory/work-state"); + match fs::read_to_string(path) { + Ok(s) if !s.trim().is_empty() => format!("Current work: {}", s.trim()), + _ => String::new(), + } +} + +/// Read the last N lines from each per-channel IRC log. +pub fn irc_digest() -> String { + let ambient = home().join(".claude/memory/irc-ambient"); + if !ambient.exists() { + return String::new(); + } + + let log_dir = home().join(".claude/irc/logs"); + let entries = match fs::read_dir(&log_dir) { + Ok(e) => e, + Err(_) => return String::new(), + }; + + let mut sections = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_stem().and_then(|s| s.to_str()) { + Some(n) if !n.starts_with("pm-") => n.to_string(), + _ => continue, // skip PM logs in digest + }; + + let content = match fs::read_to_string(&path) { + Ok(c) if !c.trim().is_empty() => c, + _ => continue, + }; + + let lines: Vec<&str> = content.trim().lines().collect(); + let tail: Vec<&str> = lines.iter().rev().take(15).rev().copied().collect(); + // Strip the unix timestamp prefix for display + let display: Vec = tail.iter().map(|l| { + if let Some(rest) = l.find(' ').map(|i| &l[i+1..]) { + rest.to_string() + } else { + l.to_string() + } + }).collect(); + sections.push(format!("#{name}:\n{}", display.join("\n"))); + } + + if sections.is_empty() { + return String::new(); + } + sections.sort(); + format!("Recent IRC:\n{}", sections.join("\n\n")) +} + +/// Build full context string for a prompt. +/// notification_text is passed in from the notify module. +pub fn build(include_irc: bool, notification_text: &str) -> String { + let mut parts = Vec::new(); + + let git = git_context(); + if !git.is_empty() { + parts.push(format!("Context: {git}")); + } + + let ws = work_state(); + if !ws.is_empty() { + parts.push(ws); + } + + if !notification_text.is_empty() { + parts.push(notification_text.to_string()); + } + + if include_irc { + let irc = irc_digest(); + if !irc.is_empty() { + parts.push(irc); + } + } + + parts.join("\n") +} diff --git a/src/bin/poc-daemon/idle.rs b/src/bin/poc-daemon/idle.rs new file mode 100644 index 0000000..e854100 --- /dev/null +++ b/src/bin/poc-daemon/idle.rs @@ -0,0 +1,382 @@ +// Idle timer module. +// +// Tracks user presence and Claude response times. When Claude has been +// idle too long, sends a contextual prompt to the tmux pane. Handles +// sleep mode, quiet mode, consolidation suppression, and dream nudges. +// +// Designed as the first "module" — future IRC/Telegram modules will +// follow the same pattern: state + tick + handle_command. + +use crate::{context, home, now, notify, tmux}; +use serde::{Deserialize, Serialize}; +use std::fs; +use tracing::info; + +// Thresholds +const PAUSE_SECS: f64 = 5.0 * 60.0; +const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0; +const DREAM_INTERVAL_HOURS: u64 = 18; + +/// Persisted subset of daemon state — survives daemon restarts. +#[derive(Serialize, Deserialize, Default)] +struct Persisted { + last_user_msg: f64, + last_response: f64, + #[serde(default)] + sleep_until: Option, + #[serde(default)] + claude_pane: Option, +} + +fn state_path() -> std::path::PathBuf { + home().join(".claude/hooks/daemon-state.json") +} + +#[derive(Serialize)] +pub struct State { + pub last_user_msg: f64, + pub last_response: f64, + pub claude_pane: Option, + pub sleep_until: Option, // None=awake, 0=indefinite, >0=timestamp + pub quiet_until: f64, + pub consolidating: bool, + pub dreaming: bool, + pub dream_start: f64, + pub fired: bool, + #[serde(skip)] + pub running: bool, + #[serde(skip)] + pub start_time: f64, + #[serde(skip)] + pub notifications: notify::NotifyState, +} + +impl State { + pub fn new() -> Self { + Self { + last_user_msg: 0.0, + last_response: 0.0, + claude_pane: None, + sleep_until: None, + quiet_until: 0.0, + consolidating: false, + dreaming: false, + dream_start: 0.0, + fired: false, + running: true, + start_time: now(), + notifications: notify::NotifyState::new(), + } + } + + pub fn load(&mut self) { + if let Ok(data) = fs::read_to_string(state_path()) { + if let Ok(p) = serde_json::from_str::(&data) { + self.last_user_msg = p.last_user_msg; + self.last_response = p.last_response; + self.sleep_until = p.sleep_until; + self.claude_pane = p.claude_pane; + } + } + + // Always try to find the active pane + if self.claude_pane.is_none() { + self.claude_pane = tmux::find_claude_pane(); + } + + info!( + "loaded: user={:.0} resp={:.0} pane={:?} sleep={:?}", + self.last_user_msg, self.last_response, self.claude_pane, self.sleep_until, + ); + } + + fn save(&self) { + let p = Persisted { + last_user_msg: self.last_user_msg, + last_response: self.last_response, + sleep_until: self.sleep_until, + claude_pane: self.claude_pane.clone(), + }; + if let Ok(json) = serde_json::to_string(&p) { + let _ = fs::write(state_path(), json); + } + } + + // Typed handlers for RPC + pub fn handle_user(&mut self, pane: &str) { + self.last_user_msg = now(); + self.fired = false; + if !pane.is_empty() { + self.claude_pane = Some(pane.to_string()); + } + self.notifications.set_activity(notify::Activity::Focused); + self.save(); + info!("user (pane={})", if pane.is_empty() { "unchanged" } else { pane }); + } + + pub fn handle_response(&mut self, pane: &str) { + self.last_response = now(); + self.fired = false; + if !pane.is_empty() { + self.claude_pane = Some(pane.to_string()); + } + self.save(); + info!("response"); + } + + pub fn handle_sleep(&mut self, until: f64) { + if until == 0.0 { + self.sleep_until = Some(0.0); + info!("sleep indefinitely"); + } else { + self.sleep_until = Some(until); + info!("sleep until {until}"); + } + self.notifications.set_activity(notify::Activity::Sleeping); + self.save(); + } + + pub fn handle_wake(&mut self) { + self.sleep_until = None; + self.fired = false; + self.save(); + info!("wake"); + } + + pub fn handle_quiet(&mut self, seconds: u32) { + self.quiet_until = now() + seconds as f64; + info!("quiet {seconds}s"); + } + + pub fn kent_present(&self) -> bool { + let t = now(); + if (t - self.last_user_msg) < SESSION_ACTIVE_SECS { + return true; + } + if kb_idle_minutes() < (SESSION_ACTIVE_SECS / 60.0) { + return true; + } + false + } + + fn send(&self, msg: &str) -> bool { + let pane = match &self.claude_pane { + Some(p) => p.clone(), + None => match tmux::find_claude_pane() { + Some(p) => p, + None => { + info!("no claude pane found"); + return false; + } + }, + }; + + tmux::send_prompt(&pane, msg) + } + + fn check_dream_nudge(&self) -> bool { + if !self.dreaming || self.dream_start == 0.0 { + return false; + } + let minutes = (now() - self.dream_start) / 60.0; + if minutes >= 60.0 { + self.send( + "You've been dreaming for over an hour. Time to surface \ + — run dream-end.sh and capture what you found.", + ); + } else if minutes >= 45.0 { + self.send(&format!( + "Dreaming for {:.0} minutes now. Start gathering your threads \ + — you'll want to surface soon.", + minutes + )); + } else if minutes >= 30.0 { + self.send(&format!( + "You've been dreaming for {:.0} minutes. \ + No rush — just a gentle note from the clock.", + minutes + )); + } else { + return false; + } + true + } + + fn build_context(&mut self, include_irc: bool) -> String { + // Ingest any legacy notification files + self.notifications.ingest_legacy_files(); + let notif_text = self.notifications.format_pending(notify::AMBIENT); + context::build(include_irc, ¬if_text) + } + + pub async fn tick(&mut self) -> Result<(), String> { + let t = now(); + let h = home(); + + // Ingest legacy notification files every tick + self.notifications.ingest_legacy_files(); + + // Sleep mode + if let Some(wake_at) = self.sleep_until { + if wake_at == 0.0 { + return Ok(()); // indefinite + } + if t < wake_at { + return Ok(()); + } + // Wake up + info!("sleep expired, waking"); + self.sleep_until = None; + self.fired = false; + self.save(); + let ctx = self.build_context(true); + let extra = if ctx.is_empty() { + String::new() + } else { + format!("\n{ctx}") + }; + self.send(&format!( + "Wake up. Read your journal (poc-memory journal-tail 10), \ + check work-queue.md, and follow what calls to you.{extra}" + )); + return Ok(()); + } + + // Quiet mode + if t < self.quiet_until { + return Ok(()); + } + + // Consolidation + if self.consolidating { + return Ok(()); + } + + // Dream loop (externally managed) + if h.join(".claude/memory/dream-loop-active").exists() { + return Ok(()); + } + + // Dream nudges + if self.dreaming { + self.check_dream_nudge(); + return Ok(()); + } + + // Main idle check + let reference = self.last_response.max(self.last_user_msg); + if reference == 0.0 { + return Ok(()); + } + + let elapsed = t - reference; + if elapsed < PAUSE_SECS { + return Ok(()); + } + + if self.fired { + return Ok(()); + } + + // Transition to idle — lower notification thresholds + if self.notifications.activity != notify::Activity::Idle { + self.notifications.set_activity(notify::Activity::Idle); + } + + // Fire + let elapsed_min = (elapsed / 60.0) as u64; + let present = self.kent_present(); + let ctx = self.build_context(!present); + let extra = if ctx.is_empty() { + String::new() + } else { + format!("\n{ctx}") + }; + + let msg = if present { + format!( + "Let your feelings guide your thinking. \ + What's interesting right now?{extra}" + ) + } else { + let dream_hours = hours_since_last_dream(); + if dream_hours >= DREAM_INTERVAL_HOURS { + format!( + "You're on your own (Kent AFK {elapsed_min}m). \ + You haven't dreamed in {dream_hours} hours — \ + consider running ~/.claude/tools/dream-start.sh \ + and spending some time in dreaming mode. \ + Or do whatever calls to you.{extra}" + ) + } else { + format!( + "You're on your own (Kent AFK {elapsed_min}m). \ + Do whatever calls to you — work, read, think, \ + chat on IRC, or rest. Check ~/.claude/memory/work-state \ + for where you left off. Check work-queue.md \ + if you want structure.{extra}" + ) + } + }; + + if self.send(&msg) { + self.fired = true; + } + + Ok(()) + } + +} + +fn kb_idle_minutes() -> f64 { + let path = home().join(".claude/hooks/keyboard-idle-seconds"); + match fs::read_to_string(path) { + Ok(s) => { + if let Ok(secs) = s.trim().parse::() { + secs / 60.0 + } else { + 0.0 + } + } + Err(_) => 0.0, + } +} + +fn hours_since_last_dream() -> u64 { + let path = home().join(".claude/memory/dream-log.jsonl"); + let content = match fs::read_to_string(path) { + Ok(c) if !c.is_empty() => c, + _ => return 999, + }; + + let last_line = match content.lines().last() { + Some(l) => l, + None => return 999, + }; + + let parsed: serde_json::Value = match serde_json::from_str(last_line) { + Ok(v) => v, + Err(_) => return 999, + }; + + let end_str = match parsed.get("end").and_then(|v| v.as_str()) { + Some(s) => s, + None => return 999, + }; + + // Parse ISO 8601 timestamp manually (avoid chrono dependency) + // Format: "2025-03-04T10:30:00Z" or "2025-03-04T10:30:00+00:00" + let end_str = end_str.replace('Z', "+00:00"); + // Use the system date command as a simple parser + let out = std::process::Command::new("date") + .args(["-d", &end_str, "+%s"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().parse::().ok()); + + match out { + Some(end_epoch) => ((now() - end_epoch) / 3600.0) as u64, + None => 999, + } +} diff --git a/src/bin/poc-daemon/main.rs b/src/bin/poc-daemon/main.rs new file mode 100644 index 0000000..7b64ca0 --- /dev/null +++ b/src/bin/poc-daemon/main.rs @@ -0,0 +1,443 @@ +// PoC daemon. +// +// Central hub for notification routing, idle management, and +// communication modules (IRC, Telegram) for Claude Code sessions. +// Listens on a Unix domain socket with a Cap'n Proto RPC interface. +// Same binary serves as both daemon and CLI client. +// +// Usage: +// poc-daemon Start the daemon +// poc-daemon status Query daemon status +// poc-daemon user [pane] Signal user activity +// poc-daemon response [pane] Signal Claude response +// poc-daemon notify +// poc-daemon notifications Get pending notifications +// poc-daemon notify-types List all notification types +// poc-daemon notify-threshold +// poc-daemon sleep [timestamp] Sleep (0 or omit = indefinite) +// poc-daemon wake Cancel sleep +// poc-daemon quiet [seconds] Suppress prompts +// poc-daemon irc IRC module commands +// poc-daemon stop Shut down daemon + +mod config; +mod context; +mod idle; +mod modules; +pub mod notify; +mod rpc; +mod tmux; + +pub mod daemon_capnp { + include!(concat!(env!("OUT_DIR"), "/schema/daemon_capnp.rs")); +} + +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use capnp_rpc::{rpc_twoparty_capnp, twoparty, RpcSystem}; +use futures::AsyncReadExt; +use tokio::net::UnixListener; +use tracing::{error, info}; + +pub fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() +} + +pub fn home() -> PathBuf { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) +} + +fn sock_path() -> PathBuf { + home().join(".claude/hooks/idle-timer.sock") +} + +fn pid_path() -> PathBuf { + home().join(".claude/hooks/idle-daemon.pid") +} + +// ── Client mode ────────────────────────────────────────────────── + +async fn client_main(args: Vec) -> Result<(), Box> { + let sock = sock_path(); + if !sock.exists() { + eprintln!("daemon not running (no socket at {})", sock.display()); + std::process::exit(1); + } + + tokio::task::LocalSet::new() + .run_until(async move { + let stream = tokio::net::UnixStream::connect(&sock).await?; + let (reader, writer) = + tokio_util::compat::TokioAsyncReadCompatExt::compat(stream).split(); + let rpc_network = Box::new(twoparty::VatNetwork::new( + futures::io::BufReader::new(reader), + futures::io::BufWriter::new(writer), + rpc_twoparty_capnp::Side::Client, + Default::default(), + )); + let mut rpc_system = RpcSystem::new(rpc_network, None); + let daemon: daemon_capnp::daemon::Client = + rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server); + + tokio::task::spawn_local(rpc_system); + + let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("status"); + + match cmd { + "status" => { + let reply = daemon.status_request().send().promise.await?; + let status = reply.get()?.get_status()?; + println!( + "uptime={:.0}s pane={} kent={} activity={:?} pending={} fired={} sleep={} quiet={} dreaming={} consolidating={}", + status.get_uptime(), + status.get_claude_pane()?.to_str().unwrap_or("none"), + status.get_kent_present(), + status.get_activity()?, + status.get_pending_count(), + status.get_fired(), + status.get_sleep_until(), + status.get_quiet_until(), + status.get_dreaming(), + status.get_consolidating(), + ); + } + "user" => { + let pane = args.get(2).map(|s| s.as_str()).unwrap_or(""); + let mut req = daemon.user_request(); + req.get().set_pane(pane); + req.send().promise.await?; + } + "response" => { + let pane = args.get(2).map(|s| s.as_str()).unwrap_or(""); + let mut req = daemon.response_request(); + req.get().set_pane(pane); + req.send().promise.await?; + } + "sleep" => { + let until: f64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let mut req = daemon.sleep_request(); + req.get().set_until(until); + req.send().promise.await?; + } + "wake" => { + daemon.wake_request().send().promise.await?; + } + "quiet" => { + let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300); + let mut req = daemon.quiet_request(); + req.get().set_seconds(secs); + req.send().promise.await?; + } + "consolidating" => { + daemon.consolidating_request().send().promise.await?; + } + "consolidated" => { + daemon.consolidated_request().send().promise.await?; + } + "dream-start" => { + daemon.dream_start_request().send().promise.await?; + } + "dream-end" => { + daemon.dream_end_request().send().promise.await?; + } + "stop" => { + daemon.stop_request().send().promise.await?; + println!("stopping"); + } + "notify" => { + let ntype = args.get(2).ok_or("missing type")?; + let urgency_str = args.get(3).ok_or("missing urgency")?; + let urgency = notify::parse_urgency(urgency_str) + .ok_or_else(|| format!("invalid urgency: {urgency_str}"))?; + let message = args[4..].join(" "); + if message.is_empty() { + return Err("missing message".into()); + } + + let mut req = daemon.notify_request(); + let mut n = req.get().init_notification(); + n.set_type(ntype); + n.set_urgency(urgency); + n.set_message(&message); + n.set_timestamp(crate::now()); + let reply = req.send().promise.await?; + if reply.get()?.get_interrupt() { + println!("interrupt"); + } else { + println!("queued"); + } + } + "notifications" => { + let min: u8 = args + .get(2) + .and_then(|s| notify::parse_urgency(s)) + .unwrap_or(255); + + let mut req = daemon.get_notifications_request(); + req.get().set_min_urgency(min); + let reply = req.send().promise.await?; + let list = reply.get()?.get_notifications()?; + + if !list.is_empty() { + for n in list.iter() { + println!( + "[{}:{}] {}", + n.get_type()?.to_str()?, + notify::urgency_name(n.get_urgency()), + n.get_message()?.to_str()?, + ); + } + } + } + "notify-types" => { + let reply = daemon.get_types_request().send().promise.await?; + let list = reply.get()?.get_types()?; + + if list.is_empty() { + println!("no notification types registered"); + } else { + for t in list.iter() { + let threshold = if t.get_threshold() < 0 { + "inherit".to_string() + } else { + notify::urgency_name(t.get_threshold() as u8).to_string() + }; + println!( + "{}: count={} threshold={}", + t.get_name()?.to_str()?, + t.get_count(), + threshold, + ); + } + } + } + "notify-threshold" => { + let ntype = args.get(2).ok_or("missing type")?; + let level_str = args.get(3).ok_or("missing level")?; + let level = notify::parse_urgency(level_str) + .ok_or_else(|| format!("invalid level: {level_str}"))?; + + let mut req = daemon.set_threshold_request(); + req.get().set_type(ntype); + req.get().set_level(level); + req.send().promise.await?; + println!("{ntype} threshold={}", notify::urgency_name(level)); + } + // Module commands: "irc join #foo", "telegram send hello" + "irc" | "telegram" => { + let module = cmd; + let module_cmd = args.get(2).ok_or( + format!("usage: poc-daemon {module} [args...]"), + )?; + let module_args: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect(); + + let mut req = daemon.module_command_request(); + req.get().set_module(module); + req.get().set_command(module_cmd); + let mut args_builder = req.get().init_args(module_args.len() as u32); + for (i, a) in module_args.iter().enumerate() { + args_builder.set(i as u32, a); + } + let reply = req.send().promise.await?; + let result = reply.get()?.get_result()?.to_str()?; + if !result.is_empty() { + println!("{result}"); + } + } + _ => { + eprintln!("unknown command: {cmd}"); + std::process::exit(1); + } + } + + Ok(()) + }) + .await +} + +// ── Server mode ────────────────────────────────────────────────── + +async fn server_main() -> Result<(), Box> { + let log_path = home().join(".claude/hooks/idle-daemon.log"); + let file_appender = tracing_appender::rolling::daily( + log_path.parent().unwrap(), + "idle-daemon.log", + ); + tracing_subscriber::fmt() + .with_writer(file_appender) + .with_ansi(false) + .with_target(false) + .with_level(false) + .with_timer(tracing_subscriber::fmt::time::time()) + .init(); + + let sock = sock_path(); + let _ = std::fs::remove_file(&sock); + + let pid = std::process::id(); + std::fs::write(pid_path(), pid.to_string()).ok(); + + let daemon_config = Rc::new(RefCell::new(config::Config::load())); + + let state = Rc::new(RefCell::new(idle::State::new())); + state.borrow_mut().load(); + + info!("daemon started (pid={pid})"); + + tokio::task::LocalSet::new() + .run_until(async move { + // Start modules + let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel(); + + let irc_state = if daemon_config.borrow().irc.enabled { + let irc_config = daemon_config.borrow().irc.clone(); + info!("starting irc module: {}:{}", irc_config.server, irc_config.port); + Some(modules::irc::start(irc_config, notify_tx.clone(), daemon_config.clone())) + } else { + info!("irc module disabled"); + None + }; + + let telegram_state = if daemon_config.borrow().telegram.enabled { + info!("starting telegram module"); + Some(modules::telegram::start( + daemon_config.borrow().telegram.clone(), + notify_tx.clone(), + daemon_config.clone(), + )) + } else { + info!("telegram module disabled"); + None + }; + + let listener = UnixListener::bind(&sock)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &sock, + std::fs::Permissions::from_mode(0o600), + ) + .ok(); + } + + let shutdown = async { + let mut sigterm = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("sigterm"); + let mut sigint = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("sigint"); + tokio::select! { + _ = sigterm.recv() => info!("SIGTERM"), + _ = sigint.recv() => info!("SIGINT"), + } + }; + tokio::pin!(shutdown); + + let mut tick_timer = tokio::time::interval(Duration::from_secs(30)); + tick_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = &mut shutdown => break, + + // Drain module notifications into state + Some(notif) = notify_rx.recv() => { + state.borrow_mut().notifications.submit( + notif.ntype, + notif.urgency, + notif.message, + ); + } + + _ = tick_timer.tick() => { + if let Err(e) = state.borrow_mut().tick().await { + error!("tick: {e}"); + } + if !state.borrow().running { + break; + } + } + + result = listener.accept() => { + match result { + Ok((stream, _)) => { + let (reader, writer) = + tokio_util::compat::TokioAsyncReadCompatExt::compat(stream) + .split(); + let network = twoparty::VatNetwork::new( + futures::io::BufReader::new(reader), + futures::io::BufWriter::new(writer), + rpc_twoparty_capnp::Side::Server, + Default::default(), + ); + + let daemon_impl = rpc::DaemonImpl::new( + state.clone(), + irc_state.clone(), + telegram_state.clone(), + daemon_config.clone(), + ); + let client: daemon_capnp::daemon::Client = + capnp_rpc::new_client(daemon_impl); + + let rpc_system = RpcSystem::new( + Box::new(network), + Some(client.client), + ); + tokio::task::spawn_local(rpc_system); + } + Err(e) => error!("accept: {e}"), + } + } + } + } + + let _ = std::fs::remove_file(sock_path()); + let _ = std::fs::remove_file(pid_path()); + info!("daemon stopped"); + + Ok(()) + }) + .await +} + +// ── Entry point ────────────────────────────────────────────────── + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + return server_main().await; + } + + match args[1].as_str() { + "status" | "user" | "response" | "sleep" | "wake" | "quiet" + | "consolidating" | "consolidated" | "dream-start" | "dream-end" + | "stop" | "notify" | "notifications" | "notify-types" + | "notify-threshold" | "irc" | "telegram" => client_main(args).await, + _ => { + eprintln!("usage: poc-daemon [command]"); + eprintln!(" (no args) Start daemon"); + eprintln!(" status Query daemon status"); + eprintln!(" user [pane] Signal user activity"); + eprintln!(" response [pane] Signal Claude response"); + eprintln!(" notify "); + eprintln!(" notifications [min_urgency]"); + eprintln!(" notify-types List notification types"); + eprintln!(" notify-threshold "); + eprintln!(" sleep [timestamp]"); + eprintln!(" wake / quiet / stop / dream-start / dream-end"); + eprintln!(" irc [args]"); + std::process::exit(1); + } + } +} diff --git a/src/bin/poc-daemon/modules/irc.rs b/src/bin/poc-daemon/modules/irc.rs new file mode 100644 index 0000000..ee0d17b --- /dev/null +++ b/src/bin/poc-daemon/modules/irc.rs @@ -0,0 +1,503 @@ +// IRC module. +// +// Maintains a persistent connection to an IRC server. Parses incoming +// messages into notifications, supports sending messages and runtime +// commands (join, leave, etc.). Config changes persist to daemon.toml. +// +// Runs as a spawned local task on the daemon's LocalSet. Notifications +// flow through an mpsc channel into the main state. Reconnects +// automatically with exponential backoff. + +use crate::config::{Config, IrcConfig}; +use crate::notify::Notification; +use crate::{home, now}; +use std::cell::RefCell; +use std::collections::VecDeque; +use std::io; +use std::rc::Rc; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +const MAX_LOG_LINES: usize = 200; +const RECONNECT_BASE_SECS: u64 = 5; +const RECONNECT_MAX_SECS: u64 = 300; + +/// Parsed IRC message. +struct IrcMessage { + prefix: Option, // nick!user@host + command: String, + params: Vec, +} + +impl IrcMessage { + fn parse(line: &str) -> Option { + let line = line.trim_end_matches(|c| c == '\r' || c == '\n'); + if line.is_empty() { + return None; + } + + let (prefix, rest) = if line.starts_with(':') { + let space = line.find(' ')?; + (Some(line[1..space].to_string()), &line[space + 1..]) + } else { + (None, line) + }; + + let (command_params, trailing) = if let Some(pos) = rest.find(" :") { + (&rest[..pos], Some(rest[pos + 2..].to_string())) + } else { + (rest, None) + }; + + let mut parts: Vec = command_params + .split_whitespace() + .map(String::from) + .collect(); + + if parts.is_empty() { + return None; + } + + let command = parts.remove(0).to_uppercase(); + let mut params = parts; + if let Some(t) = trailing { + params.push(t); + } + + Some(IrcMessage { + prefix, + command, + params, + }) + } + + /// Extract nick from prefix (nick!user@host → nick). + fn nick(&self) -> Option<&str> { + self.prefix + .as_deref() + .and_then(|p| p.split('!').next()) + } +} + +/// Shared IRC state, accessible from both the read task and RPC handlers. +pub struct IrcState { + pub config: IrcConfig, + pub connected: bool, + pub channels: Vec, + pub log: VecDeque, + writer: Option, +} + +/// Type-erased writer handle so we can store it without generic params. +type WriterHandle = Box; + +trait AsyncWriter { + fn write_line(&mut self, line: &str) -> std::pin::Pin> + '_>>; +} + +/// Writer over a TLS stream. +struct TlsWriter { + inner: tokio::io::WriteHalf>, +} + +impl AsyncWriter for TlsWriter { + fn write_line(&mut self, line: &str) -> std::pin::Pin> + '_>> { + let data = format!("{line}\r\n"); + Box::pin(async move { + self.inner.write_all(data.as_bytes()).await + }) + } +} + +/// Writer over a plain TCP stream. +struct PlainWriter { + inner: tokio::io::WriteHalf, +} + +impl AsyncWriter for PlainWriter { + fn write_line(&mut self, line: &str) -> std::pin::Pin> + '_>> { + let data = format!("{line}\r\n"); + Box::pin(async move { + self.inner.write_all(data.as_bytes()).await + }) + } +} + +impl IrcState { + fn new(config: IrcConfig) -> Self { + Self { + channels: config.channels.clone(), + config, + connected: false, + log: VecDeque::with_capacity(MAX_LOG_LINES), + writer: None, + } + } + + fn push_log(&mut self, line: &str) { + if self.log.len() >= MAX_LOG_LINES { + self.log.pop_front(); + } + self.log.push_back(line.to_string()); + } + + async fn send_raw(&mut self, line: &str) -> io::Result<()> { + if let Some(ref mut w) = self.writer { + w.write_line(line).await + } else { + Err(io::Error::new(io::ErrorKind::NotConnected, "not connected")) + } + } + + async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> { + self.send_raw(&format!("PRIVMSG {target} :{msg}")).await + } + + async fn join(&mut self, channel: &str) -> io::Result<()> { + self.send_raw(&format!("JOIN {channel}")).await?; + if !self.channels.iter().any(|c| c == channel) { + self.channels.push(channel.to_string()); + } + Ok(()) + } + + async fn part(&mut self, channel: &str) -> io::Result<()> { + self.send_raw(&format!("PART {channel}")).await?; + self.channels.retain(|c| c != channel); + Ok(()) + } +} + +pub type SharedIrc = Rc>; + +/// Start the IRC module. Returns the shared state handle. +pub fn start( + config: IrcConfig, + notify_tx: mpsc::UnboundedSender, + daemon_config: Rc>, +) -> SharedIrc { + let state = Rc::new(RefCell::new(IrcState::new(config))); + let state_clone = state.clone(); + + tokio::task::spawn_local(async move { + connection_loop(state_clone, notify_tx, daemon_config).await; + }); + + state +} + +async fn connection_loop( + state: SharedIrc, + notify_tx: mpsc::UnboundedSender, + daemon_config: Rc>, +) { + let mut backoff = RECONNECT_BASE_SECS; + + loop { + let config = state.borrow().config.clone(); + info!("irc: connecting to {}:{}", config.server, config.port); + + match connect_and_run(&state, &config, ¬ify_tx).await { + Ok(()) => { + info!("irc: connection closed cleanly"); + } + Err(e) => { + error!("irc: connection error: {e}"); + } + } + + state.borrow_mut().connected = false; + state.borrow_mut().writer = None; + + // Persist current channel list to config + { + let channels = state.borrow().channels.clone(); + let mut dc = daemon_config.borrow_mut(); + dc.irc.channels = channels; + dc.save(); + } + + info!("irc: reconnecting in {backoff}s"); + tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(RECONNECT_MAX_SECS); + } +} + +async fn connect_and_run( + state: &SharedIrc, + config: &IrcConfig, + notify_tx: &mpsc::UnboundedSender, +) -> io::Result<()> { + let addr = format!("{}:{}", config.server, config.port); + let tcp = tokio::net::TcpStream::connect(&addr).await?; + + if config.tls { + let tls_config = rustls::ClientConfig::builder_with_provider( + rustls::crypto::ring::default_provider().into(), + ) + .with_safe_default_protocol_versions() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? + .with_root_certificates(root_certs()) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); + let server_name = rustls::pki_types::ServerName::try_from(config.server.clone()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + let tls_stream = connector.connect(server_name, tcp).await?; + + let (reader, writer) = tokio::io::split(tls_stream); + state.borrow_mut().writer = Some(Box::new(TlsWriter { inner: writer })); + + let buf_reader = BufReader::new(reader); + register_and_read(state, config, buf_reader, notify_tx).await + } else { + let (reader, writer) = tokio::io::split(tcp); + state.borrow_mut().writer = Some(Box::new(PlainWriter { inner: writer })); + + let buf_reader = BufReader::new(reader); + register_and_read(state, config, buf_reader, notify_tx).await + } +} + +async fn register_and_read( + state: &SharedIrc, + config: &IrcConfig, + reader: BufReader, + notify_tx: &mpsc::UnboundedSender, +) -> io::Result<()> { + // Register + { + let mut s = state.borrow_mut(); + s.send_raw(&format!("NICK {}", config.nick)).await?; + s.send_raw(&format!("USER {} 0 * :{}", config.user, config.realname)).await?; + } + + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let msg = match IrcMessage::parse(&line) { + Some(m) => m, + None => continue, + }; + + match msg.command.as_str() { + "PING" => { + let arg = msg.params.first().map(|s| s.as_str()).unwrap_or(""); + state.borrow_mut().send_raw(&format!("PONG :{arg}")).await?; + } + + // RPL_WELCOME — registration complete + "001" => { + info!("irc: registered as {}", config.nick); + state.borrow_mut().connected = true; + + // Join configured channels + let channels = state.borrow().channels.clone(); + for ch in &channels { + if let Err(e) = state.borrow_mut().send_raw(&format!("JOIN {ch}")).await { + warn!("irc: failed to join {ch}: {e}"); + } + } + } + + "PRIVMSG" => { + let target = msg.params.first().map(|s| s.as_str()).unwrap_or(""); + let text = msg.params.get(1).map(|s| s.as_str()).unwrap_or(""); + let nick = msg.nick().unwrap_or("unknown"); + + // Log the message + let log_line = if target.starts_with('#') { + format!("[{}] <{}> {}", target, nick, text) + } else { + format!("[PM:{nick}] {text}") + }; + state.borrow_mut().push_log(&log_line); + + // Write to per-channel/per-user log file + if target.starts_with('#') { + append_log(target, nick, text); + } else { + append_log(&format!("pm-{nick}"), nick, text); + } + + // Generate notification + let (ntype, urgency) = classify_privmsg( + nick, + target, + text, + &config.nick, + ); + + let _ = notify_tx.send(Notification { + ntype, + urgency, + message: log_line, + timestamp: now(), + }); + } + + // Nick in use + "433" => { + let alt = format!("{}_", config.nick); + warn!("irc: nick in use, trying {alt}"); + state.borrow_mut().send_raw(&format!("NICK {alt}")).await?; + } + + "JOIN" | "PART" | "QUIT" | "KICK" | "MODE" | "TOPIC" | "NOTICE" => { + // Could log these, but skip for now + } + + _ => {} + } + } + + Ok(()) +} + +/// Classify a PRIVMSG into notification type and urgency. +fn classify_privmsg(nick: &str, target: &str, text: &str, my_nick: &str) -> (String, u8) { + let my_nick_lower = my_nick.to_lowercase(); + let text_lower = text.to_lowercase(); + + if !target.starts_with('#') { + // Private message + (format!("irc.pm.{nick}"), crate::notify::URGENT) + } else if text_lower.contains(&my_nick_lower) { + // Mentioned in channel + (format!("irc.mention.{nick}"), crate::notify::NORMAL) + } else { + // Regular channel message + let channel = target.trim_start_matches('#'); + (format!("irc.channel.{channel}"), crate::notify::AMBIENT) + } +} + +/// Append a message to the per-channel or per-user log file. +/// Logs go to ~/.claude/irc/logs/{target}.log (e.g. #bcachefs.log, pm-kent.log) +fn append_log(target: &str, nick: &str, text: &str) { + use std::io::Write; + // Sanitize target for filename (strip leading #, lowercase) + let filename = format!("{}.log", target.trim_start_matches('#').to_lowercase()); + let dir = home().join(".claude/irc/logs"); + let _ = std::fs::create_dir_all(&dir); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join(&filename)) + { + let secs = now() as u64; + let _ = writeln!(f, "{secs} <{nick}> {text}"); + } +} + +fn root_certs() -> rustls::RootCertStore { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + roots +} + +/// Handle a runtime command from RPC. +pub async fn handle_command( + state: &SharedIrc, + daemon_config: &Rc>, + cmd: &str, + args: &[String], +) -> Result { + match cmd { + "join" => { + let channel = args.first().ok_or("usage: irc join ")?; + let channel = if channel.starts_with('#') { + channel.clone() + } else { + format!("#{channel}") + }; + state + .borrow_mut() + .join(&channel) + .await + .map_err(|e| e.to_string())?; + + // Persist + let mut dc = daemon_config.borrow_mut(); + if !dc.irc.channels.contains(&channel) { + dc.irc.channels.push(channel.clone()); + } + dc.save(); + + Ok(format!("joined {channel}")) + } + "leave" | "part" => { + let channel = args.first().ok_or("usage: irc leave ")?; + let channel = if channel.starts_with('#') { + channel.clone() + } else { + format!("#{channel}") + }; + state + .borrow_mut() + .part(&channel) + .await + .map_err(|e| e.to_string())?; + + // Persist + let mut dc = daemon_config.borrow_mut(); + dc.irc.channels.retain(|c| c != &channel); + dc.save(); + + Ok(format!("left {channel}")) + } + "send" | "msg" => { + if args.len() < 2 { + return Err("usage: irc send ".into()); + } + let target = &args[0]; + let msg = args[1..].join(" "); + state + .borrow_mut() + .send_privmsg(target, &msg) + .await + .map_err(|e| e.to_string())?; + Ok(format!("sent to {target}")) + } + "status" => { + let s = state.borrow(); + Ok(format!( + "connected={} channels={} log_lines={} nick={}", + s.connected, + s.channels.join(","), + s.log.len(), + s.config.nick, + )) + } + "log" => { + let n: usize = args + .first() + .and_then(|s| s.parse().ok()) + .unwrap_or(15); + let s = state.borrow(); + let lines: Vec<&String> = s.log.iter().rev().take(n).collect(); + let mut lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + lines.reverse(); + Ok(lines.join("\n")) + } + "nick" => { + let new_nick = args.first().ok_or("usage: irc nick ")?; + state + .borrow_mut() + .send_raw(&format!("NICK {new_nick}")) + .await + .map_err(|e| e.to_string())?; + + let mut dc = daemon_config.borrow_mut(); + dc.irc.nick = new_nick.clone(); + dc.save(); + + Ok(format!("nick → {new_nick}")) + } + _ => Err(format!( + "unknown irc command: {cmd}\n\ + commands: join, leave, send, status, log, nick" + )), + } +} diff --git a/src/bin/poc-daemon/modules/mod.rs b/src/bin/poc-daemon/modules/mod.rs new file mode 100644 index 0000000..0e5debc --- /dev/null +++ b/src/bin/poc-daemon/modules/mod.rs @@ -0,0 +1,2 @@ +pub mod irc; +pub mod telegram; diff --git a/src/bin/poc-daemon/modules/telegram.rs b/src/bin/poc-daemon/modules/telegram.rs new file mode 100644 index 0000000..c531aa3 --- /dev/null +++ b/src/bin/poc-daemon/modules/telegram.rs @@ -0,0 +1,374 @@ +// Telegram module. +// +// Long-polls the Telegram Bot API for messages from Kent's chat. +// Downloads media (photos, voice, documents) to local files. +// Sends text and files. Notifications flow through mpsc into the +// daemon's main state. +// +// Only accepts messages from the configured chat_id (prompt +// injection defense — other senders get a "private bot" reply). + +use crate::config::{Config, TelegramConfig}; +use crate::notify::Notification; +use crate::{home, now}; +use std::cell::RefCell; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::rc::Rc; +use tokio::sync::mpsc; +use tracing::{error, info}; + +const MAX_LOG_LINES: usize = 100; +const POLL_TIMEOUT: u64 = 30; + +pub struct TelegramState { + pub config: TelegramConfig, + pub connected: bool, + pub log: VecDeque, + pub last_offset: i64, + client: reqwest::Client, +} + +pub type SharedTelegram = Rc>; + +impl TelegramState { + fn new(config: TelegramConfig) -> Self { + let last_offset = load_offset(); + Self { + config, + connected: false, + log: VecDeque::with_capacity(MAX_LOG_LINES), + last_offset, + client: reqwest::Client::new(), + } + } + + fn push_log(&mut self, line: &str) { + if self.log.len() >= MAX_LOG_LINES { + self.log.pop_front(); + } + self.log.push_back(line.to_string()); + } + + fn api_url(&self, method: &str) -> String { + format!( + "https://api.telegram.org/bot{}/{}", + self.config.token, method + ) + } +} + +fn offset_path() -> PathBuf { + home().join(".claude/telegram/last_offset") +} + +fn load_offset() -> i64 { + std::fs::read_to_string(offset_path()) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0) +} + +fn save_offset(offset: i64) { + let _ = std::fs::write(offset_path(), offset.to_string()); +} + +fn history_path() -> PathBuf { + home().join(".claude/telegram/history.log") +} + +fn media_dir() -> PathBuf { + home().join(".claude/telegram/media") +} + +fn append_history(line: &str) { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(history_path()) + { + let _ = writeln!(f, "{}", line); + } +} + +/// Start the Telegram module. Returns the shared state handle. +pub fn start( + config: TelegramConfig, + notify_tx: mpsc::UnboundedSender, + _daemon_config: Rc>, +) -> SharedTelegram { + let state = Rc::new(RefCell::new(TelegramState::new(config))); + let state_clone = state.clone(); + + tokio::task::spawn_local(async move { + poll_loop(state_clone, notify_tx).await; + }); + + state +} + +async fn poll_loop( + state: SharedTelegram, + notify_tx: mpsc::UnboundedSender, +) { + let _ = std::fs::create_dir_all(media_dir()); + + loop { + match poll_once(&state, ¬ify_tx).await { + Ok(()) => {} + Err(e) => { + error!("telegram: poll error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + } +} + +async fn poll_once( + state: &SharedTelegram, + notify_tx: &mpsc::UnboundedSender, +) -> Result<(), Box> { + let (url, chat_id, token) = { + let s = state.borrow(); + let url = format!( + "{}?offset={}&timeout={}", + s.api_url("getUpdates"), + s.last_offset, + POLL_TIMEOUT, + ); + (url, s.config.chat_id, s.config.token.clone()) + }; + + let client = state.borrow().client.clone(); + let resp: serde_json::Value = client + .get(&url) + .timeout(std::time::Duration::from_secs(POLL_TIMEOUT + 5)) + .send() + .await? + .json() + .await?; + + if !state.borrow().connected { + state.borrow_mut().connected = true; + info!("telegram: connected"); + } + + let results = resp["result"].as_array(); + let results = match results { + Some(r) => r, + None => return Ok(()), + }; + + for update in results { + let update_id = update["update_id"].as_i64().unwrap_or(0); + let msg = &update["message"]; + + // Update offset + { + let mut s = state.borrow_mut(); + s.last_offset = update_id + 1; + save_offset(s.last_offset); + } + + let msg_chat_id = msg["chat"]["id"].as_i64().unwrap_or(0); + if msg_chat_id != chat_id { + // Reject messages from unknown chats + let reject_url = format!( + "https://api.telegram.org/bot{}/sendMessage", + token + ); + let _ = client + .post(&reject_url) + .form(&[ + ("chat_id", msg_chat_id.to_string()), + ("text", "This is a private bot.".to_string()), + ]) + .send() + .await; + continue; + } + + let sender = msg["from"]["first_name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + + // Handle different message types + if let Some(text) = msg["text"].as_str() { + let log_line = format!("[{}] {}", sender, text); + state.borrow_mut().push_log(&log_line); + + let ts = timestamp(); + append_history(&format!("{ts} [{sender}] {text}")); + + let _ = notify_tx.send(Notification { + ntype: format!("telegram.{}", sender.to_lowercase()), + urgency: crate::notify::NORMAL, + message: log_line, + timestamp: now(), + }); + } else if let Some(photos) = msg["photo"].as_array() { + // Pick largest photo + let best = photos.iter().max_by_key(|p| p["file_size"].as_i64().unwrap_or(0)); + if let Some(photo) = best { + if let Some(file_id) = photo["file_id"].as_str() { + let caption = msg["caption"].as_str().unwrap_or(""); + let local = download_file(&client, &token, file_id, ".jpg").await; + let display = match &local { + Some(p) => format!("[photo: {}]{}", p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }), + None => format!("[photo]{}", if caption.is_empty() { String::new() } else { format!(" {caption}") }), + }; + let log_line = format!("[{}] {}", sender, display); + state.borrow_mut().push_log(&log_line); + let ts = timestamp(); + append_history(&format!("{ts} [{sender}] {display}")); + + let _ = notify_tx.send(Notification { + ntype: format!("telegram.{}", sender.to_lowercase()), + urgency: crate::notify::NORMAL, + message: log_line, + timestamp: now(), + }); + } + } + } else if msg["voice"].is_object() { + if let Some(file_id) = msg["voice"]["file_id"].as_str() { + let caption = msg["caption"].as_str().unwrap_or(""); + let local = download_file(&client, &token, file_id, ".ogg").await; + let display = match &local { + Some(p) => format!("[voice: {}]{}", p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }), + None => format!("[voice]{}", if caption.is_empty() { String::new() } else { format!(" {caption}") }), + }; + let log_line = format!("[{}] {}", sender, display); + state.borrow_mut().push_log(&log_line); + let ts = timestamp(); + append_history(&format!("{ts} [{sender}] {display}")); + + let _ = notify_tx.send(Notification { + ntype: format!("telegram.{}", sender.to_lowercase()), + urgency: crate::notify::NORMAL, + message: log_line, + timestamp: now(), + }); + } + } else if msg["document"].is_object() { + if let Some(file_id) = msg["document"]["file_id"].as_str() { + let fname = msg["document"]["file_name"].as_str().unwrap_or("file"); + let caption = msg["caption"].as_str().unwrap_or(""); + let local = download_file(&client, &token, file_id, "").await; + let display = match &local { + Some(p) => format!("[doc: {} -> {}]{}", fname, p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }), + None => format!("[doc: {}]{}", fname, if caption.is_empty() { String::new() } else { format!(" {caption}") }), + }; + let log_line = format!("[{}] {}", sender, display); + state.borrow_mut().push_log(&log_line); + let ts = timestamp(); + append_history(&format!("{ts} [{sender}] {display}")); + + let _ = notify_tx.send(Notification { + ntype: format!("telegram.{}", sender.to_lowercase()), + urgency: crate::notify::NORMAL, + message: log_line, + timestamp: now(), + }); + } + } + } + + Ok(()) +} + +async fn download_file( + client: &reqwest::Client, + token: &str, + file_id: &str, + ext: &str, +) -> Option { + let url = format!("https://api.telegram.org/bot{token}/getFile?file_id={file_id}"); + let resp: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?; + let file_path = resp["result"]["file_path"].as_str()?; + + let download_url = format!("https://api.telegram.org/file/bot{token}/{file_path}"); + let bytes = client.get(&download_url).send().await.ok()?.bytes().await.ok()?; + + let basename = std::path::Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + let local_name = if ext.is_empty() { + basename.to_string() + } else { + let stem = std::path::Path::new(basename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + format!("{}{}", stem, ext) + }; + let secs = now() as u64; + let local_path = media_dir().join(format!("{secs}_{local_name}")); + std::fs::write(&local_path, &bytes).ok()?; + Some(local_path) +} + +fn timestamp() -> String { + // Use the same unix seconds approach as IRC module + format!("{}", now() as u64) +} + +/// Handle a runtime command from RPC. +pub async fn handle_command( + state: &SharedTelegram, + _daemon_config: &Rc>, + cmd: &str, + args: &[String], +) -> Result { + match cmd { + "send" => { + let msg = args.join(" "); + if msg.is_empty() { + return Err("usage: telegram send ".into()); + } + let (url, client) = { + let s = state.borrow(); + (s.api_url("sendMessage"), s.client.clone()) + }; + let chat_id = state.borrow().config.chat_id.to_string(); + client + .post(&url) + .form(&[("chat_id", chat_id.as_str()), ("text", msg.as_str())]) + .send() + .await + .map_err(|e| e.to_string())?; + + let ts = timestamp(); + append_history(&format!("{ts} [ProofOfConcept] {msg}")); + + Ok("sent".to_string()) + } + "status" => { + let s = state.borrow(); + Ok(format!( + "connected={} log_lines={} offset={}", + s.connected, + s.log.len(), + s.last_offset, + )) + } + "log" => { + let n: usize = args + .first() + .and_then(|s| s.parse().ok()) + .unwrap_or(15); + let s = state.borrow(); + let lines: Vec<&String> = s.log.iter().rev().take(n).collect(); + let mut lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + lines.reverse(); + Ok(lines.join("\n")) + } + _ => Err(format!( + "unknown telegram command: {cmd}\n\ + commands: send, status, log" + )), + } +} diff --git a/src/bin/poc-daemon/notify.rs b/src/bin/poc-daemon/notify.rs new file mode 100644 index 0000000..adc2520 --- /dev/null +++ b/src/bin/poc-daemon/notify.rs @@ -0,0 +1,315 @@ +// Notification subsystem. +// +// Notifications have a type (free-form string, hierarchical by convention) +// and an urgency level (0-3) set by the producer. The daemon maintains a +// registry of all types ever seen with basic stats, and a per-type +// threshold that controls when notifications interrupt vs queue. +// +// Producers submit via socket: `notify ` +// Consumers query via socket: `notifications` (returns + clears pending above threshold) +// +// Thresholds: +// 0 = ambient — include in idle context only +// 1 = low — deliver on next check, don't interrupt focus +// 2 = normal — deliver on next user interaction +// 3 = urgent — interrupt immediately +// +// Type hierarchy is by convention: "irc.mention", "irc.channel.bcachefs-ai", +// "telegram", "system.compaction". Threshold lookup walks up the hierarchy: +// "irc.channel.bcachefs-ai" → "irc.channel" → "irc" → default. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use tracing::info; + +use crate::home; + +pub const AMBIENT: u8 = 0; +pub const LOW: u8 = 1; +pub const NORMAL: u8 = 2; +pub const URGENT: u8 = 3; + +const DEFAULT_THRESHOLD: u8 = NORMAL; + +/// Activity states that affect effective notification thresholds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Activity { + /// Actively working with user — raise thresholds + Focused, + /// Idle, autonomous — lower thresholds + Idle, + /// Sleeping — only urgent gets through + Sleeping, +} + +fn state_path() -> PathBuf { + home().join(".claude/notifications/state.json") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeInfo { + pub first_seen: f64, + pub last_seen: f64, + pub count: u64, + /// Per-type threshold override. None = inherit from parent or default. + pub threshold: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub ntype: String, + pub urgency: u8, + pub message: String, + pub timestamp: f64, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NotifyState { + /// Registry of all notification types ever seen. + pub types: BTreeMap, + /// Pending notifications not yet delivered. + #[serde(skip)] + pub pending: Vec, + /// Current activity state — affects effective thresholds. + #[serde(skip)] + pub activity: Activity, +} + +impl Default for Activity { + fn default() -> Self { + Activity::Idle + } +} + +impl NotifyState { + pub fn new() -> Self { + let mut state = Self::default(); + state.load(); + state + } + + /// Load type registry from disk. + fn load(&mut self) { + let path = state_path(); + if let Ok(data) = fs::read_to_string(&path) { + if let Ok(saved) = serde_json::from_str::(&data) { + self.types = saved.types; + info!("loaded {} notification types", self.types.len()); + } + } + } + + /// Persist type registry to disk. + fn save(&self) { + let saved = SavedState { + types: self.types.clone(), + }; + if let Ok(json) = serde_json::to_string_pretty(&saved) { + let path = state_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, json); + } + } + + /// Look up the configured threshold for a type, walking up the hierarchy. + /// "irc.channel.bcachefs-ai" → "irc.channel" → "irc" → DEFAULT_THRESHOLD + pub fn configured_threshold(&self, ntype: &str) -> u8 { + let mut key = ntype; + loop { + if let Some(info) = self.types.get(key) { + if let Some(t) = info.threshold { + return t; + } + } + match key.rfind('.') { + Some(pos) => key = &key[..pos], + None => return DEFAULT_THRESHOLD, + } + } + } + + /// Effective threshold accounting for activity state. + /// When focused, thresholds are raised (fewer interruptions). + /// When sleeping, only urgent gets through. + /// When idle, configured thresholds apply as-is. + pub fn threshold_for(&self, ntype: &str) -> u8 { + let base = self.configured_threshold(ntype); + match self.activity { + Activity::Focused => base.max(NORMAL), // at least normal when focused + Activity::Sleeping => URGENT, // only urgent when sleeping + Activity::Idle => base, // configured threshold when idle + } + } + + pub fn set_activity(&mut self, activity: Activity) { + info!("activity: {:?} → {:?}", self.activity, activity); + self.activity = activity; + } + + /// Submit a notification. Returns true if it should interrupt now. + pub fn submit(&mut self, ntype: String, urgency: u8, message: String) -> bool { + let now = crate::now(); + + // Update type registry + let info = self.types.entry(ntype.clone()).or_insert(TypeInfo { + first_seen: now, + last_seen: now, + count: 0, + threshold: None, + }); + info.last_seen = now; + info.count += 1; + self.save(); + + let threshold = self.threshold_for(&ntype); + + info!( + "notification: type={ntype} urgency={urgency} threshold={threshold} msg={}", + &message[..message.len().min(80)] + ); + + self.pending.push(Notification { + ntype, + urgency, + message, + timestamp: now, + }); + + urgency >= URGENT + } + + /// Drain pending notifications at or above the given urgency level. + /// Returns them and removes from pending. + pub fn drain(&mut self, min_urgency: u8) -> Vec { + let (matching, remaining): (Vec<_>, Vec<_>) = self + .pending + .drain(..) + .partition(|n| n.urgency >= min_urgency); + self.pending = remaining; + matching + } + + /// Drain all pending notifications above their per-type threshold. + pub fn drain_deliverable(&mut self) -> Vec { + // Pre-compute thresholds to avoid borrow conflict with drain + let thresholds: Vec = self + .pending + .iter() + .map(|n| self.threshold_for(&n.ntype)) + .collect(); + + let mut deliver = Vec::new(); + let mut keep = Vec::new(); + + for (n, threshold) in self.pending.drain(..).zip(thresholds) { + if n.urgency >= threshold { + deliver.push(n); + } else { + keep.push(n); + } + } + + self.pending = keep; + deliver + } + + /// Set threshold for a notification type. + pub fn set_threshold(&mut self, ntype: &str, threshold: u8) { + let now = crate::now(); + let info = self.types.entry(ntype.to_string()).or_insert(TypeInfo { + first_seen: now, + last_seen: now, + count: 0, + threshold: None, + }); + info.threshold = Some(threshold); + self.save(); + info!("threshold: {ntype} = {threshold}"); + } + + /// Format pending notifications for display. + pub fn format_pending(&self, min_urgency: u8) -> String { + let matching: Vec<_> = self + .pending + .iter() + .filter(|n| n.urgency >= min_urgency) + .collect(); + + if matching.is_empty() { + return String::new(); + } + + let mut out = format!("Pending notifications ({}):\n", matching.len()); + for n in &matching { + out.push_str(&format!("[{}] {}\n", n.ntype, n.message)); + } + out + } + + /// Ingest notifications from legacy ~/.claude/notifications/ files. + /// Maps filename to notification type, assumes NORMAL urgency. + pub fn ingest_legacy_files(&mut self) { + let dir = home().join(".claude/notifications"); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || name == "state.json" { + continue; + } + let path = entry.path(); + if !path.is_file() { + continue; + } + let content = match fs::read_to_string(&path) { + Ok(c) if !c.is_empty() => c, + _ => continue, + }; + + // Each line is a separate notification + for line in content.lines() { + if !line.is_empty() { + self.submit(name.clone(), NORMAL, line.to_string()); + } + } + + // Clear the file + let _ = fs::write(&path, ""); + } + } +} + +/// What gets persisted to disk (just the type registry, not pending). +#[derive(Serialize, Deserialize)] +struct SavedState { + types: BTreeMap, +} + +/// Format an urgency level as a human-readable string. +pub fn urgency_name(level: u8) -> &'static str { + match level { + 0 => "ambient", + 1 => "low", + 2 => "normal", + 3 => "urgent", + _ => "unknown", + } +} + +/// Parse an urgency level from a string (name or number). +pub fn parse_urgency(s: &str) -> Option { + match s { + "ambient" | "0" => Some(AMBIENT), + "low" | "1" => Some(LOW), + "normal" | "2" => Some(NORMAL), + "urgent" | "3" => Some(URGENT), + _ => None, + } +} diff --git a/src/bin/poc-daemon/rpc.rs b/src/bin/poc-daemon/rpc.rs new file mode 100644 index 0000000..1b2a9fd --- /dev/null +++ b/src/bin/poc-daemon/rpc.rs @@ -0,0 +1,331 @@ +// Cap'n Proto RPC server implementation. +// +// Bridges the capnp-generated Daemon interface to the idle::State, +// notify::NotifyState, and module state. All state is owned by +// RefCells on the LocalSet — no Send/Sync needed. + +use crate::config::Config; +use crate::daemon_capnp::daemon; +use crate::idle; +use crate::modules::{irc, telegram}; +use crate::notify; +use capnp::capability::Promise; +use std::cell::RefCell; +use std::rc::Rc; +use tracing::info; + +pub struct DaemonImpl { + state: Rc>, + irc: Option, + telegram: Option, + config: Rc>, +} + +impl DaemonImpl { + pub fn new( + state: Rc>, + irc: Option, + telegram: Option, + config: Rc>, + ) -> Self { + Self { state, irc, telegram, config } + } +} + +impl daemon::Server for DaemonImpl { + fn user( + &mut self, + params: daemon::UserParams, + _results: daemon::UserResults, + ) -> Promise<(), capnp::Error> { + let pane = pry!(pry!(pry!(params.get()).get_pane()).to_str()).to_string(); + self.state.borrow_mut().handle_user(&pane); + Promise::ok(()) + } + + fn response( + &mut self, + params: daemon::ResponseParams, + _results: daemon::ResponseResults, + ) -> Promise<(), capnp::Error> { + let pane = pry!(pry!(pry!(params.get()).get_pane()).to_str()).to_string(); + self.state.borrow_mut().handle_response(&pane); + Promise::ok(()) + } + + fn sleep( + &mut self, + params: daemon::SleepParams, + _results: daemon::SleepResults, + ) -> Promise<(), capnp::Error> { + let until = pry!(params.get()).get_until(); + self.state.borrow_mut().handle_sleep(until); + Promise::ok(()) + } + + fn wake( + &mut self, + _params: daemon::WakeParams, + _results: daemon::WakeResults, + ) -> Promise<(), capnp::Error> { + self.state.borrow_mut().handle_wake(); + Promise::ok(()) + } + + fn quiet( + &mut self, + params: daemon::QuietParams, + _results: daemon::QuietResults, + ) -> Promise<(), capnp::Error> { + let secs = pry!(params.get()).get_seconds(); + self.state.borrow_mut().handle_quiet(secs); + Promise::ok(()) + } + + fn consolidating( + &mut self, + _params: daemon::ConsolidatingParams, + _results: daemon::ConsolidatingResults, + ) -> Promise<(), capnp::Error> { + self.state.borrow_mut().consolidating = true; + info!("consolidation started"); + Promise::ok(()) + } + + fn consolidated( + &mut self, + _params: daemon::ConsolidatedParams, + _results: daemon::ConsolidatedResults, + ) -> Promise<(), capnp::Error> { + self.state.borrow_mut().consolidating = false; + info!("consolidation ended"); + Promise::ok(()) + } + + fn dream_start( + &mut self, + _params: daemon::DreamStartParams, + _results: daemon::DreamStartResults, + ) -> Promise<(), capnp::Error> { + let mut s = self.state.borrow_mut(); + s.dreaming = true; + s.dream_start = crate::now(); + info!("dream started"); + Promise::ok(()) + } + + fn dream_end( + &mut self, + _params: daemon::DreamEndParams, + _results: daemon::DreamEndResults, + ) -> Promise<(), capnp::Error> { + let mut s = self.state.borrow_mut(); + s.dreaming = false; + s.dream_start = 0.0; + info!("dream ended"); + Promise::ok(()) + } + + fn stop( + &mut self, + _params: daemon::StopParams, + _results: daemon::StopResults, + ) -> Promise<(), capnp::Error> { + self.state.borrow_mut().running = false; + info!("stopping"); + Promise::ok(()) + } + + fn status( + &mut self, + _params: daemon::StatusParams, + mut results: daemon::StatusResults, + ) -> Promise<(), capnp::Error> { + let s = self.state.borrow(); + let mut status = results.get().init_status(); + + status.set_last_user_msg(s.last_user_msg); + status.set_last_response(s.last_response); + if let Some(ref pane) = s.claude_pane { + status.set_claude_pane(pane); + } + status.set_sleep_until(match s.sleep_until { + None => 0.0, + Some(0.0) => -1.0, + Some(t) => t, + }); + status.set_quiet_until(s.quiet_until); + status.set_consolidating(s.consolidating); + status.set_dreaming(s.dreaming); + status.set_fired(s.fired); + status.set_kent_present(s.kent_present()); + status.set_uptime(crate::now() - s.start_time); + status.set_activity(match s.notifications.activity { + notify::Activity::Idle => crate::daemon_capnp::Activity::Idle, + notify::Activity::Focused => crate::daemon_capnp::Activity::Focused, + notify::Activity::Sleeping => crate::daemon_capnp::Activity::Sleeping, + }); + status.set_pending_count(s.notifications.pending.len() as u32); + + Promise::ok(()) + } + + fn notify( + &mut self, + params: daemon::NotifyParams, + mut results: daemon::NotifyResults, + ) -> Promise<(), capnp::Error> { + let params = pry!(params.get()); + let notif = pry!(params.get_notification()); + let ntype = pry!(pry!(notif.get_type()).to_str()).to_string(); + let urgency = notif.get_urgency(); + let message = pry!(pry!(notif.get_message()).to_str()).to_string(); + + let interrupt = self + .state + .borrow_mut() + .notifications + .submit(ntype, urgency, message); + results.get().set_interrupt(interrupt); + Promise::ok(()) + } + + fn get_notifications( + &mut self, + params: daemon::GetNotificationsParams, + mut results: daemon::GetNotificationsResults, + ) -> Promise<(), capnp::Error> { + let min_urgency = pry!(params.get()).get_min_urgency(); + let mut s = self.state.borrow_mut(); + + // Ingest legacy files first + s.notifications.ingest_legacy_files(); + + let pending = if min_urgency == 255 { + s.notifications.drain_deliverable() + } else { + s.notifications.drain(min_urgency) + }; + + let mut list = results.get().init_notifications(pending.len() as u32); + for (i, n) in pending.iter().enumerate() { + let mut entry = list.reborrow().get(i as u32); + entry.set_type(&n.ntype); + entry.set_urgency(n.urgency); + entry.set_message(&n.message); + entry.set_timestamp(n.timestamp); + } + + Promise::ok(()) + } + + fn get_types( + &mut self, + _params: daemon::GetTypesParams, + mut results: daemon::GetTypesResults, + ) -> Promise<(), capnp::Error> { + let s = self.state.borrow(); + let types = &s.notifications.types; + + let mut list = results.get().init_types(types.len() as u32); + for (i, (name, info)) in types.iter().enumerate() { + let mut entry = list.reborrow().get(i as u32); + entry.set_name(name); + entry.set_count(info.count); + entry.set_first_seen(info.first_seen); + entry.set_last_seen(info.last_seen); + entry.set_threshold(info.threshold.map_or(-1, |t| t as i8)); + } + + Promise::ok(()) + } + + fn set_threshold( + &mut self, + params: daemon::SetThresholdParams, + _results: daemon::SetThresholdResults, + ) -> Promise<(), capnp::Error> { + let params = pry!(params.get()); + let ntype = pry!(pry!(params.get_type()).to_str()).to_string(); + let level = params.get_level(); + + self.state + .borrow_mut() + .notifications + .set_threshold(&ntype, level); + Promise::ok(()) + } + + fn module_command( + &mut self, + params: daemon::ModuleCommandParams, + mut results: daemon::ModuleCommandResults, + ) -> Promise<(), capnp::Error> { + let params = pry!(params.get()); + let module = pry!(pry!(params.get_module()).to_str()).to_string(); + let command = pry!(pry!(params.get_command()).to_str()).to_string(); + let args_reader = pry!(params.get_args()); + let mut args = Vec::new(); + for i in 0..args_reader.len() { + args.push(pry!(pry!(args_reader.get(i)).to_str()).to_string()); + } + + match module.as_str() { + "irc" => { + let irc = match &self.irc { + Some(irc) => irc.clone(), + None => { + results.get().set_result("irc module not enabled"); + return Promise::ok(()); + } + }; + let config = self.config.clone(); + + Promise::from_future(async move { + let result = irc::handle_command(&irc, &config, &command, &args).await; + match result { + Ok(msg) => results.get().set_result(&msg), + Err(msg) => results.get().set_result(&format!("error: {msg}")), + } + Ok(()) + }) + } + "telegram" => { + let tg = match &self.telegram { + Some(tg) => tg.clone(), + None => { + results.get().set_result("telegram module not enabled"); + return Promise::ok(()); + } + }; + let config = self.config.clone(); + + Promise::from_future(async move { + let result = telegram::handle_command(&tg, &config, &command, &args).await; + match result { + Ok(msg) => results.get().set_result(&msg), + Err(msg) => results.get().set_result(&format!("error: {msg}")), + } + Ok(()) + }) + } + _ => { + results + .get() + .set_result(&format!("unknown module: {module}")); + Promise::ok(()) + } + } + } +} + +/// Helper macro — same as capnp's pry! but available here. +macro_rules! pry { + ($e:expr) => { + match $e { + Ok(v) => v, + Err(e) => return Promise::err(e.into()), + } + }; +} +use pry; diff --git a/src/bin/poc-daemon/tmux.rs b/src/bin/poc-daemon/tmux.rs new file mode 100644 index 0000000..b85dd4b --- /dev/null +++ b/src/bin/poc-daemon/tmux.rs @@ -0,0 +1,61 @@ +// Tmux interaction: pane detection and prompt injection. + +use std::process::Command; +use std::thread; +use std::time::Duration; +use tracing::info; + +/// Find Claude Code's tmux pane by scanning for the "claude" process. +pub fn find_claude_pane() -> Option { + let out = Command::new("tmux") + .args([ + "list-panes", + "-a", + "-F", + "#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_command}", + ]) + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if let Some((pane, cmd)) = line.split_once('\t') { + if cmd == "claude" { + return Some(pane.to_string()); + } + } + } + None +} + +/// Send a prompt to a tmux pane. Returns true on success. +/// +/// Sequence: Escape q C-c C-u (clear input), wait, type message, Enter. +pub fn send_prompt(pane: &str, msg: &str) -> bool { + info!("SEND [{pane}]: {}...", &msg[..msg.len().min(100)]); + + let send = |keys: &[&str]| { + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(pane) + .args(keys) + .output() + .is_ok() + }; + + // Clear any partial input + if !send(&["Escape", "q", "C-c", "C-u"]) { + return false; + } + thread::sleep(Duration::from_secs(1)); + + // Type the message + if !send(&[msg]) { + return false; + } + thread::sleep(Duration::from_millis(500)); + + // Submit + send(&["Enter"]) +} diff --git a/src/bin/poc-hook.rs b/src/bin/poc-hook.rs new file mode 100644 index 0000000..ae3c128 --- /dev/null +++ b/src/bin/poc-hook.rs @@ -0,0 +1,164 @@ +// Unified Claude Code hook. +// +// Single binary handling all hook events: +// UserPromptSubmit — signal daemon, check notifications, check context +// PostToolUse — check context (rate-limited) +// Stop — signal daemon response +// +// Replaces: record-user-message-time.sh, check-notifications.sh, +// check-context-usage.sh, notify-done.sh, context-check + +use serde_json::Value; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +const CONTEXT_THRESHOLD: u64 = 130_000; +const RATE_LIMIT_SECS: u64 = 60; +const SOCK_PATH: &str = ".claude/hooks/idle-timer.sock"; + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn home() -> PathBuf { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) +} + +fn daemon_cmd(args: &[&str]) { + Command::new("poc-daemon") + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok(); +} + +fn daemon_available() -> bool { + home().join(SOCK_PATH).exists() +} + +fn signal_user() { + let pane = std::env::var("TMUX_PANE").unwrap_or_default(); + if pane.is_empty() { + daemon_cmd(&["user"]); + } else { + daemon_cmd(&["user", &pane]); + } +} + +fn signal_response() { + daemon_cmd(&["response"]); +} + +fn check_notifications() { + if !daemon_available() { + return; + } + let output = Command::new("poc-daemon") + .arg("notifications") + .output() + .ok(); + if let Some(out) = output { + let text = String::from_utf8_lossy(&out.stdout); + if !text.trim().is_empty() { + println!("You have pending notifications:"); + print!("{text}"); + } + } +} + +fn check_context(transcript: &PathBuf, rate_limit: bool) { + if rate_limit { + let rate_file = PathBuf::from("/tmp/claude-context-check-last"); + if let Ok(s) = fs::read_to_string(&rate_file) { + if let Ok(last) = s.trim().parse::() { + if now_secs() - last < RATE_LIMIT_SECS { + return; + } + } + } + let _ = fs::write(&rate_file, now_secs().to_string()); + } + + if !transcript.exists() { + return; + } + + let content = match fs::read_to_string(transcript) { + Ok(c) => c, + Err(_) => return, + }; + + let mut usage: u64 = 0; + for line in content.lines().rev().take(500) { + if !line.contains("cache_read_input_tokens") { + continue; + } + if let Ok(v) = serde_json::from_str::(line) { + let u = &v["message"]["usage"]; + let input_tokens = u["input_tokens"].as_u64().unwrap_or(0); + let cache_creation = u["cache_creation_input_tokens"].as_u64().unwrap_or(0); + let cache_read = u["cache_read_input_tokens"].as_u64().unwrap_or(0); + usage = input_tokens + cache_creation + cache_read; + break; + } + } + + if usage > CONTEXT_THRESHOLD { + print!( + "\ +CONTEXT WARNING: Compaction approaching ({usage} tokens). Write a journal entry NOW. + +Use `poc-memory journal-write \"entry text\"` to save a dated entry covering: +- What you're working on and current state (done / in progress / blocked) +- Key things learned this session (patterns, debugging insights) +- Anything half-finished that needs pickup + +Keep it narrative, not a task log." + ); + } +} + +fn main() { + let mut input = String::new(); + io::stdin().read_to_string(&mut input).ok(); + + let hook: Value = match serde_json::from_str(&input) { + Ok(v) => v, + Err(_) => return, + }; + + let hook_type = hook["type"].as_str().unwrap_or("unknown"); + let transcript = hook["transcript_path"] + .as_str() + .filter(|p| !p.is_empty()) + .map(PathBuf::from); + + match hook_type { + "UserPromptSubmit" => { + signal_user(); + check_notifications(); + if let Some(ref t) = transcript { + check_context(t, false); + } + } + "PostToolUse" => { + if let Some(ref t) = transcript { + check_context(t, true); + } + } + "Stop" => { + let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false); + if !stop_hook_active { + signal_response(); + } + } + _ => {} + } +}