diff --git a/.gitignore b/.gitignore index 1b068a3..badd0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target +firmware/*/target *.db *.db-journal .DS_Store .wt-pr37/ -docker-compose.override.yml +.env diff --git a/AGENTS.md b/AGENTS.md index a6fb171..8ed3a4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Key extension points: - `src/memory/traits.rs` (`Memory`) - `src/observability/traits.rs` (`Observer`) - `src/runtime/traits.rs` (`RuntimeAdapter`) +- `src/peripherals/traits.rs` (`Peripheral`) — hardware boards (STM32, RPi GPIO) ## 2) Deep Architecture Observations (Why This Protocol Exists) @@ -141,7 +142,8 @@ Required: - `src/providers/` — model providers and resilient wrapper - `src/channels/` — Telegram/Discord/Slack/etc channels - `src/tools/` — tool execution surface (shell, file, memory, browser) -- `src/runtime/` — runtime adapters (currently native/docker) +- `src/peripherals/` — hardware peripherals (STM32, RPi GPIO); see `docs/hardware-peripherals-design.md` +- `src/runtime/` — runtime adapters (currently native) - `docs/` — architecture + process docs - `.github/` — CI, templates, automation workflows @@ -236,13 +238,14 @@ Use these rules to keep the trait/factory architecture stable under growth. - Validate and sanitize all inputs. - Return structured `ToolResult`; avoid panics in runtime path. -### 7.4 Memory / Runtime / Config Changes +### 5.4 Adding a Peripheral -- Keep compatibility explicit (config defaults, migration impact, fallback behavior). -- Add targeted tests for boundary conditions and unsupported values. -- Avoid hidden side effects in startup path. +- Implement `Peripheral` in `src/peripherals/`. +- Peripherals expose `tools()` — each tool delegates to the hardware (GPIO, sensors, etc.). +- Register board type in config schema if needed. +- See `docs/hardware-peripherals-design.md` for protocol and firmware notes. -### 7.5 Security / Gateway / CI Changes +### 5.5 Security / Runtime / Gateway Changes - Include threat/risk notes and rollback strategy. - Add/update tests or validation evidence for failure modes and boundaries. @@ -301,6 +304,74 @@ Treat privacy and neutrality as merge gates, not best-effort guidelines. - If reproducing external incidents, redact and anonymize all payloads before committing. - Before push, review `git diff --cached` specifically for accidental sensitive strings and identity leakage. +### 9.2 Superseded-PR Attribution (Required) + +When a PR supersedes another contributor's PR and carries forward substantive code or design decisions, preserve authorship explicitly. + +- In the integrating commit message, add one `Co-authored-by: Name ` trailer per superseded contributor whose work is materially incorporated. +- Use a GitHub-recognized email (`` or the contributor's verified commit email) so attribution is rendered correctly. +- Keep trailers on their own lines after a blank line at commit-message end; never encode them as escaped `\\n` text. +- In the PR body, list superseded PR links and briefly state what was incorporated from each. +- If no actual code/design was incorporated (only inspiration), do not use `Co-authored-by`; give credit in PR notes instead. + +### 9.3 Superseded-PR PR Template (Recommended) + +When superseding multiple PRs, use a consistent title/body structure to reduce reviewer ambiguity. + +- Recommended title format: `feat(): unify and supersede #, # [and #]` +- If this is docs/chore/meta only, keep the same supersede suffix and use the appropriate conventional-commit type. +- In the PR body, include the following template (fill placeholders, remove non-applicable lines): + +```md +## Supersedes +- # by @ +- # by @ +- # by @ + +## Integrated Scope +- From #: +- From #: +- From #: + +## Attribution +- Co-authored-by trailers added for materially incorporated contributors: Yes/No +- If No, explain why (for example: no direct code/design carry-over) + +## Non-goals +- + +## Risk and Rollback +- Risk: +- Rollback: +``` + +### 9.4 Superseded-PR Commit Template (Recommended) + +When a commit unifies or supersedes prior PR work, use a deterministic commit message layout so attribution is machine-parsed and reviewer-friendly. + +- Keep one blank line between message sections, and exactly one blank line before trailer lines. +- Keep each trailer on its own line; do not wrap, indent, or encode as escaped `\n` text. +- Add one `Co-authored-by` trailer per materially incorporated contributor, using GitHub-recognized email. +- If no direct code/design is carried over, omit `Co-authored-by` and explain attribution in the PR body instead. + +```text +feat(): unify and supersede #, # [and #] + + + +Supersedes: +- # by @ +- # by @ +- # by @ + +Integrated scope: +- : from # +- : from # + +Co-authored-by: +Co-authored-by: +``` + Reference docs: - `CONTRIBUTING.md` diff --git a/Cargo.lock b/Cargo.lock index 41924f2..6df10c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "aead" version = "0.5.2" @@ -12,6 +27,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,6 +50,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -101,7 +136,25 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", ] [[package]] @@ -205,11 +258,72 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "block-buffer" @@ -220,12 +334,47 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -238,6 +387,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -250,6 +408,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -368,12 +532,31 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ef0193218d365c251b5b9297f9911a908a8ddd2ebd3a36cc5d0ef0f63aee9e" +dependencies = [ + "heapless", + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -383,7 +566,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -396,7 +579,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -421,6 +604,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -455,6 +648,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "cron" version = "0.12.1" @@ -466,6 +668,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -477,12 +685,93 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deku" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9711031e209dc1306d66985363b4397d4c7b911597580340b93c9729b55f6eb" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cb0719583cbe4e81fb40434ace2f0d22ccc3e39a74bb3796c22b451b4f139d" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.6" @@ -569,12 +858,41 @@ dependencies = [ "syn", ] +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -603,6 +921,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -639,6 +966,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esp-idf-part" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ebc2381d030e4e89183554c3fcd4ad44dc5ab34961ab09e09b4adbe4f94b61" +dependencies = [ + "bitflags 2.11.0", + "csv", + "deku", + "md-5", + "parse_int", + "regex", + "serde", + "serde_plain", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "espflash" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f05d15cb2479a3cbbbe684b9f0831b2ae036d9faefd1eb08f21267275862f9" +dependencies = [ + "base64", + "bitflags 2.11.0", + "bytemuck", + "esp-idf-part", + "flate2", + "gimli", + "libc", + "log", + "md-5", + "miette", + "nix 0.30.1", + "object 0.38.1", + "serde", + "sha2", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -686,6 +1064,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -719,6 +1107,27 @@ 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" @@ -752,6 +1161,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -781,6 +1200,7 @@ 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", @@ -850,12 +1270,32 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -904,18 +1344,55 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hidapi" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565dd4c730b8f8b2c0fb36df6be12e5470ae10895ddcc4e9dcfbfb495de202b0" +dependencies = [ + "cc", + "cfg-if", + "libc", + "nix 0.27.1", + "pkg-config", + "udev", + "windows-sys 0.48.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1241,6 +1718,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1262,6 +1745,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ihex" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365a784774bb381e8c19edb91190a90d7f2625e057b55de2bc0f6b57bc779ff2" + [[package]] name = "indexmap" version = "2.13.0" @@ -1280,9 +1769,31 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1320,6 +1831,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jep106" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1354c92c91fd5595fd4cc46694b6914749cc90ea437246549c26b6ff0ec6d1" +dependencies = [ + "serde", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1405,7 +1925,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] @@ -1420,6 +1940,28 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1453,12 +1995,49 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags 2.11.0", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mail-parser" version = "0.11.2" @@ -1474,12 +2053,44 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -1502,6 +2113,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1509,10 +2130,79 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "mio-serial" +version = "5.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" +dependencies = [ + "log", + "mio", + "nix 0.29.0", + "serialport", + "winapi", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -1532,6 +2222,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1556,6 +2257,43 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nusb" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f861541f15de120eae5982923d073bfc0c1a65466561988c82d6e197734c19e" +dependencies = [ + "atomic-waker", + "core-foundation 0.9.4", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "libc", + "log", + "once_cell", + "rustix 0.38.44", + "slab", + "windows-sys 0.48.0", +] + +[[package]] +name = "nusb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0226f4db3ee78f820747cf713767722877f6449d7a0fcfbf2ec3b840969763f" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "linux-raw-sys 0.9.4", + "log", + "once_cell", + "rustix 1.1.3", + "slab", + "windows-sys 0.60.2", +] + [[package]] name = "object" version = "0.37.3" @@ -1565,6 +2303,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "flate2", + "memchr", + "ruzstd", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1665,6 +2414,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1688,6 +2443,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse_int" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c464266693329dd5a8715098c7f86e6c5fd5d985018b8318f53d9c6c2b21a31" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1732,6 +2513,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1743,6 +2538,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1777,6 +2584,65 @@ dependencies = [ "syn", ] +[[package]] +name = "probe-rs" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee27329ac37fa02b194c62a4e3c1aa053739884ea7bcf861249866d3bf7de00" +dependencies = [ + "anyhow", + "async-io", + "bincode", + "bitfield", + "bitvec", + "cobs", + "docsplay", + "dunce", + "espflash", + "flate2", + "futures-lite", + "hidapi", + "ihex", + "itertools", + "jep106", + "nusb 0.1.14", + "object 0.37.3", + "parking_lot", + "probe-rs-target", + "rmp-serde", + "scroll", + "serde", + "serde_yaml", + "serialport", + "thiserror 2.0.18", + "tracing", + "uf2-decode", + "zerocopy", +] + +[[package]] +name = "probe-rs-target" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239aca5dc62c68ca6d8ff0051fe617cb8363b803380fbc60567e67c82b474df" +dependencies = [ + "base64", + "indexmap", + "jep106", + "serde", + "serde_with", + "url", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1909,6 +2775,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1968,13 +2840,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1999,6 +2877,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "reqwest" version = "0.12.28" @@ -2056,6 +2963,34 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rppal" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612e1a22e21f08a246657c6433fe52b773ae43d07c9ef88ccfc433cc8683caba" +dependencies = [ + "libc", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -2072,7 +3007,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2087,16 +3022,29 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2156,6 +3104,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ruzstd" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2177,14 +3134,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" + [[package]] name = "security-framework" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2260,6 +3223,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2281,6 +3253,52 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap", + "serde_core", + "serde_json", + "time", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serialport" +version = "4.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "winapi", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2343,6 +3361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -2396,12 +3420,44 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2439,6 +3495,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.25.0" @@ -2448,7 +3510,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2603,6 +3665,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-serial" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" +dependencies = [ + "cfg-if", + "futures", + "log", + "mio-serial", + "serialport", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2663,12 +3739,21 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -2678,6 +3763,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.8+spec-1.1.0" @@ -2746,7 +3843,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", @@ -2821,6 +3918,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.24.0" @@ -2841,24 +3944,93 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" +dependencies = [ + "pom", +] + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "udev" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50051c6e22be28ee6f217d50014f3bc29e81c20dc66ff7ca0d5c5226e1dcc5a1" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uf2-decode" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77d41ab27e3fa45df42043f96c79b80c6d8632eed906b54681d8d47ab00623" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -2881,12 +4053,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -2897,6 +4081,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2940,6 +4125,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "want" version = "0.3.1" @@ -3073,7 +4264,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3137,6 +4328,34 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3432,6 +4651,9 @@ name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -3491,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -3533,6 +4755,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.7.5" @@ -3605,11 +4836,15 @@ dependencies = [ "landlock", "lettre", "mail-parser", + "nusb 0.2.1", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "pdf-extract", + "probe-rs", "prometheus", "reqwest", + "rppal", "rusqlite", "rustls", "rustls-pki-types", @@ -3621,6 +4856,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rustls", + "tokio-serial", "tokio-test", "tokio-tungstenite", "toml", diff --git a/Cargo.toml b/Cargo.toml index 6a6bc78..a9ff034 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,22 +95,32 @@ http-body-util = "0.1" # OpenTelemetry — OTLP trace + metrics export opentelemetry = { version = "0.31", default-features = false, features = ["trace", "metrics"] } opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] } -opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-blocking-client"] } +opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } + +# USB device enumeration (hardware discovery) +nusb = { version = "0.2", default-features = false, optional = true } + +# Serial port for peripheral communication (STM32, etc.) +tokio-serial = { version = "5", default-features = false, optional = true } + +# probe-rs for STM32/Nucleo memory read (Phase B) +probe-rs = { version = "0.30", optional = true } + +# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf) +pdf-extract = { version = "0.10", optional = true } + +# Raspberry Pi GPIO (Linux/RPi only) — target-specific to avoid compile failure on macOS +[target.'cfg(target_os = "linux")'.dependencies] +rppal = { version = "0.14", optional = true } [features] -default = [] -browser-native = ["dep:fantoccini"] - -# Sandbox backends (platform-specific, opt-in) -sandbox-landlock = ["landlock"] # Linux kernel LSM -sandbox-bubblewrap = [] # User namespaces (Linux/macOS) - -# Full security suite -security-full = ["sandbox-landlock"] - -[[bin]] -name = "zeroclaw" -path = "src/main.rs" +default = ["hardware"] +hardware = ["nusb", "tokio-serial"] +peripheral-rpi = ["rppal"] +# probe = probe-rs for Nucleo memory read (adds ~50 deps; optional) +probe = ["dep:probe-rs"] +# rag-pdf = PDF ingestion for datasheet RAG +rag-pdf = ["dep:pdf-extract"] [profile.release] opt-level = "z" # Optimize for size diff --git a/README.md b/README.md index ac9a8b2..1faf4eb 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ ls -lh target/release/zeroclaw ```bash git clone https://github.com/zeroclaw-labs/zeroclaw.git cd zeroclaw -cargo build --release -cargo install --path . --force +cargo build --release --locked +cargo install --path . --force --locked # Quick setup (no prompts) zeroclaw onboard --api-key sk-... --provider openrouter @@ -128,7 +128,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze |-----------|-------|------------|--------| | **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API | | **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API | -| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend | +| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend | | **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | | **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | WASM (planned; unsupported kinds fail fast) | @@ -164,11 +164,21 @@ The agent automatically recalls, saves, and manages memory via tools. ```toml [memory] -backend = "sqlite" # "sqlite", "markdown", "none" +backend = "sqlite" # "sqlite", "lucid", "markdown", "none" auto_save = true embedding_provider = "openai" vector_weight = 0.7 keyword_weight = 0.3 + +# backend = "none" uses an explicit no-op memory backend (no persistence) + +# Optional for backend = "lucid" +# ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid # default: lucid +# ZEROCLAW_LUCID_BUDGET=200 # default: 200 +# ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD=3 # local hit count to skip external recall +# ZEROCLAW_LUCID_RECALL_TIMEOUT_MS=120 # low-latency budget for lucid context recall +# ZEROCLAW_LUCID_STORE_TIMEOUT_MS=800 # async sync timeout for lucid store +# ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS=15000 # cooldown after lucid failure to avoid repeated slow attempts ``` ## Security @@ -264,12 +274,14 @@ default_model = "anthropic/claude-sonnet-4-20250514" default_temperature = 0.7 [memory] -backend = "sqlite" # "sqlite", "markdown", "none" +backend = "sqlite" # "sqlite", "lucid", "markdown", "none" auto_save = true embedding_provider = "openai" # "openai", "noop" vector_weight = 0.7 keyword_weight = 0.3 +# backend = "none" disables persistent memory via no-op backend + [gateway] require_pairing = true # require pairing code on first connect allow_public_bind = false # refuse 0.0.0.0 without tunnel @@ -305,15 +317,34 @@ encrypt = true # API keys encrypted with local key file [browser] enabled = false # opt-in browser_open + browser tools allowed_domains = ["docs.rs"] # required when browser is enabled -backend = "agent_browser" # "agent_browser" (default), "rust_native", "auto" +backend = "agent_browser" # "agent_browser" (default), "rust_native", "computer_use", "auto" native_headless = true # applies when backend uses rust-native native_webdriver_url = "http://127.0.0.1:9515" # WebDriver endpoint (chromedriver/selenium) # native_chrome_path = "/usr/bin/chromium" # optional explicit browser binary for driver +[browser.computer_use] +endpoint = "http://127.0.0.1:8787/v1/actions" # computer-use sidecar HTTP endpoint +timeout_ms = 15000 # per-action timeout +allow_remote_endpoint = false # secure default: only private/localhost endpoint +window_allowlist = [] # optional window title/process allowlist hints +# api_key = "..." # optional bearer token for sidecar +# max_coordinate_x = 3840 # optional coordinate guardrail +# max_coordinate_y = 2160 # optional coordinate guardrail + # Rust-native backend build flag: # cargo build --release --features browser-native # Ensure a WebDriver server is running, e.g. chromedriver --port=9515 +# Computer-use sidecar contract (MVP) +# POST browser.computer_use.endpoint +# Request: { +# "action": "mouse_click", +# "params": {"x": 640, "y": 360, "button": "left"}, +# "policy": {"allowed_domains": [...], "window_allowlist": [...], "max_coordinate_x": 3840, "max_coordinate_y": 2160}, +# "metadata": {"session_name": "...", "source": "zeroclaw.browser", "version": "..."} +# } +# Response: {"success": true, "data": {...}} or {"success": false, "error": "..."} + [composio] enabled = false # opt-in: 1000+ OAuth apps via composio.dev # api_key = "cmp_..." # optional: stored encrypted when [secrets].encrypt = true @@ -443,6 +474,18 @@ A git hook runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo t git config core.hooksPath .githooks ``` +### Build troubleshooting (Linux OpenSSL errors) + +If you see an `openssl-sys` build error, sync dependencies and rebuild with the repository lockfile: + +```bash +git pull +cargo build --release --locked +cargo install --path . --force --locked +``` + +ZeroClaw is configured to use `rustls` for HTTP/TLS dependencies; `--locked` keeps the transitive graph deterministic on fresh environments. + To skip the hook when you need a quick push during development: ```bash diff --git a/docs/Hardware_architecture.jpg b/docs/Hardware_architecture.jpg new file mode 100644 index 0000000..8daf589 Binary files /dev/null and b/docs/Hardware_architecture.jpg differ diff --git a/docs/adding-boards-and-tools.md b/docs/adding-boards-and-tools.md new file mode 100644 index 0000000..a7e9eaa --- /dev/null +++ b/docs/adding-boards-and-tools.md @@ -0,0 +1,116 @@ +# Adding Boards and Tools — ZeroClaw Hardware Guide + +This guide explains how to add new hardware boards and custom tools to ZeroClaw. + +## Quick Start: Add a Board via CLI + +```bash +# Add a board (updates ~/.zeroclaw/config.toml) +zeroclaw peripheral add nucleo-f401re /dev/ttyACM0 +zeroclaw peripheral add arduino-uno /dev/cu.usbmodem12345 +zeroclaw peripheral add rpi-gpio native # for Raspberry Pi GPIO (Linux) + +# Restart daemon to apply +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +## Supported Boards + +| Board | Transport | Path Example | +|-----------------|-----------|---------------------------| +| nucleo-f401re | serial | /dev/ttyACM0, /dev/cu.usbmodem* | +| arduino-uno | serial | /dev/ttyACM0, /dev/cu.usbmodem* | +| arduino-uno-q | bridge | (Uno Q IP) | +| rpi-gpio | native | native | +| esp32 | serial | /dev/ttyUSB0 | + +## Manual Config + +Edit `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true +datasheet_dir = "docs/datasheets" # optional: RAG for "turn on red led" → pin 13 + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[[peripherals.boards]] +board = "arduino-uno" +transport = "serial" +path = "/dev/cu.usbmodem12345" +baud = 115200 +``` + +## Adding a Datasheet (RAG) + +Place `.md` or `.txt` files in `docs/datasheets/` (or your `datasheet_dir`). Name files by board: `nucleo-f401re.md`, `arduino-uno.md`. + +### Pin Aliases (Recommended) + +Add a `## Pin Aliases` section so the agent can map "red led" → pin 13: + +```markdown +# My Board + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| builtin_led | 13 | +| user_led | 5 | +``` + +Or use key-value format: + +```markdown +## Pin Aliases +red_led: 13 +builtin_led: 13 +``` + +### PDF Datasheets + +With the `rag-pdf` feature, ZeroClaw can index PDF files: + +```bash +cargo build --features hardware,rag-pdf +``` + +Place PDFs in the datasheet directory. They are extracted and chunked for RAG. + +## Adding a New Board Type + +1. **Create a datasheet** — `docs/datasheets/my-board.md` with pin aliases and GPIO info. +2. **Add to config** — `zeroclaw peripheral add my-board /dev/ttyUSB0` +3. **Implement a peripheral** (optional) — For custom protocols, implement the `Peripheral` trait in `src/peripherals/` and register in `create_peripheral_tools`. + +See `docs/hardware-peripherals-design.md` for the full design. + +## Adding a Custom Tool + +1. Implement the `Tool` trait in `src/tools/`. +2. Register in `create_peripheral_tools` (for hardware tools) or the agent tool registry. +3. Add a tool description to the agent's `tool_descs` in `src/agent/loop_.rs`. + +## CLI Reference + +| Command | Description | +|---------|-------------| +| `zeroclaw peripheral list` | List configured boards | +| `zeroclaw peripheral add ` | Add board (writes config) | +| `zeroclaw peripheral flash` | Flash Arduino firmware | +| `zeroclaw peripheral flash-nucleo` | Flash Nucleo firmware | +| `zeroclaw hardware discover` | List USB devices | +| `zeroclaw hardware info` | Chip info via probe-rs | + +## Troubleshooting + +- **Serial port not found** — On macOS use `/dev/cu.usbmodem*`; on Linux use `/dev/ttyACM0` or `/dev/ttyUSB0`. +- **Build with hardware** — `cargo build --features hardware` +- **Probe-rs for Nucleo** — `cargo build --features hardware,probe` diff --git a/docs/arduino-uno-q-setup.md b/docs/arduino-uno-q-setup.md new file mode 100644 index 0000000..8e170e8 --- /dev/null +++ b/docs/arduino-uno-q-setup.md @@ -0,0 +1,217 @@ +# ZeroClaw on Arduino Uno Q — Step-by-Step Guide + +Run ZeroClaw on the Arduino Uno Q's Linux side. Telegram works over WiFi; GPIO control uses the Bridge (requires a minimal App Lab app). + +--- + +## What's Included (No Code Changes Needed) + +ZeroClaw includes everything needed for Arduino Uno Q. **Clone the repo and follow this guide — no patches or custom code required.** + +| Component | Location | Purpose | +|-----------|----------|---------| +| Bridge app | `firmware/zeroclaw-uno-q-bridge/` | MCU sketch + Python socket server (port 9999) for GPIO | +| Bridge tools | `src/peripherals/uno_q_bridge.rs` | `gpio_read` / `gpio_write` tools that talk to the Bridge over TCP | +| Setup command | `src/peripherals/uno_q_setup.rs` | `zeroclaw peripheral setup-uno-q` deploys the Bridge via scp + arduino-app-cli | +| Config schema | `board = "arduino-uno-q"`, `transport = "bridge"` | Supported in `config.toml` | + +Build with `--features hardware` (or the default features) to include Uno Q support. + +--- + +## Prerequisites + +- Arduino Uno Q with WiFi configured +- Arduino App Lab installed on your Mac (for initial setup and deployment) +- API key for LLM (OpenRouter, etc.) + +--- + +## Phase 1: Initial Uno Q Setup (One-Time) + +### 1.1 Configure Uno Q via App Lab + +1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux). +2. Connect Uno Q via USB, power it on. +3. Open App Lab, connect to the board. +4. Follow the setup wizard: + - Set username and password (for SSH) + - Configure WiFi (SSID, password) + - Apply any firmware updates +5. Note the IP address shown (e.g. `arduino@192.168.1.42`) or find it later via `ip addr show` in App Lab's terminal. + +### 1.2 Verify SSH Access + +```bash +ssh arduino@ +# Enter the password you set +``` + +--- + +## Phase 2: Install ZeroClaw on Uno Q + +### Option A: Build on the Device (Simpler, ~20–40 min) + +```bash +# SSH into Uno Q +ssh arduino@ + +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +source ~/.cargo/env + +# Install build deps (Debian) +sudo apt-get update +sudo apt-get install -y pkg-config libssl-dev + +# Clone zeroclaw (or scp your project) +git clone https://github.com/theonlyhennygod/zeroclaw.git +cd zeroclaw + +# Build (takes ~15–30 min on Uno Q) +cargo build --release + +# Install +sudo cp target/release/zeroclaw /usr/local/bin/ +``` + +### Option B: Cross-Compile on Mac (Faster) + +```bash +# On your Mac — add aarch64 target +rustup target add aarch64-unknown-linux-gnu + +# Install cross-compiler (macOS; required for linking) +brew tap messense/macos-cross-toolchains +brew install aarch64-unknown-linux-gnu + +# Build +CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc cargo build --release --target aarch64-unknown-linux-gnu + +# Copy to Uno Q +scp target/aarch64-unknown-linux-gnu/release/zeroclaw arduino@:~/ +ssh arduino@ "sudo mv ~/zeroclaw /usr/local/bin/" +``` + +If cross-compile fails, use Option A and build on the device. + +--- + +## Phase 3: Configure ZeroClaw + +### 3.1 Run Onboard (or Create Config Manually) + +```bash +ssh arduino@ + +# Quick config +zeroclaw onboard --api-key YOUR_OPENROUTER_KEY --provider openrouter + +# Or create config manually +mkdir -p ~/.zeroclaw/workspace +nano ~/.zeroclaw/config.toml +``` + +### 3.2 Minimal config.toml + +```toml +api_key = "YOUR_OPENROUTER_API_KEY" +default_provider = "openrouter" +default_model = "anthropic/claude-sonnet-4" + +[peripherals] +enabled = false +# GPIO via Bridge requires Phase 4 + +[channels_config.telegram] +bot_token = "YOUR_TELEGRAM_BOT_TOKEN" +allowed_users = ["*"] + +[gateway] +host = "127.0.0.1" +port = 8080 +allow_public_bind = false + +[agent] +compact_context = true +``` + +--- + +## Phase 4: Run ZeroClaw Daemon + +```bash +ssh arduino@ + +# Run daemon (Telegram polling works over WiFi) +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +**At this point:** Telegram chat works. Send messages to your bot — ZeroClaw responds. No GPIO yet. + +--- + +## Phase 5: GPIO via Bridge (ZeroClaw Handles It) + +ZeroClaw includes the Bridge app and setup command. + +### 5.1 Deploy Bridge App + +**From your Mac** (with zeroclaw repo): +```bash +zeroclaw peripheral setup-uno-q --host 192.168.0.48 +``` + +**From the Uno Q** (SSH'd in): +```bash +zeroclaw peripheral setup-uno-q +``` + +This copies the Bridge app to `~/ArduinoApps/zeroclaw-uno-q-bridge` and starts it. + +### 5.2 Add to config.toml + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "arduino-uno-q" +transport = "bridge" +``` + +### 5.3 Run ZeroClaw + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +Now when you message your Telegram bot *"Turn on the LED"* or *"Set pin 13 high"*, ZeroClaw uses `gpio_write` via the Bridge. + +--- + +## Summary: Commands Start to End + +| Step | Command | +|------|---------| +| 1 | Configure Uno Q in App Lab (WiFi, SSH) | +| 2 | `ssh arduino@` | +| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` | +| 4 | `sudo apt-get install -y pkg-config libssl-dev` | +| 5 | `git clone https://github.com/theonlyhennygod/zeroclaw.git && cd zeroclaw` | +| 6 | `cargo build --release --no-default-features` | +| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` | +| 8 | Edit `~/.zeroclaw/config.toml` (add Telegram bot_token) | +| 9 | `zeroclaw daemon --host 127.0.0.1 --port 8080` | +| 10 | Message your Telegram bot — it responds | + +--- + +## Troubleshooting + +- **"command not found: zeroclaw"** — Use full path: `/usr/local/bin/zeroclaw` or ensure `~/.cargo/bin` is in PATH. +- **Telegram not responding** — Check bot_token, allowed_users, and that the Uno Q has internet (WiFi). +- **Out of memory** — Use `--no-default-features` to reduce binary size; consider `compact_context = true`. +- **GPIO commands ignored** — Ensure Bridge app is running (`zeroclaw peripheral setup-uno-q` deploys and starts it). Config must have `board = "arduino-uno-q"` and `transport = "bridge"`. +- **LLM provider (GLM/Zhipu)** — Use `default_provider = "glm"` or `"zhipu"` with `GLM_API_KEY` in env or config. ZeroClaw uses the correct v4 endpoint. diff --git a/docs/datasheets/arduino-uno.md b/docs/datasheets/arduino-uno.md new file mode 100644 index 0000000..be4d4fc --- /dev/null +++ b/docs/datasheets/arduino-uno.md @@ -0,0 +1,37 @@ +# Arduino Uno + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| builtin_led | 13 | +| user_led | 13 | + +## Overview + +Arduino Uno is a microcontroller board based on the ATmega328P. It has 14 digital I/O pins (0–13) and 6 analog inputs (A0–A5). + +## Digital Pins + +- **Pins 0–13:** Digital I/O. Can be INPUT or OUTPUT. +- **Pin 13:** Built-in LED (onboard). Connect LED to GND or use for output. +- **Pins 0–1:** Also used for Serial (RX/TX). Avoid if using Serial. + +## GPIO + +- `digitalWrite(pin, HIGH)` or `digitalWrite(pin, LOW)` for output. +- `digitalRead(pin)` for input (returns 0 or 1). +- Pin numbers in ZeroClaw protocol: 0–13. + +## Serial + +- UART on pins 0 (RX) and 1 (TX). +- USB via ATmega16U2 or CH340 (clones). +- Baud rate: 115200 for ZeroClaw firmware. + +## ZeroClaw Tools + +- `gpio_read`: Read pin value (0 or 1). +- `gpio_write`: Set pin high (1) or low (0). +- `arduino_upload`: Agent generates full Arduino sketch code; ZeroClaw compiles and uploads it via arduino-cli. Use for "make a heart", custom patterns — agent writes the code, no manual editing. Pin 13 = built-in LED. diff --git a/docs/datasheets/esp32.md b/docs/datasheets/esp32.md new file mode 100644 index 0000000..8cb453d --- /dev/null +++ b/docs/datasheets/esp32.md @@ -0,0 +1,22 @@ +# ESP32 GPIO Reference + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| builtin_led | 2 | +| red_led | 2 | + +## Common pins (ESP32 / ESP32-C3) + +- **GPIO 2**: Built-in LED on many dev boards (output) +- **GPIO 13**: General-purpose output +- **GPIO 21/20**: Often used for UART0 TX/RX (avoid if using serial) + +## Protocol + +ZeroClaw host sends JSON over serial (115200 baud): +- `gpio_read`: `{"id":"1","cmd":"gpio_read","args":{"pin":13}}` +- `gpio_write`: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}` + +Response: `{"id":"1","ok":true,"result":"0"}` or `{"id":"1","ok":true,"result":"done"}` diff --git a/docs/datasheets/nucleo-f401re.md b/docs/datasheets/nucleo-f401re.md new file mode 100644 index 0000000..22b1e93 --- /dev/null +++ b/docs/datasheets/nucleo-f401re.md @@ -0,0 +1,16 @@ +# Nucleo-F401RE GPIO + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| user_led | 13 | +| ld2 | 13 | +| builtin_led | 13 | + +## GPIO + +Pin 13: User LED (LD2) +- Output, active high +- PA5 on STM32F401 diff --git a/docs/hardware-peripherals-design.md b/docs/hardware-peripherals-design.md new file mode 100644 index 0000000..87f61bf --- /dev/null +++ b/docs/hardware-peripherals-design.md @@ -0,0 +1,324 @@ +# Hardware Peripherals Design — ZeroClaw + +ZeroClaw enables microcontrollers (MCUs) and Single Board Computers (SBCs) to **dynamically interpret natural language commands**, generate hardware-specific code, and execute peripheral interactions in real-time. + +## 1. Vision + +**Goal:** ZeroClaw acts as a hardware-aware AI agent that: +- Receives natural language triggers (e.g. "Move X arm", "Turn on LED") via channels (WhatsApp, Telegram) +- Fetches accurate hardware documentation (datasheets, register maps) +- Synthesizes Rust code/logic using an LLM (Gemini, local open-source models) +- Executes the logic to manipulate peripherals (GPIO, I2C, SPI) +- Persists optimized code for future reuse + +**Mental model:** ZeroClaw = brain that understands hardware. Peripherals = arms and legs it controls. + +## 2. Two Modes of Operation + +### Mode 1: Edge-Native (Standalone) + +**Target:** Wi-Fi-enabled boards (ESP32, Raspberry Pi). + +ZeroClaw runs **directly on the device**. The board spins up a gRPC/nanoRPC server and communicates with peripherals locally. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ZeroClaw on ESP32 / Raspberry Pi (Edge-Native) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────────┐ │ +│ │ Channels │───►│ Agent Loop │───►│ RAG: datasheets, register maps │ │ +│ │ WhatsApp │ │ (LLM calls) │ │ → LLM context │ │ +│ │ Telegram │ └──────┬───────┘ └─────────────────────────────────┘ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ Code synthesis → Wasm / dynamic exec → GPIO / I2C / SPI → persist ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ gRPC/nanoRPC server ◄──► Peripherals (GPIO, I2C, SPI, sensors, actuators) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Workflow:** +1. User sends WhatsApp: *"Turn on LED on pin 13"* +2. ZeroClaw fetches board-specific docs (e.g. ESP32 GPIO mapping) +3. LLM synthesizes Rust code +4. Code runs in a sandbox (Wasm or dynamic linking) +5. GPIO is toggled; result returned to user +6. Optimized code is persisted for future "Turn on LED" requests + +**All happens on-device.** No host required. + +### Mode 2: Host-Mediated (Development / Debugging) + +**Target:** Hardware connected via USB / J-Link / Aardvark to a host (macOS, Linux). + +ZeroClaw runs on the **host** and maintains a hardware-aware link to the target. Used for development, introspection, and flashing. + +``` +┌─────────────────────┐ ┌──────────────────────────────────┐ +│ ZeroClaw on Mac │ USB / J-Link / │ STM32 Nucleo-F401RE │ +│ │ Aardvark │ (or other MCU) │ +│ - Channels │ ◄────────────────► │ - Memory map │ +│ - LLM │ │ - Peripherals (GPIO, ADC, I2C) │ +│ - Hardware probe │ VID/PID │ - Flash / RAM │ +│ - Flash / debug │ discovery │ │ +└─────────────────────┘ └──────────────────────────────────┘ +``` + +**Workflow:** +1. User sends Telegram: *"What are the readable memory addresses on this USB device?"* +2. ZeroClaw identifies connected hardware (VID/PID, architecture) +3. Performs memory mapping; suggests available address spaces +4. Returns result to user + +**Or:** +1. User: *"Flash this firmware to the Nucleo"* +2. ZeroClaw writes/flashes via OpenOCD or probe-rs +3. Confirms success + +**Or:** +1. ZeroClaw auto-discovers: *"STM32 Nucleo on /dev/ttyACM0, ARM Cortex-M4"* +2. Suggests: *"I can read/write GPIO, ADC, flash. What would you like to do?"* + +--- + +### Mode Comparison + +| Aspect | Edge-Native | Host-Mediated | +|------------------|--------------------------------|----------------------------------| +| ZeroClaw runs on | Device (ESP32, RPi) | Host (Mac, Linux) | +| Hardware link | Local (GPIO, I2C, SPI) | USB, J-Link, Aardvark | +| LLM | On-device or cloud (Gemini) | Host (cloud or local) | +| Use case | Production, standalone | Dev, debug, introspection | +| Channels | WhatsApp, etc. (via WiFi) | Telegram, CLI, etc. | + +## 3. Legacy / Simpler Modes (Pre-LLM-on-Edge) + +For boards without WiFi or before full Edge-Native is ready: + +### Mode A: Host + Remote Peripheral (STM32 via serial) + +Host runs ZeroClaw; peripheral runs minimal firmware. Simple JSON over serial. + +### Mode B: RPi as Host (Native GPIO) + +ZeroClaw on Pi; GPIO via rppal or sysfs. No separate firmware. + +## 4. Technical Requirements + +| Requirement | Description | +|-------------|-------------| +| **Language** | Pure Rust. `no_std` where applicable for embedded targets (STM32, ESP32). | +| **Communication** | Lightweight gRPC or nanoRPC stack for low-latency command processing. | +| **Dynamic execution** | Safely run LLM-generated logic on-the-fly: Wasm runtime for isolation, or dynamic linking where supported. | +| **Documentation retrieval** | RAG (Retrieval-Augmented Generation) pipeline to feed datasheet snippets, register maps, and pinouts into LLM context. | +| **Hardware discovery** | VID/PID-based identification for USB devices; architecture detection (ARM Cortex-M, RISC-V, etc.). | + +### RAG Pipeline (Datasheet Retrieval) + +- **Index:** Datasheets, reference manuals, register maps (PDF → chunks, embeddings). +- **Retrieve:** On user query ("turn on LED"), fetch relevant snippets (e.g. GPIO section for target board). +- **Inject:** Add to LLM system prompt or context. +- **Result:** LLM generates accurate, board-specific code. + +### Dynamic Execution Options + +| Option | Pros | Cons | +|-------|------|------| +| **Wasm** | Sandboxed, portable, no FFI | Overhead; limited HW access from Wasm | +| **Dynamic linking** | Native speed, full HW access | Platform-specific; security concerns | +| **Interpreted DSL** | Safe, auditable | Slower; limited expressiveness | +| **Pre-compiled templates** | Fast, secure | Less flexible; requires template library | + +**Recommendation:** Start with pre-compiled templates + parameterization; evolve to Wasm for user-defined logic once stable. + +## 5. CLI and Config + +### CLI Flags + +```bash +# Edge-Native: run on device (ESP32, RPi) +zeroclaw agent --mode edge + +# Host-Mediated: connect to USB/J-Link target +zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0 +zeroclaw agent --probe jlink + +# Hardware introspection +zeroclaw hardware discover +zeroclaw hardware introspect /dev/ttyACM0 +``` + +### Config (config.toml) + +```toml +[peripherals] +enabled = true +mode = "host" # "edge" | "host" +datasheet_dir = "docs/datasheets" # RAG: board-specific docs for LLM context + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[[peripherals.boards]] +board = "rpi-gpio" +transport = "native" + +[[peripherals.boards]] +board = "esp32" +transport = "wifi" +# Edge-Native: ZeroClaw runs on ESP32 +``` + +## 6. Architecture: Peripheral as Extension Point + +### New Trait: `Peripheral` + +```rust +/// A hardware peripheral that exposes capabilities as tools. +#[async_trait] +pub trait Peripheral: Send + Sync { + fn name(&self) -> &str; + fn board_type(&self) -> &str; // e.g. "nucleo-f401re", "rpi-gpio" + async fn connect(&mut self) -> anyhow::Result<()>; + async fn disconnect(&mut self) -> anyhow::Result<()>; + async fn health_check(&self) -> bool; + /// Tools this peripheral provides (gpio_read, gpio_write, sensor_read, etc.) + fn tools(&self) -> Vec>; +} +``` + +### Flow + +1. **Startup:** ZeroClaw loads config, sees `peripherals.boards`. +2. **Connect:** For each board, create a `Peripheral` impl, call `connect()`. +3. **Tools:** Collect tools from all connected peripherals; merge with default tools. +4. **Agent loop:** Agent can call `gpio_write`, `sensor_read`, etc. — these delegate to the peripheral. +5. **Shutdown:** Call `disconnect()` on each peripheral. + +### Board Support + +| Board | Transport | Firmware / Driver | Tools | +|--------------------|-----------|------------------------|--------------------------| +| nucleo-f401re | serial | Zephyr / Embassy | gpio_read, gpio_write, adc_read | +| rpi-gpio | native | rppal or sysfs | gpio_read, gpio_write | +| esp32 | serial/ws | ESP-IDF / Embassy | gpio, wifi, mqtt | + +## 7. Communication Protocols + +### gRPC / nanoRPC (Edge-Native, Host-Mediated) + +For low-latency, typed RPC between ZeroClaw and peripherals: + +- **nanoRPC** or **tonic** (gRPC): Protobuf-defined services. +- Methods: `GpioWrite`, `GpioRead`, `I2cTransfer`, `SpiTransfer`, `MemoryRead`, `FlashWrite`, etc. +- Enables streaming, bidirectional calls, and code generation from `.proto` files. + +### Serial Fallback (Host-Mediated, legacy) + +Simple JSON over serial for boards without gRPC support: + +**Request (host → peripheral):** +```json +{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} +``` + +**Response (peripheral → host):** +```json +{"id":"1","ok":true,"result":"done"} +``` + +## 8. Firmware (Separate Repo or Crate) + +- **zeroclaw-firmware** or **zeroclaw-peripheral** — a separate crate/workspace. +- Targets: `thumbv7em-none-eabihf` (STM32), `armv7-unknown-linux-gnueabihf` (RPi), etc. +- Uses `embassy` or Zephyr for STM32. +- Implements the protocol above. +- User flashes this to the board; ZeroClaw connects and discovers capabilities. + +## 9. Implementation Phases + +### Phase 1: Skeleton ✅ (Done) + +- [x] Add `Peripheral` trait, config schema, CLI (`zeroclaw peripheral list/add`) +- [x] Add `--peripheral` flag to agent +- [x] Document in AGENTS.md + +### Phase 2: Host-Mediated — Hardware Discovery ✅ (Done) + +- [x] `zeroclaw hardware discover`: enumerate USB devices (VID/PID) +- [x] Board registry: map VID/PID → architecture, name (e.g. Nucleo-F401RE) +- [x] `zeroclaw hardware introspect `: memory map, peripheral list + +### Phase 3: Host-Mediated — Serial / J-Link + +- [x] `SerialPeripheral` for STM32 over USB CDC +- [ ] probe-rs or OpenOCD integration for flash/debug +- [x] Tools: `gpio_read`, `gpio_write` (memory_read, flash_write in future) + +### Phase 4: RAG Pipeline ✅ (Done) + +- [x] Datasheet index (markdown/text → chunks) +- [x] Retrieve-and-inject into LLM context on hardware-related queries +- [x] Board-specific prompt augmentation + +**Usage:** Add `datasheet_dir = "docs/datasheets"` to `[peripherals]` in config.toml. Place `.md` or `.txt` files named by board (e.g. `nucleo-f401re.md`, `rpi-gpio.md`). Files in `_generic/` or named `generic.md` apply to all boards. Chunks are retrieved by keyword match and injected into the user message context. + +### Phase 5: Edge-Native — RPi ✅ (Done) + +- [x] ZeroClaw on Raspberry Pi (native GPIO via rppal) +- [ ] gRPC/nanoRPC server for local peripheral access +- [ ] Code persistence (store synthesized snippets) + +### Phase 6: Edge-Native — ESP32 + +- [x] Host-mediated ESP32 (serial transport) — same JSON protocol as STM32 +- [x] `zeroclaw-esp32` firmware crate (`firmware/zeroclaw-esp32`) — GPIO over UART +- [x] ESP32 in hardware registry (CH340 VID/PID) +- [ ] ZeroClaw *on* ESP32 (WiFi + LLM, edge-native) — future +- [ ] Wasm or template-based execution for LLM-generated logic + +**Usage:** Flash `firmware/zeroclaw-esp32` to ESP32, add `board = "esp32"`, `transport = "serial"`, `path = "/dev/ttyUSB0"` to config. + +### Phase 7: Dynamic Execution (LLM-Generated Code) + +- [ ] Template library: parameterized GPIO/I2C/SPI snippets +- [ ] Optional: Wasm runtime for user-defined logic (sandboxed) +- [ ] Persist and reuse optimized code paths + +## 10. Security Considerations + +- **Serial path:** Validate `path` is in allowlist (e.g. `/dev/ttyACM*`, `/dev/ttyUSB*`); never arbitrary paths. +- **GPIO:** Restrict which pins are exposed; avoid power/reset pins. +- **No secrets on peripheral:** Firmware should not store API keys; host handles auth. + +## 11. Non-Goals (For Now) + +- Running full ZeroClaw *on* bare STM32 (no WiFi, limited RAM) — use Host-Mediated instead +- Real-time guarantees — peripherals are best-effort +- Arbitrary native code execution from LLM — prefer Wasm or templates + +## 12. Related Documents + +- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — How to add boards and datasheets +- [network-deployment.md](./network-deployment.md) — RPi and network deployment + +## 13. References + +- [Zephyr RTOS Rust support](https://docs.zephyrproject.org/latest/develop/languages/rust/index.html) +- [Embassy](https://embassy.dev/) — async embedded framework +- [rppal](https://github.com/golemparts/rppal) — Raspberry Pi GPIO in Rust +- [STM32 Nucleo-F401RE](https://www.st.com/en/evaluation-tools/nucleo-f401re.html) +- [tonic](https://github.com/hyperium/tonic) — gRPC for Rust +- [probe-rs](https://probe.rs/) — ARM debug probe, flash, memory access +- [nusb](https://github.com/nic-hartley/nusb) — USB device enumeration (VID/PID) + +## 14. Raw Prompt Summary + +> *"Boards like ESP, Raspberry Pi, or boards with WiFi can connect to an LLM (Gemini or open-source). ZeroClaw runs on the device, creates its own gRPC, spins it up, and communicates with peripherals. User asks via WhatsApp: 'move X arm' or 'turn on LED'. ZeroClaw gets accurate documentation, writes code, executes it, stores it optimally, runs it, and turns on the LED — all on the development board.* +> +> *For STM Nucleo connected via USB/J-Link/Aardvark to my Mac: ZeroClaw from my Mac accesses the hardware, installs or writes what it wants on the device, and returns the result. Example: 'Hey ZeroClaw, what are the available/readable addresses on this USB device?' It can figure out what's connected where and suggest."* diff --git a/docs/network-deployment.md b/docs/network-deployment.md new file mode 100644 index 0000000..5fdc7fa --- /dev/null +++ b/docs/network-deployment.md @@ -0,0 +1,182 @@ +# Network Deployment — ZeroClaw on Raspberry Pi and Local Network + +This document covers deploying ZeroClaw on a Raspberry Pi or other host on your local network, with Telegram and optional webhook channels. + +--- + +## 1. Overview + +| Mode | Inbound port needed? | Use case | +|------|----------------------|----------| +| **Telegram polling** | No | ZeroClaw polls Telegram API; works from anywhere | +| **Discord/Slack** | No | Same — outbound only | +| **Gateway webhook** | Yes | POST /webhook, WhatsApp, etc. need a public URL | +| **Gateway pairing** | Yes | If you pair clients via the gateway | + +**Key:** Telegram, Discord, and Slack use **long-polling** — ZeroClaw makes outbound requests. No port forwarding or public IP required. + +--- + +## 2. ZeroClaw on Raspberry Pi + +### 2.1 Prerequisites + +- Raspberry Pi (3/4/5) with Raspberry Pi OS +- USB peripherals (Arduino, Nucleo) if using serial transport +- Optional: `rppal` for native GPIO (`peripheral-rpi` feature) + +### 2.2 Install + +```bash +# Build for RPi (or cross-compile from host) +cargo build --release --features hardware + +# Or install via your preferred method +``` + +### 2.3 Config + +Edit `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "rpi-gpio" +transport = "native" + +# Or Arduino over USB +[[peripherals.boards]] +board = "arduino-uno" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[channels_config.telegram] +bot_token = "YOUR_BOT_TOKEN" +allowed_users = ["*"] + +[gateway] +host = "127.0.0.1" +port = 8080 +allow_public_bind = false +``` + +### 2.4 Run Daemon (Local Only) + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +- Gateway binds to `127.0.0.1` — not reachable from other machines +- Telegram channel works: ZeroClaw polls Telegram API (outbound) +- No firewall or port forwarding needed + +--- + +## 3. Binding to 0.0.0.0 (Local Network) + +To allow other devices on your LAN to hit the gateway (e.g. for pairing or webhooks): + +### 3.1 Option A: Explicit Opt-In + +```toml +[gateway] +host = "0.0.0.0" +port = 8080 +allow_public_bind = true +``` + +```bash +zeroclaw daemon --host 0.0.0.0 --port 8080 +``` + +**Security:** `allow_public_bind = true` exposes the gateway to your local network. Only use on trusted LANs. + +### 3.2 Option B: Tunnel (Recommended for Webhooks) + +If you need a **public URL** (e.g. WhatsApp webhook, external clients): + +1. Run gateway on localhost: + ```bash + zeroclaw daemon --host 127.0.0.1 --port 8080 + ``` + +2. Start a tunnel: + ```toml + [tunnel] + provider = "tailscale" # or "ngrok", "cloudflare" + ``` + Or use `zeroclaw tunnel` (see tunnel docs). + +3. ZeroClaw will refuse `0.0.0.0` unless `allow_public_bind = true` or a tunnel is active. + +--- + +## 4. Telegram Polling (No Inbound Port) + +Telegram uses **long-polling** by default: + +- ZeroClaw calls `https://api.telegram.org/bot{token}/getUpdates` +- No inbound port or public IP needed +- Works behind NAT, on RPi, in a home lab + +**Config:** + +```toml +[channels_config.telegram] +bot_token = "YOUR_BOT_TOKEN" +allowed_users = ["*"] # or specific @usernames / user IDs +``` + +Run `zeroclaw daemon` — Telegram channel starts automatically. + +--- + +## 5. Webhook Channels (WhatsApp, Custom) + +Webhook-based channels need a **public URL** so Meta (WhatsApp) or your client can POST events. + +### 5.1 Tailscale Funnel + +```toml +[tunnel] +provider = "tailscale" +``` + +Tailscale Funnel exposes your gateway via a `*.ts.net` URL. No port forwarding. + +### 5.2 ngrok + +```toml +[tunnel] +provider = "ngrok" +``` + +Or run ngrok manually: +```bash +ngrok http 8080 +# Use the HTTPS URL for your webhook +``` + +### 5.3 Cloudflare Tunnel + +Configure Cloudflare Tunnel to forward to `127.0.0.1:8080`, then set your webhook URL to the tunnel's public hostname. + +--- + +## 6. Checklist: RPi Deployment + +- [ ] Build with `--features hardware` (and `peripheral-rpi` if using native GPIO) +- [ ] Configure `[peripherals]` and `[channels_config.telegram]` +- [ ] Run `zeroclaw daemon --host 127.0.0.1 --port 8080` (Telegram works without 0.0.0.0) +- [ ] For LAN access: `--host 0.0.0.0` + `allow_public_bind = true` in config +- [ ] For webhooks: use Tailscale, ngrok, or Cloudflare tunnel + +--- + +## 7. References + +- [hardware-peripherals-design.md](./hardware-peripherals-design.md) — Peripherals design +- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Hardware setup and adding boards diff --git a/docs/nucleo-setup.md b/docs/nucleo-setup.md new file mode 100644 index 0000000..76e942e --- /dev/null +++ b/docs/nucleo-setup.md @@ -0,0 +1,147 @@ +# ZeroClaw on Nucleo-F401RE — Step-by-Step Guide + +Run ZeroClaw on your Mac or Linux host. Connect a Nucleo-F401RE via USB. Control GPIO (LED, pins) via Telegram or CLI. + +--- + +## Get Board Info via Telegram (No Firmware Needed) + +ZeroClaw can read chip info from the Nucleo over USB **without flashing any firmware**. Message your Telegram bot: + +- *"What board info do I have?"* +- *"Board info"* +- *"What hardware is connected?"* +- *"Chip info"* + +The agent uses the `hardware_board_info` tool to return chip name, architecture, and memory map. With the `probe` feature, it reads live data via USB/SWD; otherwise it returns static datasheet info. + +**Config:** Add Nucleo to `config.toml` first (so the agent knows which board to query): + +```toml +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 +``` + +**CLI alternative:** + +```bash +cargo build --features hardware,probe +zeroclaw hardware info +zeroclaw hardware discover +``` + +--- + +## What's Included (No Code Changes Needed) + +ZeroClaw includes everything for Nucleo-F401RE: + +| Component | Location | Purpose | +|-----------|----------|---------| +| Firmware | `firmware/zeroclaw-nucleo/` | Embassy Rust — USART2 (115200), gpio_read, gpio_write | +| Serial peripheral | `src/peripherals/serial.rs` | JSON-over-serial protocol (same as Arduino/ESP32) | +| Flash command | `zeroclaw peripheral flash-nucleo` | Builds firmware, flashes via probe-rs | + +Protocol: newline-delimited JSON. Request: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`. Response: `{"id":"1","ok":true,"result":"done"}`. + +--- + +## Prerequisites + +- Nucleo-F401RE board +- USB cable (USB-A to Mini-USB; Nucleo has built-in ST-Link) +- For flashing: `cargo install probe-rs-tools --locked` (or use the [install script](https://probe.rs/docs/getting-started/installation/)) + +--- + +## Phase 1: Flash Firmware + +### 1.1 Connect Nucleo + +1. Connect Nucleo to your Mac/Linux via USB. +2. The board appears as a USB device (ST-Link). No separate driver needed on modern systems. + +### 1.2 Flash via ZeroClaw + +From the zeroclaw repo root: + +```bash +zeroclaw peripheral flash-nucleo +``` + +This builds `firmware/zeroclaw-nucleo` and runs `probe-rs run --chip STM32F401RETx`. The firmware runs immediately after flashing. + +### 1.3 Manual Flash (Alternative) + +```bash +cd firmware/zeroclaw-nucleo +cargo build --release --target thumbv7em-none-eabihf +probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo +``` + +--- + +## Phase 2: Find Serial Port + +- **macOS:** `/dev/cu.usbmodem*` or `/dev/tty.usbmodem*` (e.g. `/dev/cu.usbmodem101`) +- **Linux:** `/dev/ttyACM0` (or check `dmesg` after plugging in) + +USART2 (PA2/PA3) is bridged to the ST-Link's virtual COM port, so the host sees one serial device. + +--- + +## Phase 3: Configure ZeroClaw + +Add to `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/cu.usbmodem101" # adjust to your port +baud = 115200 +``` + +--- + +## Phase 4: Run and Test + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +Or use the agent directly: + +```bash +zeroclaw agent --message "Turn on the LED on pin 13" +``` + +Pin 13 = PA5 = User LED (LD2) on Nucleo-F401RE. + +--- + +## Summary: Commands + +| Step | Command | +|------|---------| +| 1 | Connect Nucleo via USB | +| 2 | `cargo install probe-rs --locked` | +| 3 | `zeroclaw peripheral flash-nucleo` | +| 4 | Add Nucleo to config.toml (path = your serial port) | +| 5 | `zeroclaw daemon` or `zeroclaw agent -m "Turn on LED"` | + +--- + +## Troubleshooting + +- **flash-nucleo unrecognized** — Build from repo: `cargo run --features hardware -- peripheral flash-nucleo`. The subcommand is only in the repo build, not in crates.io installs. +- **probe-rs not found** — `cargo install probe-rs-tools --locked` (the `probe-rs` crate is a library; the CLI is in `probe-rs-tools`) +- **No probe detected** — Ensure Nucleo is connected. Try another USB cable/port. +- **Serial port not found** — On Linux, add user to `dialout`: `sudo usermod -a -G dialout $USER`, then log out/in. +- **GPIO commands ignored** — Check `path` in config matches your serial port. Run `zeroclaw peripheral list` to verify. diff --git a/firmware/zeroclaw-arduino/zeroclaw-arduino.ino b/firmware/zeroclaw-arduino/zeroclaw-arduino.ino new file mode 100644 index 0000000..5e9c4ee --- /dev/null +++ b/firmware/zeroclaw-arduino/zeroclaw-arduino.ino @@ -0,0 +1,143 @@ +/* + * ZeroClaw Arduino Uno Firmware + * + * Listens for JSON commands on Serial (115200 baud), executes gpio_read/gpio_write, + * responds with JSON. Compatible with ZeroClaw SerialPeripheral protocol. + * + * Protocol (newline-delimited JSON): + * Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} + * Response: {"id":"1","ok":true,"result":"done"} + * + * Arduino Uno: Pin 13 has built-in LED. Digital pins 0-13 supported. + * + * 1. Open in Arduino IDE + * 2. Select Board: Arduino Uno + * 3. Select correct Port (Tools -> Port) + * 4. Upload + */ + +#define BAUDRATE 115200 +#define MAX_LINE 256 + +char lineBuf[MAX_LINE]; +int lineLen = 0; + +// Parse integer from JSON: "pin":13 or "value":1 +int parseArg(const char* key, const char* json) { + char search[32]; + snprintf(search, sizeof(search), "\"%s\":", key); + const char* p = strstr(json, search); + if (!p) return -1; + p += strlen(search); + return atoi(p); +} + +// Extract "id" for response +void copyId(char* out, int outLen, const char* json) { + const char* p = strstr(json, "\"id\":\""); + if (!p) { + out[0] = '0'; + out[1] = '\0'; + return; + } + p += 6; + int i = 0; + while (i < outLen - 1 && *p && *p != '"') { + out[i++] = *p++; + } + out[i] = '\0'; +} + +// Check if cmd is present +bool hasCmd(const char* json, const char* cmd) { + char search[64]; + snprintf(search, sizeof(search), "\"cmd\":\"%s\"", cmd); + return strstr(json, search) != NULL; +} + +void handleLine(const char* line) { + char idBuf[16]; + copyId(idBuf, sizeof(idBuf), line); + + if (hasCmd(line, "ping")) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":true,\"result\":\"pong\"}"); + return; + } + + // Phase C: Dynamic discovery — report GPIO pins and LED pin + if (hasCmd(line, "capabilities")) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":true,\"result\":\"{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}\"}"); + Serial.println(); + return; + } + + if (hasCmd(line, "gpio_read")) { + int pin = parseArg("pin", line); + if (pin < 0 || pin > 13) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin "); + Serial.print(pin); + Serial.println("\"}"); + return; + } + pinMode(pin, INPUT); + int val = digitalRead(pin); + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":true,\"result\":\""); + Serial.print(val); + Serial.println("\"}"); + return; + } + + if (hasCmd(line, "gpio_write")) { + int pin = parseArg("pin", line); + int value = parseArg("value", line); + if (pin < 0 || pin > 13) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin "); + Serial.print(pin); + Serial.println("\"}"); + return; + } + pinMode(pin, OUTPUT); + digitalWrite(pin, value ? HIGH : LOW); + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":true,\"result\":\"done\"}"); + return; + } + + // Unknown command + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}"); +} + +void setup() { + Serial.begin(BAUDRATE); + lineLen = 0; +} + +void loop() { + while (Serial.available()) { + char c = Serial.read(); + if (c == '\n' || c == '\r') { + if (lineLen > 0) { + lineBuf[lineLen] = '\0'; + handleLine(lineBuf); + lineLen = 0; + } + } else if (lineLen < MAX_LINE - 1) { + lineBuf[lineLen++] = c; + } else { + lineLen = 0; // Overflow, discard + } + } +} diff --git a/firmware/zeroclaw-esp32/.cargo/config.toml b/firmware/zeroclaw-esp32/.cargo/config.toml new file mode 100644 index 0000000..8746ad1 --- /dev/null +++ b/firmware/zeroclaw-esp32/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "riscv32imc-esp-espidf" + +[target.riscv32imc-esp-espidf] +runner = "espflash flash --monitor" diff --git a/firmware/zeroclaw-esp32/Cargo.lock b/firmware/zeroclaw-esp32/Cargo.lock new file mode 100644 index 0000000..6f8ad22 --- /dev/null +++ b/firmware/zeroclaw-esp32/Cargo.lock @@ -0,0 +1,1840 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[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 = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "build-time" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1219c19fc29b7bfd74b7968b420aff5bc951cf517800176e795d6b2300dd382" +dependencies = [ + "chrono", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-util", + "heapless", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-svc" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc" +dependencies = [ + "defmt 0.3.100", + "embedded-io", + "embedded-io-async", + "enumset", + "heapless", + "no-std-net", + "num_enum", + "serde", + "strum 0.25.0", +] + +[[package]] +name = "embuild" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563" +dependencies = [ + "anyhow", + "bindgen", + "bitflags 1.3.2", + "cmake", + "filetime", + "globwalk", + "home", + "log", + "remove_dir_all", + "serde", + "serde_json", + "shlex", + "strum 0.24.1", + "tempfile", + "thiserror 1.0.69", + "which", + "xmas-elf", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esp-idf-hal" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74" +dependencies = [ + "atomic-waker", + "embassy-sync", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io", + "embedded-io-async", + "embuild", + "enumset", + "esp-idf-sys", + "heapless", + "log", + "nb 1.1.0", + "num_enum", +] + +[[package]] +name = "esp-idf-svc" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b" +dependencies = [ + "embassy-futures", + "embedded-hal-async", + "embedded-svc", + "embuild", + "enumset", + "esp-idf-hal", + "heapless", + "log", + "num_enum", + "uncased", +] + +[[package]] +name = "esp-idf-sys" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af" +dependencies = [ + "anyhow", + "bindgen", + "build-time", + "cargo_metadata", + "const_format", + "embuild", + "envy", + "libc", + "regex", + "serde", + "strum 0.24.1", + "which", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no-std-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "remove_dir_all" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a694f9e0eb3104451127f6cc1e5de55f59d3b1fc8c5ddfaeb6f1e716479ceb4a" +dependencies = [ + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros 0.25.3", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.116", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[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.116", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +dependencies = [ + "winnow", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.116", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.116", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.116", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xmas-elf" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c49817e78342f7f30a181573d82ff55b88a35f86ccaf07fc64b3008f56d1c6" +dependencies = [ + "zero", +] + +[[package]] +name = "zero" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe21bcc34ca7fe6dd56cc2cb1261ea59d6b93620215aefb5ea6032265527784" + +[[package]] +name = "zeroclaw-esp32" +version = "0.1.0" +dependencies = [ + "anyhow", + "embuild", + "esp-idf-svc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/firmware/zeroclaw-esp32/Cargo.toml b/firmware/zeroclaw-esp32/Cargo.toml new file mode 100644 index 0000000..2f7a001 --- /dev/null +++ b/firmware/zeroclaw-esp32/Cargo.toml @@ -0,0 +1,35 @@ +# ZeroClaw ESP32 firmware — JSON-over-serial peripheral for host-mediated control. +# +# Flash to ESP32 and connect via serial. The host ZeroClaw sends gpio_read/gpio_write +# commands; this firmware executes them and responds. +# +# Prerequisites: espup (cargo install espup; espup install; source ~/export-esp.sh) +# Build: cargo build --release +# Flash: cargo espflash flash --monitor + +[package] +name = "zeroclaw-esp32" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial" + +[dependencies] +esp-idf-svc = "0.48" +log = "0.4" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[build-dependencies] +embuild = { version = "0.31", features = ["elf"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" + +[profile.dev] +opt-level = "s" diff --git a/firmware/zeroclaw-esp32/README.md b/firmware/zeroclaw-esp32/README.md new file mode 100644 index 0000000..804aaca --- /dev/null +++ b/firmware/zeroclaw-esp32/README.md @@ -0,0 +1,52 @@ +# ZeroClaw ESP32 Firmware + +Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial. + +## Protocol + +- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n` +- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n` + +Commands: `gpio_read`, `gpio_write`. + +## Prerequisites + +1. **ESP toolchain** (espup): + ```sh + cargo install espup espflash + espup install + source ~/export-esp.sh # or ~/export-esp.fish for Fish + ``` + +2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32). + +## Build & Flash + +```sh +cd firmware/zeroclaw-esp32 +cargo build --release +espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor +``` + +## Host Config + +Add to `config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "esp32" +transport = "serial" +path = "/dev/ttyUSB0" # or /dev/ttyACM0, COM3, etc. +baud = 115200 +``` + +## Pin Mapping + +Default GPIO 2 and 13 are configured for output. Edit `src/main.rs` to add more pins or change for your board. ESP32-C3 has different pin layout — adjust UART pins (gpio21/gpio20) if needed. + +## Edge-Native (Future) + +Phase 6 also envisions ZeroClaw running *on* the ESP32 (WiFi + LLM). This firmware is the host-mediated serial peripheral; edge-native will be a separate crate. diff --git a/firmware/zeroclaw-esp32/build.rs b/firmware/zeroclaw-esp32/build.rs new file mode 100644 index 0000000..112ec3f --- /dev/null +++ b/firmware/zeroclaw-esp32/build.rs @@ -0,0 +1,3 @@ +fn main() { + embuild::espidf::sysenv::output(); +} diff --git a/firmware/zeroclaw-esp32/src/main.rs b/firmware/zeroclaw-esp32/src/main.rs new file mode 100644 index 0000000..b1a487c --- /dev/null +++ b/firmware/zeroclaw-esp32/src/main.rs @@ -0,0 +1,154 @@ +//! ZeroClaw ESP32 firmware — JSON-over-serial peripheral. +//! +//! Listens for newline-delimited JSON commands on UART0, executes gpio_read/gpio_write, +//! responds with JSON. Compatible with host ZeroClaw SerialPeripheral protocol. +//! +//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md + +use esp_idf_svc::hal::gpio::PinDriver; +use esp_idf_svc::hal::prelude::*; +use esp_idf_svc::hal::uart::*; +use log::info; +use serde::{Deserialize, Serialize}; + +/// Incoming command from host. +#[derive(Debug, Deserialize)] +struct Request { + id: String, + cmd: String, + args: serde_json::Value, +} + +/// Outgoing response to host. +#[derive(Debug, Serialize)] +struct Response { + id: String, + ok: bool, + result: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn main() -> anyhow::Result<()> { + esp_idf_svc::sys::link_patches(); + esp_idf_svc::log::EspLogger::initialize_default(); + + let peripherals = Peripherals::take()?; + let pins = peripherals.pins; + + // UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board + let config = UartConfig::new().baudrate(Hertz(115_200)); + let mut uart = UartDriver::new( + peripherals.uart0, + pins.gpio21, + pins.gpio20, + Option::::None, + Option::::None, + &config, + )?; + + info!("ZeroClaw ESP32 firmware ready on UART0 (115200)"); + + let mut buf = [0u8; 512]; + let mut line = Vec::new(); + + loop { + match uart.read(&mut buf, 100) { + Ok(0) => continue, + Ok(n) => { + for &b in &buf[..n] { + if b == b'\n' { + if !line.is_empty() { + if let Ok(line_str) = std::str::from_utf8(&line) { + if let Ok(resp) = handle_request(line_str, &peripherals) { + let out = serde_json::to_string(&resp).unwrap_or_default(); + let _ = uart.write(format!("{}\n", out).as_bytes()); + } + } + line.clear(); + } + } else { + line.push(b); + if line.len() > 400 { + line.clear(); + } + } + } + } + Err(_) => {} + } + } +} + +fn handle_request( + line: &str, + peripherals: &esp_idf_svc::hal::peripherals::Peripherals, +) -> anyhow::Result { + let req: Request = serde_json::from_str(line.trim())?; + let id = req.id.clone(); + + let result = match req.cmd.as_str() { + "capabilities" => { + // Phase C: report GPIO pins and LED pin (matches Arduino protocol) + let caps = serde_json::json!({ + "gpio": [0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19], + "led_pin": 2 + }); + Ok(caps.to_string()) + } + "gpio_read" => { + let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let value = gpio_read(peripherals, pin_num)?; + Ok(value.to_string()) + } + "gpio_write" => { + let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + gpio_write(peripherals, pin_num, value)?; + Ok("done".into()) + } + _ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)), + }; + + match result { + Ok(r) => Ok(Response { + id, + ok: true, + result: r, + error: None, + }), + Err(e) => Ok(Response { + id, + ok: false, + result: String::new(), + error: Some(e.to_string()), + }), + } +} + +fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result { + // TODO: implement input pin read — requires storing InputPin drivers per pin + Ok(0) +} + +fn gpio_write( + peripherals: &esp_idf_svc::hal::peripherals::Peripherals, + pin: i32, + value: u64, +) -> anyhow::Result<()> { + let pins = peripherals.pins; + let level = value != 0; + + match pin { + 2 => { + let mut out = PinDriver::output(pins.gpio2)?; + out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?; + } + 13 => { + let mut out = PinDriver::output(pins.gpio13)?; + out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?; + } + _ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin), + } + Ok(()) +} diff --git a/firmware/zeroclaw-nucleo/Cargo.lock b/firmware/zeroclaw-nucleo/Cargo.lock new file mode 100644 index 0000000..41b57b5 --- /dev/null +++ b/firmware/zeroclaw-nucleo/Cargo.lock @@ -0,0 +1,849 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-device-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c051592f59fe68053524b4c4935249b806f72c1f544cfb7abe4f57c3be258e" +dependencies = [ + "aligned", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal", + "bitfield", + "embedded-hal 0.2.7", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "defmt-rtt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e" +dependencies = [ + "critical-section", + "defmt 1.0.1", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8" +dependencies = [ + "defmt 1.0.1", + "embassy-futures", + "embassy-hal-internal 0.3.0", + "embassy-sync", + "embassy-time", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "num-traits", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" +dependencies = [ + "defmt 0.3.100", +] + +[[package]] +name = "embassy-stm32" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088d65743a48f2cc9b3ae274ed85d6e8b68bd3ee92eb6b87b15dca2f81f7a101" +dependencies = [ + "aligned", + "bit_field", + "bitflags 2.11.0", + "block-device-driver", + "cfg-if", + "cortex-m", + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-hal-internal 0.4.0", + "embassy-net-driver", + "embassy-sync", + "embassy-time", + "embassy-time-driver", + "embassy-time-queue-utils", + "embassy-usb-driver", + "embassy-usb-synopsys-otg", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "embedded-storage", + "embedded-storage-async", + "futures-util", + "heapless 0.9.2", + "nb 1.1.0", + "proc-macro2", + "quote", + "rand_core 0.6.4", + "rand_core 0.9.5", + "sdio-host", + "static_assertions", + "stm32-fmc", + "stm32-metapac", + "trait-set", + "vcell", + "volatile-register", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-time" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa65b9284d974dad7a23bb72835c4ec85c0b540d86af7fc4098c88cff51d65" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a244c7dc22c8d0289379c8d8830cae06bb93d8f990194d0de5efb3b5ae7ba6" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a" +dependencies = [ + "defmt 1.0.1", + "embedded-io-async 0.6.1", +] + +[[package]] +name = "embassy-usb-synopsys-otg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288751f8eaa44a5cf2613f13cee0ca8e06e6638cb96e897e6834702c79084b23" +dependencies = [ + "critical-section", + "defmt 1.0.1", + "embassy-sync", + "embassy-usb-driver", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "defmt 1.0.1", + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "sdio-host" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b328e2cb950eeccd55b7f55c3a963691455dcd044cfb5354f0c5e68d2c2d6ee2" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stm32-fmc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72692594faa67f052e5e06dd34460951c21e83bc55de4feb8d2666e2f15480a2" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "stm32-metapac" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a411079520dbccc613af73172f944b7cf97ba84e3bd7381a0352b6ec7bfef03b" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "defmt 0.3.100", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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.116", +] + +[[package]] +name = "trait-set" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79e2e9c9ab44c6d7c20d5976961b47e8f49ac199154daa514b77cd1ab536625" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + +[[package]] +name = "zeroclaw-nucleo" +version = "0.1.0" +dependencies = [ + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "defmt-rtt", + "embassy-executor", + "embassy-stm32", + "embassy-time", + "heapless 0.9.2", + "panic-probe", +] diff --git a/firmware/zeroclaw-nucleo/Cargo.toml b/firmware/zeroclaw-nucleo/Cargo.toml new file mode 100644 index 0000000..a5d97f8 --- /dev/null +++ b/firmware/zeroclaw-nucleo/Cargo.toml @@ -0,0 +1,39 @@ +# ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral. +# +# Listens for newline-delimited JSON on USART2 (PA2/PA3, ST-Link VCP). +# Protocol: same as Arduino/ESP32 — ping, capabilities, gpio_read, gpio_write. +# +# Build: cargo build --release +# Flash: probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo +# Or: zeroclaw peripheral flash-nucleo + +[package] +name = "zeroclaw-nucleo" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ZeroClaw Nucleo-F401RE peripheral firmware — GPIO over JSON serial" + +[dependencies] +embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread", "defmt"] } +embassy-stm32 = { version = "0.5", features = ["defmt", "stm32f401re", "unstable-pac", "memory-x", "time-driver-tim4", "exti"] } +embassy-time = { version = "0.5", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] } +defmt = "1.0" +defmt-rtt = "1.0" +panic-probe = { version = "1.0", features = ["print-defmt"] } +heapless = { version = "0.9", default-features = false } +critical-section = "1.1" +cortex-m-rt = "0.7" + +[package.metadata.embassy] +build = [ + { target = "thumbv7em-none-eabihf", artifact-dir = "target" } +] + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" +debug = 1 diff --git a/firmware/zeroclaw-nucleo/src/main.rs b/firmware/zeroclaw-nucleo/src/main.rs new file mode 100644 index 0000000..909645e --- /dev/null +++ b/firmware/zeroclaw-nucleo/src/main.rs @@ -0,0 +1,187 @@ +//! ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral. +//! +//! Listens for newline-delimited JSON on USART2 (PA2=TX, PA3=RX). +//! USART2 is connected to ST-Link VCP — host sees /dev/ttyACM0 (Linux) or /dev/cu.usbmodem* (macOS). +//! +//! Protocol: same as Arduino/ESP32 — see docs/hardware-peripherals-design.md + +#![no_std] +#![no_main] + +use core::fmt::Write; +use core::str; +use defmt::info; +use embassy_executor::Spawner; +use embassy_stm32::gpio::{Level, Output, Speed}; +use embassy_stm32::usart::{Config, Uart}; +use heapless::String; +use {defmt_rtt as _, panic_probe as _}; + +/// Arduino-style pin 13 = PA5 (User LED LD2 on Nucleo-F401RE) +const LED_PIN: u8 = 13; + +/// Parse integer from JSON: "pin":13 or "value":1 +fn parse_arg(line: &[u8], key: &[u8]) -> Option { + // key like b"pin" -> search for b"\"pin\":" + let mut suffix: [u8; 32] = [0; 32]; + suffix[0] = b'"'; + let mut len = 1; + for (i, &k) in key.iter().enumerate() { + if i >= 30 { + break; + } + suffix[len] = k; + len += 1; + } + suffix[len] = b'"'; + suffix[len + 1] = b':'; + len += 2; + let suffix = &suffix[..len]; + + let line_len = line.len(); + if line_len < len { + return None; + } + for i in 0..=line_len - len { + if line[i..].starts_with(suffix) { + let rest = &line[i + len..]; + let mut num: i32 = 0; + let mut neg = false; + let mut j = 0; + if j < rest.len() && rest[j] == b'-' { + neg = true; + j += 1; + } + while j < rest.len() && rest[j].is_ascii_digit() { + num = num * 10 + (rest[j] - b'0') as i32; + j += 1; + } + return Some(if neg { -num } else { num }); + } + } + None +} + +fn has_cmd(line: &[u8], cmd: &[u8]) -> bool { + let mut pat: [u8; 64] = [0; 64]; + pat[0..7].copy_from_slice(b"\"cmd\":\""); + let clen = cmd.len().min(50); + pat[7..7 + clen].copy_from_slice(&cmd[..clen]); + pat[7 + clen] = b'"'; + let pat = &pat[..8 + clen]; + + let line_len = line.len(); + if line_len < pat.len() { + return false; + } + for i in 0..=line_len - pat.len() { + if line[i..].starts_with(pat) { + return true; + } + } + false +} + +/// Extract "id" for response +fn copy_id(line: &[u8], out: &mut [u8]) -> usize { + let prefix = b"\"id\":\""; + if line.len() < prefix.len() + 1 { + out[0] = b'0'; + return 1; + } + for i in 0..=line.len() - prefix.len() { + if line[i..].starts_with(prefix) { + let start = i + prefix.len(); + let mut j = 0; + while start + j < line.len() && j < out.len() - 1 && line[start + j] != b'"' { + out[j] = line[start + j]; + j += 1; + } + return j; + } + } + out[0] = b'0'; + 1 +} + +#[embassy_executor::main] +async fn main(_spawner: Spawner) { + let p = embassy_stm32::init(Default::default()); + + let mut config = Config::default(); + config.baudrate = 115_200; + + let mut usart = Uart::new_blocking(p.USART2, p.PA3, p.PA2, config).unwrap(); + let mut led = Output::new(p.PA5, Level::Low, Speed::Low); + + info!("ZeroClaw Nucleo firmware ready on USART2 (115200)"); + + let mut line_buf: heapless::Vec = heapless::Vec::new(); + let mut id_buf = [0u8; 16]; + let mut resp_buf: String<128> = String::new(); + + loop { + let mut byte = [0u8; 1]; + if usart.blocking_read(&mut byte).is_ok() { + let b = byte[0]; + if b == b'\n' || b == b'\r' { + if !line_buf.is_empty() { + let id_len = copy_id(&line_buf, &mut id_buf); + let id_str = str::from_utf8(&id_buf[..id_len]).unwrap_or("0"); + + resp_buf.clear(); + if has_cmd(&line_buf, b"ping") { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"pong\"}}", id_str); + } else if has_cmd(&line_buf, b"capabilities") { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":true,\"result\":\"{{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}}\"}}", + id_str + ); + } else if has_cmd(&line_buf, b"gpio_read") { + let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1); + if pin == LED_PIN as i32 { + // Output doesn't support read; return 0 (LED state not readable) + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str); + } else if pin >= 0 && pin <= 13 { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str); + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}", + id_str, pin + ); + } + } else if has_cmd(&line_buf, b"gpio_write") { + let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1); + let value = parse_arg(&line_buf, b"value").unwrap_or(0); + if pin == LED_PIN as i32 { + led.set_level(if value != 0 { Level::High } else { Level::Low }); + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str); + } else if pin >= 0 && pin <= 13 { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str); + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}", + id_str, pin + ); + } + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}}", + id_str + ); + } + + let _ = usart.blocking_write(resp_buf.as_bytes()); + let _ = usart.blocking_write(b"\n"); + line_buf.clear(); + } + } else if line_buf.push(b).is_err() { + line_buf.clear(); + } + } + } +} diff --git a/firmware/zeroclaw-uno-q-bridge/app.yaml b/firmware/zeroclaw-uno-q-bridge/app.yaml new file mode 100644 index 0000000..32c5eb6 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/app.yaml @@ -0,0 +1,9 @@ +name: ZeroClaw Bridge +description: "GPIO bridge for ZeroClaw — exposes digitalWrite/digitalRead via socket for agent control" +icon: 🦀 +version: "1.0.0" + +ports: + - 9999 + +bricks: [] diff --git a/firmware/zeroclaw-uno-q-bridge/python/main.py b/firmware/zeroclaw-uno-q-bridge/python/main.py new file mode 100644 index 0000000..d4b286b --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/python/main.py @@ -0,0 +1,66 @@ +# ZeroClaw Bridge — socket server for GPIO control from ZeroClaw agent +# SPDX-License-Identifier: MPL-2.0 + +import socket +import threading +from arduino.app_utils import App, Bridge + +ZEROCLAW_PORT = 9999 + +def handle_client(conn): + try: + data = conn.recv(256).decode().strip() + if not data: + conn.close() + return + parts = data.split() + if len(parts) < 2: + conn.sendall(b"error: invalid command\n") + conn.close() + return + cmd = parts[0].lower() + if cmd == "gpio_write" and len(parts) >= 3: + pin = int(parts[1]) + value = int(parts[2]) + Bridge.call("digitalWrite", [pin, value]) + conn.sendall(b"ok\n") + elif cmd == "gpio_read" and len(parts) >= 2: + pin = int(parts[1]) + val = Bridge.call("digitalRead", [pin]) + conn.sendall(f"{val}\n".encode()) + else: + conn.sendall(b"error: unknown command\n") + except Exception as e: + try: + conn.sendall(f"error: {e}\n".encode()) + except Exception: + pass + finally: + conn.close() + +def accept_loop(server): + while True: + try: + conn, _ = server.accept() + t = threading.Thread(target=handle_client, args=(conn,)) + t.daemon = True + t.start() + except Exception: + break + +def loop(): + App.sleep(1) + +def main(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", ZEROCLAW_PORT)) + server.listen(5) + server.settimeout(1.0) + t = threading.Thread(target=accept_loop, args=(server,)) + t.daemon = True + t.start() + App.run(user_loop=loop) + +if __name__ == "__main__": + main() diff --git a/firmware/zeroclaw-uno-q-bridge/python/requirements.txt b/firmware/zeroclaw-uno-q-bridge/python/requirements.txt new file mode 100644 index 0000000..a7fe2e0 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/python/requirements.txt @@ -0,0 +1 @@ +# ZeroClaw Bridge — no extra deps (arduino.app_utils is preinstalled on Uno Q) diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino new file mode 100644 index 0000000..0e7b11b --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino @@ -0,0 +1,24 @@ +// ZeroClaw Bridge — expose digitalWrite/digitalRead for agent GPIO control +// SPDX-License-Identifier: MPL-2.0 + +#include "Arduino_RouterBridge.h" + +void gpio_write(int pin, int value) { + pinMode(pin, OUTPUT); + digitalWrite(pin, value ? HIGH : LOW); +} + +int gpio_read(int pin) { + pinMode(pin, INPUT); + return digitalRead(pin); +} + +void setup() { + Bridge.begin(); + Bridge.provide("digitalWrite", gpio_write); + Bridge.provide("digitalRead", gpio_read); +} + +void loop() { + Bridge.update(); +} diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml new file mode 100644 index 0000000..d9fe917 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) +default_profile: default diff --git a/src/agent/agent.rs b/src/agent/agent.rs new file mode 100644 index 0000000..45b4d54 --- /dev/null +++ b/src/agent/agent.rs @@ -0,0 +1,705 @@ +use crate::agent::dispatcher::{ + NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher, +}; +use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader}; +use crate::agent::prompt::{PromptContext, SystemPromptBuilder}; +use crate::config::Config; +use crate::memory::{self, Memory, MemoryCategory}; +use crate::observability::{self, Observer, ObserverEvent}; +use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider}; +use crate::runtime; +use crate::security::SecurityPolicy; +use crate::tools::{self, Tool, ToolSpec}; +use crate::util::truncate_with_ellipsis; +use anyhow::Result; +use std::io::Write as IoWrite; +use std::sync::Arc; +use std::time::Instant; + +pub struct Agent { + provider: Box, + tools: Vec>, + tool_specs: Vec, + memory: Arc, + observer: Arc, + prompt_builder: SystemPromptBuilder, + tool_dispatcher: Box, + memory_loader: Box, + config: crate::config::AgentConfig, + model_name: String, + temperature: f64, + workspace_dir: std::path::PathBuf, + identity_config: crate::config::IdentityConfig, + skills: Vec, + auto_save: bool, + history: Vec, +} + +pub struct AgentBuilder { + provider: Option>, + tools: Option>>, + memory: Option>, + observer: Option>, + prompt_builder: Option, + tool_dispatcher: Option>, + memory_loader: Option>, + config: Option, + model_name: Option, + temperature: Option, + workspace_dir: Option, + identity_config: Option, + skills: Option>, + auto_save: Option, +} + +impl AgentBuilder { + pub fn new() -> Self { + Self { + provider: None, + tools: None, + memory: None, + observer: None, + prompt_builder: None, + tool_dispatcher: None, + memory_loader: None, + config: None, + model_name: None, + temperature: None, + workspace_dir: None, + identity_config: None, + skills: None, + auto_save: None, + } + } + + pub fn provider(mut self, provider: Box) -> Self { + self.provider = Some(provider); + self + } + + pub fn tools(mut self, tools: Vec>) -> Self { + self.tools = Some(tools); + self + } + + pub fn memory(mut self, memory: Arc) -> Self { + self.memory = Some(memory); + self + } + + pub fn observer(mut self, observer: Arc) -> Self { + self.observer = Some(observer); + self + } + + pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self { + self.prompt_builder = Some(prompt_builder); + self + } + + pub fn tool_dispatcher(mut self, tool_dispatcher: Box) -> Self { + self.tool_dispatcher = Some(tool_dispatcher); + self + } + + pub fn memory_loader(mut self, memory_loader: Box) -> Self { + self.memory_loader = Some(memory_loader); + self + } + + pub fn config(mut self, config: crate::config::AgentConfig) -> Self { + self.config = Some(config); + self + } + + pub fn model_name(mut self, model_name: String) -> Self { + self.model_name = Some(model_name); + self + } + + pub fn temperature(mut self, temperature: f64) -> Self { + self.temperature = Some(temperature); + self + } + + pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self { + self.workspace_dir = Some(workspace_dir); + self + } + + pub fn identity_config(mut self, identity_config: crate::config::IdentityConfig) -> Self { + self.identity_config = Some(identity_config); + self + } + + pub fn skills(mut self, skills: Vec) -> Self { + self.skills = Some(skills); + self + } + + pub fn auto_save(mut self, auto_save: bool) -> Self { + self.auto_save = Some(auto_save); + self + } + + pub fn build(self) -> Result { + let tools = self + .tools + .ok_or_else(|| anyhow::anyhow!("tools are required"))?; + let tool_specs = tools.iter().map(|tool| tool.spec()).collect(); + + Ok(Agent { + provider: self + .provider + .ok_or_else(|| anyhow::anyhow!("provider is required"))?, + tools, + tool_specs, + memory: self + .memory + .ok_or_else(|| anyhow::anyhow!("memory is required"))?, + observer: self + .observer + .ok_or_else(|| anyhow::anyhow!("observer is required"))?, + prompt_builder: self + .prompt_builder + .unwrap_or_else(SystemPromptBuilder::with_defaults), + tool_dispatcher: self + .tool_dispatcher + .ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?, + memory_loader: self + .memory_loader + .unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())), + config: self.config.unwrap_or_default(), + model_name: self + .model_name + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()), + temperature: self.temperature.unwrap_or(0.7), + workspace_dir: self + .workspace_dir + .unwrap_or_else(|| std::path::PathBuf::from(".")), + identity_config: self.identity_config.unwrap_or_default(), + skills: self.skills.unwrap_or_default(), + auto_save: self.auto_save.unwrap_or(false), + history: Vec::new(), + }) + } +} + +impl Agent { + pub fn builder() -> AgentBuilder { + AgentBuilder::new() + } + + pub fn history(&self) -> &[ConversationMessage] { + &self.history + } + + pub fn clear_history(&mut self) { + self.history.clear(); + } + + pub fn from_config(config: &Config) -> Result { + let observer: Arc = + Arc::from(observability::create_observer(&config.observability)); + let runtime: Arc = + Arc::from(runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + + let memory: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + config.api_key.as_deref(), + )?); + + let composio_key = if config.composio.enabled { + config.composio.api_key.as_deref() + } else { + None + }; + let composio_entity_id = if config.composio.enabled { + Some(config.composio.entity_id.as_str()) + } else { + None + }; + + let tools = tools::all_tools_with_runtime( + &security, + runtime, + memory.clone(), + composio_key, + composio_entity_id, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + config, + ); + + let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); + + let model_name = config + .default_model + .as_deref() + .unwrap_or("anthropic/claude-sonnet-4-20250514") + .to_string(); + + let provider: Box = providers::create_routed_provider( + provider_name, + config.api_key.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + )?; + + let dispatcher_choice = config.agent.tool_dispatcher.as_str(); + let tool_dispatcher: Box = match dispatcher_choice { + "native" => Box::new(NativeToolDispatcher), + "xml" => Box::new(XmlToolDispatcher), + _ if provider.supports_native_tools() => Box::new(NativeToolDispatcher), + _ => Box::new(XmlToolDispatcher), + }; + + Agent::builder() + .provider(provider) + .tools(tools) + .memory(memory) + .observer(observer) + .tool_dispatcher(tool_dispatcher) + .memory_loader(Box::new(DefaultMemoryLoader::default())) + .prompt_builder(SystemPromptBuilder::with_defaults()) + .config(config.agent.clone()) + .model_name(model_name) + .temperature(config.default_temperature) + .workspace_dir(config.workspace_dir.clone()) + .identity_config(config.identity.clone()) + .skills(crate::skills::load_skills(&config.workspace_dir)) + .auto_save(config.memory.auto_save) + .build() + } + + fn trim_history(&mut self) { + let max = self.config.max_history_messages; + if self.history.len() <= max { + return; + } + + let mut system_messages = Vec::new(); + let mut other_messages = Vec::new(); + + for msg in self.history.drain(..) { + match &msg { + ConversationMessage::Chat(chat) if chat.role == "system" => { + system_messages.push(msg); + } + _ => other_messages.push(msg), + } + } + + if other_messages.len() > max { + let drop_count = other_messages.len() - max; + other_messages.drain(0..drop_count); + } + + self.history = system_messages; + self.history.extend(other_messages); + } + + fn build_system_prompt(&self) -> Result { + let instructions = self.tool_dispatcher.prompt_instructions(&self.tools); + let ctx = PromptContext { + workspace_dir: &self.workspace_dir, + model_name: &self.model_name, + tools: &self.tools, + skills: &self.skills, + identity_config: Some(&self.identity_config), + dispatcher_instructions: &instructions, + }; + self.prompt_builder.build(&ctx) + } + + async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult { + let start = Instant::now(); + + let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) { + match tool.execute(call.arguments.clone()).await { + Ok(r) => { + self.observer.record_event(&ObserverEvent::ToolCall { + tool: call.name.clone(), + duration: start.elapsed(), + success: r.success, + }); + if r.success { + r.output + } else { + format!("Error: {}", r.error.unwrap_or(r.output)) + } + } + Err(e) => { + self.observer.record_event(&ObserverEvent::ToolCall { + tool: call.name.clone(), + duration: start.elapsed(), + success: false, + }); + format!("Error executing {}: {e}", call.name) + } + } + } else { + format!("Unknown tool: {}", call.name) + }; + + ToolExecutionResult { + name: call.name.clone(), + output: result, + success: true, + tool_call_id: call.tool_call_id.clone(), + } + } + + async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec { + if !self.config.parallel_tools { + let mut results = Vec::with_capacity(calls.len()); + for call in calls { + results.push(self.execute_tool_call(call).await); + } + return results; + } + + let mut results = Vec::with_capacity(calls.len()); + for call in calls { + results.push(self.execute_tool_call(call).await); + } + results + } + + pub async fn turn(&mut self, user_message: &str) -> Result { + if self.history.is_empty() { + let system_prompt = self.build_system_prompt()?; + self.history + .push(ConversationMessage::Chat(ChatMessage::system( + system_prompt, + ))); + } + + if self.auto_save { + let _ = self + .memory + .store("user_msg", user_message, MemoryCategory::Conversation) + .await; + } + + let context = self + .memory_loader + .load_context(self.memory.as_ref(), user_message) + .await + .unwrap_or_default(); + + let enriched = if context.is_empty() { + user_message.to_string() + } else { + format!("{context}{user_message}") + }; + + self.history + .push(ConversationMessage::Chat(ChatMessage::user(enriched))); + + for _ in 0..self.config.max_tool_iterations { + let messages = self.tool_dispatcher.to_provider_messages(&self.history); + let response = match self + .provider + .chat( + ChatRequest { + messages: &messages, + tools: if self.tool_dispatcher.should_send_tool_specs() { + Some(&self.tool_specs) + } else { + None + }, + }, + &self.model_name, + self.temperature, + ) + .await + { + Ok(resp) => resp, + Err(err) => return Err(err), + }; + + let (text, calls) = self.tool_dispatcher.parse_response(&response); + if calls.is_empty() { + let final_text = if text.is_empty() { + response.text.unwrap_or_default() + } else { + text + }; + + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + final_text.clone(), + ))); + self.trim_history(); + + if self.auto_save { + let summary = truncate_with_ellipsis(&final_text, 100); + let _ = self + .memory + .store("assistant_resp", &summary, MemoryCategory::Daily) + .await; + } + + return Ok(final_text); + } + + if !text.is_empty() { + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + text.clone(), + ))); + print!("{text}"); + let _ = std::io::stdout().flush(); + } + + self.history.push(ConversationMessage::AssistantToolCalls { + text: response.text.clone(), + tool_calls: response.tool_calls.clone(), + }); + + let results = self.execute_tools(&calls).await; + let formatted = self.tool_dispatcher.format_results(&results); + self.history.push(formatted); + self.trim_history(); + } + + anyhow::bail!( + "Agent exceeded maximum tool iterations ({})", + self.config.max_tool_iterations + ) + } + + pub async fn run_single(&mut self, message: &str) -> Result { + self.turn(message).await + } + + pub async fn run_interactive(&mut self) -> Result<()> { + println!("🦀 ZeroClaw Interactive Mode"); + println!("Type /quit to exit.\n"); + + let (tx, mut rx) = tokio::sync::mpsc::channel(32); + let cli = crate::channels::CliChannel::new(); + + let listen_handle = tokio::spawn(async move { + let _ = crate::channels::Channel::listen(&cli, tx).await; + }); + + while let Some(msg) = rx.recv().await { + let response = match self.turn(&msg.content).await { + Ok(resp) => resp, + Err(e) => { + eprintln!("\nError: {e}\n"); + continue; + } + }; + println!("\n{response}\n"); + } + + listen_handle.abort(); + Ok(()) + } +} + +pub async fn run( + config: Config, + message: Option, + provider_override: Option, + model_override: Option, + temperature: f64, +) -> Result<()> { + let start = Instant::now(); + + let mut effective_config = config; + if let Some(p) = provider_override { + effective_config.default_provider = Some(p); + } + if let Some(m) = model_override { + effective_config.default_model = Some(m); + } + effective_config.default_temperature = temperature; + + let mut agent = Agent::from_config(&effective_config)?; + + let provider_name = effective_config + .default_provider + .as_deref() + .unwrap_or("openrouter") + .to_string(); + let model_name = effective_config + .default_model + .as_deref() + .unwrap_or("anthropic/claude-sonnet-4-20250514") + .to_string(); + + agent.observer.record_event(&ObserverEvent::AgentStart { + provider: provider_name, + model: model_name, + }); + + if let Some(msg) = message { + let response = agent.run_single(&msg).await?; + println!("{response}"); + } else { + agent.run_interactive().await?; + } + + agent.observer.record_event(&ObserverEvent::AgentEnd { + duration: start.elapsed(), + tokens_used: None, + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::sync::Mutex; + + struct MockProvider { + responses: Mutex>, + } + + #[async_trait] + impl Provider for MockProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> Result { + Ok("ok".into()) + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> Result { + let mut guard = self.responses.lock().unwrap(); + if guard.is_empty() { + return Ok(crate::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + }); + } + Ok(guard.remove(0)) + } + } + + struct MockTool; + + #[async_trait] + impl Tool for MockTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "echo" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute(&self, _args: serde_json::Value) -> Result { + Ok(crate::tools::ToolResult { + success: true, + output: "tool-out".into(), + error: None, + }) + } + } + + #[tokio::test] + async fn turn_without_tools_returns_text() { + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![crate::providers::ChatResponse { + text: Some("hello".into()), + tool_calls: vec![], + }]), + }); + + let memory_cfg = crate::config::MemoryConfig { + backend: "none".into(), + ..crate::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(), + ); + + let observer: Arc = Arc::from(crate::observability::NoopObserver {}); + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .observer(observer) + .tool_dispatcher(Box::new(XmlToolDispatcher)) + .workspace_dir(std::path::PathBuf::from("/tmp")) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "hello"); + } + + #[tokio::test] + async fn turn_with_native_dispatcher_handles_tool_results_variant() { + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![ + crate::providers::ChatResponse { + text: Some(String::new()), + tool_calls: vec![crate::providers::ToolCall { + id: "tc1".into(), + name: "echo".into(), + arguments: "{}".into(), + }], + }, + crate::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + }, + ]), + }); + + let memory_cfg = crate::config::MemoryConfig { + backend: "none".into(), + ..crate::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(), + ); + + let observer: Arc = Arc::from(crate::observability::NoopObserver {}); + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .observer(observer) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(std::path::PathBuf::from("/tmp")) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "done"); + assert!(agent + .history() + .iter() + .any(|msg| matches!(msg, ConversationMessage::ToolResults(_)))); + } +} diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs new file mode 100644 index 0000000..673ec8c --- /dev/null +++ b/src/agent/dispatcher.rs @@ -0,0 +1,312 @@ +use crate::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage}; +use crate::tools::{Tool, ToolSpec}; +use serde_json::Value; +use std::fmt::Write; + +#[derive(Debug, Clone)] +pub struct ParsedToolCall { + pub name: String, + pub arguments: Value, + pub tool_call_id: Option, +} + +#[derive(Debug, Clone)] +pub struct ToolExecutionResult { + pub name: String, + pub output: String, + pub success: bool, + pub tool_call_id: Option, +} + +pub trait ToolDispatcher: Send + Sync { + fn parse_response(&self, response: &ChatResponse) -> (String, Vec); + fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage; + fn prompt_instructions(&self, tools: &[Box]) -> String; + fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec; + fn should_send_tool_specs(&self) -> bool; +} + +#[derive(Default)] +pub struct XmlToolDispatcher; + +impl XmlToolDispatcher { + fn parse_xml_tool_calls(response: &str) -> (String, Vec) { + let mut text_parts = Vec::new(); + let mut calls = Vec::new(); + let mut remaining = response; + + while let Some(start) = remaining.find("") { + let before = &remaining[..start]; + if !before.trim().is_empty() { + text_parts.push(before.trim().to_string()); + } + + if let Some(end) = remaining[start..].find("") { + let inner = &remaining[start + 11..start + end]; + match serde_json::from_str::(inner.trim()) { + Ok(parsed) => { + let name = parsed + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if name.is_empty() { + remaining = &remaining[start + end + 12..]; + continue; + } + let arguments = parsed + .get("arguments") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + calls.push(ParsedToolCall { + name, + arguments, + tool_call_id: None, + }); + } + Err(e) => { + tracing::warn!("Malformed JSON: {e}"); + } + } + remaining = &remaining[start + end + 12..]; + } else { + break; + } + } + + if !remaining.trim().is_empty() { + text_parts.push(remaining.trim().to_string()); + } + + (text_parts.join("\n"), calls) + } + + pub fn tool_specs(tools: &[Box]) -> Vec { + tools.iter().map(|tool| tool.spec()).collect() + } +} + +impl ToolDispatcher for XmlToolDispatcher { + fn parse_response(&self, response: &ChatResponse) -> (String, Vec) { + let text = response.text_or_empty(); + Self::parse_xml_tool_calls(text) + } + + fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage { + let mut content = String::new(); + for result in results { + let status = if result.success { "ok" } else { "error" }; + let _ = writeln!( + content, + "\n{}\n", + result.name, status, result.output + ); + } + ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}"))) + } + + fn prompt_instructions(&self, tools: &[Box]) -> String { + let mut instructions = String::new(); + instructions.push_str("## Tool Use Protocol\n\n"); + instructions + .push_str("To use a tool, wrap a JSON object in tags:\n\n"); + instructions.push_str( + "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n", + ); + instructions.push_str("### Available Tools\n\n"); + + for tool in tools { + let _ = writeln!( + instructions, + "- **{}**: {}\n Parameters: `{}`", + tool.name(), + tool.description(), + tool.parameters_schema() + ); + } + + instructions + } + + fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec { + history + .iter() + .flat_map(|msg| match msg { + ConversationMessage::Chat(chat) => vec![chat.clone()], + ConversationMessage::AssistantToolCalls { text, .. } => { + vec![ChatMessage::assistant(text.clone().unwrap_or_default())] + } + ConversationMessage::ToolResults(results) => { + let mut content = String::new(); + for result in results { + let _ = writeln!( + content, + "\n{}\n", + result.tool_call_id, result.content + ); + } + vec![ChatMessage::user(format!("[Tool results]\n{content}"))] + } + }) + .collect() + } + + fn should_send_tool_specs(&self) -> bool { + false + } +} + +pub struct NativeToolDispatcher; + +impl ToolDispatcher for NativeToolDispatcher { + fn parse_response(&self, response: &ChatResponse) -> (String, Vec) { + let text = response.text.clone().unwrap_or_default(); + let calls = response + .tool_calls + .iter() + .map(|tc| ParsedToolCall { + name: tc.name.clone(), + arguments: serde_json::from_str(&tc.arguments) + .unwrap_or_else(|_| Value::Object(serde_json::Map::new())), + tool_call_id: Some(tc.id.clone()), + }) + .collect(); + (text, calls) + } + + fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage { + let messages = results + .iter() + .map(|result| ToolResultMessage { + tool_call_id: result + .tool_call_id + .clone() + .unwrap_or_else(|| "unknown".to_string()), + content: result.output.clone(), + }) + .collect(); + ConversationMessage::ToolResults(messages) + } + + fn prompt_instructions(&self, _tools: &[Box]) -> String { + String::new() + } + + fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec { + history + .iter() + .flat_map(|msg| match msg { + ConversationMessage::Chat(chat) => vec![chat.clone()], + ConversationMessage::AssistantToolCalls { text, tool_calls } => { + let payload = serde_json::json!({ + "content": text, + "tool_calls": tool_calls, + }); + vec![ChatMessage::assistant(payload.to_string())] + } + ConversationMessage::ToolResults(results) => results + .iter() + .map(|result| { + ChatMessage::tool( + serde_json::json!({ + "tool_call_id": result.tool_call_id, + "content": result.content, + }) + .to_string(), + ) + }) + .collect(), + }) + .collect() + } + + fn should_send_tool_specs(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xml_dispatcher_parses_tool_calls() { + let response = ChatResponse { + text: Some( + "Checking\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}" + .into(), + ), + tool_calls: vec![], + }; + let dispatcher = XmlToolDispatcher; + let (_, calls) = dispatcher.parse_response(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); + } + + #[test] + fn native_dispatcher_roundtrip() { + let response = ChatResponse { + text: Some("ok".into()), + tool_calls: vec![crate::providers::ToolCall { + id: "tc1".into(), + name: "file_read".into(), + arguments: "{\"path\":\"a.txt\"}".into(), + }], + }; + let dispatcher = NativeToolDispatcher; + let (_, calls) = dispatcher.parse_response(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].tool_call_id.as_deref(), Some("tc1")); + + let msg = dispatcher.format_results(&[ToolExecutionResult { + name: "file_read".into(), + output: "hello".into(), + success: true, + tool_call_id: Some("tc1".into()), + }]); + match msg { + ConversationMessage::ToolResults(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_call_id, "tc1"); + } + _ => panic!("expected tool results"), + } + } + + #[test] + fn xml_format_results_contains_tool_result_tags() { + let dispatcher = XmlToolDispatcher; + let msg = dispatcher.format_results(&[ToolExecutionResult { + name: "shell".into(), + output: "ok".into(), + success: true, + tool_call_id: None, + }]); + let rendered = match msg { + ConversationMessage::Chat(chat) => chat.content, + _ => String::new(), + }; + assert!(rendered.contains(" { + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_call_id, "tc-1"); + } + _ => panic!("expected ToolResults variant"), + } + } +} diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 932606f..1888866 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -8,11 +8,10 @@ use crate::tools::{self, Tool}; use crate::util::truncate_with_ellipsis; use anyhow::Result; use std::fmt::Write; -use std::io::Write as IoWrite; +use std::io::Write as _; use std::sync::Arc; use std::time::Instant; use uuid::Uuid; - /// Maximum agentic tool-use iterations per user message to prevent runaway loops. const MAX_TOOL_ITERATIONS: usize = 10; @@ -113,7 +112,6 @@ async fn auto_compact_history( let summary_raw = provider .chat_with_system(Some(summarizer_system), &summarizer_user, model, 0.2) .await - .map(|resp| resp.text_or_empty().to_string()) .unwrap_or_else(|_| { // Fallback to deterministic local truncation when summarization fails. truncate_with_ellipsis(&transcript, COMPACTION_MAX_SUMMARY_CHARS) @@ -143,6 +141,46 @@ async fn build_context(mem: &dyn Memory, user_msg: &str) -> String { context } +/// Build hardware datasheet context from RAG when peripherals are enabled. +/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks. +fn build_hardware_context( + rag: &crate::rag::HardwareRag, + user_msg: &str, + boards: &[String], + chunk_limit: usize, +) -> String { + if rag.is_empty() || boards.is_empty() { + return String::new(); + } + + let mut context = String::new(); + + // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards + let pin_ctx = rag.pin_alias_context(user_msg, boards); + if !pin_ctx.is_empty() { + context.push_str(&pin_ctx); + } + + let chunks = rag.retrieve(user_msg, boards, chunk_limit); + if chunks.is_empty() && pin_ctx.is_empty() { + return String::new(); + } + + if !chunks.is_empty() { + context.push_str("[Hardware documentation]\n"); + } + for chunk in chunks { + let board_tag = chunk.board.as_deref().unwrap_or("generic"); + let _ = writeln!( + context, + "--- {} ({}) ---\n{}\n", + chunk.source, board_tag, chunk.content + ); + } + context.push('\n'); + context +} + /// Find a tool by name in the registry. fn find_tool<'a>(tools: &'a [Box], name: &str) -> Option<&'a dyn Tool> { tools.iter().find(|t| t.name() == name).map(|t| t.as_ref()) @@ -370,10 +408,9 @@ struct ParsedToolCall { arguments: serde_json::Value, } -/// Execute a single turn for channel runtime paths. -/// -/// Channel runtime now provides an explicit provider label so observer events -/// stay consistent with the main agent loop execution path. +/// Execute a single turn of the agent loop: send messages, parse tool calls, +/// execute tools, and loop until the LLM produces a final text response. +/// When `silent` is true, suppresses stdout (for channel use). pub(crate) async fn agent_turn( provider: &dyn Provider, history: &mut Vec, @@ -382,6 +419,7 @@ pub(crate) async fn agent_turn( provider_name: &str, model: &str, temperature: f64, + silent: bool, ) -> Result { run_tool_call_loop( provider, @@ -391,6 +429,7 @@ pub(crate) async fn agent_turn( provider_name, model, temperature, + silent, ) .await } @@ -405,6 +444,7 @@ pub(crate) async fn run_tool_call_loop( provider_name: &str, model: &str, temperature: f64, + silent: bool, ) -> Result { for _iteration in 0..MAX_TOOL_ITERATIONS { observer.record_event(&ObserverEvent::LlmRequest { @@ -440,35 +480,24 @@ pub(crate) async fn run_tool_call_loop( } }; - let response_text = response.text.unwrap_or_default(); + let response_text = response; let mut assistant_history_content = response_text.clone(); - let mut parsed_text = response_text.clone(); - let mut tool_calls = parse_structured_tool_calls(&response.tool_calls); - - if !response.tool_calls.is_empty() { - assistant_history_content = - build_assistant_history_with_tool_calls(&response_text, &response.tool_calls); - } - - if tool_calls.is_empty() { - let (fallback_text, fallback_calls) = parse_tool_calls(&response_text); - parsed_text = fallback_text; - tool_calls = fallback_calls; - } + let (parsed_text, tool_calls) = parse_tool_calls(&response_text); + let mut parsed_text = parsed_text; + let mut tool_calls = tool_calls; if tool_calls.is_empty() { // No tool calls — this is the final response - let final_text = if parsed_text.is_empty() { + history.push(ChatMessage::assistant(response_text.clone())); + return Ok(if parsed_text.is_empty() { response_text } else { parsed_text - }; - history.push(ChatMessage::assistant(&final_text)); - return Ok(final_text); + }); } - // Print any text the LLM produced alongside tool calls - if !parsed_text.is_empty() { + // Print any text the LLM produced alongside tool calls (unless silent) + if !silent && !parsed_text.is_empty() { print!("{parsed_text}"); let _ = std::io::stdout().flush(); } @@ -515,7 +544,7 @@ pub(crate) async fn run_tool_call_loop( } // Add assistant message with tool calls + tool results to history - history.push(ChatMessage::assistant(&assistant_history_content)); + history.push(ChatMessage::assistant(assistant_history_content.clone())); history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}"))); } @@ -529,6 +558,10 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> Strin instructions.push_str("\n## Tool Use Protocol\n\n"); instructions.push_str("To use a tool, wrap a JSON object in tags:\n\n"); instructions.push_str("```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n"); + instructions.push_str( + "CRITICAL: Output actual tags—never describe steps or give examples.\n\n", + ); + instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n\n\n"); instructions.push_str("You may use multiple tool calls in a single response. "); instructions.push_str("After tool execution, results appear in tags. "); instructions @@ -555,18 +588,11 @@ pub async fn run( provider_override: Option, model_override: Option, temperature: f64, - verbose: bool, + peripheral_overrides: Vec, ) -> Result<()> { // ── Wire up agnostic subsystems ────────────────────────────── let base_observer = observability::create_observer(&config.observability); - let observer: Arc = if verbose { - Arc::from(Box::new(observability::MultiObserver::new(vec![ - base_observer, - Box::new(observability::VerboseObserver::new()), - ])) as Box) - } else { - Arc::from(base_observer) - }; + let observer: Arc = Arc::from(base_observer); let runtime: Arc = Arc::from(runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( @@ -582,7 +608,15 @@ pub async fn run( )?); tracing::info!(backend = mem.name(), "Memory initialized"); - // ── Tools (including memory tools) ──────────────────────────── + // ── Peripherals (merge peripheral tools into registry) ─ + if !peripheral_overrides.is_empty() { + tracing::info!( + peripherals = ?peripheral_overrides, + "Peripheral overrides from CLI (config boards take precedence)" + ); + } + + // ── Tools (including memory tools and peripherals) ──────────── let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), @@ -591,7 +625,7 @@ pub async fn run( } else { (None, None) }; - let tools_registry = tools::all_tools_with_runtime( + let mut tools_registry = tools::all_tools_with_runtime( &security, runtime, mem.clone(), @@ -605,6 +639,13 @@ pub async fn run( &config, ); + let peripheral_tools: Vec> = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + if !peripheral_tools.is_empty() { + tracing::info!(count = peripheral_tools.len(), "Peripheral tools added"); + tools_registry.extend(peripheral_tools); + } + // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override .as_deref() @@ -629,6 +670,26 @@ pub async fn run( model: model_name.to_string(), }); + // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ── + let hardware_rag: Option = config + .peripherals + .datasheet_dir + .as_ref() + .filter(|d| !d.trim().is_empty()) + .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) + .and_then(Result::ok) + .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); + if let Some(ref rag) = hardware_rag { + tracing::info!(chunks = rag.len(), "Hardware RAG loaded"); + } + + let board_names: Vec = config + .peripherals + .boards + .iter() + .map(|b| b.board.clone()) + .collect(); + // ── Build system prompt from workspace MD files (OpenClaw framework) ── let skills = crate::skills::load_skills(&config.workspace_dir); let mut tool_descs: Vec<(&str, &str)> = vec![ @@ -684,17 +745,51 @@ pub async fn run( if !config.agents.is_empty() { tool_descs.push(( "delegate", - "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \ - (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \ - prompt and returns its response.", + "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.", )); } + if config.peripherals.enabled && !config.peripherals.boards.is_empty() { + tool_descs.push(( + "gpio_read", + "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.", + )); + tool_descs.push(( + "gpio_write", + "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.", + )); + tool_descs.push(( + "arduino_upload", + "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.", + )); + tool_descs.push(( + "hardware_memory_map", + "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.", + )); + tool_descs.push(( + "hardware_board_info", + "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.", + )); + tool_descs.push(( + "hardware_memory_read", + "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).", + )); + tool_descs.push(( + "hardware_capabilities", + "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.", + )); + } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; let mut system_prompt = crate::channels::build_system_prompt( &config.workspace_dir, model_name, &tool_descs, &skills, Some(&config.identity), + bootstrap_max_chars, ); // Append structured tool-use instructions with schemas @@ -712,8 +807,14 @@ pub async fn run( .await; } - // Inject memory context into user message - let context = build_context(mem.as_ref(), &msg).await; + // Inject memory + hardware RAG context into user message + let mem_context = build_context(mem.as_ref(), &msg).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, &msg, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); let enriched = if context.is_empty() { msg.clone() } else { @@ -733,6 +834,7 @@ pub async fn run( provider_name, model_name, temperature, + false, ) .await?; println!("{response}"); @@ -770,8 +872,14 @@ pub async fn run( .await; } - // Inject memory context into user message - let context = build_context(mem.as_ref(), &msg.content).await; + // Inject memory + hardware RAG context into user message + let mem_context = build_context(mem.as_ref(), &msg.content).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, &msg.content, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); let enriched = if context.is_empty() { msg.content.clone() } else { @@ -788,6 +896,7 @@ pub async fn run( provider_name, model_name, temperature, + false, ) .await { @@ -833,6 +942,166 @@ pub async fn run( Ok(()) } +/// Process a single message through the full agent (with tools, peripherals, memory). +/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use. +pub async fn process_message(config: Config, message: &str) -> Result { + let observer: Arc = + Arc::from(observability::create_observer(&config.observability)); + let runtime: Arc = + Arc::from(runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + let mem: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + config.api_key.as_deref(), + )?); + + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) + } else { + (None, None) + }; + let mut tools_registry = tools::all_tools_with_runtime( + &security, + runtime, + mem.clone(), + composio_key, + composio_entity_id, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + &config, + ); + let peripheral_tools: Vec> = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + tools_registry.extend(peripheral_tools); + + let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); + let model_name = config + .default_model + .clone() + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); + let provider: Box = providers::create_routed_provider( + provider_name, + config.api_key.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + )?; + + let hardware_rag: Option = config + .peripherals + .datasheet_dir + .as_ref() + .filter(|d| !d.trim().is_empty()) + .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) + .and_then(Result::ok) + .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); + let board_names: Vec = config + .peripherals + .boards + .iter() + .map(|b| b.board.clone()) + .collect(); + + let skills = crate::skills::load_skills(&config.workspace_dir); + let mut tool_descs: Vec<(&str, &str)> = vec![ + ("shell", "Execute terminal commands."), + ("file_read", "Read file contents."), + ("file_write", "Write file contents."), + ("memory_store", "Save to memory."), + ("memory_recall", "Search memory."), + ("memory_forget", "Delete a memory entry."), + ("screenshot", "Capture a screenshot."), + ("image_info", "Read image metadata."), + ]; + if config.browser.enabled { + tool_descs.push(("browser_open", "Open approved URLs in browser.")); + } + if config.composio.enabled { + tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio.")); + } + if config.peripherals.enabled && !config.peripherals.boards.is_empty() { + tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware.")); + tool_descs.push(( + "gpio_write", + "Set GPIO pin high or low on connected hardware.", + )); + tool_descs.push(( + "arduino_upload", + "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.", + )); + tool_descs.push(( + "hardware_memory_map", + "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.", + )); + tool_descs.push(( + "hardware_board_info", + "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.", + )); + tool_descs.push(( + "hardware_memory_read", + "Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.", + )); + tool_descs.push(( + "hardware_capabilities", + "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.", + )); + } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; + let mut system_prompt = crate::channels::build_system_prompt( + &config.workspace_dir, + &model_name, + &tool_descs, + &skills, + Some(&config.identity), + bootstrap_max_chars, + ); + system_prompt.push_str(&build_tool_instructions(&tools_registry)); + + let mem_context = build_context(mem.as_ref(), message).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, message, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); + let enriched = if context.is_empty() { + message.to_string() + } else { + format!("{context}{message}") + }; + + let mut history = vec![ + ChatMessage::system(&system_prompt), + ChatMessage::user(&enriched), + ]; + + agent_turn( + provider.as_ref(), + &mut history, + &tools_registry, + observer.as_ref(), + provider_name, + &model_name, + config.default_temperature, + true, + ) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -1247,18 +1516,16 @@ Done."#; // Recovery Tests - Constants Validation // ═══════════════════════════════════════════════════════════════════════ - #[test] - fn max_tool_iterations_is_reasonable() { - // Recovery: MAX_TOOL_ITERATIONS should be set to prevent runaway loops + const _: () = { assert!(MAX_TOOL_ITERATIONS > 0); assert!(MAX_TOOL_ITERATIONS <= 100); - } - - #[test] - fn max_history_messages_is_reasonable() { - // Recovery: MAX_HISTORY_MESSAGES should be set to prevent memory bloat assert!(MAX_HISTORY_MESSAGES > 0); assert!(MAX_HISTORY_MESSAGES <= 1000); + }; + + #[test] + fn constants_bounds_are_compile_time_checked() { + // Bounds are enforced by the const assertions above. } // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/agent/memory_loader.rs b/src/agent/memory_loader.rs new file mode 100644 index 0000000..f5733ec --- /dev/null +++ b/src/agent/memory_loader.rs @@ -0,0 +1,118 @@ +use crate::memory::Memory; +use async_trait::async_trait; +use std::fmt::Write; + +#[async_trait] +pub trait MemoryLoader: Send + Sync { + async fn load_context(&self, memory: &dyn Memory, user_message: &str) + -> anyhow::Result; +} + +pub struct DefaultMemoryLoader { + limit: usize, +} + +impl Default for DefaultMemoryLoader { + fn default() -> Self { + Self { limit: 5 } + } +} + +impl DefaultMemoryLoader { + pub fn new(limit: usize) -> Self { + Self { + limit: limit.max(1), + } + } +} + +#[async_trait] +impl MemoryLoader for DefaultMemoryLoader { + async fn load_context( + &self, + memory: &dyn Memory, + user_message: &str, + ) -> anyhow::Result { + let entries = memory.recall(user_message, self.limit).await?; + if entries.is_empty() { + return Ok(String::new()); + } + + let mut context = String::from("[Memory context]\n"); + for entry in entries { + let _ = writeln!(context, "- {}: {}", entry.key, entry.content); + } + context.push('\n'); + Ok(context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::{Memory, MemoryCategory, MemoryEntry}; + + struct MockMemory; + + #[async_trait] + impl Memory for MockMemory { + async fn store( + &self, + _key: &str, + _content: &str, + _category: MemoryCategory, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall(&self, _query: &str, limit: usize) -> anyhow::Result> { + if limit == 0 { + return Ok(vec![]); + } + Ok(vec![MemoryEntry { + id: "1".into(), + key: "k".into(), + content: "v".into(), + category: MemoryCategory::Conversation, + timestamp: "now".into(), + session_id: None, + score: None, + }]) + } + + async fn get(&self, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list( + &self, + _category: Option<&MemoryCategory>, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(true) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } + + fn name(&self) -> &str { + "mock" + } + } + + #[tokio::test] + async fn default_loader_formats_context() { + let loader = DefaultMemoryLoader::default(); + let context = loader.load_context(&MockMemory, "hello").await.unwrap(); + assert!(context.contains("[Memory context]")); + assert!(context.contains("- k: v")); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 83fd645..89406ef 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,6 +1,13 @@ +#[allow(clippy::module_inception)] +pub mod agent; +pub mod dispatcher; pub mod loop_; +pub mod memory_loader; +pub mod prompt; -pub use loop_::run; +#[allow(unused_imports)] +pub use agent::{Agent, AgentBuilder}; +pub use loop_::{process_message, run}; #[cfg(test)] mod tests { @@ -11,6 +18,8 @@ mod tests { #[test] fn run_function_is_reexported() { assert_reexport_exists(run); + assert_reexport_exists(process_message); assert_reexport_exists(loop_::run); + assert_reexport_exists(loop_::process_message); } } diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs new file mode 100644 index 0000000..bdc426f --- /dev/null +++ b/src/agent/prompt.rs @@ -0,0 +1,304 @@ +use crate::config::IdentityConfig; +use crate::identity; +use crate::skills::Skill; +use crate::tools::Tool; +use anyhow::Result; +use chrono::Local; +use std::fmt::Write; +use std::path::Path; + +const BOOTSTRAP_MAX_CHARS: usize = 20_000; + +pub struct PromptContext<'a> { + pub workspace_dir: &'a Path, + pub model_name: &'a str, + pub tools: &'a [Box], + pub skills: &'a [Skill], + pub identity_config: Option<&'a IdentityConfig>, + pub dispatcher_instructions: &'a str, +} + +pub trait PromptSection: Send + Sync { + fn name(&self) -> &str; + fn build(&self, ctx: &PromptContext<'_>) -> Result; +} + +#[derive(Default)] +pub struct SystemPromptBuilder { + sections: Vec>, +} + +impl SystemPromptBuilder { + pub fn with_defaults() -> Self { + Self { + sections: vec![ + Box::new(IdentitySection), + Box::new(ToolsSection), + Box::new(SafetySection), + Box::new(SkillsSection), + Box::new(WorkspaceSection), + Box::new(DateTimeSection), + Box::new(RuntimeSection), + ], + } + } + + pub fn add_section(mut self, section: Box) -> Self { + self.sections.push(section); + self + } + + pub fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut output = String::new(); + for section in &self.sections { + let part = section.build(ctx)?; + if part.trim().is_empty() { + continue; + } + output.push_str(part.trim_end()); + output.push_str("\n\n"); + } + Ok(output) + } +} + +pub struct IdentitySection; +pub struct ToolsSection; +pub struct SafetySection; +pub struct SkillsSection; +pub struct WorkspaceSection; +pub struct RuntimeSection; +pub struct DateTimeSection; + +impl PromptSection for IdentitySection { + fn name(&self) -> &str { + "identity" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut prompt = String::from("## Project Context\n\n"); + if let Some(config) = ctx.identity_config { + if identity::is_aieos_configured(config) { + if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) { + let rendered = identity::aieos_to_system_prompt(&aieos); + if !rendered.is_empty() { + prompt.push_str(&rendered); + return Ok(prompt); + } + } + } + } + + prompt.push_str( + "The following workspace files define your identity, behavior, and context.\n\n", + ); + for file in [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", + "MEMORY.md", + ] { + inject_workspace_file(&mut prompt, ctx.workspace_dir, file); + } + + Ok(prompt) + } +} + +impl PromptSection for ToolsSection { + fn name(&self) -> &str { + "tools" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut out = String::from("## Tools\n\n"); + for tool in ctx.tools { + let _ = writeln!( + out, + "- **{}**: {}\n Parameters: `{}`", + tool.name(), + tool.description(), + tool.parameters_schema() + ); + } + if !ctx.dispatcher_instructions.is_empty() { + out.push('\n'); + out.push_str(ctx.dispatcher_instructions); + } + Ok(out) + } +} + +impl PromptSection for SafetySection { + fn name(&self) -> &str { + "safety" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) + } +} + +impl PromptSection for SkillsSection { + fn name(&self) -> &str { + "skills" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + if ctx.skills.is_empty() { + return Ok(String::new()); + } + + let mut prompt = String::from("## Available Skills\n\n\n"); + for skill in ctx.skills { + let location = skill.location.clone().unwrap_or_else(|| { + ctx.workspace_dir + .join("skills") + .join(&skill.name) + .join("SKILL.md") + }); + let _ = writeln!( + prompt, + " \n {}\n {}\n {}\n ", + skill.name, + skill.description, + location.display() + ); + } + prompt.push_str(""); + Ok(prompt) + } +} + +impl PromptSection for WorkspaceSection { + fn name(&self) -> &str { + "workspace" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + Ok(format!( + "## Workspace\n\nWorking directory: `{}`", + ctx.workspace_dir.display() + )) + } +} + +impl PromptSection for RuntimeSection { + fn name(&self) -> &str { + "runtime" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let host = + hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + Ok(format!( + "## Runtime\n\nHost: {host} | OS: {} | Model: {}", + std::env::consts::OS, + ctx.model_name + )) + } +} + +impl PromptSection for DateTimeSection { + fn name(&self) -> &str { + "datetime" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + let now = Local::now(); + Ok(format!( + "## Current Date & Time\n\nTimezone: {}", + now.format("%Z") + )) + } +} + +fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { + let path = workspace_dir.join(filename); + match std::fs::read_to_string(&path) { + Ok(content) => { + let trimmed = content.trim(); + if trimmed.is_empty() { + return; + } + let _ = writeln!(prompt, "### {filename}\n"); + let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS { + trimmed + .char_indices() + .nth(BOOTSTRAP_MAX_CHARS) + .map(|(idx, _)| &trimmed[..idx]) + .unwrap_or(trimmed) + } else { + trimmed + }; + prompt.push_str(truncated); + if truncated.len() < trimmed.len() { + let _ = writeln!( + prompt, + "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" + ); + } else { + prompt.push_str("\n\n"); + } + } + Err(_) => { + let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::traits::Tool; + use async_trait::async_trait; + + struct TestTool; + + #[async_trait] + impl Tool for TestTool { + fn name(&self) -> &str { + "test_tool" + } + + fn description(&self) -> &str { + "tool desc" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::tools::ToolResult { + success: true, + output: "ok".into(), + error: None, + }) + } + } + + #[test] + fn prompt_builder_assembles_sections() { + let tools: Vec> = vec![Box::new(TestTool)]; + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + tools: &tools, + skills: &[], + identity_config: None, + dispatcher_instructions: "instr", + }; + let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + assert!(prompt.contains("## Tools")); + assert!(prompt.contains("test_tool")); + assert!(prompt.contains("instr")); + } +} diff --git a/src/channels/email_channel.rs b/src/channels/email_channel.rs index e7c54a8..4fcfd71 100644 --- a/src/channels/email_channel.rs +++ b/src/channels/email_channel.rs @@ -14,11 +14,14 @@ use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; use mail_parser::{MessageParser, MimeHeaders}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use std::io::Write as IoWrite; use std::net::TcpStream; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Maximum number of seen message IDs to retain before evicting the oldest. +const SEEN_MESSAGES_CAPACITY: usize = 100_000; use tokio::sync::mpsc; use tokio::time::{interval, sleep}; use tracing::{error, info, warn}; @@ -93,17 +96,56 @@ impl Default for EmailConfig { } } +/// Bounded dedup set that evicts oldest entries when capacity is reached. +struct BoundedSeenSet { + set: HashSet, + order: VecDeque, + capacity: usize, +} + +impl BoundedSeenSet { + fn new(capacity: usize) -> Self { + Self { + set: HashSet::with_capacity(capacity.min(1024)), + order: VecDeque::with_capacity(capacity.min(1024)), + capacity, + } + } + + fn contains(&self, id: &str) -> bool { + self.set.contains(id) + } + + fn insert(&mut self, id: String) -> bool { + if self.set.contains(&id) { + return false; + } + if self.order.len() >= self.capacity { + if let Some(oldest) = self.order.pop_front() { + self.set.remove(&oldest); + } + } + self.order.push_back(id.clone()); + self.set.insert(id); + true + } + + fn len(&self) -> usize { + self.set.len() + } +} + /// Email channel — IMAP polling for inbound, SMTP for outbound pub struct EmailChannel { pub config: EmailConfig, - seen_messages: Mutex>, + seen_messages: Mutex, } impl EmailChannel { pub fn new(config: EmailConfig) -> Self { Self { config, - seen_messages: Mutex::new(HashSet::new()), + seen_messages: Mutex::new(BoundedSeenSet::new(SEEN_MESSAGES_CAPACITY)), } } @@ -459,7 +501,7 @@ impl Channel for EmailChannel { #[cfg(test)] mod tests { - use super::EmailChannel; + use super::{BoundedSeenSet, EmailChannel}; #[test] fn build_imap_tls_config_succeeds() { @@ -467,4 +509,65 @@ mod tests { EmailChannel::build_imap_tls_config().expect("TLS config construction should succeed"); assert_eq!(std::sync::Arc::strong_count(&tls_config), 1); } + + #[test] + fn bounded_seen_set_insert_and_contains() { + let mut set = BoundedSeenSet::new(10); + assert!(set.insert("a".into())); + assert!(set.contains("a")); + assert!(!set.contains("b")); + } + + #[test] + fn bounded_seen_set_rejects_duplicates() { + let mut set = BoundedSeenSet::new(10); + assert!(set.insert("a".into())); + assert!(!set.insert("a".into())); + assert_eq!(set.len(), 1); + } + + #[test] + fn bounded_seen_set_evicts_oldest_at_capacity() { + let mut set = BoundedSeenSet::new(3); + set.insert("a".into()); + set.insert("b".into()); + set.insert("c".into()); + assert_eq!(set.len(), 3); + + // Inserting a 4th should evict "a" + set.insert("d".into()); + assert_eq!(set.len(), 3); + assert!(!set.contains("a"), "oldest entry should be evicted"); + assert!(set.contains("b")); + assert!(set.contains("c")); + assert!(set.contains("d")); + } + + #[test] + fn bounded_seen_set_evicts_in_fifo_order() { + let mut set = BoundedSeenSet::new(2); + set.insert("first".into()); + set.insert("second".into()); + set.insert("third".into()); + assert!(!set.contains("first")); + assert!(set.contains("second")); + assert!(set.contains("third")); + + set.insert("fourth".into()); + assert!(!set.contains("second")); + assert!(set.contains("third")); + assert!(set.contains("fourth")); + } + + #[test] + fn bounded_seen_set_capacity_one() { + let mut set = BoundedSeenSet::new(1); + set.insert("a".into()); + assert!(set.contains("a")); + + set.insert("b".into()); + assert!(!set.contains("a")); + assert!(set.contains("b")); + assert_eq!(set.len(), 1); + } } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 81fa704..3c96f19 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -43,7 +43,9 @@ const BOOTSTRAP_MAX_CHARS: usize = 20_000; const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2; const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60; -const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90; +/// Timeout for processing a single channel message (LLM + tools). +/// 300s for on-device LLMs (Ollama) which are slower than cloud APIs. +const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300; const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4; const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8; const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64; @@ -52,7 +54,6 @@ const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64; struct ChannelRuntimeContext { channels_by_name: Arc>>, provider: Arc, - provider_name: Arc, memory: Arc, tools_registry: Arc>>, observer: Arc, @@ -188,9 +189,10 @@ async fn process_channel_message(ctx: Arc, msg: traits::C &mut history, ctx.tools_registry.as_ref(), ctx.observer.as_ref(), - "channels", + "channel-runtime", ctx.model.as_str(), ctx.temperature, + true, // silent — channels don't write to stdout ), ) .await; @@ -276,9 +278,14 @@ async fn run_message_dispatch_loop( } /// Load OpenClaw format bootstrap files into the prompt. -fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) { - prompt - .push_str("The following workspace files define your identity, behavior, and context.\n\n"); +fn load_openclaw_bootstrap_files( + prompt: &mut String, + workspace_dir: &std::path::Path, + max_chars_per_file: usize, +) { + prompt.push_str( + "The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n", + ); let bootstrap_files = [ "AGENTS.md", @@ -290,17 +297,17 @@ fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path: ]; for filename in &bootstrap_files { - inject_workspace_file(prompt, workspace_dir, filename); + inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file); } // BOOTSTRAP.md — only if it exists (first-run ritual) let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); if bootstrap_path.exists() { - inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md"); + inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file); } // MEMORY.md — curated long-term memory (main session only) - inject_workspace_file(prompt, workspace_dir, "MEMORY.md"); + inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file); } /// Load workspace identity files and build a system prompt. @@ -325,6 +332,7 @@ pub fn build_system_prompt( tools: &[(&str, &str)], skills: &[crate::skills::Skill], identity_config: Option<&crate::config::IdentityConfig>, + bootstrap_max_chars: Option, ) -> String { use std::fmt::Write; let mut prompt = String::with_capacity(8192); @@ -345,6 +353,35 @@ pub fn build_system_prompt( .push_str("Continue reasoning with the results until you can give a final answer.\n\n"); } + // ── 1b. Hardware (when gpio/arduino tools present) ─────────── + let has_hardware = tools.iter().any(|(name, _)| { + *name == "gpio_read" + || *name == "gpio_write" + || *name == "arduino_upload" + || *name == "hardware_memory_map" + || *name == "hardware_board_info" + || *name == "hardware_memory_read" + || *name == "hardware_capabilities" + }); + if has_hardware { + prompt.push_str( + "## Hardware Access\n\n\ + You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\ + All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\ + When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\ + When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\ + Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n", + ); + } + + // ── 1c. Action instruction (avoid meta-summary) ─────────────── + prompt.push_str( + "## Your Task\n\n\ + When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\ + Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\ + Instead: emit actual tags when you need to act. Just do what they ask.\n\n", + ); + // ── 2. Safety ─────────────────────────────────────────────── prompt.push_str("## Safety\n\n"); prompt.push_str( @@ -407,23 +444,27 @@ pub fn build_system_prompt( Ok(None) => { // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true) // Fall back to OpenClaw bootstrap files - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } Err(e) => { // Log error but don't fail - fall back to OpenClaw eprintln!( "Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format." ); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } } } else { // OpenClaw format - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } } else { // No identity config - use OpenClaw format - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } // ── 6. Date & Time ────────────────────────────────────────── @@ -448,7 +489,12 @@ pub fn build_system_prompt( } /// Inject a single workspace file into the prompt with truncation and missing-file markers. -fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) { +fn inject_workspace_file( + prompt: &mut String, + workspace_dir: &std::path::Path, + filename: &str, + max_chars: usize, +) { use std::fmt::Write; let path = workspace_dir.join(filename); @@ -460,10 +506,10 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f } let _ = writeln!(prompt, "### {filename}\n"); // Use character-boundary-safe truncation for UTF-8 - let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS { + let truncated = if trimmed.chars().count() > max_chars { trimmed .char_indices() - .nth(BOOTSTRAP_MAX_CHARS) + .nth(max_chars) .map(|(idx, _)| &trimmed[..idx]) .unwrap_or(trimmed) } else { @@ -473,7 +519,7 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f prompt.push_str(truncated); let _ = writeln!( prompt, - "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" + "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" ); } else { prompt.push_str(trimmed); @@ -699,9 +745,8 @@ pub async fn start_channels(config: Config) -> Result<()> { .default_provider .clone() .unwrap_or_else(|| "openrouter".into()); - let provider: Arc = Arc::from(providers::create_resilient_provider( - provider_name.as_str(), + &provider_name, config.api_key.as_deref(), &config.reliability, )?); @@ -720,18 +765,16 @@ pub async fn start_channels(config: Config) -> Result<()> { &config.autonomy, &config.workspace_dir, )); - let model = config .default_model .clone() - .unwrap_or_else(|| "anthropic/claude-sonnet-4".into()); + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); let temperature = config.default_temperature; let mem: Arc = Arc::from(memory::create_memory( &config.memory, &config.workspace_dir, config.api_key.as_deref(), )?); - let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), @@ -740,6 +783,8 @@ pub async fn start_channels(config: Config) -> Result<()> { } else { (None, None) }; + // Build system prompt from workspace identity files + skills + let workspace = config.workspace_dir.clone(); let tools_registry = Arc::new(tools::all_tools_with_runtime( &security, runtime, @@ -748,14 +793,12 @@ pub async fn start_channels(config: Config) -> Result<()> { composio_entity_id, &config.browser, &config.http_request, - &config.workspace_dir, + &workspace, &config.agents, config.api_key.as_deref(), &config, )); - // Build system prompt from workspace identity files + skills - let workspace = config.workspace_dir.clone(); let skills = crate::skills::load_skills(&workspace); // Collect tool descriptions for the prompt @@ -809,12 +852,18 @@ pub async fn start_channels(config: Config) -> Result<()> { )); } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; let mut system_prompt = build_system_prompt( &workspace, &model, &tool_descs, &skills, Some(&config.identity), + bootstrap_max_chars, ); system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref())); @@ -970,7 +1019,6 @@ pub async fn start_channels(config: Config) -> Result<()> { let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name, provider: Arc::clone(&provider), - provider_name: Arc::new(provider_name), memory: Arc::clone(&mem), tools_registry: Arc::clone(&tools_registry), observer, @@ -1062,23 +1110,19 @@ mod tests { message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { tokio::time::sleep(self.delay).await; - Ok(ChatResponse::with_text(format!("echo: {message}"))) + Ok(format!("echo: {message}")) } } struct ToolCallingProvider; - fn tool_call_payload() -> ChatResponse { - ChatResponse { - text: Some(String::new()), - tool_calls: vec![ToolCall { - id: "call_1".into(), - name: "mock_price".into(), - arguments: r#"{"symbol":"BTC"}"#.into(), - }], - } + fn tool_call_payload() -> String { + r#" +{"name":"mock_price","arguments":{"symbol":"BTC"}} +"# + .to_string() } #[async_trait::async_trait] @@ -1089,7 +1133,7 @@ mod tests { _message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { Ok(tool_call_payload()) } @@ -1098,14 +1142,12 @@ mod tests { messages: &[ChatMessage], _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let has_tool_results = messages .iter() .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]")); if has_tool_results { - Ok(ChatResponse::with_text( - "BTC is currently around $65,000 based on latest tool output.", - )) + Ok("BTC is currently around $65,000 based on latest tool output.".to_string()) } else { Ok(tool_call_payload()) } @@ -1163,7 +1205,6 @@ mod tests { let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), - provider_name: Arc::new("test-provider".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), observer: Arc::new(NoopObserver), @@ -1254,7 +1295,6 @@ mod tests { provider: Arc::new(SlowProvider { delay: Duration::from_millis(250), }), - provider_name: Arc::new("test-provider".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![]), observer: Arc::new(NoopObserver), @@ -1303,7 +1343,7 @@ mod tests { fn prompt_contains_all_sections() { let ws = make_workspace(); let tools = vec![("shell", "Run commands"), ("file_read", "Read files")]; - let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None); + let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None); // Section headers assert!(prompt.contains("## Tools"), "missing Tools section"); @@ -1327,7 +1367,7 @@ mod tests { ("shell", "Run commands"), ("memory_recall", "Search memory"), ]; - let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None); + let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None); assert!(prompt.contains("**shell**")); assert!(prompt.contains("Run commands")); @@ -1337,7 +1377,7 @@ mod tests { #[test] fn prompt_injects_safety() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains("Do not exfiltrate private data")); assert!(prompt.contains("Do not run destructive commands")); @@ -1347,7 +1387,7 @@ mod tests { #[test] fn prompt_injects_workspace_files() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); assert!(prompt.contains("Be helpful"), "missing SOUL content"); @@ -1368,7 +1408,7 @@ mod tests { fn prompt_missing_file_markers() { let tmp = TempDir::new().unwrap(); // Empty workspace — no files at all - let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None); assert!(prompt.contains("[File not found: SOUL.md]")); assert!(prompt.contains("[File not found: AGENTS.md]")); @@ -1379,7 +1419,7 @@ mod tests { fn prompt_bootstrap_only_if_exists() { let ws = make_workspace(); // No BOOTSTRAP.md — should not appear - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( !prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing" @@ -1387,7 +1427,7 @@ mod tests { // Create BOOTSTRAP.md — should appear std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap(); - let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present" @@ -1407,7 +1447,7 @@ mod tests { ) .unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Daily notes should NOT be in the system prompt (on-demand via tools) assert!( @@ -1423,7 +1463,7 @@ mod tests { #[test] fn prompt_runtime_metadata() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None); assert!(prompt.contains("Model: claude-sonnet-4")); assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS))); @@ -1444,7 +1484,7 @@ mod tests { location: None, }]; - let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None); + let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None); assert!(prompt.contains(""), "missing skills XML"); assert!(prompt.contains("code-review")); @@ -1465,7 +1505,7 @@ mod tests { let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000); std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( prompt.contains("truncated at"), @@ -1482,7 +1522,7 @@ mod tests { let ws = make_workspace(); std::fs::write(ws.path().join("TOOLS.md"), "").unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Empty file should not produce a header assert!( @@ -1510,7 +1550,7 @@ mod tests { #[test] fn prompt_workspace_path() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); } @@ -1640,7 +1680,7 @@ mod tests { aieos_inline: None, }; - let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None); // Should contain AIEOS sections assert!(prompt.contains("## Identity")); @@ -1680,6 +1720,7 @@ mod tests { &[], &[], Some(&config), + None, ); assert!(prompt.contains("**Name:** Claw")); @@ -1697,7 +1738,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should fall back to OpenClaw format when AIEOS file is not found // (Error is logged to stderr with filename, not included in prompt) @@ -1716,7 +1757,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should use OpenClaw format (not configured for AIEOS) assert!(prompt.contains("### SOUL.md")); @@ -1734,7 +1775,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should use OpenClaw format even if aieos_path is set assert!(prompt.contains("### SOUL.md")); @@ -1746,7 +1787,7 @@ mod tests { fn none_identity_config_uses_openclaw() { let ws = make_workspace(); // Pass None for identity config - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Should use OpenClaw format assert!(prompt.contains("### SOUL.md")); diff --git a/src/config/mod.rs b/src/config/mod.rs index e53b597..cd9601c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,12 +2,14 @@ pub mod schema; #[allow(unused_imports)] pub use schema::{ - AuditConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, CostConfig, - DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, + AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, + ChannelsConfig, ComposioConfig, Config, CostConfig, DelegateAgentConfig, DiscordConfig, + DockerRuntimeConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, - ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, - SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, - TelegramConfig, TunnelConfig, WebhookConfig, + ModelRouteConfig, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, + ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, + SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig, + WebhookConfig, }; #[cfg(test)] diff --git a/src/config/schema.rs b/src/config/schema.rs index 8a66124..4f8056d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -37,6 +37,9 @@ pub struct Config { #[serde(default)] pub scheduler: SchedulerConfig, + #[serde(default)] + pub agent: AgentConfig, + /// Model routing rules — route `hint:` to specific provider+model combos. #[serde(default)] pub model_routes: Vec, @@ -74,31 +77,157 @@ pub struct Config { #[serde(default)] pub cost: CostConfig, - /// Hardware Abstraction Layer (HAL) configuration. - /// Controls how ZeroClaw interfaces with physical hardware - /// (GPIO, serial, debug probes). #[serde(default)] - pub hardware: crate::hardware::HardwareConfig, + pub peripherals: PeripheralsConfig, - /// Named delegate agents for agent-to-agent handoff. - /// - /// ```toml - /// [agents.researcher] - /// provider = "gemini" - /// model = "gemini-2.0-flash" - /// system_prompt = "You are a research assistant..." - /// - /// [agents.coder] - /// provider = "openrouter" - /// model = "anthropic/claude-sonnet-4-20250514" - /// system_prompt = "You are a coding assistant..." - /// ``` + /// Delegate agent configurations for multi-agent workflows. #[serde(default)] pub agents: HashMap, - /// Security configuration (sandboxing, resource limits, audit logging) + /// Hardware configuration (wizard-driven physical world setup). #[serde(default)] - pub security: SecurityConfig, + pub hardware: HardwareConfig, +} + +// ── Delegate Agents ────────────────────────────────────────────── + +/// Configuration for a delegate sub-agent used by the `delegate` tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegateAgentConfig { + /// Provider name (e.g. "ollama", "openrouter", "anthropic") + pub provider: String, + /// Model name + pub model: String, + /// Optional system prompt for the sub-agent + #[serde(default)] + pub system_prompt: Option, + /// Optional API key override + #[serde(default)] + pub api_key: Option, + /// Temperature override + #[serde(default)] + pub temperature: Option, + /// Max recursion depth for nested delegation + #[serde(default = "default_max_depth")] + pub max_depth: u32, +} + +fn default_max_depth() -> u32 { + 3 +} + +// ── Hardware Config (wizard-driven) ───────────────────────────── + +/// Hardware transport mode. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HardwareTransport { + None, + Native, + Serial, + Probe, +} + +impl Default for HardwareTransport { + fn default() -> Self { + Self::None + } +} + +impl std::fmt::Display for HardwareTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "none"), + Self::Native => write!(f, "native"), + Self::Serial => write!(f, "serial"), + Self::Probe => write!(f, "probe"), + } + } +} + +/// Wizard-driven hardware configuration for physical world interaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HardwareConfig { + /// Whether hardware access is enabled + #[serde(default)] + pub enabled: bool, + /// Transport mode + #[serde(default)] + pub transport: HardwareTransport, + /// Serial port path (e.g. "/dev/ttyACM0") + #[serde(default)] + pub serial_port: Option, + /// Serial baud rate + #[serde(default = "default_baud_rate")] + pub baud_rate: u32, + /// Probe target chip (e.g. "STM32F401RE") + #[serde(default)] + pub probe_target: Option, + /// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups) + #[serde(default)] + pub workspace_datasheets: bool, +} + +fn default_baud_rate() -> u32 { + 115200 +} + +impl HardwareConfig { + /// Return the active transport mode. + pub fn transport_mode(&self) -> HardwareTransport { + self.transport.clone() + } +} + +impl Default for HardwareConfig { + fn default() -> Self { + Self { + enabled: false, + transport: HardwareTransport::None, + serial_port: None, + baud_rate: default_baud_rate(), + probe_target: None, + workspace_datasheets: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models. + #[serde(default)] + pub compact_context: bool, + #[serde(default = "default_agent_max_tool_iterations")] + pub max_tool_iterations: usize, + #[serde(default = "default_agent_max_history_messages")] + pub max_history_messages: usize, + #[serde(default)] + pub parallel_tools: bool, + #[serde(default = "default_agent_tool_dispatcher")] + pub tool_dispatcher: String, +} + +fn default_agent_max_tool_iterations() -> usize { + 10 +} + +fn default_agent_max_history_messages() -> usize { + 50 +} + +fn default_agent_tool_dispatcher() -> String { + "auto".into() +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + compact_context: false, + max_tool_iterations: default_agent_max_tool_iterations(), + max_history_messages: default_agent_max_history_messages(), + parallel_tools: false, + tool_dispatcher: default_agent_tool_dispatcher(), + } + } } // ── Identity (AIEOS / OpenClaw format) ────────────────────────── @@ -271,34 +400,64 @@ fn get_default_pricing() -> std::collections::HashMap { prices } -// ── Agent delegation ───────────────────────────────────────────── +// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ──────────────────────── -/// Configuration for a named delegate agent that can be invoked via the -/// `delegate` tool. Each agent uses its own provider/model combination -/// and system prompt, enabling multi-agent workflows with specialization. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DelegateAgentConfig { - /// Provider name (e.g. "gemini", "openrouter", "ollama") - pub provider: String, - /// Model identifier for the provider - pub model: String, - /// System prompt defining the agent's role and capabilities +pub struct PeripheralsConfig { + /// Enable peripheral support (boards become agent tools) #[serde(default)] - pub system_prompt: Option, - /// Optional API key override (uses default if not set). - /// Stored encrypted when `secrets.encrypt = true`. + pub enabled: bool, + /// Board configurations (nucleo-f401re, rpi-gpio, etc.) #[serde(default)] - pub api_key: Option, - /// Temperature override (uses 0.7 if not set) + pub boards: Vec, + /// Path to datasheet docs (relative to workspace) for RAG retrieval. + /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md). #[serde(default)] - pub temperature: Option, - /// Maximum delegation depth to prevent infinite recursion (default: 3) - #[serde(default = "default_max_delegation_depth")] - pub max_depth: u32, + pub datasheet_dir: Option, } -fn default_max_delegation_depth() -> u32 { - 3 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeripheralBoardConfig { + /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc. + pub board: String, + /// Transport: "serial", "native", "websocket" + #[serde(default = "default_peripheral_transport")] + pub transport: String, + /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0" + #[serde(default)] + pub path: Option, + /// Baud rate for serial (default: 115200) + #[serde(default = "default_peripheral_baud")] + pub baud: u32, +} + +fn default_peripheral_transport() -> String { + "serial".into() +} + +fn default_peripheral_baud() -> u32 { + 115200 +} + +impl Default for PeripheralsConfig { + fn default() -> Self { + Self { + enabled: false, + boards: Vec::new(), + datasheet_dir: None, + } + } +} + +impl Default for PeripheralBoardConfig { + fn default() -> Self { + Self { + board: String::new(), + transport: default_peripheral_transport(), + path: None, + baud: default_peripheral_baud(), + } + } } // ── Gateway security ───────────────────────────────────────────── @@ -419,6 +578,53 @@ impl Default for SecretsConfig { // ── Browser (friendly-service browsing only) ─────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserComputerUseConfig { + /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot) + #[serde(default = "default_browser_computer_use_endpoint")] + pub endpoint: String, + /// Optional bearer token for computer-use sidecar + #[serde(default)] + pub api_key: Option, + /// Per-action request timeout in milliseconds + #[serde(default = "default_browser_computer_use_timeout_ms")] + pub timeout_ms: u64, + /// Allow remote/public endpoint for computer-use sidecar (default: false) + #[serde(default)] + pub allow_remote_endpoint: bool, + /// Optional window title/process allowlist forwarded to sidecar policy + #[serde(default)] + pub window_allowlist: Vec, + /// Optional X-axis boundary for coordinate-based actions + #[serde(default)] + pub max_coordinate_x: Option, + /// Optional Y-axis boundary for coordinate-based actions + #[serde(default)] + pub max_coordinate_y: Option, +} + +fn default_browser_computer_use_endpoint() -> String { + "http://127.0.0.1:8787/v1/actions".into() +} + +fn default_browser_computer_use_timeout_ms() -> u64 { + 15_000 +} + +impl Default for BrowserComputerUseConfig { + fn default() -> Self { + Self { + endpoint: default_browser_computer_use_endpoint(), + api_key: None, + timeout_ms: default_browser_computer_use_timeout_ms(), + allow_remote_endpoint: false, + window_allowlist: Vec::new(), + max_coordinate_x: None, + max_coordinate_y: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserConfig { /// Enable `browser_open` tool (opens URLs in Brave without scraping) @@ -430,7 +636,7 @@ pub struct BrowserConfig { /// Browser session name (for agent-browser automation) #[serde(default)] pub session_name: Option, - /// Browser automation backend: "agent_browser" | "rust_native" | "auto" + /// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto" #[serde(default = "default_browser_backend")] pub backend: String, /// Headless mode for rust-native backend @@ -442,6 +648,9 @@ pub struct BrowserConfig { /// Optional Chrome/Chromium executable path for rust-native backend #[serde(default)] pub native_chrome_path: Option, + /// Computer-use sidecar configuration + #[serde(default)] + pub computer_use: BrowserComputerUseConfig, } fn default_browser_backend() -> String { @@ -462,6 +671,7 @@ impl Default for BrowserConfig { native_headless: default_true(), native_webdriver_url: default_browser_webdriver_url(), native_chrome_path: None, + computer_use: BrowserComputerUseConfig::default(), } } } @@ -496,7 +706,7 @@ fn default_http_timeout_secs() -> u64 { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryConfig { - /// "sqlite" | "markdown" | "none" + /// "sqlite" | "lucid" | "markdown" | "none" (`none` = explicit no-op memory) pub backend: String, /// Auto-save conversation context to memory pub auto_save: bool, @@ -1148,7 +1358,7 @@ pub struct LarkConfig { // ── Security Config ───────────────────────────────────────────────── /// Security configuration for sandboxing, resource limits, and audit logging -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SecurityConfig { /// Sandbox configuration #[serde(default)] @@ -1163,16 +1373,6 @@ pub struct SecurityConfig { pub audit: AuditConfig, } -impl Default for SecurityConfig { - fn default() -> Self { - Self { - sandbox: SandboxConfig::default(), - resources: ResourceLimitsConfig::default(), - audit: AuditConfig::default(), - } - } -} - /// Sandbox configuration for OS-level isolation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxConfig { @@ -1200,10 +1400,11 @@ impl Default for SandboxConfig { } /// Sandbox backend selection -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum SandboxBackend { /// Auto-detect best available (default) + #[default] Auto, /// Landlock (Linux kernel LSM, native) Landlock, @@ -1217,12 +1418,6 @@ pub enum SandboxBackend { None, } -impl Default for SandboxBackend { - fn default() -> Self { - Self::Auto - } -} - /// Resource limits for command execution #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceLimitsConfig { @@ -1333,6 +1528,7 @@ impl Default for Config { runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), + agent: AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), @@ -1345,9 +1541,9 @@ impl Default for Config { http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), } } } @@ -1374,37 +1570,36 @@ impl Config { // Set computed paths that are skipped during serialization config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); - - // Decrypt agent API keys if encryption is enabled - let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt); - for agent in config.agents.values_mut() { - if let Some(ref encrypted_key) = agent.api_key { - agent.api_key = Some( - store - .decrypt(encrypted_key) - .context("Failed to decrypt agent API key")?, - ); - } - } - + config.apply_env_overrides(); Ok(config) } else { let mut config = Config::default(); config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); config.save()?; + config.apply_env_overrides(); Ok(config) } } /// Apply environment variable overrides to config pub fn apply_env_overrides(&mut self) { - // API Key: ZEROCLAW_API_KEY or API_KEY + // API Key: ZEROCLAW_API_KEY or API_KEY (generic) if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) { if !key.is_empty() { self.api_key = Some(key); } } + // API Key: GLM_API_KEY overrides when provider is glm (provider-specific) + if self.default_provider.as_deref() == Some("glm") + || self.default_provider.as_deref() == Some("zhipu") + { + if let Ok(key) = std::env::var("GLM_API_KEY") { + if !key.is_empty() { + self.api_key = Some(key); + } + } + } // Provider: ZEROCLAW_PROVIDER or PROVIDER if let Ok(provider) = @@ -1567,7 +1762,6 @@ fn sync_directory(_path: &Path) -> Result<()> { mod tests { use super::*; use std::path::PathBuf; - use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; // ── Defaults ───────────────────────────────────────────── @@ -1700,11 +1894,12 @@ mod tests { secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), + agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -1748,6 +1943,35 @@ default_temperature = 0.7 assert_eq!(parsed.memory.conversation_retention_days, 30); } + #[test] + fn agent_config_defaults() { + let cfg = AgentConfig::default(); + assert!(!cfg.compact_context); + assert_eq!(cfg.max_tool_iterations, 10); + assert_eq!(cfg.max_history_messages, 50); + assert!(!cfg.parallel_tools); + assert_eq!(cfg.tool_dispatcher, "auto"); + } + + #[test] + fn agent_config_deserializes() { + let raw = r#" +default_temperature = 0.7 +[agent] +compact_context = true +max_tool_iterations = 20 +max_history_messages = 80 +parallel_tools = true +tool_dispatcher = "xml" +"#; + let parsed: Config = toml::from_str(raw).unwrap(); + assert!(parsed.agent.compact_context); + assert_eq!(parsed.agent.max_tool_iterations, 20); + assert_eq!(parsed.agent.max_history_messages, 80); + assert!(parsed.agent.parallel_tools); + assert_eq!(parsed.agent.tool_dispatcher, "xml"); + } + #[test] fn config_save_and_load_tmpdir() { let dir = std::env::temp_dir().join("zeroclaw_test_config"); @@ -1777,11 +2001,12 @@ default_temperature = 0.7 secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), + agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), }; config.save().unwrap(); @@ -2334,6 +2559,12 @@ default_temperature = 0.7 assert!(b.native_headless); assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515"); assert!(b.native_chrome_path.is_none()); + assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions"); + assert_eq!(b.computer_use.timeout_ms, 15_000); + assert!(!b.computer_use.allow_remote_endpoint); + assert!(b.computer_use.window_allowlist.is_empty()); + assert!(b.computer_use.max_coordinate_x.is_none()); + assert!(b.computer_use.max_coordinate_y.is_none()); } #[test] @@ -2346,6 +2577,15 @@ default_temperature = 0.7 native_headless: false, native_webdriver_url: "http://localhost:4444".into(), native_chrome_path: Some("/usr/bin/chromium".into()), + computer_use: BrowserComputerUseConfig { + endpoint: "https://computer-use.example.com/v1/actions".into(), + api_key: Some("test-token".into()), + timeout_ms: 8_000, + allow_remote_endpoint: true, + window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()], + max_coordinate_x: Some(3840), + max_coordinate_y: Some(2160), + }, }; let toml_str = toml::to_string(&b).unwrap(); let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap(); @@ -2359,6 +2599,16 @@ default_temperature = 0.7 parsed.native_chrome_path.as_deref(), Some("/usr/bin/chromium") ); + assert_eq!( + parsed.computer_use.endpoint, + "https://computer-use.example.com/v1/actions" + ); + assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token")); + assert_eq!(parsed.computer_use.timeout_ms, 8_000); + assert!(parsed.computer_use.allow_remote_endpoint); + assert_eq!(parsed.computer_use.window_allowlist.len(), 2); + assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840)); + assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160)); } #[test] @@ -2373,19 +2623,18 @@ default_temperature = 0.7 assert!(parsed.browser.allowed_domains.is_empty()); } - fn env_override_lock() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK - .get_or_init(|| Mutex::new(())) + // ── Environment variable overrides (Docker support) ───────── + + fn env_override_test_guard() -> std::sync::MutexGuard<'static, ()> { + static ENV_OVERRIDE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + ENV_OVERRIDE_TEST_LOCK .lock() .expect("env override test lock poisoned") } - // ── Environment variable overrides (Docker support) ───────── - #[test] fn env_override_api_key() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert!(config.api_key.is_none()); @@ -2398,7 +2647,7 @@ default_temperature = 0.7 #[test] fn env_override_api_key_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_API_KEY"); @@ -2411,7 +2660,7 @@ default_temperature = 0.7 #[test] fn env_override_provider() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_PROVIDER", "anthropic"); @@ -2423,7 +2672,7 @@ default_temperature = 0.7 #[test] fn env_override_provider_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_PROVIDER"); @@ -2436,7 +2685,7 @@ default_temperature = 0.7 #[test] fn env_override_model() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_MODEL", "gpt-4o"); @@ -2448,7 +2697,7 @@ default_temperature = 0.7 #[test] fn env_override_workspace() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace"); @@ -2460,7 +2709,7 @@ default_temperature = 0.7 #[test] fn env_override_empty_values_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); let original_provider = config.default_provider.clone(); @@ -2473,7 +2722,7 @@ default_temperature = 0.7 #[test] fn env_override_gateway_port() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert_eq!(config.gateway.port, 3000); @@ -2486,7 +2735,7 @@ default_temperature = 0.7 #[test] fn env_override_port_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); @@ -2499,7 +2748,7 @@ default_temperature = 0.7 #[test] fn env_override_gateway_host() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert_eq!(config.gateway.host, "127.0.0.1"); @@ -2512,7 +2761,7 @@ default_temperature = 0.7 #[test] fn env_override_host_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); @@ -2525,7 +2774,7 @@ default_temperature = 0.7 #[test] fn env_override_temperature() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5"); @@ -2537,7 +2786,7 @@ default_temperature = 0.7 #[test] fn env_override_temperature_out_of_range_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); // Clean up any leftover env vars from other tests std::env::remove_var("ZEROCLAW_TEMPERATURE"); @@ -2557,7 +2806,7 @@ default_temperature = 0.7 #[test] fn env_override_invalid_port_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); let original_port = config.gateway.port; @@ -2578,236 +2827,41 @@ default_temperature = 0.7 assert!(g.paired_tokens.is_empty()); } - // ── Lark config ─────────────────────────────────────────────── + // ── Peripherals config ─────────────────────────────────────── #[test] - fn lark_config_serde() { - let lc = LarkConfig { - app_id: "cli_123456".into(), - app_secret: "secret_abc".into(), - encrypt_key: Some("encrypt_key".into()), - verification_token: Some("verify_token".into()), - allowed_users: vec!["user_123".into(), "user_456".into()], - use_feishu: true, + fn peripherals_config_default_disabled() { + let p = PeripheralsConfig::default(); + assert!(!p.enabled); + assert!(p.boards.is_empty()); + } + + #[test] + fn peripheral_board_config_defaults() { + let b = PeripheralBoardConfig::default(); + assert!(b.board.is_empty()); + assert_eq!(b.transport, "serial"); + assert!(b.path.is_none()); + assert_eq!(b.baud, 115200); + } + + #[test] + fn peripherals_config_toml_roundtrip() { + let p = PeripheralsConfig { + enabled: true, + boards: vec![PeripheralBoardConfig { + board: "nucleo-f401re".into(), + transport: "serial".into(), + path: Some("/dev/ttyACM0".into()), + baud: 115200, + }], + datasheet_dir: None, }; - let json = serde_json::to_string(&lc).unwrap(); - let parsed: LarkConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.app_id, "cli_123456"); - assert_eq!(parsed.app_secret, "secret_abc"); - assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key")); - assert_eq!(parsed.verification_token.as_deref(), Some("verify_token")); - assert_eq!(parsed.allowed_users.len(), 2); - assert!(parsed.use_feishu); - } - - #[test] - fn lark_config_toml_roundtrip() { - let lc = LarkConfig { - app_id: "cli_123456".into(), - app_secret: "secret_abc".into(), - encrypt_key: Some("encrypt_key".into()), - verification_token: Some("verify_token".into()), - allowed_users: vec!["*".into()], - use_feishu: false, - }; - let toml_str = toml::to_string(&lc).unwrap(); - let parsed: LarkConfig = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed.app_id, "cli_123456"); - assert_eq!(parsed.app_secret, "secret_abc"); - assert!(!parsed.use_feishu); - } - - #[test] - fn lark_config_deserializes_without_optional_fields() { - let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert!(parsed.encrypt_key.is_none()); - assert!(parsed.verification_token.is_none()); - assert!(parsed.allowed_users.is_empty()); - assert!(!parsed.use_feishu); - } - - #[test] - fn lark_config_defaults_to_lark_endpoint() { - let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert!( - !parsed.use_feishu, - "use_feishu should default to false (Lark)" - ); - } - - #[test] - fn lark_config_with_wildcard_allowed_users() { - let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.allowed_users, vec!["*"]); - } - - // ══════════════════════════════════════════════════════════ - // AGENT DELEGATION CONFIG TESTS - // ══════════════════════════════════════════════════════════ - - #[test] - fn agents_config_default_empty() { - let c = Config::default(); - assert!(c.agents.is_empty()); - } - - #[test] - fn agents_config_backward_compat_missing_section() { - let minimal = r#" -workspace_dir = "/tmp/ws" -config_path = "/tmp/config.toml" -default_temperature = 0.7 -"#; - let parsed: Config = toml::from_str(minimal).unwrap(); - assert!(parsed.agents.is_empty()); - } - - #[test] - fn agents_config_toml_roundtrip() { - let toml_str = r#" -default_temperature = 0.7 - -[agents.researcher] -provider = "gemini" -model = "gemini-2.0-flash" -system_prompt = "You are a research assistant." -max_depth = 2 - -[agents.coder] -provider = "openrouter" -model = "anthropic/claude-sonnet-4-20250514" -"#; - let parsed: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(parsed.agents.len(), 2); - - let researcher = &parsed.agents["researcher"]; - assert_eq!(researcher.provider, "gemini"); - assert_eq!(researcher.model, "gemini-2.0-flash"); - assert_eq!( - researcher.system_prompt.as_deref(), - Some("You are a research assistant.") - ); - assert_eq!(researcher.max_depth, 2); - assert!(researcher.api_key.is_none()); - assert!(researcher.temperature.is_none()); - - let coder = &parsed.agents["coder"]; - assert_eq!(coder.provider, "openrouter"); - assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514"); - assert!(coder.system_prompt.is_none()); - assert_eq!(coder.max_depth, 3); // default - } - - #[test] - fn agents_config_with_api_key_and_temperature() { - let toml_str = r#" -[agents.fast] -provider = "groq" -model = "llama-3.3-70b-versatile" -api_key = "gsk-test-key" -temperature = 0.3 -"#; - let parsed: HashMap = toml::from_str::(toml_str) - .unwrap()["agents"] - .clone() - .try_into() - .unwrap(); - let fast = &parsed["fast"]; - assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key")); - assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON); - } - - #[test] - fn agent_api_key_encrypted_on_save_and_decrypted_on_load() { - let tmp = TempDir::new().unwrap(); - let zeroclaw_dir = tmp.path(); - let config_path = zeroclaw_dir.join("config.toml"); - - // Create a config with a plaintext agent API key - let mut agents = HashMap::new(); - agents.insert( - "test_agent".to_string(), - DelegateAgentConfig { - provider: "openrouter".to_string(), - model: "test-model".to_string(), - system_prompt: None, - api_key: Some("sk-super-secret".to_string()), - temperature: None, - max_depth: 3, - }, - ); - let config = Config { - config_path: config_path.clone(), - workspace_dir: zeroclaw_dir.join("workspace"), - secrets: SecretsConfig { encrypt: true }, - agents, - ..Config::default() - }; - std::fs::create_dir_all(&config.workspace_dir).unwrap(); - config.save().unwrap(); - - // Read the raw TOML and verify the key is encrypted (not plaintext) - let raw = std::fs::read_to_string(&config_path).unwrap(); - assert!( - !raw.contains("sk-super-secret"), - "Plaintext API key should not appear in saved config" - ); - assert!( - raw.contains("enc2:"), - "Encrypted key should use enc2: prefix" - ); - - // Parse and decrypt — simulate load_or_init by reading + decrypting - let store = crate::security::SecretStore::new(zeroclaw_dir, true); - let mut loaded: Config = toml::from_str(&raw).unwrap(); - for agent in loaded.agents.values_mut() { - if let Some(ref encrypted_key) = agent.api_key { - agent.api_key = Some(store.decrypt(encrypted_key).unwrap()); - } - } - assert_eq!( - loaded.agents["test_agent"].api_key.as_deref(), - Some("sk-super-secret"), - "Decrypted key should match original" - ); - } - - #[test] - fn agent_api_key_not_encrypted_when_disabled() { - let tmp = TempDir::new().unwrap(); - let zeroclaw_dir = tmp.path(); - let config_path = zeroclaw_dir.join("config.toml"); - - let mut agents = HashMap::new(); - agents.insert( - "test_agent".to_string(), - DelegateAgentConfig { - provider: "openrouter".to_string(), - model: "test-model".to_string(), - system_prompt: None, - api_key: Some("sk-plaintext-ok".to_string()), - temperature: None, - max_depth: 3, - }, - ); - let config = Config { - config_path: config_path.clone(), - workspace_dir: zeroclaw_dir.join("workspace"), - secrets: SecretsConfig { encrypt: false }, - agents, - ..Config::default() - }; - std::fs::create_dir_all(&config.workspace_dir).unwrap(); - config.save().unwrap(); - - let raw = std::fs::read_to_string(&config_path).unwrap(); - assert!( - raw.contains("sk-plaintext-ok"), - "With encryption disabled, key should remain plaintext" - ); - assert!(!raw.contains("enc2:"), "No encryption prefix when disabled"); + let toml_str = toml::to_string(&p).unwrap(); + let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.boards.len(), 1); + assert_eq!(parsed.boards[0].board, "nucleo-f401re"); + assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0")); } } diff --git a/src/cost/tracker.rs b/src/cost/tracker.rs index 16b874f..697f381 100644 --- a/src/cost/tracker.rs +++ b/src/cost/tracker.rs @@ -1,5 +1,5 @@ use super::types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod}; -use crate::config::CostConfig; +use crate::config::schema::CostConfig; use anyhow::{anyhow, Context, Result}; use chrono::{Datelike, NaiveDate, Utc}; use std::collections::HashMap; diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index f1bc4a1..c7935ca 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -194,7 +194,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> { let prompt = format!("[Heartbeat Task] {task}"); let temp = config.default_temperature; if let Err(e) = - crate::agent::run(config.clone(), Some(prompt), None, None, temp, false).await + crate::agent::run(config.clone(), Some(prompt), None, None, temp, vec![]).await { crate::health::mark_component_error("heartbeat", e.to_string()); tracing::warn!("Heartbeat task failed: {e}"); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 638de00..9c97fe6 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -10,14 +10,8 @@ use crate::channels::{Channel, WhatsAppChannel}; use crate::config::Config; use crate::memory::{self, Memory, MemoryCategory}; -use crate::observability::{self, Observer}; -use crate::providers::{self, ChatMessage, Provider}; -use crate::runtime; -use crate::security::{ - pairing::{constant_time_eq, is_public_bind, PairingGuard}, - SecurityPolicy, -}; -use crate::tools::{self, Tool}; +use crate::providers::{self, Provider}; +use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard}; use crate::util::truncate_with_ellipsis; use anyhow::Result; use axum::{ @@ -51,39 +45,14 @@ fn whatsapp_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String format!("whatsapp_{}_{}", msg.sender, msg.id) } -fn normalize_gateway_reply(reply: String) -> String { - if reply.trim().is_empty() { - return "Model returned an empty response.".to_string(); - } - - reply -} - -async fn gateway_agent_reply(state: &AppState, message: &str) -> Result { - let mut history = vec![ - ChatMessage::system(state.system_prompt.as_str()), - ChatMessage::user(message), - ]; - - let reply = crate::agent::loop_::run_tool_call_loop( - state.provider.as_ref(), - &mut history, - state.tools_registry.as_ref(), - state.observer.as_ref(), - "gateway", - &state.model, - state.temperature, - ) - .await?; - - Ok(normalize_gateway_reply(reply)) -} +/// How often the rate limiter sweeps stale IP entries from its map. +const RATE_LIMITER_SWEEP_INTERVAL_SECS: u64 = 300; // 5 minutes #[derive(Debug)] struct SlidingWindowRateLimiter { limit_per_window: u32, window: Duration, - requests: Mutex>>, + requests: Mutex<(HashMap>, Instant)>, } impl SlidingWindowRateLimiter { @@ -91,7 +60,7 @@ impl SlidingWindowRateLimiter { Self { limit_per_window, window, - requests: Mutex::new(HashMap::new()), + requests: Mutex::new((HashMap::new(), Instant::now())), } } @@ -103,10 +72,20 @@ impl SlidingWindowRateLimiter { let now = Instant::now(); let cutoff = now.checked_sub(self.window).unwrap_or_else(Instant::now); - let mut requests = self + let mut guard = self .requests .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let (requests, last_sweep) = &mut *guard; + + // Periodic sweep: remove IPs with no recent requests + if last_sweep.elapsed() >= Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS) { + requests.retain(|_, timestamps| { + timestamps.retain(|t| *t > cutoff); + !timestamps.is_empty() + }); + *last_sweep = now; + } let entry = requests.entry(key.to_owned()).or_default(); entry.retain(|instant| *instant > cutoff); @@ -193,9 +172,6 @@ fn client_key_from_headers(headers: &HeaderMap) -> String { #[derive(Clone)] pub struct AppState { pub provider: Arc, - pub observer: Arc, - pub tools_registry: Arc>>, - pub system_prompt: Arc, pub model: String, pub temperature: f64, pub mem: Arc, @@ -242,54 +218,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { &config.workspace_dir, config.api_key.as_deref(), )?); - let observer: Arc = - Arc::from(observability::create_observer(&config.observability)); - let runtime: Arc = - Arc::from(runtime::create_runtime(&config.runtime)?); - let security = Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - )); - - let (composio_key, composio_entity_id) = if config.composio.enabled { - ( - config.composio.api_key.as_deref(), - Some(config.composio.entity_id.as_str()), - ) - } else { - (None, None) - }; - - let tools_registry = Arc::new(tools::all_tools_with_runtime( - &security, - runtime, - Arc::clone(&mem), - composio_key, - composio_entity_id, - &config.browser, - &config.http_request, - &config.workspace_dir, - &config.agents, - config.api_key.as_deref(), - &config, - )); - let skills = crate::skills::load_skills(&config.workspace_dir); - let tool_descs: Vec<(&str, &str)> = tools_registry - .iter() - .map(|tool| (tool.name(), tool.description())) - .collect(); - - let mut system_prompt = crate::channels::build_system_prompt( - &config.workspace_dir, - &model, - &tool_descs, - &skills, - Some(&config.identity), - ); - system_prompt.push_str(&crate::agent::loop_::build_tool_instructions( - tools_registry.as_ref(), - )); - let system_prompt = Arc::new(system_prompt); // Extract webhook secret for authentication let webhook_secret: Option> = config @@ -393,9 +321,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { // Build shared state let state = AppState { provider, - observer, - tools_registry, - system_prompt, model, temperature, mem, @@ -579,9 +504,13 @@ async fn handle_webhook( .await; } - match gateway_agent_reply(&state, message).await { - Ok(reply) => { - let body = serde_json::json!({"response": reply, "model": state.model}); + match state + .provider + .simple_chat(message, &state.model, state.temperature) + .await + { + Ok(response) => { + let body = serde_json::json!({"response": response, "model": state.model}); (StatusCode::OK, Json(body)) } Err(e) => { @@ -729,10 +658,14 @@ async fn handle_whatsapp_message( } // Call the LLM - match gateway_agent_reply(&state, &msg.content).await { - Ok(reply) => { + match state + .provider + .simple_chat(&msg.content, &state.model, state.temperature) + .await + { + Ok(response) => { // Send reply via WhatsApp - if let Err(e) = wa.send(&reply, &msg.sender).await { + if let Err(e) = wa.send(&response, &msg.sender).await { tracing::error!("Failed to send WhatsApp reply: {e}"); } } @@ -811,6 +744,55 @@ mod tests { assert!(!limiter.allow_pair("127.0.0.1")); } + #[test] + fn rate_limiter_sweep_removes_stale_entries() { + let limiter = SlidingWindowRateLimiter::new(10, Duration::from_secs(60)); + // Add entries for multiple IPs + assert!(limiter.allow("ip-1")); + assert!(limiter.allow("ip-2")); + assert!(limiter.allow("ip-3")); + + { + let guard = limiter + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(guard.0.len(), 3); + } + + // Force a sweep by backdating last_sweep + { + let mut guard = limiter + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.1 = Instant::now() - Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS + 1); + // Clear timestamps for ip-2 and ip-3 to simulate stale entries + guard.0.get_mut("ip-2").unwrap().clear(); + guard.0.get_mut("ip-3").unwrap().clear(); + } + + // Next allow() call should trigger sweep and remove stale entries + assert!(limiter.allow("ip-1")); + + { + let guard = limiter + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(guard.0.len(), 1, "Stale entries should have been swept"); + assert!(guard.0.contains_key("ip-1")); + } + } + + #[test] + fn rate_limiter_zero_limit_always_allows() { + let limiter = SlidingWindowRateLimiter::new(0, Duration::from_secs(60)); + for _ in 0..100 { + assert!(limiter.allow("any-key")); + } + } + #[test] fn idempotency_store_rejects_duplicate_key() { let store = IdempotencyStore::new(Duration::from_secs(30)); @@ -902,9 +884,9 @@ mod tests { _message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::providers::ChatResponse::with_text("ok")) + Ok("ok".into()) } } @@ -965,36 +947,25 @@ mod tests { } } - fn test_app_state( - provider: Arc, - memory: Arc, - auto_save: bool, - ) -> AppState { - AppState { - provider, - observer: Arc::new(crate::observability::NoopObserver), - tools_registry: Arc::new(Vec::new()), - system_prompt: Arc::new("test-system-prompt".into()), - model: "test-model".into(), - temperature: 0.0, - mem: memory, - auto_save, - webhook_secret: None, - pairing: Arc::new(PairingGuard::new(false, &[])), - rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)), - idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))), - whatsapp: None, - whatsapp_app_secret: None, - } - } - #[tokio::test] async fn webhook_idempotency_skips_duplicate_provider_calls() { let provider_impl = Arc::new(MockProvider::default()); let provider: Arc = provider_impl.clone(); let memory: Arc = Arc::new(MockMemory); - let state = test_app_state(provider, memory, false); + let state = AppState { + provider, + model: "test-model".into(), + temperature: 0.0, + mem: memory, + auto_save: false, + webhook_secret: None, + pairing: Arc::new(PairingGuard::new(false, &[])), + rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)), + idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))), + whatsapp: None, + whatsapp_app_secret: None, + }; let mut headers = HeaderMap::new(); headers.insert("X-Idempotency-Key", HeaderValue::from_static("abc-123")); @@ -1030,7 +1001,19 @@ mod tests { let tracking_impl = Arc::new(TrackingMemory::default()); let memory: Arc = tracking_impl.clone(); - let state = test_app_state(provider, memory, true); + let state = AppState { + provider, + model: "test-model".into(), + temperature: 0.0, + mem: memory, + auto_save: true, + webhook_secret: None, + pairing: Arc::new(PairingGuard::new(false, &[])), + rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)), + idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))), + whatsapp: None, + whatsapp_app_secret: None, + }; let headers = HeaderMap::new(); @@ -1062,110 +1045,6 @@ mod tests { assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2); } - #[derive(Default)] - struct StructuredToolCallProvider { - calls: AtomicUsize, - } - - #[async_trait] - impl Provider for StructuredToolCallProvider { - async fn chat_with_system( - &self, - _system_prompt: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let turn = self.calls.fetch_add(1, Ordering::SeqCst); - - if turn == 0 { - return Ok(crate::providers::ChatResponse { - text: Some("Running tool...".into()), - tool_calls: vec![crate::providers::ToolCall { - id: "call_1".into(), - name: "mock_tool".into(), - arguments: r#"{"query":"gateway"}"#.into(), - }], - }); - } - - Ok(crate::providers::ChatResponse::with_text( - "Gateway tool result ready.", - )) - } - } - - struct MockTool { - calls: Arc, - } - - #[async_trait] - impl Tool for MockTool { - fn name(&self) -> &str { - "mock_tool" - } - - fn description(&self) -> &str { - "Mock tool for gateway tests" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "query": {"type": "string"} - }, - "required": ["query"] - }) - } - - async fn execute( - &self, - args: serde_json::Value, - ) -> anyhow::Result { - self.calls.fetch_add(1, Ordering::SeqCst); - assert_eq!(args["query"], "gateway"); - - Ok(crate::tools::ToolResult { - success: true, - output: "ok".into(), - error: None, - }) - } - } - - #[tokio::test] - async fn webhook_executes_structured_tool_calls() { - let provider_impl = Arc::new(StructuredToolCallProvider::default()); - let provider: Arc = provider_impl.clone(); - let memory: Arc = Arc::new(MockMemory); - - let tool_calls = Arc::new(AtomicUsize::new(0)); - let tools: Vec> = vec![Box::new(MockTool { - calls: Arc::clone(&tool_calls), - })]; - - let mut state = test_app_state(provider, memory, false); - state.tools_registry = Arc::new(tools); - - let response = handle_webhook( - State(state), - HeaderMap::new(), - Ok(Json(WebhookBody { - message: "please use tool".into(), - })), - ) - .await - .into_response(); - - assert_eq!(response.status(), StatusCode::OK); - let payload = response.into_body().collect().await.unwrap().to_bytes(); - let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap(); - assert_eq!(parsed["response"], "Gateway tool result ready."); - assert_eq!(tool_calls.load(Ordering::SeqCst), 1); - assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2); - } - // ══════════════════════════════════════════════════════════ // WhatsApp Signature Verification Tests (CWE-345 Prevention) // ══════════════════════════════════════════════════════════ diff --git a/src/hardware/discover.rs b/src/hardware/discover.rs new file mode 100644 index 0000000..4bbf31f --- /dev/null +++ b/src/hardware/discover.rs @@ -0,0 +1,45 @@ +//! USB device discovery — enumerate devices and enrich with board registry. + +use super::registry; +use anyhow::Result; +use nusb::MaybeFuture; + +/// Information about a discovered USB device. +#[derive(Debug, Clone)] +pub struct UsbDeviceInfo { + pub bus_id: String, + pub device_address: u8, + pub vid: u16, + pub pid: u16, + pub product_string: Option, + pub board_name: Option, + pub architecture: Option, +} + +/// Enumerate all connected USB devices and enrich with board registry lookup. +#[cfg(feature = "hardware")] +pub fn list_usb_devices() -> Result> { + let mut devices = Vec::new(); + + let iter = nusb::list_devices() + .wait() + .map_err(|e| anyhow::anyhow!("USB enumeration failed: {e}"))?; + + for dev in iter { + let vid = dev.vendor_id(); + let pid = dev.product_id(); + let board = registry::lookup_board(vid, pid); + + devices.push(UsbDeviceInfo { + bus_id: dev.bus_id().to_string(), + device_address: dev.device_address(), + vid, + pid, + product_string: dev.product_string().map(String::from), + board_name: board.map(|b| b.name.to_string()), + architecture: board.and_then(|b| b.architecture.map(String::from)), + }); + } + + Ok(devices) +} diff --git a/src/hardware/introspect.rs b/src/hardware/introspect.rs new file mode 100644 index 0000000..21b5744 --- /dev/null +++ b/src/hardware/introspect.rs @@ -0,0 +1,121 @@ +//! Device introspection — correlate serial path with USB device info. + +use super::discover; +use super::registry; +use anyhow::Result; + +/// Result of introspecting a device by path. +#[derive(Debug, Clone)] +pub struct IntrospectResult { + pub path: String, + pub vid: Option, + pub pid: Option, + pub board_name: Option, + pub architecture: Option, + pub memory_map_note: String, +} + +/// Introspect a device by its serial path (e.g. /dev/ttyACM0, /dev/tty.usbmodem*). +/// Attempts to correlate with USB devices from discovery. +#[cfg(feature = "hardware")] +pub fn introspect_device(path: &str) -> Result { + let devices = discover::list_usb_devices()?; + + // Try to correlate path with a discovered device. + // On Linux, /dev/ttyACM0 corresponds to a CDC-ACM device; we may have multiple. + // Best-effort: if we have exactly one CDC-like device, use it. Otherwise unknown. + let matched = if devices.len() == 1 { + devices.first().cloned() + } else if devices.is_empty() { + None + } else { + // Multiple devices: try to match by path. On Linux we could use sysfs; + // for stub, pick first known board or first device. + devices + .iter() + .find(|d| d.board_name.is_some()) + .cloned() + .or_else(|| devices.first().cloned()) + }; + + let (vid, pid, board_name, architecture) = match matched { + Some(d) => (Some(d.vid), Some(d.pid), d.board_name, d.architecture), + None => (None, None, None, None), + }; + + let board_info = vid.and_then(|v| pid.and_then(|p| registry::lookup_board(v, p))); + let architecture = + architecture.or_else(|| board_info.and_then(|b| b.architecture.map(String::from))); + let board_name = board_name.or_else(|| board_info.map(|b| b.name.to_string())); + + let memory_map_note = memory_map_for_board(board_name.as_deref()); + + Ok(IntrospectResult { + path: path.to_string(), + vid, + pid, + board_name, + architecture, + memory_map_note, + }) +} + +/// Get memory map: via probe-rs when probe feature on and Nucleo, else static or stub. +#[cfg(feature = "hardware")] +fn memory_map_for_board(board_name: Option<&str>) -> String { + #[cfg(feature = "probe")] + if let Some(board) = board_name { + let chip = match board { + "nucleo-f401re" => "STM32F401RETx", + "nucleo-f411re" => "STM32F411RETx", + _ => return "Build with --features probe for live memory map (Nucleo)".to_string(), + }; + match probe_memory_map(chip) { + Ok(s) => return s, + Err(_) => return format!("probe-rs attach failed (chip {}). Connect via USB.", chip), + } + } + + #[cfg(not(feature = "probe"))] + let _ = board_name; + + "Build with --features probe for live memory map via USB".to_string() +} + +#[cfg(all(feature = "hardware", feature = "probe"))] +fn probe_memory_map(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let target = session.target(); + let mut out = String::new(); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let (start, end) = (ram.range.start, ram.range.end); + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + MemoryRegion::Nvm(flash) => { + let (start, end) = (flash.range.start, flash.range.end); + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + _ => {} + } + } + if out.is_empty() { + out = "Could not read memory regions".to_string(); + } + Ok(out) +} diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index 30b551b..8dcd90d 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -1,1351 +1,229 @@ -//! Hardware Abstraction Layer (HAL) for ZeroClaw. +//! Hardware discovery — USB device enumeration and introspection. //! -//! Provides auto-discovery of connected hardware, transport abstraction, -//! and a unified interface so the LLM agent can control physical devices -//! without knowing the underlying communication protocol. -//! -//! # Supported Transport Modes -//! -//! | Transport | Backend | Use Case | -//! |-----------|-------------|---------------------------------------------| -//! | `native` | rppal / sysfs | Raspberry Pi / Linux SBC with local GPIO | -//! | `serial` | JSON/UART | Arduino, ESP32, Nucleo via USB serial | -//! | `probe` | probe-rs | STM32/ESP32 via SWD/JTAG debug interface | -//! | `none` | — | Software-only mode (no hardware access) | +//! See `docs/hardware-peripherals-design.md` for the full design. -use anyhow::{bail, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +pub mod registry; -// ── Hardware transport enum ────────────────────────────────────── +#[cfg(feature = "hardware")] +pub mod discover; -/// Transport protocol used to communicate with physical hardware. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum HardwareTransport { - /// Direct GPIO access on a Linux SBC (Raspberry Pi, Orange Pi, etc.) - Native, - /// JSON commands over USB serial (Arduino, ESP32, Nucleo) - Serial, - /// SWD/JTAG debug probe (probe-rs) for bare-metal MCUs - Probe, - /// No hardware — software-only mode - None, +#[cfg(feature = "hardware")] +pub mod introspect; + +use crate::config::Config; +use anyhow::Result; + +// Re-export config types so wizard can use `hardware::HardwareConfig` etc. +pub use crate::config::{HardwareConfig, HardwareTransport}; + +/// A hardware device discovered during auto-scan. +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub name: String, + pub detail: Option, + pub device_path: Option, + pub transport: HardwareTransport, } -impl Default for HardwareTransport { - fn default() -> Self { - Self::None +/// Auto-discover connected hardware devices. +/// Returns an empty vec on platforms without hardware support. +pub fn discover_hardware() -> Vec { + // USB/serial discovery is behind the "hardware" feature gate. + #[cfg(feature = "hardware")] + { + if let Ok(devices) = discover::list_usb_devices() { + return devices + .into_iter() + .map(|d| DiscoveredDevice { + name: d + .board_name + .unwrap_or_else(|| format!("{:04x}:{:04x}", d.vid, d.pid)), + detail: d.product_string, + device_path: None, + transport: if d.architecture.as_deref() == Some("native") { + HardwareTransport::Native + } else { + HardwareTransport::Serial + }, + }) + .collect(); + } + } + Vec::new() +} + +/// Return the recommended default wizard choice index based on discovered devices. +/// 0 = Native, 1 = Tethered/Serial, 2 = Debug Probe, 3 = Software Only +pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { + if devices.is_empty() { + 3 // software only + } else { + 1 // tethered (most common for detected USB devices) } } -impl std::fmt::Display for HardwareTransport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Native => write!(f, "native"), - Self::Serial => write!(f, "serial"), - Self::Probe => write!(f, "probe"), - Self::None => write!(f, "none"), +/// Build a `HardwareConfig` from the wizard menu choice (0–3) and discovered devices. +pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig { + match choice { + 0 => HardwareConfig { + enabled: true, + transport: HardwareTransport::Native, + ..HardwareConfig::default() + }, + 1 => { + let serial_port = devices + .iter() + .find(|d| d.transport == HardwareTransport::Serial) + .and_then(|d| d.device_path.clone()); + HardwareConfig { + enabled: true, + transport: HardwareTransport::Serial, + serial_port, + ..HardwareConfig::default() + } } + 2 => HardwareConfig { + enabled: true, + transport: HardwareTransport::Probe, + ..HardwareConfig::default() + }, + _ => HardwareConfig::default(), // software only } } -impl HardwareTransport { - /// Parse from a string value (config file or CLI arg). - pub fn from_str_loose(s: &str) -> Self { - match s.to_ascii_lowercase().trim() { - "native" | "gpio" | "rppal" | "sysfs" => Self::Native, - "serial" | "uart" | "usb" | "tethered" => Self::Serial, - "probe" | "probe-rs" | "swd" | "jtag" | "jlink" | "j-link" => Self::Probe, - _ => Self::None, - } +/// Handle `zeroclaw hardware` subcommands. +#[allow(clippy::module_name_repetitions)] +pub fn handle_command(cmd: crate::HardwareCommands, _config: &Config) -> Result<()> { + #[cfg(not(feature = "hardware"))] + { + println!("Hardware discovery requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + return Ok(()); + } + + #[cfg(feature = "hardware")] + match cmd { + crate::HardwareCommands::Discover => run_discover(), + crate::HardwareCommands::Introspect { path } => run_introspect(&path), + crate::HardwareCommands::Info { chip } => run_info(&chip), } } -// ── Hardware configuration ────────────────────────────────────── +#[cfg(feature = "hardware")] +fn run_discover() -> Result<()> { + let devices = discover::list_usb_devices()?; -/// Hardware configuration stored in `config.toml` under `[hardware]`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HardwareConfig { - /// Enable hardware integration - #[serde(default)] - pub enabled: bool, - - /// Transport mode: "native", "serial", "probe", "none" - #[serde(default = "default_transport")] - pub transport: String, - - /// Serial port path (e.g. `/dev/ttyUSB0`, `/dev/tty.usbmodem14201`) - #[serde(default)] - pub serial_port: Option, - - /// Serial baud rate (default: 115200) - #[serde(default = "default_baud_rate")] - pub baud_rate: u32, - - /// Enable datasheet RAG — index PDF schematics in workspace for pin lookups - #[serde(default)] - pub workspace_datasheets: bool, - - /// Auto-discovered board description (informational, set by discovery) - #[serde(default)] - pub discovered_board: Option, - - /// Probe target chip (e.g. "STM32F411CEUx", "nRF52840_xxAA") - #[serde(default)] - pub probe_target: Option, - - /// GPIO pin safety allowlist — only these pins can be written to. - /// Empty = all pins allowed (for development). Recommended for production. - #[serde(default)] - pub allowed_pins: Vec, - - /// Maximum PWM frequency in Hz (safety cap, default: 50_000) - #[serde(default = "default_max_pwm_freq")] - pub max_pwm_frequency_hz: u32, -} - -fn default_transport() -> String { - "none".into() -} - -fn default_baud_rate() -> u32 { - 115_200 -} - -fn default_max_pwm_freq() -> u32 { - 50_000 -} - -impl Default for HardwareConfig { - fn default() -> Self { - Self { - enabled: false, - transport: default_transport(), - serial_port: None, - baud_rate: default_baud_rate(), - workspace_datasheets: false, - discovered_board: None, - probe_target: None, - allowed_pins: Vec::new(), - max_pwm_frequency_hz: default_max_pwm_freq(), - } - } -} - -impl HardwareConfig { - /// Return the parsed transport enum. - pub fn transport_mode(&self) -> HardwareTransport { - HardwareTransport::from_str_loose(&self.transport) + if devices.is_empty() { + println!("No USB devices found."); + println!(); + println!("Connect a board (e.g. Nucleo-F401RE) via USB and try again."); + return Ok(()); } - /// Check if pin access is allowed by the safety allowlist. - /// An empty allowlist means all pins are permitted (dev mode). - pub fn is_pin_allowed(&self, pin: u8) -> bool { - self.allowed_pins.is_empty() || self.allowed_pins.contains(&pin) + println!("USB devices:"); + println!(); + for d in &devices { + let board = d.board_name.as_deref().unwrap_or("(unknown)"); + let arch = d.architecture.as_deref().unwrap_or("—"); + let product = d.product_string.as_deref().unwrap_or("—"); + println!( + " {:04x}:{:04x} {} {} {}", + d.vid, d.pid, board, arch, product + ); + } + println!(); + println!("Known boards: nucleo-f401re, nucleo-f411re, arduino-uno, arduino-mega, cp2102"); + + Ok(()) +} + +#[cfg(feature = "hardware")] +fn run_introspect(path: &str) -> Result<()> { + let result = introspect::introspect_device(path)?; + + println!("Device at {}:", result.path); + println!(); + if let (Some(vid), Some(pid)) = (result.vid, result.pid) { + println!(" VID:PID {:04x}:{:04x}", vid, pid); + } else { + println!(" VID:PID (could not correlate with USB device)"); + } + if let Some(name) = &result.board_name { + println!(" Board {}", name); + } + if let Some(arch) = &result.architecture { + println!(" Architecture {}", arch); + } + println!(" Memory map {}", result.memory_map_note); + + Ok(()) +} + +#[cfg(feature = "hardware")] +fn run_info(chip: &str) -> Result<()> { + #[cfg(feature = "probe")] + { + match info_via_probe(chip) { + Ok(()) => return Ok(()), + Err(e) => { + println!("probe-rs attach failed: {}", e); + println!(); + println!( + "Ensure Nucleo is connected via USB. The ST-Link is built into the board." + ); + println!("No firmware needs to be flashed — probe-rs reads chip info over SWD."); + return Err(e.into()); + } + } } - /// Validate the configuration, returning errors for invalid combos. - pub fn validate(&self) -> Result<()> { - if !self.enabled { - return Ok(()); - } - - let mode = self.transport_mode(); - - // Serial requires a port - if mode == HardwareTransport::Serial && self.serial_port.is_none() { - bail!("Hardware transport is 'serial' but no serial_port is configured. Run `zeroclaw onboard --interactive` or set hardware.serial_port in config.toml."); - } - - // Probe requires a target chip - if mode == HardwareTransport::Probe && self.probe_target.is_none() { - bail!("Hardware transport is 'probe' but no probe_target chip is configured. Set hardware.probe_target in config.toml (e.g. \"STM32F411CEUx\")."); - } - - // Baud rate sanity - if self.baud_rate == 0 { - bail!("hardware.baud_rate must be greater than 0."); - } - if self.baud_rate > 4_000_000 { - bail!( - "hardware.baud_rate of {} exceeds the 4 MHz safety limit.", - self.baud_rate - ); - } - - // PWM frequency sanity - if self.max_pwm_frequency_hz == 0 { - bail!("hardware.max_pwm_frequency_hz must be greater than 0."); - } - + #[cfg(not(feature = "probe"))] + { + println!("Chip info via USB requires the 'probe' feature."); + println!(); + println!("Build with: cargo build --features hardware,probe"); + println!(); + println!("Then run: zeroclaw hardware info --chip {}", chip); + println!(); + println!("This uses probe-rs to attach to the Nucleo's ST-Link over USB"); + println!("and read chip info (memory map, etc.) — no firmware on target needed."); Ok(()) } } -// ── Discovery: detected hardware on this system ───────────────── +#[cfg(all(feature = "hardware", feature = "probe"))] +fn info_via_probe(chip: &str) -> anyhow::Result<()> { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; -/// A single discovered hardware device. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiscoveredDevice { - /// Human-readable name (e.g. "Raspberry Pi GPIO", "Arduino Uno") - pub name: String, - /// Recommended transport mode - pub transport: HardwareTransport, - /// Path to the device (e.g. `/dev/ttyUSB0`, `/dev/gpiomem`) - pub device_path: Option, - /// Additional detail (e.g. board revision, chip ID) - pub detail: Option, -} + println!("Connecting to {} via USB (ST-Link)...", chip); + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; -/// Scan the system for connected hardware. -/// -/// This function performs non-destructive, read-only probes: -/// 1. Check for Raspberry Pi GPIO (`/dev/gpiomem`, `/proc/device-tree/model`) -/// 2. Check for USB serial devices (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/tty.usbmodem*`) -/// 3. Check for SWD/JTAG probes (`/dev/ttyACM*` with probe-rs markers) -/// -/// This is intentionally conservative — it never writes to any device. -pub fn discover_hardware() -> Vec { - let mut devices = Vec::new(); - - // ── 1. Raspberry Pi / Linux SBC native GPIO ────────────── - discover_native_gpio(&mut devices); - - // ── 2. USB Serial devices (Arduino, ESP32, etc.) ───────── - discover_serial_devices(&mut devices); - - // ── 3. SWD / JTAG debug probes ────────────────────────── - discover_debug_probes(&mut devices); - - devices -} - -/// Check for native GPIO availability (Raspberry Pi, Orange Pi, etc.) -fn discover_native_gpio(devices: &mut Vec) { - // Primary indicator: /dev/gpiomem exists (Pi-specific) - let gpiomem = Path::new("/dev/gpiomem"); - // Secondary: /dev/gpiochip0 exists (any Linux with GPIO) - let gpiochip = Path::new("/dev/gpiochip0"); - - if gpiomem.exists() || gpiochip.exists() { - // Try to read model from device tree - let model = read_board_model(); - let name = model.as_deref().unwrap_or("Linux SBC with GPIO"); - - devices.push(DiscoveredDevice { - name: format!("{name} (Native GPIO)"), - transport: HardwareTransport::Native, - device_path: Some(if gpiomem.exists() { - "/dev/gpiomem".into() - } else { - "/dev/gpiochip0".into() - }), - detail: model, - }); - } -} - -/// Read the board model string from the device tree (Linux). -fn read_board_model() -> Option { - let model_path = Path::new("/proc/device-tree/model"); - if model_path.exists() { - std::fs::read_to_string(model_path) - .ok() - .map(|s| s.trim_end_matches('\0').trim().to_string()) - .filter(|s| !s.is_empty()) - } else { - None - } -} - -/// Scan for USB serial devices. -fn discover_serial_devices(devices: &mut Vec) { - let serial_patterns = serial_device_paths(); - - for pattern in &serial_patterns { - let matches = glob_paths(pattern); - for path in matches { - let name = classify_serial_device(&path); - devices.push(DiscoveredDevice { - name: format!("{name} (USB Serial)"), - transport: HardwareTransport::Serial, - device_path: Some(path.to_string_lossy().to_string()), - detail: None, - }); - } - } -} - -/// Return platform-specific glob patterns for serial devices. -fn serial_device_paths() -> Vec { - if cfg!(target_os = "macos") { - vec![ - "/dev/tty.usbmodem*".into(), - "/dev/tty.usbserial*".into(), - "/dev/tty.wchusbserial*".into(), // CH340 clones - ] - } else if cfg!(target_os = "linux") { - vec!["/dev/ttyUSB*".into(), "/dev/ttyACM*".into()] - } else { - // Windows / other — not yet supported for auto-discovery - vec![] - } -} - -/// Classify a serial device path into a human-readable name. -fn classify_serial_device(path: &Path) -> String { - let name = path.file_name().unwrap_or_default().to_string_lossy(); - let lower = name.to_ascii_lowercase(); - - if lower.contains("usbmodem") { - "Arduino/Teensy".into() - } else if lower.contains("usbserial") || lower.contains("ttyusb") { - "USB-Serial Device (FTDI/CH340/CP2102)".into() - } else if lower.contains("wchusbserial") { - "CH340/CH341 Serial".into() - } else if lower.contains("ttyacm") { - "USB CDC Device (Arduino/STM32)".into() - } else { - "Unknown Serial Device".into() - } -} - -/// Simple glob expansion for device paths. -fn glob_paths(pattern: &str) -> Vec { - glob::glob(pattern) - .map(|paths| paths.filter_map(Result::ok).collect()) - .unwrap_or_default() -} - -/// Check for SWD/JTAG debug probes. -fn discover_debug_probes(devices: &mut Vec) { - // On Linux, ST-Link probes often show up as /dev/stlinkv* - // We also check for known USB VIDs via sysfs if available - let stlink_paths = glob_paths("/dev/stlinkv*"); - for path in stlink_paths { - devices.push(DiscoveredDevice { - name: "ST-Link Debug Probe (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: Some(path.to_string_lossy().to_string()), - detail: Some("Use probe-rs for flash/debug".into()), - }); - } - - // J-Link probes on macOS - let jlink_paths = glob_paths("/dev/tty.SLAB_USBtoUART*"); - for path in jlink_paths { - devices.push(DiscoveredDevice { - name: "SEGGER J-Link (SWD/JTAG)".into(), - transport: HardwareTransport::Probe, - device_path: Some(path.to_string_lossy().to_string()), - detail: Some("Use probe-rs for flash/debug".into()), - }); - } -} - -// ── HAL Trait: Unified hardware operations ────────────────────── - -/// The core HAL trait that all transport backends implement. -/// -/// The LLM agent calls these methods via tool invocations. The HAL -/// translates them into the correct protocol for the underlying hardware. -pub trait HardwareHal: Send + Sync { - /// Read the digital state of a GPIO pin. - fn gpio_read(&self, pin: u8) -> Result; - - /// Write a digital value to a GPIO pin. - fn gpio_write(&self, pin: u8, value: bool) -> Result<()>; - - /// Read a memory address (for probe-rs or memory-mapped I/O). - fn memory_read(&self, address: u32, length: u32) -> Result>; - - /// Upload firmware to a connected device (Arduino sketch, STM32 binary). - fn firmware_upload(&self, path: &Path) -> Result<()>; - - /// Return a human-readable description of the connected hardware. - fn describe(&self) -> String; - - /// Set PWM duty cycle on a pin (0–100%). - fn pwm_set(&self, pin: u8, duty_percent: f32) -> Result<()>; - - /// Read an analog value (ADC) from a pin, returning 0.0–1.0. - fn analog_read(&self, pin: u8) -> Result; -} - -// ── NoopHal: used in software-only mode ───────────────────────── - -/// A no-op HAL implementation for software-only mode. -/// All hardware operations return descriptive errors. -pub struct NoopHal; - -impl HardwareHal for NoopHal { - fn gpio_read(&self, pin: u8) -> Result { - bail!("Hardware not enabled. Cannot read GPIO pin {pin}. Enable hardware in config.toml or run `zeroclaw onboard --interactive`."); - } - - fn gpio_write(&self, pin: u8, value: bool) -> Result<()> { - bail!("Hardware not enabled. Cannot write GPIO pin {pin}={value}. Enable hardware in config.toml."); - } - - fn memory_read(&self, address: u32, _length: u32) -> Result> { - bail!("Hardware not enabled. Cannot read memory at 0x{address:08X}."); - } - - fn firmware_upload(&self, path: &Path) -> Result<()> { - bail!( - "Hardware not enabled. Cannot upload firmware from {}.", - path.display() - ); - } - - fn describe(&self) -> String { - "NoopHal (software-only mode — no hardware connected)".into() - } - - fn pwm_set(&self, pin: u8, _duty_percent: f32) -> Result<()> { - bail!("Hardware not enabled. Cannot set PWM on pin {pin}."); - } - - fn analog_read(&self, pin: u8) -> Result { - bail!("Hardware not enabled. Cannot read analog pin {pin}."); - } -} - -// ── Factory: create the right HAL from config ─────────────────── - -/// Create the appropriate HAL backend from the hardware configuration. -/// -/// This is the main entry point — call this once at startup and pass -/// the resulting `Box` to the tool registry. -pub fn create_hal(config: &HardwareConfig) -> Result> { - config.validate()?; - - if !config.enabled { - return Ok(Box::new(NoopHal)); - } - - match config.transport_mode() { - HardwareTransport::None => Ok(Box::new(NoopHal)), - HardwareTransport::Native => { - // In a full implementation, this would return a RppalHal or SysfsHal. - // For now, we return a stub that validates the transport is correct. - bail!( - "Native GPIO transport requires the `rppal` crate (Raspberry Pi only). \ - This will be available in a future release. For now, use 'serial' transport \ - with an Arduino/ESP32 bridge." - ); - } - HardwareTransport::Serial => { - let port = config.serial_port.as_deref().unwrap_or("/dev/ttyUSB0"); - // In a full implementation, this would open the serial port and - // return a SerialHal that sends JSON commands over UART. - bail!( - "Serial transport to '{}' at {} baud is configured but the serial HAL \ - backend is not yet compiled in. This will be available in the next release.", - port, - config.baud_rate - ); - } - HardwareTransport::Probe => { - let target = config.probe_target.as_deref().unwrap_or("unknown"); - bail!( - "Probe transport targeting '{}' is configured but the probe-rs HAL \ - backend is not yet compiled in. This will be available in a future release.", - target - ); - } - } -} - -// ── Wizard helper: build config from discovery ────────────────── - -/// Determine the best default selection index for the wizard -/// based on discovery results. -pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { - // If we found native GPIO → recommend Native (index 0) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Native) - { - return 0; - } - // If we found serial devices → recommend Tethered (index 1) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Serial) - { - return 1; - } - // If we found debug probes → recommend Probe (index 2) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Probe) - { - return 2; - } - // Default: Software Only (index 3) - 3 -} - -/// Build a `HardwareConfig` from a wizard selection and discovered devices. -pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig { - match choice { - // Native - 0 => { - let native_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Native); - HardwareConfig { - enabled: true, - transport: "native".into(), - discovered_board: native_device - .and_then(|d| d.detail.clone()) - .or_else(|| native_device.map(|d| d.name.clone())), - ..HardwareConfig::default() + let target = session.target(); + println!(); + println!("Chip: {}", target.name); + println!("Architecture: {:?}", session.architecture()); + println!(); + println!("Memory map:"); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let start = ram.range.start; + let end = ram.range.end; + let size_kb = (end - start) / 1024; + println!(" RAM: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb); } - } - // Serial / Tethered - 1 => { - let serial_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Serial); - HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: serial_device.and_then(|d| d.device_path.clone()), - discovered_board: serial_device.map(|d| d.name.clone()), - ..HardwareConfig::default() + MemoryRegion::Nvm(flash) => { + let start = flash.range.start; + let end = flash.range.end; + let size_kb = (end - start) / 1024; + println!(" Flash: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb); } + _ => {} } - // Probe - 2 => { - let probe_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Probe); - HardwareConfig { - enabled: true, - transport: "probe".into(), - discovered_board: probe_device.map(|d| d.name.clone()), - ..HardwareConfig::default() - } - } - // Software only - _ => HardwareConfig::default(), - } -} - -// ═══════════════════════════════════════════════════════════════════ -// ── Tests ─────────────────────────────────────────────────────── -// ═══════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod tests { - use super::*; - - // ── HardwareTransport parsing ────────────────────────────── - - #[test] - fn transport_parse_native_variants() { - assert_eq!( - HardwareTransport::from_str_loose("native"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("gpio"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("rppal"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("sysfs"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("NATIVE"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose(" Native "), - HardwareTransport::Native - ); - } - - #[test] - fn transport_parse_serial_variants() { - assert_eq!( - HardwareTransport::from_str_loose("serial"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("uart"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("usb"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("tethered"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("SERIAL"), - HardwareTransport::Serial - ); - } - - #[test] - fn transport_parse_probe_variants() { - assert_eq!( - HardwareTransport::from_str_loose("probe"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("probe-rs"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("swd"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("jtag"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("jlink"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("j-link"), - HardwareTransport::Probe - ); - } - - #[test] - fn transport_parse_none_and_unknown() { - assert_eq!( - HardwareTransport::from_str_loose("none"), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose(""), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose("foobar"), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose("bluetooth"), - HardwareTransport::None - ); - } - - #[test] - fn transport_default_is_none() { - assert_eq!(HardwareTransport::default(), HardwareTransport::None); - } - - #[test] - fn transport_display() { - assert_eq!(format!("{}", HardwareTransport::Native), "native"); - assert_eq!(format!("{}", HardwareTransport::Serial), "serial"); - assert_eq!(format!("{}", HardwareTransport::Probe), "probe"); - assert_eq!(format!("{}", HardwareTransport::None), "none"); - } - - // ── HardwareTransport serde ──────────────────────────────── - - #[test] - fn transport_serde_roundtrip() { - let json = serde_json::to_string(&HardwareTransport::Native).unwrap(); - assert_eq!(json, "\"native\""); - let parsed: HardwareTransport = serde_json::from_str("\"serial\"").unwrap(); - assert_eq!(parsed, HardwareTransport::Serial); - let parsed2: HardwareTransport = serde_json::from_str("\"probe\"").unwrap(); - assert_eq!(parsed2, HardwareTransport::Probe); - let parsed3: HardwareTransport = serde_json::from_str("\"none\"").unwrap(); - assert_eq!(parsed3, HardwareTransport::None); - } - - // ── HardwareConfig defaults ──────────────────────────────── - - #[test] - fn config_default_values() { - let cfg = HardwareConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.transport, "none"); - assert_eq!(cfg.baud_rate, 115_200); - assert!(cfg.serial_port.is_none()); - assert!(!cfg.workspace_datasheets); - assert!(cfg.discovered_board.is_none()); - assert!(cfg.probe_target.is_none()); - assert!(cfg.allowed_pins.is_empty()); - assert_eq!(cfg.max_pwm_frequency_hz, 50_000); - } - - #[test] - fn config_transport_mode_maps_correctly() { - let mut cfg = HardwareConfig::default(); - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - - cfg.transport = "native".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Native); - - cfg.transport = "serial".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); - - cfg.transport = "probe".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Probe); - - cfg.transport = "UART".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); - } - - // ── HardwareConfig::is_pin_allowed ───────────────────────── - - #[test] - fn pin_allowed_empty_allowlist_permits_all() { - let cfg = HardwareConfig::default(); - assert!(cfg.is_pin_allowed(0)); - assert!(cfg.is_pin_allowed(13)); - assert!(cfg.is_pin_allowed(255)); - } - - #[test] - fn pin_allowed_nonempty_allowlist_restricts() { - let cfg = HardwareConfig { - allowed_pins: vec![2, 13, 27], - ..HardwareConfig::default() - }; - assert!(cfg.is_pin_allowed(2)); - assert!(cfg.is_pin_allowed(13)); - assert!(cfg.is_pin_allowed(27)); - assert!(!cfg.is_pin_allowed(0)); - assert!(!cfg.is_pin_allowed(14)); - assert!(!cfg.is_pin_allowed(255)); - } - - #[test] - fn pin_allowed_single_pin_allowlist() { - let cfg = HardwareConfig { - allowed_pins: vec![13], - ..HardwareConfig::default() - }; - assert!(cfg.is_pin_allowed(13)); - assert!(!cfg.is_pin_allowed(12)); - assert!(!cfg.is_pin_allowed(14)); - } - - // ── HardwareConfig::validate ─────────────────────────────── - - #[test] - fn validate_disabled_always_ok() { - let cfg = HardwareConfig::default(); - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_disabled_ignores_bad_values() { - // Even with invalid values, disabled config should pass - let cfg = HardwareConfig { - enabled: false, - transport: "serial".into(), - serial_port: None, // Would fail if enabled - baud_rate: 0, // Would fail if enabled - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_serial_requires_port() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("serial_port")); - } - - #[test] - fn validate_serial_with_port_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_probe_requires_target() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - probe_target: None, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("probe_target")); - } - - #[test] - fn validate_probe_with_target_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - probe_target: Some("STM32F411CEUx".into()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_native_ok_without_extras() { - let cfg = HardwareConfig { - enabled: true, - transport: "native".into(), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_none_transport_enabled_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "none".into(), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_baud_rate_zero_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("baud_rate")); - } - - #[test] - fn validate_baud_rate_too_high_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 5_000_000, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("safety limit")); - } - - #[test] - fn validate_baud_rate_boundary_ok() { - // Exactly at the limit - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 4_000_000, - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_baud_rate_common_values_ok() { - for baud in [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: baud, - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok(), "baud rate {baud} should be valid"); - } - } - - #[test] - fn validate_pwm_frequency_zero_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "native".into(), - max_pwm_frequency_hz: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("max_pwm_frequency_hz")); - } - - // ── HardwareConfig serde ─────────────────────────────────── - - #[test] - fn config_serde_roundtrip_toml() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 9600, - workspace_datasheets: true, - discovered_board: Some("Arduino Uno".into()), - probe_target: None, - allowed_pins: vec![2, 13], - max_pwm_frequency_hz: 25_000, - }; - - let toml_str = toml::to_string_pretty(&cfg).unwrap(); - let parsed: HardwareConfig = toml::from_str(&toml_str).unwrap(); - - assert_eq!(parsed.enabled, cfg.enabled); - assert_eq!(parsed.transport, cfg.transport); - assert_eq!(parsed.serial_port, cfg.serial_port); - assert_eq!(parsed.baud_rate, cfg.baud_rate); - assert_eq!(parsed.workspace_datasheets, cfg.workspace_datasheets); - assert_eq!(parsed.discovered_board, cfg.discovered_board); - assert_eq!(parsed.allowed_pins, cfg.allowed_pins); - assert_eq!(parsed.max_pwm_frequency_hz, cfg.max_pwm_frequency_hz); - } - - #[test] - fn config_serde_minimal_toml() { - // Deserializing an empty TOML section should produce defaults - let toml_str = "enabled = false\n"; - let parsed: HardwareConfig = toml::from_str(toml_str).unwrap(); - assert!(!parsed.enabled); - assert_eq!(parsed.transport, "none"); - assert_eq!(parsed.baud_rate, 115_200); - } - - #[test] - fn config_serde_json_roundtrip() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - serial_port: None, - baud_rate: 115200, - workspace_datasheets: false, - discovered_board: None, - probe_target: Some("nRF52840_xxAA".into()), - allowed_pins: vec![], - max_pwm_frequency_hz: 50_000, - }; - - let json = serde_json::to_string(&cfg).unwrap(); - let parsed: HardwareConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.probe_target, cfg.probe_target); - assert_eq!(parsed.transport, "probe"); - } - - // ── NoopHal ──────────────────────────────────────────────── - - #[test] - fn noop_hal_gpio_read_fails() { - let hal = NoopHal; - let err = hal.gpio_read(13).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("13")); - } - - #[test] - fn noop_hal_gpio_write_fails() { - let hal = NoopHal; - let err = hal.gpio_write(5, true).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - #[test] - fn noop_hal_memory_read_fails() { - let hal = NoopHal; - let err = hal.memory_read(0x2000_0000, 4).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("0x20000000")); - } - - #[test] - fn noop_hal_firmware_upload_fails() { - let hal = NoopHal; - let err = hal - .firmware_upload(Path::new("/tmp/firmware.bin")) - .unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("firmware.bin")); - } - - #[test] - fn noop_hal_describe() { - let hal = NoopHal; - let desc = hal.describe(); - assert!(desc.contains("software-only")); - } - - #[test] - fn noop_hal_pwm_set_fails() { - let hal = NoopHal; - let err = hal.pwm_set(9, 50.0).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - #[test] - fn noop_hal_analog_read_fails() { - let hal = NoopHal; - let err = hal.analog_read(0).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - // ── create_hal factory ───────────────────────────────────── - - #[test] - fn create_hal_disabled_returns_noop() { - let cfg = HardwareConfig::default(); - let hal = create_hal(&cfg).unwrap(); - assert!(hal.describe().contains("software-only")); - } - - #[test] - fn create_hal_none_transport_returns_noop() { - let cfg = HardwareConfig { - enabled: true, - transport: "none".into(), - ..HardwareConfig::default() - }; - let hal = create_hal(&cfg).unwrap(); - assert!(hal.describe().contains("software-only")); - } - - #[test] - fn create_hal_serial_without_port_fails_validation() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - ..HardwareConfig::default() - }; - assert!(create_hal(&cfg).is_err()); - } - - #[test] - fn create_hal_invalid_baud_fails_validation() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 0, - ..HardwareConfig::default() - }; - assert!(create_hal(&cfg).is_err()); - } - - // ── Discovery helpers ────────────────────────────────────── - - #[test] - fn classify_serial_arduino() { - let path = Path::new("/dev/tty.usbmodem14201"); - assert!(classify_serial_device(path).contains("Arduino")); - } - - #[test] - fn classify_serial_ftdi() { - let path = Path::new("/dev/tty.usbserial-1234"); - assert!(classify_serial_device(path).contains("FTDI")); - } - - #[test] - fn classify_serial_ch340() { - let path = Path::new("/dev/tty.wchusbserial1420"); - assert!(classify_serial_device(path).contains("CH340")); - } - - #[test] - fn classify_serial_ttyacm() { - let path = Path::new("/dev/ttyACM0"); - assert!(classify_serial_device(path).contains("CDC")); - } - - #[test] - fn classify_serial_ttyusb() { - let path = Path::new("/dev/ttyUSB0"); - assert!(classify_serial_device(path).contains("USB-Serial")); - } - - #[test] - fn classify_serial_unknown() { - let path = Path::new("/dev/ttyXYZ99"); - assert!(classify_serial_device(path).contains("Unknown")); - } - - // ── Serial device path patterns ──────────────────────────── - - #[test] - fn serial_paths_macos_patterns() { - if cfg!(target_os = "macos") { - let patterns = serial_device_paths(); - assert!(patterns.iter().any(|p| p.contains("usbmodem"))); - assert!(patterns.iter().any(|p| p.contains("usbserial"))); - assert!(patterns.iter().any(|p| p.contains("wchusbserial"))); - } - } - - #[test] - fn serial_paths_linux_patterns() { - if cfg!(target_os = "linux") { - let patterns = serial_device_paths(); - assert!(patterns.iter().any(|p| p.contains("ttyUSB"))); - assert!(patterns.iter().any(|p| p.contains("ttyACM"))); - } - } - - // ── Wizard helpers ───────────────────────────────────────── - - #[test] - fn recommended_default_no_devices() { - let devices: Vec = vec![]; - assert_eq!(recommended_wizard_default(&devices), 3); // Software only - } - - #[test] - fn recommended_default_native_found() { - let devices = vec![DiscoveredDevice { - name: "Raspberry Pi (Native GPIO)".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 0); // Native - } - - #[test] - fn recommended_default_serial_found() { - let devices = vec![DiscoveredDevice { - name: "Arduino (USB Serial)".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 1); // Tethered - } - - #[test] - fn recommended_default_probe_found() { - let devices = vec![DiscoveredDevice { - name: "ST-Link (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: None, - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 2); // Probe - } - - #[test] - fn recommended_default_native_priority_over_serial() { - // When both native and serial are found, native wins - let devices = vec![ - DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }, - DiscoveredDevice { - name: "RPi GPIO".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }, - ]; - assert_eq!(recommended_wizard_default(&devices), 0); // Native wins - } - - #[test] - fn config_from_wizard_native() { - let devices = vec![DiscoveredDevice { - name: "Raspberry Pi 4 (Native GPIO)".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: Some("Raspberry Pi 4 Model B Rev 1.5".into()), - }]; - - let cfg = config_from_wizard_choice(0, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "native"); - assert_eq!( - cfg.discovered_board.as_deref(), - Some("Raspberry Pi 4 Model B Rev 1.5") - ); - } - - #[test] - fn config_from_wizard_serial() { - let devices = vec![DiscoveredDevice { - name: "Arduino Uno (USB Serial)".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(1, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "serial"); - assert_eq!(cfg.serial_port.as_deref(), Some("/dev/ttyUSB0")); - } - - #[test] - fn config_from_wizard_probe() { - let devices = vec![DiscoveredDevice { - name: "ST-Link (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: Some("/dev/stlinkv2".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(2, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "probe"); - } - - #[test] - fn config_from_wizard_software_only() { - let devices: Vec = vec![]; - let cfg = config_from_wizard_choice(3, &devices); - assert!(!cfg.enabled); - assert_eq!(cfg.transport, "none"); - } - - #[test] - fn config_from_wizard_serial_no_serial_device_found() { - // User picks serial but no serial device was discovered - let devices = vec![DiscoveredDevice { - name: "RPi GPIO".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(1, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "serial"); - assert!(cfg.serial_port.is_none()); // Will need manual config later - } - - #[test] - fn config_from_wizard_out_of_bounds_defaults_to_software() { - let devices: Vec = vec![]; - let cfg = config_from_wizard_choice(99, &devices); - assert!(!cfg.enabled); - } - - // ── Discovery function runs without panicking ────────────── - - #[test] - fn discover_hardware_does_not_panic() { - // Should never panic regardless of the platform - let devices = discover_hardware(); - // We can't assert what's found (platform-dependent) but it should not crash - assert!(devices.len() < 100); // Sanity check - } - - // ── DiscoveredDevice equality ────────────────────────────── - - #[test] - fn discovered_device_equality() { - let d1 = DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }; - let d2 = d1.clone(); - assert_eq!(d1, d2); - } - - #[test] - fn discovered_device_inequality() { - let d1 = DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }; - let d2 = DiscoveredDevice { - name: "ESP32".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB1".into()), - detail: None, - }; - assert_ne!(d1, d2); - } - - // ── Edge cases ───────────────────────────────────────────── - - #[test] - fn config_with_all_pins_in_allowlist() { - let cfg = HardwareConfig { - allowed_pins: (0..=255).collect(), - ..HardwareConfig::default() - }; - // Every pin should be allowed - for pin in 0..=255u8 { - assert!(cfg.is_pin_allowed(pin)); - } - } - - #[test] - fn config_transport_unknown_string() { - let cfg = HardwareConfig { - transport: "quantum_bus".into(), - ..HardwareConfig::default() - }; - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - } - - #[test] - fn config_transport_empty_string() { - let cfg = HardwareConfig { - transport: String::new(), - ..HardwareConfig::default() - }; - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - } - - #[test] - fn validate_serial_empty_port_string_treated_as_set() { - // An empty string is still Some(""), which passes the None check - // but the serial backend would fail at open time — that's acceptable - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some(String::new()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_multiple_errors_first_wins() { - // Serial with no port AND zero baud — the port error should surface first - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - baud_rate: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("serial_port")); } + println!(); + println!("Info read via USB (SWD) — no firmware on target needed."); + Ok(()) } diff --git a/src/hardware/registry.rs b/src/hardware/registry.rs new file mode 100644 index 0000000..aac15f2 --- /dev/null +++ b/src/hardware/registry.rs @@ -0,0 +1,102 @@ +//! Board registry — maps USB VID/PID to known board names and architectures. + +/// Information about a known board. +#[derive(Debug, Clone)] +pub struct BoardInfo { + pub vid: u16, + pub pid: u16, + pub name: &'static str, + pub architecture: Option<&'static str>, +} + +/// Known USB VID/PID to board mappings. +/// VID 0x0483 = STMicroelectronics, 0x2341 = Arduino, 0x10c4 = Silicon Labs. +const KNOWN_BOARDS: &[BoardInfo] = &[ + BoardInfo { + vid: 0x0483, + pid: 0x374b, + name: "nucleo-f401re", + architecture: Some("ARM Cortex-M4"), + }, + BoardInfo { + vid: 0x0483, + pid: 0x3748, + name: "nucleo-f411re", + architecture: Some("ARM Cortex-M4"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0043, + name: "arduino-uno", + architecture: Some("AVR ATmega328P"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0078, + name: "arduino-uno", + architecture: Some("Arduino Uno Q / ATmega328P"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0042, + name: "arduino-mega", + architecture: Some("AVR ATmega2560"), + }, + BoardInfo { + vid: 0x10c4, + pid: 0xea60, + name: "cp2102", + architecture: Some("USB-UART bridge"), + }, + BoardInfo { + vid: 0x10c4, + pid: 0xea70, + name: "cp2102n", + architecture: Some("USB-UART bridge"), + }, + // ESP32 dev boards often use CH340 USB-UART + BoardInfo { + vid: 0x1a86, + pid: 0x7523, + name: "esp32", + architecture: Some("ESP32 (CH340)"), + }, + BoardInfo { + vid: 0x1a86, + pid: 0x55d4, + name: "esp32", + architecture: Some("ESP32 (CH340)"), + }, +]; + +/// Look up a board by VID and PID. +pub fn lookup_board(vid: u16, pid: u16) -> Option<&'static BoardInfo> { + KNOWN_BOARDS.iter().find(|b| b.vid == vid && b.pid == pid) +} + +/// Return all known board entries. +pub fn known_boards() -> &'static [BoardInfo] { + KNOWN_BOARDS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_nucleo_f401re() { + let b = lookup_board(0x0483, 0x374b).unwrap(); + assert_eq!(b.name, "nucleo-f401re"); + assert_eq!(b.architecture, Some("ARM Cortex-M4")); + } + + #[test] + fn lookup_unknown_returns_none() { + assert!(lookup_board(0x0000, 0x0000).is_none()); + } + + #[test] + fn known_boards_not_empty() { + assert!(!known_boards().is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 588ada3..cfde7a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,9 @@ pub mod memory; pub mod migration; pub mod observability; pub mod onboard; +pub mod peripherals; pub mod providers; +pub mod rag; pub mod runtime; pub mod security; pub mod service; @@ -182,74 +184,48 @@ pub enum IntegrationCommands { }, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn service_commands_serde_roundtrip() { - let command = ServiceCommands::Status; - let json = serde_json::to_string(&command).unwrap(); - let parsed: ServiceCommands = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, ServiceCommands::Status); - } - - #[test] - fn channel_commands_struct_variants_roundtrip() { - let add = ChannelCommands::Add { - channel_type: "telegram".into(), - config: "{}".into(), - }; - let remove = ChannelCommands::Remove { - name: "main".into(), - }; - - let add_json = serde_json::to_string(&add).unwrap(); - let remove_json = serde_json::to_string(&remove).unwrap(); - - let parsed_add: ChannelCommands = serde_json::from_str(&add_json).unwrap(); - let parsed_remove: ChannelCommands = serde_json::from_str(&remove_json).unwrap(); - - assert_eq!(parsed_add, add); - assert_eq!(parsed_remove, remove); - } - - #[test] - fn commands_with_payloads_roundtrip() { - let skill = SkillCommands::Install { - source: "https://example.com/skill".into(), - }; - let migrate = MigrateCommands::Openclaw { - source: Some(std::path::PathBuf::from("/tmp/openclaw")), - dry_run: true, - }; - let cron = CronCommands::Add { - expression: "*/5 * * * *".into(), - command: "echo hi".into(), - }; - let integration = IntegrationCommands::Info { - name: "Telegram".into(), - }; - - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&skill).unwrap()).unwrap(), - skill - ); - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&migrate).unwrap()) - .unwrap(), - migrate - ); - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&cron).unwrap()).unwrap(), - cron - ); - assert_eq!( - serde_json::from_str::( - &serde_json::to_string(&integration).unwrap() - ) - .unwrap(), - integration - ); - } +/// Hardware discovery subcommands +#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HardwareCommands { + /// Enumerate USB devices (VID/PID) and show known boards + Discover, + /// Introspect a device by path (e.g. /dev/ttyACM0) + Introspect { + /// Serial or device path + path: String, + }, + /// Get chip info via USB (probe-rs over ST-Link). No firmware needed on target. + Info { + /// Chip name (e.g. STM32F401RETx). Default: STM32F401RETx for Nucleo-F401RE + #[arg(long, default_value = "STM32F401RETx")] + chip: String, + }, +} + +/// Peripheral (hardware) management subcommands +#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum PeripheralCommands { + /// List configured peripherals + List, + /// Add a peripheral (board path, e.g. nucleo-f401re /dev/ttyACM0) + Add { + /// Board type (nucleo-f401re, rpi-gpio, esp32) + board: String, + /// Path for serial transport (/dev/ttyACM0) or "native" for local GPIO + path: String, + }, + /// Flash ZeroClaw firmware to Arduino (creates .ino, installs arduino-cli if needed, uploads) + Flash { + /// Serial port (e.g. /dev/cu.usbmodem12345). If omitted, uses first arduino-uno from config. + #[arg(short, long)] + port: Option, + }, + /// Setup Arduino Uno Q Bridge app (deploy GPIO bridge for agent control) + SetupUnoQ { + /// Uno Q IP (e.g. 192.168.0.48). If omitted, assumes running ON the Uno Q. + #[arg(long)] + host: Option, + }, + /// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run) + FlashNucleo, } diff --git a/src/main.rs b/src/main.rs index 3253594..b12bc06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,9 @@ use tracing_subscriber::FmtSubscriber; mod agent; mod channels; +mod rag { + pub use zeroclaw::rag::*; +} mod config; mod cron; mod daemon; @@ -53,6 +56,7 @@ mod memory; mod migration; mod observability; mod onboard; +mod peripherals; mod providers; mod runtime; mod security; @@ -65,6 +69,9 @@ mod util; use config::Config; +// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc. +pub use zeroclaw::{HardwareCommands, PeripheralCommands}; + /// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust. #[derive(Parser, Debug)] #[command(name = "zeroclaw")] @@ -110,7 +117,7 @@ enum Commands { #[arg(long)] provider: Option, - /// Memory backend (sqlite, markdown, none) - used in quick mode, default: sqlite + /// Memory backend (sqlite, lucid, markdown, none) - used in quick mode, default: sqlite #[arg(long)] memory: Option, }, @@ -133,9 +140,9 @@ enum Commands { #[arg(short, long, default_value = "0.7")] temperature: f64, - /// Print user-facing progress lines via observer (`>` send, `<` receive/complete). + /// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0) #[arg(long)] - verbose: bool, + peripheral: Vec, }, /// Start the gateway server (webhooks, websockets) @@ -207,6 +214,18 @@ enum Commands { #[command(subcommand)] migrate_command: MigrateCommands, }, + + /// Discover and introspect USB hardware + Hardware { + #[command(subcommand)] + hardware_command: zeroclaw::HardwareCommands, + }, + + /// Manage hardware peripherals (STM32, RPi GPIO, etc.) + Peripheral { + #[command(subcommand)] + peripheral_command: zeroclaw::PeripheralCommands, + }, } #[derive(Subcommand, Debug)] @@ -380,8 +399,8 @@ async fn main() -> Result<()> { provider, model, temperature, - verbose, - } => agent::run(config, message, provider, model, temperature, verbose).await, + peripheral, + } => agent::run(config, message, provider, model, temperature, peripheral).await, Commands::Gateway { port, host } => { if port == 0 { @@ -466,6 +485,17 @@ async fn main() -> Result<()> { } ); } + println!(); + println!("Peripherals:"); + println!( + " Enabled: {}", + if config.peripherals.enabled { + "yes" + } else { + "no" + } + ); + println!(" Boards: {}", config.peripherals.boards.len()); Ok(()) } @@ -499,6 +529,14 @@ async fn main() -> Result<()> { Commands::Migrate { migrate_command } => { migration::handle_command(migrate_command, &config).await } + + Commands::Hardware { hardware_command } => { + hardware::handle_command(hardware_command.clone(), &config) + } + + Commands::Peripheral { peripheral_command } => { + peripherals::handle_command(peripheral_command.clone(), &config) + } } } diff --git a/src/memory/backend.rs b/src/memory/backend.rs new file mode 100644 index 0000000..4de636a --- /dev/null +++ b/src/memory/backend.rs @@ -0,0 +1,145 @@ +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum MemoryBackendKind { + Sqlite, + Lucid, + Markdown, + None, + Unknown, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct MemoryBackendProfile { + pub key: &'static str, + pub label: &'static str, + pub auto_save_default: bool, + pub uses_sqlite_hygiene: bool, + pub sqlite_based: bool, + pub optional_dependency: bool, +} + +const SQLITE_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "sqlite", + label: "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings", + auto_save_default: true, + uses_sqlite_hygiene: true, + sqlite_based: true, + optional_dependency: false, +}; + +const LUCID_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "lucid", + label: "Lucid Memory bridge — sync with local lucid-memory CLI, keep SQLite fallback", + auto_save_default: true, + uses_sqlite_hygiene: true, + sqlite_based: true, + optional_dependency: true, +}; + +const MARKDOWN_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "markdown", + label: "Markdown Files — simple, human-readable, no dependencies", + auto_save_default: true, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const NONE_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "none", + label: "None — disable persistent memory", + auto_save_default: false, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const CUSTOM_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "custom", + label: "Custom backend — extension point", + auto_save_default: true, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const SELECTABLE_MEMORY_BACKENDS: [MemoryBackendProfile; 4] = [ + SQLITE_PROFILE, + LUCID_PROFILE, + MARKDOWN_PROFILE, + NONE_PROFILE, +]; + +pub fn selectable_memory_backends() -> &'static [MemoryBackendProfile] { + &SELECTABLE_MEMORY_BACKENDS +} + +pub fn default_memory_backend_key() -> &'static str { + SQLITE_PROFILE.key +} + +pub fn classify_memory_backend(backend: &str) -> MemoryBackendKind { + match backend { + "sqlite" => MemoryBackendKind::Sqlite, + "lucid" => MemoryBackendKind::Lucid, + "markdown" => MemoryBackendKind::Markdown, + "none" => MemoryBackendKind::None, + _ => MemoryBackendKind::Unknown, + } +} + +pub fn memory_backend_profile(backend: &str) -> MemoryBackendProfile { + match classify_memory_backend(backend) { + MemoryBackendKind::Sqlite => SQLITE_PROFILE, + MemoryBackendKind::Lucid => LUCID_PROFILE, + MemoryBackendKind::Markdown => MARKDOWN_PROFILE, + MemoryBackendKind::None => NONE_PROFILE, + MemoryBackendKind::Unknown => CUSTOM_PROFILE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_known_backends() { + assert_eq!(classify_memory_backend("sqlite"), MemoryBackendKind::Sqlite); + assert_eq!(classify_memory_backend("lucid"), MemoryBackendKind::Lucid); + assert_eq!( + classify_memory_backend("markdown"), + MemoryBackendKind::Markdown + ); + assert_eq!(classify_memory_backend("none"), MemoryBackendKind::None); + } + + #[test] + fn classify_unknown_backend() { + assert_eq!(classify_memory_backend("redis"), MemoryBackendKind::Unknown); + } + + #[test] + fn selectable_backends_are_ordered_for_onboarding() { + let backends = selectable_memory_backends(); + assert_eq!(backends.len(), 4); + assert_eq!(backends[0].key, "sqlite"); + assert_eq!(backends[1].key, "lucid"); + assert_eq!(backends[2].key, "markdown"); + assert_eq!(backends[3].key, "none"); + } + + #[test] + fn lucid_profile_is_sqlite_based_optional_backend() { + let profile = memory_backend_profile("lucid"); + assert!(profile.sqlite_based); + assert!(profile.optional_dependency); + assert!(profile.uses_sqlite_hygiene); + } + + #[test] + fn unknown_profile_preserves_extensibility_defaults() { + let profile = memory_backend_profile("custom-memory"); + assert_eq!(profile.key, "custom"); + assert!(profile.auto_save_default); + assert!(!profile.uses_sqlite_hygiene); + } +} diff --git a/src/memory/lucid.rs b/src/memory/lucid.rs new file mode 100644 index 0000000..00e03f6 --- /dev/null +++ b/src/memory/lucid.rs @@ -0,0 +1,601 @@ +use super::sqlite::SqliteMemory; +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; +use chrono::Local; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio::process::Command; +use tokio::time::timeout; + +pub struct LucidMemory { + local: SqliteMemory, + lucid_cmd: String, + token_budget: usize, + workspace_dir: PathBuf, + recall_timeout: Duration, + store_timeout: Duration, + local_hit_threshold: usize, + failure_cooldown: Duration, + last_failure_at: Mutex>, +} + +impl LucidMemory { + const DEFAULT_LUCID_CMD: &'static str = "lucid"; + const DEFAULT_TOKEN_BUDGET: usize = 200; + const DEFAULT_RECALL_TIMEOUT_MS: u64 = 120; + const DEFAULT_STORE_TIMEOUT_MS: u64 = 800; + const DEFAULT_LOCAL_HIT_THRESHOLD: usize = 3; + const DEFAULT_FAILURE_COOLDOWN_MS: u64 = 15_000; + + pub fn new(workspace_dir: &Path, local: SqliteMemory) -> Self { + let lucid_cmd = std::env::var("ZEROCLAW_LUCID_CMD") + .unwrap_or_else(|_| Self::DEFAULT_LUCID_CMD.to_string()); + + let token_budget = std::env::var("ZEROCLAW_LUCID_BUDGET") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(Self::DEFAULT_TOKEN_BUDGET); + + let recall_timeout = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_RECALL_TIMEOUT_MS", + Self::DEFAULT_RECALL_TIMEOUT_MS, + 20, + ); + let store_timeout = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_STORE_TIMEOUT_MS", + Self::DEFAULT_STORE_TIMEOUT_MS, + 50, + ); + let local_hit_threshold = Self::read_env_usize( + "ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD", + Self::DEFAULT_LOCAL_HIT_THRESHOLD, + 1, + ); + let failure_cooldown = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS", + Self::DEFAULT_FAILURE_COOLDOWN_MS, + 100, + ); + + Self { + local, + lucid_cmd, + token_budget, + workspace_dir: workspace_dir.to_path_buf(), + recall_timeout, + store_timeout, + local_hit_threshold, + failure_cooldown, + last_failure_at: Mutex::new(None), + } + } + + #[cfg(test)] + fn with_options( + workspace_dir: &Path, + local: SqliteMemory, + lucid_cmd: String, + token_budget: usize, + local_hit_threshold: usize, + recall_timeout: Duration, + store_timeout: Duration, + failure_cooldown: Duration, + ) -> Self { + Self { + local, + lucid_cmd, + token_budget, + workspace_dir: workspace_dir.to_path_buf(), + recall_timeout, + store_timeout, + local_hit_threshold: local_hit_threshold.max(1), + failure_cooldown, + last_failure_at: Mutex::new(None), + } + } + + fn read_env_usize(name: &str, default: usize, min: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(default, |v| v.max(min)) + } + + fn read_env_duration_ms(name: &str, default_ms: u64, min_ms: u64) -> Duration { + let millis = std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(default_ms, |v| v.max(min_ms)); + Duration::from_millis(millis) + } + + fn in_failure_cooldown(&self) -> bool { + let Ok(guard) = self.last_failure_at.lock() else { + return false; + }; + + guard + .as_ref() + .is_some_and(|last| last.elapsed() < self.failure_cooldown) + } + + fn mark_failure_now(&self) { + if let Ok(mut guard) = self.last_failure_at.lock() { + *guard = Some(Instant::now()); + } + } + + fn clear_failure(&self) { + if let Ok(mut guard) = self.last_failure_at.lock() { + *guard = None; + } + } + + fn to_lucid_type(category: &MemoryCategory) -> &'static str { + match category { + MemoryCategory::Core => "decision", + MemoryCategory::Daily => "context", + MemoryCategory::Conversation => "conversation", + MemoryCategory::Custom(_) => "learning", + } + } + + fn to_memory_category(label: &str) -> MemoryCategory { + let normalized = label.to_lowercase(); + if normalized.contains("visual") { + return MemoryCategory::Custom("visual".to_string()); + } + + match normalized.as_str() { + "decision" | "learning" | "solution" => MemoryCategory::Core, + "context" | "conversation" => MemoryCategory::Conversation, + "bug" => MemoryCategory::Daily, + other => MemoryCategory::Custom(other.to_string()), + } + } + + fn merge_results( + primary_results: Vec, + secondary_results: Vec, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let mut merged = Vec::new(); + let mut seen = HashSet::new(); + + for entry in primary_results.into_iter().chain(secondary_results) { + let signature = format!( + "{}\u{0}{}", + entry.key.to_lowercase(), + entry.content.to_lowercase() + ); + + if seen.insert(signature) { + merged.push(entry); + if merged.len() >= limit { + break; + } + } + } + + merged + } + + fn parse_lucid_context(raw: &str) -> Vec { + let mut in_context_block = false; + let mut entries = Vec::new(); + let now = Local::now().to_rfc3339(); + + for line in raw.lines().map(str::trim) { + if line == "" { + in_context_block = true; + continue; + } + + if line == "" { + break; + } + + if !in_context_block || line.is_empty() { + continue; + } + + let Some(rest) = line.strip_prefix("- [") else { + continue; + }; + + let Some((label, content_part)) = rest.split_once(']') else { + continue; + }; + + let content = content_part.trim(); + if content.is_empty() { + continue; + } + + let rank = entries.len(); + entries.push(MemoryEntry { + id: format!("lucid:{rank}"), + key: format!("lucid_{rank}"), + content: content.to_string(), + category: Self::to_memory_category(label.trim()), + timestamp: now.clone(), + session_id: None, + score: Some((1.0 - rank as f64 * 0.05).max(0.1)), + }); + } + + entries + } + + async fn run_lucid_command_raw( + lucid_cmd: &str, + args: &[String], + timeout_window: Duration, + ) -> anyhow::Result { + let mut cmd = Command::new(lucid_cmd); + cmd.args(args); + + let output = timeout(timeout_window, cmd.output()).await.map_err(|_| { + anyhow::anyhow!( + "lucid command timed out after {}ms", + timeout_window.as_millis() + ) + })??; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("lucid command failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn run_lucid_command( + &self, + args: &[String], + timeout_window: Duration, + ) -> anyhow::Result { + Self::run_lucid_command_raw(&self.lucid_cmd, args, timeout_window).await + } + + fn build_store_args(&self, key: &str, content: &str, category: &MemoryCategory) -> Vec { + let payload = format!("{key}: {content}"); + vec![ + "store".to_string(), + payload, + format!("--type={}", Self::to_lucid_type(category)), + format!("--project={}", self.workspace_dir.display()), + ] + } + + fn build_recall_args(&self, query: &str) -> Vec { + vec![ + "context".to_string(), + query.to_string(), + format!("--budget={}", self.token_budget), + format!("--project={}", self.workspace_dir.display()), + ] + } + + async fn sync_to_lucid_async(&self, key: &str, content: &str, category: &MemoryCategory) { + let args = self.build_store_args(key, content, category); + if let Err(error) = self.run_lucid_command(&args, self.store_timeout).await { + tracing::debug!( + command = %self.lucid_cmd, + error = %error, + "Lucid store sync failed; sqlite remains authoritative" + ); + } + } + + async fn recall_from_lucid(&self, query: &str) -> anyhow::Result> { + let args = self.build_recall_args(query); + let output = self.run_lucid_command(&args, self.recall_timeout).await?; + Ok(Self::parse_lucid_context(&output)) + } +} + +#[async_trait] +impl Memory for LucidMemory { + fn name(&self) -> &str { + "lucid" + } + + async fn store( + &self, + key: &str, + content: &str, + category: MemoryCategory, + ) -> anyhow::Result<()> { + self.local.store(key, content, category.clone()).await?; + self.sync_to_lucid_async(key, content, &category).await; + Ok(()) + } + + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result> { + let local_results = self.local.recall(query, limit).await?; + if limit == 0 + || local_results.len() >= limit + || local_results.len() >= self.local_hit_threshold + { + return Ok(local_results); + } + + if self.in_failure_cooldown() { + return Ok(local_results); + } + + match self.recall_from_lucid(query).await { + Ok(lucid_results) if !lucid_results.is_empty() => { + self.clear_failure(); + Ok(Self::merge_results(local_results, lucid_results, limit)) + } + Ok(_) => { + self.clear_failure(); + Ok(local_results) + } + Err(error) => { + self.mark_failure_now(); + tracing::debug!( + command = %self.lucid_cmd, + error = %error, + "Lucid context unavailable; using local sqlite results" + ); + Ok(local_results) + } + } + } + + async fn get(&self, key: &str) -> anyhow::Result> { + self.local.get(key).await + } + + async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result> { + self.local.list(category).await + } + + async fn forget(&self, key: &str) -> anyhow::Result { + self.local.forget(key).await + } + + async fn count(&self) -> anyhow::Result { + self.local.count().await + } + + async fn health_check(&self) -> bool { + self.local.health_check().await + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + fn write_fake_lucid_script(dir: &Path) -> String { + let script_path = dir.join("fake-lucid.sh"); + let script = r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "store" ]]; then + echo '{"success":true,"id":"mem_1"}' + exit 0 +fi + +if [[ "${1:-}" == "context" ]]; then + cat <<'EOF' + +Auth context snapshot +- [decision] Use token refresh middleware +- [context] Working in src/auth.rs + +EOF + exit 0 +fi + +echo "unsupported command" >&2 +exit 1 +"#; + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + fn write_probe_lucid_script(dir: &Path, marker_path: &Path) -> String { + let script_path = dir.join("probe-lucid.sh"); + let marker = marker_path.display().to_string(); + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${{1:-}}" == "store" ]]; then + echo '{{"success":true,"id":"mem_store"}}' + exit 0 +fi + +if [[ "${{1:-}}" == "context" ]]; then + printf 'context\n' >> "{marker}" + cat <<'EOF' + +- [decision] should not be used when local hits are enough + +EOF + exit 0 +fi + +echo "unsupported command" >&2 +exit 1 +"# + ); + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + fn test_memory(workspace: &Path, cmd: String) -> LucidMemory { + let sqlite = SqliteMemory::new(workspace).unwrap(); + LucidMemory::with_options( + workspace, + sqlite, + cmd, + 200, + 3, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(2), + ) + } + + #[tokio::test] + async fn lucid_name() { + let tmp = TempDir::new().unwrap(); + let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string()); + assert_eq!(memory.name(), "lucid"); + } + + #[tokio::test] + async fn store_succeeds_when_lucid_missing() { + let tmp = TempDir::new().unwrap(); + let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string()); + + memory + .store("lang", "User prefers Rust", MemoryCategory::Core) + .await + .unwrap(); + + let entry = memory.get("lang").await.unwrap(); + assert!(entry.is_some()); + assert_eq!(entry.unwrap().content, "User prefers Rust"); + } + + #[tokio::test] + async fn recall_merges_lucid_and_local_results() { + let tmp = TempDir::new().unwrap(); + let fake_cmd = write_fake_lucid_script(tmp.path()); + let memory = test_memory(tmp.path(), fake_cmd); + + memory + .store( + "local_note", + "Local sqlite auth fallback note", + MemoryCategory::Core, + ) + .await + .unwrap(); + + let entries = memory.recall("auth", 5).await.unwrap(); + + assert!(entries + .iter() + .any(|e| e.content.contains("Local sqlite auth fallback note"))); + assert!(entries.iter().any(|e| e.content.contains("token refresh"))); + } + + #[tokio::test] + async fn recall_skips_lucid_when_local_hits_are_enough() { + let tmp = TempDir::new().unwrap(); + let marker = tmp.path().join("context_calls.log"); + let probe_cmd = write_probe_lucid_script(tmp.path(), &marker); + + let sqlite = SqliteMemory::new(tmp.path()).unwrap(); + let memory = LucidMemory::with_options( + tmp.path(), + sqlite, + probe_cmd, + 200, + 1, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(2), + ); + + memory + .store("pref", "Rust should stay local-first", MemoryCategory::Core) + .await + .unwrap(); + + let entries = memory.recall("rust", 5).await.unwrap(); + assert!(entries + .iter() + .any(|e| e.content.contains("Rust should stay local-first"))); + + let context_calls = fs::read_to_string(&marker).unwrap_or_default(); + assert!( + context_calls.trim().is_empty(), + "Expected local-hit short-circuit; got calls: {context_calls}" + ); + } + + fn write_failing_lucid_script(dir: &Path, marker_path: &Path) -> String { + let script_path = dir.join("failing-lucid.sh"); + let marker = marker_path.display().to_string(); + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${{1:-}}" == "store" ]]; then + echo '{{"success":true,"id":"mem_store"}}' + exit 0 +fi + +if [[ "${{1:-}}" == "context" ]]; then + printf 'context\n' >> "{marker}" + echo "simulated lucid failure" >&2 + exit 1 +fi + +echo "unsupported command" >&2 +exit 1 +"# + ); + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + #[tokio::test] + async fn failure_cooldown_avoids_repeated_lucid_calls() { + let tmp = TempDir::new().unwrap(); + let marker = tmp.path().join("failing_context_calls.log"); + let failing_cmd = write_failing_lucid_script(tmp.path(), &marker); + + let sqlite = SqliteMemory::new(tmp.path()).unwrap(); + let memory = LucidMemory::with_options( + tmp.path(), + sqlite, + failing_cmd, + 200, + 99, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(5), + ); + + let first = memory.recall("auth", 5).await.unwrap(); + let second = memory.recall("auth", 5).await.unwrap(); + + assert!(first.is_empty()); + assert!(second.is_empty()); + + let calls = fs::read_to_string(&marker).unwrap_or_default(); + assert_eq!(calls.lines().count(), 1); + } +} diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 66912ca..b04e0df 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -1,12 +1,22 @@ +pub mod backend; pub mod chunker; pub mod embeddings; pub mod hygiene; +pub mod lucid; pub mod markdown; +pub mod none; pub mod sqlite; pub mod traits; pub mod vector; +#[allow(unused_imports)] +pub use backend::{ + classify_memory_backend, default_memory_backend_key, memory_backend_profile, + selectable_memory_backends, MemoryBackendKind, MemoryBackendProfile, +}; +pub use lucid::LucidMemory; pub use markdown::MarkdownMemory; +pub use none::NoneMemory; pub use sqlite::SqliteMemory; pub use traits::Memory; #[allow(unused_imports)] @@ -16,6 +26,32 @@ use crate::config::MemoryConfig; use std::path::Path; use std::sync::Arc; +fn create_memory_with_sqlite_builder( + backend_name: &str, + workspace_dir: &Path, + mut sqlite_builder: F, + unknown_context: &str, +) -> anyhow::Result> +where + F: FnMut() -> anyhow::Result, +{ + match classify_memory_backend(backend_name) { + MemoryBackendKind::Sqlite => Ok(Box::new(sqlite_builder()?)), + MemoryBackendKind::Lucid => { + let local = sqlite_builder()?; + Ok(Box::new(LucidMemory::new(workspace_dir, local))) + } + MemoryBackendKind::Markdown => Ok(Box::new(MarkdownMemory::new(workspace_dir))), + MemoryBackendKind::None => Ok(Box::new(NoneMemory::new())), + MemoryBackendKind::Unknown => { + tracing::warn!( + "Unknown memory backend '{backend_name}'{unknown_context}, falling back to markdown" + ); + Ok(Box::new(MarkdownMemory::new(workspace_dir))) + } + } +} + /// Factory: create the right memory backend from config pub fn create_memory( config: &MemoryConfig, @@ -27,32 +63,54 @@ pub fn create_memory( tracing::warn!("memory hygiene skipped: {e}"); } - match config.backend.as_str() { - "sqlite" => { - let embedder: Arc = - Arc::from(embeddings::create_embedding_provider( - &config.embedding_provider, - api_key, - &config.embedding_model, - config.embedding_dimensions, - )); + fn build_sqlite_memory( + config: &MemoryConfig, + workspace_dir: &Path, + api_key: Option<&str>, + ) -> anyhow::Result { + let embedder: Arc = + Arc::from(embeddings::create_embedding_provider( + &config.embedding_provider, + api_key, + &config.embedding_model, + config.embedding_dimensions, + )); - #[allow(clippy::cast_possible_truncation)] - let mem = SqliteMemory::with_embedder( - workspace_dir, - embedder, - config.vector_weight as f32, - config.keyword_weight as f32, - config.embedding_cache_size, - )?; - Ok(Box::new(mem)) - } - "markdown" | "none" => Ok(Box::new(MarkdownMemory::new(workspace_dir))), - other => { - tracing::warn!("Unknown memory backend '{other}', falling back to markdown"); - Ok(Box::new(MarkdownMemory::new(workspace_dir))) - } + #[allow(clippy::cast_possible_truncation)] + let mem = SqliteMemory::with_embedder( + workspace_dir, + embedder, + config.vector_weight as f32, + config.keyword_weight as f32, + config.embedding_cache_size, + )?; + Ok(mem) } + + create_memory_with_sqlite_builder( + &config.backend, + workspace_dir, + || build_sqlite_memory(config, workspace_dir, api_key), + "", + ) +} + +pub fn create_memory_for_migration( + backend: &str, + workspace_dir: &Path, +) -> anyhow::Result> { + if matches!(classify_memory_backend(backend), MemoryBackendKind::None) { + anyhow::bail!( + "memory backend 'none' disables persistence; choose sqlite, lucid, or markdown before migration" + ); + } + + create_memory_with_sqlite_builder( + backend, + workspace_dir, + || SqliteMemory::new(workspace_dir), + " during migration", + ) } #[cfg(test)] @@ -83,14 +141,25 @@ mod tests { } #[test] - fn factory_none_falls_back_to_markdown() { + fn factory_lucid() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "lucid".into(), + ..MemoryConfig::default() + }; + let mem = create_memory(&cfg, tmp.path(), None).unwrap(); + assert_eq!(mem.name(), "lucid"); + } + + #[test] + fn factory_none_uses_noop_memory() { let tmp = TempDir::new().unwrap(); let cfg = MemoryConfig { backend: "none".into(), ..MemoryConfig::default() }; let mem = create_memory(&cfg, tmp.path(), None).unwrap(); - assert_eq!(mem.name(), "markdown"); + assert_eq!(mem.name(), "none"); } #[test] @@ -103,4 +172,20 @@ mod tests { let mem = create_memory(&cfg, tmp.path(), None).unwrap(); assert_eq!(mem.name(), "markdown"); } + + #[test] + fn migration_factory_lucid() { + let tmp = TempDir::new().unwrap(); + let mem = create_memory_for_migration("lucid", tmp.path()).unwrap(); + assert_eq!(mem.name(), "lucid"); + } + + #[test] + fn migration_factory_none_is_rejected() { + let tmp = TempDir::new().unwrap(); + let error = create_memory_for_migration("none", tmp.path()) + .err() + .expect("backend=none should be rejected for migration"); + assert!(error.to_string().contains("disables persistence")); + } } diff --git a/src/memory/none.rs b/src/memory/none.rs new file mode 100644 index 0000000..6057ad0 --- /dev/null +++ b/src/memory/none.rs @@ -0,0 +1,74 @@ +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; + +/// Explicit no-op memory backend. +/// +/// This backend is used when `memory.backend = "none"` to disable persistence +/// while keeping the runtime wiring stable. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoneMemory; + +impl NoneMemory { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Memory for NoneMemory { + fn name(&self) -> &str { + "none" + } + + async fn store( + &self, + _key: &str, + _content: &str, + _category: MemoryCategory, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall(&self, _query: &str, _limit: usize) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn get(&self, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list(&self, _category: Option<&MemoryCategory>) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(false) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn none_memory_is_noop() { + let memory = NoneMemory::new(); + + memory.store("k", "v", MemoryCategory::Core).await.unwrap(); + + assert!(memory.get("k").await.unwrap().is_none()); + assert!(memory.recall("k", 10).await.unwrap().is_empty()); + assert!(memory.list(None).await.unwrap().is_empty()); + assert!(!memory.forget("k").await.unwrap()); + assert_eq!(memory.count().await.unwrap(), 0); + assert!(memory.health_check().await); + } +} diff --git a/src/migration.rs b/src/migration.rs index 04fa458..f217030 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::memory::{MarkdownMemory, Memory, MemoryCategory, SqliteMemory}; +use crate::memory::{self, Memory, MemoryCategory}; use anyhow::{bail, Context, Result}; use directories::UserDirs; use rusqlite::{Connection, OpenFlags, OptionalExtension}; @@ -112,16 +112,7 @@ async fn migrate_openclaw_memory( } fn target_memory_backend(config: &Config) -> Result> { - match config.memory.backend.as_str() { - "sqlite" => Ok(Box::new(SqliteMemory::new(&config.workspace_dir)?)), - "markdown" | "none" => Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))), - other => { - tracing::warn!( - "Unknown memory backend '{other}' during migration, defaulting to markdown" - ); - Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))) - } - } + memory::create_memory_for_migration(&config.memory.backend, &config.workspace_dir) } fn collect_source_entries( @@ -431,6 +422,7 @@ fn backup_target_memory(workspace_dir: &Path) -> Result> { mod tests { use super::*; use crate::config::{Config, MemoryConfig}; + use crate::memory::SqliteMemory; use rusqlite::params; use tempfile::TempDir; @@ -550,4 +542,16 @@ mod tests { let target_mem = SqliteMemory::new(target.path()).unwrap(); assert_eq!(target_mem.count().await.unwrap(), 0); } + + #[test] + fn migration_target_rejects_none_backend() { + let target = TempDir::new().unwrap(); + let mut config = test_config(target.path()); + config.memory.backend = "none".to_string(); + + let err = target_memory_backend(&config) + .err() + .expect("backend=none should be rejected for migration target"); + assert!(err.to_string().contains("disables persistence")); + } } diff --git a/src/observability/otel.rs b/src/observability/otel.rs index 49f5ec0..5e0c37e 100644 --- a/src/observability/otel.rs +++ b/src/observability/otel.rs @@ -183,7 +183,9 @@ impl Observer for OtelObserver { ], ); } - ObserverEvent::LlmRequest { .. } => {} + ObserverEvent::LlmRequest { .. } + | ObserverEvent::ToolCallStart { .. } + | ObserverEvent::TurnComplete => {} ObserverEvent::LlmResponse { provider, model, @@ -247,7 +249,6 @@ impl Observer for OtelObserver { // Note: tokens are recorded via record_metric(TokensUsed) to avoid // double-counting. AgentEnd only records duration. } - ObserverEvent::ToolCallStart { .. } => {} ObserverEvent::ToolCall { tool, duration, @@ -285,7 +286,6 @@ impl Observer for OtelObserver { self.tool_duration .record(secs, &[KeyValue::new("tool", tool.clone())]); } - ObserverEvent::TurnComplete => {} ObserverEvent::ChannelMessage { channel, direction } => { self.channel_messages.add( 1, diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index ddac80e..b8b3c58 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -5,6 +5,9 @@ use crate::config::{ RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; use crate::hardware::{self, HardwareConfig}; +use crate::memory::{ + default_memory_backend_key, memory_backend_profile, selectable_memory_backends, +}; use anyhow::{Context, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; @@ -110,7 +113,8 @@ pub fn run_wizard() -> Result { autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), - scheduler: crate::config::SchedulerConfig::default(), + scheduler: crate::config::schema::SchedulerConfig::default(), + agent: crate::config::schema::AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config, @@ -123,9 +127,9 @@ pub fn run_wizard() -> Result { http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), - hardware: hardware_config, + peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), - security: crate::config::SecurityConfig::default(), + hardware: hardware_config, }; println!( @@ -237,8 +241,38 @@ pub fn run_channels_repair_wizard() -> Result { // ── Quick setup (zero prompts) ─────────────────────────────────── /// Non-interactive setup: generates a sensible default config instantly. -/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite`. +/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`. /// Use `zeroclaw onboard --interactive` for the full wizard. +fn backend_key_from_choice(choice: usize) -> &'static str { + selectable_memory_backends() + .get(choice) + .map_or(default_memory_backend_key(), |backend| backend.key) +} + +fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig { + let profile = memory_backend_profile(backend); + + MemoryConfig { + backend: backend.to_string(), + auto_save: profile.auto_save_default, + hygiene_enabled: profile.uses_sqlite_hygiene, + archive_after_days: if profile.uses_sqlite_hygiene { 7 } else { 0 }, + purge_after_days: if profile.uses_sqlite_hygiene { 30 } else { 0 }, + conversation_retention_days: 30, + embedding_provider: "none".to_string(), + embedding_model: "text-embedding-3-small".to_string(), + embedding_dimensions: 1536, + vector_weight: 0.7, + keyword_weight: 0.3, + embedding_cache_size: if profile.uses_sqlite_hygiene { + 10000 + } else { + 0 + }, + chunk_max_tokens: 512, + } +} + #[allow(clippy::too_many_lines)] pub fn run_quick_setup( api_key: Option<&str>, @@ -265,36 +299,12 @@ pub fn run_quick_setup( let provider_name = provider.unwrap_or("openrouter").to_string(); let model = default_model_for_provider(&provider_name); - let memory_backend_name = memory_backend.unwrap_or("sqlite").to_string(); + let memory_backend_name = memory_backend + .unwrap_or(default_memory_backend_key()) + .to_string(); // Create memory config based on backend choice - let memory_config = MemoryConfig { - backend: memory_backend_name.clone(), - auto_save: memory_backend_name != "none", - hygiene_enabled: memory_backend_name == "sqlite", - archive_after_days: if memory_backend_name == "sqlite" { - 7 - } else { - 0 - }, - purge_after_days: if memory_backend_name == "sqlite" { - 30 - } else { - 0 - }, - conversation_retention_days: 30, - embedding_provider: "none".to_string(), - embedding_model: "text-embedding-3-small".to_string(), - embedding_dimensions: 1536, - vector_weight: 0.7, - keyword_weight: 0.3, - embedding_cache_size: if memory_backend_name == "sqlite" { - 10000 - } else { - 0 - }, - chunk_max_tokens: 512, - }; + let memory_config = memory_config_defaults_for_backend(&memory_backend_name); let config = Config { workspace_dir: workspace_dir.clone(), @@ -307,7 +317,8 @@ pub fn run_quick_setup( autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), - scheduler: crate::config::SchedulerConfig::default(), + scheduler: crate::config::schema::SchedulerConfig::default(), + agent: crate::config::schema::AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), @@ -320,9 +331,9 @@ pub fn run_quick_setup( http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), - hardware: HardwareConfig::default(), + peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), - security: crate::config::SecurityConfig::default(), + hardware: crate::config::HardwareConfig::default(), }; config.save()?; @@ -1990,7 +2001,7 @@ fn setup_hardware() -> Result { hw_config.baud_rate = match baud_idx { 1 => 9600, 2 => 57600, - 3 => 230400, + 3 => 230_400, 4 => { let custom: String = Input::new() .with_prompt(" Custom baud rate") @@ -2164,11 +2175,10 @@ fn setup_memory() -> Result { print_bullet("You can always change this later in config.toml."); println!(); - let options = vec![ - "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings", - "Markdown Files — simple, human-readable, no dependencies", - "None — disable persistent memory", - ]; + let options: Vec<&str> = selectable_memory_backends() + .iter() + .map(|backend| backend.label) + .collect(); let choice = Select::new() .with_prompt(" Select memory backend") @@ -2176,20 +2186,16 @@ fn setup_memory() -> Result { .default(0) .interact()?; - let backend = match choice { - 1 => "markdown", - 2 => "none", - _ => "sqlite", // 0 and any unexpected value defaults to sqlite - }; + let backend = backend_key_from_choice(choice); + let profile = memory_backend_profile(backend); - let auto_save = if backend == "none" { + let auto_save = if !profile.auto_save_default { false } else { - let save = Confirm::new() + Confirm::new() .with_prompt(" Auto-save conversations to memory?") .default(true) - .interact()?; - save + .interact()? }; println!( @@ -2199,21 +2205,9 @@ fn setup_memory() -> Result { if auto_save { "on" } else { "off" } ); - Ok(MemoryConfig { - backend: backend.to_string(), - auto_save, - hygiene_enabled: backend == "sqlite", // Only enable hygiene for SQLite - archive_after_days: if backend == "sqlite" { 7 } else { 0 }, - purge_after_days: if backend == "sqlite" { 30 } else { 0 }, - conversation_retention_days: 30, - embedding_provider: "none".to_string(), - embedding_model: "text-embedding-3-small".to_string(), - embedding_dimensions: 1536, - vector_weight: 0.7, - keyword_weight: 0.3, - embedding_cache_size: if backend == "sqlite" { 10000 } else { 0 }, - chunk_max_tokens: 512, - }) + let mut config = memory_config_defaults_for_backend(backend); + config.auto_save = auto_save; + Ok(config) } // ── Step 3: Channels ──────────────────────────────────────────── @@ -2336,18 +2330,27 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — reqwest::blocking Response + // must be used and dropped there to avoid "Cannot drop a runtime" panic) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - let url = format!("https://api.telegram.org/bot{token}/getMe"); - match client.get(&url).send() { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let bot_name = data - .get("result") - .and_then(|r| r.get("username")) - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let url = format!("https://api.telegram.org/bot{token_clone}/getMe"); + let resp = client.get(&url).send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let bot_name = data + .get("result") + .and_then(|r| r.get("username")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, bot_name)) + }) + .join(); + match thread_result { + Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as @{bot_name} ", style("✅").green().bold() @@ -2420,20 +2423,27 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get("https://discord.com/api/v10/users/@me") - .header("Authorization", format!("Bot {token}")) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let bot_name = data - .get("username") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://discord.com/api/v10/users/@me") + .header("Authorization", format!("Bot {token_clone}")) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let bot_name = data + .get("username") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, bot_name)) + }) + .join(); + match thread_result { + Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as {bot_name} ", style("✅").green().bold() @@ -2512,37 +2522,44 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get("https://slack.com/api/auth.test") - .bearer_auth(&token) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let ok = data - .get("ok") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let team = data - .get("team") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); - if ok { - println!( - "\r {} Connected to workspace: {team} ", - style("✅").green().bold() - ); - } else { - let err = data - .get("error") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown error"); - println!("\r {} Slack error: {err}", style("❌").red().bold()); - continue; - } + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://slack.com/api/auth.test") + .bearer_auth(&token_clone) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let api_ok = data + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let team = data + .get("team") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + let err = data + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown error") + .to_string(); + Ok::<_, reqwest::Error>((ok, api_ok, team, err)) + }) + .join(); + match thread_result { + Ok(Ok((true, true, team, _))) => { + println!( + "\r {} Connected to workspace: {team} ", + style("✅").green().bold() + ); + } + Ok(Ok((true, false, _, err))) => { + println!("\r {} Slack error: {err}", style("❌").red().bold()); + continue; } _ => { println!( @@ -2681,21 +2698,29 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) let hs = homeserver.trim_end_matches('/'); print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get(format!("{hs}/_matrix/client/v3/account/whoami")) - .header("Authorization", format!("Bearer {access_token}")) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let user_id = data - .get("user_id") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let hs_owned = hs.to_string(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("{hs_owned}/_matrix/client/v3/account/whoami")) + .header("Authorization", format!("Bearer {access_token_clone}")) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let user_id = data + .get("user_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, user_id)) + }) + .join(); + match thread_result { + Ok(Ok((true, user_id))) => { println!( "\r {} Connected as {user_id} ", style("✅").green().bold() @@ -2769,19 +2794,28 @@ fn setup_channels() -> Result { .default("zeroclaw-whatsapp-verify".into()) .interact_text()?; - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - let url = format!( - "https://graph.facebook.com/v18.0/{}", - phone_number_id.trim() - ); - match client - .get(&url) - .header("Authorization", format!("Bearer {}", access_token.trim())) - .send() - { - Ok(resp) if resp.status().is_success() => { + let phone_number_id_clone = phone_number_id.clone(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let url = format!( + "https://graph.facebook.com/v18.0/{}", + phone_number_id_clone.trim() + ); + let resp = client + .get(&url) + .header( + "Authorization", + format!("Bearer {}", access_token_clone.trim()), + ) + .send()?; + Ok::<_, reqwest::Error>(resp.status().is_success()) + }) + .join(); + match thread_result { + Ok(Ok(true)) => { println!( "\r {} Connected to WhatsApp API ", style("✅").green().bold() @@ -4343,18 +4377,54 @@ mod tests { } #[test] - fn default_model_for_minimax_is_m2_5() { - assert_eq!(default_model_for_provider("minimax"), "MiniMax-M2.5"); + fn backend_key_from_choice_maps_supported_backends() { + assert_eq!(backend_key_from_choice(0), "sqlite"); + assert_eq!(backend_key_from_choice(1), "lucid"); + assert_eq!(backend_key_from_choice(2), "markdown"); + assert_eq!(backend_key_from_choice(3), "none"); + assert_eq!(backend_key_from_choice(999), "sqlite"); } #[test] - fn minimax_onboard_models_include_m2_variants() { - let model_names: Vec<&str> = MINIMAX_ONBOARD_MODELS - .iter() - .map(|(name, _)| *name) - .collect(); - assert_eq!(model_names.first().copied(), Some("MiniMax-M2.5")); - assert!(model_names.contains(&"MiniMax-M2.1")); - assert!(model_names.contains(&"MiniMax-M2.1-highspeed")); + fn memory_backend_profile_marks_lucid_as_optional_sqlite_backed() { + let lucid = memory_backend_profile("lucid"); + assert!(lucid.auto_save_default); + assert!(lucid.uses_sqlite_hygiene); + assert!(lucid.sqlite_based); + assert!(lucid.optional_dependency); + + let markdown = memory_backend_profile("markdown"); + assert!(markdown.auto_save_default); + assert!(!markdown.uses_sqlite_hygiene); + + let none = memory_backend_profile("none"); + assert!(!none.auto_save_default); + assert!(!none.uses_sqlite_hygiene); + + let custom = memory_backend_profile("custom-memory"); + assert!(custom.auto_save_default); + assert!(!custom.uses_sqlite_hygiene); + } + + #[test] + fn memory_config_defaults_for_lucid_enable_sqlite_hygiene() { + let config = memory_config_defaults_for_backend("lucid"); + assert_eq!(config.backend, "lucid"); + assert!(config.auto_save); + assert!(config.hygiene_enabled); + assert_eq!(config.archive_after_days, 7); + assert_eq!(config.purge_after_days, 30); + assert_eq!(config.embedding_cache_size, 10000); + } + + #[test] + fn memory_config_defaults_for_none_disable_sqlite_hygiene() { + let config = memory_config_defaults_for_backend("none"); + assert_eq!(config.backend, "none"); + assert!(!config.auto_save); + assert!(!config.hygiene_enabled); + assert_eq!(config.archive_after_days, 0); + assert_eq!(config.purge_after_days, 0); + assert_eq!(config.embedding_cache_size, 0); } } diff --git a/src/peripherals/arduino_flash.rs b/src/peripherals/arduino_flash.rs new file mode 100644 index 0000000..8aaf287 --- /dev/null +++ b/src/peripherals/arduino_flash.rs @@ -0,0 +1,144 @@ +//! Flash ZeroClaw Arduino firmware via arduino-cli. +//! +//! Ensures arduino-cli is available (installs via brew on macOS if missing), +//! installs the AVR core, compiles and uploads the base firmware. + +use anyhow::{Context, Result}; +use std::process::Command; + +/// ZeroClaw Arduino Uno base firmware (capabilities, gpio_read, gpio_write). +const FIRMWARE_INO: &str = include_str!("../../firmware/zeroclaw-arduino/zeroclaw-arduino.ino"); + +const FQBN: &str = "arduino:avr:uno"; +const SKETCH_NAME: &str = "zeroclaw-arduino"; + +/// Check if arduino-cli is available. +pub fn arduino_cli_available() -> bool { + Command::new("arduino-cli") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Try to install arduino-cli. Returns Ok(()) if installed or already present. +pub fn ensure_arduino_cli() -> Result<()> { + if arduino_cli_available() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + println!("arduino-cli not found. Installing via Homebrew..."); + let status = Command::new("brew") + .args(["install", "arduino-cli"]) + .status() + .context("Failed to run brew install")?; + if !status.success() { + anyhow::bail!("brew install arduino-cli failed. Install manually: https://arduino.github.io/arduino-cli/"); + } + println!("arduino-cli installed."); + } + + #[cfg(target_os = "linux")] + { + println!("arduino-cli not found. Run the install script:"); + println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"); + println!(); + println!("Or install via package manager (e.g. apt install arduino-cli on Debian/Ubuntu)."); + anyhow::bail!("arduino-cli not installed. Install it and try again."); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + println!("arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/"); + anyhow::bail!("arduino-cli not installed."); + } + + if !arduino_cli_available() { + anyhow::bail!("arduino-cli still not found after install. Ensure it's in PATH."); + } + Ok(()) +} + +/// Ensure arduino:avr core is installed. +fn ensure_avr_core() -> Result<()> { + let out = Command::new("arduino-cli") + .args(["core", "list"]) + .output() + .context("arduino-cli core list failed")?; + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.contains("arduino:avr") { + return Ok(()); + } + + println!("Installing Arduino AVR core..."); + let status = Command::new("arduino-cli") + .args(["core", "install", "arduino:avr"]) + .status() + .context("arduino-cli core install failed")?; + if !status.success() { + anyhow::bail!("Failed to install arduino:avr core"); + } + println!("AVR core installed."); + Ok(()) +} + +/// Flash ZeroClaw firmware to Arduino at the given port. +pub fn flash_arduino_firmware(port: &str) -> Result<()> { + ensure_arduino_cli()?; + ensure_avr_core()?; + + let temp_dir = std::env::temp_dir().join(format!("zeroclaw_flash_{}", uuid::Uuid::new_v4())); + let sketch_dir = temp_dir.join(SKETCH_NAME); + let ino_path = sketch_dir.join(format!("{}.ino", SKETCH_NAME)); + + std::fs::create_dir_all(&sketch_dir).context("Failed to create sketch dir")?; + std::fs::write(&ino_path, FIRMWARE_INO).context("Failed to write firmware")?; + + let sketch_path = sketch_dir.to_string_lossy(); + + // Compile + println!("Compiling ZeroClaw Arduino firmware..."); + let compile = Command::new("arduino-cli") + .args(["compile", "--fqbn", FQBN, &*sketch_path]) + .output() + .context("arduino-cli compile failed")?; + + if !compile.status.success() { + let stderr = String::from_utf8_lossy(&compile.stderr); + let _ = std::fs::remove_dir_all(&temp_dir); + anyhow::bail!("Compile failed:\n{}", stderr); + } + + // Upload + println!("Uploading to {}...", port); + let upload = Command::new("arduino-cli") + .args(["upload", "-p", port, "--fqbn", FQBN, &*sketch_path]) + .output() + .context("arduino-cli upload failed")?; + + let _ = std::fs::remove_dir_all(&temp_dir); + + if !upload.status.success() { + let stderr = String::from_utf8_lossy(&upload.stderr); + anyhow::bail!("Upload failed:\n{}\n\nEnsure the board is connected and the port is correct (e.g. /dev/cu.usbmodem* on macOS).", stderr); + } + + println!("ZeroClaw firmware flashed successfully."); + println!("The Arduino now supports: capabilities, gpio_read, gpio_write."); + Ok(()) +} + +/// Resolve port from config or path. Returns the path to use for flashing. +pub fn resolve_port(config: &crate::config::Config, path_override: Option<&str>) -> Option { + if let Some(p) = path_override { + return Some(p.to_string()); + } + config + .peripherals + .boards + .iter() + .find(|b| b.board == "arduino-uno" && b.transport == "serial") + .and_then(|b| b.path.clone()) +} diff --git a/src/peripherals/arduino_upload.rs b/src/peripherals/arduino_upload.rs new file mode 100644 index 0000000..e11b19f --- /dev/null +++ b/src/peripherals/arduino_upload.rs @@ -0,0 +1,161 @@ +//! Arduino upload tool — agent generates code, uploads via arduino-cli. +//! +//! When user says "make a heart on the LED grid", the agent generates Arduino +//! sketch code and calls this tool. ZeroClaw compiles and uploads it — no +//! manual IDE or file editing. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::process::Command; + +/// Tool: upload Arduino sketch (agent-generated code) to the board. +pub struct ArduinoUploadTool { + /// Serial port path (e.g. /dev/cu.usbmodem33000283452) + pub port: String, +} + +impl ArduinoUploadTool { + pub fn new(port: String) -> Self { + Self { port } + } +} + +#[async_trait] +impl Tool for ArduinoUploadTool { + fn name(&self) -> &str { + "arduino_upload" + } + + fn description(&self) -> &str { + "Generate Arduino sketch code and upload it to the connected Arduino. Use when: user asks to 'make a heart', 'blink LED', or run any custom pattern on Arduino. You MUST write the full .ino sketch code (setup + loop). Arduino Uno: pin 13 = built-in LED. Saves to temp dir, runs arduino-cli compile and upload. Requires arduino-cli installed." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Full Arduino sketch code (complete .ino file content)" + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let code = args + .get("code") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Code cannot be empty".into()), + }); + } + + // Check arduino-cli exists + if Command::new("arduino-cli").arg("version").output().is_err() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/" + .into(), + ), + }); + } + + let sketch_name = "zeroclaw_sketch"; + let temp_dir = std::env::temp_dir().join(format!("zeroclaw_{}", uuid::Uuid::new_v4())); + let sketch_dir = temp_dir.join(sketch_name); + let ino_path = sketch_dir.join(format!("{}.ino", sketch_name)); + + if let Err(e) = std::fs::create_dir_all(&sketch_dir) { + return Ok(ToolResult { + success: false, + output: format!("Failed to create sketch dir: {}", e), + error: Some(e.to_string()), + }); + } + + if let Err(e) = std::fs::write(&ino_path, code) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("Failed to write sketch: {}", e), + error: Some(e.to_string()), + }); + } + + let sketch_path = sketch_dir.to_string_lossy(); + let fqbn = "arduino:avr:uno"; + + // Compile + let compile = Command::new("arduino-cli") + .args(["compile", "--fqbn", fqbn, &sketch_path]) + .output(); + + let compile_output = match compile { + Ok(o) => o, + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("arduino-cli compile failed: {}", e), + error: Some(e.to_string()), + }); + } + }; + + if !compile_output.status.success() { + let stderr = String::from_utf8_lossy(&compile_output.stderr); + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("Compile failed:\n{}", stderr), + error: Some("Arduino compile error".into()), + }); + } + + // Upload + let upload = Command::new("arduino-cli") + .args(["upload", "-p", &self.port, "--fqbn", fqbn, &sketch_path]) + .output(); + + let upload_output = match upload { + Ok(o) => o, + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("arduino-cli upload failed: {}", e), + error: Some(e.to_string()), + }); + } + }; + + let _ = std::fs::remove_dir_all(&temp_dir); + + if !upload_output.status.success() { + let stderr = String::from_utf8_lossy(&upload_output.stderr); + return Ok(ToolResult { + success: false, + output: format!("Upload failed:\n{}", stderr), + error: Some("Arduino upload error".into()), + }); + } + + Ok(ToolResult { + success: true, + output: + "Sketch compiled and uploaded successfully. The Arduino is now running your code." + .into(), + error: None, + }) + } +} diff --git a/src/peripherals/capabilities_tool.rs b/src/peripherals/capabilities_tool.rs new file mode 100644 index 0000000..c3fca4f --- /dev/null +++ b/src/peripherals/capabilities_tool.rs @@ -0,0 +1,99 @@ +//! Hardware capabilities tool — Phase C: query device for reported GPIO pins. + +use super::serial::SerialTransport; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Tool: query device capabilities (GPIO pins, LED pin) from firmware. +pub struct HardwareCapabilitiesTool { + /// (board_name, transport) for each serial board. + boards: Vec<(String, Arc)>, +} + +impl HardwareCapabilitiesTool { + pub(crate) fn new(boards: Vec<(String, Arc)>) -> Self { + Self { boards } + } +} + +#[async_trait] +impl Tool for HardwareCapabilitiesTool { + fn name(&self) -> &str { + "hardware_capabilities" + } + + fn description(&self) -> &str { + "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name. If omitted, queries all." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let filter = args.get("board").and_then(|v| v.as_str()); + let mut outputs = Vec::new(); + + for (board_name, transport) in &self.boards { + if let Some(b) = filter { + if b != board_name { + continue; + } + } + match transport.capabilities().await { + Ok(result) => { + let output = if result.success { + if let Ok(parsed) = + serde_json::from_str::(&result.output) + { + format!( + "{}: gpio {:?}, led_pin {:?}", + board_name, + parsed.get("gpio").unwrap_or(&json!([])), + parsed.get("led_pin").unwrap_or(&json!(null)) + ) + } else { + format!("{}: {}", board_name, result.output) + } + } else { + format!( + "{}: {}", + board_name, + result.error.as_deref().unwrap_or("unknown") + ) + }; + outputs.push(output); + } + Err(e) => { + outputs.push(format!("{}: error - {}", board_name, e)); + } + } + } + + let output = if outputs.is_empty() { + if filter.is_some() { + "No matching board or capabilities not supported.".to_string() + } else { + "No serial boards configured or capabilities not supported.".to_string() + } + } else { + outputs.join("\n") + }; + + Ok(ToolResult { + success: !outputs.is_empty(), + output, + error: None, + }) + } +} diff --git a/src/peripherals/mod.rs b/src/peripherals/mod.rs new file mode 100644 index 0000000..6084cab --- /dev/null +++ b/src/peripherals/mod.rs @@ -0,0 +1,231 @@ +//! Hardware peripherals — STM32, RPi GPIO, etc. +//! +//! Peripherals extend the agent with physical capabilities. See +//! `docs/hardware-peripherals-design.md` for the full design. + +pub mod traits; + +#[cfg(feature = "hardware")] +pub mod serial; + +#[cfg(feature = "hardware")] +pub mod arduino_flash; +#[cfg(feature = "hardware")] +pub mod arduino_upload; +#[cfg(feature = "hardware")] +pub mod capabilities_tool; +#[cfg(feature = "hardware")] +pub mod nucleo_flash; +#[cfg(feature = "hardware")] +pub mod uno_q_bridge; +#[cfg(feature = "hardware")] +pub mod uno_q_setup; + +#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] +pub mod rpi; + +pub use traits::Peripheral; + +use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig}; +use crate::tools::{HardwareMemoryMapTool, Tool}; +use anyhow::Result; + +/// List configured boards from config (no connection yet). +pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> { + if !config.enabled { + return Vec::new(); + } + config.boards.iter().collect() +} + +/// Handle `zeroclaw peripheral` subcommands. +#[allow(clippy::module_name_repetitions)] +pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> { + match cmd { + crate::PeripheralCommands::List => { + let boards = list_configured_boards(&config.peripherals); + if boards.is_empty() { + println!("No peripherals configured."); + println!(); + println!("Add one with: zeroclaw peripheral add "); + println!(" Example: zeroclaw peripheral add nucleo-f401re /dev/ttyACM0"); + println!(); + println!("Or add to config.toml:"); + println!(" [peripherals]"); + println!(" enabled = true"); + println!(); + println!(" [[peripherals.boards]]"); + println!(" board = \"nucleo-f401re\""); + println!(" transport = \"serial\""); + println!(" path = \"/dev/ttyACM0\""); + } else { + println!("Configured peripherals:"); + for b in boards { + let path = b.path.as_deref().unwrap_or("(native)"); + println!(" {} {} {}", b.board, b.transport, path); + } + } + } + crate::PeripheralCommands::Add { board, path } => { + let transport = if path == "native" { "native" } else { "serial" }; + let path_opt = if path == "native" { + None + } else { + Some(path.clone()) + }; + + let mut cfg = crate::config::Config::load_or_init()?; + cfg.peripherals.enabled = true; + + if cfg + .peripherals + .boards + .iter() + .any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref()) + { + println!("Board {} at {:?} already configured.", board, path_opt); + return Ok(()); + } + + cfg.peripherals.boards.push(PeripheralBoardConfig { + board: board.clone(), + transport: transport.to_string(), + path: path_opt, + baud: 115200, + }); + cfg.save()?; + println!("Added {} at {}. Restart daemon to apply.", board, path); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::Flash { port } => { + let port_str = arduino_flash::resolve_port(config, port.as_deref()) + .or_else(|| port.clone()) + .ok_or_else(|| anyhow::anyhow!( + "No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml" + ))?; + arduino_flash::flash_arduino_firmware(&port_str)?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::Flash { .. } => { + println!("Arduino flash requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::SetupUnoQ { host } => { + uno_q_setup::setup_uno_q_bridge(host.as_deref())?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::SetupUnoQ { .. } => { + println!("Uno Q setup requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::FlashNucleo => { + nucleo_flash::flash_nucleo_firmware()?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::FlashNucleo => { + println!("Nucleo flash requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + } + Ok(()) +} + +/// Create and connect peripherals from config, returning their tools. +/// Returns empty vec if peripherals disabled or hardware feature off. +#[cfg(feature = "hardware")] +pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result>> { + if !config.enabled || config.boards.is_empty() { + return Ok(Vec::new()); + } + + let mut tools: Vec> = Vec::new(); + let mut serial_transports: Vec<(String, std::sync::Arc)> = Vec::new(); + + for board in &config.boards { + // Arduino Uno Q: Bridge transport (socket to local Bridge app) + if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q") + { + tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool)); + tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool)); + tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added"); + continue; + } + + // Native transport: RPi GPIO (Linux only) + #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] + if board.transport == "native" + && (board.board == "rpi-gpio" || board.board == "raspberry-pi") + { + match rpi::RpiGpioPeripheral::connect_from_config(board).await { + Ok(peripheral) => { + tools.extend(peripheral.tools()); + tracing::info!(board = %board.board, "RPi GPIO peripheral connected"); + } + Err(e) => { + tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e); + } + } + continue; + } + + // Serial transport (STM32, ESP32, Arduino, etc.) + if board.transport != "serial" { + continue; + } + if board.path.is_none() { + tracing::warn!("Skipping serial board {}: no path", board.board); + continue; + } + + match serial::SerialPeripheral::connect(board).await { + Ok(peripheral) => { + let mut p = peripheral; + if p.connect().await.is_err() { + tracing::warn!("Peripheral {} connect warning (continuing)", p.name()); + } + serial_transports.push((board.board.clone(), p.transport())); + tools.extend(p.tools()); + if board.board == "arduino-uno" { + if let Some(ref path) = board.path { + tools.push(Box::new(arduino_upload::ArduinoUploadTool::new( + path.clone(), + ))); + tracing::info!("Arduino upload tool added (port: {})", path); + } + } + tracing::info!(board = %board.board, "Serial peripheral connected"); + } + Err(e) => { + tracing::warn!("Failed to connect {}: {}", board.board, e); + } + } + } + + // Phase B: Add hardware tools when any boards configured + if !tools.is_empty() { + let board_names: Vec = config.boards.iter().map(|b| b.board.clone()).collect(); + tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone()))); + tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new( + board_names.clone(), + ))); + tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new( + board_names, + ))); + } + + // Phase C: Add hardware_capabilities tool when any serial boards + if !serial_transports.is_empty() { + tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new( + serial_transports, + ))); + } + + Ok(tools) +} + +#[cfg(not(feature = "hardware"))] +pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result>> { + Ok(Vec::new()) +} diff --git a/src/peripherals/nucleo_flash.rs b/src/peripherals/nucleo_flash.rs new file mode 100644 index 0000000..5558872 --- /dev/null +++ b/src/peripherals/nucleo_flash.rs @@ -0,0 +1,83 @@ +//! Flash ZeroClaw Nucleo-F401RE firmware via probe-rs. +//! +//! Builds the Embassy firmware and flashes via ST-Link (built into Nucleo). +//! Requires: cargo install probe-rs-tools --locked + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Command; + +const CHIP: &str = "STM32F401RETx"; +const TARGET: &str = "thumbv7em-none-eabihf"; + +/// Check if probe-rs CLI is available (from probe-rs-tools). +pub fn probe_rs_available() -> bool { + Command::new("probe-rs") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Flash ZeroClaw Nucleo firmware. Builds from firmware/zeroclaw-nucleo. +pub fn flash_nucleo_firmware() -> Result<()> { + if !probe_rs_available() { + anyhow::bail!( + "probe-rs not found. Install it:\n cargo install probe-rs-tools --locked\n\n\ + Or: curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh\n\n\ + Connect Nucleo via USB (ST-Link). Then run this command again." + ); + } + + // CARGO_MANIFEST_DIR = repo root (zeroclaw's Cargo.toml) + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let firmware_dir = repo_root.join("firmware").join("zeroclaw-nucleo"); + if !firmware_dir.join("Cargo.toml").exists() { + anyhow::bail!( + "Nucleo firmware not found at {}. Run from zeroclaw repo root.", + firmware_dir.display() + ); + } + + println!("Building ZeroClaw Nucleo firmware..."); + let build = Command::new("cargo") + .args(["build", "--release", "--target", TARGET]) + .current_dir(&firmware_dir) + .output() + .context("cargo build failed")?; + + if !build.status.success() { + let stderr = String::from_utf8_lossy(&build.stderr); + anyhow::bail!("Build failed:\n{}", stderr); + } + + let elf_path = firmware_dir + .join("target") + .join(TARGET) + .join("release") + .join("zeroclaw-nucleo"); + + if !elf_path.exists() { + anyhow::bail!("Built binary not found at {}", elf_path.display()); + } + + println!("Flashing to Nucleo-F401RE (connect via USB)..."); + let flash = Command::new("probe-rs") + .args(["run", "--chip", CHIP, elf_path.to_str().unwrap()]) + .output() + .context("probe-rs run failed")?; + + if !flash.status.success() { + let stderr = String::from_utf8_lossy(&flash.stderr); + anyhow::bail!( + "Flash failed:\n{}\n\n\ + Ensure Nucleo is connected via USB. The ST-Link is built into the board.", + stderr + ); + } + + println!("ZeroClaw Nucleo firmware flashed successfully."); + println!("The Nucleo now supports: ping, capabilities, gpio_read, gpio_write."); + println!("Add to config.toml: board = \"nucleo-f401re\", transport = \"serial\", path = \"/dev/ttyACM0\""); + Ok(()) +} diff --git a/src/peripherals/rpi.rs b/src/peripherals/rpi.rs new file mode 100644 index 0000000..6cea075 --- /dev/null +++ b/src/peripherals/rpi.rs @@ -0,0 +1,173 @@ +//! Raspberry Pi GPIO peripheral — native rppal access. +//! +//! Only compiled when `peripheral-rpi` feature is enabled and target is Linux. +//! Uses BCM pin numbering (e.g. GPIO 17, 27). + +use crate::config::PeripheralBoardConfig; +use crate::peripherals::traits::Peripheral; +use crate::tools::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// RPi GPIO peripheral — direct access via rppal. +pub struct RpiGpioPeripheral { + board: PeripheralBoardConfig, +} + +impl RpiGpioPeripheral { + /// Create a new RPi GPIO peripheral from config. + pub fn new(board: PeripheralBoardConfig) -> Self { + Self { board } + } + + /// Attempt to connect (init rppal). Returns Ok if GPIO is available. + pub async fn connect_from_config(board: &PeripheralBoardConfig) -> anyhow::Result { + let mut peripheral = Self::new(board.clone()); + peripheral.connect().await?; + Ok(peripheral) + } +} + +#[async_trait] +impl Peripheral for RpiGpioPeripheral { + fn name(&self) -> &str { + &self.board.board + } + + fn board_type(&self) -> &str { + "rpi-gpio" + } + + async fn connect(&mut self) -> anyhow::Result<()> { + // Verify GPIO is accessible by doing a no-op init + let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??; + drop(result); + Ok(()) + } + + async fn disconnect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new().is_ok()) + .await + .unwrap_or(false) + } + + fn tools(&self) -> Vec> { + vec![Box::new(RpiGpioReadTool), Box::new(RpiGpioWriteTool)] + } +} + +/// Tool: read GPIO pin value (BCM numbering). +struct RpiGpioReadTool; + +#[async_trait] +impl Tool for RpiGpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the value (0 or 1) of a GPIO pin on Raspberry Pi. Uses BCM pin numbers (e.g. 17, 27)." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO pin number (e.g. 17, 27)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let pin_u8 = pin as u8; + + let value = tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let pin = gpio.get(pin_u8)?.into_input(); + Ok::<_, anyhow::Error>(match pin.read() { + rppal::gpio::Level::Low => 0, + rppal::gpio::Level::High => 1, + }) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("pin {} = {}", pin, value), + error: None, + }) + } +} + +/// Tool: write GPIO pin value (BCM numbering). +struct RpiGpioWriteTool; + +#[async_trait] +impl Tool for RpiGpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin high (1) or low (0) on Raspberry Pi. Uses BCM pin numbers." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + let pin_u8 = pin as u8; + let level = match value { + 0 => rppal::gpio::Level::Low, + _ => rppal::gpio::Level::High, + }; + + tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let mut pin = gpio.get(pin_u8)?.into_output(); + pin.write(level); + Ok::<_, anyhow::Error>(()) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("pin {} = {}", pin, value), + error: None, + }) + } +} diff --git a/src/peripherals/serial.rs b/src/peripherals/serial.rs new file mode 100644 index 0000000..ab40d71 --- /dev/null +++ b/src/peripherals/serial.rs @@ -0,0 +1,274 @@ +//! Serial peripheral — STM32 and similar boards over USB CDC/serial. +//! +//! Protocol: newline-delimited JSON. +//! Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} +//! Response: {"id":"1","ok":true,"result":"done"} + +use super::traits::Peripheral; +use crate::config::PeripheralBoardConfig; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +/// Allowed serial path patterns (security: deny arbitrary paths). +const ALLOWED_PATH_PREFIXES: &[&str] = &[ + "/dev/ttyACM", + "/dev/ttyUSB", + "/dev/tty.usbmodem", + "/dev/cu.usbmodem", + "/dev/tty.usbserial", + "/dev/cu.usbserial", // Arduino Uno (FTDI), clones + "COM", // Windows +]; + +fn is_path_allowed(path: &str) -> bool { + ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) +} + +/// JSON request/response over serial. +async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow::Result { + static ID: AtomicU64 = AtomicU64::new(0); + let id = ID.fetch_add(1, Ordering::Relaxed); + let id_str = id.to_string(); + + let req = json!({ + "id": id_str, + "cmd": cmd, + "args": args + }); + let line = format!("{}\n", req); + + port.write_all(line.as_bytes()).await?; + port.flush().await?; + + let mut buf = Vec::new(); + let mut b = [0u8; 1]; + while port.read_exact(&mut b).await.is_ok() { + if b[0] == b'\n' { + break; + } + buf.push(b[0]); + } + let line_str = String::from_utf8_lossy(&buf); + let resp: Value = serde_json::from_str(line_str.trim())?; + let resp_id = resp["id"].as_str().unwrap_or(""); + if resp_id != id_str { + anyhow::bail!("Response id mismatch: expected {}, got {}", id_str, resp_id); + } + Ok(resp) +} + +/// Shared serial transport for tools. Pub(crate) for capabilities tool. +pub(crate) struct SerialTransport { + port: Mutex, +} + +/// Timeout for serial request/response (seconds). +const SERIAL_TIMEOUT_SECS: u64 = 5; + +impl SerialTransport { + async fn request(&self, cmd: &str, args: Value) -> anyhow::Result { + let mut port = self.port.lock().await; + let resp = tokio::time::timeout( + std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS), + send_request(&mut *port, cmd, args), + ) + .await + .map_err(|_| { + anyhow::anyhow!("Serial request timed out after {}s", SERIAL_TIMEOUT_SECS) + })??; + + let ok = resp["ok"].as_bool().unwrap_or(false); + let result = resp["result"] + .as_str() + .map(String::from) + .unwrap_or_else(|| resp["result"].to_string()); + let error = resp["error"].as_str().map(String::from); + + Ok(ToolResult { + success: ok, + output: result, + error, + }) + } + + /// Phase C: fetch capabilities from device (gpio pins, led_pin). + pub async fn capabilities(&self) -> anyhow::Result { + self.request("capabilities", json!({})).await + } +} + +/// Serial peripheral for STM32, Arduino, etc. over USB CDC. +pub struct SerialPeripheral { + name: String, + board_type: String, + transport: Arc, +} + +impl SerialPeripheral { + /// Create and connect to a serial peripheral. + pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result { + let path = config + .path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Serial peripheral requires path"))?; + + if !is_path_allowed(path) { + anyhow::bail!( + "Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*", + path + ); + } + + let port = tokio_serial::new(path, config.baud) + .open_native_async() + .map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path, e))?; + + let name = format!("{}-{}", config.board, path.replace('/', "_")); + let transport = Arc::new(SerialTransport { + port: Mutex::new(port), + }); + + Ok(Self { + name: name.clone(), + board_type: config.board.clone(), + transport, + }) + } +} + +#[async_trait] +impl Peripheral for SerialPeripheral { + fn name(&self) -> &str { + &self.name + } + + fn board_type(&self) -> &str { + &self.board_type + } + + async fn connect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn disconnect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + self.transport + .request("ping", json!({})) + .await + .map(|r| r.success) + .unwrap_or(false) + } + + fn tools(&self) -> Vec> { + vec![ + Box::new(GpioReadTool { + transport: self.transport.clone(), + }), + Box::new(GpioWriteTool { + transport: self.transport.clone(), + }), + ] + } +} + +impl SerialPeripheral { + /// Expose transport for capabilities tool (Phase C). + pub(crate) fn transport(&self) -> Arc { + self.transport.clone() + } +} + +/// Tool: read GPIO pin value. +struct GpioReadTool { + transport: Arc, +} + +#[async_trait] +impl Tool for GpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number (e.g. 13 for LED on Nucleo)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + self.transport + .request("gpio_read", json!({ "pin": pin })) + .await + } +} + +/// Tool: write GPIO pin value. +struct GpioWriteTool { + transport: Arc, +} + +#[async_trait] +impl Tool for GpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin high (1) or low (0) on a connected peripheral (e.g. turn on/off LED)" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + self.transport + .request("gpio_write", json!({ "pin": pin, "value": value })) + .await + } +} diff --git a/src/peripherals/traits.rs b/src/peripherals/traits.rs new file mode 100644 index 0000000..6081d1d --- /dev/null +++ b/src/peripherals/traits.rs @@ -0,0 +1,33 @@ +//! Peripheral trait — hardware boards (STM32, RPi GPIO) that expose tools. +//! +//! Peripherals are the agent's "arms and legs": remote devices that run minimal +//! firmware and expose capabilities (GPIO, sensors, actuators) as tools. + +use async_trait::async_trait; + +use crate::tools::Tool; + +/// A hardware peripheral that exposes capabilities as tools. +/// +/// Implement this for boards like Nucleo-F401RE (serial), RPi GPIO (native), etc. +/// When connected, the peripheral's tools are merged into the agent's tool registry. +#[async_trait] +pub trait Peripheral: Send + Sync { + /// Human-readable peripheral name (e.g. "nucleo-f401re-0") + fn name(&self) -> &str; + + /// Board type identifier (e.g. "nucleo-f401re", "rpi-gpio") + fn board_type(&self) -> &str; + + /// Connect to the peripheral (open serial, init GPIO, etc.) + async fn connect(&mut self) -> anyhow::Result<()>; + + /// Disconnect and release resources + async fn disconnect(&mut self) -> anyhow::Result<()>; + + /// Check if the peripheral is reachable and responsive + async fn health_check(&self) -> bool; + + /// Tools this peripheral provides (e.g. gpio_read, gpio_write, sensor_read) + fn tools(&self) -> Vec>; +} diff --git a/src/peripherals/uno_q_bridge.rs b/src/peripherals/uno_q_bridge.rs new file mode 100644 index 0000000..a621831 --- /dev/null +++ b/src/peripherals/uno_q_bridge.rs @@ -0,0 +1,151 @@ +//! Arduino Uno Q Bridge — GPIO via socket to Bridge app. +//! +//! When ZeroClaw runs on Uno Q, the Bridge app (Python + MCU) exposes +//! digitalWrite/digitalRead over a local socket. These tools connect to it. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const BRIDGE_HOST: &str = "127.0.0.1"; +const BRIDGE_PORT: u16 = 9999; + +async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result { + let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT); + let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr)) + .await + .map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??; + + let msg = format!("{} {}\n", cmd, args.join(" ")); + stream.write_all(msg.as_bytes()).await?; + + let mut buf = vec![0u8; 64]; + let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf)) + .await + .map_err(|_| anyhow::anyhow!("Bridge response timed out"))??; + let resp = String::from_utf8_lossy(&buf[..n]).trim().to_string(); + Ok(resp) +} + +/// Tool: read GPIO pin via Uno Q Bridge. +pub struct UnoQGpioReadTool; + +#[async_trait] +impl Tool for UnoQGpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number (e.g. 13 for LED)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + match bridge_request("gpio_read", &[pin.to_string()]).await { + Ok(resp) => { + if resp.starts_with("error:") { + Ok(ToolResult { + success: false, + output: resp.clone(), + error: Some(resp), + }) + } else { + Ok(ToolResult { + success: true, + output: resp, + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }), + } + } +} + +/// Tool: write GPIO pin via Uno Q Bridge. +pub struct UnoQGpioWriteTool; + +#[async_trait] +impl Tool for UnoQGpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await { + Ok(resp) => { + if resp.starts_with("error:") { + Ok(ToolResult { + success: false, + output: resp.clone(), + error: Some(resp), + }) + } else { + Ok(ToolResult { + success: true, + output: "done".into(), + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }), + } + } +} diff --git a/src/peripherals/uno_q_setup.rs b/src/peripherals/uno_q_setup.rs new file mode 100644 index 0000000..3b7d114 --- /dev/null +++ b/src/peripherals/uno_q_setup.rs @@ -0,0 +1,143 @@ +//! Deploy ZeroClaw Bridge app to Arduino Uno Q. + +use anyhow::{Context, Result}; +use std::process::Command; + +const BRIDGE_APP_NAME: &str = "zeroclaw-uno-q-bridge"; + +/// Deploy the Bridge app. If host is Some, scp from repo and ssh to start. +/// If host is None, assume we're ON the Uno Q — use embedded files and start. +pub fn setup_uno_q_bridge(host: Option<&str>) -> Result<()> { + let bridge_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("firmware") + .join("zeroclaw-uno-q-bridge"); + + if let Some(h) = host { + if bridge_dir.exists() { + deploy_remote(h, &bridge_dir)?; + } else { + anyhow::bail!( + "Bridge app not found at {}. Run from zeroclaw repo root.", + bridge_dir.display() + ); + } + } else { + deploy_local(if bridge_dir.exists() { + Some(&bridge_dir) + } else { + None + })?; + } + Ok(()) +} + +fn deploy_remote(host: &str, bridge_dir: &std::path::Path) -> Result<()> { + let ssh_target = if host.contains('@') { + host.to_string() + } else { + format!("arduino@{}", host) + }; + + println!("Copying Bridge app to {}...", host); + let status = Command::new("ssh") + .args([&ssh_target, "mkdir", "-p", "~/ArduinoApps"]) + .status() + .context("ssh mkdir failed")?; + if !status.success() { + anyhow::bail!("Failed to create ArduinoApps dir on Uno Q"); + } + + let status = Command::new("scp") + .args([ + "-r", + bridge_dir.to_str().unwrap(), + &format!("{}:~/ArduinoApps/", ssh_target), + ]) + .status() + .context("scp failed")?; + if !status.success() { + anyhow::bail!("Failed to copy Bridge app"); + } + + println!("Starting Bridge app on Uno Q..."); + let status = Command::new("ssh") + .args([ + &ssh_target, + "arduino-app-cli", + "app", + "start", + &format!("~/ArduinoApps/zeroclaw-uno-q-bridge"), + ]) + .status() + .context("arduino-app-cli start failed")?; + if !status.success() { + anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q."); + } + + println!("ZeroClaw Bridge app started. Add to config.toml:"); + println!(" [[peripherals.boards]]"); + println!(" board = \"arduino-uno-q\""); + println!(" transport = \"bridge\""); + Ok(()) +} + +fn deploy_local(bridge_dir: Option<&std::path::Path>) -> Result<()> { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/arduino".into()); + let apps_dir = std::path::Path::new(&home).join("ArduinoApps"); + let dest_dir = apps_dir.join(BRIDGE_APP_NAME); + + std::fs::create_dir_all(&dest_dir).context("create dest dir")?; + + if let Some(src) = bridge_dir { + println!("Copying Bridge app from repo..."); + copy_dir(src, &dest_dir)?; + } else { + println!("Writing embedded Bridge app..."); + write_embedded_bridge(&dest_dir)?; + } + + println!("Starting Bridge app..."); + let status = Command::new("arduino-app-cli") + .args(["app", "start", dest_dir.to_str().unwrap()]) + .status() + .context("arduino-app-cli start failed")?; + if !status.success() { + anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q."); + } + + println!("ZeroClaw Bridge app started."); + Ok(()) +} + +fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> { + let app_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/app.yaml"); + let sketch_ino = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino"); + let sketch_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml"); + let main_py = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/main.py"); + let requirements = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/requirements.txt"); + + std::fs::write(dest.join("app.yaml"), app_yaml)?; + std::fs::create_dir_all(dest.join("sketch"))?; + std::fs::write(dest.join("sketch").join("sketch.ino"), sketch_ino)?; + std::fs::write(dest.join("sketch").join("sketch.yaml"), sketch_yaml)?; + std::fs::create_dir_all(dest.join("python"))?; + std::fs::write(dest.join("python").join("main.py"), main_py)?; + std::fs::write(dest.join("python").join("requirements.txt"), requirements)?; + Ok(()) +} + +fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> { + for entry in std::fs::read_dir(src)? { + let e = entry?; + let name = e.file_name(); + let src_path = src.join(&name); + let dst_path = dst.join(&name); + if e.file_type()?.is_dir() { + std::fs::create_dir_all(&dst_path)?; + copy_dir(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index c3c7870..fb940e9 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -1,4 +1,8 @@ -use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; +use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -26,13 +30,79 @@ struct Message { } #[derive(Debug, Deserialize)] -struct ApiChatResponse { +struct ChatResponse { content: Vec, } #[derive(Debug, Deserialize)] struct ContentBlock { - text: String, + #[serde(rename = "type")] + kind: String, + #[serde(default)] + text: Option, +} + +#[derive(Debug, Serialize)] +struct NativeChatRequest { + model: String, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + messages: Vec, + temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, +} + +#[derive(Debug, Serialize)] +struct NativeMessage { + role: String, + content: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum NativeContentOut { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + #[serde(rename = "tool_result")] + ToolResult { + tool_use_id: String, + content: String, + }, +} + +#[derive(Debug, Serialize)] +struct NativeToolSpec { + name: String, + description: String, + input_schema: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct NativeChatResponse { + #[serde(default)] + content: Vec, +} + +#[derive(Debug, Deserialize)] +struct NativeContentIn { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + text: Option, + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + input: Option, } impl AnthropicProvider { @@ -62,6 +132,186 @@ impl AnthropicProvider { fn is_setup_token(token: &str) -> bool { token.starts_with("sk-ant-oat01-") } + + fn apply_auth( + &self, + request: reqwest::RequestBuilder, + credential: &str, + ) -> reqwest::RequestBuilder { + if Self::is_setup_token(credential) { + request.header("Authorization", format!("Bearer {credential}")) + } else { + request.header("x-api-key", credential) + } + } + + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { + let items = tools?; + if items.is_empty() { + return None; + } + Some( + items + .iter() + .map(|tool| NativeToolSpec { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.parameters.clone(), + }) + .collect(), + ) + } + + fn parse_assistant_tool_call_message(content: &str) -> Option> { + let value = serde_json::from_str::(content).ok()?; + let tool_calls = value + .get("tool_calls") + .and_then(|v| serde_json::from_value::>(v.clone()).ok())?; + + let mut blocks = Vec::new(); + if let Some(text) = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + blocks.push(NativeContentOut::Text { + text: text.to_string(), + }); + } + for call in tool_calls { + let input = serde_json::from_str::(&call.arguments) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); + blocks.push(NativeContentOut::ToolUse { + id: call.id, + name: call.name, + input, + }); + } + Some(blocks) + } + + fn parse_tool_result_message(content: &str) -> Option { + let value = serde_json::from_str::(content).ok()?; + let tool_use_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str)? + .to_string(); + let result = value + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + Some(NativeMessage { + role: "user".to_string(), + content: vec![NativeContentOut::ToolResult { + tool_use_id, + content: result, + }], + }) + } + + fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec) { + let mut system_prompt = None; + let mut native_messages = Vec::new(); + + for msg in messages { + match msg.role.as_str() { + "system" => { + if system_prompt.is_none() { + system_prompt = Some(msg.content.clone()); + } + } + "assistant" => { + if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) { + native_messages.push(NativeMessage { + role: "assistant".to_string(), + content: blocks, + }); + } else { + native_messages.push(NativeMessage { + role: "assistant".to_string(), + content: vec![NativeContentOut::Text { + text: msg.content.clone(), + }], + }); + } + } + "tool" => { + if let Some(tool_result) = Self::parse_tool_result_message(&msg.content) { + native_messages.push(tool_result); + } else { + native_messages.push(NativeMessage { + role: "user".to_string(), + content: vec![NativeContentOut::Text { + text: msg.content.clone(), + }], + }); + } + } + _ => { + native_messages.push(NativeMessage { + role: "user".to_string(), + content: vec![NativeContentOut::Text { + text: msg.content.clone(), + }], + }); + } + } + } + + (system_prompt, native_messages) + } + + fn parse_text_response(response: ChatResponse) -> anyhow::Result { + response + .content + .into_iter() + .find(|c| c.kind == "text") + .and_then(|c| c.text) + .ok_or_else(|| anyhow::anyhow!("No response from Anthropic")) + } + + fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse { + let mut text_parts = Vec::new(); + let mut tool_calls = Vec::new(); + + for block in response.content { + match block.kind.as_str() { + "text" => { + if let Some(text) = block.text.map(|t| t.trim().to_string()) { + if !text.is_empty() { + text_parts.push(text); + } + } + } + "tool_use" => { + let name = block.name.unwrap_or_default(); + if name.is_empty() { + continue; + } + let arguments = block + .input + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + tool_calls.push(ProviderToolCall { + id: block.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + name, + arguments: arguments.to_string(), + }); + } + _ => {} + } + } + + ProviderChatResponse { + text: if text_parts.is_empty() { + None + } else { + Some(text_parts.join("\n")) + }, + tool_calls, + } + } } #[async_trait] @@ -72,7 +322,7 @@ impl Provider for AnthropicProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let credential = self.credential.as_ref().ok_or_else(|| { anyhow::anyhow!( "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)." @@ -97,11 +347,7 @@ impl Provider for AnthropicProvider { .header("content-type", "application/json") .json(&request); - if Self::is_setup_token(credential) { - request = request.header("Authorization", format!("Bearer {credential}")); - } else { - request = request.header("x-api-key", credential); - } + request = self.apply_auth(request, credential); let response = request.send().await?; @@ -109,14 +355,50 @@ impl Provider for AnthropicProvider { return Err(super::api_error("Anthropic", response).await); } - let chat_response: ApiChatResponse = response.json().await?; + let chat_response: ChatResponse = response.json().await?; + Self::parse_text_response(chat_response) + } - chat_response - .content - .into_iter() - .next() - .map(|c| ProviderChatResponse::with_text(c.text)) - .ok_or_else(|| anyhow::anyhow!("No response from Anthropic")) + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let credential = self.credential.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)." + ) + })?; + + let (system_prompt, messages) = Self::convert_messages(request.messages); + let native_request = NativeChatRequest { + model: model.to_string(), + max_tokens: 4096, + system: system_prompt, + messages, + temperature, + tools: Self::convert_tools(request.tools), + }; + + let req = self + .client + .post(format!("{}/v1/messages", self.base_url)) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&native_request); + + let response = self.apply_auth(req, credential).send().await?; + if !response.status().is_success() { + return Err(super::api_error("Anthropic", response).await); + } + + let native_response: NativeChatResponse = response.json().await?; + Ok(Self::parse_native_response(native_response)) + } + + fn supports_native_tools(&self) -> bool { + true } } @@ -241,15 +523,16 @@ mod tests { #[test] fn chat_response_deserializes() { let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.content.len(), 1); - assert_eq!(resp.content[0].text, "Hello there!"); + assert_eq!(resp.content[0].kind, "text"); + assert_eq!(resp.content[0].text.as_deref(), Some("Hello there!")); } #[test] fn chat_response_empty_content() { let json = r#"{"content":[]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert!(resp.content.is_empty()); } @@ -257,10 +540,10 @@ mod tests { fn chat_response_multiple_blocks() { let json = r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.content.len(), 2); - assert_eq!(resp.content[0].text, "First"); - assert_eq!(resp.content[1].text, "Second"); + assert_eq!(resp.content[0].text.as_deref(), Some("First")); + assert_eq!(resp.content[1].text.as_deref(), Some("Second")); } #[test] diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index 4c59992..a9942f0 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -2,7 +2,10 @@ //! Most LLM APIs follow the same `/v1/chat/completions` format. //! This module provides a single implementation that works for all of them. -use crate::providers::traits::{ChatMessage, ChatResponse, Provider, ToolCall}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -15,6 +18,9 @@ pub struct OpenAiCompatibleProvider { pub(crate) base_url: String, pub(crate) api_key: Option, pub(crate) auth_header: AuthStyle, + /// When false, do not fall back to /v1/responses on chat completions 404. + /// GLM/Zhipu does not support the responses API. + supports_responses_fallback: bool, client: Client, } @@ -36,6 +42,29 @@ impl OpenAiCompatibleProvider { base_url: base_url.trim_end_matches('/').to_string(), api_key: api_key.map(ToString::to_string), auth_header: auth_style, + supports_responses_fallback: true, + client: Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + /// Same as `new` but skips the /v1/responses fallback on 404. + /// Use for providers (e.g. GLM) that only support chat completions. + pub fn new_no_responses_fallback( + name: &str, + base_url: &str, + api_key: Option<&str>, + auth_style: AuthStyle, + ) -> Self { + Self { + name: name.to_string(), + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.map(ToString::to_string), + auth_header: auth_style, + supports_responses_fallback: false, client: Client::builder() .timeout(std::time::Duration::from_secs(120)) .connect_timeout(std::time::Duration::from_secs(10)) @@ -112,6 +141,8 @@ struct ChatRequest { model: String, messages: Vec, temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + stream: Option, } #[derive(Debug, Serialize)] @@ -135,12 +166,11 @@ struct ResponseMessage { #[serde(default)] content: Option, #[serde(default)] - tool_calls: Option>, + tool_calls: Option>, } #[derive(Debug, Deserialize, Serialize)] -struct ApiToolCall { - id: Option, +struct ToolCall { #[serde(rename = "type")] kind: Option, function: Option, @@ -226,44 +256,6 @@ fn extract_responses_text(response: ResponsesResponse) -> Option { None } -fn map_response_message(message: ResponseMessage) -> ChatResponse { - let text = first_nonempty(message.content.as_deref()); - let tool_calls = message - .tool_calls - .unwrap_or_default() - .into_iter() - .enumerate() - .filter_map(|(index, call)| map_api_tool_call(call, index)) - .collect(); - - ChatResponse { text, tool_calls } -} - -fn map_api_tool_call(call: ApiToolCall, index: usize) -> Option { - if call.kind.as_deref().is_some_and(|kind| kind != "function") { - return None; - } - - let function = call.function?; - let name = function - .name - .and_then(|value| first_nonempty(Some(value.as_str())))?; - let arguments = function - .arguments - .and_then(|value| first_nonempty(Some(value.as_str()))) - .unwrap_or_else(|| "{}".to_string()); - let id = call - .id - .and_then(|value| first_nonempty(Some(value.as_str()))) - .unwrap_or_else(|| format!("call_{}", index + 1)); - - Some(ToolCall { - id, - name, - arguments, - }) -} - impl OpenAiCompatibleProvider { fn apply_auth_header( &self, @@ -283,7 +275,7 @@ impl OpenAiCompatibleProvider { system_prompt: Option<&str>, message: &str, model: &str, - ) -> anyhow::Result { + ) -> anyhow::Result { let request = ResponsesRequest { model: model.to_string(), input: vec![ResponsesInput { @@ -309,7 +301,6 @@ impl OpenAiCompatibleProvider { let responses: ResponsesResponse = response.json().await?; extract_responses_text(responses) - .map(ChatResponse::with_text) .ok_or_else(|| anyhow::anyhow!("No response from {} Responses API", self.name)) } } @@ -322,7 +313,7 @@ impl Provider for OpenAiCompatibleProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { anyhow::anyhow!( "{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.", @@ -348,6 +339,7 @@ impl Provider for OpenAiCompatibleProvider { model: model.to_string(), messages, temperature, + stream: Some(false), }; let url = self.chat_completions_url(); @@ -362,7 +354,7 @@ impl Provider for OpenAiCompatibleProvider { let error = response.text().await?; let sanitized = super::sanitize_api_error(&error); - if status == reqwest::StatusCode::NOT_FOUND { + if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { return self .chat_via_responses(api_key, system_prompt, message, model) .await @@ -379,13 +371,27 @@ impl Provider for OpenAiCompatibleProvider { let chat_response: ApiChatResponse = response.json().await?; - let choice = chat_response + chat_response .choices .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?; - - Ok(map_response_message(choice.message)) + .map(|c| { + // If tool_calls are present, serialize the full message as JSON + // so parse_tool_calls can handle the OpenAI-style format + if c.message.tool_calls.is_some() + && c.message + .tool_calls + .as_ref() + .map_or(false, |t| !t.is_empty()) + { + serde_json::to_string(&c.message) + .unwrap_or_else(|_| c.message.content.unwrap_or_default()) + } else { + // No tool calls, return content as-is + c.message.content.unwrap_or_default() + } + }) + .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name)) } async fn chat_with_history( @@ -393,7 +399,7 @@ impl Provider for OpenAiCompatibleProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { anyhow::anyhow!( "{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.", @@ -413,6 +419,7 @@ impl Provider for OpenAiCompatibleProvider { model: model.to_string(), messages: api_messages, temperature, + stream: Some(false), }; let url = self.chat_completions_url(); @@ -425,7 +432,7 @@ impl Provider for OpenAiCompatibleProvider { let status = response.status(); // Mirror chat_with_system: 404 may mean this provider uses the Responses API - if status == reqwest::StatusCode::NOT_FOUND { + if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { // Extract system prompt and last user message for responses fallback let system = messages.iter().find(|m| m.role == "system"); let last_user = messages.iter().rfind(|m| m.role == "user"); @@ -452,13 +459,71 @@ impl Provider for OpenAiCompatibleProvider { let chat_response: ApiChatResponse = response.json().await?; - let choice = chat_response + chat_response .choices .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?; + .map(|c| { + // If tool_calls are present, serialize the full message as JSON + // so parse_tool_calls can handle the OpenAI-style format + if c.message.tool_calls.is_some() + && c.message + .tool_calls + .as_ref() + .map_or(false, |t| !t.is_empty()) + { + serde_json::to_string(&c.message) + .unwrap_or_else(|_| c.message.content.unwrap_or_default()) + } else { + // No tool calls, return content as-is + c.message.content.unwrap_or_default() + } + }) + .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name)) + } - Ok(map_response_message(choice.message)) + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let text = self + .chat_with_history(request.messages, model, temperature) + .await?; + + // Backward compatible path: chat_with_history may serialize tool_calls JSON into content. + if let Ok(message) = serde_json::from_str::(&text) { + let tool_calls = message + .tool_calls + .unwrap_or_default() + .into_iter() + .filter_map(|tc| { + let function = tc.function?; + let name = function.name?; + let arguments = function.arguments.unwrap_or_else(|| "{}".to_string()); + Some(ProviderToolCall { + id: uuid::Uuid::new_v4().to_string(), + name, + arguments, + }) + }) + .collect::>(); + + return Ok(ProviderChatResponse { + text: message.content, + tool_calls, + }); + } + + Ok(ProviderChatResponse { + text: Some(text), + tool_calls: vec![], + }) + } + + fn supports_native_tools(&self) -> bool { + true } } @@ -517,7 +582,8 @@ mod tests { content: "hello".to_string(), }, ], - temperature: 0.7, + temperature: 0.4, + stream: Some(false), }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("llama-3.3-70b")); @@ -542,20 +608,6 @@ mod tests { assert!(resp.choices.is_empty()); } - #[test] - fn response_with_tool_calls_maps_structured_data() { - let json = r#"{"choices":[{"message":{"content":"Running checks","tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); - let choice = resp.choices.into_iter().next().unwrap(); - - let mapped = map_response_message(choice.message); - assert_eq!(mapped.text.as_deref(), Some("Running checks")); - assert_eq!(mapped.tool_calls.len(), 1); - assert_eq!(mapped.tool_calls[0].id, "call_1"); - assert_eq!(mapped.tool_calls[0].name, "shell"); - assert_eq!(mapped.tool_calls[0].arguments, r#"{"command":"pwd"}"#); - } - #[test] fn x_api_key_auth_style() { let p = OpenAiCompatibleProvider::new( diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index 189daf0..a988224 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -3,7 +3,7 @@ //! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication) //! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`) -use crate::providers::traits::{ChatResponse, Provider}; +use crate::providers::traits::Provider; use async_trait::async_trait; use directories::UserDirs; use reqwest::Client; @@ -260,7 +260,7 @@ impl Provider for GeminiProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let auth = self.auth.as_ref().ok_or_else(|| { anyhow::anyhow!( "Gemini API key not found. Options:\n\ @@ -319,7 +319,6 @@ impl Provider for GeminiProvider { .and_then(|c| c.into_iter().next()) .and_then(|c| c.content.parts.into_iter().next()) .and_then(|p| p.text) - .map(ChatResponse::with_text) .ok_or_else(|| anyhow::anyhow!("No response from Gemini")) } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index b342675..1ddaddc 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -9,7 +9,10 @@ pub mod router; pub mod traits; #[allow(unused_imports)] -pub use traits::{ChatMessage, ChatResponse, Provider, ToolCall}; +pub use traits::{ + ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall, + ToolResultMessage, +}; use compatible::{AuthStyle, OpenAiCompatibleProvider}; use reliable::ReliableProvider; @@ -123,6 +126,9 @@ fn resolve_api_key(name: &str, api_key: Option<&str>) -> Option { "glm" | "zhipu" => vec!["GLM_API_KEY"], "minimax" => vec!["MINIMAX_API_KEY"], "qianfan" | "baidu" => vec!["QIANFAN_API_KEY"], + "qwen" | "dashscope" | "qwen-intl" | "dashscope-intl" | "qwen-us" | "dashscope-us" => { + vec!["DASHSCOPE_API_KEY"] + } "zai" | "z.ai" => vec!["ZAI_API_KEY"], "synthetic" => vec!["SYNTHETIC_API_KEY"], "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"], @@ -202,7 +208,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Cloudflare AI Gateway", "https://gateway.ai.cloudflare.com/v1", - api_key, + key, AuthStyle::Bearer, ))), "moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -217,8 +223,8 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Z.AI", "https://api.z.ai/api/coding/paas/v4", key, AuthStyle::Bearer, ))), - "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new( - "GLM", "https://open.bigmodel.cn/api/paas/v4", key, AuthStyle::Bearer, + "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback( + "GLM", "https://api.z.ai/api/paas/v4", key, AuthStyle::Bearer, ))), "minimax" => Ok(Box::new(OpenAiCompatibleProvider::new( "MiniMax", @@ -229,12 +235,21 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Amazon Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", - api_key, + key, AuthStyle::Bearer, ))), "qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new( "Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer, ))), + "qwen" | "dashscope" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer, + ))), + "qwen-intl" | "dashscope-intl" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer, + ))), + "qwen-us" | "dashscope-us" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qwen", "https://dashscope-us.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer, + ))), // ── Extended ecosystem (community favorites) ───────── "groq" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -421,6 +436,12 @@ pub fn create_routed_provider( mod tests { use super::*; + #[test] + fn resolve_api_key_prefers_explicit_argument() { + let resolved = resolve_api_key("openrouter", Some(" explicit-key ")); + assert_eq!(resolved.as_deref(), Some("explicit-key")); + } + // ── Primary providers ──────────────────────────────────── #[test] @@ -521,6 +542,16 @@ mod tests { assert!(create_provider("baidu", Some("key")).is_ok()); } + #[test] + fn factory_qwen() { + assert!(create_provider("qwen", Some("key")).is_ok()); + assert!(create_provider("dashscope", Some("key")).is_ok()); + assert!(create_provider("qwen-intl", Some("key")).is_ok()); + assert!(create_provider("dashscope-intl", Some("key")).is_ok()); + assert!(create_provider("qwen-us", Some("key")).is_ok()); + assert!(create_provider("dashscope-us", Some("key")).is_ok()); + } + // ── Extended ecosystem ─────────────────────────────────── #[test] @@ -749,6 +780,9 @@ mod tests { "minimax", "bedrock", "qianfan", + "qwen", + "qwen-intl", + "qwen-us", "groq", "mistral", "xai", diff --git a/src/providers/ollama.rs b/src/providers/ollama.rs index 481d0bf..8ecfb5a 100644 --- a/src/providers/ollama.rs +++ b/src/providers/ollama.rs @@ -1,4 +1,4 @@ -use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider}; +use crate::providers::traits::Provider; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -61,7 +61,7 @@ impl Provider for OllamaProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let mut messages = Vec::new(); if let Some(sys) = system_prompt { @@ -93,9 +93,7 @@ impl Provider for OllamaProvider { } let chat_response: ApiChatResponse = response.json().await?; - Ok(ProviderChatResponse::with_text( - chat_response.message.content, - )) + Ok(chat_response.message.content) } } diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 6b8bbe5..ef67678 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -1,4 +1,8 @@ -use crate::providers::traits::{ChatResponse, Provider}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; +use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -22,7 +26,7 @@ struct Message { } #[derive(Debug, Deserialize)] -struct ApiChatResponse { +struct ChatResponse { choices: Vec, } @@ -36,6 +40,75 @@ struct ResponseMessage { content: String, } +#[derive(Debug, Serialize)] +struct NativeChatRequest { + model: String, + messages: Vec, + temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +#[derive(Debug, Serialize)] +struct NativeMessage { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, +} + +#[derive(Debug, Serialize)] +struct NativeToolSpec { + #[serde(rename = "type")] + kind: String, + function: NativeToolFunctionSpec, +} + +#[derive(Debug, Serialize)] +struct NativeToolFunctionSpec { + name: String, + description: String, + parameters: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeToolCall { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + kind: Option, + function: NativeFunctionCall, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeFunctionCall { + name: String, + arguments: String, +} + +#[derive(Debug, Deserialize)] +struct NativeChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct NativeChoice { + message: NativeResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct NativeResponseMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Option>, +} + impl OpenAiProvider { pub fn new(api_key: Option<&str>) -> Self { Self { @@ -47,6 +120,107 @@ impl OpenAiProvider { .unwrap_or_else(|_| Client::new()), } } + + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { + tools.map(|items| { + items + .iter() + .map(|tool| NativeToolSpec { + kind: "function".to_string(), + function: NativeToolFunctionSpec { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: tool.parameters.clone(), + }, + }) + .collect() + }) + } + + fn convert_messages(messages: &[ChatMessage]) -> Vec { + messages + .iter() + .map(|m| { + if m.role == "assistant" { + if let Ok(value) = serde_json::from_str::(&m.content) { + if let Some(tool_calls_value) = value.get("tool_calls") { + if let Ok(parsed_calls) = + serde_json::from_value::>( + tool_calls_value.clone(), + ) + { + let tool_calls = parsed_calls + .into_iter() + .map(|tc| NativeToolCall { + id: Some(tc.id), + kind: Some("function".to_string()), + function: NativeFunctionCall { + name: tc.name, + arguments: tc.arguments, + }, + }) + .collect::>(); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "assistant".to_string(), + content, + tool_call_id: None, + tool_calls: Some(tool_calls), + }; + } + } + } + } + + if m.role == "tool" { + if let Ok(value) = serde_json::from_str::(&m.content) { + let tool_call_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "tool".to_string(), + content, + tool_call_id, + tool_calls: None, + }; + } + } + + NativeMessage { + role: m.role.clone(), + content: Some(m.content.clone()), + tool_call_id: None, + tool_calls: None, + } + }) + .collect() + } + + fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse { + let tool_calls = message + .tool_calls + .unwrap_or_default() + .into_iter() + .map(|tc| ProviderToolCall { + id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + name: tc.function.name, + arguments: tc.function.arguments, + }) + .collect::>(); + + ProviderChatResponse { + text: message.content, + tool_calls, + } + } } #[async_trait] @@ -57,7 +231,7 @@ impl Provider for OpenAiProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") })?; @@ -94,15 +268,60 @@ impl Provider for OpenAiProvider { return Err(super::api_error("OpenAI", response).await); } - let chat_response: ApiChatResponse = response.json().await?; + let chat_response: ChatResponse = response.json().await?; chat_response .choices .into_iter() .next() - .map(|c| ChatResponse::with_text(c.message.content)) + .map(|c| c.message.content) .ok_or_else(|| anyhow::anyhow!("No response from OpenAI")) } + + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let api_key = self.api_key.as_ref().ok_or_else(|| { + anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") + })?; + + let tools = Self::convert_tools(request.tools); + let native_request = NativeChatRequest { + model: model.to_string(), + messages: Self::convert_messages(request.messages), + temperature, + tool_choice: tools.as_ref().map(|_| "auto".to_string()), + tools, + }; + + let response = self + .client + .post("https://api.openai.com/v1/chat/completions") + .header("Authorization", format!("Bearer {api_key}")) + .json(&native_request) + .send() + .await?; + + if !response.status().is_success() { + return Err(super::api_error("OpenAI", response).await); + } + + let native_response: NativeChatResponse = response.json().await?; + let message = native_response + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?; + Ok(Self::parse_native_response(message)) + } + + fn supports_native_tools(&self) -> bool { + true + } } #[cfg(test)] @@ -184,7 +403,7 @@ mod tests { #[test] fn response_deserializes_single_choice() { let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.choices.len(), 1); assert_eq!(resp.choices[0].message.content, "Hi!"); } @@ -192,14 +411,14 @@ mod tests { #[test] fn response_deserializes_empty_choices() { let json = r#"{"choices":[]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert!(resp.choices.is_empty()); } #[test] fn response_deserializes_multiple_choices() { let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.choices.len(), 2); assert_eq!(resp.choices[0].message.content, "A"); } @@ -207,7 +426,7 @@ mod tests { #[test] fn response_with_unicode() { let json = r#"{"choices":[{"message":{"content":"こんにちは 🦀"}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.choices[0].message.content, "こんにちは 🦀"); } @@ -215,7 +434,7 @@ mod tests { fn response_with_long_content() { let long = "x".repeat(100_000); let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#); - let resp: ApiChatResponse = serde_json::from_str(&json).unwrap(); + let resp: ChatResponse = serde_json::from_str(&json).unwrap(); assert_eq!(resp.choices[0].message.content.len(), 100_000); } } diff --git a/src/providers/openrouter.rs b/src/providers/openrouter.rs index 287dd88..3a02e2d 100644 --- a/src/providers/openrouter.rs +++ b/src/providers/openrouter.rs @@ -1,4 +1,8 @@ -use crate::providers::traits::{ChatMessage, ChatResponse, Provider}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; +use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -36,6 +40,75 @@ struct ResponseMessage { content: String, } +#[derive(Debug, Serialize)] +struct NativeChatRequest { + model: String, + messages: Vec, + temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +#[derive(Debug, Serialize)] +struct NativeMessage { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, +} + +#[derive(Debug, Serialize)] +struct NativeToolSpec { + #[serde(rename = "type")] + kind: String, + function: NativeToolFunctionSpec, +} + +#[derive(Debug, Serialize)] +struct NativeToolFunctionSpec { + name: String, + description: String, + parameters: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeToolCall { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + kind: Option, + function: NativeFunctionCall, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeFunctionCall { + name: String, + arguments: String, +} + +#[derive(Debug, Deserialize)] +struct NativeChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct NativeChoice { + message: NativeResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct NativeResponseMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Option>, +} + impl OpenRouterProvider { pub fn new(api_key: Option<&str>) -> Self { Self { @@ -47,6 +120,111 @@ impl OpenRouterProvider { .unwrap_or_else(|_| Client::new()), } } + + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { + let items = tools?; + if items.is_empty() { + return None; + } + Some( + items + .iter() + .map(|tool| NativeToolSpec { + kind: "function".to_string(), + function: NativeToolFunctionSpec { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: tool.parameters.clone(), + }, + }) + .collect(), + ) + } + + fn convert_messages(messages: &[ChatMessage]) -> Vec { + messages + .iter() + .map(|m| { + if m.role == "assistant" { + if let Ok(value) = serde_json::from_str::(&m.content) { + if let Some(tool_calls_value) = value.get("tool_calls") { + if let Ok(parsed_calls) = + serde_json::from_value::>( + tool_calls_value.clone(), + ) + { + let tool_calls = parsed_calls + .into_iter() + .map(|tc| NativeToolCall { + id: Some(tc.id), + kind: Some("function".to_string()), + function: NativeFunctionCall { + name: tc.name, + arguments: tc.arguments, + }, + }) + .collect::>(); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "assistant".to_string(), + content, + tool_call_id: None, + tool_calls: Some(tool_calls), + }; + } + } + } + } + + if m.role == "tool" { + if let Ok(value) = serde_json::from_str::(&m.content) { + let tool_call_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "tool".to_string(), + content, + tool_call_id, + tool_calls: None, + }; + } + } + + NativeMessage { + role: m.role.clone(), + content: Some(m.content.clone()), + tool_call_id: None, + tool_calls: None, + } + }) + .collect() + } + + fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse { + let tool_calls = message + .tool_calls + .unwrap_or_default() + .into_iter() + .map(|tc| ProviderToolCall { + id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + name: tc.function.name, + arguments: tc.function.arguments, + }) + .collect::>(); + + ProviderChatResponse { + text: message.content, + tool_calls, + } + } } #[async_trait] @@ -71,7 +249,7 @@ impl Provider for OpenRouterProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref() .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?; @@ -118,7 +296,7 @@ impl Provider for OpenRouterProvider { .choices .into_iter() .next() - .map(|c| ChatResponse::with_text(c.message.content)) + .map(|c| c.message.content) .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } @@ -127,7 +305,7 @@ impl Provider for OpenRouterProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref() .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?; @@ -168,9 +346,61 @@ impl Provider for OpenRouterProvider { .choices .into_iter() .next() - .map(|c| ChatResponse::with_text(c.message.content)) + .map(|c| c.message.content) .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } + + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let api_key = self.api_key.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var." + ) + })?; + + let tools = Self::convert_tools(request.tools); + let native_request = NativeChatRequest { + model: model.to_string(), + messages: Self::convert_messages(request.messages), + temperature, + tool_choice: tools.as_ref().map(|_| "auto".to_string()), + tools, + }; + + let response = self + .client + .post("https://openrouter.ai/api/v1/chat/completions") + .header("Authorization", format!("Bearer {api_key}")) + .header( + "HTTP-Referer", + "https://github.com/theonlyhennygod/zeroclaw", + ) + .header("X-Title", "ZeroClaw") + .json(&native_request) + .send() + .await?; + + if !response.status().is_success() { + return Err(super::api_error("OpenRouter", response).await); + } + + let native_response: NativeChatResponse = response.json().await?; + let message = native_response + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?; + Ok(Self::parse_native_response(message)) + } + + fn supports_native_tools(&self) -> bool { + true + } } #[cfg(test)] diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 423bfff..41a0a1a 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -1,4 +1,4 @@ -use super::traits::{ChatMessage, ChatResponse}; +use super::traits::ChatMessage; use super::Provider; use async_trait::async_trait; use std::collections::HashMap; @@ -57,7 +57,12 @@ fn parse_retry_after_ms(err: &anyhow::Error) -> Option { .take_while(|c| c.is_ascii_digit() || *c == '.') .collect(); if let Ok(secs) = num_str.parse::() { - return Some((secs * 1000.0) as u64); + if secs.is_finite() && secs >= 0.0 { + let millis = Duration::from_secs_f64(secs).as_millis(); + if let Ok(value) = u64::try_from(millis) { + return Some(value); + } + } } } } @@ -151,7 +156,7 @@ impl Provider for ReliableProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let models = self.model_chain(model); let mut failures = Vec::new(); @@ -249,7 +254,7 @@ impl Provider for ReliableProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let models = self.model_chain(model); let mut failures = Vec::new(); @@ -354,12 +359,12 @@ mod tests { _message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if attempt <= self.fail_until_attempt { anyhow::bail!(self.error); } - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } async fn chat_with_history( @@ -367,12 +372,12 @@ mod tests { _messages: &[ChatMessage], _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if attempt <= self.fail_until_attempt { anyhow::bail!(self.error); } - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } } @@ -392,13 +397,13 @@ mod tests { _message: &str, model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); self.models_seen.lock().unwrap().push(model.to_string()); if self.fail_models.contains(&model) { anyhow::bail!("500 model {} unavailable", model); } - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } } @@ -421,8 +426,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "ok"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "ok"); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -443,8 +448,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "recovered"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "recovered"); assert_eq!(calls.load(Ordering::SeqCst), 2); } @@ -478,8 +483,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "from fallback"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "from fallback"); assert_eq!(primary_calls.load(Ordering::SeqCst), 2); assert_eq!(fallback_calls.load(Ordering::SeqCst), 1); } @@ -512,7 +517,7 @@ mod tests { ); let err = provider - .chat("hello", "test", 0.0) + .simple_chat("hello", "test", 0.0) .await .expect_err("all providers should fail"); let msg = err.to_string(); @@ -567,8 +572,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "from fallback"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "from fallback"); // Primary should have been called only once (no retries) assert_eq!(primary_calls.load(Ordering::SeqCst), 1); assert_eq!(fallback_calls.load(Ordering::SeqCst), 1); @@ -596,7 +601,7 @@ mod tests { .chat_with_history(&messages, "test", 0.0) .await .unwrap(); - assert_eq!(result.text_or_empty(), "history ok"); + assert_eq!(result, "history ok"); assert_eq!(calls.load(Ordering::SeqCst), 2); } @@ -635,7 +640,7 @@ mod tests { .chat_with_history(&messages, "test", 0.0) .await .unwrap(); - assert_eq!(result.text_or_empty(), "fallback ok"); + assert_eq!(result, "fallback ok"); assert_eq!(primary_calls.load(Ordering::SeqCst), 2); assert_eq!(fallback_calls.load(Ordering::SeqCst), 1); } @@ -665,8 +670,11 @@ mod tests { ) .with_model_fallbacks(fallbacks); - let result = provider.chat("hello", "claude-opus", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "ok from sonnet"); + let result = provider + .simple_chat("hello", "claude-opus", 0.0) + .await + .unwrap(); + assert_eq!(result, "ok from sonnet"); let seen = mock.models_seen.lock().unwrap(); assert_eq!(seen.len(), 2); @@ -698,7 +706,7 @@ mod tests { .with_model_fallbacks(fallbacks); let err = provider - .chat("hello", "model-a", 0.0) + .simple_chat("hello", "model-a", 0.0) .await .expect_err("all models should fail"); assert!(err.to_string().contains("All providers/models failed")); @@ -724,8 +732,8 @@ mod tests { 1, ); // No model_fallbacks set — should work exactly as before - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "ok"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "ok"); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -822,7 +830,7 @@ mod tests { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.as_ref() .chat_with_system(system_prompt, message, model, temperature) .await diff --git a/src/providers/router.rs b/src/providers/router.rs index eb3101f..ccbdffb 100644 --- a/src/providers/router.rs +++ b/src/providers/router.rs @@ -1,4 +1,4 @@ -use super::traits::{ChatMessage, ChatResponse}; +use super::traits::{ChatMessage, ChatRequest, ChatResponse}; use super::Provider; use async_trait::async_trait; use std::collections::HashMap; @@ -98,7 +98,7 @@ impl Provider for RouterProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let (provider_idx, resolved_model) = self.resolve(model); let (provider_name, provider) = &self.providers[provider_idx]; @@ -118,7 +118,7 @@ impl Provider for RouterProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let (provider_idx, resolved_model) = self.resolve(model); let (_, provider) = &self.providers[provider_idx]; provider @@ -126,6 +126,24 @@ impl Provider for RouterProvider { .await } + async fn chat( + &self, + request: ChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let (provider_idx, resolved_model) = self.resolve(model); + let (_, provider) = &self.providers[provider_idx]; + provider.chat(request, &resolved_model, temperature).await + } + + fn supports_native_tools(&self) -> bool { + self.providers + .get(self.default_index) + .map(|(_, p)| p.supports_native_tools()) + .unwrap_or(false) + } + async fn warmup(&self) -> anyhow::Result<()> { for (name, provider) in &self.providers { tracing::info!(provider = name, "Warming up routed provider"); @@ -175,10 +193,10 @@ mod tests { _message: &str, model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); *self.last_model.lock().unwrap() = model.to_string(); - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } } @@ -229,7 +247,7 @@ mod tests { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.as_ref() .chat_with_system(system_prompt, message, model, temperature) .await @@ -246,8 +264,11 @@ mod tests { ], ); - let result = router.chat("hello", "hint:reasoning", 0.5).await.unwrap(); - assert_eq!(result.text_or_empty(), "smart-response"); + let result = router + .simple_chat("hello", "hint:reasoning", 0.5) + .await + .unwrap(); + assert_eq!(result, "smart-response"); assert_eq!(mocks[1].call_count(), 1); assert_eq!(mocks[1].last_model(), "claude-opus"); assert_eq!(mocks[0].call_count(), 0); @@ -260,8 +281,8 @@ mod tests { vec![("fast", "fast", "llama-3-70b")], ); - let result = router.chat("hello", "hint:fast", 0.5).await.unwrap(); - assert_eq!(result.text_or_empty(), "fast-response"); + let result = router.simple_chat("hello", "hint:fast", 0.5).await.unwrap(); + assert_eq!(result, "fast-response"); assert_eq!(mocks[0].call_count(), 1); assert_eq!(mocks[0].last_model(), "llama-3-70b"); } @@ -273,8 +294,11 @@ mod tests { vec![], ); - let result = router.chat("hello", "hint:nonexistent", 0.5).await.unwrap(); - assert_eq!(result.text_or_empty(), "default-response"); + let result = router + .simple_chat("hello", "hint:nonexistent", 0.5) + .await + .unwrap(); + assert_eq!(result, "default-response"); assert_eq!(mocks[0].call_count(), 1); // Falls back to default with the hint as model name assert_eq!(mocks[0].last_model(), "hint:nonexistent"); @@ -291,10 +315,10 @@ mod tests { ); let result = router - .chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5) + .simple_chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5) .await .unwrap(); - assert_eq!(result.text_or_empty(), "primary-response"); + assert_eq!(result, "primary-response"); assert_eq!(mocks[0].call_count(), 1); assert_eq!(mocks[0].last_model(), "anthropic/claude-sonnet-4-20250514"); } @@ -355,7 +379,7 @@ mod tests { .chat_with_system(Some("system"), "hello", "model", 0.5) .await .unwrap(); - assert_eq!(result.text_or_empty(), "response"); + assert_eq!(result, "response"); assert_eq!(mock.call_count(), 1); } } diff --git a/src/providers/traits.rs b/src/providers/traits.rs index d1f8dd1..2117e57 100644 --- a/src/providers/traits.rs +++ b/src/providers/traits.rs @@ -1,3 +1,4 @@ +use crate::tools::ToolSpec; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -29,6 +30,13 @@ impl ChatMessage { content: content.into(), } } + + pub fn tool(content: impl Into) -> Self { + Self { + role: "tool".into(), + content: content.into(), + } + } } /// A tool call requested by the LLM. @@ -49,14 +57,6 @@ pub struct ChatResponse { } impl ChatResponse { - /// Convenience: construct a plain text response with no tool calls. - pub fn with_text(text: impl Into) -> Self { - Self { - text: Some(text.into()), - tool_calls: vec![], - } - } - /// True when the LLM wants to invoke at least one tool. pub fn has_tool_calls(&self) -> bool { !self.tool_calls.is_empty() @@ -68,6 +68,13 @@ impl ChatResponse { } } +/// Request payload for provider chat calls. +#[derive(Debug, Clone, Copy)] +pub struct ChatRequest<'a> { + pub messages: &'a [ChatMessage], + pub tools: Option<&'a [ToolSpec]>, +} + /// A tool result to feed back to the LLM. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolResultMessage { @@ -77,7 +84,7 @@ pub struct ToolResultMessage { /// A message in a multi-turn conversation, including tool interactions. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] +#[serde(tag = "type", content = "data")] pub enum ConversationMessage { /// Regular chat message (system, user, assistant). Chat(ChatMessage), @@ -86,29 +93,35 @@ pub enum ConversationMessage { text: Option, tool_calls: Vec, }, - /// Result of a tool execution, fed back to the LLM. - ToolResult(ToolResultMessage), + /// Results of tool executions, fed back to the LLM. + ToolResults(Vec), } #[async_trait] pub trait Provider: Send + Sync { - async fn chat( + /// Simple one-shot chat (single user message, no explicit system prompt). + /// + /// This is the preferred API for non-agentic direct interactions. + async fn simple_chat( &self, message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.chat_with_system(None, message, model, temperature) .await } + /// One-shot chat with optional system prompt. + /// + /// Kept for compatibility and advanced one-shot prompting. async fn chat_with_system( &self, system_prompt: Option<&str>, message: &str, model: &str, temperature: f64, - ) -> anyhow::Result; + ) -> anyhow::Result; /// Multi-turn conversation. Default implementation extracts the last user /// message and delegates to `chat_with_system`. @@ -117,7 +130,7 @@ pub trait Provider: Send + Sync { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let system = messages .iter() .find(|m| m.role == "system") @@ -131,6 +144,27 @@ pub trait Provider: Send + Sync { .await } + /// Structured chat API for agent loop callers. + async fn chat( + &self, + request: ChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let text = self + .chat_with_history(request.messages, model, temperature) + .await?; + Ok(ChatResponse { + text: Some(text), + tool_calls: Vec::new(), + }) + } + + /// Whether provider supports native tool calls over API. + fn supports_native_tools(&self) -> bool { + false + } + /// Warm up the HTTP connection pool (TLS handshake, DNS, HTTP/2 setup). /// Default implementation is a no-op; providers with HTTP clients should override. async fn warmup(&self) -> anyhow::Result<()> { @@ -153,6 +187,9 @@ mod tests { let asst = ChatMessage::assistant("Hi there"); assert_eq!(asst.role, "assistant"); + + let tool = ChatMessage::tool("{}"); + assert_eq!(tool.role, "tool"); } #[test] @@ -194,11 +231,11 @@ mod tests { let json = serde_json::to_string(&chat).unwrap(); assert!(json.contains("\"type\":\"Chat\"")); - let tool_result = ConversationMessage::ToolResult(ToolResultMessage { + let tool_result = ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "1".into(), content: "done".into(), - }); + }]); let json = serde_json::to_string(&tool_result).unwrap(); - assert!(json.contains("\"type\":\"ToolResult\"")); + assert!(json.contains("\"type\":\"ToolResults\"")); } } diff --git a/src/rag/mod.rs b/src/rag/mod.rs new file mode 100644 index 0000000..cc98c5a --- /dev/null +++ b/src/rag/mod.rs @@ -0,0 +1,397 @@ +//! RAG pipeline for hardware datasheet retrieval. +//! +//! Supports: +//! - Markdown and text datasheets (always) +//! - PDF ingestion (with `rag-pdf` feature) +//! - Pin/alias tables (e.g. `red_led: 13`) for explicit lookup +//! - Keyword retrieval (default) or semantic search via embeddings (optional) + +use crate::memory::chunker; +use std::collections::HashMap; +use std::path::Path; + +/// A chunk of datasheet content with board metadata. +#[derive(Debug, Clone)] +pub struct DatasheetChunk { + /// Board this chunk applies to (e.g. "nucleo-f401re", "rpi-gpio"), or None for generic. + pub board: Option, + /// Source file path (for debugging). + pub source: String, + /// Chunk content. + pub content: String, +} + +/// Pin alias: human-readable name → pin number (e.g. "red_led" → 13). +pub type PinAliases = HashMap; + +/// Parse pin aliases from markdown. Looks for: +/// - `## Pin Aliases` section with `alias: pin` lines +/// - Markdown table `| alias | pin |` +fn parse_pin_aliases(content: &str) -> PinAliases { + let mut aliases = PinAliases::new(); + let content_lower = content.to_lowercase(); + + // Find ## Pin Aliases section + let section_markers = ["## pin aliases", "## pin alias", "## pins"]; + let mut in_section = false; + let mut section_start = 0; + + for marker in section_markers { + if let Some(pos) = content_lower.find(marker) { + in_section = true; + section_start = pos + marker.len(); + break; + } + } + + if !in_section { + return aliases; + } + + let rest = &content[section_start..]; + let section_end = rest + .find("\n## ") + .map(|i| section_start + i) + .unwrap_or(content.len()); + let section = &content[section_start..section_end]; + + // Parse "alias: pin" or "alias = pin" lines + for line in section.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Table row: | red_led | 13 | (skip header | alias | pin | and separator |---|) + if line.starts_with('|') { + let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect(); + if parts.len() >= 3 { + let alias = parts[1].trim().to_lowercase().replace(' ', "_"); + let pin_str = parts[2].trim(); + // Skip header row and separator (|---|) + if alias.eq("alias") + || alias.eq("pin") + || pin_str.eq("pin") + || alias.contains("---") + || pin_str.contains("---") + { + continue; + } + if let Ok(pin) = pin_str.parse::() { + if !alias.is_empty() { + aliases.insert(alias, pin); + } + } + } + continue; + } + // Key: value + if let Some((k, v)) = line.split_once(':').or_else(|| line.split_once('=')) { + let alias = k.trim().to_lowercase().replace(' ', "_"); + if let Ok(pin) = v.trim().parse::() { + if !alias.is_empty() { + aliases.insert(alias, pin); + } + } + } + } + + aliases +} + +fn collect_md_txt_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_txt_paths(&path, out); + } else if path.is_file() { + let ext = path.extension().and_then(|e| e.to_str()); + if ext == Some("md") || ext == Some("txt") { + out.push(path); + } + } + } +} + +#[cfg(feature = "rag-pdf")] +fn collect_pdf_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_pdf_paths(&path, out); + } else if path.is_file() { + if path.extension().and_then(|e| e.to_str()) == Some("pdf") { + out.push(path); + } + } + } +} + +#[cfg(feature = "rag-pdf")] +fn extract_pdf_text(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + pdf_extract::extract_text_from_mem(&bytes).ok() +} + +/// Hardware RAG index — loads and retrieves datasheet chunks. +pub struct HardwareRag { + chunks: Vec, + /// Per-board pin aliases (board -> alias -> pin). + pin_aliases: HashMap, +} + +impl HardwareRag { + /// Load datasheets from a directory. Expects .md, .txt, and optionally .pdf (with rag-pdf). + /// Filename (without extension) is used as board tag. + /// Supports `## Pin Aliases` section for explicit alias→pin mapping. + pub fn load(workspace_dir: &Path, datasheet_dir: &str) -> anyhow::Result { + let base = workspace_dir.join(datasheet_dir); + if !base.exists() || !base.is_dir() { + return Ok(Self { + chunks: Vec::new(), + pin_aliases: HashMap::new(), + }); + } + + let mut paths: Vec = Vec::new(); + collect_md_txt_paths(&base, &mut paths); + #[cfg(feature = "rag-pdf")] + collect_pdf_paths(&base, &mut paths); + + let mut chunks = Vec::new(); + let mut pin_aliases: HashMap = HashMap::new(); + let max_tokens = 512; + + for path in paths { + let content = if path.extension().and_then(|e| e.to_str()) == Some("pdf") { + #[cfg(feature = "rag-pdf")] + { + extract_pdf_text(&path).unwrap_or_default() + } + #[cfg(not(feature = "rag-pdf"))] + { + String::new() + } + } else { + std::fs::read_to_string(&path).unwrap_or_default() + }; + + if content.trim().is_empty() { + continue; + } + + let board = infer_board_from_path(&path, &base); + let source = path + .strip_prefix(workspace_dir) + .unwrap_or(&path) + .display() + .to_string(); + + // Parse pin aliases from full content + let aliases = parse_pin_aliases(&content); + if let Some(ref b) = board { + if !aliases.is_empty() { + pin_aliases.insert(b.clone(), aliases); + } + } + + for chunk in chunker::chunk_markdown(&content, max_tokens) { + chunks.push(DatasheetChunk { + board: board.clone(), + source: source.clone(), + content: chunk.content, + }); + } + } + + Ok(Self { + chunks, + pin_aliases, + }) + } + + /// Get pin aliases for a board (e.g. "red_led" -> 13). + pub fn pin_aliases_for_board(&self, board: &str) -> Option<&PinAliases> { + self.pin_aliases.get(board) + } + + /// Build pin-alias context for query. When user says "red led", inject "red_led: 13" for matching boards. + pub fn pin_alias_context(&self, query: &str, boards: &[String]) -> String { + let query_lower = query.to_lowercase(); + let query_words: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 1) + .collect(); + + let mut lines = Vec::new(); + for board in boards { + if let Some(aliases) = self.pin_aliases.get(board) { + for (alias, pin) in aliases { + let alias_words: Vec<&str> = alias.split('_').collect(); + let matches = query_words + .iter() + .any(|qw| alias_words.iter().any(|aw| *aw == *qw)) + || query_lower.contains(&alias.replace('_', " ")); + if matches { + lines.push(format!("{board}: {alias} = pin {pin}")); + } + } + } + } + if lines.is_empty() { + return String::new(); + } + format!("[Pin aliases for query]\n{}\n\n", lines.join("\n")) + } + + /// Retrieve chunks relevant to the query and boards. + /// Uses keyword matching and board filter. Pin-alias context is built separately via `pin_alias_context`. + pub fn retrieve(&self, query: &str, boards: &[String], limit: usize) -> Vec<&DatasheetChunk> { + if self.chunks.is_empty() || limit == 0 { + return Vec::new(); + } + + let query_lower = query.to_lowercase(); + let query_terms: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 2) + .collect(); + + let mut scored: Vec<(&DatasheetChunk, f32)> = Vec::new(); + for chunk in &self.chunks { + let content_lower = chunk.content.to_lowercase(); + let mut score = 0.0f32; + + for term in &query_terms { + if content_lower.contains(term) { + score += 1.0; + } + } + + if score > 0.0 { + let board_match = chunk.board.as_ref().map_or(false, |b| boards.contains(b)); + if board_match { + score += 2.0; + } + scored.push((chunk, score)); + } + } + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(limit); + scored.into_iter().map(|(c, _)| c).collect() + } + + /// Number of indexed chunks. + pub fn len(&self) -> usize { + self.chunks.len() + } + + /// True if no chunks are indexed. + pub fn is_empty(&self) -> bool { + self.chunks.is_empty() + } +} + +/// Infer board tag from file path. `nucleo-f401re.md` → Some("nucleo-f401re"). +fn infer_board_from_path(path: &Path, base: &Path) -> Option { + let rel = path.strip_prefix(base).ok()?; + let stem = path.file_stem()?.to_str()?; + + if stem == "generic" || stem.starts_with("generic_") { + return None; + } + if rel.parent().and_then(|p| p.to_str()) == Some("_generic") { + return None; + } + + Some(stem.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pin_aliases_key_value() { + let md = r#"## Pin Aliases +red_led: 13 +builtin_led: 13 +user_led: 5"#; + let a = parse_pin_aliases(md); + assert_eq!(a.get("red_led"), Some(&13)); + assert_eq!(a.get("builtin_led"), Some(&13)); + assert_eq!(a.get("user_led"), Some(&5)); + } + + #[test] + fn parse_pin_aliases_table() { + let md = r#"## Pin Aliases +| alias | pin | +|-------|-----| +| red_led | 13 | +| builtin_led | 13 |"#; + let a = parse_pin_aliases(md); + assert_eq!(a.get("red_led"), Some(&13)); + assert_eq!(a.get("builtin_led"), Some(&13)); + } + + #[test] + fn parse_pin_aliases_empty() { + let a = parse_pin_aliases("No aliases here"); + assert!(a.is_empty()); + } + + #[test] + fn infer_board_from_path_nucleo() { + let base = std::path::Path::new("/base"); + let path = std::path::Path::new("/base/nucleo-f401re.md"); + assert_eq!( + infer_board_from_path(path, base), + Some("nucleo-f401re".into()) + ); + } + + #[test] + fn infer_board_generic_none() { + let base = std::path::Path::new("/base"); + let path = std::path::Path::new("/base/generic.md"); + assert_eq!(infer_board_from_path(path, base), None); + } + + #[test] + fn hardware_rag_load_and_retrieve() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("datasheets"); + std::fs::create_dir_all(&base).unwrap(); + let content = r#"# Test Board +## Pin Aliases +red_led: 13 +## GPIO +Pin 13: LED +"#; + std::fs::write(base.join("test-board.md"), content).unwrap(); + + let rag = HardwareRag::load(tmp.path(), "datasheets").unwrap(); + assert!(!rag.is_empty()); + let boards = vec!["test-board".to_string()]; + let chunks = rag.retrieve("led", &boards, 5); + assert!(!chunks.is_empty()); + let ctx = rag.pin_alias_context("red led", &boards); + assert!(ctx.contains("13")); + } + + #[test] + fn hardware_rag_load_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("empty_ds"); + std::fs::create_dir_all(&base).unwrap(); + let rag = HardwareRag::load(tmp.path(), "empty_ds").unwrap(); + assert!(rag.is_empty()); + } +} diff --git a/src/security/audit.rs b/src/security/audit.rs index b7dabae..f18208f 100644 --- a/src/security/audit.rs +++ b/src/security/audit.rs @@ -150,6 +150,18 @@ pub struct AuditLogger { buffer: Mutex>, } +/// Structured command execution details for audit logging. +#[derive(Debug, Clone)] +pub struct CommandExecutionLog<'a> { + pub channel: &'a str, + pub command: &'a str, + pub risk_level: &'a str, + pub approved: bool, + pub allowed: bool, + pub success: bool, + pub duration_ms: u64, +} + impl AuditLogger { /// Create a new audit logger pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result { @@ -183,7 +195,23 @@ impl AuditLogger { Ok(()) } - /// Log a command execution event + /// Log a command execution event. + pub fn log_command_event(&self, entry: CommandExecutionLog<'_>) -> Result<()> { + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_actor(entry.channel.to_string(), None, None) + .with_action( + entry.command.to_string(), + entry.risk_level.to_string(), + entry.approved, + entry.allowed, + ) + .with_result(entry.success, None, entry.duration_ms, None); + + self.log(&event) + } + + /// Backward-compatible helper to log a command execution event. + #[allow(clippy::too_many_arguments)] pub fn log_command( &self, channel: &str, @@ -194,24 +222,22 @@ impl AuditLogger { success: bool, duration_ms: u64, ) -> Result<()> { - let event = AuditEvent::new(AuditEventType::CommandExecution) - .with_actor(channel.to_string(), None, None) - .with_action( - command.to_string(), - risk_level.to_string(), - approved, - allowed, - ) - .with_result(success, None, duration_ms, None); - - self.log(&event) + self.log_command_event(CommandExecutionLog { + channel, + command, + risk_level, + approved, + allowed, + success, + duration_ms, + }) } /// Rotate log if it exceeds max size fn rotate_if_needed(&self) -> Result<()> { if let Ok(metadata) = std::fs::metadata(&self.log_path) { let current_size_mb = metadata.len() / (1024 * 1024); - if current_size_mb >= self.config.max_size_mb as u64 { + if current_size_mb >= u64::from(self.config.max_size_mb) { self.rotate()?; } } @@ -283,7 +309,8 @@ mod tests { let json = serde_json::to_string(&event); assert!(json.is_ok()); - let parsed: AuditEvent = serde_json::from_str(&json.unwrap().as_str()).expect("parse"); + let json = json.expect("serialize"); + let parsed: AuditEvent = serde_json::from_str(json.as_str()).expect("parse"); assert!(parsed.actor.is_some()); assert!(parsed.action.is_some()); assert!(parsed.result.is_some()); diff --git a/src/tools/browser.rs b/src/tools/browser.rs index ec469d6..d138f09 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -3,18 +3,48 @@ //! By default this uses Vercel's `agent-browser` CLI for automation. //! Optionally, a Rust-native backend can be enabled at build time via //! `--features browser-native` and selected through config. +//! Computer-use (OS-level) actions are supported via an optional sidecar endpoint. use super::traits::{Tool, ToolResult}; use crate::security::SecurityPolicy; +use anyhow::Context; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::net::ToSocketAddrs; use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use tokio::process::Command; use tracing::debug; -/// Browser automation tool using agent-browser CLI +/// Computer-use sidecar settings. +#[derive(Debug, Clone)] +pub struct ComputerUseConfig { + pub endpoint: String, + pub api_key: Option, + pub timeout_ms: u64, + pub allow_remote_endpoint: bool, + pub window_allowlist: Vec, + pub max_coordinate_x: Option, + pub max_coordinate_y: Option, +} + +impl Default for ComputerUseConfig { + fn default() -> Self { + Self { + endpoint: "http://127.0.0.1:8787/v1/actions".into(), + api_key: None, + timeout_ms: 15_000, + allow_remote_endpoint: false, + window_allowlist: Vec::new(), + max_coordinate_x: None, + max_coordinate_y: None, + } + } +} + +/// Browser automation tool using pluggable backends. pub struct BrowserTool { security: Arc, allowed_domains: Vec, @@ -23,6 +53,7 @@ pub struct BrowserTool { native_headless: bool, native_webdriver_url: String, native_chrome_path: Option, + computer_use: ComputerUseConfig, #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex, } @@ -31,6 +62,7 @@ pub struct BrowserTool { enum BrowserBackendKind { AgentBrowser, RustNative, + ComputerUse, Auto, } @@ -38,6 +70,7 @@ enum BrowserBackendKind { enum ResolvedBackend { AgentBrowser, RustNative, + ComputerUse, } impl BrowserBackendKind { @@ -46,9 +79,10 @@ impl BrowserBackendKind { match key.as_str() { "agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser), "rust_native" | "native" => Ok(Self::RustNative), + "computer_use" | "computeruse" => Ok(Self::ComputerUse), "auto" => Ok(Self::Auto), _ => anyhow::bail!( - "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', or 'auto'" + "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'" ), } } @@ -57,6 +91,7 @@ impl BrowserBackendKind { match self { Self::AgentBrowser => "agent_browser", Self::RustNative => "rust_native", + Self::ComputerUse => "computer_use", Self::Auto => "auto", } } @@ -70,6 +105,17 @@ struct AgentBrowserResponse { error: Option, } +/// Response format from computer-use sidecar. +#[derive(Debug, Deserialize)] +struct ComputerUseResponse { + #[serde(default)] + success: Option, + #[serde(default)] + data: Option, + #[serde(default)] + error: Option, +} + /// Supported browser actions #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -151,9 +197,11 @@ impl BrowserTool { true, "http://127.0.0.1:9515".into(), None, + ComputerUseConfig::default(), ) } + #[allow(clippy::too_many_arguments)] pub fn new_with_backend( security: Arc, allowed_domains: Vec, @@ -162,6 +210,7 @@ impl BrowserTool { native_headless: bool, native_webdriver_url: String, native_chrome_path: Option, + computer_use: ComputerUseConfig, ) -> Self { Self { security, @@ -171,6 +220,7 @@ impl BrowserTool { native_headless, native_webdriver_url, native_chrome_path, + computer_use, #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()), } @@ -216,6 +266,52 @@ impl BrowserTool { } } + fn computer_use_endpoint_url(&self) -> anyhow::Result { + if self.computer_use.timeout_ms == 0 { + anyhow::bail!("browser.computer_use.timeout_ms must be > 0"); + } + + let endpoint = self.computer_use.endpoint.trim(); + if endpoint.is_empty() { + anyhow::bail!("browser.computer_use.endpoint cannot be empty"); + } + + let parsed = reqwest::Url::parse(endpoint).map_err(|_| { + anyhow::anyhow!( + "Invalid browser.computer_use.endpoint: '{endpoint}'. Expected http(s) URL" + ) + })?; + + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + anyhow::bail!("browser.computer_use.endpoint must use http:// or https://"); + } + + let host = parsed + .host_str() + .ok_or_else(|| anyhow::anyhow!("browser.computer_use.endpoint must include host"))?; + + let host_is_private = is_private_host(host); + if !self.computer_use.allow_remote_endpoint && !host_is_private { + anyhow::bail!( + "browser.computer_use.endpoint host '{host}' is public. Set browser.computer_use.allow_remote_endpoint=true to allow it" + ); + } + + if self.computer_use.allow_remote_endpoint && !host_is_private && scheme != "https" { + anyhow::bail!( + "browser.computer_use.endpoint must use https:// when allow_remote_endpoint=true and host is public" + ); + } + + Ok(parsed) + } + + fn computer_use_available(&self) -> anyhow::Result { + let endpoint = self.computer_use_endpoint_url()?; + Ok(endpoint_reachable(&endpoint, Duration::from_millis(500))) + } + async fn resolve_backend(&self) -> anyhow::Result { let configured = self.configured_backend()?; @@ -243,6 +339,14 @@ impl BrowserTool { } Ok(ResolvedBackend::RustNative) } + BrowserBackendKind::ComputerUse => { + if !self.computer_use_available()? { + anyhow::bail!( + "browser.backend='computer_use' but sidecar endpoint is unreachable. Check browser.computer_use.endpoint and sidecar status" + ); + } + Ok(ResolvedBackend::ComputerUse) + } BrowserBackendKind::Auto => { if Self::rust_native_compiled() && self.rust_native_available() { return Ok(ResolvedBackend::RustNative); @@ -251,14 +355,31 @@ impl BrowserTool { return Ok(ResolvedBackend::AgentBrowser); } + let computer_use_err = match self.computer_use_available() { + Ok(true) => return Ok(ResolvedBackend::ComputerUse), + Ok(false) => None, + Err(err) => Some(err.to_string()), + }; + if Self::rust_native_compiled() { + if let Some(err) = computer_use_err { + anyhow::bail!( + "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use invalid: {err})" + ); + } anyhow::bail!( - "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable)" + "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use sidecar unreachable)" ) } + if let Some(err) = computer_use_err { + anyhow::bail!( + "browser.backend='auto' needs agent-browser CLI, browser-native, or valid computer-use sidecar (error: {err})" + ); + } + anyhow::bail!( - "browser.backend='auto' needs agent-browser CLI, or build with --features browser-native" + "browser.backend='auto' needs agent-browser CLI, browser-native, or computer-use sidecar" ) } } @@ -272,9 +393,10 @@ impl BrowserTool { anyhow::bail!("URL cannot be empty"); } - // Allow file:// URLs for local testing + // Block file:// URLs — browser file access bypasses all SSRF and + // domain-allowlist controls and can exfiltrate arbitrary local files. if url.starts_with("file://") { - return Ok(()); + anyhow::bail!("file:// URLs are not allowed in browser automation"); } if !url.starts_with("https://") && !url.starts_with("http://") { @@ -523,6 +645,179 @@ impl BrowserTool { } } + fn validate_coordinate(&self, key: &str, value: i64, max: Option) -> anyhow::Result<()> { + if value < 0 { + anyhow::bail!("'{key}' must be >= 0") + } + if let Some(limit) = max { + if limit < 0 { + anyhow::bail!("Configured coordinate limit for '{key}' must be >= 0") + } + if value > limit { + anyhow::bail!("'{key}'={value} exceeds configured limit {limit}") + } + } + Ok(()) + } + + fn read_required_i64( + &self, + params: &serde_json::Map, + key: &str, + ) -> anyhow::Result { + params + .get(key) + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("Missing or invalid '{key}' parameter")) + } + + fn validate_computer_use_action( + &self, + action: &str, + params: &serde_json::Map, + ) -> anyhow::Result<()> { + match action { + "open" => { + let url = params + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; + self.validate_url(url)?; + } + "mouse_move" | "mouse_click" => { + let x = self.read_required_i64(params, "x")?; + let y = self.read_required_i64(params, "y")?; + self.validate_coordinate("x", x, self.computer_use.max_coordinate_x)?; + self.validate_coordinate("y", y, self.computer_use.max_coordinate_y)?; + } + "mouse_drag" => { + let from_x = self.read_required_i64(params, "from_x")?; + let from_y = self.read_required_i64(params, "from_y")?; + let to_x = self.read_required_i64(params, "to_x")?; + let to_y = self.read_required_i64(params, "to_y")?; + self.validate_coordinate("from_x", from_x, self.computer_use.max_coordinate_x)?; + self.validate_coordinate("to_x", to_x, self.computer_use.max_coordinate_x)?; + self.validate_coordinate("from_y", from_y, self.computer_use.max_coordinate_y)?; + self.validate_coordinate("to_y", to_y, self.computer_use.max_coordinate_y)?; + } + _ => {} + } + Ok(()) + } + + async fn execute_computer_use_action( + &self, + action: &str, + args: &Value, + ) -> anyhow::Result { + let endpoint = self.computer_use_endpoint_url()?; + + let mut params = args + .as_object() + .cloned() + .ok_or_else(|| anyhow::anyhow!("browser args must be a JSON object"))?; + params.remove("action"); + + self.validate_computer_use_action(action, ¶ms)?; + + let payload = json!({ + "action": action, + "params": params, + "policy": { + "allowed_domains": self.allowed_domains, + "window_allowlist": self.computer_use.window_allowlist, + "max_coordinate_x": self.computer_use.max_coordinate_x, + "max_coordinate_y": self.computer_use.max_coordinate_y, + }, + "metadata": { + "session_name": self.session_name, + "source": "zeroclaw.browser", + "version": env!("CARGO_PKG_VERSION"), + } + }); + + let client = reqwest::Client::new(); + let mut request = client + .post(endpoint) + .timeout(Duration::from_millis(self.computer_use.timeout_ms)) + .json(&payload); + + if let Some(api_key) = self.computer_use.api_key.as_deref() { + let token = api_key.trim(); + if !token.is_empty() { + request = request.bearer_auth(token); + } + } + + let response = request.send().await.with_context(|| { + format!( + "Failed to call computer-use sidecar at {}", + self.computer_use.endpoint + ) + })?; + + let status = response.status(); + let body = response + .text() + .await + .context("Failed to read computer-use sidecar response body")?; + + if let Ok(parsed) = serde_json::from_str::(&body) { + if status.is_success() && parsed.success.unwrap_or(true) { + let output = parsed + .data + .map(|data| serde_json::to_string_pretty(&data).unwrap_or_default()) + .unwrap_or_else(|| { + serde_json::to_string_pretty(&json!({ + "backend": "computer_use", + "action": action, + "ok": true, + })) + .unwrap_or_default() + }); + + return Ok(ToolResult { + success: true, + output, + error: None, + }); + } + + let error = parsed.error.or_else(|| { + if status.is_success() && parsed.success == Some(false) { + Some("computer-use sidecar returned success=false".to_string()) + } else { + Some(format!( + "computer-use sidecar request failed with status {status}" + )) + } + }); + + return Ok(ToolResult { + success: false, + output: String::new(), + error, + }); + } + + if status.is_success() { + return Ok(ToolResult { + success: true, + output: body, + error: None, + }); + } + + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "computer-use sidecar request failed with status {status}: {}", + body.trim() + )), + }) + } + async fn execute_action( &self, action: BrowserAction, @@ -531,6 +826,9 @@ impl BrowserTool { match backend { ResolvedBackend::AgentBrowser => self.execute_agent_browser_action(action).await, ResolvedBackend::RustNative => self.execute_rust_native_action(action).await, + ResolvedBackend::ComputerUse => anyhow::bail!( + "Internal error: computer_use backend must be handled before BrowserAction parsing" + ), } } @@ -564,10 +862,12 @@ impl Tool for BrowserTool { } fn description(&self) -> &str { - "Web browser automation with pluggable backends (agent-browser or rust-native). \ - Supports navigation, clicking, filling forms, screenshots, and page snapshots. \ - Use 'snapshot' to map interactive elements to refs (@e1, @e2), then use refs for \ - precise interaction. Enforces browser.allowed_domains for open actions." + concat!( + "Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). ", + "Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, ", + "key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map ", + "interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions." + ) } fn parameters_schema(&self) -> Value { @@ -578,8 +878,10 @@ impl Tool for BrowserTool { "type": "string", "enum": ["open", "snapshot", "click", "fill", "type", "get_text", "get_title", "get_url", "screenshot", "wait", "press", - "hover", "scroll", "is_visible", "close", "find"], - "description": "Browser action to perform" + "hover", "scroll", "is_visible", "close", "find", + "mouse_move", "mouse_click", "mouse_drag", "key_type", + "key_press", "screen_capture"], + "description": "Browser action to perform (OS-level actions require backend=computer_use)" }, "url": { "type": "string", @@ -601,6 +903,35 @@ impl Tool for BrowserTool { "type": "string", "description": "Key to press (Enter, Tab, Escape, etc.)" }, + "x": { + "type": "integer", + "description": "Screen X coordinate (computer_use: mouse_move/mouse_click)" + }, + "y": { + "type": "integer", + "description": "Screen Y coordinate (computer_use: mouse_move/mouse_click)" + }, + "from_x": { + "type": "integer", + "description": "Drag source X coordinate (computer_use: mouse_drag)" + }, + "from_y": { + "type": "integer", + "description": "Drag source Y coordinate (computer_use: mouse_drag)" + }, + "to_x": { + "type": "integer", + "description": "Drag target X coordinate (computer_use: mouse_drag)" + }, + "to_y": { + "type": "integer", + "description": "Drag target Y coordinate (computer_use: mouse_drag)" + }, + "button": { + "type": "string", + "enum": ["left", "right", "middle"], + "description": "Mouse button for computer_use mouse_click" + }, "direction": { "type": "string", "enum": ["up", "down", "left", "right"], @@ -688,6 +1019,18 @@ impl Tool for BrowserTool { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; + if !is_supported_browser_action(action_str) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Unknown action: {action_str}")), + }); + } + + if backend == ResolvedBackend::ComputerUse { + return self.execute_computer_use_action(action_str, &args).await; + } + let action = match action_str { "open" => { let url = args @@ -839,7 +1182,14 @@ impl Tool for BrowserTool { return Ok(ToolResult { success: false, output: String::new(), - error: Some(format!("Unknown action: {action_str}")), + error: Some(format!( + "Action '{action_str}' is unavailable for backend '{}'", + match backend { + ResolvedBackend::AgentBrowser => "agent_browser", + ResolvedBackend::RustNative => "rust_native", + ResolvedBackend::ComputerUse => "computer_use", + } + )), }); } }; @@ -1523,6 +1873,34 @@ mod native_backend { // ── Helper functions ───────────────────────────────────────────── +fn is_supported_browser_action(action: &str) -> bool { + matches!( + action, + "open" + | "snapshot" + | "click" + | "fill" + | "type" + | "get_text" + | "get_title" + | "get_url" + | "screenshot" + | "wait" + | "press" + | "hover" + | "scroll" + | "is_visible" + | "close" + | "find" + | "mouse_move" + | "mouse_click" + | "mouse_drag" + | "key_type" + | "key_press" + | "screen_capture" + ) +} + fn normalize_domains(domains: Vec) -> Vec { domains .into_iter() @@ -1531,6 +1909,30 @@ fn normalize_domains(domains: Vec) -> Vec { .collect() } +fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool { + let host = match endpoint.host_str() { + Some(host) if !host.is_empty() => host, + _ => return false, + }; + + let port = match endpoint.port_or_known_default() { + Some(port) => port, + None => return false, + }; + + let mut addrs = match (host, port).to_socket_addrs() { + Ok(addrs) => addrs, + Err(_) => return false, + }; + + let addr = match addrs.next() { + Some(addr) => addr, + None => return false, + }; + + std::net::TcpStream::connect_timeout(&addr, timeout).is_ok() +} + fn extract_host(url_str: &str) -> anyhow::Result { // Simple host extraction without url crate let url = url_str.trim(); @@ -1565,49 +1967,63 @@ fn is_private_host(host: &str) -> bool { .and_then(|h| h.strip_suffix(']')) .unwrap_or(host); - if bare == "localhost" { + if bare == "localhost" || bare.ends_with(".localhost") { + return true; + } + + // .local TLD (mDNS) + if bare + .rsplit('.') + .next() + .is_some_and(|label| label == "local") + { return true; } // Parse as IP address to catch all representations (decimal, hex, octal, mapped) if let Ok(ip) = bare.parse::() { return match ip { - std::net::IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - } - std::net::IpAddr::V6(v6) => { - let segs = v6.segments(); - v6.is_loopback() - || v6.is_unspecified() - // Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918 - || (segs[0] & 0xfe00) == 0xfc00 - // Link-local (fe80::/10) - || (segs[0] & 0xffc0) == 0xfe80 - // IPv4-mapped addresses (::ffff:127.0.0.1) - || v6.to_ipv4_mapped().is_some_and(|v4| { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - }) - } + std::net::IpAddr::V4(v4) => is_non_global_v4(v4), + std::net::IpAddr::V6(v6) => is_non_global_v6(v6), }; } - // Fallback string patterns for hostnames that look like IPs but don't parse - // (e.g., partial addresses used in DNS names). - let string_patterns = [ - "127.", "10.", "192.168.", "0.0.0.0", "172.16.", "172.17.", "172.18.", "172.19.", - "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", - "172.28.", "172.29.", "172.30.", "172.31.", - ]; + false +} - string_patterns.iter().any(|p| bare.starts_with(p)) +/// Returns `true` for any IPv4 address that is not globally routable. +fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { + let [a, b, _, _] = v4.octets(); + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + || v4.is_multicast() + // Shared address space (100.64/10) + || (a == 100 && (64..=127).contains(&b)) + // Reserved (240.0.0.0/4) + || a >= 240 + // Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) + || (a == 192 && b == 0) + || (a == 198 && b == 51) + || (a == 203 && b == 0) + // Benchmarking (198.18.0.0/15) + || (a == 198 && (18..=19).contains(&b)) +} + +/// Returns `true` for any IPv6 address that is not globally routable. +fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { + let segs = v6.segments(); + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + // Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918 + || (segs[0] & 0xfe00) == 0xfc00 + // Link-local (fe80::/10) + || (segs[0] & 0xffc0) == 0xfe80 + // IPv4-mapped addresses + || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool { @@ -1669,6 +2085,8 @@ mod tests { #[test] fn is_private_host_detects_local() { assert!(is_private_host("localhost")); + assert!(is_private_host("app.localhost")); + assert!(is_private_host("printer.local")); assert!(is_private_host("127.0.0.1")); assert!(is_private_host("192.168.1.1")); assert!(is_private_host("10.0.0.1")); @@ -1676,6 +2094,18 @@ mod tests { assert!(!is_private_host("google.com")); } + #[test] + fn is_private_host_blocks_multicast_and_reserved() { + assert!(is_private_host("224.0.0.1")); // multicast + assert!(is_private_host("255.255.255.255")); // broadcast + assert!(is_private_host("100.64.0.1")); // shared address space + assert!(is_private_host("240.0.0.1")); // reserved + assert!(is_private_host("192.0.2.1")); // documentation + assert!(is_private_host("198.51.100.1")); // documentation + assert!(is_private_host("203.0.113.1")); // documentation + assert!(is_private_host("198.18.0.1")); // benchmarking + } + #[test] fn is_private_host_catches_ipv6() { assert!(is_private_host("::1")); @@ -1746,6 +2176,10 @@ mod tests { BrowserBackendKind::parse("rust-native").unwrap(), BrowserBackendKind::RustNative ); + assert_eq!( + BrowserBackendKind::parse("computer_use").unwrap(), + BrowserBackendKind::ComputerUse + ); assert_eq!( BrowserBackendKind::parse("auto").unwrap(), BrowserBackendKind::Auto @@ -1778,10 +2212,100 @@ mod tests { true, "http://127.0.0.1:9515".into(), None, + ComputerUseConfig::default(), ); assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto); } + #[test] + fn browser_tool_accepts_computer_use_backend_config() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig::default(), + ); + assert_eq!( + tool.configured_backend().unwrap(), + BrowserBackendKind::ComputerUse + ); + } + + #[test] + fn computer_use_endpoint_rejects_public_http_by_default() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig { + endpoint: "http://computer-use.example.com/v1/actions".into(), + ..ComputerUseConfig::default() + }, + ); + + assert!(tool.computer_use_endpoint_url().is_err()); + } + + #[test] + fn computer_use_endpoint_requires_https_for_public_remote() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig { + endpoint: "https://computer-use.example.com/v1/actions".into(), + allow_remote_endpoint: true, + ..ComputerUseConfig::default() + }, + ); + + assert!(tool.computer_use_endpoint_url().is_ok()); + } + + #[test] + fn computer_use_coordinate_validation_applies_limits() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig { + max_coordinate_x: Some(100), + max_coordinate_y: Some(100), + ..ComputerUseConfig::default() + }, + ); + + assert!(tool + .validate_coordinate("x", 50, tool.computer_use.max_coordinate_x) + .is_ok()); + assert!(tool + .validate_coordinate("x", 101, tool.computer_use.max_coordinate_x) + .is_err()); + assert!(tool + .validate_coordinate("y", -1, tool.computer_use.max_coordinate_y) + .is_err()); + } + #[test] fn browser_tool_name() { let security = Arc::new(SecurityPolicy::default()); @@ -1808,8 +2332,8 @@ mod tests { // Invalid - not https assert!(tool.validate_url("ftp://example.com").is_err()); - // File URLs allowed - assert!(tool.validate_url("file:///tmp/test.html").is_ok()); + // file:// URLs blocked (local file exfiltration risk) + assert!(tool.validate_url("file:///tmp/test.html").is_err()); } #[test] diff --git a/src/tools/composio.rs b/src/tools/composio.rs index b010240..4e608cb 100644 --- a/src/tools/composio.rs +++ b/src/tools/composio.rs @@ -902,8 +902,8 @@ mod tests { let json_str = r#"{"name": "GMAIL_SEND_EMAIL_WITH_ATTACHMENT", "appName": "gmail", "description": "Send email with attachment & special chars: <>'\"\"", "enabled": true}"#; let action: ComposioAction = serde_json::from_str(json_str).unwrap(); assert_eq!(action.name, "GMAIL_SEND_EMAIL_WITH_ATTACHMENT"); - assert!(action.description.as_ref().unwrap().contains("&")); - assert!(action.description.as_ref().unwrap().contains("<")); + assert!(action.description.as_ref().unwrap().contains('&')); + assert!(action.description.as_ref().unwrap().contains('<')); } #[test] diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index f205a58..7f30b64 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -221,14 +221,9 @@ impl Tool for DelegateTool { match result { Ok(response) => { - let has_tool_calls = response.has_tool_calls(); - let mut rendered = response.text.unwrap_or_default(); + let mut rendered = response; if rendered.trim().is_empty() { - if has_tool_calls { - rendered = "[Tool-only response; no text content]".to_string(); - } else { - rendered = "[Empty response]".to_string(); - } + rendered = "[Empty response]".to_string(); } Ok(ToolResult { diff --git a/src/tools/git_operations.rs b/src/tools/git_operations.rs index e20113a..a9461fc 100644 --- a/src/tools/git_operations.rs +++ b/src/tools/git_operations.rs @@ -2,6 +2,8 @@ use super::traits::{Tool, ToolResult}; use crate::security::{AutonomyLevel, SecurityPolicy}; use async_trait::async_trait; use serde_json::json; +#[cfg(test)] +use std::path::Path; use std::sync::Arc; /// Git operations tool for structured repository management. @@ -29,7 +31,7 @@ impl GitOperationsTool { || arg_lower.starts_with("--upload-pack=") || arg_lower.starts_with("--receive-pack=") || arg_lower.contains("$(") - || arg_lower.contains("`") + || arg_lower.contains('`') || arg.contains('|') || arg.contains(';') { @@ -88,10 +90,8 @@ impl GitOperationsTool { branch = line.trim_start_matches("# branch.head ").to_string(); } else if let Some(rest) = line.strip_prefix("1 ") { // Ordinary changed entry - let parts: Vec<&str> = rest.split(' ').collect(); - if parts.len() >= 2 { - let path = parts.get(1).unwrap_or(&""); - let staging = parts.get(0).unwrap_or(&""); + let mut parts = rest.splitn(3, ' '); + if let (Some(staging), Some(path)) = (parts.next(), parts.next()) { if !staging.is_empty() { let status_char = staging.chars().next().unwrap_or(' '); if status_char != '.' && status_char != ' ' { @@ -201,7 +201,8 @@ impl GitOperationsTool { } async fn git_log(&self, args: serde_json::Value) -> anyhow::Result { - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10); + let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000); let limit_str = limit.to_string(); let output = self @@ -381,7 +382,9 @@ impl GitOperationsTool { "pop" => self.run_git_command(&["stash", "pop"]).await, "list" => self.run_git_command(&["stash", "list"]).await, "drop" => { - let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let index = i32::try_from(index_raw) + .map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?; self.run_git_command(&["stash", "drop", &format!("stash@{{{index}}}")]) .await } @@ -514,12 +517,7 @@ impl Tool for GitOperationsTool { error: Some("Action blocked: read-only mode".into()), }); } - AutonomyLevel::Supervised => { - // Allow but require tracking - } - AutonomyLevel::Full => { - // Allow freely - } + AutonomyLevel::Supervised | AutonomyLevel::Full => {} } } diff --git a/src/tools/hardware_board_info.rs b/src/tools/hardware_board_info.rs new file mode 100644 index 0000000..f7af262 --- /dev/null +++ b/src/tools/hardware_board_info.rs @@ -0,0 +1,205 @@ +//! Hardware board info tool — returns chip name, architecture, memory map for Telegram/agent. +//! +//! Use when user asks "what board do I have?", "board info", "connected hardware", etc. +//! Uses probe-rs for Nucleo when available; otherwise static datasheet info. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Static board info (datasheets). Used when probe-rs is unavailable. +const BOARD_INFO: &[(&str, &str, &str)] = &[ + ( + "nucleo-f401re", + "STM32F401RET6", + "ARM Cortex-M4, 84 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).", + ), + ( + "nucleo-f411re", + "STM32F411RET6", + "ARM Cortex-M4, 100 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).", + ), + ( + "arduino-uno", + "ATmega328P", + "8-bit AVR, 16 MHz. Flash: 16 KB, SRAM: 2 KB. Built-in LED on pin 13.", + ), + ( + "arduino-uno-q", + "STM32U585 + Qualcomm", + "Dual-core: STM32 (MCU) + Linux (aarch64). GPIO via Bridge app on port 9999.", + ), + ( + "esp32", + "ESP32", + "Dual-core Xtensa LX6, 240 MHz. Flash: 4 MB typical. Built-in LED on GPIO 2.", + ), + ( + "rpi-gpio", + "Raspberry Pi", + "ARM Linux. Native GPIO via sysfs/rppal. No fixed LED pin.", + ), +]; + +/// Tool: return full board info (chip, architecture, memory map) for agent/Telegram. +pub struct HardwareBoardInfoTool { + boards: Vec, +} + +impl HardwareBoardInfoTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn static_info_for_board(&self, board: &str) -> Option { + BOARD_INFO + .iter() + .find(|(b, _, _)| *b == board) + .map(|(_, chip, desc)| { + format!( + "**Board:** {}\n**Chip:** {}\n**Description:** {}", + board, chip, desc + ) + }) + } +} + +#[async_trait] +impl Tool for HardwareBoardInfoTool { + fn name(&self) -> &str { + "hardware_board_info" + } + + fn description(&self) -> &str { + "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name (e.g. nucleo-f401re). If omitted, returns info for first configured board." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()); + + let board = board.as_deref().unwrap_or("unknown"); + + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add boards to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let mut output = String::new(); + + #[cfg(feature = "probe")] + if board == "nucleo-f401re" || board == "nucleo-f411re" { + let chip = if board == "nucleo-f411re" { + "STM32F411RETx" + } else { + "STM32F401RETx" + }; + match probe_board_info(chip) { + Ok(info) => { + return Ok(ToolResult { + success: true, + output: info, + error: None, + }); + } + Err(e) => { + output.push_str(&format!( + "probe-rs attach failed: {}. Using static info.\n\n", + e + )); + } + } + } + + if let Some(info) = self.static_info_for_board(board) { + output.push_str(&info); + if let Some(mem) = memory_map_static(board) { + output.push_str(&format!("\n\n**Memory map:**\n{}", mem)); + } + } else { + output.push_str(&format!( + "Board '{}' configured. No static info available.", + board + )); + } + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +#[cfg(feature = "probe")] +fn probe_board_info(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let target = session.target(); + let arch = session.architecture(); + + let mut out = format!( + "**Board:** {}\n**Chip:** {}\n**Architecture:** {:?}\n\n**Memory map:**\n", + chip, target.name, arch + ); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let (start, end) = (ram.range.start, ram.range.end); + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + MemoryRegion::Nvm(flash) => { + let (start, end) = (flash.range.start, flash.range.end); + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + _ => {} + } + } + out.push_str("\n(Info read via USB/SWD — no firmware on target needed.)"); + Ok(out) +} + +fn memory_map_static(board: &str) -> Option<&'static str> { + match board { + "nucleo-f401re" | "nucleo-f411re" => Some( + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)", + ), + "arduino-uno" => Some("Flash: 16 KB, SRAM: 2 KB, EEPROM: 1 KB"), + "esp32" => Some("Flash: 4 MB, IRAM/DRAM per ESP-IDF layout"), + _ => None, + } +} diff --git a/src/tools/hardware_memory_map.rs b/src/tools/hardware_memory_map.rs new file mode 100644 index 0000000..bdb4f96 --- /dev/null +++ b/src/tools/hardware_memory_map.rs @@ -0,0 +1,205 @@ +//! Hardware memory map tool — returns flash/RAM address ranges for connected boards. +//! +//! Phase B: When user asks "what are the upper and lower memory addresses?", this tool +//! returns the memory map. Uses probe-rs for Nucleo/STM32 when available; otherwise +//! returns static maps from datasheets. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Known memory maps (from datasheets). Used when probe-rs is unavailable. +const MEMORY_MAPS: &[(&str, &str)] = &[ + ( + "nucleo-f401re", + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F401RET6, ARM Cortex-M4", + ), + ( + "nucleo-f411re", + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F411RET6, ARM Cortex-M4", + ), + ( + "arduino-uno", + "Flash: 0x0000 - 0x3FFF (16 KB, ATmega328P)\nSRAM: 0x0100 - 0x08FF (2 KB)\nEEPROM: 0x0000 - 0x03FF (1 KB)", + ), + ( + "arduino-mega", + "Flash: 0x0000 - 0x3FFFF (256 KB, ATmega2560)\nSRAM: 0x0200 - 0x21FF (8 KB)\nEEPROM: 0x0000 - 0x0FFF (4 KB)", + ), + ( + "esp32", + "Flash: 0x3F40_0000 - 0x3F7F_FFFF (4 MB typical)\nIRAM: 0x4000_0000 - 0x4005_FFFF\nDRAM: 0x3FFB_0000 - 0x3FFF_FFFF", + ), +]; + +/// Tool: report hardware memory map for connected boards. +pub struct HardwareMemoryMapTool { + boards: Vec, +} + +impl HardwareMemoryMapTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn static_map_for_board(&self, board: &str) -> Option<&'static str> { + MEMORY_MAPS + .iter() + .find(|(b, _)| *b == board) + .map(|(_, m)| *m) + } +} + +#[async_trait] +impl Tool for HardwareMemoryMapTool { + fn name(&self) -> &str { + "hardware_memory_map" + } + + fn description(&self) -> &str { + "Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name (e.g. nucleo-f401re, arduino-uno). If omitted, returns map for first configured board." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()); + + let board = board.as_deref().unwrap_or("unknown"); + + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add boards to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let mut output = String::new(); + + #[cfg(feature = "probe")] + let probe_ok = { + if board == "nucleo-f401re" || board == "nucleo-f411re" { + let chip = if board == "nucleo-f411re" { + "STM32F411RETx" + } else { + "STM32F401RETx" + }; + match probe_rs_memory_map(chip) { + Ok(probe_msg) => { + output.push_str(&format!("**{}** (via probe-rs):\n{}\n", board, probe_msg)); + true + } + Err(e) => { + output.push_str(&format!("Probe-rs failed: {}. ", e)); + false + } + } + } else { + false + } + }; + + #[cfg(not(feature = "probe"))] + let probe_ok = false; + + if !probe_ok { + if let Some(map) = self.static_map_for_board(board) { + output.push_str(&format!("**{}** (from datasheet):\n{}", board, map)); + } else { + let known: Vec<&str> = MEMORY_MAPS.iter().map(|(b, _)| *b).collect(); + output.push_str(&format!( + "No memory map for board '{}'. Known boards: {}", + board, + known.join(", ") + )); + } + } + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +#[cfg(feature = "probe")] +fn probe_rs_memory_map(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("probe-rs attach failed: {}", e))?; + + let target = session.target(); + let mut out = String::new(); + + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let start = ram.range.start; + let end = ram.range.end; + let size_kb = (end - start) / 1024; + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, end, size_kb + )); + } + MemoryRegion::Nvm(flash) => { + let start = flash.range.start; + let end = flash.range.end; + let size_kb = (end - start) / 1024; + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, end, size_kb + )); + } + _ => {} + } + } + + if out.is_empty() { + out = "Could not read memory regions from probe.".to_string(); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_map_nucleo() { + let tool = HardwareMemoryMapTool::new(vec!["nucleo-f401re".into()]); + assert!(tool.static_map_for_board("nucleo-f401re").is_some()); + assert!(tool + .static_map_for_board("nucleo-f401re") + .unwrap() + .contains("Flash")); + } + + #[test] + fn static_map_arduino() { + let tool = HardwareMemoryMapTool::new(vec!["arduino-uno".into()]); + assert!(tool.static_map_for_board("arduino-uno").is_some()); + } +} diff --git a/src/tools/hardware_memory_read.rs b/src/tools/hardware_memory_read.rs new file mode 100644 index 0000000..4cc42d5 --- /dev/null +++ b/src/tools/hardware_memory_read.rs @@ -0,0 +1,181 @@ +//! Hardware memory read tool — read actual memory/register values from Nucleo via probe-rs. +//! +//! Use when user asks to "read register values", "read memory at address", "dump lower memory", etc. +//! Requires probe feature and Nucleo connected via USB. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// RAM base for Nucleo-F401RE (STM32F401) +const NUCLEO_RAM_BASE: u64 = 0x2000_0000; + +/// Tool: read memory at address from connected Nucleo via probe-rs. +pub struct HardwareMemoryReadTool { + boards: Vec, +} + +impl HardwareMemoryReadTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn chip_for_board(board: &str) -> Option<&'static str> { + match board { + "nucleo-f401re" => Some("STM32F401RETx"), + "nucleo-f411re" => Some("STM32F411RETx"), + _ => None, + } + } +} + +#[async_trait] +impl Tool for HardwareMemoryReadTool { + fn name(&self) -> &str { + "hardware_memory_read" + } + + fn description(&self) -> &str { + "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Memory address in hex (e.g. 0x20000000 for RAM start). Default: 0x20000000 (RAM base)." + }, + "length": { + "type": "integer", + "description": "Number of bytes to read (default 128, max 256)." + }, + "board": { + "type": "string", + "description": "Board name (nucleo-f401re). Optional if only one configured." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add nucleo-f401re to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()) + .unwrap_or_else(|| "nucleo-f401re".into()); + + let chip = Self::chip_for_board(&board); + if chip.is_none() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Memory read only supports nucleo-f401re, nucleo-f411re. Got: {}", + board + )), + }); + } + + let address_str = args + .get("address") + .and_then(|v| v.as_str()) + .unwrap_or("0x20000000"); + let address = parse_hex_address(address_str).unwrap_or(NUCLEO_RAM_BASE); + + let length = args.get("length").and_then(|v| v.as_u64()).unwrap_or(128) as usize; + let length = length.min(256).max(1); + + #[cfg(feature = "probe")] + { + match probe_read_memory(chip.unwrap(), address, length) { + Ok(output) => { + return Ok(ToolResult { + success: true, + output, + error: None, + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "probe-rs read failed: {}. Ensure Nucleo is connected via USB and built with --features probe.", + e + )), + }); + } + } + } + + #[cfg(not(feature = "probe"))] + { + Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "Memory read requires probe feature. Build with: cargo build --features hardware,probe" + .into(), + ), + }) + } + } +} + +fn parse_hex_address(s: &str) -> Option { + let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); + u64::from_str_radix(s, 16).ok() +} + +#[cfg(feature = "probe")] +fn probe_read_memory(chip: &str, address: u64, length: usize) -> anyhow::Result { + use probe_rs::MemoryInterface; + use probe_rs::Session; + use probe_rs::SessionConfig; + + let mut session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let mut core = session.core(0)?; + let mut buf = vec![0u8; length]; + core.read_8(address, &mut buf) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Format as hex dump: address | bytes (16 per line) + let mut out = format!("Memory read from 0x{:08X} ({} bytes):\n\n", address, length); + const COLS: usize = 16; + for (i, chunk) in buf.chunks(COLS).enumerate() { + let addr = address + (i * COLS) as u64; + let hex: String = chunk + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + let ascii: String = chunk + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + out.push_str(&format!("0x{:08X} {:48} {}\n", addr, hex, ascii)); + } + Ok(out) +} diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 43b05ac..450bde5 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -377,39 +377,58 @@ fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { } fn is_private_or_local_host(host: &str) -> bool { - let has_local_tld = host + // Strip brackets from IPv6 addresses like [::1] + let bare = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + + let has_local_tld = bare .rsplit('.') .next() .is_some_and(|label| label == "local"); - if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" { + if bare == "localhost" || bare.ends_with(".localhost") || has_local_tld { return true; } - if let Some([a, b, _, _]) = parse_ipv4(host) { - return a == 0 - || a == 10 - || a == 127 - || (a == 169 && b == 254) - || (a == 172 && (16..=31).contains(&b)) - || (a == 192 && b == 168) - || (a == 100 && (64..=127).contains(&b)); + if let Ok(ip) = bare.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => is_non_global_v4(v4), + std::net::IpAddr::V6(v6) => is_non_global_v6(v6), + }; } false } -fn parse_ipv4(host: &str) -> Option<[u8; 4]> { - let parts: Vec<&str> = host.split('.').collect(); - if parts.len() != 4 { - return None; - } +/// Returns true if the IPv4 address is not globally routable. +fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { + let [a, b, c, _] = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + || v4.is_multicast() // 224.0.0.0/4 + || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598) + || a >= 240 // Reserved (240.0.0.0/4, except broadcast) + || (a == 192 && b == 0 && (c == 0 || c == 2)) // IETF assignments + TEST-NET-1 + || (a == 198 && b == 51) // Documentation (198.51.100.0/24) + || (a == 203 && b == 0) // Documentation (203.0.113.0/24) + || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15) +} - let mut octets = [0_u8; 4]; - for (i, part) in parts.iter().enumerate() { - octets[i] = part.parse::().ok()?; - } - Some(octets) +/// Returns true if the IPv6 address is not globally routable. +fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { + let segs = v6.segments(); + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + || v6.is_multicast() // ff00::/8 + || (segs[0] & 0xfe00) == 0xfc00 // Unique-local (fc00::/7) + || (segs[0] & 0xffc0) == 0xfe80 // Link-local (fe80::/10) + || (segs[0] == 0x2001 && segs[1] == 0x0db8) // Documentation (2001:db8::/32) + || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } #[cfg(test)] @@ -546,15 +565,86 @@ mod tests { } #[test] - fn parse_ipv4_valid() { - assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4])); + fn blocks_multicast_ipv4() { + assert!(is_private_or_local_host("224.0.0.1")); + assert!(is_private_or_local_host("239.255.255.255")); } #[test] - fn parse_ipv4_invalid() { - assert_eq!(parse_ipv4("1.2.3"), None); - assert_eq!(parse_ipv4("1.2.3.999"), None); - assert_eq!(parse_ipv4("not-an-ip"), None); + fn blocks_broadcast() { + assert!(is_private_or_local_host("255.255.255.255")); + } + + #[test] + fn blocks_reserved_ipv4() { + assert!(is_private_or_local_host("240.0.0.1")); + assert!(is_private_or_local_host("250.1.2.3")); + } + + #[test] + fn blocks_documentation_ranges() { + assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 + assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2 + assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 + } + + #[test] + fn blocks_benchmarking_range() { + assert!(is_private_or_local_host("198.18.0.1")); + assert!(is_private_or_local_host("198.19.255.255")); + } + + #[test] + fn blocks_ipv6_localhost() { + assert!(is_private_or_local_host("::1")); + assert!(is_private_or_local_host("[::1]")); + } + + #[test] + fn blocks_ipv6_multicast() { + assert!(is_private_or_local_host("ff02::1")); + } + + #[test] + fn blocks_ipv6_link_local() { + assert!(is_private_or_local_host("fe80::1")); + } + + #[test] + fn blocks_ipv6_unique_local() { + assert!(is_private_or_local_host("fd00::1")); + } + + #[test] + fn blocks_ipv4_mapped_ipv6() { + assert!(is_private_or_local_host("::ffff:127.0.0.1")); + assert!(is_private_or_local_host("::ffff:192.168.1.1")); + assert!(is_private_or_local_host("::ffff:10.0.0.1")); + } + + #[test] + fn allows_public_ipv4() { + assert!(!is_private_or_local_host("8.8.8.8")); + assert!(!is_private_or_local_host("1.1.1.1")); + assert!(!is_private_or_local_host("93.184.216.34")); + } + + #[test] + fn blocks_ipv6_documentation_range() { + assert!(is_private_or_local_host("2001:db8::1")); + } + + #[test] + fn allows_public_ipv6() { + assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); + } + + #[test] + fn blocks_shared_address_space() { + assert!(is_private_or_local_host("100.64.0.1")); + assert!(is_private_or_local_host("100.127.255.255")); + assert!(!is_private_or_local_host("100.63.0.1")); // Just below range + assert!(!is_private_or_local_host("100.128.0.1")); // Just above range } #[tokio::test] diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 964ba5b..fcf8fa5 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -5,6 +5,9 @@ pub mod delegate; pub mod file_read; pub mod file_write; pub mod git_operations; +pub mod hardware_board_info; +pub mod hardware_memory_map; +pub mod hardware_memory_read; pub mod http_request; pub mod image_info; pub mod memory_forget; @@ -15,13 +18,16 @@ pub mod screenshot; pub mod shell; pub mod traits; -pub use browser::BrowserTool; +pub use browser::{BrowserTool, ComputerUseConfig}; pub use browser_open::BrowserOpenTool; pub use composio::ComposioTool; pub use delegate::DelegateTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; pub use git_operations::GitOperationsTool; +pub use hardware_board_info::HardwareBoardInfoTool; +pub use hardware_memory_map::HardwareMemoryMapTool; +pub use hardware_memory_read::HardwareMemoryReadTool; pub use http_request::HttpRequestTool; pub use image_info::ImageInfoTool; pub use memory_forget::MemoryForgetTool; @@ -131,6 +137,15 @@ pub fn all_tools_with_runtime( browser_config.native_headless, browser_config.native_webdriver_url.clone(), browser_config.native_chrome_path.clone(), + ComputerUseConfig { + endpoint: browser_config.computer_use.endpoint.clone(), + api_key: browser_config.computer_use.api_key.clone(), + timeout_ms: browser_config.computer_use.timeout_ms, + allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint, + window_allowlist: browser_config.computer_use.window_allowlist.clone(), + max_coordinate_x: browser_config.computer_use.max_coordinate_x, + max_coordinate_y: browser_config.computer_use.max_coordinate_y, + }, ))); } @@ -155,8 +170,12 @@ pub fn all_tools_with_runtime( // Add delegation tool when agents are configured if !agents.is_empty() { + let delegate_agents: HashMap = agents + .iter() + .map(|(name, cfg)| (name.clone(), cfg.clone())) + .collect(); tools.push(Box::new(DelegateTool::new( - agents.clone(), + delegate_agents, fallback_api_key.map(String::from), ))); }