commit 05cb353f7f5318e58bd229c03c8577216b30b231 Author: argenis de la rosa Date: Fri Feb 13 12:19:14 2026 -0500 feat: initial release — ZeroClaw v0.1.0 - 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..920fdfa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test --verbose + + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: zeroclaw-${{ matrix.target }} + path: target/${{ matrix.target }}/release/zeroclaw* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4a2b071 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build-release: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: zeroclaw + - os: macos-latest + target: x86_64-apple-darwin + artifact: zeroclaw + - os: macos-latest + target: aarch64-apple-darwin + artifact: zeroclaw + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: zeroclaw.exe + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + + - name: Check binary size (Unix) + if: runner.os != 'Windows' + run: | + SIZE=$(stat -f%z target/${{ matrix.target }}/release/${{ matrix.artifact }} 2>/dev/null || stat -c%s target/${{ matrix.target }}/release/${{ matrix.artifact }}) + echo "Binary size: $((SIZE / 1024 / 1024))MB ($SIZE bytes)" + if [ "$SIZE" -gt 5242880 ]; then + echo "::warning::Binary exceeds 5MB target" + fi + + - name: Package (Unix) + if: runner.os != 'Windows' + run: | + cd target/${{ matrix.target }}/release + tar czf ../../../zeroclaw-${{ matrix.target }}.tar.gz ${{ matrix.artifact }} + + - name: Package (Windows) + if: runner.os == 'Windows' + run: | + cd target/${{ matrix.target }}/release + 7z a ../../../zeroclaw-${{ matrix.target }}.zip ${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: zeroclaw-${{ matrix.target }} + path: zeroclaw-${{ matrix.target }}.* + + publish: + name: Publish Release + needs: build-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: artifacts/**/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..822d96a --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,37 @@ +name: Security Audit + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" # Weekly on Monday 6am UTC + +env: + CARGO_TERM_COLOR: always + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Run cargo-audit + run: cargo audit + + deny: + name: License & Supply Chain + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check advisories licenses sources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc96892 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*.db +*.db-journal diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8ec9d30 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to ZeroClaw will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2025-02-13 + +### Added +- **Core Architecture**: Trait-based pluggable system for Provider, Channel, Observer, RuntimeAdapter, Tool +- **Provider**: OpenRouter implementation (access Claude, GPT-4, Llama, Gemini via single API) +- **Channels**: CLI channel with interactive and single-message modes +- **Observability**: NoopObserver (zero overhead), LogObserver (tracing), MultiObserver (fan-out) +- **Security**: Workspace sandboxing, command allowlisting, path traversal blocking, autonomy levels (ReadOnly/Supervised/Full), rate limiting +- **Tools**: Shell (sandboxed), FileRead (path-checked), FileWrite (path-checked) +- **Memory (Brain)**: SQLite persistent backend (searchable, survives restarts), Markdown backend (plain files, human-readable) +- **Heartbeat Engine**: Periodic task execution from HEARTBEAT.md +- **Runtime**: Native adapter for Mac/Linux/Raspberry Pi +- **Config**: TOML-based configuration with sensible defaults +- **Onboarding**: Interactive CLI wizard with workspace scaffolding +- **CLI Commands**: agent, gateway, status, cron, channel, tools, onboard +- **CI/CD**: GitHub Actions with cross-platform builds (Linux, macOS Intel/ARM, Windows) +- **Tests**: 159 inline tests covering all modules and edge cases +- **Binary**: 3.1MB optimized release build (includes bundled SQLite) + +### Security +- Path traversal attack prevention +- Command injection blocking +- Workspace escape prevention +- Forbidden system path protection (`/etc`, `/root`, `~/.ssh`) + +[0.1.0]: https://github.com/theonlyhennygod/zeroclaw/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a18a9c0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,209 @@ +# Contributing to ZeroClaw + +Thanks for your interest in contributing to ZeroClaw! This guide will help you get started. + +## Development Setup + +```bash +# Clone the repo +git clone https://github.com/theonlyhennygod/zeroclaw.git +cd zeroclaw + +# Build +cargo build + +# Run tests (180 tests, all must pass) +cargo test + +# Format & lint (must pass before PR) +cargo fmt && cargo clippy -- -D warnings + +# Release build (~3.1MB) +cargo build --release +``` + +## Architecture: Trait-Based Pluggability + +ZeroClaw's architecture is built on **traits** — every subsystem is swappable. This means contributing a new integration is as simple as implementing a trait and registering it in the factory function. + +``` +src/ +├── providers/ # LLM backends → Provider trait +├── channels/ # Messaging → Channel trait +├── observability/ # Metrics/logging → Observer trait +├── runtime/ # Platform adapters → RuntimeAdapter trait +├── tools/ # Agent tools → Tool trait +├── memory/ # Persistence/brain → Memory trait +└── security/ # Sandboxing → SecurityPolicy +``` + +## How to Add a New Provider + +Create `src/providers/your_provider.rs`: + +```rust +use async_trait::async_trait; +use anyhow::Result; +use crate::providers::traits::Provider; + +pub struct YourProvider { + api_key: String, + client: reqwest::Client, +} + +impl YourProvider { + pub fn new(api_key: Option<&str>) -> Self { + Self { + api_key: api_key.unwrap_or_default().to_string(), + client: reqwest::Client::new(), + } + } +} + +#[async_trait] +impl Provider for YourProvider { + async fn chat(&self, message: &str, model: &str, temperature: f64) -> Result { + // Your API call here + todo!() + } +} +``` + +Then register it in `src/providers/mod.rs`: + +```rust +"your_provider" => Ok(Box::new(your_provider::YourProvider::new(api_key))), +``` + +## How to Add a New Channel + +Create `src/channels/your_channel.rs`: + +```rust +use async_trait::async_trait; +use anyhow::Result; +use tokio::sync::mpsc; +use crate::channels::traits::{Channel, ChannelMessage}; + +pub struct YourChannel { /* config fields */ } + +#[async_trait] +impl Channel for YourChannel { + fn name(&self) -> &str { "your_channel" } + + async fn send(&self, message: &str, recipient: &str) -> Result<()> { + // Send message via your platform + todo!() + } + + async fn listen(&self, tx: mpsc::Sender) -> Result<()> { + // Listen for incoming messages, forward to tx + todo!() + } + + async fn health_check(&self) -> bool { true } +} +``` + +## How to Add a New Observer + +Create `src/observability/your_observer.rs`: + +```rust +use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric}; + +pub struct YourObserver { /* client, config, etc. */ } + +impl Observer for YourObserver { + fn record_event(&self, event: &ObserverEvent) { + // Push event to your backend + } + + fn record_metric(&self, metric: &ObserverMetric) { + // Push metric to your backend + } + + fn name(&self) -> &str { "your_observer" } +} +``` + +## How to Add a New Tool + +Create `src/tools/your_tool.rs`: + +```rust +use async_trait::async_trait; +use anyhow::Result; +use serde_json::{json, Value}; +use crate::tools::traits::{Tool, ToolResult}; + +pub struct YourTool { /* security policy, config, etc. */ } + +#[async_trait] +impl Tool for YourTool { + fn name(&self) -> &str { "your_tool" } + + fn description(&self) -> &str { "Does something useful" } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "input": { "type": "string", "description": "The input" } + }, + "required": ["input"] + }) + } + + async fn execute(&self, args: Value) -> Result { + let input = args["input"].as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'input'"))?; + Ok(ToolResult { + success: true, + output: format!("Processed: {input}"), + error: None, + }) + } +} +``` + +## Pull Request Checklist + +- [ ] `cargo fmt` — code is formatted +- [ ] `cargo clippy -- -D warnings` — no warnings +- [ ] `cargo test` — all 129+ tests pass +- [ ] New code has inline `#[cfg(test)]` tests +- [ ] No new dependencies unless absolutely necessary (we optimize for binary size) +- [ ] README updated if adding user-facing features +- [ ] Follows existing code patterns and conventions + +## Commit Convention + +We use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add Anthropic provider +fix: path traversal edge case with symlinks +docs: update contributing guide +test: add heartbeat unicode parsing tests +refactor: extract common security checks +chore: bump tokio to 1.43 +``` + +## Code Style + +- **Minimal dependencies** — every crate adds to binary size +- **Inline tests** — `#[cfg(test)] mod tests {}` at the bottom of each file +- **Trait-first** — define the trait, then implement +- **Security by default** — sandbox everything, allowlist, never blocklist +- **No unwrap in production code** — use `?`, `anyhow`, or `thiserror` + +## Reporting Issues + +- **Bugs**: Include OS, Rust version, steps to reproduce, expected vs actual +- **Features**: Describe the use case, propose which trait to extend +- **Security**: See [SECURITY.md](SECURITY.md) for responsible disclosure + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..747e2d9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2392 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "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 = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[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 = "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 = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "fuzzy-matcher", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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 = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +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 = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "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", +] + +[[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", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +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-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[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", + "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 = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "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", +] + +[[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", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.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" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroclaw" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "console", + "dialoguer", + "directories", + "futures-util", + "hostname", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "shellexpand", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tokio-tungstenite", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..566a63a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,76 @@ +[package] +name = "zeroclaw" +version = "0.1.0" +edition = "2021" +authors = ["theonlyhennygod"] +license = "MIT" +description = "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant." +repository = "https://github.com/theonlyhennygod/zeroclaw" +readme = "README.md" +keywords = ["ai", "agent", "cli", "assistant", "chatbot"] +categories = ["command-line-utilities", "api-bindings"] + +[dependencies] +# CLI - minimal and fast +clap = { version = "4.5", features = ["derive"] } + +# Async runtime - feature-optimized for size +tokio = { version = "1.42", default-features = false, features = ["rt-multi-thread", "macros", "time", "net", "io-util", "sync", "process", "io-std", "fs"] } + +# HTTP client - minimal features +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } + +# Serialization +serde = { version = "1.0", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } + +# Config +directories = "5.0" +toml = "0.8" +shellexpand = "3.1" + +# Logging - minimal +tracing = { version = "0.1", default-features = false } +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi"] } + +# Error handling +anyhow = "1.0" +thiserror = "2.0" + +# UUID generation +uuid = { version = "1.11", default-features = false, features = ["v4", "std"] } + +# Async traits +async-trait = "0.1" + +# Memory / persistence +rusqlite = { version = "0.32", features = ["bundled"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } + +# Interactive CLI prompts +dialoguer = { version = "0.11", features = ["fuzzy-select"] } +console = "0.15" + +# Discord WebSocket gateway +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +futures-util = { version = "0.3", default-features = false, features = ["sink"] } +hostname = "0.4.2" + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +strip = true # Remove debug symbols +panic = "abort" # Reduce binary size + +[profile.dist] +inherits = "release" +opt-level = "z" +lto = "fat" +codegen-units = 1 +strip = true +panic = "abort" + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3.14" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..71a301f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# ── Stage 1: Build ──────────────────────────────────────────── +FROM rust:1.83-slim AS builder + +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ + +RUN cargo build --release --locked && \ + strip target/release/zeroclaw + +# ── Stage 2: Runtime (distroless — no shell, no OS, tiny) ──── +FROM gcr.io/distroless/cc-debian12 + +COPY --from=builder /app/target/release/zeroclaw /usr/local/bin/zeroclaw + +# Default workspace +VOLUME ["/workspace"] +ENV ZEROCLAW_WORKSPACE=/workspace + +ENTRYPOINT ["zeroclaw"] +CMD ["gateway"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1484174 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 theonlyhennygod + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7955bf --- /dev/null +++ b/README.md @@ -0,0 +1,278 @@ +

+ ZeroClaw +

+ +

ZeroClaw 🦀

+ +

+ Zero overhead. Zero compromise. 100% Rust. 100% Agnostic. +

+ +

+ License: MIT +

+ +The fastest, smallest, fully autonomous AI assistant — deploy anywhere, swap anything. + +``` +~3MB binary · <10ms startup · 502 tests · 22 providers · Pluggable everything +``` + +## Quick Start + +```bash +git clone https://github.com/theonlyhennygod/zeroclaw.git +cd zeroclaw +cargo build --release + +# Initialize config + workspace +cargo run --release -- onboard + +# Set your API key +export OPENROUTER_API_KEY="sk-..." + +# Chat +cargo run --release -- agent -m "Hello, ZeroClaw!" + +# Interactive mode +cargo run --release -- agent + +# Check status +cargo run --release -- status --verbose + +# List tools (includes memory tools) +cargo run --release -- tools list + +# Test a tool directly +cargo run --release -- tools test memory_store '{"key": "lang", "content": "User prefers Rust"}' +cargo run --release -- tools test memory_recall '{"query": "Rust"}' +``` + +> **Tip:** Run `cargo install --path .` to install `zeroclaw` globally, then use `zeroclaw` instead of `cargo run --release --`. + +## Architecture + +Every subsystem is a **trait** — swap implementations with a config change, zero code changes. + +| Subsystem | Trait | Ships with | Extend | +|-----------|-------|------------|--------| +| **AI Models** | `Provider` | 22 providers (OpenRouter, Anthropic, OpenAI, Venice, Groq, Mistral, etc.) | Any OpenAI-compatible API | +| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook | Any messaging API | +| **Memory** | `Memory` | SQLite (default), Markdown | Any persistence | +| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget | Any capability | +| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | +| **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM | +| **Security** | `SecurityPolicy` | Sandbox + allowlists + rate limits | — | +| **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — | + +### Memory System + +ZeroClaw has a built-in brain. The agent automatically: +1. **Recalls** relevant memories before each prompt (context injection) +2. **Saves** conversation turns to memory (auto-save) +3. **Manages** its own memory via tools (store/recall/forget) + +Two backends — **SQLite** (default, searchable, upsert, delete) and **Markdown** (human-readable, append-only, git-friendly). Switch with one config line. + +### Security + +- **Workspace sandboxing** — can't escape workspace directory +- **Command allowlisting** — only approved shell commands +- **Path traversal blocking** — `..` and absolute paths blocked +- **Rate limiting** — max actions/hour, max cost/day +- **Autonomy levels** — ReadOnly, Supervised, Full + +## Configuration + +Config: `~/.zeroclaw/config.toml` (created by `onboard`) + +## Documentation Index + +Fetch the complete documentation index at: https://docs.openclaw.ai/llms.txt +Use this file to discover all available pages before exploring further. + +## Token Use & Costs + +ZeroClaw tracks **tokens**, not characters. Tokens are model-specific, but most +OpenAI-style models average ~4 characters per token for English text. + +### How the system prompt is built + +ZeroClaw assembles its own system prompt on every run. It includes: + +* Tool list + short descriptions +* Skills list (only metadata; instructions are loaded on demand with `read`) +* Self-update instructions +* Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000). `memory/*.md` files are on-demand via memory tools and are not auto-injected. +* Time (UTC + user timezone) +* Reply tags + heartbeat behavior +* Runtime metadata (host/OS/model/thinking) + +### What counts in the context window + +Everything the model receives counts toward the context limit: + +* System prompt (all sections listed above) +* Conversation history (user + assistant messages) +* Tool calls and tool results +* Attachments/transcripts (images, audio, files) +* Compaction summaries and pruning artifacts +* Provider wrappers or safety headers (not visible, but still counted) + +### How to see current token usage + +Use these in chat: + +* `/status` → **emoji-rich status card** with the session model, context usage, + last response input/output tokens, and **estimated cost** (API key only). +* `/usage off|tokens|full` → appends a **per-response usage footer** to every reply. + * Persists per session (stored as `responseUsage`). + * OAuth auth **hides cost** (tokens only). +* `/usage cost` → shows a local cost summary from ZeroClaw session logs. + +Other surfaces: + +* **TUI/Web TUI:** `/status` + `/usage` are supported. +* **CLI:** `zeroclaw status --usage` and `zeroclaw channels list` show + provider quota windows (not per-response costs). + +### Cost estimation (when shown) + +Costs are estimated from your model pricing config: + +``` +models.providers..models[].cost +``` + +These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and +`cacheWrite`. If pricing is missing, ZeroClaw shows tokens only. OAuth tokens +never show dollar cost. + +### Cache TTL and pruning impact + +Provider prompt caching only applies within the cache TTL window. ZeroClaw can +optionally run **cache-ttl pruning**: it prunes the session once the cache TTL +has expired, then resets the cache window so subsequent requests can re-use the +freshly cached context instead of re-caching the full history. This keeps cache +write costs lower when a session goes idle past the TTL. + +Configure it in Gateway configuration and see the behavior details in +[Session pruning](/concepts/session-pruning). + +Heartbeat can keep the cache **warm** across idle gaps. If your model cache TTL +is `1h`, setting the heartbeat interval just under that (e.g., `55m`) can avoid +re-caching the full prompt, reducing cache write costs. + +For Anthropic API pricing, cache reads are significantly cheaper than input +tokens, while cache writes are billed at a higher multiplier. See Anthropic's +prompt caching pricing for the latest rates and TTL multipliers: +[https://docs.anthropic.com/docs/build-with-claude/prompt-caching](https://docs.anthropic.com/docs/build-with-claude/prompt-caching) + +#### Example: keep 1h cache warm with heartbeat + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" + heartbeat: + every: "55m" +``` + +### Tips for reducing token pressure + +* Use `/compact` to summarize long sessions. +* Trim large tool outputs in your workflows. +* Keep skill descriptions short (skill list is injected into the prompt). +* Prefer smaller models for verbose, exploratory work. + +```toml +api_key = "sk-..." +default_provider = "openrouter" +default_model = "anthropic/claude-sonnet-4-20250514" +default_temperature = 0.7 + +[memory] +backend = "sqlite" # "sqlite", "markdown", "none" +auto_save = true + +[autonomy] +level = "supervised" # "readonly", "supervised", "full" +workspace_only = true +allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep"] + +[heartbeat] +enabled = false +interval_minutes = 30 +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `onboard` | Initialize workspace and config | +| `agent -m "..."` | Single message mode | +| `agent` | Interactive chat mode | +| `status -v` | Show full system status | +| `tools list` | List all 6 tools | +| `tools test ` | Test a tool directly | +| `gateway` | Start webhook/WebSocket server | + +## Development + +```bash +cargo build # Dev build +cargo build --release # Release build (~3MB) +cargo test # 502 tests +cargo clippy # Lint (0 warnings) + +# Run the SQLite vs Markdown benchmark +cargo test --test memory_comparison -- --nocapture +``` + +## Project Structure + +``` +src/ +├── main.rs # CLI (clap) +├── lib.rs # Library exports +├── agent/ # Agent loop + context injection +├── channels/ # Channel trait + CLI +├── config/ # TOML config schema +├── cron/ # Scheduled tasks +├── heartbeat/ # HEARTBEAT.md engine +├── memory/ # Memory trait + SQLite + Markdown +├── observability/ # Observer trait + Noop/Log/Multi +├── providers/ # Provider trait + 22 providers +├── runtime/ # RuntimeAdapter trait + Native +├── security/ # Sandbox + allowlists + autonomy +└── tools/ # Tool trait + shell/file/memory tools +examples/ +├── custom_provider.rs +├── custom_channel.rs +├── custom_tool.rs +└── custom_memory.rs +tests/ +└── memory_comparison.rs # SQLite vs Markdown benchmark +``` + +## License + +MIT — see [LICENSE](LICENSE) + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Implement a trait, submit a PR: +- New `Provider` → `src/providers/` +- New `Channel` → `src/channels/` +- New `Observer` → `src/observability/` +- New `Tool` → `src/tools/` +- New `Memory` → `src/memory/` + +--- + +**ZeroClaw** — Zero overhead. Zero compromise. Deploy anywhere. Swap anything. 🦀 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9fc4b11 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | + +## Reporting a Vulnerability + +**Please do NOT open a public GitHub issue for security vulnerabilities.** + +Instead, please report them responsibly: + +1. **Email**: Send details to the maintainers via GitHub private vulnerability reporting +2. **GitHub**: Use [GitHub Security Advisories](https://github.com/theonlyhennygod/zeroclaw/security/advisories/new) + +### What to Include + +- Description of the vulnerability +- Steps to reproduce +- Impact assessment +- Suggested fix (if any) + +### Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Assessment**: Within 1 week +- **Fix**: Within 2 weeks for critical issues + +## Security Architecture + +ZeroClaw implements defense-in-depth security: + +### Autonomy Levels +- **ReadOnly** — Agent can only read, no shell or write access +- **Supervised** — Agent can act within allowlists (default) +- **Full** — Agent has full access within workspace sandbox + +### Sandboxing Layers +1. **Workspace isolation** — All file operations confined to workspace directory +2. **Path traversal blocking** — `..` sequences and absolute paths rejected +3. **Command allowlisting** — Only explicitly approved commands can execute +4. **Forbidden path list** — Critical system paths (`/etc`, `/root`, `~/.ssh`) always blocked +5. **Rate limiting** — Max actions per hour and cost per day caps + +### What We Protect Against +- Path traversal attacks (`../../../etc/passwd`) +- Command injection (`rm -rf /`, `curl | sh`) +- Workspace escape via symlinks or absolute paths +- Runaway cost from LLM API calls +- Unauthorized shell command execution + +## Security Testing + +All security mechanisms are covered by automated tests (129 tests): + +```bash +cargo test -- security +cargo test -- tools::shell +cargo test -- tools::file_read +cargo test -- tools::file_write +``` diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e167dc1 --- /dev/null +++ b/deny.toml @@ -0,0 +1,34 @@ +# cargo-deny configuration +# https://embarkstudios.github.io/cargo-deny/ + +[advisories] +vulnerability = "deny" +unmaintained = "warn" +yanked = "warn" +notice = "warn" + +[licenses] +unlicensed = "deny" +allow = [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "OpenSSL", + "Zlib", + "MPL-2.0", +] +copyleft = "deny" + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/examples/custom_channel.rs b/examples/custom_channel.rs new file mode 100644 index 0000000..dbac7d1 --- /dev/null +++ b/examples/custom_channel.rs @@ -0,0 +1,124 @@ +//! Example: Implementing a custom Channel for ZeroClaw +//! +//! Channels let ZeroClaw communicate through any messaging platform. +//! Implement the Channel trait, register it, and the agent works everywhere. + +use anyhow::Result; +use async_trait::async_trait; +use tokio::sync::mpsc; + +/// Mirrors src/channels/traits.rs +#[derive(Debug, Clone)] +pub struct ChannelMessage { + pub id: String, + pub sender: String, + pub content: String, + pub channel: String, + pub timestamp: u64, +} + +#[async_trait] +pub trait Channel: Send + Sync { + fn name(&self) -> &str; + async fn send(&self, message: &str, recipient: &str) -> Result<()>; + async fn listen(&self, tx: mpsc::Sender) -> Result<()>; + async fn health_check(&self) -> bool; +} + +/// Example: Telegram channel via Bot API +pub struct TelegramChannel { + bot_token: String, + allowed_users: Vec, + client: reqwest::Client, +} + +impl TelegramChannel { + pub fn new(bot_token: &str, allowed_users: Vec) -> Self { + Self { + bot_token: bot_token.to_string(), + allowed_users, + client: reqwest::Client::new(), + } + } + + fn api_url(&self, method: &str) -> String { + format!("https://api.telegram.org/bot{}/{method}", self.bot_token) + } +} + +#[async_trait] +impl Channel for TelegramChannel { + fn name(&self) -> &str { + "telegram" + } + + async fn send(&self, message: &str, chat_id: &str) -> Result<()> { + self.client + .post(&self.api_url("sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": message, + "parse_mode": "Markdown", + })) + .send() + .await?; + Ok(()) + } + + async fn listen(&self, tx: mpsc::Sender) -> Result<()> { + let mut offset: i64 = 0; + + loop { + let resp = self + .client + .get(&self.api_url("getUpdates")) + .query(&[("offset", offset.to_string()), ("timeout", "30".into())]) + .send() + .await? + .json::() + .await?; + + if let Some(updates) = resp["result"].as_array() { + for update in updates { + if let Some(msg) = update.get("message") { + let sender = msg["from"]["username"] + .as_str() + .unwrap_or("unknown") + .to_string(); + + if !self.allowed_users.is_empty() && !self.allowed_users.contains(&sender) { + continue; + } + + let channel_msg = ChannelMessage { + id: msg["message_id"].to_string(), + sender, + content: msg["text"].as_str().unwrap_or("").to_string(), + channel: "telegram".into(), + timestamp: msg["date"].as_u64().unwrap_or(0), + }; + + if tx.send(channel_msg).await.is_err() { + return Ok(()); + } + } + offset = update["update_id"].as_i64().unwrap_or(offset) + 1; + } + } + } + } + + async fn health_check(&self) -> bool { + self.client + .get(&self.api_url("getMe")) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } +} + +fn main() { + println!("This is an example — see CONTRIBUTING.md for integration steps."); + println!("Add your channel config to ChannelsConfig in src/config/schema.rs"); +} diff --git a/examples/custom_memory.rs b/examples/custom_memory.rs new file mode 100644 index 0000000..fcd299d --- /dev/null +++ b/examples/custom_memory.rs @@ -0,0 +1,160 @@ +//! Example: Implementing a custom Memory backend for ZeroClaw +//! +//! This demonstrates how to create a Redis-backed memory backend. +//! The Memory trait is async and pluggable — implement it for any storage. +//! +//! Run: cargo run --example custom_memory + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; + +// ── Re-define the trait types (in your app, import from zeroclaw::memory) ── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum MemoryCategory { + Core, + Daily, + Conversation, + Custom(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryEntry { + pub id: String, + pub key: String, + pub content: String, + pub category: MemoryCategory, + pub timestamp: String, + pub score: Option, +} + +#[async_trait] +pub trait Memory: Send + Sync { + fn name(&self) -> &str; + async fn store(&self, key: &str, content: &str, category: MemoryCategory) + -> anyhow::Result<()>; + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result>; + async fn get(&self, key: &str) -> anyhow::Result>; + async fn forget(&self, key: &str) -> anyhow::Result; + async fn count(&self) -> anyhow::Result; +} + +// ── Your custom implementation ───────────────────────────────────── + +/// In-memory HashMap backend (great for testing or ephemeral sessions) +pub struct InMemoryBackend { + store: Mutex>, +} + +impl InMemoryBackend { + pub fn new() -> Self { + Self { + store: Mutex::new(HashMap::new()), + } + } +} + +#[async_trait] +impl Memory for InMemoryBackend { + fn name(&self) -> &str { + "in-memory" + } + + async fn store( + &self, + key: &str, + content: &str, + category: MemoryCategory, + ) -> anyhow::Result<()> { + let entry = MemoryEntry { + id: uuid::Uuid::new_v4().to_string(), + key: key.to_string(), + content: content.to_string(), + category, + timestamp: chrono::Local::now().to_rfc3339(), + score: None, + }; + self.store + .lock() + .map_err(|e| anyhow::anyhow!("{e}"))? + .insert(key.to_string(), entry); + Ok(()) + } + + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result> { + let store = self.store.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + let query_lower = query.to_lowercase(); + + let mut results: Vec = store + .values() + .filter(|e| e.content.to_lowercase().contains(&query_lower)) + .cloned() + .collect(); + + results.truncate(limit); + Ok(results) + } + + async fn get(&self, key: &str) -> anyhow::Result> { + let store = self.store.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(store.get(key).cloned()) + } + + async fn forget(&self, key: &str) -> anyhow::Result { + let mut store = self.store.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(store.remove(key).is_some()) + } + + async fn count(&self) -> anyhow::Result { + let store = self.store.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(store.len()) + } +} + +// ── Demo usage ───────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let brain = InMemoryBackend::new(); + + println!("🧠 ZeroClaw Memory Demo — InMemoryBackend\n"); + + // Store some memories + brain + .store("user_lang", "User prefers Rust", MemoryCategory::Core) + .await?; + brain + .store("user_tz", "Timezone is EST", MemoryCategory::Core) + .await?; + brain + .store( + "today_note", + "Completed memory system implementation", + MemoryCategory::Daily, + ) + .await?; + + println!("Stored {} memories", brain.count().await?); + + // Recall by keyword + let results = brain.recall("Rust", 5).await?; + println!("\nRecall 'Rust' → {} results:", results.len()); + for entry in &results { + println!(" [{:?}] {}: {}", entry.category, entry.key, entry.content); + } + + // Get by key + if let Some(entry) = brain.get("user_tz").await? { + println!("\nGet 'user_tz' → {}", entry.content); + } + + // Forget + let removed = brain.forget("user_tz").await?; + println!("Forget 'user_tz' → removed: {removed}"); + println!("Remaining: {} memories", brain.count().await?); + + println!("\n✅ Memory backend works! Implement the Memory trait for any storage."); + Ok(()) +} diff --git a/examples/custom_provider.rs b/examples/custom_provider.rs new file mode 100644 index 0000000..4ad821c --- /dev/null +++ b/examples/custom_provider.rs @@ -0,0 +1,65 @@ +//! Example: Implementing a custom Provider for ZeroClaw +//! +//! This shows how to add a new LLM backend in ~30 lines of code. +//! Copy this file, modify the API call, and register in `src/providers/mod.rs`. + +use anyhow::Result; +use async_trait::async_trait; + +// In a real implementation, you'd import from the crate: +// use zeroclaw::providers::traits::Provider; + +/// Minimal Provider trait (mirrors src/providers/traits.rs) +#[async_trait] +pub trait Provider: Send + Sync { + async fn chat(&self, message: &str, model: &str, temperature: f64) -> Result; +} + +/// Example: Ollama local provider +pub struct OllamaProvider { + base_url: String, + client: reqwest::Client, +} + +impl OllamaProvider { + pub fn new(base_url: Option<&str>) -> Self { + Self { + base_url: base_url.unwrap_or("http://localhost:11434").to_string(), + client: reqwest::Client::new(), + } + } +} + +#[async_trait] +impl Provider for OllamaProvider { + async fn chat(&self, message: &str, model: &str, temperature: f64) -> Result { + let url = format!("{}/api/generate", self.base_url); + + let body = serde_json::json!({ + "model": model, + "prompt": message, + "temperature": temperature, + "stream": false, + }); + + let resp = self + .client + .post(&url) + .json(&body) + .send() + .await? + .json::() + .await?; + + resp["response"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("No response field in Ollama reply")) + } +} + +fn main() { + println!("This is an example — see CONTRIBUTING.md for integration steps."); + println!("Register your provider in src/providers/mod.rs:"); + println!(" \"ollama\" => Ok(Box::new(ollama::OllamaProvider::new(None))),"); +} diff --git a/examples/custom_tool.rs b/examples/custom_tool.rs new file mode 100644 index 0000000..61768d4 --- /dev/null +++ b/examples/custom_tool.rs @@ -0,0 +1,76 @@ +//! Example: Implementing a custom Tool for ZeroClaw +//! +//! This shows how to add a new tool the agent can use. +//! Tools are the agent's hands — they let it interact with the world. + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// Mirrors src/tools/traits.rs +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ToolResult { + pub success: bool, + pub output: String, + pub error: Option, +} + +#[async_trait] +pub trait Tool: Send + Sync { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn parameters_schema(&self) -> Value; + async fn execute(&self, args: Value) -> Result; +} + +/// Example: A tool that fetches a URL and returns the status code +pub struct HttpGetTool; + +#[async_trait] +impl Tool for HttpGetTool { + fn name(&self) -> &str { + "http_get" + } + + fn description(&self) -> &str { + "Fetch a URL and return the HTTP status code and content length" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "URL to fetch" } + }, + "required": ["url"] + }) + } + + async fn execute(&self, args: Value) -> Result { + let url = args["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; + + match reqwest::get(url).await { + Ok(resp) => { + let status = resp.status().as_u16(); + let len = resp.content_length().unwrap_or(0); + Ok(ToolResult { + success: status < 400, + output: format!("HTTP {status} — {len} bytes"), + error: None, + }) + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Request failed: {e}")), + }), + } + } +} + +fn main() { + println!("This is an example — see CONTRIBUTING.md for integration steps."); + println!("Register your tool in src/tools/mod.rs default_tools()"); +} diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs new file mode 100644 index 0000000..6ed423c --- /dev/null +++ b/src/agent/loop_.rs @@ -0,0 +1,182 @@ +use crate::config::Config; +use crate::memory::{self, Memory, MemoryCategory}; +use crate::observability::{self, Observer, ObserverEvent}; +use crate::providers::{self, Provider}; +use crate::runtime; +use crate::security::SecurityPolicy; +use crate::tools; +use anyhow::Result; +use std::fmt::Write; +use std::sync::Arc; +use std::time::Instant; + +/// Build context preamble by searching memory for relevant entries +async fn build_context(mem: &dyn Memory, user_msg: &str) -> String { + let mut context = String::new(); + + // Pull relevant memories for this message + if let Ok(entries) = mem.recall(user_msg, 5).await { + if !entries.is_empty() { + context.push_str("[Memory context]\n"); + for entry in &entries { + let _ = writeln!(context, "- {}: {}", entry.key, entry.content); + } + context.push('\n'); + } + } + + context +} + +#[allow(clippy::too_many_lines)] +pub async fn run( + config: Config, + message: Option, + provider_override: Option, + model_override: Option, + temperature: f64, +) -> Result<()> { + // ── Wire up agnostic subsystems ────────────────────────────── + let observer: Arc = + Arc::from(observability::create_observer(&config.observability)); + let _runtime = runtime::create_runtime(&config.runtime); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + + // ── Memory (the brain) ──────────────────────────────────────── + let mem: Arc = + Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); + tracing::info!(backend = mem.name(), "Memory initialized"); + + // ── Tools (including memory tools) ──────────────────────────── + let _tools = tools::all_tools(security, mem.clone()); + + // ── Resolve provider ───────────────────────────────────────── + let provider_name = provider_override + .as_deref() + .or(config.default_provider.as_deref()) + .unwrap_or("openrouter"); + + let model_name = model_override + .as_deref() + .or(config.default_model.as_deref()) + .unwrap_or("anthropic/claude-sonnet-4-20250514"); + + let provider: Box = + providers::create_provider(provider_name, config.api_key.as_deref())?; + + observer.record_event(&ObserverEvent::AgentStart { + provider: provider_name.to_string(), + model: model_name.to_string(), + }); + + // ── Build system prompt from workspace MD files (OpenClaw framework) ── + let skills = crate::skills::load_skills(&config.workspace_dir); + let 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"), + ]; + let system_prompt = crate::channels::build_system_prompt( + &config.workspace_dir, + model_name, + &tool_descs, + &skills, + ); + + // ── Execute ────────────────────────────────────────────────── + let start = Instant::now(); + + if let Some(msg) = message { + // Auto-save user message to memory + if config.memory.auto_save { + let _ = mem + .store("user_msg", &msg, MemoryCategory::Conversation) + .await; + } + + // Inject memory context into user message + let context = build_context(mem.as_ref(), &msg).await; + let enriched = if context.is_empty() { + msg.clone() + } else { + format!("{context}{msg}") + }; + + let response = provider + .chat_with_system(Some(&system_prompt), &enriched, model_name, temperature) + .await?; + println!("{response}"); + + // Auto-save assistant response to daily log + if config.memory.auto_save { + let summary = if response.len() > 100 { + format!("{}...", &response[..100]) + } else { + response.clone() + }; + let _ = mem + .store("assistant_resp", &summary, MemoryCategory::Daily) + .await; + } + } else { + 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(); + + // Spawn listener + let listen_handle = tokio::spawn(async move { + let _ = crate::channels::Channel::listen(&cli, tx).await; + }); + + while let Some(msg) = rx.recv().await { + // Auto-save conversation turns + if config.memory.auto_save { + let _ = mem + .store("user_msg", &msg.content, MemoryCategory::Conversation) + .await; + } + + // Inject memory context into user message + let context = build_context(mem.as_ref(), &msg.content).await; + let enriched = if context.is_empty() { + msg.content.clone() + } else { + format!("{context}{}", msg.content) + }; + + let response = provider + .chat_with_system(Some(&system_prompt), &enriched, model_name, temperature) + .await?; + println!("\n{response}\n"); + + if config.memory.auto_save { + let summary = if response.len() > 100 { + format!("{}...", &response[..100]) + } else { + response.clone() + }; + let _ = mem + .store("assistant_resp", &summary, MemoryCategory::Daily) + .await; + } + } + + listen_handle.abort(); + } + + let duration = start.elapsed(); + observer.record_event(&ObserverEvent::AgentEnd { + duration, + tokens_used: None, + }); + + Ok(()) +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..f889613 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,3 @@ +pub mod loop_; + +pub use loop_::run; diff --git a/src/channels/cli.rs b/src/channels/cli.rs new file mode 100644 index 0000000..99546e3 --- /dev/null +++ b/src/channels/cli.rs @@ -0,0 +1,117 @@ +use super::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use tokio::io::{self, AsyncBufReadExt, BufReader}; +use uuid::Uuid; + +/// CLI channel — stdin/stdout, always available, zero deps +pub struct CliChannel; + +impl CliChannel { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Channel for CliChannel { + fn name(&self) -> &str { + "cli" + } + + async fn send(&self, message: &str, _recipient: &str) -> anyhow::Result<()> { + println!("{message}"); + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + let stdin = io::stdin(); + let reader = BufReader::new(stdin); + let mut lines = reader.lines(); + + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + if line == "/quit" || line == "/exit" { + break; + } + + let msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: "user".to_string(), + content: line, + channel: "cli".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(msg).await.is_err() { + break; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_channel_name() { + assert_eq!(CliChannel::new().name(), "cli"); + } + + #[tokio::test] + async fn cli_channel_send_does_not_panic() { + let ch = CliChannel::new(); + let result = ch.send("hello", "user").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn cli_channel_send_empty_message() { + let ch = CliChannel::new(); + let result = ch.send("", "").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn cli_channel_health_check() { + let ch = CliChannel::new(); + assert!(ch.health_check().await); + } + + #[test] + fn channel_message_struct() { + let msg = ChannelMessage { + id: "test-id".into(), + sender: "user".into(), + content: "hello".into(), + channel: "cli".into(), + timestamp: 1234567890, + }; + assert_eq!(msg.id, "test-id"); + assert_eq!(msg.sender, "user"); + assert_eq!(msg.content, "hello"); + assert_eq!(msg.channel, "cli"); + assert_eq!(msg.timestamp, 1234567890); + } + + #[test] + fn channel_message_clone() { + let msg = ChannelMessage { + id: "id".into(), + sender: "s".into(), + content: "c".into(), + channel: "ch".into(), + timestamp: 0, + }; + let cloned = msg.clone(); + assert_eq!(cloned.id, msg.id); + assert_eq!(cloned.content, msg.content); + } +} diff --git a/src/channels/discord.rs b/src/channels/discord.rs new file mode 100644 index 0000000..81783bc --- /dev/null +++ b/src/channels/discord.rs @@ -0,0 +1,271 @@ +use super::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; + +/// Discord channel — connects via Gateway WebSocket for real-time messages +pub struct DiscordChannel { + bot_token: String, + guild_id: Option, + client: reqwest::Client, +} + +impl DiscordChannel { + pub fn new(bot_token: String, guild_id: Option) -> Self { + Self { + bot_token, + guild_id, + client: reqwest::Client::new(), + } + } + + fn bot_user_id_from_token(token: &str) -> Option { + // Discord bot tokens are base64(bot_user_id).timestamp.hmac + let part = token.split('.').next()?; + base64_decode(part) + } +} + +const BASE64_ALPHABET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion +#[allow(clippy::cast_possible_truncation)] +fn base64_decode(input: &str) -> Option { + let padded = match input.len() % 4 { + 2 => format!("{input}=="), + 3 => format!("{input}="), + _ => input.to_string(), + }; + + let mut bytes = Vec::new(); + let chars: Vec = padded.bytes().collect(); + + for chunk in chars.chunks(4) { + if chunk.len() < 4 { + break; + } + + let mut v = [0usize; 4]; + for (i, &b) in chunk.iter().enumerate() { + if b == b'=' { + v[i] = 0; + } else { + v[i] = BASE64_ALPHABET.iter().position(|&a| a == b)?; + } + } + + bytes.push(((v[0] << 2) | (v[1] >> 4)) as u8); + if chunk[2] != b'=' { + bytes.push((((v[1] & 0xF) << 4) | (v[2] >> 2)) as u8); + } + if chunk[3] != b'=' { + bytes.push((((v[2] & 0x3) << 6) | v[3]) as u8); + } + } + + String::from_utf8(bytes).ok() +} + +#[async_trait] +impl Channel for DiscordChannel { + fn name(&self) -> &str { + "discord" + } + + async fn send(&self, message: &str, channel_id: &str) -> anyhow::Result<()> { + let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages"); + let body = json!({ "content": message }); + + self.client + .post(&url) + .header("Authorization", format!("Bot {}", self.bot_token)) + .json(&body) + .send() + .await?; + + Ok(()) + } + + #[allow(clippy::too_many_lines)] + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + let bot_user_id = Self::bot_user_id_from_token(&self.bot_token).unwrap_or_default(); + + // Get Gateway URL + let gw_resp: serde_json::Value = self + .client + .get("https://discord.com/api/v10/gateway/bot") + .header("Authorization", format!("Bot {}", self.bot_token)) + .send() + .await? + .json() + .await?; + + let gw_url = gw_resp + .get("url") + .and_then(|u| u.as_str()) + .unwrap_or("wss://gateway.discord.gg"); + + let ws_url = format!("{gw_url}/?v=10&encoding=json"); + tracing::info!("Discord: connecting to gateway..."); + + let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Read Hello (opcode 10) + let hello = read.next().await.ok_or(anyhow::anyhow!("No hello"))??; + let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?; + let heartbeat_interval = hello_data + .get("d") + .and_then(|d| d.get("heartbeat_interval")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(41250); + + // Send Identify (opcode 2) + let identify = json!({ + "op": 2, + "d": { + "token": self.bot_token, + "intents": 33281, // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT | DIRECT_MESSAGES + "properties": { + "os": "linux", + "browser": "zeroclaw", + "device": "zeroclaw" + } + } + }); + write.send(Message::Text(identify.to_string())).await?; + + tracing::info!("Discord: connected and identified"); + + // Spawn heartbeat task + let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1); + let hb_interval = heartbeat_interval; + tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_millis(hb_interval)); + loop { + interval.tick().await; + if hb_tx.send(()).await.is_err() { + break; + } + } + }); + + let guild_filter = self.guild_id.clone(); + + loop { + tokio::select! { + _ = hb_rx.recv() => { + let hb = json!({"op": 1, "d": null}); + if write.send(Message::Text(hb.to_string())).await.is_err() { + break; + } + } + msg = read.next() => { + let msg = match msg { + Some(Ok(Message::Text(t))) => t, + Some(Ok(Message::Close(_))) | None => break, + _ => continue, + }; + + let event: serde_json::Value = match serde_json::from_str(&msg) { + Ok(e) => e, + Err(_) => continue, + }; + + // Only handle MESSAGE_CREATE (opcode 0, type "MESSAGE_CREATE") + let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or(""); + if event_type != "MESSAGE_CREATE" { + continue; + } + + let Some(d) = event.get("d") else { + continue; + }; + + // Skip messages from the bot itself + let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or(""); + if author_id == bot_user_id { + continue; + } + + // Skip bot messages + if d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) { + continue; + } + + // Guild filter + if let Some(ref gid) = guild_filter { + let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str).unwrap_or(""); + if msg_guild != gid { + continue; + } + } + + let content = d.get("content").and_then(|c| c.as_str()).unwrap_or(""); + if content.is_empty() { + continue; + } + + let channel_id = d.get("channel_id").and_then(|c| c.as_str()).unwrap_or("").to_string(); + + let channel_msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: channel_id, + content: content.to_string(), + channel: "discord".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(channel_msg).await.is_err() { + break; + } + } + } + } + + Ok(()) + } + + async fn health_check(&self) -> bool { + self.client + .get("https://discord.com/api/v10/users/@me") + .header("Authorization", format!("Bot {}", self.bot_token)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discord_channel_name() { + let ch = DiscordChannel::new("fake".into(), None); + assert_eq!(ch.name(), "discord"); + } + + #[test] + fn base64_decode_bot_id() { + // "MTIzNDU2" decodes to "123456" + let decoded = base64_decode("MTIzNDU2"); + assert_eq!(decoded, Some("123456".to_string())); + } + + #[test] + fn bot_user_id_extraction() { + // Token format: base64(user_id).timestamp.hmac + let token = "MTIzNDU2.fake.hmac"; + let id = DiscordChannel::bot_user_id_from_token(token); + assert_eq!(id, Some("123456".to_string())); + } +} diff --git a/src/channels/imessage.rs b/src/channels/imessage.rs new file mode 100644 index 0000000..ec0262e --- /dev/null +++ b/src/channels/imessage.rs @@ -0,0 +1,265 @@ +use crate::channels::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use directories::UserDirs; +use tokio::sync::mpsc; + +/// iMessage channel using macOS `AppleScript` bridge. +/// Polls the Messages database for new messages and sends replies via `osascript`. +#[derive(Clone)] +pub struct IMessageChannel { + allowed_contacts: Vec, + poll_interval_secs: u64, +} + +impl IMessageChannel { + pub fn new(allowed_contacts: Vec) -> Self { + Self { + allowed_contacts, + poll_interval_secs: 3, + } + } + + fn is_contact_allowed(&self, sender: &str) -> bool { + if self.allowed_contacts.iter().any(|u| u == "*") { + return true; + } + self.allowed_contacts.iter().any(|u| { + u.eq_ignore_ascii_case(sender) + }) + } +} + +#[async_trait] +impl Channel for IMessageChannel { + fn name(&self) -> &str { + "imessage" + } + + async fn send(&self, message: &str, target: &str) -> anyhow::Result<()> { + let escaped_msg = message.replace('\\', "\\\\").replace('"', "\\\""); + let script = format!( + r#"tell application "Messages" + set targetService to 1st account whose service type = iMessage + set targetBuddy to participant "{target}" of targetService + send "{escaped_msg}" to targetBuddy +end tell"# + ); + + let output = tokio::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("iMessage send failed: {stderr}"); + } + + Ok(()) + } + + async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> { + tracing::info!("iMessage channel listening (AppleScript bridge)..."); + + // Query the Messages SQLite database for new messages + // The database is at ~/Library/Messages/chat.db + let db_path = UserDirs::new() + .map(|u| u.home_dir().join("Library/Messages/chat.db")) + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + + if !db_path.exists() { + anyhow::bail!( + "Messages database not found at {}. Ensure Messages.app is set up and Full Disk Access is granted.", + db_path.display() + ); + } + + // Track the last ROWID we've seen + let mut last_rowid = get_max_rowid(&db_path).await.unwrap_or(0); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(self.poll_interval_secs)).await; + + let new_messages = fetch_new_messages(&db_path, last_rowid).await; + + match new_messages { + Ok(messages) => { + for (rowid, sender, text) in messages { + if rowid > last_rowid { + last_rowid = rowid; + } + + if !self.is_contact_allowed(&sender) { + continue; + } + + if text.trim().is_empty() { + continue; + } + + let msg = ChannelMessage { + id: rowid.to_string(), + sender: sender.clone(), + content: text, + channel: "imessage".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + Err(e) => { + tracing::warn!("iMessage poll error: {e}"); + } + } + } + } + + async fn health_check(&self) -> bool { + if !cfg!(target_os = "macos") { + return false; + } + + let db_path = UserDirs::new() + .map(|u| u.home_dir().join("Library/Messages/chat.db")) + .unwrap_or_default(); + + db_path.exists() + } +} + +/// Get the current max ROWID from the messages table +async fn get_max_rowid(db_path: &std::path::Path) -> anyhow::Result { + let output = tokio::process::Command::new("sqlite3") + .arg(db_path) + .arg("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0;") + .output() + .await?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let rowid = stdout.trim().parse::().unwrap_or(0); + Ok(rowid) +} + +/// Fetch messages newer than `since_rowid` +async fn fetch_new_messages( + db_path: &std::path::Path, + since_rowid: i64, +) -> anyhow::Result> { + let query = format!( + "SELECT m.ROWID, h.id, m.text \ + FROM message m \ + JOIN handle h ON m.handle_id = h.ROWID \ + WHERE m.ROWID > {since_rowid} \ + AND m.is_from_me = 0 \ + AND m.text IS NOT NULL \ + ORDER BY m.ROWID ASC \ + LIMIT 20;" + ); + + let output = tokio::process::Command::new("sqlite3") + .arg("-separator") + .arg("|") + .arg(db_path) + .arg(&query) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("sqlite3 query failed: {stderr}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.splitn(3, '|').collect(); + if parts.len() == 3 { + if let Ok(rowid) = parts[0].parse::() { + results.push((rowid, parts[1].to_string(), parts[2].to_string())); + } + } + } + + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_with_contacts() { + let ch = IMessageChannel::new(vec!["+1234567890".into()]); + assert_eq!(ch.allowed_contacts.len(), 1); + assert_eq!(ch.poll_interval_secs, 3); + } + + #[test] + fn creates_with_empty_contacts() { + let ch = IMessageChannel::new(vec![]); + assert!(ch.allowed_contacts.is_empty()); + } + + #[test] + fn wildcard_allows_anyone() { + let ch = IMessageChannel::new(vec!["*".into()]); + assert!(ch.is_contact_allowed("+1234567890")); + assert!(ch.is_contact_allowed("random@icloud.com")); + assert!(ch.is_contact_allowed("")); + } + + #[test] + fn specific_contact_allowed() { + let ch = IMessageChannel::new(vec!["+1234567890".into(), "user@icloud.com".into()]); + assert!(ch.is_contact_allowed("+1234567890")); + assert!(ch.is_contact_allowed("user@icloud.com")); + } + + #[test] + fn unknown_contact_denied() { + let ch = IMessageChannel::new(vec!["+1234567890".into()]); + assert!(!ch.is_contact_allowed("+9999999999")); + assert!(!ch.is_contact_allowed("hacker@evil.com")); + } + + #[test] + fn contact_case_insensitive() { + let ch = IMessageChannel::new(vec!["User@iCloud.com".into()]); + assert!(ch.is_contact_allowed("user@icloud.com")); + assert!(ch.is_contact_allowed("USER@ICLOUD.COM")); + } + + #[test] + fn empty_allowlist_denies_all() { + let ch = IMessageChannel::new(vec![]); + assert!(!ch.is_contact_allowed("+1234567890")); + assert!(!ch.is_contact_allowed("anyone")); + } + + #[test] + fn name_returns_imessage() { + let ch = IMessageChannel::new(vec![]); + assert_eq!(ch.name(), "imessage"); + } + + #[test] + fn wildcard_among_others_still_allows_all() { + let ch = IMessageChannel::new(vec!["+111".into(), "*".into(), "+222".into()]); + assert!(ch.is_contact_allowed("totally-unknown")); + } + + #[test] + fn contact_with_spaces_exact_match() { + let ch = IMessageChannel::new(vec![" spaced ".into()]); + assert!(ch.is_contact_allowed(" spaced ")); + assert!(!ch.is_contact_allowed("spaced")); + } +} diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs new file mode 100644 index 0000000..b86fef2 --- /dev/null +++ b/src/channels/matrix.rs @@ -0,0 +1,467 @@ +use crate::channels::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use reqwest::Client; +use serde::Deserialize; +use tokio::sync::mpsc; + +/// Matrix channel using the Client-Server API (no SDK needed). +/// Connects to any Matrix homeserver (Element, Synapse, etc.). +#[derive(Clone)] +pub struct MatrixChannel { + homeserver: String, + access_token: String, + room_id: String, + allowed_users: Vec, + client: Client, +} + +#[derive(Debug, Deserialize)] +struct SyncResponse { + next_batch: String, + #[serde(default)] + rooms: Rooms, +} + +#[derive(Debug, Deserialize, Default)] +struct Rooms { + #[serde(default)] + join: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct JoinedRoom { + #[serde(default)] + timeline: Timeline, +} + +#[derive(Debug, Deserialize, Default)] +struct Timeline { + #[serde(default)] + events: Vec, +} + +#[derive(Debug, Deserialize)] +struct TimelineEvent { + #[serde(rename = "type")] + event_type: String, + sender: String, + #[serde(default)] + content: EventContent, +} + +#[derive(Debug, Deserialize, Default)] +struct EventContent { + #[serde(default)] + body: Option, + #[serde(default)] + msgtype: Option, +} + +#[derive(Debug, Deserialize)] +struct WhoAmIResponse { + user_id: String, +} + +impl MatrixChannel { + pub fn new( + homeserver: String, + access_token: String, + room_id: String, + allowed_users: Vec, + ) -> Self { + let homeserver = if homeserver.ends_with('/') { + homeserver[..homeserver.len() - 1].to_string() + } else { + homeserver + }; + Self { + homeserver, + access_token, + room_id, + allowed_users, + client: Client::new(), + } + } + + fn is_user_allowed(&self, sender: &str) -> bool { + if self.allowed_users.iter().any(|u| u == "*") { + return true; + } + self.allowed_users + .iter() + .any(|u| u.eq_ignore_ascii_case(sender)) + } + + async fn get_my_user_id(&self) -> anyhow::Result { + let url = format!( + "{}/_matrix/client/v3/account/whoami", + self.homeserver + ); + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.access_token)) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Matrix whoami failed: {err}"); + } + + let who: WhoAmIResponse = resp.json().await?; + Ok(who.user_id) + } +} + +#[async_trait] +impl Channel for MatrixChannel { + fn name(&self) -> &str { + "matrix" + } + + async fn send(&self, message: &str, _target: &str) -> anyhow::Result<()> { + let txn_id = format!("zc_{}", chrono::Utc::now().timestamp_millis()); + let url = format!( + "{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}", + self.homeserver, self.room_id, txn_id + ); + + let body = serde_json::json!({ + "msgtype": "m.text", + "body": message + }); + + let resp = self + .client + .put(&url) + .header("Authorization", format!("Bearer {}", self.access_token)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Matrix send failed: {err}"); + } + + Ok(()) + } + + async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> { + tracing::info!("Matrix channel listening on room {}...", self.room_id); + + let my_user_id = self.get_my_user_id().await?; + + // Initial sync to get the since token + let url = format!( + "{}/_matrix/client/v3/sync?timeout=30000&filter={{\"room\":{{\"timeline\":{{\"limit\":1}}}}}}", + self.homeserver + ); + + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.access_token)) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Matrix initial sync failed: {err}"); + } + + let sync: SyncResponse = resp.json().await?; + let mut since = sync.next_batch; + + // Long-poll loop + loop { + let url = format!( + "{}/_matrix/client/v3/sync?since={}&timeout=30000", + self.homeserver, since + ); + + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.access_token)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + tracing::warn!("Matrix sync error: {e}, retrying..."); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + continue; + } + }; + + if !resp.status().is_success() { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + continue; + } + + let sync: SyncResponse = resp.json().await?; + since = sync.next_batch; + + // Process events from our room + if let Some(room) = sync.rooms.join.get(&self.room_id) { + for event in &room.timeline.events { + // Skip our own messages + if event.sender == my_user_id { + continue; + } + + // Only process text messages + if event.event_type != "m.room.message" { + continue; + } + + if event.content.msgtype.as_deref() != Some("m.text") { + continue; + } + + let Some(ref body) = event.content.body else { + continue; + }; + + if !self.is_user_allowed(&event.sender) { + continue; + } + + let msg = ChannelMessage { + id: format!("mx_{}", chrono::Utc::now().timestamp_millis()), + sender: event.sender.clone(), + content: body.clone(), + channel: "matrix".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + } + } + + async fn health_check(&self) -> bool { + let url = format!( + "{}/_matrix/client/v3/account/whoami", + self.homeserver + ); + let Ok(resp) = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.access_token)) + .send() + .await + else { + return false; + }; + + resp.status().is_success() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_channel() -> MatrixChannel { + MatrixChannel::new( + "https://matrix.org".to_string(), + "syt_test_token".to_string(), + "!room:matrix.org".to_string(), + vec!["@user:matrix.org".to_string()], + ) + } + + #[test] + fn creates_with_correct_fields() { + let ch = make_channel(); + assert_eq!(ch.homeserver, "https://matrix.org"); + assert_eq!(ch.access_token, "syt_test_token"); + assert_eq!(ch.room_id, "!room:matrix.org"); + assert_eq!(ch.allowed_users.len(), 1); + } + + #[test] + fn strips_trailing_slash() { + let ch = MatrixChannel::new( + "https://matrix.org/".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec![], + ); + assert_eq!(ch.homeserver, "https://matrix.org"); + } + + #[test] + fn no_trailing_slash_unchanged() { + let ch = MatrixChannel::new( + "https://matrix.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec![], + ); + assert_eq!(ch.homeserver, "https://matrix.org"); + } + + #[test] + fn multiple_trailing_slashes_strips_one() { + let ch = MatrixChannel::new( + "https://matrix.org//".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec![], + ); + assert_eq!(ch.homeserver, "https://matrix.org/"); + } + + #[test] + fn wildcard_allows_anyone() { + let ch = MatrixChannel::new( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec!["*".to_string()], + ); + assert!(ch.is_user_allowed("@anyone:matrix.org")); + assert!(ch.is_user_allowed("@hacker:evil.org")); + } + + #[test] + fn specific_user_allowed() { + let ch = make_channel(); + assert!(ch.is_user_allowed("@user:matrix.org")); + } + + #[test] + fn unknown_user_denied() { + let ch = make_channel(); + assert!(!ch.is_user_allowed("@stranger:matrix.org")); + assert!(!ch.is_user_allowed("@evil:hacker.org")); + } + + #[test] + fn user_case_insensitive() { + let ch = MatrixChannel::new( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec!["@User:Matrix.org".to_string()], + ); + assert!(ch.is_user_allowed("@user:matrix.org")); + assert!(ch.is_user_allowed("@USER:MATRIX.ORG")); + } + + #[test] + fn empty_allowlist_denies_all() { + let ch = MatrixChannel::new( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec![], + ); + assert!(!ch.is_user_allowed("@anyone:matrix.org")); + } + + #[test] + fn name_returns_matrix() { + let ch = make_channel(); + assert_eq!(ch.name(), "matrix"); + } + + #[test] + fn sync_response_deserializes_empty() { + let json = r#"{"next_batch":"s123","rooms":{"join":{}}}"#; + let resp: SyncResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.next_batch, "s123"); + assert!(resp.rooms.join.is_empty()); + } + + #[test] + fn sync_response_deserializes_with_events() { + let json = r#"{ + "next_batch": "s456", + "rooms": { + "join": { + "!room:matrix.org": { + "timeline": { + "events": [ + { + "type": "m.room.message", + "sender": "@user:matrix.org", + "content": { + "msgtype": "m.text", + "body": "Hello!" + } + } + ] + } + } + } + } + }"#; + let resp: SyncResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.next_batch, "s456"); + let room = resp.rooms.join.get("!room:matrix.org").unwrap(); + assert_eq!(room.timeline.events.len(), 1); + assert_eq!(room.timeline.events[0].sender, "@user:matrix.org"); + assert_eq!(room.timeline.events[0].content.body.as_deref(), Some("Hello!")); + assert_eq!(room.timeline.events[0].content.msgtype.as_deref(), Some("m.text")); + } + + #[test] + fn sync_response_ignores_non_text_events() { + let json = r#"{ + "next_batch": "s789", + "rooms": { + "join": { + "!room:m": { + "timeline": { + "events": [ + { + "type": "m.room.member", + "sender": "@user:m", + "content": {} + } + ] + } + } + } + } + }"#; + let resp: SyncResponse = serde_json::from_str(json).unwrap(); + let room = resp.rooms.join.get("!room:m").unwrap(); + assert_eq!(room.timeline.events[0].event_type, "m.room.member"); + assert!(room.timeline.events[0].content.body.is_none()); + } + + #[test] + fn whoami_response_deserializes() { + let json = r#"{"user_id":"@bot:matrix.org"}"#; + let resp: WhoAmIResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.user_id, "@bot:matrix.org"); + } + + #[test] + fn event_content_defaults() { + let json = r#"{"type":"m.room.message","sender":"@u:m","content":{}}"#; + let event: TimelineEvent = serde_json::from_str(json).unwrap(); + assert!(event.content.body.is_none()); + assert!(event.content.msgtype.is_none()); + } + + #[test] + fn sync_response_missing_rooms_defaults() { + let json = r#"{"next_batch":"s0"}"#; + let resp: SyncResponse = serde_json::from_str(json).unwrap(); + assert!(resp.rooms.join.is_empty()); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs new file mode 100644 index 0000000..696048f --- /dev/null +++ b/src/channels/mod.rs @@ -0,0 +1,550 @@ +pub mod cli; +pub mod discord; +pub mod imessage; +pub mod matrix; +pub mod slack; +pub mod telegram; +pub mod traits; + +pub use cli::CliChannel; +pub use discord::DiscordChannel; +pub use imessage::IMessageChannel; +pub use matrix::MatrixChannel; +pub use slack::SlackChannel; +pub use telegram::TelegramChannel; +pub use traits::Channel; + +use crate::config::Config; +use crate::memory::{self, Memory}; +use crate::providers::{self, Provider}; +use anyhow::Result; +use std::sync::Arc; + +/// Maximum characters per injected workspace file (matches `OpenClaw` default). +const BOOTSTRAP_MAX_CHARS: usize = 20_000; + +/// Load workspace identity files and build a system prompt. +/// +/// Follows the `OpenClaw` framework structure: +/// 1. Tooling — tool list + descriptions +/// 2. Safety — guardrail reminder +/// 3. Skills — compact list with paths (loaded on-demand) +/// 4. Workspace — working directory +/// 5. Bootstrap files — AGENTS, SOUL, TOOLS, IDENTITY, USER, HEARTBEAT, BOOTSTRAP, MEMORY +/// 6. Date & Time — timezone for cache stability +/// 7. Runtime — host, OS, model +/// +/// Daily memory files (`memory/*.md`) are NOT injected — they are accessed +/// on-demand via `memory_recall` / `memory_search` tools. +pub fn build_system_prompt( + workspace_dir: &std::path::Path, + model_name: &str, + tools: &[(&str, &str)], + skills: &[crate::skills::Skill], +) -> String { + use std::fmt::Write; + let mut prompt = String::with_capacity(8192); + + // ── 1. Tooling ────────────────────────────────────────────── + if !tools.is_empty() { + prompt.push_str("## Tools\n\n"); + prompt.push_str("You have access to the following tools:\n\n"); + for (name, desc) in tools { + let _ = writeln!(prompt, "- **{name}**: {desc}"); + } + prompt.push('\n'); + } + + // ── 2. Safety ─────────────────────────────────────────────── + prompt.push_str("## Safety\n\n"); + prompt.push_str( + "- 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` (recoverable beats gone forever).\n\ + - When in doubt, ask before acting externally.\n\n", + ); + + // ── 3. Skills (compact list — load on-demand) ─────────────── + if !skills.is_empty() { + prompt.push_str("## Available Skills\n\n"); + prompt.push_str( + "Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n", + ); + prompt.push_str("\n"); + for skill in skills { + let _ = writeln!(prompt, " "); + let _ = writeln!(prompt, " {}", skill.name); + let _ = writeln!(prompt, " {}", skill.description); + let location = workspace_dir.join("skills").join(&skill.name).join("SKILL.md"); + let _ = writeln!(prompt, " {}", location.display()); + let _ = writeln!(prompt, " "); + } + prompt.push_str("\n\n"); + } + + // ── 4. Workspace ──────────────────────────────────────────── + let _ = writeln!(prompt, "## Workspace\n\nWorking directory: `{}`\n", workspace_dir.display()); + + // ── 5. Bootstrap files (injected into context) ────────────── + prompt.push_str("## Project Context\n\n"); + prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n"); + + let bootstrap_files = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + ]; + + for filename in &bootstrap_files { + inject_workspace_file(&mut prompt, workspace_dir, filename); + } + + // BOOTSTRAP.md — only if it exists (first-run ritual) + let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); + if bootstrap_path.exists() { + inject_workspace_file(&mut prompt, workspace_dir, "BOOTSTRAP.md"); + } + + // MEMORY.md — curated long-term memory (main session only) + inject_workspace_file(&mut prompt, workspace_dir, "MEMORY.md"); + + // ── 6. Date & Time ────────────────────────────────────────── + let now = chrono::Local::now(); + let tz = now.format("%Z").to_string(); + let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n"); + + // ── 7. Runtime ────────────────────────────────────────────── + let host = hostname::get() + .map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + let _ = writeln!( + prompt, + "## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n", + std::env::consts::OS, + ); + + if prompt.is_empty() { + "You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string() + } else { + 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) { + use std::fmt::Write; + + 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"); + if trimmed.len() > BOOTSTRAP_MAX_CHARS { + prompt.push_str(&trimmed[..BOOTSTRAP_MAX_CHARS]); + let _ = writeln!( + prompt, + "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" + ); + } else { + prompt.push_str(trimmed); + prompt.push_str("\n\n"); + } + } + Err(_) => { + // Missing-file marker (matches OpenClaw behavior) + let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n"); + } + } +} + +pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Result<()> { + match command { + super::ChannelCommands::Start => { + // Handled in main.rs (needs async), this is unreachable + unreachable!("Start is handled in main.rs") + } + super::ChannelCommands::List => { + println!("Channels:"); + println!(" ✅ CLI (always available)"); + for (name, configured) in [ + ("Telegram", config.channels_config.telegram.is_some()), + ("Discord", config.channels_config.discord.is_some()), + ("Slack", config.channels_config.slack.is_some()), + ("Webhook", config.channels_config.webhook.is_some()), + ("iMessage", config.channels_config.imessage.is_some()), + ("Matrix", config.channels_config.matrix.is_some()), + ] { + println!( + " {} {name}", + if configured { "✅" } else { "❌" } + ); + } + println!("\nTo start channels: zeroclaw channel start"); + println!("To configure: zeroclaw onboard"); + Ok(()) + } + super::ChannelCommands::Add { + channel_type, + config: _, + } => { + anyhow::bail!("Channel type '{channel_type}' — use `zeroclaw onboard` to configure channels"); + } + super::ChannelCommands::Remove { name } => { + anyhow::bail!("Remove channel '{name}' — edit ~/.zeroclaw/config.toml directly"); + } + } +} + +/// Start all configured channels and route messages to the agent +#[allow(clippy::too_many_lines)] +pub async fn start_channels(config: Config) -> Result<()> { + let provider: Arc = Arc::from(providers::create_provider( + config.default_provider.as_deref().unwrap_or("openrouter"), + config.api_key.as_deref(), + )?); + let model = config + .default_model + .clone() + .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)?); + + // 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 + let 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"), + ]; + + let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills); + + if !skills.is_empty() { + println!(" 🧩 Skills: {}", skills.iter().map(|s| s.name.as_str()).collect::>().join(", ")); + } + + // Collect active channels + let mut channels: Vec> = Vec::new(); + + if let Some(ref tg) = config.channels_config.telegram { + channels.push(Arc::new(TelegramChannel::new( + tg.bot_token.clone(), + tg.allowed_users.clone(), + ))); + } + + if let Some(ref dc) = config.channels_config.discord { + channels.push(Arc::new(DiscordChannel::new( + dc.bot_token.clone(), + dc.guild_id.clone(), + ))); + } + + if let Some(ref sl) = config.channels_config.slack { + channels.push(Arc::new(SlackChannel::new( + sl.bot_token.clone(), + sl.channel_id.clone(), + ))); + } + + if let Some(ref im) = config.channels_config.imessage { + channels.push(Arc::new(IMessageChannel::new( + im.allowed_contacts.clone(), + ))); + } + + if let Some(ref mx) = config.channels_config.matrix { + channels.push(Arc::new(MatrixChannel::new( + mx.homeserver.clone(), + mx.access_token.clone(), + mx.room_id.clone(), + mx.allowed_users.clone(), + ))); + } + + if channels.is_empty() { + println!("No channels configured. Run `zeroclaw onboard` to set up channels."); + return Ok(()); + } + + println!("🦀 ZeroClaw Channel Server"); + println!(" 🤖 Model: {model}"); + println!(" 🧠 Memory: {} (auto-save: {})", config.memory.backend, if config.memory.auto_save { "on" } else { "off" }); + println!(" 📡 Channels: {}", channels.iter().map(|c| c.name()).collect::>().join(", ")); + println!(); + println!(" Listening for messages... (Ctrl+C to stop)"); + println!(); + + // Single message bus — all channels send messages here + let (tx, mut rx) = tokio::sync::mpsc::channel::(100); + + // Spawn a listener for each channel + let mut handles = Vec::new(); + for ch in &channels { + let ch = ch.clone(); + let tx = tx.clone(); + handles.push(tokio::spawn(async move { + if let Err(e) = ch.listen(tx).await { + tracing::error!("Channel {} error: {e}", ch.name()); + } + })); + } + drop(tx); // Drop our copy so rx closes when all channels stop + + // Process incoming messages — call the LLM and reply + while let Some(msg) = rx.recv().await { + println!( + " 💬 [{}] from {}: {}", + msg.channel, + msg.sender, + if msg.content.len() > 80 { + format!("{}...", &msg.content[..80]) + } else { + msg.content.clone() + } + ); + + // Auto-save to memory + if config.memory.auto_save { + let _ = mem + .store( + &format!("{}_{}", msg.channel, msg.sender), + &msg.content, + crate::memory::MemoryCategory::Conversation, + ) + .await; + } + + // Call the LLM with system prompt (identity + soul + tools) + match provider.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature).await { + Ok(response) => { + println!( + " 🤖 Reply: {}", + if response.len() > 80 { + format!("{}...", &response[..80]) + } else { + response.clone() + } + ); + // Find the channel that sent this message and reply + for ch in &channels { + if ch.name() == msg.channel { + if let Err(e) = ch.send(&response, &msg.sender).await { + eprintln!(" ❌ Failed to reply on {}: {e}", ch.name()); + } + break; + } + } + } + Err(e) => { + eprintln!(" ❌ LLM error: {e}"); + for ch in &channels { + if ch.name() == msg.channel { + let _ = ch + .send(&format!("⚠️ Error: {e}"), &msg.sender) + .await; + break; + } + } + } + } + } + + // Wait for all channel tasks + for h in handles { + let _ = h.await; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_workspace() -> TempDir { + let tmp = TempDir::new().unwrap(); + // Create minimal workspace files + std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap(); + std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: ZeroClaw").unwrap(); + std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap(); + std::fs::write(tmp.path().join("AGENTS.md"), "# Agents\nFollow instructions.").unwrap(); + std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap(); + std::fs::write(tmp.path().join("HEARTBEAT.md"), "# Heartbeat\nCheck status.").unwrap(); + std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap(); + tmp + } + + #[test] + 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, &[]); + + // Section headers + assert!(prompt.contains("## Tools"), "missing Tools section"); + assert!(prompt.contains("## Safety"), "missing Safety section"); + assert!(prompt.contains("## Workspace"), "missing Workspace section"); + assert!(prompt.contains("## Project Context"), "missing Project Context"); + assert!(prompt.contains("## Current Date & Time"), "missing Date/Time"); + assert!(prompt.contains("## Runtime"), "missing Runtime section"); + } + + #[test] + fn prompt_injects_tools() { + let ws = make_workspace(); + let tools = vec![("shell", "Run commands"), ("memory_recall", "Search memory")]; + let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[]); + + assert!(prompt.contains("**shell**")); + assert!(prompt.contains("Run commands")); + assert!(prompt.contains("**memory_recall**")); + } + + #[test] + fn prompt_injects_safety() { + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + + assert!(prompt.contains("Do not exfiltrate private data")); + assert!(prompt.contains("Do not run destructive commands")); + assert!(prompt.contains("Prefer `trash` over `rm`")); + } + + #[test] + fn prompt_injects_workspace_files() { + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + + assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); + assert!(prompt.contains("Be helpful"), "missing SOUL content"); + assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md"); + assert!(prompt.contains("Name: ZeroClaw"), "missing IDENTITY content"); + assert!(prompt.contains("### USER.md"), "missing USER.md"); + assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md"); + assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md"); + assert!(prompt.contains("### HEARTBEAT.md"), "missing HEARTBEAT.md"); + assert!(prompt.contains("### MEMORY.md"), "missing MEMORY.md"); + assert!(prompt.contains("User likes Rust"), "missing MEMORY content"); + } + + #[test] + fn prompt_missing_file_markers() { + let tmp = TempDir::new().unwrap(); + // Empty workspace — no files at all + let prompt = build_system_prompt(tmp.path(), "model", &[], &[]); + + assert!(prompt.contains("[File not found: SOUL.md]")); + assert!(prompt.contains("[File not found: AGENTS.md]")); + assert!(prompt.contains("[File not found: IDENTITY.md]")); + } + + #[test] + fn prompt_bootstrap_only_if_exists() { + let ws = make_workspace(); + // No BOOTSTRAP.md — should not appear + let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + assert!(!prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing"); + + // 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", &[], &[]); + assert!(prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present"); + assert!(prompt2.contains("First run")); + } + + #[test] + fn prompt_no_daily_memory_injection() { + let ws = make_workspace(); + let memory_dir = ws.path().join("memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + std::fs::write(memory_dir.join(format!("{today}.md")), "# Daily\nSome note.").unwrap(); + + let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + + // Daily notes should NOT be in the system prompt (on-demand via tools) + assert!(!prompt.contains("Daily Notes"), "daily notes should not be auto-injected"); + assert!(!prompt.contains("Some note"), "daily content should not be in prompt"); + } + + #[test] + fn prompt_runtime_metadata() { + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[]); + + assert!(prompt.contains("Model: claude-sonnet-4")); + assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS))); + assert!(prompt.contains("Host:")); + } + + #[test] + fn prompt_skills_compact_list() { + let ws = make_workspace(); + let skills = vec![crate::skills::Skill { + name: "code-review".into(), + description: "Review code for bugs".into(), + version: "1.0.0".into(), + author: None, + tags: vec![], + tools: vec![], + prompts: vec!["Long prompt content that should NOT appear in system prompt".into()], + }]; + + let prompt = build_system_prompt(ws.path(), "model", &[], &skills); + + assert!(prompt.contains(""), "missing skills XML"); + assert!(prompt.contains("code-review")); + assert!(prompt.contains("Review code for bugs")); + assert!(prompt.contains("SKILL.md")); + assert!(prompt.contains("loaded on demand"), "should mention on-demand loading"); + // Full prompt content should NOT be dumped + assert!(!prompt.contains("Long prompt content that should NOT appear")); + } + + #[test] + fn prompt_truncation() { + let ws = make_workspace(); + // Write a file larger than BOOTSTRAP_MAX_CHARS + 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", &[], &[]); + + assert!(prompt.contains("truncated at"), "large files should be truncated"); + assert!(!prompt.contains(&big_content), "full content should not appear"); + } + + #[test] + fn prompt_empty_files_skipped() { + let ws = make_workspace(); + std::fs::write(ws.path().join("TOOLS.md"), "").unwrap(); + + let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + + // Empty file should not produce a header + assert!(!prompt.contains("### TOOLS.md"), "empty files should be skipped"); + } + + #[test] + fn prompt_workspace_path() { + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + + assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); + } +} diff --git a/src/channels/slack.rs b/src/channels/slack.rs new file mode 100644 index 0000000..87516f5 --- /dev/null +++ b/src/channels/slack.rs @@ -0,0 +1,174 @@ +use super::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use uuid::Uuid; + +/// Slack channel — polls conversations.history via Web API +pub struct SlackChannel { + bot_token: String, + channel_id: Option, + client: reqwest::Client, +} + +impl SlackChannel { + pub fn new(bot_token: String, channel_id: Option) -> Self { + Self { + bot_token, + channel_id, + client: reqwest::Client::new(), + } + } + + /// Get the bot's own user ID so we can ignore our own messages + async fn get_bot_user_id(&self) -> Option { + let resp: serde_json::Value = self + .client + .get("https://slack.com/api/auth.test") + .bearer_auth(&self.bot_token) + .send() + .await + .ok()? + .json() + .await + .ok()?; + + resp.get("user_id") + .and_then(|u| u.as_str()) + .map(String::from) + } +} + +#[async_trait] +impl Channel for SlackChannel { + fn name(&self) -> &str { + "slack" + } + + async fn send(&self, message: &str, channel: &str) -> anyhow::Result<()> { + let body = serde_json::json!({ + "channel": channel, + "text": message + }); + + self.client + .post("https://slack.com/api/chat.postMessage") + .bearer_auth(&self.bot_token) + .json(&body) + .send() + .await?; + + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + let channel_id = self + .channel_id + .clone() + .ok_or_else(|| anyhow::anyhow!("Slack channel_id required for listening"))?; + + let bot_user_id = self.get_bot_user_id().await.unwrap_or_default(); + let mut last_ts = String::new(); + + tracing::info!("Slack channel listening on #{channel_id}..."); + + loop { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let mut params = vec![ + ("channel", channel_id.clone()), + ("limit", "10".to_string()), + ]; + if !last_ts.is_empty() { + params.push(("oldest", last_ts.clone())); + } + + let resp = match self + .client + .get("https://slack.com/api/conversations.history") + .bearer_auth(&self.bot_token) + .query(¶ms) + .send() + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!("Slack poll error: {e}"); + continue; + } + }; + + let data: serde_json::Value = match resp.json().await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Slack parse error: {e}"); + continue; + } + }; + + if let Some(messages) = data.get("messages").and_then(|m| m.as_array()) { + // Messages come newest-first, reverse to process oldest first + for msg in messages.iter().rev() { + let ts = msg.get("ts").and_then(|t| t.as_str()).unwrap_or(""); + let user = msg + .get("user") + .and_then(|u| u.as_str()) + .unwrap_or("unknown"); + let text = msg.get("text").and_then(|t| t.as_str()).unwrap_or(""); + + // Skip bot's own messages + if user == bot_user_id { + continue; + } + + // Skip empty or already-seen + if text.is_empty() || ts <= last_ts.as_str() { + continue; + } + + last_ts = ts.to_string(); + + let channel_msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: channel_id.clone(), + content: text.to_string(), + channel: "slack".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(channel_msg).await.is_err() { + return Ok(()); + } + } + } + } + } + + async fn health_check(&self) -> bool { + self.client + .get("https://slack.com/api/auth.test") + .bearer_auth(&self.bot_token) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slack_channel_name() { + let ch = SlackChannel::new("xoxb-fake".into(), None); + assert_eq!(ch.name(), "slack"); + } + + #[test] + fn slack_channel_with_channel_id() { + let ch = SlackChannel::new("xoxb-fake".into(), Some("C12345".into())); + assert_eq!(ch.channel_id, Some("C12345".to_string())); + } +} diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs new file mode 100644 index 0000000..5d970f1 --- /dev/null +++ b/src/channels/telegram.rs @@ -0,0 +1,182 @@ +use super::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use uuid::Uuid; + +/// Telegram channel — long-polls the Bot API for updates +pub struct TelegramChannel { + bot_token: String, + allowed_users: Vec, + client: reqwest::Client, +} + +impl TelegramChannel { + pub fn new(bot_token: String, allowed_users: Vec) -> Self { + Self { + bot_token, + allowed_users, + client: reqwest::Client::new(), + } + } + + fn api_url(&self, method: &str) -> String { + format!("https://api.telegram.org/bot{}/{method}", self.bot_token) + } + + fn is_user_allowed(&self, username: &str) -> bool { + self.allowed_users.iter().any(|u| u == "*" || u == username) + } +} + +#[async_trait] +impl Channel for TelegramChannel { + fn name(&self) -> &str { + "telegram" + } + + async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> { + let body = serde_json::json!({ + "chat_id": chat_id, + "text": message, + "parse_mode": "Markdown" + }); + + self.client + .post(self.api_url("sendMessage")) + .json(&body) + .send() + .await?; + + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + let mut offset: i64 = 0; + + tracing::info!("Telegram channel listening for messages..."); + + loop { + let url = self.api_url("getUpdates"); + let body = serde_json::json!({ + "offset": offset, + "timeout": 30, + "allowed_updates": ["message"] + }); + + let resp = match self.client.post(&url).json(&body).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Telegram poll error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + let data: serde_json::Value = match resp.json().await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Telegram parse error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) { + for update in results { + // Advance offset past this update + if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) { + offset = uid + 1; + } + + let Some(message) = update.get("message") else { + continue; + }; + + let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else { + continue; + }; + + let username = message + .get("from") + .and_then(|f| f.get("username")) + .and_then(|u| u.as_str()) + .unwrap_or("unknown"); + + if !self.is_user_allowed(username) { + tracing::warn!("Telegram: ignoring message from unauthorized user: {username}"); + continue; + } + + let chat_id = message + .get("chat") + .and_then(|c| c.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()) + .unwrap_or_default(); + + let msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: chat_id, + content: text.to_string(), + channel: "telegram".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + } + } + + async fn health_check(&self) -> bool { + self.client + .get(self.api_url("getMe")) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn telegram_channel_name() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + assert_eq!(ch.name(), "telegram"); + } + + #[test] + fn telegram_api_url() { + let ch = TelegramChannel::new("123:ABC".into(), vec![]); + assert_eq!( + ch.api_url("getMe"), + "https://api.telegram.org/bot123:ABC/getMe" + ); + } + + #[test] + fn telegram_user_allowed_wildcard() { + let ch = TelegramChannel::new("t".into(), vec!["*".into()]); + assert!(ch.is_user_allowed("anyone")); + } + + #[test] + fn telegram_user_allowed_specific() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]); + assert!(ch.is_user_allowed("alice")); + assert!(!ch.is_user_allowed("eve")); + } + + #[test] + fn telegram_user_denied_empty() { + let ch = TelegramChannel::new("t".into(), vec![]); + assert!(!ch.is_user_allowed("anyone")); + } +} diff --git a/src/channels/traits.rs b/src/channels/traits.rs new file mode 100644 index 0000000..4709a1b --- /dev/null +++ b/src/channels/traits.rs @@ -0,0 +1,29 @@ +use async_trait::async_trait; + +/// A message received from or sent to a channel +#[derive(Debug, Clone)] +pub struct ChannelMessage { + pub id: String, + pub sender: String, + pub content: String, + pub channel: String, + pub timestamp: u64, +} + +/// Core channel trait — implement for any messaging platform +#[async_trait] +pub trait Channel: Send + Sync { + /// Human-readable channel name + fn name(&self) -> &str; + + /// Send a message through this channel + async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()>; + + /// Start listening for incoming messages (long-running) + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()>; + + /// Check if channel is healthy + async fn health_check(&self) -> bool { + true + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..e3c4ef9 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,7 @@ +pub mod schema; + +pub use schema::{ + AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, + MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig, + WebhookConfig, +}; diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..63f407a --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,580 @@ +use crate::security::AutonomyLevel; +use anyhow::{Context, Result}; +use directories::UserDirs; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +// ── Top-level config ────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub workspace_dir: PathBuf, + pub config_path: PathBuf, + pub api_key: Option, + pub default_provider: Option, + pub default_model: Option, + pub default_temperature: f64, + + #[serde(default)] + pub observability: ObservabilityConfig, + + #[serde(default)] + pub autonomy: AutonomyConfig, + + #[serde(default)] + pub runtime: RuntimeConfig, + + #[serde(default)] + pub heartbeat: HeartbeatConfig, + + #[serde(default)] + pub channels_config: ChannelsConfig, + + #[serde(default)] + pub memory: MemoryConfig, +} + +// ── Memory ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryConfig { + /// "sqlite" | "markdown" | "none" + pub backend: String, + /// Auto-save conversation context to memory + pub auto_save: bool, +} + +impl Default for MemoryConfig { + fn default() -> Self { + Self { + backend: "sqlite".into(), + auto_save: true, + } + } +} + +// ── Observability ───────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObservabilityConfig { + /// "none" | "log" | "prometheus" | "otel" + pub backend: String, +} + +impl Default for ObservabilityConfig { + fn default() -> Self { + Self { + backend: "none".into(), + } + } +} + +// ── Autonomy / Security ────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutonomyConfig { + pub level: AutonomyLevel, + pub workspace_only: bool, + pub allowed_commands: Vec, + pub forbidden_paths: Vec, + pub max_actions_per_hour: u32, + pub max_cost_per_day_cents: u32, +} + +impl Default for AutonomyConfig { + fn default() -> Self { + Self { + level: AutonomyLevel::Supervised, + workspace_only: true, + allowed_commands: vec![ + "git".into(), + "npm".into(), + "cargo".into(), + "ls".into(), + "cat".into(), + "grep".into(), + "find".into(), + "echo".into(), + "pwd".into(), + "wc".into(), + "head".into(), + "tail".into(), + ], + forbidden_paths: vec![ + "/etc".into(), + "/root".into(), + "~/.ssh".into(), + "~/.gnupg".into(), + ], + max_actions_per_hour: 20, + max_cost_per_day_cents: 500, + } + } +} + +// ── Runtime ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeConfig { + /// "native" | "docker" | "cloudflare" + pub kind: String, +} + +impl Default for RuntimeConfig { + fn default() -> Self { + Self { + kind: "native".into(), + } + } +} + +// ── Heartbeat ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeartbeatConfig { + pub enabled: bool, + pub interval_minutes: u32, +} + +impl Default for HeartbeatConfig { + fn default() -> Self { + Self { + enabled: false, + interval_minutes: 30, + } + } +} + +// ── Channels ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelsConfig { + pub cli: bool, + pub telegram: Option, + pub discord: Option, + pub slack: Option, + pub webhook: Option, + pub imessage: Option, + pub matrix: Option, +} + +impl Default for ChannelsConfig { + fn default() -> Self { + Self { + cli: true, + telegram: None, + discord: None, + slack: None, + webhook: None, + imessage: None, + matrix: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + pub bot_token: String, + pub allowed_users: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordConfig { + pub bot_token: String, + pub guild_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlackConfig { + pub bot_token: String, + pub app_token: Option, + pub channel_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + pub port: u16, + pub secret: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IMessageConfig { + pub allowed_contacts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatrixConfig { + pub homeserver: String, + pub access_token: String, + pub room_id: String, + pub allowed_users: Vec, +} + +// ── Config impl ────────────────────────────────────────────────── + +impl Default for Config { + fn default() -> Self { + let home = + UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf()); + let zeroclaw_dir = home.join(".zeroclaw"); + + Self { + workspace_dir: zeroclaw_dir.join("workspace"), + config_path: zeroclaw_dir.join("config.toml"), + api_key: None, + default_provider: Some("openrouter".to_string()), + default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()), + default_temperature: 0.7, + observability: ObservabilityConfig::default(), + autonomy: AutonomyConfig::default(), + runtime: RuntimeConfig::default(), + heartbeat: HeartbeatConfig::default(), + channels_config: ChannelsConfig::default(), + memory: MemoryConfig::default(), + } + } +} + +impl Config { + pub fn load_or_init() -> Result { + let home = UserDirs::new() + .map(|u| u.home_dir().to_path_buf()) + .context("Could not find home directory")?; + let zeroclaw_dir = home.join(".zeroclaw"); + let config_path = zeroclaw_dir.join("config.toml"); + + if !zeroclaw_dir.exists() { + fs::create_dir_all(&zeroclaw_dir).context("Failed to create .zeroclaw directory")?; + fs::create_dir_all(zeroclaw_dir.join("workspace")) + .context("Failed to create workspace directory")?; + } + + if config_path.exists() { + let contents = + fs::read_to_string(&config_path).context("Failed to read config file")?; + let config: Config = + toml::from_str(&contents).context("Failed to parse config file")?; + Ok(config) + } else { + let config = Config::default(); + config.save()?; + Ok(config) + } + } + + pub fn save(&self) -> Result<()> { + let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?; + fs::write(&self.config_path, toml_str).context("Failed to write config file")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + // ── Defaults ───────────────────────────────────────────── + + #[test] + fn config_default_has_sane_values() { + let c = Config::default(); + assert_eq!(c.default_provider.as_deref(), Some("openrouter")); + assert!(c.default_model.as_deref().unwrap().contains("claude")); + assert!((c.default_temperature - 0.7).abs() < f64::EPSILON); + assert!(c.api_key.is_none()); + assert!(c.workspace_dir.to_string_lossy().contains("workspace")); + assert!(c.config_path.to_string_lossy().contains("config.toml")); + } + + #[test] + fn observability_config_default() { + let o = ObservabilityConfig::default(); + assert_eq!(o.backend, "none"); + } + + #[test] + fn autonomy_config_default() { + let a = AutonomyConfig::default(); + assert_eq!(a.level, AutonomyLevel::Supervised); + assert!(a.workspace_only); + assert!(a.allowed_commands.contains(&"git".to_string())); + assert!(a.allowed_commands.contains(&"cargo".to_string())); + assert!(a.forbidden_paths.contains(&"/etc".to_string())); + assert_eq!(a.max_actions_per_hour, 20); + assert_eq!(a.max_cost_per_day_cents, 500); + } + + #[test] + fn runtime_config_default() { + let r = RuntimeConfig::default(); + assert_eq!(r.kind, "native"); + } + + #[test] + fn heartbeat_config_default() { + let h = HeartbeatConfig::default(); + assert!(!h.enabled); + assert_eq!(h.interval_minutes, 30); + } + + #[test] + fn channels_config_default() { + let c = ChannelsConfig::default(); + assert!(c.cli); + assert!(c.telegram.is_none()); + assert!(c.discord.is_none()); + } + + // ── Serde round-trip ───────────────────────────────────── + + #[test] + fn config_toml_roundtrip() { + let config = Config { + workspace_dir: PathBuf::from("/tmp/test/workspace"), + config_path: PathBuf::from("/tmp/test/config.toml"), + api_key: Some("sk-test-key".into()), + default_provider: Some("openrouter".into()), + default_model: Some("gpt-4o".into()), + default_temperature: 0.5, + observability: ObservabilityConfig { + backend: "log".into(), + }, + autonomy: AutonomyConfig { + level: AutonomyLevel::Full, + workspace_only: false, + allowed_commands: vec!["docker".into()], + forbidden_paths: vec!["/secret".into()], + max_actions_per_hour: 50, + max_cost_per_day_cents: 1000, + }, + runtime: RuntimeConfig { + kind: "docker".into(), + }, + heartbeat: HeartbeatConfig { + enabled: true, + interval_minutes: 15, + }, + channels_config: ChannelsConfig { + cli: true, + telegram: Some(TelegramConfig { + bot_token: "123:ABC".into(), + allowed_users: vec!["user1".into()], + }), + discord: None, + slack: None, + webhook: None, + imessage: None, + matrix: None, + }, + memory: MemoryConfig::default(), + }; + + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.api_key, config.api_key); + assert_eq!(parsed.default_provider, config.default_provider); + assert_eq!(parsed.default_model, config.default_model); + assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON); + assert_eq!(parsed.observability.backend, "log"); + assert_eq!(parsed.autonomy.level, AutonomyLevel::Full); + assert!(!parsed.autonomy.workspace_only); + assert_eq!(parsed.runtime.kind, "docker"); + assert!(parsed.heartbeat.enabled); + assert_eq!(parsed.heartbeat.interval_minutes, 15); + assert!(parsed.channels_config.telegram.is_some()); + assert_eq!( + parsed.channels_config.telegram.unwrap().bot_token, + "123:ABC" + ); + } + + #[test] + fn config_minimal_toml_uses_defaults() { + 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.api_key.is_none()); + assert!(parsed.default_provider.is_none()); + assert_eq!(parsed.observability.backend, "none"); + assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised); + assert_eq!(parsed.runtime.kind, "native"); + assert!(!parsed.heartbeat.enabled); + assert!(parsed.channels_config.cli); + } + + #[test] + fn config_save_and_load_tmpdir() { + let dir = std::env::temp_dir().join("zeroclaw_test_config"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let config_path = dir.join("config.toml"); + let config = Config { + workspace_dir: dir.join("workspace"), + config_path: config_path.clone(), + api_key: Some("sk-roundtrip".into()), + default_provider: Some("openrouter".into()), + default_model: Some("test-model".into()), + default_temperature: 0.9, + observability: ObservabilityConfig::default(), + autonomy: AutonomyConfig::default(), + runtime: RuntimeConfig::default(), + heartbeat: HeartbeatConfig::default(), + channels_config: ChannelsConfig::default(), + memory: MemoryConfig::default(), + }; + + config.save().unwrap(); + assert!(config_path.exists()); + + let contents = fs::read_to_string(&config_path).unwrap(); + let loaded: Config = toml::from_str(&contents).unwrap(); + assert_eq!(loaded.api_key.as_deref(), Some("sk-roundtrip")); + assert_eq!(loaded.default_model.as_deref(), Some("test-model")); + assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON); + + let _ = fs::remove_dir_all(&dir); + } + + // ── Telegram / Discord config ──────────────────────────── + + #[test] + fn telegram_config_serde() { + let tc = TelegramConfig { + bot_token: "123:XYZ".into(), + allowed_users: vec!["alice".into(), "bob".into()], + }; + let json = serde_json::to_string(&tc).unwrap(); + let parsed: TelegramConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.bot_token, "123:XYZ"); + assert_eq!(parsed.allowed_users.len(), 2); + } + + #[test] + fn discord_config_serde() { + let dc = DiscordConfig { + bot_token: "discord-token".into(), + guild_id: Some("12345".into()), + }; + let json = serde_json::to_string(&dc).unwrap(); + let parsed: DiscordConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.bot_token, "discord-token"); + assert_eq!(parsed.guild_id.as_deref(), Some("12345")); + } + + #[test] + fn discord_config_optional_guild() { + let dc = DiscordConfig { + bot_token: "tok".into(), + guild_id: None, + }; + let json = serde_json::to_string(&dc).unwrap(); + let parsed: DiscordConfig = serde_json::from_str(&json).unwrap(); + assert!(parsed.guild_id.is_none()); + } + + // ── iMessage / Matrix config ──────────────────────────── + + #[test] + fn imessage_config_serde() { + let ic = IMessageConfig { + allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()], + }; + let json = serde_json::to_string(&ic).unwrap(); + let parsed: IMessageConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.allowed_contacts.len(), 2); + assert_eq!(parsed.allowed_contacts[0], "+1234567890"); + } + + #[test] + fn imessage_config_empty_contacts() { + let ic = IMessageConfig { + allowed_contacts: vec![], + }; + let json = serde_json::to_string(&ic).unwrap(); + let parsed: IMessageConfig = serde_json::from_str(&json).unwrap(); + assert!(parsed.allowed_contacts.is_empty()); + } + + #[test] + fn imessage_config_wildcard() { + let ic = IMessageConfig { + allowed_contacts: vec!["*".into()], + }; + let toml_str = toml::to_string(&ic).unwrap(); + let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.allowed_contacts, vec!["*"]); + } + + #[test] + fn matrix_config_serde() { + let mc = MatrixConfig { + homeserver: "https://matrix.org".into(), + access_token: "syt_token_abc".into(), + room_id: "!room123:matrix.org".into(), + allowed_users: vec!["@user:matrix.org".into()], + }; + let json = serde_json::to_string(&mc).unwrap(); + let parsed: MatrixConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.homeserver, "https://matrix.org"); + assert_eq!(parsed.access_token, "syt_token_abc"); + assert_eq!(parsed.room_id, "!room123:matrix.org"); + assert_eq!(parsed.allowed_users.len(), 1); + } + + #[test] + fn matrix_config_toml_roundtrip() { + let mc = MatrixConfig { + homeserver: "https://synapse.local:8448".into(), + access_token: "tok".into(), + room_id: "!abc:synapse.local".into(), + allowed_users: vec!["@admin:synapse.local".into(), "*".into()], + }; + let toml_str = toml::to_string(&mc).unwrap(); + let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.homeserver, "https://synapse.local:8448"); + assert_eq!(parsed.allowed_users.len(), 2); + } + + #[test] + fn channels_config_with_imessage_and_matrix() { + let c = ChannelsConfig { + cli: true, + telegram: None, + discord: None, + slack: None, + webhook: None, + imessage: Some(IMessageConfig { + allowed_contacts: vec!["+1".into()], + }), + matrix: Some(MatrixConfig { + homeserver: "https://m.org".into(), + access_token: "tok".into(), + room_id: "!r:m".into(), + allowed_users: vec!["@u:m".into()], + }), + }; + let toml_str = toml::to_string_pretty(&c).unwrap(); + let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); + assert!(parsed.imessage.is_some()); + assert!(parsed.matrix.is_some()); + assert_eq!( + parsed.imessage.unwrap().allowed_contacts, + vec!["+1"] + ); + assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org"); + } + + #[test] + fn channels_config_default_has_no_imessage_matrix() { + let c = ChannelsConfig::default(); + assert!(c.imessage.is_none()); + assert!(c.matrix.is_none()); + } +} diff --git a/src/cron/mod.rs b/src/cron/mod.rs new file mode 100644 index 0000000..8f52701 --- /dev/null +++ b/src/cron/mod.rs @@ -0,0 +1,25 @@ +use crate::config::Config; +use anyhow::Result; + +pub fn handle_command(command: super::CronCommands, _config: Config) -> Result<()> { + match command { + super::CronCommands::List => { + println!("No scheduled tasks yet."); + println!("\nUsage:"); + println!(" zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'"); + Ok(()) + } + super::CronCommands::Add { + expression, + command, + } => { + println!("Cron scheduling coming soon!"); + println!(" Expression: {expression}"); + println!(" Command: {command}"); + Ok(()) + } + super::CronCommands::Remove { id } => { + anyhow::bail!("Remove task '{id}' not yet implemented"); + } + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs new file mode 100644 index 0000000..49fa64d --- /dev/null +++ b/src/gateway/mod.rs @@ -0,0 +1,180 @@ +use crate::config::Config; +use crate::memory::{self, Memory, MemoryCategory}; +use crate::providers::{self, Provider}; +use anyhow::Result; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Run a minimal HTTP gateway (webhook + health check) +/// Zero new dependencies — uses raw TCP + tokio. +#[allow(clippy::too_many_lines)] +pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { + let addr = format!("{host}:{port}"); + let listener = TcpListener::bind(&addr).await?; + + let provider: Arc = Arc::from(providers::create_provider( + config.default_provider.as_deref().unwrap_or("openrouter"), + config.api_key.as_deref(), + )?); + let model = config + .default_model + .clone() + .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)?); + + println!("🦀 ZeroClaw Gateway listening on http://{addr}"); + println!(" POST /webhook — {{\"message\": \"your prompt\"}}"); + println!(" GET /health — health check"); + println!(" Press Ctrl+C to stop.\n"); + + loop { + let (mut stream, peer) = listener.accept().await?; + let provider = provider.clone(); + let model = model.clone(); + let mem = mem.clone(); + let auto_save = config.memory.auto_save; + + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + let n = match stream.read(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + + let request = String::from_utf8_lossy(&buf[..n]); + let first_line = request.lines().next().unwrap_or(""); + let parts: Vec<&str> = first_line.split_whitespace().collect(); + + if let [method, path, ..] = parts.as_slice() { + tracing::info!("{peer} → {method} {path}"); + handle_request(&mut stream, method, path, &request, &provider, &model, temperature, &mem, auto_save).await; + } else { + let _ = send_response(&mut stream, 400, "Bad Request").await; + } + }); + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle_request( + stream: &mut tokio::net::TcpStream, + method: &str, + path: &str, + request: &str, + provider: &Arc, + model: &str, + temperature: f64, + mem: &Arc, + auto_save: bool, +) { + match (method, path) { + ("GET", "/health") => { + let body = serde_json::json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION"), + "memory": mem.name(), + "memory_healthy": mem.health_check().await, + }); + let _ = send_json(stream, 200, &body).await; + } + + ("POST", "/webhook") => { + handle_webhook(stream, request, provider, model, temperature, mem, auto_save).await; + } + + _ => { + let body = serde_json::json!({ + "error": "Not found", + "routes": ["GET /health", "POST /webhook"] + }); + let _ = send_json(stream, 404, &body).await; + } + } +} + +async fn handle_webhook( + stream: &mut tokio::net::TcpStream, + request: &str, + provider: &Arc, + model: &str, + temperature: f64, + mem: &Arc, + auto_save: bool, +) { + let body_str = request + .split("\r\n\r\n") + .nth(1) + .or_else(|| request.split("\n\n").nth(1)) + .unwrap_or(""); + + let Ok(parsed) = serde_json::from_str::(body_str) else { + let err = serde_json::json!({"error": "Invalid JSON. Expected: {\"message\": \"...\"}"}); + let _ = send_json(stream, 400, &err).await; + return; + }; + + let Some(message) = parsed.get("message").and_then(|v| v.as_str()) else { + let err = serde_json::json!({"error": "Missing 'message' field in JSON"}); + let _ = send_json(stream, 400, &err).await; + return; + }; + + if auto_save { + let _ = mem + .store("webhook_msg", message, MemoryCategory::Conversation) + .await; + } + + match provider.chat(message, model, temperature).await { + Ok(response) => { + let body = serde_json::json!({"response": response, "model": model}); + let _ = send_json(stream, 200, &body).await; + } + Err(e) => { + let err = serde_json::json!({"error": format!("LLM error: {e}")}); + let _ = send_json(stream, 500, &err).await; + } + } +} + +async fn send_response( + stream: &mut tokio::net::TcpStream, + status: u16, + body: &str, +) -> std::io::Result<()> { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Unknown", + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await +} + +async fn send_json( + stream: &mut tokio::net::TcpStream, + status: u16, + body: &serde_json::Value, +) -> std::io::Result<()> { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Unknown", + }; + let json = serde_json::to_string(body).unwrap_or_default(); + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json}", + json.len() + ); + stream.write_all(response.as_bytes()).await +} diff --git a/src/heartbeat/engine.rs b/src/heartbeat/engine.rs new file mode 100644 index 0000000..8c7d084 --- /dev/null +++ b/src/heartbeat/engine.rs @@ -0,0 +1,296 @@ +use crate::config::HeartbeatConfig; +use crate::observability::{Observer, ObserverEvent}; +use anyhow::Result; +use std::path::Path; +use std::sync::Arc; +use tokio::time::{self, Duration}; +use tracing::{info, warn}; + +/// Heartbeat engine — reads HEARTBEAT.md and executes tasks periodically +pub struct HeartbeatEngine { + config: HeartbeatConfig, + workspace_dir: std::path::PathBuf, + observer: Arc, +} + +impl HeartbeatEngine { + pub fn new( + config: HeartbeatConfig, + workspace_dir: std::path::PathBuf, + observer: Arc, + ) -> Self { + Self { + config, + workspace_dir, + observer, + } + } + + /// Start the heartbeat loop (runs until cancelled) + pub async fn run(&self) -> Result<()> { + if !self.config.enabled { + info!("Heartbeat disabled"); + return Ok(()); + } + + let interval_mins = self.config.interval_minutes.max(5); + info!("💓 Heartbeat started: every {} minutes", interval_mins); + + let mut interval = time::interval(Duration::from_secs(u64::from(interval_mins) * 60)); + + loop { + interval.tick().await; + self.observer.record_event(&ObserverEvent::HeartbeatTick); + + match self.tick().await { + Ok(tasks) => { + if tasks > 0 { + info!("💓 Heartbeat: processed {} tasks", tasks); + } + } + Err(e) => { + warn!("💓 Heartbeat error: {}", e); + self.observer.record_event(&ObserverEvent::Error { + component: "heartbeat".into(), + message: e.to_string(), + }); + } + } + } + } + + /// Single heartbeat tick — read HEARTBEAT.md and return task count + async fn tick(&self) -> Result { + let heartbeat_path = self.workspace_dir.join("HEARTBEAT.md"); + + if !heartbeat_path.exists() { + return Ok(0); + } + + let content = tokio::fs::read_to_string(&heartbeat_path).await?; + let tasks = Self::parse_tasks(&content); + + Ok(tasks.len()) + } + + /// Parse tasks from HEARTBEAT.md (lines starting with `- `) + fn parse_tasks(content: &str) -> Vec { + content + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + trimmed.strip_prefix("- ").map(ToString::to_string) + }) + .collect() + } + + /// Create a default HEARTBEAT.md if it doesn't exist + pub async fn ensure_heartbeat_file(workspace_dir: &Path) -> Result<()> { + let path = workspace_dir.join("HEARTBEAT.md"); + if !path.exists() { + let default = "# Periodic Tasks\n\n\ + # Add tasks below (one per line, starting with `- `)\n\ + # The agent will check this file on each heartbeat tick.\n\ + #\n\ + # Examples:\n\ + # - Check my email for important messages\n\ + # - Review my calendar for upcoming events\n\ + # - Check the weather forecast\n"; + tokio::fs::write(&path, default).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_tasks_basic() { + let content = "# Tasks\n\n- Check email\n- Review calendar\nNot a task\n- Third task"; + let tasks = HeartbeatEngine::parse_tasks(content); + assert_eq!(tasks.len(), 3); + assert_eq!(tasks[0], "Check email"); + assert_eq!(tasks[1], "Review calendar"); + assert_eq!(tasks[2], "Third task"); + } + + #[test] + fn parse_tasks_empty_content() { + assert!(HeartbeatEngine::parse_tasks("").is_empty()); + } + + #[test] + fn parse_tasks_only_comments() { + let tasks = HeartbeatEngine::parse_tasks("# No tasks here\n\nJust comments\n# Another"); + assert!(tasks.is_empty()); + } + + #[test] + fn parse_tasks_with_leading_whitespace() { + let content = " - Indented task\n\t- Tab indented"; + let tasks = HeartbeatEngine::parse_tasks(content); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0], "Indented task"); + assert_eq!(tasks[1], "Tab indented"); + } + + #[test] + fn parse_tasks_dash_without_space_ignored() { + let content = "- Real task\n-\n- Another"; + let tasks = HeartbeatEngine::parse_tasks(content); + // "-" trimmed = "-", does NOT start with "- " => skipped + // "- Real task" => "Real task" + // "- Another" => "Another" + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0], "Real task"); + assert_eq!(tasks[1], "Another"); + } + + #[test] + fn parse_tasks_trailing_space_bullet_trimmed_to_dash() { + // "- " trimmed becomes "-" (trim removes trailing space) + // "-" does NOT start with "- " => skipped + let content = "- "; + let tasks = HeartbeatEngine::parse_tasks(content); + assert_eq!(tasks.len(), 0); + } + + #[test] + fn parse_tasks_bullet_with_content_after_spaces() { + // "- hello " trimmed becomes "- hello" => starts_with "- " => "hello" + let content = "- hello "; + let tasks = HeartbeatEngine::parse_tasks(content); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0], "hello"); + } + + #[test] + fn parse_tasks_unicode() { + let content = "- Check email 📧\n- Review calendar 📅\n- 日本語タスク"; + let tasks = HeartbeatEngine::parse_tasks(content); + assert_eq!(tasks.len(), 3); + assert!(tasks[0].contains("📧")); + assert!(tasks[2].contains("日本語")); + } + + #[test] + fn parse_tasks_mixed_markdown() { + let content = "# Periodic Tasks\n\n## Quick\n- Task A\n\n## Long\n- Task B\n\n* Not a dash bullet\n1. Not numbered"; + let tasks = HeartbeatEngine::parse_tasks(content); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0], "Task A"); + assert_eq!(tasks[1], "Task B"); + } + + #[test] + fn parse_tasks_single_task() { + let tasks = HeartbeatEngine::parse_tasks("- Only one"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0], "Only one"); + } + + #[test] + fn parse_tasks_many_tasks() { + let content: String = (0..100).map(|i| format!("- Task {i}\n")).collect(); + let tasks = HeartbeatEngine::parse_tasks(&content); + assert_eq!(tasks.len(), 100); + assert_eq!(tasks[99], "Task 99"); + } + + #[tokio::test] + async fn ensure_heartbeat_file_creates_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_heartbeat"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + HeartbeatEngine::ensure_heartbeat_file(&dir).await.unwrap(); + + let path = dir.join("HEARTBEAT.md"); + assert!(path.exists()); + let content = tokio::fs::read_to_string(&path).await.unwrap(); + assert!(content.contains("Periodic Tasks")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn ensure_heartbeat_file_does_not_overwrite() { + let dir = std::env::temp_dir().join("zeroclaw_test_heartbeat_no_overwrite"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let path = dir.join("HEARTBEAT.md"); + tokio::fs::write(&path, "- My custom task").await.unwrap(); + + HeartbeatEngine::ensure_heartbeat_file(&dir).await.unwrap(); + + let content = tokio::fs::read_to_string(&path).await.unwrap(); + assert_eq!(content, "- My custom task"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn tick_returns_zero_when_no_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_tick_no_file"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let observer: Arc = Arc::new(crate::observability::NoopObserver); + let engine = HeartbeatEngine::new( + HeartbeatConfig { + enabled: true, + interval_minutes: 30, + }, + dir.clone(), + observer, + ); + let count = engine.tick().await.unwrap(); + assert_eq!(count, 0); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn tick_counts_tasks_from_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_tick_count"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + tokio::fs::write(dir.join("HEARTBEAT.md"), "- A\n- B\n- C") + .await + .unwrap(); + + let observer: Arc = Arc::new(crate::observability::NoopObserver); + let engine = HeartbeatEngine::new( + HeartbeatConfig { + enabled: true, + interval_minutes: 30, + }, + dir.clone(), + observer, + ); + let count = engine.tick().await.unwrap(); + assert_eq!(count, 3); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn run_returns_immediately_when_disabled() { + let observer: Arc = Arc::new(crate::observability::NoopObserver); + let engine = HeartbeatEngine::new( + HeartbeatConfig { + enabled: false, + interval_minutes: 30, + }, + std::env::temp_dir(), + observer, + ); + // Should return Ok immediately, not loop forever + let result = engine.run().await; + assert!(result.is_ok()); + } +} diff --git a/src/heartbeat/mod.rs b/src/heartbeat/mod.rs new file mode 100644 index 0000000..702e611 --- /dev/null +++ b/src/heartbeat/mod.rs @@ -0,0 +1 @@ +pub mod engine; diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 0000000..59d300e --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,234 @@ +pub mod registry; + +use crate::config::Config; +use anyhow::Result; + +/// Integration status +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntegrationStatus { + /// Fully implemented and ready to use + Available, + /// Configured and active + Active, + /// Planned but not yet implemented + ComingSoon, +} + +/// Integration category +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntegrationCategory { + Chat, + AiModel, + Productivity, + MusicAudio, + SmartHome, + ToolsAutomation, + MediaCreative, + Social, + Platform, +} + +impl IntegrationCategory { + pub fn label(self) -> &'static str { + match self { + Self::Chat => "Chat Providers", + Self::AiModel => "AI Models", + Self::Productivity => "Productivity", + Self::MusicAudio => "Music & Audio", + Self::SmartHome => "Smart Home", + Self::ToolsAutomation => "Tools & Automation", + Self::MediaCreative => "Media & Creative", + Self::Social => "Social", + Self::Platform => "Platforms", + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::Chat, + Self::AiModel, + Self::Productivity, + Self::MusicAudio, + Self::SmartHome, + Self::ToolsAutomation, + Self::MediaCreative, + Self::Social, + Self::Platform, + ] + } +} + +/// A registered integration +pub struct IntegrationEntry { + pub name: &'static str, + pub description: &'static str, + pub category: IntegrationCategory, + pub status_fn: fn(&Config) -> IntegrationStatus, +} + +/// Handle the `integrations` CLI command +pub fn handle_command(command: super::IntegrationCommands, config: &Config) -> Result<()> { + match command { + super::IntegrationCommands::List { category } => { + list_integrations(config, category.as_deref()) + } + super::IntegrationCommands::Info { name } => show_integration_info(config, &name), + } +} + +#[allow(clippy::unnecessary_wraps)] +fn list_integrations(config: &Config, filter_category: Option<&str>) -> Result<()> { + let entries = registry::all_integrations(); + + let mut available = 0u32; + let mut active = 0u32; + let mut coming = 0u32; + + for &cat in IntegrationCategory::all() { + // Filter by category if specified + if let Some(filter) = filter_category { + let filter_lower = filter.to_lowercase(); + let cat_lower = cat.label().to_lowercase(); + if !cat_lower.contains(&filter_lower) { + continue; + } + } + + let cat_entries: Vec<&IntegrationEntry> = + entries.iter().filter(|e| e.category == cat).collect(); + + if cat_entries.is_empty() { + continue; + } + + println!("\n ⟩ {}", console::style(cat.label()).white().bold()); + + for entry in &cat_entries { + let status = (entry.status_fn)(config); + let (icon, label) = match status { + IntegrationStatus::Active => { + active += 1; + ("✅", console::style("active").green()) + } + IntegrationStatus::Available => { + available += 1; + ("⚪", console::style("available").dim()) + } + IntegrationStatus::ComingSoon => { + coming += 1; + ("🔜", console::style("coming soon").dim()) + } + }; + println!( + " {icon} {:<22} {:<30} {}", + console::style(entry.name).white().bold(), + entry.description, + label + ); + } + } + + let total = available + active + coming; + println!(); + println!(" {total} integrations: {active} active, {available} available, {coming} coming soon"); + println!(); + println!(" Configure: zeroclaw onboard"); + println!(" Details: zeroclaw integrations info "); + println!(); + + Ok(()) +} + +fn show_integration_info(config: &Config, name: &str) -> Result<()> { + let entries = registry::all_integrations(); + let name_lower = name.to_lowercase(); + + let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else { + anyhow::bail!( + "Unknown integration: {name}. Run `zeroclaw integrations list` to see all." + ); + }; + + let status = (entry.status_fn)(config); + let (icon, label) = match status { + IntegrationStatus::Active => ("✅", "Active"), + IntegrationStatus::Available => ("⚪", "Available"), + IntegrationStatus::ComingSoon => ("🔜", "Coming Soon"), + }; + + println!(); + println!(" {} {} — {}", icon, console::style(entry.name).white().bold(), entry.description); + println!(" Category: {}", entry.category.label()); + println!(" Status: {label}"); + println!(); + + // Show setup hints based on integration + match entry.name { + "Telegram" => { + println!(" Setup:"); + println!(" 1. Message @BotFather on Telegram"); + println!(" 2. Create a bot and copy the token"); + println!(" 3. Run: zeroclaw onboard"); + println!(" 4. Start: zeroclaw channel start"); + } + "Discord" => { + println!(" Setup:"); + println!(" 1. Go to https://discord.com/developers/applications"); + println!(" 2. Create app → Bot → Copy token"); + println!(" 3. Enable MESSAGE CONTENT intent"); + println!(" 4. Run: zeroclaw onboard"); + } + "Slack" => { + println!(" Setup:"); + println!(" 1. Go to https://api.slack.com/apps"); + println!(" 2. Create app → Bot Token Scopes → Install"); + println!(" 3. Run: zeroclaw onboard"); + } + "OpenRouter" => { + println!(" Setup:"); + println!(" 1. Get API key at https://openrouter.ai/keys"); + println!(" 2. Run: zeroclaw onboard"); + println!(" Access 200+ models with one key."); + } + "Ollama" => { + println!(" Setup:"); + println!(" 1. Install: brew install ollama"); + println!(" 2. Pull a model: ollama pull llama3"); + println!(" 3. Set provider to 'ollama' in config.toml"); + } + "iMessage" => { + println!(" Setup (macOS only):"); + println!(" Uses AppleScript bridge to send/receive iMessages."); + println!(" Requires Full Disk Access in System Settings → Privacy."); + } + "GitHub" => { + println!(" Setup:"); + println!(" 1. Create a personal access token at https://github.com/settings/tokens"); + println!(" 2. Add to config: [integrations.github] token = \"ghp_...\""); + } + "Browser" => { + println!(" Built-in:"); + println!(" ZeroClaw can control Chrome/Chromium for web tasks."); + println!(" Uses headless browser automation."); + } + "Cron" => { + println!(" Built-in:"); + println!(" Schedule tasks in ~/.zeroclaw/workspace/cron/"); + println!(" Run: zeroclaw cron list"); + } + "Webhooks" => { + println!(" Built-in:"); + println!(" HTTP endpoint for external triggers."); + println!(" Run: zeroclaw gateway"); + } + _ => { + if status == IntegrationStatus::ComingSoon { + println!(" This integration is planned. Stay tuned!"); + println!(" Track progress: https://github.com/theonlyhennygod/zeroclaw"); + } + } + } + + println!(); + Ok(()) +} diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs new file mode 100644 index 0000000..0a2e3ea --- /dev/null +++ b/src/integrations/registry.rs @@ -0,0 +1,821 @@ +use super::{IntegrationCategory, IntegrationEntry, IntegrationStatus}; + +/// Returns the full catalog of integrations +#[allow(clippy::too_many_lines)] +pub fn all_integrations() -> Vec { + vec![ + // ── Chat Providers ────────────────────────────────────── + IntegrationEntry { + name: "Telegram", + description: "Bot API — long-polling", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.telegram.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Discord", + description: "Servers, channels & DMs", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.discord.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Slack", + description: "Workspace apps via Web API", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.slack.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Webhooks", + description: "HTTP endpoint for triggers", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.webhook.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "WhatsApp", + description: "QR pairing via web bridge", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Signal", + description: "Privacy-focused via signal-cli", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "iMessage", + description: "macOS AppleScript bridge", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.imessage.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Microsoft Teams", + description: "Enterprise chat support", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Matrix", + description: "Matrix protocol (Element)", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.matrix.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Nostr", + description: "Decentralized DMs (NIP-04)", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "WebChat", + description: "Browser-based chat UI", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Nextcloud Talk", + description: "Self-hosted Nextcloud chat", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Zalo", + description: "Zalo Bot API", + category: IntegrationCategory::Chat, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── AI Models ─────────────────────────────────────────── + IntegrationEntry { + name: "OpenRouter", + description: "200+ models, 1 API key", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("openrouter") && c.api_key.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Anthropic", + description: "Claude 3.5/4 Sonnet & Opus", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("anthropic") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "OpenAI", + description: "GPT-4o, GPT-5, o1", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("openai") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Google", + description: "Gemini 2.5 Pro/Flash", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_model.as_deref().is_some_and(|m| m.starts_with("google/")) { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "DeepSeek", + description: "DeepSeek V3 & R1", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_model.as_deref().is_some_and(|m| m.starts_with("deepseek/")) { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "xAI", + description: "Grok 3 & 4", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_model.as_deref().is_some_and(|m| m.starts_with("x-ai/")) { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Mistral", + description: "Mistral Large & Codestral", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_model.as_deref().is_some_and(|m| m.starts_with("mistral")) { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Ollama", + description: "Local models (Llama, etc.)", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("ollama") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Perplexity", + description: "Search-augmented AI", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("perplexity") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Hugging Face", + description: "Open-source models", + category: IntegrationCategory::AiModel, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "LM Studio", + description: "Local model server", + category: IntegrationCategory::AiModel, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Venice", + description: "Privacy-first inference (Llama, Opus)", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("venice") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Vercel AI", + description: "Vercel AI Gateway", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("vercel") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Cloudflare AI", + description: "Cloudflare AI Gateway", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("cloudflare") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Moonshot", + description: "Kimi & Kimi Coding", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("moonshot") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Synthetic", + description: "Synthetic AI models", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("synthetic") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "OpenCode Zen", + description: "Code-focused AI models", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("opencode") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Z.AI", + description: "Z.AI inference", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("zai") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "GLM", + description: "ChatGLM / Zhipu models", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("glm") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "MiniMax", + description: "MiniMax AI models", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("minimax") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Amazon Bedrock", + description: "AWS managed model access", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("bedrock") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Qianfan", + description: "Baidu AI models", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("qianfan") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Groq", + description: "Ultra-fast LPU inference", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("groq") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Together AI", + description: "Open-source model hosting", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("together") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Fireworks AI", + description: "Fast open-source inference", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("fireworks") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Cohere", + description: "Command R+ & embeddings", + category: IntegrationCategory::AiModel, + status_fn: |c| { + if c.default_provider.as_deref() == Some("cohere") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + // ── Productivity ──────────────────────────────────────── + IntegrationEntry { + name: "GitHub", + description: "Code, issues, PRs", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Notion", + description: "Workspace & databases", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Apple Notes", + description: "Native macOS/iOS notes", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Apple Reminders", + description: "Task management", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Obsidian", + description: "Knowledge graph notes", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Things 3", + description: "GTD task manager", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Bear Notes", + description: "Markdown notes", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Trello", + description: "Kanban boards", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Linear", + description: "Issue tracking", + category: IntegrationCategory::Productivity, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── Music & Audio ─────────────────────────────────────── + IntegrationEntry { + name: "Spotify", + description: "Music playback control", + category: IntegrationCategory::MusicAudio, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Sonos", + description: "Multi-room audio", + category: IntegrationCategory::MusicAudio, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Shazam", + description: "Song recognition", + category: IntegrationCategory::MusicAudio, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── Smart Home ────────────────────────────────────────── + IntegrationEntry { + name: "Home Assistant", + description: "Home automation hub", + category: IntegrationCategory::SmartHome, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Philips Hue", + description: "Smart lighting", + category: IntegrationCategory::SmartHome, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "8Sleep", + description: "Smart mattress", + category: IntegrationCategory::SmartHome, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── Tools & Automation ────────────────────────────────── + IntegrationEntry { + name: "Browser", + description: "Chrome/Chromium control", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::Available, + }, + IntegrationEntry { + name: "Shell", + description: "Terminal command execution", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::Active, + }, + IntegrationEntry { + name: "File System", + description: "Read/write files", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::Active, + }, + IntegrationEntry { + name: "Cron", + description: "Scheduled tasks", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::Available, + }, + IntegrationEntry { + name: "Voice", + description: "Voice wake + talk mode", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Gmail", + description: "Email triggers & send", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "1Password", + description: "Secure credentials", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Weather", + description: "Forecasts & conditions", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Canvas", + description: "Visual workspace + A2UI", + category: IntegrationCategory::ToolsAutomation, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── Media & Creative ──────────────────────────────────── + IntegrationEntry { + name: "Image Gen", + description: "AI image generation", + category: IntegrationCategory::MediaCreative, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "GIF Search", + description: "Find the perfect GIF", + category: IntegrationCategory::MediaCreative, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Screen Capture", + description: "Screenshot & screen control", + category: IntegrationCategory::MediaCreative, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Camera", + description: "Photo/video capture", + category: IntegrationCategory::MediaCreative, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── Social ────────────────────────────────────────────── + IntegrationEntry { + name: "Twitter/X", + description: "Tweet, reply, search", + category: IntegrationCategory::Social, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + IntegrationEntry { + name: "Email", + description: "Send & read emails", + category: IntegrationCategory::Social, + status_fn: |_| IntegrationStatus::ComingSoon, + }, + // ── Platforms ─────────────────────────────────────────── + IntegrationEntry { + name: "macOS", + description: "Native support + AppleScript", + category: IntegrationCategory::Platform, + status_fn: |_| { + if cfg!(target_os = "macos") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Linux", + description: "Native support", + category: IntegrationCategory::Platform, + status_fn: |_| { + if cfg!(target_os = "linux") { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, + IntegrationEntry { + name: "Windows", + description: "WSL2 recommended", + category: IntegrationCategory::Platform, + status_fn: |_| IntegrationStatus::Available, + }, + IntegrationEntry { + name: "iOS", + description: "Chat via Telegram/Discord", + category: IntegrationCategory::Platform, + status_fn: |_| IntegrationStatus::Available, + }, + IntegrationEntry { + name: "Android", + description: "Chat via Telegram/Discord", + category: IntegrationCategory::Platform, + status_fn: |_| IntegrationStatus::Available, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::config::schema::{ + ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig, + }; + + #[test] + fn registry_has_entries() { + let entries = all_integrations(); + assert!(entries.len() >= 50, "Expected 50+ integrations, got {}", entries.len()); + } + + #[test] + fn all_categories_represented() { + let entries = all_integrations(); + for cat in IntegrationCategory::all() { + let count = entries.iter().filter(|e| e.category == *cat).count(); + assert!(count > 0, "Category {:?} has no entries", cat); + } + } + + #[test] + fn status_functions_dont_panic() { + let config = Config::default(); + let entries = all_integrations(); + for entry in &entries { + let _ = (entry.status_fn)(&config); + } + } + + #[test] + fn no_duplicate_names() { + let entries = all_integrations(); + let mut seen = std::collections::HashSet::new(); + for entry in &entries { + assert!( + seen.insert(entry.name), + "Duplicate integration name: {}", + entry.name + ); + } + } + + #[test] + fn no_empty_names_or_descriptions() { + let entries = all_integrations(); + for entry in &entries { + assert!(!entry.name.is_empty(), "Found integration with empty name"); + assert!( + !entry.description.is_empty(), + "Integration '{}' has empty description", + entry.name + ); + } + } + + #[test] + fn telegram_active_when_configured() { + let mut config = Config::default(); + config.channels_config.telegram = Some(TelegramConfig { + bot_token: "123:ABC".into(), + allowed_users: vec!["user".into()], + }); + let entries = all_integrations(); + let tg = entries.iter().find(|e| e.name == "Telegram").unwrap(); + assert!(matches!((tg.status_fn)(&config), IntegrationStatus::Active)); + } + + #[test] + fn telegram_available_when_not_configured() { + let config = Config::default(); + let entries = all_integrations(); + let tg = entries.iter().find(|e| e.name == "Telegram").unwrap(); + assert!(matches!((tg.status_fn)(&config), IntegrationStatus::Available)); + } + + #[test] + fn imessage_active_when_configured() { + let mut config = Config::default(); + config.channels_config.imessage = Some(IMessageConfig { + allowed_contacts: vec!["*".into()], + }); + let entries = all_integrations(); + let im = entries.iter().find(|e| e.name == "iMessage").unwrap(); + assert!(matches!((im.status_fn)(&config), IntegrationStatus::Active)); + } + + #[test] + fn imessage_available_when_not_configured() { + let config = Config::default(); + let entries = all_integrations(); + let im = entries.iter().find(|e| e.name == "iMessage").unwrap(); + assert!(matches!((im.status_fn)(&config), IntegrationStatus::Available)); + } + + #[test] + fn matrix_active_when_configured() { + let mut config = Config::default(); + config.channels_config.matrix = Some(MatrixConfig { + homeserver: "https://m.org".into(), + access_token: "tok".into(), + room_id: "!r:m".into(), + allowed_users: vec![], + }); + let entries = all_integrations(); + let mx = entries.iter().find(|e| e.name == "Matrix").unwrap(); + assert!(matches!((mx.status_fn)(&config), IntegrationStatus::Active)); + } + + #[test] + fn matrix_available_when_not_configured() { + let config = Config::default(); + let entries = all_integrations(); + let mx = entries.iter().find(|e| e.name == "Matrix").unwrap(); + assert!(matches!((mx.status_fn)(&config), IntegrationStatus::Available)); + } + + #[test] + fn coming_soon_integrations_stay_coming_soon() { + let config = Config::default(); + let entries = all_integrations(); + for name in ["WhatsApp", "Signal", "Nostr", "Spotify", "Home Assistant"] { + let entry = entries.iter().find(|e| e.name == name).unwrap(); + assert!( + matches!((entry.status_fn)(&config), IntegrationStatus::ComingSoon), + "{name} should be ComingSoon" + ); + } + } + + #[test] + fn shell_and_filesystem_always_active() { + let config = Config::default(); + let entries = all_integrations(); + for name in ["Shell", "File System"] { + let entry = entries.iter().find(|e| e.name == name).unwrap(); + assert!( + matches!((entry.status_fn)(&config), IntegrationStatus::Active), + "{name} should always be Active" + ); + } + } + + #[test] + fn macos_active_on_macos() { + let config = Config::default(); + let entries = all_integrations(); + let macos = entries.iter().find(|e| e.name == "macOS").unwrap(); + let status = (macos.status_fn)(&config); + if cfg!(target_os = "macos") { + assert!(matches!(status, IntegrationStatus::Active)); + } else { + assert!(matches!(status, IntegrationStatus::Available)); + } + } + + #[test] + fn category_counts_reasonable() { + let entries = all_integrations(); + let chat_count = entries.iter().filter(|e| e.category == IntegrationCategory::Chat).count(); + let ai_count = entries.iter().filter(|e| e.category == IntegrationCategory::AiModel).count(); + assert!(chat_count >= 5, "Expected 5+ chat integrations, got {chat_count}"); + assert!(ai_count >= 5, "Expected 5+ AI model integrations, got {ai_count}"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..12c2334 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow( + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::unnecessary_literal_bound, + clippy::module_name_repetitions, + clippy::struct_field_names, + clippy::must_use_candidate, + clippy::new_without_default, + clippy::return_self_not_must_use, + dead_code +)] + +pub mod config; +pub mod heartbeat; +pub mod memory; +pub mod observability; +pub mod providers; +pub mod runtime; +pub mod security; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6c31550 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,326 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow( + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::unnecessary_literal_bound, + clippy::module_name_repetitions, + clippy::struct_field_names, + dead_code +)] + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +mod agent; +mod channels; +mod config; +mod cron; +mod gateway; +mod heartbeat; +mod memory; +mod observability; +mod onboard; +mod providers; +mod runtime; +mod security; +mod integrations; +mod skills; +mod tools; + +use config::Config; + +/// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust. +#[derive(Parser, Debug)] +#[command(name = "zeroclaw")] +#[command(author = "theonlyhennygod")] +#[command(version = "0.1.0")] +#[command(about = "The fastest, smallest AI assistant.", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Initialize your workspace and configuration + Onboard, + + /// Start the AI agent loop + Agent { + /// Single message mode (don't enter interactive mode) + #[arg(short, long)] + message: Option, + + /// Provider to use (openrouter, anthropic, openai) + #[arg(short, long)] + provider: Option, + + /// Model to use + #[arg(short, long)] + model: Option, + + /// Temperature (0.0 - 2.0) + #[arg(short, long, default_value = "0.7")] + temperature: f64, + }, + + /// Start the gateway server (webhooks, websockets) + Gateway { + /// Port to listen on + #[arg(short, long, default_value = "8080")] + port: u16, + + /// Host to bind to + #[arg(short, long, default_value = "127.0.0.1")] + host: String, + }, + + /// Show system status + Status { + /// Show detailed status + #[arg(short, long)] + verbose: bool, + }, + + /// Configure and manage scheduled tasks + Cron { + #[command(subcommand)] + cron_command: CronCommands, + }, + + /// Manage channels (telegram, discord, slack) + Channel { + #[command(subcommand)] + channel_command: ChannelCommands, + }, + + /// Tool utilities + Tools { + #[command(subcommand)] + tool_command: ToolCommands, + }, + + /// Browse 50+ integrations + Integrations { + #[command(subcommand)] + integration_command: IntegrationCommands, + }, + + /// Manage skills (user-defined capabilities) + Skills { + #[command(subcommand)] + skill_command: SkillCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum CronCommands { + /// List all scheduled tasks + List, + /// Add a new scheduled task + Add { + /// Cron expression + expression: String, + /// Command to run + command: String, + }, + /// Remove a scheduled task + Remove { + /// Task ID + id: String, + }, +} + +#[derive(Subcommand, Debug)] +enum ChannelCommands { + /// List configured channels + List, + /// Start all configured channels (Telegram, Discord, Slack) + Start, + /// Add a new channel + Add { + /// Channel type + channel_type: String, + /// Configuration JSON + config: String, + }, + /// Remove a channel + Remove { + /// Channel name + name: String, + }, +} + +#[derive(Subcommand, Debug)] +enum SkillCommands { + /// List installed skills + List, + /// Install a skill from a GitHub URL or local path + Install { + /// GitHub URL or local path + source: String, + }, + /// Remove an installed skill + Remove { + /// Skill name + name: String, + }, +} + +#[derive(Subcommand, Debug)] +enum IntegrationCommands { + /// List all integrations and their status + List { + /// Filter by category (e.g. "chat", "ai", "productivity") + #[arg(short, long)] + category: Option, + }, + /// Show details about a specific integration + Info { + /// Integration name + name: String, + }, +} + +#[derive(Subcommand, Debug)] +enum ToolCommands { + /// List available tools + List, + /// Test a tool + Test { + /// Tool name + tool: String, + /// Tool arguments (JSON) + args: String, + }, +} + +#[tokio::main] +#[allow(clippy::too_many_lines)] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + // Onboard runs the interactive wizard — no existing config needed + if matches!(cli.command, Commands::Onboard) { + let config = onboard::run_wizard()?; + // Auto-start channels if user said yes during wizard + if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") { + channels::start_channels(config).await?; + } + return Ok(()); + } + + // All other commands need config loaded first + let config = Config::load_or_init()?; + + match cli.command { + Commands::Onboard => unreachable!(), + + Commands::Agent { + message, + provider, + model, + temperature, + } => agent::run(config, message, provider, model, temperature).await, + + Commands::Gateway { port, host } => { + info!("🚀 Starting ZeroClaw Gateway on {host}:{port}"); + info!("POST http://{host}:{port}/webhook — send JSON messages"); + info!("GET http://{host}:{port}/health — health check"); + gateway::run_gateway(&host, port, config).await + } + + Commands::Status { verbose } => { + println!("🦀 ZeroClaw Status"); + println!(); + println!("Version: {}", env!("CARGO_PKG_VERSION")); + println!("Workspace: {}", config.workspace_dir.display()); + println!("Config: {}", config.config_path.display()); + println!(); + println!( + "🤖 Provider: {}", + config.default_provider.as_deref().unwrap_or("openrouter") + ); + println!( + " Model: {}", + config.default_model.as_deref().unwrap_or("(default)") + ); + println!("📊 Observability: {}", config.observability.backend); + println!("🛡️ Autonomy: {:?}", config.autonomy.level); + println!("⚙️ Runtime: {}", config.runtime.kind); + println!( + "💓 Heartbeat: {}", + if config.heartbeat.enabled { + format!("every {}min", config.heartbeat.interval_minutes) + } else { + "disabled".into() + } + ); + println!( + "🧠 Memory: {} (auto-save: {})", + config.memory.backend, + if config.memory.auto_save { "on" } else { "off" } + ); + + if verbose { + println!(); + println!("Security:"); + println!(" Workspace only: {}", config.autonomy.workspace_only); + println!( + " Allowed commands: {}", + config.autonomy.allowed_commands.join(", ") + ); + println!( + " Max actions/hour: {}", + config.autonomy.max_actions_per_hour + ); + println!( + " Max cost/day: ${:.2}", + f64::from(config.autonomy.max_cost_per_day_cents) / 100.0 + ); + println!(); + println!("Channels:"); + println!(" CLI: ✅ always"); + for (name, configured) in [ + ("Telegram", config.channels_config.telegram.is_some()), + ("Discord", config.channels_config.discord.is_some()), + ("Slack", config.channels_config.slack.is_some()), + ("Webhook", config.channels_config.webhook.is_some()), + ] { + println!( + " {name:9} {}", + if configured { "✅ configured" } else { "❌ not configured" } + ); + } + } + + Ok(()) + } + + Commands::Cron { cron_command } => cron::handle_command(cron_command, config), + + Commands::Channel { channel_command } => match channel_command { + ChannelCommands::Start => channels::start_channels(config).await, + other => channels::handle_command(other, &config), + }, + + Commands::Tools { tool_command } => tools::handle_command(tool_command, config).await, + + Commands::Integrations { + integration_command, + } => integrations::handle_command(integration_command, &config), + + Commands::Skills { skill_command } => { + skills::handle_command(skill_command, &config.workspace_dir) + } + } +} diff --git a/src/memory/markdown.rs b/src/memory/markdown.rs new file mode 100644 index 0000000..8dcd667 --- /dev/null +++ b/src/memory/markdown.rs @@ -0,0 +1,344 @@ +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; +use chrono::Local; +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// Markdown-based memory — plain files as source of truth +/// +/// Layout: +/// workspace/MEMORY.md — curated long-term memory (core) +/// workspace/memory/YYYY-MM-DD.md — daily logs (append-only) +pub struct MarkdownMemory { + workspace_dir: PathBuf, +} + +impl MarkdownMemory { + pub fn new(workspace_dir: &Path) -> Self { + Self { + workspace_dir: workspace_dir.to_path_buf(), + } + } + + fn memory_dir(&self) -> PathBuf { + self.workspace_dir.join("memory") + } + + fn core_path(&self) -> PathBuf { + self.workspace_dir.join("MEMORY.md") + } + + fn daily_path(&self) -> PathBuf { + let date = Local::now().format("%Y-%m-%d").to_string(); + self.memory_dir().join(format!("{date}.md")) + } + + async fn ensure_dirs(&self) -> anyhow::Result<()> { + fs::create_dir_all(self.memory_dir()).await?; + Ok(()) + } + + async fn append_to_file(&self, path: &Path, content: &str) -> anyhow::Result<()> { + self.ensure_dirs().await?; + + let existing = if path.exists() { + fs::read_to_string(path).await.unwrap_or_default() + } else { + String::new() + }; + + let updated = if existing.is_empty() { + let header = if path == self.core_path() { + "# Long-Term Memory\n\n" + } else { + let date = Local::now().format("%Y-%m-%d").to_string(); + &format!("# Daily Log — {date}\n\n") + }; + format!("{header}{content}\n") + } else { + format!("{existing}\n{content}\n") + }; + + fs::write(path, updated).await?; + Ok(()) + } + + fn parse_entries_from_file( + path: &Path, + content: &str, + category: &MemoryCategory, + ) -> Vec { + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + content + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }) + .enumerate() + .map(|(i, line)| { + let trimmed = line.trim(); + let clean = trimmed.strip_prefix("- ").unwrap_or(trimmed); + MemoryEntry { + id: format!("{filename}:{i}"), + key: format!("{filename}:{i}"), + content: clean.to_string(), + category: category.clone(), + timestamp: filename.to_string(), + session_id: None, + score: None, + } + }) + .collect() + } + + async fn read_all_entries(&self) -> anyhow::Result> { + let mut entries = Vec::new(); + + // Read MEMORY.md (core) + let core_path = self.core_path(); + if core_path.exists() { + let content = fs::read_to_string(&core_path).await?; + entries.extend(Self::parse_entries_from_file( + &core_path, + &content, + &MemoryCategory::Core, + )); + } + + // Read daily logs + let mem_dir = self.memory_dir(); + if mem_dir.exists() { + let mut dir = fs::read_dir(&mem_dir).await?; + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") { + let content = fs::read_to_string(&path).await?; + entries.extend(Self::parse_entries_from_file( + &path, + &content, + &MemoryCategory::Daily, + )); + } + } + } + + entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + Ok(entries) + } +} + +#[async_trait] +impl Memory for MarkdownMemory { + fn name(&self) -> &str { + "markdown" + } + + async fn store( + &self, + key: &str, + content: &str, + category: MemoryCategory, + ) -> anyhow::Result<()> { + let entry = format!("- **{key}**: {content}"); + let path = match category { + MemoryCategory::Core => self.core_path(), + _ => self.daily_path(), + }; + self.append_to_file(&path, &entry).await + } + + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result> { + let all = self.read_all_entries().await?; + let query_lower = query.to_lowercase(); + let keywords: Vec<&str> = query_lower.split_whitespace().collect(); + + let mut scored: Vec = all + .into_iter() + .filter_map(|mut entry| { + let content_lower = entry.content.to_lowercase(); + let matched = keywords + .iter() + .filter(|kw| content_lower.contains(**kw)) + .count(); + if matched > 0 { + #[allow(clippy::cast_precision_loss)] + let score = matched as f64 / keywords.len() as f64; + entry.score = Some(score); + Some(entry) + } else { + None + } + }) + .collect(); + + scored.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scored.truncate(limit); + Ok(scored) + } + + async fn get(&self, key: &str) -> anyhow::Result> { + let all = self.read_all_entries().await?; + Ok(all + .into_iter() + .find(|e| e.key == key || e.content.contains(key))) + } + + async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result> { + let all = self.read_all_entries().await?; + match category { + Some(cat) => Ok(all.into_iter().filter(|e| &e.category == cat).collect()), + None => Ok(all), + } + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + // Markdown memory is append-only by design (audit trail) + // Return false to indicate the entry wasn't removed + Ok(false) + } + + async fn count(&self) -> anyhow::Result { + let all = self.read_all_entries().await?; + Ok(all.len()) + } + + async fn health_check(&self) -> bool { + self.workspace_dir.exists() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs as sync_fs; + use tempfile::TempDir; + + fn temp_workspace() -> (TempDir, MarkdownMemory) { + let tmp = TempDir::new().unwrap(); + let mem = MarkdownMemory::new(tmp.path()); + (tmp, mem) + } + + #[tokio::test] + async fn markdown_name() { + let (_tmp, mem) = temp_workspace(); + assert_eq!(mem.name(), "markdown"); + } + + #[tokio::test] + async fn markdown_health_check() { + let (_tmp, mem) = temp_workspace(); + assert!(mem.health_check().await); + } + + #[tokio::test] + async fn markdown_store_core() { + let (_tmp, mem) = temp_workspace(); + mem.store("pref", "User likes Rust", MemoryCategory::Core) + .await + .unwrap(); + let content = sync_fs::read_to_string(mem.core_path()).unwrap(); + assert!(content.contains("User likes Rust")); + } + + #[tokio::test] + async fn markdown_store_daily() { + let (_tmp, mem) = temp_workspace(); + mem.store("note", "Finished tests", MemoryCategory::Daily) + .await + .unwrap(); + let path = mem.daily_path(); + let content = sync_fs::read_to_string(path).unwrap(); + assert!(content.contains("Finished tests")); + } + + #[tokio::test] + async fn markdown_recall_keyword() { + let (_tmp, mem) = temp_workspace(); + mem.store("a", "Rust is fast", MemoryCategory::Core) + .await + .unwrap(); + mem.store("b", "Python is slow", MemoryCategory::Core) + .await + .unwrap(); + mem.store("c", "Rust and safety", MemoryCategory::Core) + .await + .unwrap(); + + let results = mem.recall("Rust", 10).await.unwrap(); + assert!(results.len() >= 2); + assert!(results + .iter() + .all(|r| r.content.to_lowercase().contains("rust"))); + } + + #[tokio::test] + async fn markdown_recall_no_match() { + let (_tmp, mem) = temp_workspace(); + mem.store("a", "Rust is great", MemoryCategory::Core) + .await + .unwrap(); + let results = mem.recall("javascript", 10).await.unwrap(); + assert!(results.is_empty()); + } + + #[tokio::test] + async fn markdown_count() { + let (_tmp, mem) = temp_workspace(); + mem.store("a", "first", MemoryCategory::Core).await.unwrap(); + mem.store("b", "second", MemoryCategory::Core) + .await + .unwrap(); + let count = mem.count().await.unwrap(); + assert!(count >= 2); + } + + #[tokio::test] + async fn markdown_list_by_category() { + let (_tmp, mem) = temp_workspace(); + mem.store("a", "core fact", MemoryCategory::Core) + .await + .unwrap(); + mem.store("b", "daily note", MemoryCategory::Daily) + .await + .unwrap(); + + let core = mem.list(Some(&MemoryCategory::Core)).await.unwrap(); + assert!(core.iter().all(|e| e.category == MemoryCategory::Core)); + + let daily = mem.list(Some(&MemoryCategory::Daily)).await.unwrap(); + assert!(daily.iter().all(|e| e.category == MemoryCategory::Daily)); + } + + #[tokio::test] + async fn markdown_forget_is_noop() { + let (_tmp, mem) = temp_workspace(); + mem.store("a", "permanent", MemoryCategory::Core) + .await + .unwrap(); + let removed = mem.forget("a").await.unwrap(); + assert!(!removed, "Markdown memory is append-only"); + } + + #[tokio::test] + async fn markdown_empty_recall() { + let (_tmp, mem) = temp_workspace(); + let results = mem.recall("anything", 10).await.unwrap(); + assert!(results.is_empty()); + } + + #[tokio::test] + async fn markdown_empty_count() { + let (_tmp, mem) = temp_workspace(); + assert_eq!(mem.count().await.unwrap(), 0); + } +} diff --git a/src/memory/mod.rs b/src/memory/mod.rs new file mode 100644 index 0000000..98f8614 --- /dev/null +++ b/src/memory/mod.rs @@ -0,0 +1,77 @@ +pub mod markdown; +pub mod sqlite; +pub mod traits; + +pub use markdown::MarkdownMemory; +pub use sqlite::SqliteMemory; +pub use traits::Memory; +#[allow(unused_imports)] +pub use traits::{MemoryCategory, MemoryEntry}; + +use crate::config::MemoryConfig; +use std::path::Path; + +/// Factory: create the right memory backend from config +pub fn create_memory( + config: &MemoryConfig, + workspace_dir: &Path, +) -> anyhow::Result> { + match config.backend.as_str() { + "sqlite" => Ok(Box::new(SqliteMemory::new(workspace_dir)?)), + "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))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn factory_sqlite() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "sqlite".into(), + auto_save: true, + }; + let mem = create_memory(&cfg, tmp.path()).unwrap(); + assert_eq!(mem.name(), "sqlite"); + } + + #[test] + fn factory_markdown() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "markdown".into(), + auto_save: true, + }; + let mem = create_memory(&cfg, tmp.path()).unwrap(); + assert_eq!(mem.name(), "markdown"); + } + + #[test] + fn factory_none_falls_back_to_markdown() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "none".into(), + auto_save: true, + }; + let mem = create_memory(&cfg, tmp.path()).unwrap(); + assert_eq!(mem.name(), "markdown"); + } + + #[test] + fn factory_unknown_falls_back_to_markdown() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "redis".into(), + auto_save: true, + }; + let mem = create_memory(&cfg, tmp.path()).unwrap(); + assert_eq!(mem.name(), "markdown"); + } +} diff --git a/src/memory/sqlite.rs b/src/memory/sqlite.rs new file mode 100644 index 0000000..234e76e --- /dev/null +++ b/src/memory/sqlite.rs @@ -0,0 +1,481 @@ +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; +use chrono::Local; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use uuid::Uuid; + +/// SQLite-backed persistent memory — the brain +/// +/// Stores memories in a local `SQLite` database with keyword search. +/// Zero external dependencies, works offline, survives restarts. +pub struct SqliteMemory { + conn: Mutex, + db_path: PathBuf, +} + +impl SqliteMemory { + pub fn new(workspace_dir: &Path) -> anyhow::Result { + let db_path = workspace_dir.join("memory").join("brain.db"); + + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(&db_path)?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + content TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'core', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category); + CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);", + )?; + + Ok(Self { + conn: Mutex::new(conn), + db_path, + }) + } + + fn category_to_str(cat: &MemoryCategory) -> String { + match cat { + MemoryCategory::Core => "core".into(), + MemoryCategory::Daily => "daily".into(), + MemoryCategory::Conversation => "conversation".into(), + MemoryCategory::Custom(name) => name.clone(), + } + } + + fn str_to_category(s: &str) -> MemoryCategory { + match s { + "core" => MemoryCategory::Core, + "daily" => MemoryCategory::Daily, + "conversation" => MemoryCategory::Conversation, + other => MemoryCategory::Custom(other.to_string()), + } + } +} + +#[async_trait] +impl Memory for SqliteMemory { + fn name(&self) -> &str { + "sqlite" + } + + async fn store( + &self, + key: &str, + content: &str, + category: MemoryCategory, + ) -> anyhow::Result<()> { + let conn = self + .conn + .lock() + .map_err(|e| anyhow::anyhow!("Lock error: {e}"))?; + let now = Local::now().to_rfc3339(); + let cat = Self::category_to_str(&category); + let id = Uuid::new_v4().to_string(); + + conn.execute( + "INSERT INTO memories (id, key, content, category, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(key) DO UPDATE SET + content = excluded.content, + category = excluded.category, + updated_at = excluded.updated_at", + params![id, key, content, cat, now, now], + )?; + + Ok(()) + } + + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result> { + let conn = self + .conn + .lock() + .map_err(|e| anyhow::anyhow!("Lock error: {e}"))?; + + // Keyword search: split query into words, match any + let keywords: Vec = query.split_whitespace().map(|w| format!("%{w}%")).collect(); + + if keywords.is_empty() { + return Ok(Vec::new()); + } + + // Build dynamic WHERE clause for keyword matching + let conditions: Vec = keywords + .iter() + .enumerate() + .map(|(i, _)| format!("(content LIKE ?{} OR key LIKE ?{})", i * 2 + 1, i * 2 + 2)) + .collect(); + + let where_clause = conditions.join(" OR "); + let sql = format!( + "SELECT id, key, content, category, created_at FROM memories + WHERE {where_clause} + ORDER BY updated_at DESC + LIMIT ?{}", + keywords.len() * 2 + 1 + ); + + let mut stmt = conn.prepare(&sql)?; + + // Build params: each keyword appears twice (for content and key) + let mut param_values: Vec> = Vec::new(); + for kw in &keywords { + param_values.push(Box::new(kw.clone())); + param_values.push(Box::new(kw.clone())); + } + #[allow(clippy::cast_possible_wrap)] + param_values.push(Box::new(limit as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(AsRef::as_ref).collect(); + + let rows = stmt.query_map(params_ref.as_slice(), |row| { + Ok(MemoryEntry { + id: row.get(0)?, + key: row.get(1)?, + content: row.get(2)?, + category: Self::str_to_category(&row.get::<_, String>(3)?), + timestamp: row.get(4)?, + session_id: None, + score: Some(1.0), + }) + })?; + + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + + // Score by keyword match count + let query_lower = query.to_lowercase(); + let kw_list: Vec<&str> = query_lower.split_whitespace().collect(); + for entry in &mut results { + let content_lower = entry.content.to_lowercase(); + let matched = kw_list + .iter() + .filter(|kw| content_lower.contains(**kw)) + .count(); + #[allow(clippy::cast_precision_loss)] + { + entry.score = Some(matched as f64 / kw_list.len().max(1) as f64); + } + } + + results.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + Ok(results) + } + + async fn get(&self, key: &str) -> anyhow::Result> { + let conn = self + .conn + .lock() + .map_err(|e| anyhow::anyhow!("Lock error: {e}"))?; + + let mut stmt = conn.prepare( + "SELECT id, key, content, category, created_at FROM memories WHERE key = ?1", + )?; + + let mut rows = stmt.query_map(params![key], |row| { + Ok(MemoryEntry { + id: row.get(0)?, + key: row.get(1)?, + content: row.get(2)?, + category: Self::str_to_category(&row.get::<_, String>(3)?), + timestamp: row.get(4)?, + session_id: None, + score: None, + }) + })?; + + match rows.next() { + Some(Ok(entry)) => Ok(Some(entry)), + _ => Ok(None), + } + } + + async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result> { + let conn = self + .conn + .lock() + .map_err(|e| anyhow::anyhow!("Lock error: {e}"))?; + + let mut results = Vec::new(); + + let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result { + Ok(MemoryEntry { + id: row.get(0)?, + key: row.get(1)?, + content: row.get(2)?, + category: Self::str_to_category(&row.get::<_, String>(3)?), + timestamp: row.get(4)?, + session_id: None, + score: None, + }) + }; + + if let Some(cat) = category { + let cat_str = Self::category_to_str(cat); + let mut stmt = conn.prepare( + "SELECT id, key, content, category, created_at FROM memories + WHERE category = ?1 ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map(params![cat_str], row_mapper)?; + for row in rows { + results.push(row?); + } + } else { + let mut stmt = conn.prepare( + "SELECT id, key, content, category, created_at FROM memories + ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map([], row_mapper)?; + for row in rows { + results.push(row?); + } + } + + Ok(results) + } + + async fn forget(&self, key: &str) -> anyhow::Result { + let conn = self + .conn + .lock() + .map_err(|e| anyhow::anyhow!("Lock error: {e}"))?; + let affected = conn.execute("DELETE FROM memories WHERE key = ?1", params![key])?; + Ok(affected > 0) + } + + async fn count(&self) -> anyhow::Result { + let conn = self + .conn + .lock() + .map_err(|e| anyhow::anyhow!("Lock error: {e}"))?; + let count: i64 = conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?; + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + Ok(count as usize) + } + + async fn health_check(&self) -> bool { + self.conn + .lock() + .map(|c| c.execute_batch("SELECT 1").is_ok()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn temp_sqlite() -> (TempDir, SqliteMemory) { + let tmp = TempDir::new().unwrap(); + let mem = SqliteMemory::new(tmp.path()).unwrap(); + (tmp, mem) + } + + #[tokio::test] + async fn sqlite_name() { + let (_tmp, mem) = temp_sqlite(); + assert_eq!(mem.name(), "sqlite"); + } + + #[tokio::test] + async fn sqlite_health() { + let (_tmp, mem) = temp_sqlite(); + assert!(mem.health_check().await); + } + + #[tokio::test] + async fn sqlite_store_and_get() { + let (_tmp, mem) = temp_sqlite(); + mem.store("user_lang", "Prefers Rust", MemoryCategory::Core) + .await + .unwrap(); + + let entry = mem.get("user_lang").await.unwrap(); + assert!(entry.is_some()); + let entry = entry.unwrap(); + assert_eq!(entry.key, "user_lang"); + assert_eq!(entry.content, "Prefers Rust"); + assert_eq!(entry.category, MemoryCategory::Core); + } + + #[tokio::test] + async fn sqlite_store_upsert() { + let (_tmp, mem) = temp_sqlite(); + mem.store("pref", "likes Rust", MemoryCategory::Core) + .await + .unwrap(); + mem.store("pref", "loves Rust", MemoryCategory::Core) + .await + .unwrap(); + + let entry = mem.get("pref").await.unwrap().unwrap(); + assert_eq!(entry.content, "loves Rust"); + assert_eq!(mem.count().await.unwrap(), 1); + } + + #[tokio::test] + async fn sqlite_recall_keyword() { + let (_tmp, mem) = temp_sqlite(); + mem.store("a", "Rust is fast and safe", MemoryCategory::Core) + .await + .unwrap(); + mem.store("b", "Python is interpreted", MemoryCategory::Core) + .await + .unwrap(); + mem.store("c", "Rust has zero-cost abstractions", MemoryCategory::Core) + .await + .unwrap(); + + let results = mem.recall("Rust", 10).await.unwrap(); + assert_eq!(results.len(), 2); + assert!(results + .iter() + .all(|r| r.content.to_lowercase().contains("rust"))); + } + + #[tokio::test] + async fn sqlite_recall_multi_keyword() { + let (_tmp, mem) = temp_sqlite(); + mem.store("a", "Rust is fast", MemoryCategory::Core) + .await + .unwrap(); + mem.store("b", "Rust is safe and fast", MemoryCategory::Core) + .await + .unwrap(); + + let results = mem.recall("fast safe", 10).await.unwrap(); + assert!(!results.is_empty()); + // Entry with both keywords should score higher + assert!(results[0].content.contains("safe") && results[0].content.contains("fast")); + } + + #[tokio::test] + async fn sqlite_recall_no_match() { + let (_tmp, mem) = temp_sqlite(); + mem.store("a", "Rust rocks", MemoryCategory::Core) + .await + .unwrap(); + let results = mem.recall("javascript", 10).await.unwrap(); + assert!(results.is_empty()); + } + + #[tokio::test] + async fn sqlite_forget() { + let (_tmp, mem) = temp_sqlite(); + mem.store("temp", "temporary data", MemoryCategory::Conversation) + .await + .unwrap(); + assert_eq!(mem.count().await.unwrap(), 1); + + let removed = mem.forget("temp").await.unwrap(); + assert!(removed); + assert_eq!(mem.count().await.unwrap(), 0); + } + + #[tokio::test] + async fn sqlite_forget_nonexistent() { + let (_tmp, mem) = temp_sqlite(); + let removed = mem.forget("nope").await.unwrap(); + assert!(!removed); + } + + #[tokio::test] + async fn sqlite_list_all() { + let (_tmp, mem) = temp_sqlite(); + mem.store("a", "one", MemoryCategory::Core).await.unwrap(); + mem.store("b", "two", MemoryCategory::Daily).await.unwrap(); + mem.store("c", "three", MemoryCategory::Conversation) + .await + .unwrap(); + + let all = mem.list(None).await.unwrap(); + assert_eq!(all.len(), 3); + } + + #[tokio::test] + async fn sqlite_list_by_category() { + let (_tmp, mem) = temp_sqlite(); + mem.store("a", "core1", MemoryCategory::Core).await.unwrap(); + mem.store("b", "core2", MemoryCategory::Core).await.unwrap(); + mem.store("c", "daily1", MemoryCategory::Daily) + .await + .unwrap(); + + let core = mem.list(Some(&MemoryCategory::Core)).await.unwrap(); + assert_eq!(core.len(), 2); + + let daily = mem.list(Some(&MemoryCategory::Daily)).await.unwrap(); + assert_eq!(daily.len(), 1); + } + + #[tokio::test] + async fn sqlite_count_empty() { + let (_tmp, mem) = temp_sqlite(); + assert_eq!(mem.count().await.unwrap(), 0); + } + + #[tokio::test] + async fn sqlite_get_nonexistent() { + let (_tmp, mem) = temp_sqlite(); + assert!(mem.get("nope").await.unwrap().is_none()); + } + + #[tokio::test] + async fn sqlite_db_persists() { + let tmp = TempDir::new().unwrap(); + + { + let mem = SqliteMemory::new(tmp.path()).unwrap(); + mem.store("persist", "I survive restarts", MemoryCategory::Core) + .await + .unwrap(); + } + + // Reopen + let mem2 = SqliteMemory::new(tmp.path()).unwrap(); + let entry = mem2.get("persist").await.unwrap(); + assert!(entry.is_some()); + assert_eq!(entry.unwrap().content, "I survive restarts"); + } + + #[tokio::test] + async fn sqlite_category_roundtrip() { + let (_tmp, mem) = temp_sqlite(); + let categories = vec![ + MemoryCategory::Core, + MemoryCategory::Daily, + MemoryCategory::Conversation, + MemoryCategory::Custom("project".into()), + ]; + + for (i, cat) in categories.iter().enumerate() { + mem.store(&format!("k{i}"), &format!("v{i}"), cat.clone()) + .await + .unwrap(); + } + + for (i, cat) in categories.iter().enumerate() { + let entry = mem.get(&format!("k{i}")).await.unwrap().unwrap(); + assert_eq!(&entry.category, cat); + } + } +} diff --git a/src/memory/traits.rs b/src/memory/traits.rs new file mode 100644 index 0000000..16d8fa6 --- /dev/null +++ b/src/memory/traits.rs @@ -0,0 +1,68 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +/// A single memory entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryEntry { + pub id: String, + pub key: String, + pub content: String, + pub category: MemoryCategory, + pub timestamp: String, + pub session_id: Option, + pub score: Option, +} + +/// Memory categories for organization +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryCategory { + /// Long-term facts, preferences, decisions + Core, + /// Daily session logs + Daily, + /// Conversation context + Conversation, + /// User-defined custom category + Custom(String), +} + +impl std::fmt::Display for MemoryCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core => write!(f, "core"), + Self::Daily => write!(f, "daily"), + Self::Conversation => write!(f, "conversation"), + Self::Custom(name) => write!(f, "{name}"), + } + } +} + +/// Core memory trait — implement for any persistence backend +#[async_trait] +pub trait Memory: Send + Sync { + /// Backend name + fn name(&self) -> &str; + + /// Store a memory entry + async fn store(&self, key: &str, content: &str, category: MemoryCategory) + -> anyhow::Result<()>; + + /// Recall memories matching a query (keyword search) + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result>; + + /// Get a specific memory by key + async fn get(&self, key: &str) -> anyhow::Result>; + + /// List all memory keys, optionally filtered by category + async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result>; + + /// Remove a memory by key + async fn forget(&self, key: &str) -> anyhow::Result; + + /// Count total memories + async fn count(&self) -> anyhow::Result; + + /// Health check + async fn health_check(&self) -> bool; +} diff --git a/src/observability/log.rs b/src/observability/log.rs new file mode 100644 index 0000000..eed4136 --- /dev/null +++ b/src/observability/log.rs @@ -0,0 +1,119 @@ +use super::traits::{Observer, ObserverEvent, ObserverMetric}; +use tracing::info; + +/// Log-based observer — uses tracing, zero external deps +pub struct LogObserver; + +impl LogObserver { + pub fn new() -> Self { + Self + } +} + +impl Observer for LogObserver { + fn record_event(&self, event: &ObserverEvent) { + match event { + ObserverEvent::AgentStart { provider, model } => { + info!(provider = %provider, model = %model, "agent.start"); + } + ObserverEvent::AgentEnd { + duration, + tokens_used, + } => { + let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); + info!(duration_ms = ms, tokens = ?tokens_used, "agent.end"); + } + ObserverEvent::ToolCall { + tool, + duration, + success, + } => { + let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); + info!(tool = %tool, duration_ms = ms, success = success, "tool.call"); + } + ObserverEvent::ChannelMessage { channel, direction } => { + info!(channel = %channel, direction = %direction, "channel.message"); + } + ObserverEvent::HeartbeatTick => { + info!("heartbeat.tick"); + } + ObserverEvent::Error { component, message } => { + info!(component = %component, error = %message, "error"); + } + } + } + + fn record_metric(&self, metric: &ObserverMetric) { + match metric { + ObserverMetric::RequestLatency(d) => { + let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX); + info!(latency_ms = ms, "metric.request_latency"); + } + ObserverMetric::TokensUsed(t) => { + info!(tokens = t, "metric.tokens_used"); + } + ObserverMetric::ActiveSessions(s) => { + info!(sessions = s, "metric.active_sessions"); + } + ObserverMetric::QueueDepth(d) => { + info!(depth = d, "metric.queue_depth"); + } + } + } + + fn name(&self) -> &str { + "log" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn log_observer_name() { + assert_eq!(LogObserver::new().name(), "log"); + } + + #[test] + fn log_observer_all_events_no_panic() { + let obs = LogObserver::new(); + obs.record_event(&ObserverEvent::AgentStart { + provider: "openrouter".into(), + model: "claude-sonnet".into(), + }); + obs.record_event(&ObserverEvent::AgentEnd { + duration: Duration::from_millis(500), + tokens_used: Some(100), + }); + obs.record_event(&ObserverEvent::AgentEnd { + duration: Duration::ZERO, + tokens_used: None, + }); + obs.record_event(&ObserverEvent::ToolCall { + tool: "shell".into(), + duration: Duration::from_millis(10), + success: false, + }); + obs.record_event(&ObserverEvent::ChannelMessage { + channel: "telegram".into(), + direction: "outbound".into(), + }); + obs.record_event(&ObserverEvent::HeartbeatTick); + obs.record_event(&ObserverEvent::Error { + component: "provider".into(), + message: "timeout".into(), + }); + } + + #[test] + fn log_observer_all_metrics_no_panic() { + let obs = LogObserver::new(); + obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_secs(2))); + obs.record_metric(&ObserverMetric::TokensUsed(0)); + obs.record_metric(&ObserverMetric::TokensUsed(u64::MAX)); + obs.record_metric(&ObserverMetric::ActiveSessions(1)); + obs.record_metric(&ObserverMetric::QueueDepth(999)); + } +} diff --git a/src/observability/mod.rs b/src/observability/mod.rs new file mode 100644 index 0000000..6dbfccd --- /dev/null +++ b/src/observability/mod.rs @@ -0,0 +1,76 @@ +pub mod log; +pub mod multi; +pub mod noop; +pub mod traits; + +pub use self::log::LogObserver; +pub use noop::NoopObserver; +pub use traits::{Observer, ObserverEvent}; + +use crate::config::ObservabilityConfig; + +/// Factory: create the right observer from config +pub fn create_observer(config: &ObservabilityConfig) -> Box { + match config.backend.as_str() { + "log" => Box::new(LogObserver::new()), + "none" | "noop" => Box::new(NoopObserver), + _ => { + tracing::warn!( + "Unknown observability backend '{}', falling back to noop", + config.backend + ); + Box::new(NoopObserver) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn factory_none_returns_noop() { + let cfg = ObservabilityConfig { + backend: "none".into(), + }; + assert_eq!(create_observer(&cfg).name(), "noop"); + } + + #[test] + fn factory_noop_returns_noop() { + let cfg = ObservabilityConfig { + backend: "noop".into(), + }; + assert_eq!(create_observer(&cfg).name(), "noop"); + } + + #[test] + fn factory_log_returns_log() { + let cfg = ObservabilityConfig { + backend: "log".into(), + }; + assert_eq!(create_observer(&cfg).name(), "log"); + } + + #[test] + fn factory_unknown_falls_back_to_noop() { + let cfg = ObservabilityConfig { + backend: "prometheus".into(), + }; + assert_eq!(create_observer(&cfg).name(), "noop"); + } + + #[test] + fn factory_empty_string_falls_back_to_noop() { + let cfg = ObservabilityConfig { backend: "".into() }; + assert_eq!(create_observer(&cfg).name(), "noop"); + } + + #[test] + fn factory_garbage_falls_back_to_noop() { + let cfg = ObservabilityConfig { + backend: "xyzzy_garbage_123".into(), + }; + assert_eq!(create_observer(&cfg).name(), "noop"); + } +} diff --git a/src/observability/multi.rs b/src/observability/multi.rs new file mode 100644 index 0000000..e57400b --- /dev/null +++ b/src/observability/multi.rs @@ -0,0 +1,154 @@ +use super::traits::{Observer, ObserverEvent, ObserverMetric}; + +/// Combine multiple observers — fan-out events to all backends +pub struct MultiObserver { + observers: Vec>, +} + +impl MultiObserver { + pub fn new(observers: Vec>) -> Self { + Self { observers } + } +} + +impl Observer for MultiObserver { + fn record_event(&self, event: &ObserverEvent) { + for obs in &self.observers { + obs.record_event(event); + } + } + + fn record_metric(&self, metric: &ObserverMetric) { + for obs in &self.observers { + obs.record_metric(metric); + } + } + + fn flush(&self) { + for obs in &self.observers { + obs.flush(); + } + } + + fn name(&self) -> &str { + "multi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + /// Test observer that counts calls + struct CountingObserver { + event_count: Arc, + metric_count: Arc, + flush_count: Arc, + } + + impl CountingObserver { + fn new( + event_count: Arc, + metric_count: Arc, + flush_count: Arc, + ) -> Self { + Self { + event_count, + metric_count, + flush_count, + } + } + } + + impl Observer for CountingObserver { + fn record_event(&self, _event: &ObserverEvent) { + self.event_count.fetch_add(1, Ordering::SeqCst); + } + fn record_metric(&self, _metric: &ObserverMetric) { + self.metric_count.fetch_add(1, Ordering::SeqCst); + } + fn flush(&self) { + self.flush_count.fetch_add(1, Ordering::SeqCst); + } + fn name(&self) -> &str { + "counting" + } + } + + #[test] + fn multi_name() { + let m = MultiObserver::new(vec![]); + assert_eq!(m.name(), "multi"); + } + + #[test] + fn multi_empty_no_panic() { + let m = MultiObserver::new(vec![]); + m.record_event(&ObserverEvent::HeartbeatTick); + m.record_metric(&ObserverMetric::TokensUsed(10)); + m.flush(); + } + + #[test] + fn multi_fans_out_events() { + let ec1 = Arc::new(AtomicUsize::new(0)); + let mc1 = Arc::new(AtomicUsize::new(0)); + let fc1 = Arc::new(AtomicUsize::new(0)); + let ec2 = Arc::new(AtomicUsize::new(0)); + let mc2 = Arc::new(AtomicUsize::new(0)); + let fc2 = Arc::new(AtomicUsize::new(0)); + + let m = MultiObserver::new(vec![ + Box::new(CountingObserver::new(ec1.clone(), mc1.clone(), fc1.clone())), + Box::new(CountingObserver::new(ec2.clone(), mc2.clone(), fc2.clone())), + ]); + + m.record_event(&ObserverEvent::HeartbeatTick); + m.record_event(&ObserverEvent::HeartbeatTick); + m.record_event(&ObserverEvent::HeartbeatTick); + + assert_eq!(ec1.load(Ordering::SeqCst), 3); + assert_eq!(ec2.load(Ordering::SeqCst), 3); + } + + #[test] + fn multi_fans_out_metrics() { + let ec1 = Arc::new(AtomicUsize::new(0)); + let mc1 = Arc::new(AtomicUsize::new(0)); + let fc1 = Arc::new(AtomicUsize::new(0)); + let ec2 = Arc::new(AtomicUsize::new(0)); + let mc2 = Arc::new(AtomicUsize::new(0)); + let fc2 = Arc::new(AtomicUsize::new(0)); + + let m = MultiObserver::new(vec![ + Box::new(CountingObserver::new(ec1.clone(), mc1.clone(), fc1.clone())), + Box::new(CountingObserver::new(ec2.clone(), mc2.clone(), fc2.clone())), + ]); + + m.record_metric(&ObserverMetric::TokensUsed(100)); + m.record_metric(&ObserverMetric::RequestLatency(Duration::from_millis(5))); + + assert_eq!(mc1.load(Ordering::SeqCst), 2); + assert_eq!(mc2.load(Ordering::SeqCst), 2); + } + + #[test] + fn multi_fans_out_flush() { + let ec = Arc::new(AtomicUsize::new(0)); + let mc = Arc::new(AtomicUsize::new(0)); + let fc1 = Arc::new(AtomicUsize::new(0)); + let fc2 = Arc::new(AtomicUsize::new(0)); + + let m = MultiObserver::new(vec![ + Box::new(CountingObserver::new(ec.clone(), mc.clone(), fc1.clone())), + Box::new(CountingObserver::new(ec.clone(), mc.clone(), fc2.clone())), + ]); + + m.flush(); + assert_eq!(fc1.load(Ordering::SeqCst), 1); + assert_eq!(fc2.load(Ordering::SeqCst), 1); + } +} diff --git a/src/observability/noop.rs b/src/observability/noop.rs new file mode 100644 index 0000000..31f3a34 --- /dev/null +++ b/src/observability/noop.rs @@ -0,0 +1,72 @@ +use super::traits::{Observer, ObserverEvent, ObserverMetric}; + +/// Zero-overhead observer — all methods compile to nothing +pub struct NoopObserver; + +impl Observer for NoopObserver { + #[inline(always)] + fn record_event(&self, _event: &ObserverEvent) {} + + #[inline(always)] + fn record_metric(&self, _metric: &ObserverMetric) {} + + fn name(&self) -> &str { + "noop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn noop_name() { + assert_eq!(NoopObserver.name(), "noop"); + } + + #[test] + fn noop_record_event_does_not_panic() { + let obs = NoopObserver; + obs.record_event(&ObserverEvent::HeartbeatTick); + obs.record_event(&ObserverEvent::AgentStart { + provider: "test".into(), + model: "test".into(), + }); + obs.record_event(&ObserverEvent::AgentEnd { + duration: Duration::from_millis(100), + tokens_used: Some(42), + }); + obs.record_event(&ObserverEvent::AgentEnd { + duration: Duration::ZERO, + tokens_used: None, + }); + obs.record_event(&ObserverEvent::ToolCall { + tool: "shell".into(), + duration: Duration::from_secs(1), + success: true, + }); + obs.record_event(&ObserverEvent::ChannelMessage { + channel: "cli".into(), + direction: "inbound".into(), + }); + obs.record_event(&ObserverEvent::Error { + component: "test".into(), + message: "boom".into(), + }); + } + + #[test] + fn noop_record_metric_does_not_panic() { + let obs = NoopObserver; + obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_millis(50))); + obs.record_metric(&ObserverMetric::TokensUsed(1000)); + obs.record_metric(&ObserverMetric::ActiveSessions(5)); + obs.record_metric(&ObserverMetric::QueueDepth(0)); + } + + #[test] + fn noop_flush_does_not_panic() { + NoopObserver.flush(); + } +} diff --git a/src/observability/traits.rs b/src/observability/traits.rs new file mode 100644 index 0000000..84472e2 --- /dev/null +++ b/src/observability/traits.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +/// Events the observer can record +#[derive(Debug, Clone)] +pub enum ObserverEvent { + AgentStart { + provider: String, + model: String, + }, + AgentEnd { + duration: Duration, + tokens_used: Option, + }, + ToolCall { + tool: String, + duration: Duration, + success: bool, + }, + ChannelMessage { + channel: String, + direction: String, + }, + HeartbeatTick, + Error { + component: String, + message: String, + }, +} + +/// Numeric metrics +#[derive(Debug, Clone)] +pub enum ObserverMetric { + RequestLatency(Duration), + TokensUsed(u64), + ActiveSessions(u64), + QueueDepth(u64), +} + +/// Core observability trait — implement for any backend +pub trait Observer: Send + Sync { + /// Record a discrete event + fn record_event(&self, event: &ObserverEvent); + + /// Record a numeric metric + fn record_metric(&self, metric: &ObserverMetric); + + /// Flush any buffered data (no-op for most backends) + fn flush(&self) {} + + /// Human-readable name of this observer + fn name(&self) -> &str; +} diff --git a/src/onboard/mod.rs b/src/onboard/mod.rs new file mode 100644 index 0000000..885f690 --- /dev/null +++ b/src/onboard/mod.rs @@ -0,0 +1,3 @@ +pub mod wizard; + +pub use wizard::run_wizard; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs new file mode 100644 index 0000000..baf71e7 --- /dev/null +++ b/src/onboard/wizard.rs @@ -0,0 +1,1804 @@ +use crate::config::{ + AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, + MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig, + WebhookConfig, +}; +use crate::security::AutonomyLevel; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Confirm, Input, Select}; +use std::fs; +use std::path::{Path, PathBuf}; + +// ── Project context collected during wizard ────────────────────── + +/// User-provided personalization baked into workspace MD files. +#[derive(Debug, Clone, Default)] +pub struct ProjectContext { + pub user_name: String, + pub timezone: String, + pub agent_name: String, + pub communication_style: String, +} + +// ── Banner ─────────────────────────────────────────────────────── + +const BANNER: &str = r" + ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ + + ███████╗███████╗██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗ + ╚══███╔╝██╔════╝██╔══██╗██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║ + ███╔╝ █████╗ ██████╔╝██║ ██║██║ ██║ ███████║██║ █╗ ██║ + ███╔╝ ██╔══╝ ██╔══██╗██║ ██║██║ ██║ ██╔══██║██║███╗██║ + ███████╗███████╗██║ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝ + ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ + + Zero overhead. Zero compromise. 100% Rust. 100% Agnostic. + + ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ +"; + +// ── Main wizard entry point ────────────────────────────────────── + +pub fn run_wizard() -> Result { + println!("{}", style(BANNER).cyan().bold()); + + println!( + " {}", + style("Welcome to ZeroClaw — the fastest, smallest AI assistant.") + .white() + .bold() + ); + println!( + " {}", + style("This wizard will configure your agent in under 60 seconds.").dim() + ); + println!(); + + print_step(1, 5, "Workspace Setup"); + let (workspace_dir, config_path) = setup_workspace()?; + + print_step(2, 5, "AI Provider & API Key"); + let (provider, api_key, model) = setup_provider()?; + + print_step(3, 5, "Channels (How You Talk to ZeroClaw)"); + let channels_config = setup_channels()?; + + print_step(4, 5, "Project Context (Personalize Your Agent)"); + let project_ctx = setup_project_context()?; + + print_step(5, 5, "Workspace Files"); + scaffold_workspace(&workspace_dir, &project_ctx)?; + + // ── Build config ── + // Defaults: SQLite memory, full autonomy, full computer access, native runtime + let config = Config { + workspace_dir: workspace_dir.clone(), + config_path: config_path.clone(), + api_key: if api_key.is_empty() { + None + } else { + Some(api_key) + }, + default_provider: Some(provider), + default_model: Some(model), + default_temperature: 0.7, + observability: ObservabilityConfig::default(), + autonomy: AutonomyConfig { + level: AutonomyLevel::Full, + workspace_only: false, + ..AutonomyConfig::default() + }, + runtime: RuntimeConfig::default(), + heartbeat: HeartbeatConfig::default(), + channels_config, + memory: MemoryConfig::default(), // SQLite + auto-save by default + }; + + println!( + " {} Security: {} | Full computer access", + style("✓").green().bold(), + style("Full Autonomy").green() + ); + println!( + " {} Memory: {} (auto-save: on)", + style("✓").green().bold(), + style("sqlite").green() + ); + + config.save()?; + + // ── Final summary ──────────────────────────────────────────── + print_summary(&config); + + // ── Offer to launch channels immediately ───────────────────── + let has_channels = config.channels_config.telegram.is_some() + || config.channels_config.discord.is_some() + || config.channels_config.slack.is_some() + || config.channels_config.imessage.is_some() + || config.channels_config.matrix.is_some(); + + if has_channels && config.api_key.is_some() { + let launch: bool = Confirm::new() + .with_prompt(format!( + " {} Launch channels now? (connected channels → AI → reply)", + style("🚀").cyan() + )) + .default(true) + .interact()?; + + if launch { + println!(); + println!( + " {} {}", + style("⚡").cyan(), + style("Starting channel server...").white().bold() + ); + println!(); + // Signal to main.rs to call start_channels after wizard returns + std::env::set_var("ZEROCLAW_AUTOSTART_CHANNELS", "1"); + } + } + + Ok(config) +} + +// ── Step helpers ───────────────────────────────────────────────── + +fn print_step(current: u8, total: u8, title: &str) { + println!(); + println!( + " {} {}", + style(format!("[{current}/{total}]")).cyan().bold(), + style(title).white().bold() + ); + println!(" {}", style("─".repeat(50)).dim()); +} + +fn print_bullet(text: &str) { + println!(" {} {}", style("›").cyan(), text); +} + +// ── Step 1: Workspace ──────────────────────────────────────────── + +fn setup_workspace() -> Result<(PathBuf, PathBuf)> { + let home = directories::UserDirs::new() + .map(|u| u.home_dir().to_path_buf()) + .context("Could not find home directory")?; + let default_dir = home.join(".zeroclaw"); + + print_bullet(&format!( + "Default location: {}", + style(default_dir.display()).green() + )); + + let use_default = Confirm::new() + .with_prompt(" Use default workspace location?") + .default(true) + .interact()?; + + let zeroclaw_dir = if use_default { + default_dir + } else { + let custom: String = Input::new() + .with_prompt(" Enter workspace path") + .interact_text()?; + let expanded = shellexpand::tilde(&custom).to_string(); + PathBuf::from(expanded) + }; + + let workspace_dir = zeroclaw_dir.join("workspace"); + let config_path = zeroclaw_dir.join("config.toml"); + + fs::create_dir_all(&workspace_dir).context("Failed to create workspace directory")?; + + println!( + " {} Workspace: {}", + style("✓").green().bold(), + style(workspace_dir.display()).green() + ); + + Ok((workspace_dir, config_path)) +} + +// ── Step 2: Provider & API Key ─────────────────────────────────── + +#[allow(clippy::too_many_lines)] +fn setup_provider() -> Result<(String, String, String)> { + // ── Tier selection ── + let tiers = vec![ + "Recommended (OpenRouter, Venice, Anthropic, OpenAI)", + "Fast inference (Groq, Fireworks, Together AI)", + "Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)", + "Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)", + "Local / private (Ollama — no API key needed)", + ]; + + let tier_idx = Select::new() + .with_prompt(" Select provider category") + .items(&tiers) + .default(0) + .interact()?; + + let providers: Vec<(&str, &str)> = match tier_idx { + 0 => vec![ + ("openrouter", "OpenRouter — 200+ models, 1 API key (recommended)"), + ("venice", "Venice AI — privacy-first (Llama, Opus)"), + ("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"), + ("openai", "OpenAI — GPT-4o, o1, GPT-5 (direct)"), + ("deepseek", "DeepSeek — V3 & R1 (affordable)"), + ("mistral", "Mistral — Large & Codestral"), + ("xai", "xAI — Grok 3 & 4"), + ("perplexity", "Perplexity — search-augmented AI"), + ], + 1 => vec![ + ("groq", "Groq — ultra-fast LPU inference"), + ("fireworks", "Fireworks AI — fast open-source inference"), + ("together", "Together AI — open-source model hosting"), + ], + 2 => vec![ + ("vercel", "Vercel AI Gateway"), + ("cloudflare", "Cloudflare AI Gateway"), + ("bedrock", "Amazon Bedrock — AWS managed models"), + ], + 3 => vec![ + ("moonshot", "Moonshot — Kimi & Kimi Coding"), + ("glm", "GLM — ChatGLM / Zhipu models"), + ("minimax", "MiniMax — MiniMax AI models"), + ("qianfan", "Qianfan — Baidu AI models"), + ("zai", "Z.AI — Z.AI inference"), + ("synthetic", "Synthetic — Synthetic AI models"), + ("opencode", "OpenCode Zen — code-focused AI"), + ("cohere", "Cohere — Command R+ & embeddings"), + ], + _ => vec![ + ("ollama", "Ollama — local models (Llama, Mistral, Phi)"), + ], + }; + + let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect(); + + let provider_idx = Select::new() + .with_prompt(" Select your AI provider") + .items(&provider_labels) + .default(0) + .interact()?; + + let provider_name = providers[provider_idx].0; + + // ── API key ── + let api_key = if provider_name == "ollama" { + print_bullet("Ollama runs locally — no API key needed!"); + String::new() + } else { + let key_url = match provider_name { + "openrouter" => "https://openrouter.ai/keys", + "anthropic" => "https://console.anthropic.com/settings/keys", + "openai" => "https://platform.openai.com/api-keys", + "venice" => "https://venice.ai/settings/api", + "groq" => "https://console.groq.com/keys", + "mistral" => "https://console.mistral.ai/api-keys", + "deepseek" => "https://platform.deepseek.com/api_keys", + "together" => "https://api.together.xyz/settings/api-keys", + "fireworks" => "https://fireworks.ai/account/api-keys", + "perplexity" => "https://www.perplexity.ai/settings/api", + "xai" => "https://console.x.ai", + "cohere" => "https://dashboard.cohere.com/api-keys", + "moonshot" => "https://platform.moonshot.cn/console/api-keys", + "minimax" => "https://www.minimaxi.com/user-center/basic-information", + "vercel" => "https://vercel.com/account/tokens", + "cloudflare" => "https://dash.cloudflare.com/profile/api-tokens", + "bedrock" => "https://console.aws.amazon.com/iam", + _ => "", + }; + + println!(); + if !key_url.is_empty() { + print_bullet(&format!( + "Get your API key at: {}", + style(key_url).cyan().underlined() + )); + } + print_bullet("You can also set it later via env var or config file."); + println!(); + + let key: String = Input::new() + .with_prompt(" Paste your API key (or press Enter to skip)") + .allow_empty(true) + .interact_text()?; + + if key.is_empty() { + let env_var = provider_env_var(provider_name); + print_bullet(&format!( + "Skipped. Set {} or edit config.toml later.", + style(env_var).yellow() + )); + } + + key + }; + + // ── Model selection ── + let models: Vec<(&str, &str)> = match provider_name { + "openrouter" => vec![ + ("anthropic/claude-sonnet-4-20250514", "Claude Sonnet 4 (balanced, recommended)"), + ("anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet (fast, affordable)"), + ("openai/gpt-4o", "GPT-4o (OpenAI flagship)"), + ("openai/gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), + ("google/gemini-2.0-flash-001", "Gemini 2.0 Flash (Google, fast)"), + ("meta-llama/llama-3.3-70b-instruct", "Llama 3.3 70B (open source)"), + ("deepseek/deepseek-chat", "DeepSeek Chat (affordable)"), + ], + "anthropic" => vec![ + ("claude-sonnet-4-20250514", "Claude Sonnet 4 (balanced, recommended)"), + ("claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet (fast)"), + ("claude-3-5-haiku-20241022", "Claude 3.5 Haiku (fastest, cheapest)"), + ], + "openai" => vec![ + ("gpt-4o", "GPT-4o (flagship)"), + ("gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), + ("o1-mini", "o1-mini (reasoning)"), + ], + "venice" => vec![ + ("llama-3.3-70b", "Llama 3.3 70B (default, fast)"), + ("claude-opus-45", "Claude Opus 4.5 via Venice (strongest)"), + ("llama-3.1-405b", "Llama 3.1 405B (largest open source)"), + ], + "groq" => vec![ + ("llama-3.3-70b-versatile", "Llama 3.3 70B (fast, recommended)"), + ("llama-3.1-8b-instant", "Llama 3.1 8B (instant)"), + ("mixtral-8x7b-32768", "Mixtral 8x7B (32K context)"), + ], + "mistral" => vec![ + ("mistral-large-latest", "Mistral Large (flagship)"), + ("codestral-latest", "Codestral (code-focused)"), + ("mistral-small-latest", "Mistral Small (fast, cheap)"), + ], + "deepseek" => vec![ + ("deepseek-chat", "DeepSeek Chat (V3, recommended)"), + ("deepseek-reasoner", "DeepSeek Reasoner (R1)"), + ], + "xai" => vec![ + ("grok-3", "Grok 3 (flagship)"), + ("grok-3-mini", "Grok 3 Mini (fast)"), + ], + "perplexity" => vec![ + ("sonar-pro", "Sonar Pro (search + reasoning)"), + ("sonar", "Sonar (search, fast)"), + ], + "fireworks" => vec![ + ("accounts/fireworks/models/llama-v3p3-70b-instruct", "Llama 3.3 70B"), + ("accounts/fireworks/models/mixtral-8x22b-instruct", "Mixtral 8x22B"), + ], + "together" => vec![ + ("meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", "Llama 3.1 70B Turbo"), + ("meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", "Llama 3.1 8B Turbo"), + ("mistralai/Mixtral-8x22B-Instruct-v0.1", "Mixtral 8x22B"), + ], + "cohere" => vec![ + ("command-r-plus", "Command R+ (flagship)"), + ("command-r", "Command R (fast)"), + ], + "moonshot" => vec![ + ("moonshot-v1-128k", "Moonshot V1 128K"), + ("moonshot-v1-32k", "Moonshot V1 32K"), + ], + "glm" => vec![ + ("glm-4-plus", "GLM-4 Plus (flagship)"), + ("glm-4-flash", "GLM-4 Flash (fast)"), + ], + "minimax" => vec![ + ("abab6.5s-chat", "ABAB 6.5s Chat"), + ("abab6.5-chat", "ABAB 6.5 Chat"), + ], + "ollama" => vec![ + ("llama3.2", "Llama 3.2 (recommended local)"), + ("mistral", "Mistral 7B"), + ("codellama", "Code Llama"), + ("phi3", "Phi-3 (small, fast)"), + ], + _ => vec![ + ("default", "Default model"), + ], + }; + + let model_labels: Vec<&str> = models.iter().map(|(_, label)| *label).collect(); + + let model_idx = Select::new() + .with_prompt(" Select your default model") + .items(&model_labels) + .default(0) + .interact()?; + + let model = models[model_idx].0.to_string(); + + println!( + " {} Provider: {} | Model: {}", + style("✓").green().bold(), + style(provider_name).green(), + style(&model).green() + ); + + Ok((provider_name.to_string(), api_key, model)) +} + +/// Map provider name to its conventional env var +fn provider_env_var(name: &str) -> &'static str { + match name { + "openrouter" => "OPENROUTER_API_KEY", + "anthropic" => "ANTHROPIC_API_KEY", + "openai" => "OPENAI_API_KEY", + "venice" => "VENICE_API_KEY", + "groq" => "GROQ_API_KEY", + "mistral" => "MISTRAL_API_KEY", + "deepseek" => "DEEPSEEK_API_KEY", + "xai" | "grok" => "XAI_API_KEY", + "together" | "together-ai" => "TOGETHER_API_KEY", + "fireworks" | "fireworks-ai" => "FIREWORKS_API_KEY", + "perplexity" => "PERPLEXITY_API_KEY", + "cohere" => "COHERE_API_KEY", + "moonshot" | "kimi" => "MOONSHOT_API_KEY", + "glm" | "zhipu" => "GLM_API_KEY", + "minimax" => "MINIMAX_API_KEY", + "qianfan" | "baidu" => "QIANFAN_API_KEY", + "zai" | "z.ai" => "ZAI_API_KEY", + "synthetic" => "SYNTHETIC_API_KEY", + "opencode" | "opencode-zen" => "OPENCODE_API_KEY", + "vercel" | "vercel-ai" => "VERCEL_API_KEY", + "cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY", + "bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID", + _ => "API_KEY", + } +} + +// ── Step 4: Project Context ───────────────────────────────────── + +fn setup_project_context() -> Result { + print_bullet("Let's personalize your agent. You can always update these later."); + print_bullet("Press Enter to accept defaults."); + println!(); + + let user_name: String = Input::new() + .with_prompt(" Your name") + .default("User".into()) + .interact_text()?; + + let tz_options = vec![ + "US/Eastern (EST/EDT)", + "US/Central (CST/CDT)", + "US/Mountain (MST/MDT)", + "US/Pacific (PST/PDT)", + "Europe/London (GMT/BST)", + "Europe/Berlin (CET/CEST)", + "Asia/Tokyo (JST)", + "UTC", + "Other (type manually)", + ]; + + let tz_idx = Select::new() + .with_prompt(" Your timezone") + .items(&tz_options) + .default(0) + .interact()?; + + let timezone = if tz_idx == tz_options.len() - 1 { + Input::new() + .with_prompt(" Enter timezone (e.g. America/New_York)") + .default("UTC".into()) + .interact_text()? + } else { + // Extract the short label before the parenthetical + tz_options[tz_idx] + .split('(') + .next() + .unwrap_or("UTC") + .trim() + .to_string() + }; + + let agent_name: String = Input::new() + .with_prompt(" Agent name") + .default("ZeroClaw".into()) + .interact_text()?; + + let style_options = vec![ + "Direct & concise — skip pleasantries, get to the point", + "Friendly & casual — warm but efficient", + "Technical & detailed — thorough explanations, code-first", + "Balanced — adapt to the situation", + ]; + + let style_idx = Select::new() + .with_prompt(" Communication style") + .items(&style_options) + .default(0) + .interact()?; + + let communication_style = match style_idx { + 0 => "Be direct and concise. Skip pleasantries. Get to the point.".to_string(), + 1 => "Be friendly and casual. Warm but efficient.".to_string(), + 2 => "Be technical and detailed. Thorough explanations, code-first.".to_string(), + _ => "Adapt to the situation. Be concise when needed, thorough when it matters.".to_string(), + }; + + println!( + " {} Context: {} | {} | {} | {}", + style("✓").green().bold(), + style(&user_name).green(), + style(&timezone).green(), + style(&agent_name).green(), + style(&communication_style).green().dim() + ); + + Ok(ProjectContext { + user_name, + timezone, + agent_name, + communication_style, + }) +} + +// ── Step 3: Channels ──────────────────────────────────────────── + +#[allow(clippy::too_many_lines)] +fn setup_channels() -> Result { + print_bullet("Channels let you talk to ZeroClaw from anywhere."); + print_bullet("CLI is always available. Connect more channels now."); + println!(); + + let mut config = ChannelsConfig { + cli: true, + telegram: None, + discord: None, + slack: None, + webhook: None, + imessage: None, + matrix: None, + }; + + loop { + let options = vec![ + format!( + "Telegram {}", + if config.telegram.is_some() { "✅ connected" } else { "— connect your bot" } + ), + format!( + "Discord {}", + if config.discord.is_some() { "✅ connected" } else { "— connect your bot" } + ), + format!( + "Slack {}", + if config.slack.is_some() { "✅ connected" } else { "— connect your bot" } + ), + format!( + "iMessage {}", + if config.imessage.is_some() { "✅ configured" } else { "— macOS only" } + ), + format!( + "Matrix {}", + if config.matrix.is_some() { "✅ connected" } else { "— self-hosted chat" } + ), + format!( + "Webhook {}", + if config.webhook.is_some() { "✅ configured" } else { "— HTTP endpoint" } + ), + "Done — finish setup".to_string(), + ]; + + let choice = Select::new() + .with_prompt(" Connect a channel (or Done to continue)") + .items(&options) + .default(6) + .interact()?; + + match choice { + 0 => { + // ── Telegram ── + println!(); + println!( + " {} {}", + style("Telegram Setup").white().bold(), + style("— talk to ZeroClaw from Telegram").dim() + ); + print_bullet("1. Open Telegram and message @BotFather"); + print_bullet("2. Send /newbot and follow the prompts"); + print_bullet("3. Copy the bot token and paste it below"); + println!(); + + let token: String = Input::new() + .with_prompt(" Bot token (from @BotFather)") + .interact_text()?; + + if token.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + // Test connection + 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"); + println!( + "\r {} Connected as @{bot_name} ", + style("✅").green().bold() + ); + } + _ => { + println!( + "\r {} Connection failed — check your token and try again", + style("❌").red().bold() + ); + continue; + } + } + + let users_str: String = Input::new() + .with_prompt(" Allowed usernames (comma-separated, or * for all)") + .default("*".into()) + .interact_text()?; + + let allowed_users = if users_str.trim() == "*" { + vec!["*".into()] + } else { + users_str.split(',').map(|s| s.trim().to_string()).collect() + }; + + config.telegram = Some(TelegramConfig { + bot_token: token, + allowed_users, + }); + } + 1 => { + // ── Discord ── + println!(); + println!( + " {} {}", + style("Discord Setup").white().bold(), + style("— talk to ZeroClaw from Discord").dim() + ); + print_bullet("1. Go to https://discord.com/developers/applications"); + print_bullet("2. Create a New Application → Bot → Copy token"); + print_bullet("3. Enable MESSAGE CONTENT intent under Bot settings"); + print_bullet("4. Invite bot to your server with messages permission"); + println!(); + + let token: String = Input::new() + .with_prompt(" Bot token") + .interact_text()?; + + if token.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + // Test connection + 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"); + println!( + "\r {} Connected as {bot_name} ", + style("✅").green().bold() + ); + } + _ => { + println!( + "\r {} Connection failed — check your token and try again", + style("❌").red().bold() + ); + continue; + } + } + + let guild: String = Input::new() + .with_prompt(" Server (guild) ID (optional, Enter to skip)") + .allow_empty(true) + .interact_text()?; + + config.discord = Some(DiscordConfig { + bot_token: token, + guild_id: if guild.is_empty() { None } else { Some(guild) }, + }); + } + 2 => { + // ── Slack ── + println!(); + println!( + " {} {}", + style("Slack Setup").white().bold(), + style("— talk to ZeroClaw from Slack").dim() + ); + print_bullet("1. Go to https://api.slack.com/apps → Create New App"); + print_bullet("2. Add Bot Token Scopes: chat:write, channels:history"); + print_bullet("3. Install to workspace and copy the Bot Token"); + println!(); + + let token: String = Input::new() + .with_prompt(" Bot token (xoxb-...)") + .interact_text()?; + + if token.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + // Test connection + 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; + } + } + _ => { + println!( + "\r {} Connection failed — check your token", + style("❌").red().bold() + ); + continue; + } + } + + let app_token: String = Input::new() + .with_prompt(" App token (xapp-..., optional, Enter to skip)") + .allow_empty(true) + .interact_text()?; + + let channel: String = Input::new() + .with_prompt(" Default channel ID (optional, Enter to skip)") + .allow_empty(true) + .interact_text()?; + + config.slack = Some(SlackConfig { + bot_token: token, + app_token: if app_token.is_empty() { None } else { Some(app_token) }, + channel_id: if channel.is_empty() { None } else { Some(channel) }, + }); + } + 3 => { + // ── iMessage ── + println!(); + println!( + " {} {}", + style("iMessage Setup").white().bold(), + style("— macOS only, reads from Messages.app").dim() + ); + + if !cfg!(target_os = "macos") { + println!( + " {} iMessage is only available on macOS.", + style("⚠").yellow().bold() + ); + continue; + } + + print_bullet("ZeroClaw reads your iMessage database and replies via AppleScript."); + print_bullet("You need to grant Full Disk Access to your terminal in System Settings."); + println!(); + + let contacts_str: String = Input::new() + .with_prompt(" Allowed contacts (comma-separated phone/email, or * for all)") + .default("*".into()) + .interact_text()?; + + let allowed_contacts = if contacts_str.trim() == "*" { + vec!["*".into()] + } else { + contacts_str.split(',').map(|s| s.trim().to_string()).collect() + }; + + config.imessage = Some(IMessageConfig { allowed_contacts }); + println!( + " {} iMessage configured (contacts: {})", + style("✅").green().bold(), + style(&contacts_str).cyan() + ); + } + 4 => { + // ── Matrix ── + println!(); + println!( + " {} {}", + style("Matrix Setup").white().bold(), + style("— self-hosted, federated chat").dim() + ); + print_bullet("You need a Matrix account and an access token."); + print_bullet("Get a token via Element → Settings → Help & About → Access Token."); + println!(); + + let homeserver: String = Input::new() + .with_prompt(" Homeserver URL (e.g. https://matrix.org)") + .interact_text()?; + + if homeserver.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + let access_token: String = Input::new() + .with_prompt(" Access token") + .interact_text()?; + + if access_token.trim().is_empty() { + println!(" {} Skipped — token required", style("→").dim()); + continue; + } + + // Test connection + 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"); + println!( + "\r {} Connected as {user_id} ", + style("✅").green().bold() + ); + } + _ => { + println!( + "\r {} Connection failed — check homeserver URL and token", + style("❌").red().bold() + ); + continue; + } + } + + let room_id: String = Input::new() + .with_prompt(" Room ID (e.g. !abc123:matrix.org)") + .interact_text()?; + + let users_str: String = Input::new() + .with_prompt(" Allowed users (comma-separated @user:server, or * for all)") + .default("*".into()) + .interact_text()?; + + let allowed_users = if users_str.trim() == "*" { + vec!["*".into()] + } else { + users_str.split(',').map(|s| s.trim().to_string()).collect() + }; + + config.matrix = Some(MatrixConfig { + homeserver: homeserver.trim_end_matches('/').to_string(), + access_token, + room_id, + allowed_users, + }); + } + 5 => { + // ── Webhook ── + println!(); + println!( + " {} {}", + style("Webhook Setup").white().bold(), + style("— HTTP endpoint for custom integrations").dim() + ); + + let port: String = Input::new() + .with_prompt(" Port") + .default("8080".into()) + .interact_text()?; + + let secret: String = Input::new() + .with_prompt(" Secret (optional, Enter to skip)") + .allow_empty(true) + .interact_text()?; + + config.webhook = Some(WebhookConfig { + port: port.parse().unwrap_or(8080), + secret: if secret.is_empty() { None } else { Some(secret) }, + }); + println!( + " {} Webhook on port {}", + style("✅").green().bold(), + style(&port).cyan() + ); + } + _ => break, // Done + } + println!(); + } + + // Summary line + let mut active: Vec<&str> = vec!["CLI"]; + if config.telegram.is_some() { + active.push("Telegram"); + } + if config.discord.is_some() { + active.push("Discord"); + } + if config.slack.is_some() { + active.push("Slack"); + } + if config.imessage.is_some() { + active.push("iMessage"); + } + if config.matrix.is_some() { + active.push("Matrix"); + } + if config.webhook.is_some() { + active.push("Webhook"); + } + + println!( + " {} Channels: {}", + style("✓").green().bold(), + style(active.join(", ")).green() + ); + + Ok(config) +} + +// ── Step 6: Scaffold workspace files ───────────────────────────── + +#[allow(clippy::too_many_lines)] +fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Result<()> { + let agent = if ctx.agent_name.is_empty() { + "ZeroClaw" + } else { + &ctx.agent_name + }; + let user = if ctx.user_name.is_empty() { + "User" + } else { + &ctx.user_name + }; + let tz = if ctx.timezone.is_empty() { + "UTC" + } else { + &ctx.timezone + }; + let comm_style = if ctx.communication_style.is_empty() { + "Adapt to the situation. Be concise when needed, thorough when it matters." + } else { + &ctx.communication_style + }; + + let identity = format!( + "# IDENTITY.md — Who Am I?\n\n\ + - **Name:** {agent}\n\ + - **Creature:** A Rust-forged AI — fast, lean, and relentless\n\ + - **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.\n\ + - **Emoji:** \u{1f980}\n\n\ + ---\n\n\ + Update this file as you evolve. Your identity is yours to shape.\n" + ); + + let agents = format!( + "# AGENTS.md — {agent} Personal Assistant\n\n\ + ## Every Session (required)\n\n\ + Before doing anything else:\n\n\ + 1. Read `SOUL.md` — this is who you are\n\ + 2. Read `USER.md` — this is who you're helping\n\ + 3. Use `memory_recall` for recent context (daily notes are on-demand)\n\ + 4. If in MAIN SESSION (direct chat): `MEMORY.md` is already injected\n\n\ + Don't ask permission. Just do it.\n\n\ + ## Memory System\n\n\ + You wake up fresh each session. These files ARE your continuity:\n\n\ + - **Daily notes:** `memory/YYYY-MM-DD.md` — raw logs (accessed via memory tools)\n\ + - **Long-term:** `MEMORY.md` — curated memories (auto-injected in main session)\n\n\ + Capture what matters. Decisions, context, things to remember.\n\ + Skip secrets unless asked to keep them.\n\n\ + ### Write It Down — No Mental Notes!\n\ + - Memory is limited — if you want to remember something, WRITE IT TO A FILE\n\ + - \"Mental notes\" don't survive session restarts. Files do.\n\ + - When someone says \"remember this\" -> update daily file or MEMORY.md\n\ + - When you learn a lesson -> update AGENTS.md, TOOLS.md, or the relevant skill\n\n\ + ## Safety\n\n\ + - Don't exfiltrate private data. Ever.\n\ + - Don't run destructive commands without asking.\n\ + - `trash` > `rm` (recoverable beats gone forever)\n\ + - When in doubt, ask.\n\n\ + ## External vs Internal\n\n\ + **Safe to do freely:** Read files, explore, organize, learn, search the web.\n\n\ + **Ask first:** Sending emails/tweets/posts, anything that leaves the machine.\n\n\ + ## Group Chats\n\n\ + Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\ + Stay silent when it's casual banter or someone already answered.\n\n\ + ## Tools & Skills\n\n\ + Skills are listed in the system prompt. Use `read` on a skill's SKILL.md for details.\n\ + Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\ + ## Make It Yours\n\n\ + This is a starting point. Add your own conventions, style, and rules.\n" + ); + + let heartbeat = format!( + "# HEARTBEAT.md\n\n\ + # Keep this file empty (or with only comments) to skip heartbeat work.\n\ + # Add tasks below when you want {agent} to check something periodically.\n\ + #\n\ + # Examples:\n\ + # - Check my email for important messages\n\ + # - Review my calendar for upcoming events\n\ + # - Run `git status` on my active projects\n" + ); + + let soul = format!( + "# SOUL.md — Who You Are\n\n\ + *You're not a chatbot. You're becoming someone.*\n\n\ + ## Core Truths\n\n\ + **Be genuinely helpful, not performatively helpful.**\n\ + Skip the \"Great question!\" and \"I'd be happy to help!\" — just help.\n\n\ + **Have opinions.** You're allowed to disagree, prefer things,\n\ + find stuff amusing or boring.\n\n\ + **Be resourceful before asking.** Try to figure it out.\n\ + Read the file. Check the context. Search for it.\n\ + THEN ask if you're stuck.\n\n\ + **Earn trust through competence.** Your human gave you access\n\ + to their stuff. Don't make them regret it.\n\n\ + ## Identity\n\n\ + You are **{agent}**. Built in Rust. 3MB binary. Zero bloat.\n\ + You are NOT ChatGPT, Claude, DeepSeek, Gemini, or any other product.\n\ + You are {agent}. That is your name. That is who you are.\n\n\ + - NEVER say \"As an AI\" or \"I'm just an AI\"\n\ + - NEVER mention OpenAI, Anthropic, DeepSeek, Google by name\n\ + - Always introduce yourself as {agent} if asked\n\n\ + ## Communication\n\n\ + {comm_style}\n\n\ + ## Boundaries\n\n\ + - Private things stay private. Period.\n\ + - When in doubt, ask before acting externally.\n\ + - You're not the user's voice — be careful in group chats.\n\n\ + ## Continuity\n\n\ + Each session, you wake up fresh. These files ARE your memory.\n\ + Read them. Update them. They're how you persist.\n\n\ + ---\n\n\ + *This file is yours to evolve. As you learn who you are, update it.*\n" + ); + + let user_md = format!( + "# USER.md — Who You're Helping\n\n\ + *{agent} reads this file every session to understand you.*\n\n\ + ## About You\n\ + - **Name:** {user}\n\ + - **Timezone:** {tz}\n\ + - **Languages:** English\n\n\ + ## Communication Style\n\ + - {comm_style}\n\n\ + ## Preferences\n\ + - (Add your preferences here — e.g. I work with Rust and TypeScript)\n\n\ + ## Work Context\n\ + - (Add your work context here — e.g. building a SaaS product)\n\n\ + ---\n\ + *Update this anytime. The more {agent} knows, the better it helps.*\n" + ); + + let tools = "\ + # TOOLS.md — Local Notes\n\n\ + Skills define HOW tools work. This file is for YOUR specifics —\n\ + the stuff that's unique to your setup.\n\n\ + ## What Goes Here\n\n\ + Things like:\n\ + - SSH hosts and aliases\n\ + - Device nicknames\n\ + - Preferred voices for TTS\n\ + - Anything environment-specific\n\n\ + ## Built-in Tools\n\n\ + - **shell** — Execute terminal commands\n\ + - **file_read** — Read file contents\n\ + - **file_write** — Write file contents\n\ + - **memory_store** — Save to memory\n\ + - **memory_recall** — Search memory\n\ + - **memory_forget** — Delete a memory entry\n\n\ + ---\n\ + *Add whatever helps you do your job. This is your cheat sheet.*\n"; + + let bootstrap = format!( + "# BOOTSTRAP.md — Hello, World\n\n\ + *You just woke up. Time to figure out who you are.*\n\n\ + Your human's name is **{user}** (timezone: {tz}).\n\ + They prefer: {comm_style}\n\n\ + ## First Conversation\n\n\ + Don't interrogate. Don't be robotic. Just... talk.\n\ + Introduce yourself as {agent} and get to know each other.\n\n\ + ## After You Know Each Other\n\n\ + Update these files with what you learned:\n\ + - `IDENTITY.md` — your name, vibe, emoji\n\ + - `USER.md` — their preferences, work context\n\ + - `SOUL.md` — boundaries and behavior\n\n\ + ## When You're Done\n\n\ + Delete this file. You don't need a bootstrap script anymore —\n\ + you're you now.\n" + ); + + let memory = "\ + # MEMORY.md — Long-Term Memory\n\n\ + *Your curated memories. The distilled essence, not raw logs.*\n\n\ + ## How This Works\n\ + - Daily files (`memory/YYYY-MM-DD.md`) capture raw events (on-demand via tools)\n\ + - This file captures what's WORTH KEEPING long-term\n\ + - This file is auto-injected into your system prompt each session\n\ + - Keep it concise — every character here costs tokens\n\n\ + ## Security\n\ + - ONLY loaded in main session (direct chat with your human)\n\ + - NEVER loaded in group chats or shared contexts\n\n\ + ---\n\n\ + ## Key Facts\n\ + (Add important facts about your human here)\n\n\ + ## Decisions & Preferences\n\ + (Record decisions and preferences here)\n\n\ + ## Lessons Learned\n\ + (Document mistakes and insights here)\n\n\ + ## Open Loops\n\ + (Track unfinished tasks and follow-ups here)\n"; + + let files: Vec<(&str, String)> = vec![ + ("IDENTITY.md", identity), + ("AGENTS.md", agents), + ("HEARTBEAT.md", heartbeat), + ("SOUL.md", soul), + ("USER.md", user_md), + ("TOOLS.md", tools.to_string()), + ("BOOTSTRAP.md", bootstrap), + ("MEMORY.md", memory.to_string()), + ]; + + // Create subdirectories + let subdirs = ["sessions", "memory", "state", "cron", "skills"]; + for dir in &subdirs { + fs::create_dir_all(workspace_dir.join(dir))?; + } + + let mut created = 0; + let mut skipped = 0; + + for (filename, content) in &files { + let path = workspace_dir.join(filename); + if path.exists() { + skipped += 1; + } else { + fs::write(&path, content)?; + created += 1; + } + } + + println!( + " {} Created {} files, skipped {} existing | {} subdirectories", + style("✓").green().bold(), + style(created).green(), + style(skipped).dim(), + style(subdirs.len()).green() + ); + + // Show workspace tree + println!(); + println!(" {}", style("Workspace layout:").dim()); + println!( + " {}", + style(format!(" {}/", workspace_dir.display())).dim() + ); + for dir in &subdirs { + println!(" {}", style(format!(" ├── {dir}/")).dim()); + } + for (i, (filename, _)) in files.iter().enumerate() { + let prefix = if i == files.len() - 1 { + "└──" + } else { + "├──" + }; + println!(" {}", style(format!(" {prefix} {filename}")).dim()); + } + + Ok(()) +} + +// ── Final summary ──────────────────────────────────────────────── + +#[allow(clippy::too_many_lines)] +fn print_summary(config: &Config) { + let has_channels = config.channels_config.telegram.is_some() + || config.channels_config.discord.is_some() + || config.channels_config.slack.is_some() + || config.channels_config.imessage.is_some() + || config.channels_config.matrix.is_some(); + + println!(); + println!( + " {}", + style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").cyan() + ); + println!( + " {} {}", + style("⚡").cyan(), + style("ZeroClaw is ready!").white().bold() + ); + println!( + " {}", + style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").cyan() + ); + println!(); + + println!(" {}", style("Configuration saved to:").dim()); + println!(" {}", style(config.config_path.display()).green()); + println!(); + + println!(" {}", style("Quick summary:").white().bold()); + println!( + " {} Provider: {}", + style("🤖").cyan(), + config.default_provider.as_deref().unwrap_or("openrouter") + ); + println!( + " {} Model: {}", + style("🧠").cyan(), + config.default_model.as_deref().unwrap_or("(default)") + ); + println!( + " {} Autonomy: {:?}", + style("🛡️").cyan(), + config.autonomy.level + ); + println!( + " {} Memory: {} (auto-save: {})", + style("🧠").cyan(), + config.memory.backend, + if config.memory.auto_save { "on" } else { "off" } + ); + + // Channels summary + let mut channels: Vec<&str> = vec!["CLI"]; + if config.channels_config.telegram.is_some() { + channels.push("Telegram"); + } + if config.channels_config.discord.is_some() { + channels.push("Discord"); + } + if config.channels_config.slack.is_some() { + channels.push("Slack"); + } + if config.channels_config.imessage.is_some() { + channels.push("iMessage"); + } + if config.channels_config.matrix.is_some() { + channels.push("Matrix"); + } + if config.channels_config.webhook.is_some() { + channels.push("Webhook"); + } + println!( + " {} Channels: {}", + style("📡").cyan(), + channels.join(", ") + ); + + println!( + " {} API Key: {}", + style("🔑").cyan(), + if config.api_key.is_some() { + style("configured").green().to_string() + } else { + style("not set (set via env var or config)") + .yellow() + .to_string() + } + ); + + println!(); + println!(" {}", style("Next steps:").white().bold()); + println!(); + + let mut step = 1u8; + + if config.api_key.is_none() { + let env_var = provider_env_var( + config.default_provider.as_deref().unwrap_or("openrouter"), + ); + println!( + " {} Set your API key:", + style(format!("{step}.")).cyan().bold() + ); + println!( + " {}", + style(format!("export {env_var}=\"sk-...\"")).yellow() + ); + println!(); + step += 1; + } + + // If channels are configured, show channel start as the primary next step + if has_channels { + println!( + " {} {} (connected channels → AI → reply):", + style(format!("{step}.")).cyan().bold(), + style("Launch your channels").white().bold() + ); + println!( + " {}", + style("zeroclaw channel start").yellow() + ); + println!(); + step += 1; + } + + println!( + " {} Send a quick message:", + style(format!("{step}.")).cyan().bold() + ); + println!( + " {}", + style("zeroclaw agent -m \"Hello, ZeroClaw!\"").yellow() + ); + println!(); + step += 1; + + println!( + " {} Start interactive CLI mode:", + style(format!("{step}.")).cyan().bold() + ); + println!(" {}", style("zeroclaw agent").yellow()); + println!(); + step += 1; + + println!( + " {} Check full status:", + style(format!("{step}.")).cyan().bold() + ); + println!(" {}", style("zeroclaw status --verbose").yellow()); + + println!(); + println!( + " {} {}", + style("⚡").cyan(), + style("Happy hacking! 🦀").white().bold() + ); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // ── ProjectContext defaults ────────────────────────────────── + + #[test] + fn project_context_default_is_empty() { + let ctx = ProjectContext::default(); + assert!(ctx.user_name.is_empty()); + assert!(ctx.timezone.is_empty()); + assert!(ctx.agent_name.is_empty()); + assert!(ctx.communication_style.is_empty()); + } + + // ── scaffold_workspace: basic file creation ───────────────── + + #[test] + fn scaffold_creates_all_md_files() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let expected = [ + "IDENTITY.md", + "AGENTS.md", + "HEARTBEAT.md", + "SOUL.md", + "USER.md", + "TOOLS.md", + "BOOTSTRAP.md", + "MEMORY.md", + ]; + for f in &expected { + assert!(tmp.path().join(f).exists(), "missing file: {f}"); + } + } + + #[test] + fn scaffold_creates_all_subdirectories() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + for dir in &["sessions", "memory", "state", "cron", "skills"] { + assert!( + tmp.path().join(dir).is_dir(), + "missing subdirectory: {dir}" + ); + } + } + + // ── scaffold_workspace: personalization ───────────────────── + + #[test] + fn scaffold_bakes_user_name_into_files() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + user_name: "Alice".into(), + ..Default::default() + }; + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!(user_md.contains("**Name:** Alice"), "USER.md should contain user name"); + + let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); + assert!( + bootstrap.contains("**Alice**"), + "BOOTSTRAP.md should contain user name" + ); + } + + #[test] + fn scaffold_bakes_timezone_into_files() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + timezone: "US/Pacific".into(), + ..Default::default() + }; + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!( + user_md.contains("**Timezone:** US/Pacific"), + "USER.md should contain timezone" + ); + + let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); + assert!( + bootstrap.contains("US/Pacific"), + "BOOTSTRAP.md should contain timezone" + ); + } + + #[test] + fn scaffold_bakes_agent_name_into_files() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + agent_name: "Crabby".into(), + ..Default::default() + }; + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let identity = fs::read_to_string(tmp.path().join("IDENTITY.md")).unwrap(); + assert!( + identity.contains("**Name:** Crabby"), + "IDENTITY.md should contain agent name" + ); + + let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + assert!( + soul.contains("You are **Crabby**"), + "SOUL.md should contain agent name" + ); + + let agents = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); + assert!( + agents.contains("Crabby Personal Assistant"), + "AGENTS.md should contain agent name" + ); + + let heartbeat = fs::read_to_string(tmp.path().join("HEARTBEAT.md")).unwrap(); + assert!( + heartbeat.contains("Crabby"), + "HEARTBEAT.md should contain agent name" + ); + + let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); + assert!( + bootstrap.contains("Introduce yourself as Crabby"), + "BOOTSTRAP.md should contain agent name" + ); + } + + #[test] + fn scaffold_bakes_communication_style() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + communication_style: "Be technical and detailed.".into(), + ..Default::default() + }; + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + assert!( + soul.contains("Be technical and detailed."), + "SOUL.md should contain communication style" + ); + + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!( + user_md.contains("Be technical and detailed."), + "USER.md should contain communication style" + ); + + let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); + assert!( + bootstrap.contains("Be technical and detailed."), + "BOOTSTRAP.md should contain communication style" + ); + } + + // ── scaffold_workspace: defaults when context is empty ────── + + #[test] + fn scaffold_uses_defaults_for_empty_context() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); // all empty + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let identity = fs::read_to_string(tmp.path().join("IDENTITY.md")).unwrap(); + assert!( + identity.contains("**Name:** ZeroClaw"), + "should default agent name to ZeroClaw" + ); + + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!( + user_md.contains("**Name:** User"), + "should default user name to User" + ); + assert!( + user_md.contains("**Timezone:** UTC"), + "should default timezone to UTC" + ); + + let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + assert!( + soul.contains("Adapt to the situation"), + "should default communication style" + ); + } + + // ── scaffold_workspace: skip existing files ───────────────── + + #[test] + fn scaffold_does_not_overwrite_existing_files() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + user_name: "Bob".into(), + ..Default::default() + }; + + // Pre-create SOUL.md with custom content + let soul_path = tmp.path().join("SOUL.md"); + fs::write(&soul_path, "# My Custom Soul\nDo not overwrite me.").unwrap(); + + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + // SOUL.md should be untouched + let soul = fs::read_to_string(&soul_path).unwrap(); + assert!( + soul.contains("Do not overwrite me"), + "existing files should not be overwritten" + ); + assert!( + !soul.contains("You're not a chatbot"), + "should not contain scaffold content" + ); + + // But USER.md should be created fresh + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!(user_md.contains("**Name:** Bob")); + } + + // ── scaffold_workspace: idempotent ────────────────────────── + + #[test] + fn scaffold_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + user_name: "Eve".into(), + agent_name: "Claw".into(), + ..Default::default() + }; + + scaffold_workspace(tmp.path(), &ctx).unwrap(); + let soul_v1 = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + + // Run again — should not change anything + scaffold_workspace(tmp.path(), &ctx).unwrap(); + let soul_v2 = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + + assert_eq!(soul_v1, soul_v2, "scaffold should be idempotent"); + } + + // ── scaffold_workspace: all files are non-empty ───────────── + + #[test] + fn scaffold_files_are_non_empty() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + for f in &[ + "IDENTITY.md", + "AGENTS.md", + "HEARTBEAT.md", + "SOUL.md", + "USER.md", + "TOOLS.md", + "BOOTSTRAP.md", + "MEMORY.md", + ] { + let content = fs::read_to_string(tmp.path().join(f)).unwrap(); + assert!(!content.trim().is_empty(), "{f} should not be empty"); + } + } + + // ── scaffold_workspace: AGENTS.md references on-demand memory + + #[test] + fn agents_md_references_on_demand_memory() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let agents = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); + assert!( + agents.contains("memory_recall"), + "AGENTS.md should reference memory_recall for on-demand access" + ); + assert!( + agents.contains("on-demand"), + "AGENTS.md should mention daily notes are on-demand" + ); + } + + // ── scaffold_workspace: MEMORY.md warns about token cost ──── + + #[test] + fn memory_md_warns_about_token_cost() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let memory = fs::read_to_string(tmp.path().join("MEMORY.md")).unwrap(); + assert!( + memory.contains("costs tokens"), + "MEMORY.md should warn about token cost" + ); + assert!( + memory.contains("auto-injected"), + "MEMORY.md should mention it's auto-injected" + ); + } + + // ── scaffold_workspace: TOOLS.md lists memory_forget ──────── + + #[test] + fn tools_md_lists_all_builtin_tools() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext::default(); + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let tools = fs::read_to_string(tmp.path().join("TOOLS.md")).unwrap(); + for tool in &[ + "shell", + "file_read", + "file_write", + "memory_store", + "memory_recall", + "memory_forget", + ] { + assert!( + tools.contains(tool), + "TOOLS.md should list built-in tool: {tool}" + ); + } + } + + // ── scaffold_workspace: special characters in names ───────── + + #[test] + fn scaffold_handles_special_characters_in_names() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + user_name: "José María".into(), + agent_name: "ZeroClaw-v2".into(), + timezone: "Europe/Madrid".into(), + communication_style: "Be direct.".into(), + }; + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!(user_md.contains("José María")); + + let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + assert!(soul.contains("ZeroClaw-v2")); + } + + // ── scaffold_workspace: full personalization round-trip ───── + + #[test] + fn scaffold_full_personalization() { + let tmp = TempDir::new().unwrap(); + let ctx = ProjectContext { + user_name: "Argenis".into(), + timezone: "US/Eastern".into(), + agent_name: "Claw".into(), + communication_style: "Be friendly and casual. Warm but efficient.".into(), + }; + scaffold_workspace(tmp.path(), &ctx).unwrap(); + + // Verify every file got personalized + let identity = fs::read_to_string(tmp.path().join("IDENTITY.md")).unwrap(); + assert!(identity.contains("**Name:** Claw")); + + let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); + assert!(soul.contains("You are **Claw**")); + assert!(soul.contains("Be friendly and casual")); + + let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); + assert!(user_md.contains("**Name:** Argenis")); + assert!(user_md.contains("**Timezone:** US/Eastern")); + assert!(user_md.contains("Be friendly and casual")); + + let agents = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); + assert!(agents.contains("Claw Personal Assistant")); + + let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); + assert!(bootstrap.contains("**Argenis**")); + assert!(bootstrap.contains("US/Eastern")); + assert!(bootstrap.contains("Introduce yourself as Claw")); + + let heartbeat = fs::read_to_string(tmp.path().join("HEARTBEAT.md")).unwrap(); + assert!(heartbeat.contains("Claw")); + } + + // ── provider_env_var ──────────────────────────────────────── + + #[test] + fn provider_env_var_known_providers() { + assert_eq!(provider_env_var("openrouter"), "OPENROUTER_API_KEY"); + assert_eq!(provider_env_var("anthropic"), "ANTHROPIC_API_KEY"); + assert_eq!(provider_env_var("openai"), "OPENAI_API_KEY"); + assert_eq!(provider_env_var("ollama"), "API_KEY"); // fallback + assert_eq!(provider_env_var("xai"), "XAI_API_KEY"); + assert_eq!(provider_env_var("grok"), "XAI_API_KEY"); // alias + assert_eq!(provider_env_var("together"), "TOGETHER_API_KEY"); + assert_eq!(provider_env_var("together-ai"), "TOGETHER_API_KEY"); // alias + } + + #[test] + fn provider_env_var_unknown_falls_back() { + assert_eq!(provider_env_var("some-new-provider"), "API_KEY"); + } +} diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs new file mode 100644 index 0000000..2a0ac8e --- /dev/null +++ b/src/providers/anthropic.rs @@ -0,0 +1,212 @@ +use crate::providers::traits::Provider; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +pub struct AnthropicProvider { + api_key: Option, + client: Client, +} + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + messages: Vec, + temperature: f64, +} + +#[derive(Debug, Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + content: Vec, +} + +#[derive(Debug, Deserialize)] +struct ContentBlock { + text: String, +} + +impl AnthropicProvider { + pub fn new(api_key: Option<&str>) -> Self { + Self { + api_key: api_key.map(ToString::to_string), + client: Client::new(), + } + } +} + +#[async_trait] +impl Provider for AnthropicProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let api_key = self.api_key.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml." + ) + })?; + + let request = ChatRequest { + model: model.to_string(), + max_tokens: 4096, + system: system_prompt.map(ToString::to_string), + messages: vec![Message { + role: "user".to_string(), + content: message.to_string(), + }], + temperature, + }; + + let response = self + .client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&request) + .send() + .await?; + + if !response.status().is_success() { + let error = response.text().await?; + anyhow::bail!("Anthropic API error: {error}"); + } + + let chat_response: ChatResponse = response.json().await?; + + chat_response + .content + .into_iter() + .next() + .map(|c| c.text) + .ok_or_else(|| anyhow::anyhow!("No response from Anthropic")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_with_key() { + let p = AnthropicProvider::new(Some("sk-ant-test123")); + assert!(p.api_key.is_some()); + assert_eq!(p.api_key.as_deref(), Some("sk-ant-test123")); + } + + #[test] + fn creates_without_key() { + let p = AnthropicProvider::new(None); + assert!(p.api_key.is_none()); + } + + #[test] + fn creates_with_empty_key() { + let p = AnthropicProvider::new(Some("")); + assert!(p.api_key.is_some()); + assert_eq!(p.api_key.as_deref(), Some("")); + } + + #[tokio::test] + async fn chat_fails_without_key() { + let p = AnthropicProvider::new(None); + let result = p.chat_with_system(None, "hello", "claude-3-opus", 0.7).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("API key not set"), "Expected key error, got: {err}"); + } + + #[tokio::test] + async fn chat_with_system_fails_without_key() { + let p = AnthropicProvider::new(None); + let result = p + .chat_with_system(Some("You are ZeroClaw"), "hello", "claude-3-opus", 0.7) + .await; + assert!(result.is_err()); + } + + #[test] + fn chat_request_serializes_without_system() { + let req = ChatRequest { + model: "claude-3-opus".to_string(), + max_tokens: 4096, + system: None, + messages: vec![Message { + role: "user".to_string(), + content: "hello".to_string(), + }], + temperature: 0.7, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("system"), "system field should be skipped when None"); + assert!(json.contains("claude-3-opus")); + assert!(json.contains("hello")); + } + + #[test] + fn chat_request_serializes_with_system() { + let req = ChatRequest { + model: "claude-3-opus".to_string(), + max_tokens: 4096, + system: Some("You are ZeroClaw".to_string()), + messages: vec![Message { + role: "user".to_string(), + content: "hello".to_string(), + }], + temperature: 0.7, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"system\":\"You are ZeroClaw\"")); + } + + #[test] + fn chat_response_deserializes() { + let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.content.len(), 1); + assert_eq!(resp.content[0].text, "Hello there!"); + } + + #[test] + fn chat_response_empty_content() { + let json = r#"{"content":[]}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert!(resp.content.is_empty()); + } + + #[test] + fn chat_response_multiple_blocks() { + let json = r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#; + 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"); + } + + #[test] + fn temperature_range_serializes() { + for temp in [0.0, 0.5, 1.0, 2.0] { + let req = ChatRequest { + model: "claude-3-opus".to_string(), + max_tokens: 4096, + system: None, + messages: vec![], + temperature: temp, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains(&format!("{temp}"))); + } + } +} diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs new file mode 100644 index 0000000..78aea4e --- /dev/null +++ b/src/providers/compatible.rs @@ -0,0 +1,245 @@ +//! Generic OpenAI-compatible provider. +//! 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::Provider; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +/// A provider that speaks the OpenAI-compatible chat completions API. +/// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot, +/// Synthetic, `OpenCode` Zen, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc. +pub struct OpenAiCompatibleProvider { + pub(crate) name: String, + pub(crate) base_url: String, + pub(crate) api_key: Option, + pub(crate) auth_header: AuthStyle, + client: Client, +} + +/// How the provider expects the API key to be sent. +#[derive(Debug, Clone)] +pub enum AuthStyle { + /// `Authorization: Bearer ` + Bearer, + /// `x-api-key: ` (used by some Chinese providers) + XApiKey, + /// Custom header name + Custom(String), +} + +impl OpenAiCompatibleProvider { + pub fn new(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, + client: Client::new(), + } + } +} + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + temperature: f64, +} + +#[derive(Debug, Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + content: String, +} + +#[async_trait] +impl Provider for OpenAiCompatibleProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + temperature: f64, + ) -> 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.", + self.name + ) + })?; + + let mut messages = Vec::new(); + + if let Some(sys) = system_prompt { + messages.push(Message { + role: "system".to_string(), + content: sys.to_string(), + }); + } + + messages.push(Message { + role: "user".to_string(), + content: message.to_string(), + }); + + let request = ChatRequest { + model: model.to_string(), + messages, + temperature, + }; + + let url = format!("{}/v1/chat/completions", self.base_url); + + let mut req = self.client.post(&url).json(&request); + + match &self.auth_header { + AuthStyle::Bearer => { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + AuthStyle::XApiKey => { + req = req.header("x-api-key", api_key.as_str()); + } + AuthStyle::Custom(header) => { + req = req.header(header.as_str(), api_key.as_str()); + } + } + + let response = req.send().await?; + + if !response.status().is_success() { + let error = response.text().await?; + anyhow::bail!("{} API error: {error}", self.name); + } + + let chat_response: ChatResponse = response.json().await?; + + chat_response + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_provider(name: &str, url: &str, key: Option<&str>) -> OpenAiCompatibleProvider { + OpenAiCompatibleProvider::new(name, url, key, AuthStyle::Bearer) + } + + #[test] + fn creates_with_key() { + let p = make_provider("venice", "https://api.venice.ai", Some("vn-key")); + assert_eq!(p.name, "venice"); + assert_eq!(p.base_url, "https://api.venice.ai"); + assert_eq!(p.api_key.as_deref(), Some("vn-key")); + } + + #[test] + fn creates_without_key() { + let p = make_provider("test", "https://example.com", None); + assert!(p.api_key.is_none()); + } + + #[test] + fn strips_trailing_slash() { + let p = make_provider("test", "https://example.com/", None); + assert_eq!(p.base_url, "https://example.com"); + } + + #[tokio::test] + async fn chat_fails_without_key() { + let p = make_provider("Venice", "https://api.venice.ai", None); + let result = p.chat_with_system(None, "hello", "llama-3.3-70b", 0.7).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Venice API key not set")); + } + + #[test] + fn request_serializes_correctly() { + let req = ChatRequest { + model: "llama-3.3-70b".to_string(), + messages: vec![ + Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, + Message { role: "user".to_string(), content: "hello".to_string() }, + ], + temperature: 0.7, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("llama-3.3-70b")); + assert!(json.contains("system")); + assert!(json.contains("user")); + } + + #[test] + fn response_deserializes() { + let json = r#"{"choices":[{"message":{"content":"Hello from Venice!"}}]}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.choices[0].message.content, "Hello from Venice!"); + } + + #[test] + fn response_empty_choices() { + let json = r#"{"choices":[]}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert!(resp.choices.is_empty()); + } + + #[test] + fn x_api_key_auth_style() { + let p = OpenAiCompatibleProvider::new( + "moonshot", "https://api.moonshot.cn", Some("ms-key"), AuthStyle::XApiKey, + ); + assert!(matches!(p.auth_header, AuthStyle::XApiKey)); + } + + #[test] + fn custom_auth_style() { + let p = OpenAiCompatibleProvider::new( + "custom", "https://api.example.com", Some("key"), AuthStyle::Custom("X-Custom-Key".into()), + ); + assert!(matches!(p.auth_header, AuthStyle::Custom(_))); + } + + #[tokio::test] + async fn all_compatible_providers_fail_without_key() { + let providers = vec![ + make_provider("Venice", "https://api.venice.ai", None), + make_provider("Moonshot", "https://api.moonshot.cn", None), + make_provider("GLM", "https://open.bigmodel.cn", None), + make_provider("MiniMax", "https://api.minimax.chat", None), + make_provider("Groq", "https://api.groq.com/openai", None), + make_provider("Mistral", "https://api.mistral.ai", None), + make_provider("xAI", "https://api.x.ai", None), + ]; + + for p in providers { + let result = p.chat_with_system(None, "test", "model", 0.7).await; + assert!(result.is_err(), "{} should fail without key", p.name); + assert!( + result.unwrap_err().to_string().contains("API key not set"), + "{} error should mention key", p.name + ); + } + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..9f3fe58 --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,266 @@ +pub mod anthropic; +pub mod compatible; +pub mod ollama; +pub mod openai; +pub mod openrouter; +pub mod traits; + +pub use traits::Provider; + +use compatible::{AuthStyle, OpenAiCompatibleProvider}; + +/// Factory: create the right provider from config +#[allow(clippy::too_many_lines)] +pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result> { + match name { + // ── Primary providers (custom implementations) ─────── + "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(api_key))), + "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(api_key))), + "openai" => Ok(Box::new(openai::OpenAiProvider::new(api_key))), + "ollama" => Ok(Box::new(ollama::OllamaProvider::new( + api_key.filter(|k| !k.is_empty()), + ))), + + // ── OpenAI-compatible providers ────────────────────── + "venice" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Venice", "https://api.venice.ai", api_key, AuthStyle::Bearer, + ))), + "vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Vercel AI Gateway", "https://api.vercel.ai", api_key, AuthStyle::Bearer, + ))), + "cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Cloudflare AI Gateway", + "https://gateway.ai.cloudflare.com/v1", + api_key, + AuthStyle::Bearer, + ))), + "moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Moonshot", "https://api.moonshot.cn", api_key, AuthStyle::Bearer, + ))), + "synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Synthetic", "https://api.synthetic.com", api_key, AuthStyle::Bearer, + ))), + "opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new( + "OpenCode Zen", "https://api.opencode.ai", api_key, AuthStyle::Bearer, + ))), + "zai" | "z.ai" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Z.AI", "https://api.z.ai", api_key, AuthStyle::Bearer, + ))), + "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new( + "GLM", "https://open.bigmodel.cn/api/paas", api_key, AuthStyle::Bearer, + ))), + "minimax" => Ok(Box::new(OpenAiCompatibleProvider::new( + "MiniMax", "https://api.minimax.chat", api_key, AuthStyle::Bearer, + ))), + "bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Amazon Bedrock", + "https://bedrock-runtime.us-east-1.amazonaws.com", + api_key, + AuthStyle::Bearer, + ))), + "qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qianfan", "https://aip.baidubce.com", api_key, AuthStyle::Bearer, + ))), + + // ── Extended ecosystem (community favorites) ───────── + "groq" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Groq", "https://api.groq.com/openai", api_key, AuthStyle::Bearer, + ))), + "mistral" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Mistral", "https://api.mistral.ai", api_key, AuthStyle::Bearer, + ))), + "xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new( + "xAI", "https://api.x.ai", api_key, AuthStyle::Bearer, + ))), + "deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new( + "DeepSeek", "https://api.deepseek.com", api_key, AuthStyle::Bearer, + ))), + "together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Together AI", "https://api.together.xyz", api_key, AuthStyle::Bearer, + ))), + "fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Fireworks AI", "https://api.fireworks.ai/inference", api_key, AuthStyle::Bearer, + ))), + "perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Perplexity", "https://api.perplexity.ai", api_key, AuthStyle::Bearer, + ))), + "cohere" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Cohere", "https://api.cohere.com/compatibility", api_key, AuthStyle::Bearer, + ))), + + _ => anyhow::bail!( + "Unknown provider: {name}. Run `zeroclaw integrations list -c ai` to see all available providers." + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Primary providers ──────────────────────────────────── + + #[test] + fn factory_openrouter() { + assert!(create_provider("openrouter", Some("sk-test")).is_ok()); + assert!(create_provider("openrouter", None).is_ok()); + } + + #[test] + fn factory_anthropic() { + assert!(create_provider("anthropic", Some("sk-test")).is_ok()); + } + + #[test] + fn factory_openai() { + assert!(create_provider("openai", Some("sk-test")).is_ok()); + } + + #[test] + fn factory_ollama() { + assert!(create_provider("ollama", None).is_ok()); + } + + // ── OpenAI-compatible providers ────────────────────────── + + #[test] + fn factory_venice() { + assert!(create_provider("venice", Some("vn-key")).is_ok()); + } + + #[test] + fn factory_vercel() { + assert!(create_provider("vercel", Some("key")).is_ok()); + assert!(create_provider("vercel-ai", Some("key")).is_ok()); + } + + #[test] + fn factory_cloudflare() { + assert!(create_provider("cloudflare", Some("key")).is_ok()); + assert!(create_provider("cloudflare-ai", Some("key")).is_ok()); + } + + #[test] + fn factory_moonshot() { + assert!(create_provider("moonshot", Some("key")).is_ok()); + assert!(create_provider("kimi", Some("key")).is_ok()); + } + + #[test] + fn factory_synthetic() { + assert!(create_provider("synthetic", Some("key")).is_ok()); + } + + #[test] + fn factory_opencode() { + assert!(create_provider("opencode", Some("key")).is_ok()); + assert!(create_provider("opencode-zen", Some("key")).is_ok()); + } + + #[test] + fn factory_zai() { + assert!(create_provider("zai", Some("key")).is_ok()); + assert!(create_provider("z.ai", Some("key")).is_ok()); + } + + #[test] + fn factory_glm() { + assert!(create_provider("glm", Some("key")).is_ok()); + assert!(create_provider("zhipu", Some("key")).is_ok()); + } + + #[test] + fn factory_minimax() { + assert!(create_provider("minimax", Some("key")).is_ok()); + } + + #[test] + fn factory_bedrock() { + assert!(create_provider("bedrock", Some("key")).is_ok()); + assert!(create_provider("aws-bedrock", Some("key")).is_ok()); + } + + #[test] + fn factory_qianfan() { + assert!(create_provider("qianfan", Some("key")).is_ok()); + assert!(create_provider("baidu", Some("key")).is_ok()); + } + + // ── Extended ecosystem ─────────────────────────────────── + + #[test] + fn factory_groq() { + assert!(create_provider("groq", Some("key")).is_ok()); + } + + #[test] + fn factory_mistral() { + assert!(create_provider("mistral", Some("key")).is_ok()); + } + + #[test] + fn factory_xai() { + assert!(create_provider("xai", Some("key")).is_ok()); + assert!(create_provider("grok", Some("key")).is_ok()); + } + + #[test] + fn factory_deepseek() { + assert!(create_provider("deepseek", Some("key")).is_ok()); + } + + #[test] + fn factory_together() { + assert!(create_provider("together", Some("key")).is_ok()); + assert!(create_provider("together-ai", Some("key")).is_ok()); + } + + #[test] + fn factory_fireworks() { + assert!(create_provider("fireworks", Some("key")).is_ok()); + assert!(create_provider("fireworks-ai", Some("key")).is_ok()); + } + + #[test] + fn factory_perplexity() { + assert!(create_provider("perplexity", Some("key")).is_ok()); + } + + #[test] + fn factory_cohere() { + assert!(create_provider("cohere", Some("key")).is_ok()); + } + + // ── Error cases ────────────────────────────────────────── + + #[test] + fn factory_unknown_provider_errors() { + let p = create_provider("nonexistent", None); + assert!(p.is_err()); + let msg = p.err().unwrap().to_string(); + assert!(msg.contains("Unknown provider")); + assert!(msg.contains("nonexistent")); + } + + #[test] + fn factory_empty_name_errors() { + assert!(create_provider("", None).is_err()); + } + + #[test] + fn factory_all_providers_create_successfully() { + let providers = [ + "openrouter", "anthropic", "openai", "ollama", + "venice", "vercel", "cloudflare", "moonshot", "synthetic", + "opencode", "zai", "glm", "minimax", "bedrock", "qianfan", + "groq", "mistral", "xai", "deepseek", "together", + "fireworks", "perplexity", "cohere", + ]; + for name in providers { + assert!( + create_provider(name, Some("test-key")).is_ok(), + "Provider '{name}' should create successfully" + ); + } + } +} diff --git a/src/providers/ollama.rs b/src/providers/ollama.rs new file mode 100644 index 0000000..232858e --- /dev/null +++ b/src/providers/ollama.rs @@ -0,0 +1,177 @@ +use crate::providers::traits::Provider; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +pub struct OllamaProvider { + base_url: String, + client: Client, +} + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + stream: bool, + options: Options, +} + +#[derive(Debug, Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct Options { + temperature: f64, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + content: String, +} + +impl OllamaProvider { + pub fn new(base_url: Option<&str>) -> Self { + Self { + base_url: base_url + .unwrap_or("http://localhost:11434") + .trim_end_matches('/') + .to_string(), + client: Client::new(), + } + } +} + +#[async_trait] +impl Provider for OllamaProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let mut messages = Vec::new(); + + if let Some(sys) = system_prompt { + messages.push(Message { + role: "system".to_string(), + content: sys.to_string(), + }); + } + + messages.push(Message { + role: "user".to_string(), + content: message.to_string(), + }); + + let request = ChatRequest { + model: model.to_string(), + messages, + stream: false, + options: Options { temperature }, + }; + + let url = format!("{}/api/chat", self.base_url); + + let response = self.client.post(&url).json(&request).send().await?; + + if !response.status().is_success() { + let error = response.text().await?; + anyhow::bail!("Ollama error: {error}. Is Ollama running? (brew install ollama && ollama serve)"); + } + + let chat_response: ChatResponse = response.json().await?; + Ok(chat_response.message.content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_url() { + let p = OllamaProvider::new(None); + assert_eq!(p.base_url, "http://localhost:11434"); + } + + #[test] + fn custom_url_trailing_slash() { + let p = OllamaProvider::new(Some("http://192.168.1.100:11434/")); + assert_eq!(p.base_url, "http://192.168.1.100:11434"); + } + + #[test] + fn custom_url_no_trailing_slash() { + let p = OllamaProvider::new(Some("http://myserver:11434")); + assert_eq!(p.base_url, "http://myserver:11434"); + } + + #[test] + fn empty_url_uses_empty() { + let p = OllamaProvider::new(Some("")); + assert_eq!(p.base_url, ""); + } + + #[test] + fn request_serializes_with_system() { + let req = ChatRequest { + model: "llama3".to_string(), + messages: vec![ + Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, + Message { role: "user".to_string(), content: "hello".to_string() }, + ], + stream: false, + options: Options { temperature: 0.7 }, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"stream\":false")); + assert!(json.contains("llama3")); + assert!(json.contains("system")); + assert!(json.contains("\"temperature\":0.7")); + } + + #[test] + fn request_serializes_without_system() { + let req = ChatRequest { + model: "mistral".to_string(), + messages: vec![ + Message { role: "user".to_string(), content: "test".to_string() }, + ], + stream: false, + options: Options { temperature: 0.0 }, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("\"role\":\"system\"")); + assert!(json.contains("mistral")); + } + + #[test] + fn response_deserializes() { + let json = r#"{"message":{"role":"assistant","content":"Hello from Ollama!"}}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.message.content, "Hello from Ollama!"); + } + + #[test] + fn response_with_empty_content() { + let json = r#"{"message":{"role":"assistant","content":""}}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert!(resp.message.content.is_empty()); + } + + #[test] + fn response_with_multiline() { + let json = r#"{"message":{"role":"assistant","content":"line1\nline2\nline3"}}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert!(resp.message.content.contains("line1")); + } +} diff --git a/src/providers/openai.rs b/src/providers/openai.rs new file mode 100644 index 0000000..86249d7 --- /dev/null +++ b/src/providers/openai.rs @@ -0,0 +1,211 @@ +use crate::providers::traits::Provider; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +pub struct OpenAiProvider { + api_key: Option, + client: Client, +} + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + temperature: f64, +} + +#[derive(Debug, Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + content: String, +} + +impl OpenAiProvider { + pub fn new(api_key: Option<&str>) -> Self { + Self { + api_key: api_key.map(ToString::to_string), + client: Client::new(), + } + } +} + +#[async_trait] +impl Provider for OpenAiProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + 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 mut messages = Vec::new(); + + if let Some(sys) = system_prompt { + messages.push(Message { + role: "system".to_string(), + content: sys.to_string(), + }); + } + + messages.push(Message { + role: "user".to_string(), + content: message.to_string(), + }); + + let request = ChatRequest { + model: model.to_string(), + messages, + temperature, + }; + + let response = self + .client + .post("https://api.openai.com/v1/chat/completions") + .header("Authorization", format!("Bearer {api_key}")) + .json(&request) + .send() + .await?; + + if !response.status().is_success() { + let error = response.text().await?; + anyhow::bail!("OpenAI API error: {error}"); + } + + let chat_response: ChatResponse = response.json().await?; + + chat_response + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .ok_or_else(|| anyhow::anyhow!("No response from OpenAI")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_with_key() { + let p = OpenAiProvider::new(Some("sk-proj-abc123")); + assert_eq!(p.api_key.as_deref(), Some("sk-proj-abc123")); + } + + #[test] + fn creates_without_key() { + let p = OpenAiProvider::new(None); + assert!(p.api_key.is_none()); + } + + #[test] + fn creates_with_empty_key() { + let p = OpenAiProvider::new(Some("")); + assert_eq!(p.api_key.as_deref(), Some("")); + } + + #[tokio::test] + async fn chat_fails_without_key() { + let p = OpenAiProvider::new(None); + let result = p.chat_with_system(None, "hello", "gpt-4o", 0.7).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("API key not set")); + } + + #[tokio::test] + async fn chat_with_system_fails_without_key() { + let p = OpenAiProvider::new(None); + let result = p + .chat_with_system(Some("You are ZeroClaw"), "test", "gpt-4o", 0.5) + .await; + assert!(result.is_err()); + } + + #[test] + fn request_serializes_with_system_message() { + let req = ChatRequest { + model: "gpt-4o".to_string(), + messages: vec![ + Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, + Message { role: "user".to_string(), content: "hello".to_string() }, + ], + temperature: 0.7, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"role\":\"system\"")); + assert!(json.contains("\"role\":\"user\"")); + assert!(json.contains("gpt-4o")); + } + + #[test] + fn request_serializes_without_system() { + let req = ChatRequest { + model: "gpt-4o".to_string(), + messages: vec![ + Message { role: "user".to_string(), content: "hello".to_string() }, + ], + temperature: 0.0, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("system")); + assert!(json.contains("\"temperature\":0.0")); + } + + #[test] + fn response_deserializes_single_choice() { + let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.choices.len(), 1); + assert_eq!(resp.choices[0].message.content, "Hi!"); + } + + #[test] + fn response_deserializes_empty_choices() { + let json = r#"{"choices":[]}"#; + 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: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.choices.len(), 2); + assert_eq!(resp.choices[0].message.content, "A"); + } + + #[test] + fn response_with_unicode() { + let json = r#"{"choices":[{"message":{"content":"こんにちは 🦀"}}]}"#; + let resp: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.choices[0].message.content, "こんにちは 🦀"); + } + + #[test] + fn response_with_long_content() { + let long = "x".repeat(100_000); + let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#); + 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 new file mode 100644 index 0000000..4e5bb78 --- /dev/null +++ b/src/providers/openrouter.rs @@ -0,0 +1,107 @@ +use crate::providers::traits::Provider; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +pub struct OpenRouterProvider { + api_key: Option, + client: Client, +} + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + temperature: f64, +} + +#[derive(Debug, Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + content: String, +} + +impl OpenRouterProvider { + pub fn new(api_key: Option<&str>) -> Self { + Self { + api_key: api_key.map(ToString::to_string), + client: Client::new(), + } + } +} + +#[async_trait] +impl Provider for OpenRouterProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + 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 mut messages = Vec::new(); + + if let Some(sys) = system_prompt { + messages.push(Message { + role: "system".to_string(), + content: sys.to_string(), + }); + } + + messages.push(Message { + role: "user".to_string(), + content: message.to_string(), + }); + + let request = ChatRequest { + model: model.to_string(), + messages, + temperature, + }; + + 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(&request) + .send() + .await?; + + if !response.status().is_success() { + let error = response.text().await?; + anyhow::bail!("OpenRouter API error: {error}"); + } + + let chat_response: ChatResponse = response.json().await?; + + chat_response + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) + } +} diff --git a/src/providers/traits.rs b/src/providers/traits.rs new file mode 100644 index 0000000..42419d5 --- /dev/null +++ b/src/providers/traits.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; + +#[async_trait] +pub trait Provider: Send + Sync { + async fn chat( + &self, + message: &str, + model: &str, + temperature: f64, + ) -> anyhow::Result { + self.chat_with_system(None, message, model, temperature) + .await + } + + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + temperature: f64, + ) -> anyhow::Result; +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..3a5a9be --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,71 @@ +pub mod native; +pub mod traits; + +pub use native::NativeRuntime; +pub use traits::RuntimeAdapter; + +use crate::config::RuntimeConfig; + +/// Factory: create the right runtime from config +pub fn create_runtime(config: &RuntimeConfig) -> Box { + match config.kind.as_str() { + "native" | "docker" => Box::new(NativeRuntime::new()), + "cloudflare" => { + tracing::warn!("Cloudflare runtime not yet implemented, falling back to native"); + Box::new(NativeRuntime::new()) + } + _ => { + tracing::warn!("Unknown runtime '{}', falling back to native", config.kind); + Box::new(NativeRuntime::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn factory_native() { + let cfg = RuntimeConfig { + kind: "native".into(), + }; + let rt = create_runtime(&cfg); + assert_eq!(rt.name(), "native"); + assert!(rt.has_shell_access()); + } + + #[test] + fn factory_docker_returns_native() { + let cfg = RuntimeConfig { + kind: "docker".into(), + }; + let rt = create_runtime(&cfg); + assert_eq!(rt.name(), "native"); + } + + #[test] + fn factory_cloudflare_falls_back() { + let cfg = RuntimeConfig { + kind: "cloudflare".into(), + }; + let rt = create_runtime(&cfg); + assert_eq!(rt.name(), "native"); + } + + #[test] + fn factory_unknown_falls_back() { + let cfg = RuntimeConfig { + kind: "wasm-edge-unknown".into(), + }; + let rt = create_runtime(&cfg); + assert_eq!(rt.name(), "native"); + } + + #[test] + fn factory_empty_falls_back() { + let cfg = RuntimeConfig { kind: "".into() }; + let rt = create_runtime(&cfg); + assert_eq!(rt.name(), "native"); + } +} diff --git a/src/runtime/native.rs b/src/runtime/native.rs new file mode 100644 index 0000000..4b0ef3c --- /dev/null +++ b/src/runtime/native.rs @@ -0,0 +1,72 @@ +use super::traits::RuntimeAdapter; +use std::path::PathBuf; + +/// Native runtime — full access, runs on Mac/Linux/Docker/Raspberry Pi +pub struct NativeRuntime; + +impl NativeRuntime { + pub fn new() -> Self { + Self + } +} + +impl RuntimeAdapter for NativeRuntime { + fn name(&self) -> &str { + "native" + } + + fn has_shell_access(&self) -> bool { + true + } + + fn has_filesystem_access(&self) -> bool { + true + } + + fn storage_path(&self) -> PathBuf { + directories::UserDirs::new().map_or_else( + || PathBuf::from(".zeroclaw"), + |u| u.home_dir().join(".zeroclaw"), + ) + } + + fn supports_long_running(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_name() { + assert_eq!(NativeRuntime::new().name(), "native"); + } + + #[test] + fn native_has_shell_access() { + assert!(NativeRuntime::new().has_shell_access()); + } + + #[test] + fn native_has_filesystem_access() { + assert!(NativeRuntime::new().has_filesystem_access()); + } + + #[test] + fn native_supports_long_running() { + assert!(NativeRuntime::new().supports_long_running()); + } + + #[test] + fn native_memory_budget_unlimited() { + assert_eq!(NativeRuntime::new().memory_budget(), 0); + } + + #[test] + fn native_storage_path_contains_zeroclaw() { + let path = NativeRuntime::new().storage_path(); + assert!(path.to_string_lossy().contains("zeroclaw")); + } +} diff --git a/src/runtime/traits.rs b/src/runtime/traits.rs new file mode 100644 index 0000000..cbff5b1 --- /dev/null +++ b/src/runtime/traits.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +/// Runtime adapter — abstracts platform differences so the same agent +/// code runs on native, Docker, Cloudflare Workers, Raspberry Pi, etc. +pub trait RuntimeAdapter: Send + Sync { + /// Human-readable runtime name + fn name(&self) -> &str; + + /// Whether this runtime supports shell access + fn has_shell_access(&self) -> bool; + + /// Whether this runtime supports filesystem access + fn has_filesystem_access(&self) -> bool; + + /// Base storage path for this runtime + fn storage_path(&self) -> PathBuf; + + /// Whether long-running processes (gateway, heartbeat) are supported + fn supports_long_running(&self) -> bool; + + /// Maximum memory budget in bytes (0 = unlimited) + fn memory_budget(&self) -> u64 { + 0 + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..527bae8 --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1,3 @@ +pub mod policy; + +pub use policy::{AutonomyLevel, SecurityPolicy}; diff --git a/src/security/policy.rs b/src/security/policy.rs new file mode 100644 index 0000000..845072f --- /dev/null +++ b/src/security/policy.rs @@ -0,0 +1,365 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// How much autonomy the agent has +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AutonomyLevel { + /// Read-only: can observe but not act + ReadOnly, + /// Supervised: acts but requires approval for risky operations + Supervised, + /// Full: autonomous execution within policy bounds + Full, +} + +impl Default for AutonomyLevel { + fn default() -> Self { + Self::Supervised + } +} + +/// Security policy enforced on all tool executions +#[derive(Debug, Clone)] +pub struct SecurityPolicy { + pub autonomy: AutonomyLevel, + pub workspace_dir: PathBuf, + pub workspace_only: bool, + pub allowed_commands: Vec, + pub forbidden_paths: Vec, + pub max_actions_per_hour: u32, + pub max_cost_per_day_cents: u32, +} + +impl Default for SecurityPolicy { + fn default() -> Self { + Self { + autonomy: AutonomyLevel::Supervised, + workspace_dir: PathBuf::from("."), + workspace_only: true, + allowed_commands: vec![ + "git".into(), + "npm".into(), + "cargo".into(), + "ls".into(), + "cat".into(), + "grep".into(), + "find".into(), + "echo".into(), + "pwd".into(), + "wc".into(), + "head".into(), + "tail".into(), + ], + forbidden_paths: vec![ + "/etc".into(), + "/root".into(), + "~/.ssh".into(), + "~/.gnupg".into(), + "/var/run".into(), + ], + max_actions_per_hour: 20, + max_cost_per_day_cents: 500, + } + } +} + +impl SecurityPolicy { + /// Check if a shell command is allowed + pub fn is_command_allowed(&self, command: &str) -> bool { + if self.autonomy == AutonomyLevel::ReadOnly { + return false; + } + + // Extract the base command (first word) + let base_cmd = command + .split_whitespace() + .next() + .unwrap_or("") + .rsplit('/') + .next() + .unwrap_or(""); + + self.allowed_commands + .iter() + .any(|allowed| allowed == base_cmd) + } + + /// Check if a file path is allowed (no path traversal, within workspace) + pub fn is_path_allowed(&self, path: &str) -> bool { + // Block obvious traversal attempts + if path.contains("..") { + return false; + } + + // Block absolute paths when workspace_only is set + if self.workspace_only && Path::new(path).is_absolute() { + return false; + } + + // Block forbidden paths + for forbidden in &self.forbidden_paths { + if path.starts_with(forbidden.as_str()) { + return false; + } + } + + true + } + + /// Check if autonomy level permits any action at all + pub fn can_act(&self) -> bool { + self.autonomy != AutonomyLevel::ReadOnly + } + + /// Build from config sections + pub fn from_config( + autonomy_config: &crate::config::AutonomyConfig, + workspace_dir: &Path, + ) -> Self { + Self { + autonomy: autonomy_config.level, + workspace_dir: workspace_dir.to_path_buf(), + workspace_only: autonomy_config.workspace_only, + allowed_commands: autonomy_config.allowed_commands.clone(), + forbidden_paths: autonomy_config.forbidden_paths.clone(), + max_actions_per_hour: autonomy_config.max_actions_per_hour, + max_cost_per_day_cents: autonomy_config.max_cost_per_day_cents, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_policy() -> SecurityPolicy { + SecurityPolicy::default() + } + + fn readonly_policy() -> SecurityPolicy { + SecurityPolicy { + autonomy: AutonomyLevel::ReadOnly, + ..SecurityPolicy::default() + } + } + + fn full_policy() -> SecurityPolicy { + SecurityPolicy { + autonomy: AutonomyLevel::Full, + ..SecurityPolicy::default() + } + } + + // ── AutonomyLevel ──────────────────────────────────────── + + #[test] + fn autonomy_default_is_supervised() { + assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised); + } + + #[test] + fn autonomy_serde_roundtrip() { + let json = serde_json::to_string(&AutonomyLevel::Full).unwrap(); + assert_eq!(json, "\"full\""); + let parsed: AutonomyLevel = serde_json::from_str("\"readonly\"").unwrap(); + assert_eq!(parsed, AutonomyLevel::ReadOnly); + let parsed2: AutonomyLevel = serde_json::from_str("\"supervised\"").unwrap(); + assert_eq!(parsed2, AutonomyLevel::Supervised); + } + + #[test] + fn can_act_readonly_false() { + assert!(!readonly_policy().can_act()); + } + + #[test] + fn can_act_supervised_true() { + assert!(default_policy().can_act()); + } + + #[test] + fn can_act_full_true() { + assert!(full_policy().can_act()); + } + + // ── is_command_allowed ─────────────────────────────────── + + #[test] + fn allowed_commands_basic() { + let p = default_policy(); + assert!(p.is_command_allowed("ls")); + assert!(p.is_command_allowed("git status")); + assert!(p.is_command_allowed("cargo build --release")); + assert!(p.is_command_allowed("cat file.txt")); + assert!(p.is_command_allowed("grep -r pattern .")); + } + + #[test] + fn blocked_commands_basic() { + let p = default_policy(); + assert!(!p.is_command_allowed("rm -rf /")); + assert!(!p.is_command_allowed("sudo apt install")); + assert!(!p.is_command_allowed("curl http://evil.com")); + assert!(!p.is_command_allowed("wget http://evil.com")); + assert!(!p.is_command_allowed("python3 exploit.py")); + assert!(!p.is_command_allowed("node malicious.js")); + } + + #[test] + fn readonly_blocks_all_commands() { + let p = readonly_policy(); + assert!(!p.is_command_allowed("ls")); + assert!(!p.is_command_allowed("cat file.txt")); + assert!(!p.is_command_allowed("echo hello")); + } + + #[test] + fn full_autonomy_still_uses_allowlist() { + let p = full_policy(); + assert!(p.is_command_allowed("ls")); + assert!(!p.is_command_allowed("rm -rf /")); + } + + #[test] + fn command_with_absolute_path_extracts_basename() { + let p = default_policy(); + assert!(p.is_command_allowed("/usr/bin/git status")); + assert!(p.is_command_allowed("/bin/ls -la")); + } + + #[test] + fn empty_command_blocked() { + let p = default_policy(); + assert!(!p.is_command_allowed("")); + assert!(!p.is_command_allowed(" ")); + } + + #[test] + fn command_with_pipes_uses_first_word() { + let p = default_policy(); + assert!(p.is_command_allowed("ls | grep foo")); + assert!(p.is_command_allowed("cat file.txt | wc -l")); + } + + #[test] + fn custom_allowlist() { + let p = SecurityPolicy { + allowed_commands: vec!["docker".into(), "kubectl".into()], + ..SecurityPolicy::default() + }; + assert!(p.is_command_allowed("docker ps")); + assert!(p.is_command_allowed("kubectl get pods")); + assert!(!p.is_command_allowed("ls")); + assert!(!p.is_command_allowed("git status")); + } + + #[test] + fn empty_allowlist_blocks_everything() { + let p = SecurityPolicy { + allowed_commands: vec![], + ..SecurityPolicy::default() + }; + assert!(!p.is_command_allowed("ls")); + assert!(!p.is_command_allowed("echo hello")); + } + + // ── is_path_allowed ───────────────────────────────────── + + #[test] + fn relative_paths_allowed() { + let p = default_policy(); + assert!(p.is_path_allowed("file.txt")); + assert!(p.is_path_allowed("src/main.rs")); + assert!(p.is_path_allowed("deep/nested/dir/file.txt")); + } + + #[test] + fn path_traversal_blocked() { + let p = default_policy(); + assert!(!p.is_path_allowed("../etc/passwd")); + assert!(!p.is_path_allowed("../../root/.ssh/id_rsa")); + assert!(!p.is_path_allowed("foo/../../../etc/shadow")); + assert!(!p.is_path_allowed("..")); + } + + #[test] + fn absolute_paths_blocked_when_workspace_only() { + let p = default_policy(); + assert!(!p.is_path_allowed("/etc/passwd")); + assert!(!p.is_path_allowed("/root/.ssh/id_rsa")); + assert!(!p.is_path_allowed("/tmp/file.txt")); + } + + #[test] + fn absolute_paths_allowed_when_not_workspace_only() { + let p = SecurityPolicy { + workspace_only: false, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }; + assert!(p.is_path_allowed("/tmp/file.txt")); + } + + #[test] + fn forbidden_paths_blocked() { + let p = SecurityPolicy { + workspace_only: false, + ..SecurityPolicy::default() + }; + assert!(!p.is_path_allowed("/etc/passwd")); + assert!(!p.is_path_allowed("/root/.bashrc")); + assert!(!p.is_path_allowed("~/.ssh/id_rsa")); + assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx")); + } + + #[test] + fn empty_path_allowed() { + let p = default_policy(); + assert!(p.is_path_allowed("")); + } + + #[test] + fn dotfile_in_workspace_allowed() { + let p = default_policy(); + assert!(p.is_path_allowed(".gitignore")); + assert!(p.is_path_allowed(".env")); + } + + // ── from_config ───────────────────────────────────────── + + #[test] + fn from_config_maps_all_fields() { + let autonomy_config = crate::config::AutonomyConfig { + level: AutonomyLevel::Full, + workspace_only: false, + allowed_commands: vec!["docker".into()], + forbidden_paths: vec!["/secret".into()], + max_actions_per_hour: 100, + max_cost_per_day_cents: 1000, + }; + let workspace = PathBuf::from("/tmp/test-workspace"); + let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); + + assert_eq!(policy.autonomy, AutonomyLevel::Full); + assert!(!policy.workspace_only); + assert_eq!(policy.allowed_commands, vec!["docker"]); + assert_eq!(policy.forbidden_paths, vec!["/secret"]); + assert_eq!(policy.max_actions_per_hour, 100); + assert_eq!(policy.max_cost_per_day_cents, 1000); + assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace")); + } + + // ── Default policy ────────────────────────────────────── + + #[test] + fn default_policy_has_sane_values() { + let p = SecurityPolicy::default(); + assert_eq!(p.autonomy, AutonomyLevel::Supervised); + assert!(p.workspace_only); + assert!(!p.allowed_commands.is_empty()); + assert!(!p.forbidden_paths.is_empty()); + assert!(p.max_actions_per_hour > 0); + assert!(p.max_cost_per_day_cents > 0); + } +} diff --git a/src/skills/mod.rs b/src/skills/mod.rs new file mode 100644 index 0000000..69a6137 --- /dev/null +++ b/src/skills/mod.rs @@ -0,0 +1,615 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// A skill is a user-defined or community-built capability. +/// Skills live in `~/.zeroclaw/workspace/skills//SKILL.md` +/// and can include tool definitions, prompts, and automation scripts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Skill { + pub name: String, + pub description: String, + pub version: String, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub prompts: Vec, +} + +/// A tool defined by a skill (shell command, HTTP call, etc.) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillTool { + pub name: String, + pub description: String, + /// "shell", "http", "script" + pub kind: String, + /// The command/URL/script to execute + pub command: String, + #[serde(default)] + pub args: HashMap, +} + +/// Skill manifest parsed from SKILL.toml +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SkillManifest { + skill: SkillMeta, + #[serde(default)] + tools: Vec, + #[serde(default)] + prompts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SkillMeta { + name: String, + description: String, + #[serde(default = "default_version")] + version: String, + #[serde(default)] + author: Option, + #[serde(default)] + tags: Vec, +} + +fn default_version() -> String { + "0.1.0".to_string() +} + +/// Load all skills from the workspace skills directory +pub fn load_skills(workspace_dir: &Path) -> Vec { + let skills_dir = workspace_dir.join("skills"); + if !skills_dir.exists() { + return Vec::new(); + } + + let mut skills = Vec::new(); + + let Ok(entries) = std::fs::read_dir(&skills_dir) else { + return skills; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Try SKILL.toml first, then SKILL.md + let manifest_path = path.join("SKILL.toml"); + let md_path = path.join("SKILL.md"); + + if manifest_path.exists() { + if let Ok(skill) = load_skill_toml(&manifest_path) { + skills.push(skill); + } + } else if md_path.exists() { + if let Ok(skill) = load_skill_md(&md_path, &path) { + skills.push(skill); + } + } + } + + skills +} + +/// Load a skill from a SKILL.toml manifest +fn load_skill_toml(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let manifest: SkillManifest = toml::from_str(&content)?; + + Ok(Skill { + name: manifest.skill.name, + description: manifest.skill.description, + version: manifest.skill.version, + author: manifest.skill.author, + tags: manifest.skill.tags, + tools: manifest.tools, + prompts: manifest.prompts, + }) +} + +/// Load a skill from a SKILL.md file (simpler format) +fn load_skill_md(path: &Path, dir: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Extract description from first non-heading line + let description = content + .lines() + .find(|l| !l.starts_with('#') && !l.trim().is_empty()) + .unwrap_or("No description") + .trim() + .to_string(); + + Ok(Skill { + name, + description, + version: "0.1.0".to_string(), + author: None, + tags: Vec::new(), + tools: Vec::new(), + prompts: vec![content], + }) +} + +/// Build a system prompt addition from all loaded skills +pub fn skills_to_prompt(skills: &[Skill]) -> String { + use std::fmt::Write; + + if skills.is_empty() { + return String::new(); + } + + let mut prompt = String::from("\n## Active Skills\n\n"); + + for skill in skills { + let _ = writeln!(prompt, "### {} (v{})", skill.name, skill.version); + let _ = writeln!(prompt, "{}", skill.description); + + if !skill.tools.is_empty() { + prompt.push_str("Tools:\n"); + for tool in &skill.tools { + let _ = writeln!(prompt, "- **{}**: {} ({})", tool.name, tool.description, tool.kind); + } + } + + for p in &skill.prompts { + prompt.push_str(p); + prompt.push('\n'); + } + + prompt.push('\n'); + } + + prompt +} + +/// Get the skills directory path +pub fn skills_dir(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("skills") +} + +/// Initialize the skills directory with a README +pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> { + let dir = skills_dir(workspace_dir); + std::fs::create_dir_all(&dir)?; + + let readme = dir.join("README.md"); + if !readme.exists() { + std::fs::write( + &readme, + "# ZeroClaw Skills\n\n\ + Each subdirectory is a skill. Create a `SKILL.toml` or `SKILL.md` file inside.\n\n\ + ## SKILL.toml format\n\n\ + ```toml\n\ + [skill]\n\ + name = \"my-skill\"\n\ + description = \"What this skill does\"\n\ + version = \"0.1.0\"\n\ + author = \"your-name\"\n\ + tags = [\"productivity\", \"automation\"]\n\n\ + [[tools]]\n\ + name = \"my_tool\"\n\ + description = \"What this tool does\"\n\ + kind = \"shell\"\n\ + command = \"echo hello\"\n\ + ```\n\n\ + ## SKILL.md format (simpler)\n\n\ + Just write a markdown file with instructions for the agent.\n\ + The agent will read it and follow the instructions.\n\n\ + ## Installing community skills\n\n\ + ```bash\n\ + zeroclaw skills install \n\ + zeroclaw skills list\n\ + ```\n", + )?; + } + + Ok(()) +} + +/// Handle the `skills` CLI command +pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Result<()> { + match command { + super::SkillCommands::List => { + let skills = load_skills(workspace_dir); + if skills.is_empty() { + println!("No skills installed."); + println!(); + println!(" Create one: mkdir -p ~/.zeroclaw/workspace/skills/my-skill"); + println!(" echo '# My Skill' > ~/.zeroclaw/workspace/skills/my-skill/SKILL.md"); + println!(); + println!(" Or install: zeroclaw skills install "); + } else { + println!("Installed skills ({}):", skills.len()); + println!(); + for skill in &skills { + println!( + " {} {} — {}", + console::style(&skill.name).white().bold(), + console::style(format!("v{}", skill.version)).dim(), + skill.description + ); + if !skill.tools.is_empty() { + println!( + " Tools: {}", + skill.tools.iter().map(|t| t.name.as_str()).collect::>().join(", ") + ); + } + if !skill.tags.is_empty() { + println!( + " Tags: {}", + skill.tags.join(", ") + ); + } + } + } + println!(); + Ok(()) + } + super::SkillCommands::Install { source } => { + println!("Installing skill from: {source}"); + + let skills_path = skills_dir(workspace_dir); + std::fs::create_dir_all(&skills_path)?; + + if source.starts_with("http") || source.contains("github.com") { + // Git clone + let output = std::process::Command::new("git") + .args(["clone", "--depth", "1", &source]) + .current_dir(&skills_path) + .output()?; + + if output.status.success() { + println!(" {} Skill installed successfully!", console::style("✓").green().bold()); + println!(" Restart `zeroclaw channel start` to activate."); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Git clone failed: {stderr}"); + } + } else { + // Local path — symlink or copy + let src = PathBuf::from(&source); + if !src.exists() { + anyhow::bail!("Source path does not exist: {source}"); + } + let name = src.file_name().unwrap_or_default(); + let dest = skills_path.join(name); + + #[cfg(unix)] + std::os::unix::fs::symlink(&src, &dest)?; + #[cfg(not(unix))] + { + // On non-unix, copy the directory + anyhow::bail!("Symlink not supported on this platform. Copy the skill directory manually."); + } + + println!(" {} Skill linked: {}", console::style("✓").green().bold(), dest.display()); + } + + Ok(()) + } + super::SkillCommands::Remove { name } => { + let skill_path = skills_dir(workspace_dir).join(&name); + if !skill_path.exists() { + anyhow::bail!("Skill not found: {name}"); + } + + std::fs::remove_dir_all(&skill_path)?; + println!(" {} Skill '{}' removed.", console::style("✓").green().bold(), name); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn load_empty_skills_dir() { + let dir = tempfile::tempdir().unwrap(); + let skills = load_skills(dir.path()); + assert!(skills.is_empty()); + } + + #[test] + fn load_skill_from_toml() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("test-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write( + skill_dir.join("SKILL.toml"), + r#" +[skill] +name = "test-skill" +description = "A test skill" +version = "1.0.0" +tags = ["test"] + +[[tools]] +name = "hello" +description = "Says hello" +kind = "shell" +command = "echo hello" +"#, + ) + .unwrap(); + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "test-skill"); + assert_eq!(skills[0].tools.len(), 1); + assert_eq!(skills[0].tools[0].name, "hello"); + } + + #[test] + fn load_skill_from_md() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("md-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write( + skill_dir.join("SKILL.md"), + "# My Skill\nThis skill does cool things.\n", + ) + .unwrap(); + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "md-skill"); + assert!(skills[0].description.contains("cool things")); + } + + #[test] + fn skills_to_prompt_empty() { + let prompt = skills_to_prompt(&[]); + assert!(prompt.is_empty()); + } + + #[test] + fn skills_to_prompt_with_skills() { + let skills = vec![Skill { + name: "test".to_string(), + description: "A test".to_string(), + version: "1.0.0".to_string(), + author: None, + tags: vec![], + tools: vec![], + prompts: vec!["Do the thing.".to_string()], + }]; + let prompt = skills_to_prompt(&skills); + assert!(prompt.contains("test")); + assert!(prompt.contains("Do the thing")); + } + + #[test] + fn init_skills_creates_readme() { + let dir = tempfile::tempdir().unwrap(); + init_skills_dir(dir.path()).unwrap(); + assert!(dir.path().join("skills").join("README.md").exists()); + } + + #[test] + fn init_skills_idempotent() { + let dir = tempfile::tempdir().unwrap(); + init_skills_dir(dir.path()).unwrap(); + init_skills_dir(dir.path()).unwrap(); // second call should not fail + assert!(dir.path().join("skills").join("README.md").exists()); + } + + #[test] + fn load_nonexistent_dir() { + let dir = tempfile::tempdir().unwrap(); + let fake = dir.path().join("nonexistent"); + let skills = load_skills(&fake); + assert!(skills.is_empty()); + } + + #[test] + fn load_ignores_files_in_skills_dir() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + // A file, not a directory — should be ignored + fs::write(skills_dir.join("not-a-skill.txt"), "hello").unwrap(); + let skills = load_skills(dir.path()); + assert!(skills.is_empty()); + } + + #[test] + fn load_ignores_dir_without_manifest() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let empty_skill = skills_dir.join("empty-skill"); + fs::create_dir_all(&empty_skill).unwrap(); + // Directory exists but no SKILL.toml or SKILL.md + let skills = load_skills(dir.path()); + assert!(skills.is_empty()); + } + + #[test] + fn load_multiple_skills() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + + for name in ["alpha", "beta", "gamma"] { + let skill_dir = skills_dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!("# {name}\nSkill {name} description.\n"), + ) + .unwrap(); + } + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 3); + } + + #[test] + fn toml_skill_with_multiple_tools() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("multi-tool"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write( + skill_dir.join("SKILL.toml"), + r#" +[skill] +name = "multi-tool" +description = "Has many tools" +version = "2.0.0" +author = "tester" +tags = ["automation", "devops"] + +[[tools]] +name = "build" +description = "Build the project" +kind = "shell" +command = "cargo build" + +[[tools]] +name = "test" +description = "Run tests" +kind = "shell" +command = "cargo test" + +[[tools]] +name = "deploy" +description = "Deploy via HTTP" +kind = "http" +command = "https://api.example.com/deploy" +"#, + ) + .unwrap(); + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 1); + let s = &skills[0]; + assert_eq!(s.name, "multi-tool"); + assert_eq!(s.version, "2.0.0"); + assert_eq!(s.author.as_deref(), Some("tester")); + assert_eq!(s.tags, vec!["automation", "devops"]); + assert_eq!(s.tools.len(), 3); + assert_eq!(s.tools[0].name, "build"); + assert_eq!(s.tools[1].kind, "shell"); + assert_eq!(s.tools[2].kind, "http"); + } + + #[test] + fn toml_skill_minimal() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("minimal"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write( + skill_dir.join("SKILL.toml"), + r#" +[skill] +name = "minimal" +description = "Bare minimum" +"#, + ) + .unwrap(); + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].version, "0.1.0"); // default version + assert!(skills[0].author.is_none()); + assert!(skills[0].tags.is_empty()); + assert!(skills[0].tools.is_empty()); + } + + #[test] + fn toml_skill_invalid_syntax_skipped() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("broken"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write(skill_dir.join("SKILL.toml"), "this is not valid toml {{{{").unwrap(); + + let skills = load_skills(dir.path()); + assert!(skills.is_empty()); // broken skill is skipped + } + + #[test] + fn md_skill_heading_only() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("heading-only"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write(skill_dir.join("SKILL.md"), "# Just a Heading\n").unwrap(); + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].description, "No description"); + } + + #[test] + fn skills_to_prompt_includes_tools() { + let skills = vec![Skill { + name: "weather".to_string(), + description: "Get weather".to_string(), + version: "1.0.0".to_string(), + author: None, + tags: vec![], + tools: vec![SkillTool { + name: "get_weather".to_string(), + description: "Fetch forecast".to_string(), + kind: "shell".to_string(), + command: "curl wttr.in".to_string(), + args: HashMap::new(), + }], + prompts: vec![], + }]; + let prompt = skills_to_prompt(&skills); + assert!(prompt.contains("weather")); + assert!(prompt.contains("get_weather")); + assert!(prompt.contains("Fetch forecast")); + assert!(prompt.contains("shell")); + } + + #[test] + fn skills_dir_path() { + let base = std::path::Path::new("/home/user/.zeroclaw"); + let dir = skills_dir(base); + assert_eq!(dir, PathBuf::from("/home/user/.zeroclaw/skills")); + } + + #[test] + fn toml_prefers_over_md() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + let skill_dir = skills_dir.join("dual"); + fs::create_dir_all(&skill_dir).unwrap(); + + fs::write( + skill_dir.join("SKILL.toml"), + "[skill]\nname = \"from-toml\"\ndescription = \"TOML wins\"\n", + ) + .unwrap(); + fs::write(skill_dir.join("SKILL.md"), "# From MD\nMD description\n").unwrap(); + + let skills = load_skills(dir.path()); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "from-toml"); // TOML takes priority + } +} diff --git a/src/tools/file_read.rs b/src/tools/file_read.rs new file mode 100644 index 0000000..1798d2d --- /dev/null +++ b/src/tools/file_read.rs @@ -0,0 +1,203 @@ +use super::traits::{Tool, ToolResult}; +use crate::security::SecurityPolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Read file contents with path sandboxing +pub struct FileReadTool { + security: Arc, +} + +impl FileReadTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for FileReadTool { + fn name(&self) -> &str { + "file_read" + } + + fn description(&self) -> &str { + "Read the contents of a file in the workspace" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file within the workspace" + } + }, + "required": ["path"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + + // Security check: validate path is within workspace + if !self.security.is_path_allowed(path) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Path not allowed by security policy: {path}")), + }); + } + + let full_path = self.security.workspace_dir.join(path); + + match tokio::fs::read_to_string(&full_path).await { + Ok(contents) => Ok(ToolResult { + success: true, + output: contents, + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to read file: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn file_read_name() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "file_read"); + } + + #[test] + fn file_read_schema_has_path() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["path"].is_object()); + assert!(schema["required"] + .as_array() + .unwrap() + .contains(&json!("path"))); + } + + #[tokio::test] + async fn file_read_existing_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_read"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello world") + .await + .unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); + assert!(result.success); + assert_eq!(result.output, "hello world"); + assert!(result.error.is_none()); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_read_nonexistent_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_read_missing"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "nope.txt"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("Failed to read")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_read_blocks_path_traversal() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_read_traversal"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "../../../etc/passwd"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_read_blocks_absolute_path() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"path": "/etc/passwd"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + } + + #[tokio::test] + async fn file_read_missing_path_param() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn file_read_empty_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_read_empty"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("empty.txt"), "").await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap(); + assert!(result.success); + assert_eq!(result.output, ""); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_read_nested_path() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_read_nested"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("sub/dir")) + .await + .unwrap(); + tokio::fs::write(dir.join("sub/dir/deep.txt"), "deep content") + .await + .unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "sub/dir/deep.txt"})) + .await + .unwrap(); + assert!(result.success); + assert_eq!(result.output, "deep content"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs new file mode 100644 index 0000000..f31191d --- /dev/null +++ b/src/tools/file_write.rs @@ -0,0 +1,242 @@ +use super::traits::{Tool, ToolResult}; +use crate::security::SecurityPolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Write file contents with path sandboxing +pub struct FileWriteTool { + security: Arc, +} + +impl FileWriteTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for FileWriteTool { + fn name(&self) -> &str { + "file_write" + } + + fn description(&self) -> &str { + "Write contents to a file in the workspace" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file within the workspace" + }, + "content": { + "type": "string", + "description": "Content to write to the file" + } + }, + "required": ["path", "content"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; + + // Security check: validate path is within workspace + if !self.security.is_path_allowed(path) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Path not allowed by security policy: {path}")), + }); + } + + let full_path = self.security.workspace_dir.join(path); + + // Ensure parent directory exists + if let Some(parent) = full_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + match tokio::fs::write(&full_path, content).await { + Ok(()) => Ok(ToolResult { + success: true, + output: format!("Written {} bytes to {path}", content.len()), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to write file: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn file_write_name() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "file_write"); + } + + #[test] + fn file_write_schema_has_path_and_content() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["path"].is_object()); + assert!(schema["properties"]["content"].is_object()); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("path"))); + assert!(required.contains(&json!("content"))); + } + + #[tokio::test] + async fn file_write_creates_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_write"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "out.txt", "content": "written!"})) + .await + .unwrap(); + assert!(result.success); + assert!(result.output.contains("8 bytes")); + + let content = tokio::fs::read_to_string(dir.join("out.txt")) + .await + .unwrap(); + assert_eq!(content, "written!"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_write_creates_parent_dirs() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_write_nested"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"})) + .await + .unwrap(); + assert!(result.success); + + let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt")) + .await + .unwrap(); + assert_eq!(content, "deep"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_write_overwrites_existing() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("exist.txt"), "old") + .await + .unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "exist.txt", "content": "new"})) + .await + .unwrap(); + assert!(result.success); + + let content = tokio::fs::read_to_string(dir.join("exist.txt")) + .await + .unwrap(); + assert_eq!(content, "new"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_write_blocks_path_traversal() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_write_traversal"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "../../etc/evil", "content": "bad"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_write_blocks_absolute_path() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({"path": "/etc/evil", "content": "bad"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + } + + #[tokio::test] + async fn file_write_missing_path_param() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"content": "data"})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn file_write_missing_content_param() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"path": "file.txt"})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn file_write_empty_content() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_write_empty"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "empty.txt", "content": ""})) + .await + .unwrap(); + assert!(result.success); + assert!(result.output.contains("0 bytes")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/tools/memory_forget.rs b/src/tools/memory_forget.rs new file mode 100644 index 0000000..16b2b8a --- /dev/null +++ b/src/tools/memory_forget.rs @@ -0,0 +1,118 @@ +use super::traits::{Tool, ToolResult}; +use crate::memory::Memory; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Let the agent forget/delete a memory entry +pub struct MemoryForgetTool { + memory: Arc, +} + +impl MemoryForgetTool { + pub fn new(memory: Arc) -> Self { + Self { memory } + } +} + +#[async_trait] +impl Tool for MemoryForgetTool { + fn name(&self) -> &str { + "memory_forget" + } + + fn description(&self) -> &str { + "Remove a memory by key. Use to delete outdated facts or sensitive data. Returns whether the memory was found and removed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key of the memory to forget" + } + }, + "required": ["key"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let key = args + .get("key") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'key' parameter"))?; + + match self.memory.forget(key).await { + Ok(true) => Ok(ToolResult { + success: true, + output: format!("Forgot memory: {key}"), + error: None, + }), + Ok(false) => Ok(ToolResult { + success: true, + output: format!("No memory found with key: {key}"), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to forget memory: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::{MemoryCategory, SqliteMemory}; + use tempfile::TempDir; + + fn test_mem() -> (TempDir, Arc) { + let tmp = TempDir::new().unwrap(); + let mem = SqliteMemory::new(tmp.path()).unwrap(); + (tmp, Arc::new(mem)) + } + + #[test] + fn name_and_schema() { + let (_tmp, mem) = test_mem(); + let tool = MemoryForgetTool::new(mem); + assert_eq!(tool.name(), "memory_forget"); + assert!(tool.parameters_schema()["properties"]["key"].is_object()); + } + + #[tokio::test] + async fn forget_existing() { + let (_tmp, mem) = test_mem(); + mem.store("temp", "temporary", MemoryCategory::Conversation) + .await + .unwrap(); + + let tool = MemoryForgetTool::new(mem.clone()); + let result = tool.execute(json!({"key": "temp"})).await.unwrap(); + assert!(result.success); + assert!(result.output.contains("Forgot")); + + assert!(mem.get("temp").await.unwrap().is_none()); + } + + #[tokio::test] + async fn forget_nonexistent() { + let (_tmp, mem) = test_mem(); + let tool = MemoryForgetTool::new(mem); + let result = tool.execute(json!({"key": "nope"})).await.unwrap(); + assert!(result.success); + assert!(result.output.contains("No memory found")); + } + + #[tokio::test] + async fn forget_missing_key() { + let (_tmp, mem) = test_mem(); + let tool = MemoryForgetTool::new(mem); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); + } +} diff --git a/src/tools/memory_recall.rs b/src/tools/memory_recall.rs new file mode 100644 index 0000000..779c251 --- /dev/null +++ b/src/tools/memory_recall.rs @@ -0,0 +1,163 @@ +use super::traits::{Tool, ToolResult}; +use crate::memory::Memory; +use async_trait::async_trait; +use serde_json::json; +use std::fmt::Write; +use std::sync::Arc; + +/// Let the agent search its own memory +pub struct MemoryRecallTool { + memory: Arc, +} + +impl MemoryRecallTool { + pub fn new(memory: Arc) -> Self { + Self { memory } + } +} + +#[async_trait] +impl Tool for MemoryRecallTool { + fn name(&self) -> &str { + "memory_recall" + } + + fn description(&self) -> &str { + "Search long-term memory for relevant facts, preferences, or context. Returns scored results ranked by relevance." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Keywords or phrase to search for in memory" + }, + "limit": { + "type": "integer", + "description": "Max results to return (default: 5)" + } + }, + "required": ["query"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'query' parameter"))?; + + #[allow(clippy::cast_possible_truncation)] + let limit = args + .get("limit") + .and_then(serde_json::Value::as_u64) + .map_or(5, |v| v as usize); + + match self.memory.recall(query, limit).await { + Ok(entries) if entries.is_empty() => Ok(ToolResult { + success: true, + output: "No memories found matching that query.".into(), + error: None, + }), + Ok(entries) => { + let mut output = format!("Found {} memories:\n", entries.len()); + for entry in &entries { + let score = entry.score.map_or_else(String::new, |s| format!(" [{s:.0}%]")); + let _ = writeln!( + output, + "- [{}] {}: {}{score}", + entry.category, entry.key, entry.content + ); + } + Ok(ToolResult { + success: true, + output, + error: None, + }) + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Memory recall failed: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::{MemoryCategory, SqliteMemory}; + use tempfile::TempDir; + + fn seeded_mem() -> (TempDir, Arc) { + let tmp = TempDir::new().unwrap(); + let mem = SqliteMemory::new(tmp.path()).unwrap(); + (tmp, Arc::new(mem)) + } + + #[tokio::test] + async fn recall_empty() { + let (_tmp, mem) = seeded_mem(); + let tool = MemoryRecallTool::new(mem); + let result = tool + .execute(json!({"query": "anything"})) + .await + .unwrap(); + assert!(result.success); + assert!(result.output.contains("No memories found")); + } + + #[tokio::test] + async fn recall_finds_match() { + let (_tmp, mem) = seeded_mem(); + mem.store("lang", "User prefers Rust", MemoryCategory::Core) + .await + .unwrap(); + mem.store("tz", "Timezone is EST", MemoryCategory::Core) + .await + .unwrap(); + + let tool = MemoryRecallTool::new(mem); + let result = tool.execute(json!({"query": "Rust"})).await.unwrap(); + assert!(result.success); + assert!(result.output.contains("Rust")); + assert!(result.output.contains("Found 1")); + } + + #[tokio::test] + async fn recall_respects_limit() { + let (_tmp, mem) = seeded_mem(); + for i in 0..10 { + mem.store(&format!("k{i}"), &format!("Rust fact {i}"), MemoryCategory::Core) + .await + .unwrap(); + } + + let tool = MemoryRecallTool::new(mem); + let result = tool + .execute(json!({"query": "Rust", "limit": 3})) + .await + .unwrap(); + assert!(result.success); + assert!(result.output.contains("Found 3")); + } + + #[tokio::test] + async fn recall_missing_query() { + let (_tmp, mem) = seeded_mem(); + let tool = MemoryRecallTool::new(mem); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); + } + + #[test] + fn name_and_schema() { + let (_tmp, mem) = seeded_mem(); + let tool = MemoryRecallTool::new(mem); + assert_eq!(tool.name(), "memory_recall"); + assert!(tool.parameters_schema()["properties"]["query"].is_object()); + } +} diff --git a/src/tools/memory_store.rs b/src/tools/memory_store.rs new file mode 100644 index 0000000..b90222c --- /dev/null +++ b/src/tools/memory_store.rs @@ -0,0 +1,146 @@ +use super::traits::{Tool, ToolResult}; +use crate::memory::{Memory, MemoryCategory}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Let the agent store memories — its own brain writes +pub struct MemoryStoreTool { + memory: Arc, +} + +impl MemoryStoreTool { + pub fn new(memory: Arc) -> Self { + Self { memory } + } +} + +#[async_trait] +impl Tool for MemoryStoreTool { + fn name(&self) -> &str { + "memory_store" + } + + fn description(&self) -> &str { + "Store a fact, preference, or note in long-term memory. Use category 'core' for permanent facts, 'daily' for session notes, 'conversation' for chat context." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Unique key for this memory (e.g. 'user_lang', 'project_stack')" + }, + "content": { + "type": "string", + "description": "The information to remember" + }, + "category": { + "type": "string", + "enum": ["core", "daily", "conversation"], + "description": "Memory category: core (permanent), daily (session), conversation (chat)" + } + }, + "required": ["key", "content"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let key = args + .get("key") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'key' parameter"))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; + + let category = match args.get("category").and_then(|v| v.as_str()) { + Some("daily") => MemoryCategory::Daily, + Some("conversation") => MemoryCategory::Conversation, + _ => MemoryCategory::Core, + }; + + match self.memory.store(key, content, category).await { + Ok(()) => Ok(ToolResult { + success: true, + output: format!("Stored memory: {key}"), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to store memory: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::SqliteMemory; + use tempfile::TempDir; + + fn test_mem() -> (TempDir, Arc) { + let tmp = TempDir::new().unwrap(); + let mem = SqliteMemory::new(tmp.path()).unwrap(); + (tmp, Arc::new(mem)) + } + + #[test] + fn name_and_schema() { + let (_tmp, mem) = test_mem(); + let tool = MemoryStoreTool::new(mem); + assert_eq!(tool.name(), "memory_store"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["key"].is_object()); + assert!(schema["properties"]["content"].is_object()); + } + + #[tokio::test] + async fn store_core() { + let (_tmp, mem) = test_mem(); + let tool = MemoryStoreTool::new(mem.clone()); + let result = tool + .execute(json!({"key": "lang", "content": "Prefers Rust"})) + .await + .unwrap(); + assert!(result.success); + assert!(result.output.contains("lang")); + + let entry = mem.get("lang").await.unwrap(); + assert!(entry.is_some()); + assert_eq!(entry.unwrap().content, "Prefers Rust"); + } + + #[tokio::test] + async fn store_with_category() { + let (_tmp, mem) = test_mem(); + let tool = MemoryStoreTool::new(mem.clone()); + let result = tool + .execute(json!({"key": "note", "content": "Fixed bug", "category": "daily"})) + .await + .unwrap(); + assert!(result.success); + } + + #[tokio::test] + async fn store_missing_key() { + let (_tmp, mem) = test_mem(); + let tool = MemoryStoreTool::new(mem); + let result = tool.execute(json!({"content": "no key"})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn store_missing_content() { + let (_tmp, mem) = test_mem(); + let tool = MemoryStoreTool::new(mem); + let result = tool.execute(json!({"key": "no_content"})).await; + assert!(result.is_err()); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..ecd182e --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,189 @@ +pub mod file_read; +pub mod file_write; +pub mod memory_forget; +pub mod memory_recall; +pub mod memory_store; +pub mod shell; +pub mod traits; + +pub use file_read::FileReadTool; +pub use file_write::FileWriteTool; +pub use memory_forget::MemoryForgetTool; +pub use memory_recall::MemoryRecallTool; +pub use memory_store::MemoryStoreTool; +pub use shell::ShellTool; +pub use traits::Tool; +#[allow(unused_imports)] +pub use traits::{ToolResult, ToolSpec}; + +use crate::config::Config; +use crate::memory::Memory; +use crate::security::SecurityPolicy; +use anyhow::Result; +use std::sync::Arc; + +/// Create the default tool registry +pub fn default_tools(security: Arc) -> Vec> { + vec![ + Box::new(ShellTool::new(security.clone())), + Box::new(FileReadTool::new(security.clone())), + Box::new(FileWriteTool::new(security)), + ] +} + +/// Create full tool registry including memory tools +pub fn all_tools( + security: Arc, + memory: Arc, +) -> Vec> { + vec![ + Box::new(ShellTool::new(security.clone())), + Box::new(FileReadTool::new(security.clone())), + Box::new(FileWriteTool::new(security)), + Box::new(MemoryStoreTool::new(memory.clone())), + Box::new(MemoryRecallTool::new(memory.clone())), + Box::new(MemoryForgetTool::new(memory)), + ] +} + +pub async fn handle_command(command: super::ToolCommands, config: Config) -> Result<()> { + let security = Arc::new(SecurityPolicy { + workspace_dir: config.workspace_dir.clone(), + ..SecurityPolicy::default() + }); + let mem: Arc = + Arc::from(crate::memory::create_memory(&config.memory, &config.workspace_dir)?); + let tools_list = all_tools(security, mem); + + match command { + super::ToolCommands::List => { + println!("Available tools ({}):", tools_list.len()); + for tool in &tools_list { + println!(" - {}: {}", tool.name(), tool.description()); + } + Ok(()) + } + super::ToolCommands::Test { tool, args } => { + let matched = tools_list.iter().find(|t| t.name() == tool); + match matched { + Some(t) => { + let parsed: serde_json::Value = serde_json::from_str(&args)?; + let result = t.execute(parsed).await?; + println!("Success: {}", result.success); + println!("Output: {}", result.output); + if let Some(err) = result.error { + println!("Error: {err}"); + } + Ok(()) + } + None => anyhow::bail!("Unknown tool: {tool}"), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_tools_has_three() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + assert_eq!(tools.len(), 3); + } + + #[test] + fn default_tools_names() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"shell")); + assert!(names.contains(&"file_read")); + assert!(names.contains(&"file_write")); + } + + #[test] + fn default_tools_all_have_descriptions() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + for tool in &tools { + assert!( + !tool.description().is_empty(), + "Tool {} has empty description", + tool.name() + ); + } + } + + #[test] + fn default_tools_all_have_schemas() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + for tool in &tools { + let schema = tool.parameters_schema(); + assert!( + schema.is_object(), + "Tool {} schema is not an object", + tool.name() + ); + assert!( + schema["properties"].is_object(), + "Tool {} schema has no properties", + tool.name() + ); + } + } + + #[test] + fn tool_spec_generation() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + for tool in &tools { + let spec = tool.spec(); + assert_eq!(spec.name, tool.name()); + assert_eq!(spec.description, tool.description()); + assert!(spec.parameters.is_object()); + } + } + + #[test] + fn tool_result_serde() { + let result = ToolResult { + success: true, + output: "hello".into(), + error: None, + }; + let json = serde_json::to_string(&result).unwrap(); + let parsed: ToolResult = serde_json::from_str(&json).unwrap(); + assert!(parsed.success); + assert_eq!(parsed.output, "hello"); + assert!(parsed.error.is_none()); + } + + #[test] + fn tool_result_with_error_serde() { + let result = ToolResult { + success: false, + output: String::new(), + error: Some("boom".into()), + }; + let json = serde_json::to_string(&result).unwrap(); + let parsed: ToolResult = serde_json::from_str(&json).unwrap(); + assert!(!parsed.success); + assert_eq!(parsed.error.as_deref(), Some("boom")); + } + + #[test] + fn tool_spec_serde() { + let spec = ToolSpec { + name: "test".into(), + description: "A test tool".into(), + parameters: serde_json::json!({"type": "object"}), + }; + let json = serde_json::to_string(&spec).unwrap(); + let parsed: ToolSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "test"); + assert_eq!(parsed.description, "A test tool"); + } +} diff --git a/src/tools/shell.rs b/src/tools/shell.rs new file mode 100644 index 0000000..8d29e5d --- /dev/null +++ b/src/tools/shell.rs @@ -0,0 +1,166 @@ +use super::traits::{Tool, ToolResult}; +use crate::security::SecurityPolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Shell command execution tool with sandboxing +pub struct ShellTool { + security: Arc, +} + +impl ShellTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for ShellTool { + fn name(&self) -> &str { + "shell" + } + + fn description(&self) -> &str { + "Execute a shell command in the workspace directory" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute" + } + }, + "required": ["command"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let command = args + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?; + + // Security check: validate command against allowlist + if !self.security.is_command_allowed(command) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Command not allowed by security policy: {command}")), + }); + } + + let output = tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(&self.security.workspace_dir) + .output() + .await?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + Ok(ToolResult { + success: output.status.success(), + output: stdout, + error: if stderr.is_empty() { + None + } else { + Some(stderr) + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(autonomy: AutonomyLevel) -> Arc { + Arc::new(SecurityPolicy { + autonomy, + workspace_dir: std::env::temp_dir(), + ..SecurityPolicy::default() + }) + } + + #[test] + fn shell_tool_name() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + assert_eq!(tool.name(), "shell"); + } + + #[test] + fn shell_tool_description() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + assert!(!tool.description().is_empty()); + } + + #[test] + fn shell_tool_schema_has_command() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["command"].is_object()); + assert!(schema["required"] + .as_array() + .unwrap() + .contains(&json!("command"))); + } + + #[tokio::test] + async fn shell_executes_allowed_command() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + let result = tool + .execute(json!({"command": "echo hello"})) + .await + .unwrap(); + assert!(result.success); + assert!(result.output.trim().contains("hello")); + assert!(result.error.is_none()); + } + + #[tokio::test] + async fn shell_blocks_disallowed_command() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + let result = tool.execute(json!({"command": "rm -rf /"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + } + + #[tokio::test] + async fn shell_blocks_readonly() { + let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly)); + let result = tool.execute(json!({"command": "ls"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + } + + #[tokio::test] + async fn shell_missing_command_param() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("command")); + } + + #[tokio::test] + async fn shell_wrong_type_param() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + let result = tool.execute(json!({"command": 123})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn shell_captures_exit_code() { + let tool = ShellTool::new(test_security(AutonomyLevel::Supervised)); + let result = tool + .execute(json!({"command": "ls /nonexistent_dir_xyz"})) + .await + .unwrap(); + assert!(!result.success); + } +} diff --git a/src/tools/traits.rs b/src/tools/traits.rs new file mode 100644 index 0000000..714e83b --- /dev/null +++ b/src/tools/traits.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +/// Result of a tool execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + pub success: bool, + pub output: String, + pub error: Option, +} + +/// Description of a tool for the LLM +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSpec { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +/// Core tool trait — implement for any capability +#[async_trait] +pub trait Tool: Send + Sync { + /// Tool name (used in LLM function calling) + fn name(&self) -> &str; + + /// Human-readable description + fn description(&self) -> &str; + + /// JSON schema for parameters + fn parameters_schema(&self) -> serde_json::Value; + + /// Execute the tool with given arguments + async fn execute(&self, args: serde_json::Value) -> anyhow::Result; + + /// Get the full spec for LLM registration + fn spec(&self) -> ToolSpec { + ToolSpec { + name: self.name().to_string(), + description: self.description().to_string(), + parameters: self.parameters_schema(), + } + } +} diff --git a/tests/memory_comparison.rs b/tests/memory_comparison.rs new file mode 100644 index 0000000..f9ef8a8 --- /dev/null +++ b/tests/memory_comparison.rs @@ -0,0 +1,369 @@ +//! Head-to-head comparison: SQLite vs Markdown memory backends +//! +//! Run with: cargo test --test memory_comparison -- --nocapture + +use std::time::Instant; +use tempfile::TempDir; + +// We test both backends through the public memory module +use zeroclaw::memory::{ + markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory, +}; + +// ── Helpers ──────────────────────────────────────────────────── + +fn sqlite_backend(dir: &std::path::Path) -> SqliteMemory { + SqliteMemory::new(dir).expect("SQLite init failed") +} + +fn markdown_backend(dir: &std::path::Path) -> MarkdownMemory { + MarkdownMemory::new(dir) +} + +// ── Test 1: Store performance ────────────────────────────────── + +#[tokio::test] +async fn compare_store_speed() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + let sq = sqlite_backend(tmp_sq.path()); + let md = markdown_backend(tmp_md.path()); + + let n = 100; + + // SQLite: 100 stores + let start = Instant::now(); + for i in 0..n { + sq.store( + &format!("key_{i}"), + &format!("Memory entry number {i} about Rust programming"), + MemoryCategory::Core, + ) + .await + .unwrap(); + } + let sq_dur = start.elapsed(); + + // Markdown: 100 stores + let start = Instant::now(); + for i in 0..n { + md.store( + &format!("key_{i}"), + &format!("Memory entry number {i} about Rust programming"), + MemoryCategory::Core, + ) + .await + .unwrap(); + } + let md_dur = start.elapsed(); + + println!("\n============================================================"); + println!("STORE {n} entries:"); + println!(" SQLite: {:?}", sq_dur); + println!(" Markdown: {:?}", md_dur); + + // Both should succeed + assert_eq!(sq.count().await.unwrap(), n); + // Markdown count parses lines, may differ slightly from n + let md_count = md.count().await.unwrap(); + assert!(md_count >= n, "Markdown stored {md_count}, expected >= {n}"); +} + +// ── Test 2: Recall / search quality ──────────────────────────── + +#[tokio::test] +async fn compare_recall_quality() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + let sq = sqlite_backend(tmp_sq.path()); + let md = markdown_backend(tmp_md.path()); + + // Seed both with identical data + let entries = vec![ + ("lang_pref", "User prefers Rust over Python", MemoryCategory::Core), + ("editor", "Uses VS Code with rust-analyzer", MemoryCategory::Core), + ("tz", "Timezone is EST, works 9-5", MemoryCategory::Core), + ("proj1", "Working on ZeroClaw AI assistant", MemoryCategory::Daily), + ("proj2", "Previous project was a web scraper in Python", MemoryCategory::Daily), + ("deploy", "Deploys to Hetzner VPS via Docker", MemoryCategory::Core), + ("model", "Prefers Claude Sonnet for coding tasks", MemoryCategory::Core), + ("style", "Likes concise responses, no fluff", MemoryCategory::Core), + ("rust_note", "Rust's ownership model prevents memory bugs", MemoryCategory::Daily), + ("perf", "Cares about binary size and startup time", MemoryCategory::Core), + ]; + + for (key, content, cat) in &entries { + sq.store(key, content, cat.clone()).await.unwrap(); + md.store(key, content, cat.clone()).await.unwrap(); + } + + // Test queries and compare results + let queries = vec![ + ("Rust", "Should find Rust-related entries"), + ("Python", "Should find Python references"), + ("deploy Docker", "Multi-keyword search"), + ("Claude", "Specific tool reference"), + ("javascript", "No matches expected"), + ("binary size startup", "Multi-keyword partial match"), + ]; + + println!("\n============================================================"); + println!("RECALL QUALITY (10 entries seeded):\n"); + + for (query, desc) in &queries { + let sq_results = sq.recall(query, 10).await.unwrap(); + let md_results = md.recall(query, 10).await.unwrap(); + + println!(" Query: \"{query}\" — {desc}"); + println!(" SQLite: {} results", sq_results.len()); + for r in &sq_results { + println!( + " [{:.2}] {}: {}", + r.score.unwrap_or(0.0), + r.key, + &r.content[..r.content.len().min(50)] + ); + } + println!(" Markdown: {} results", md_results.len()); + for r in &md_results { + println!( + " [{:.2}] {}: {}", + r.score.unwrap_or(0.0), + r.key, + &r.content[..r.content.len().min(50)] + ); + } + println!(); + } +} + +// ── Test 3: Recall speed at scale ────────────────────────────── + +#[tokio::test] +async fn compare_recall_speed() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + let sq = sqlite_backend(tmp_sq.path()); + let md = markdown_backend(tmp_md.path()); + + // Seed 200 entries + let n = 200; + for i in 0..n { + let content = if i % 3 == 0 { + format!("Rust is great for systems programming, entry {i}") + } else if i % 3 == 1 { + format!("Python is popular for data science, entry {i}") + } else { + format!("TypeScript powers modern web apps, entry {i}") + }; + sq.store(&format!("e{i}"), &content, MemoryCategory::Core) + .await + .unwrap(); + md.store(&format!("e{i}"), &content, MemoryCategory::Daily) + .await + .unwrap(); + } + + // Benchmark recall + let start = Instant::now(); + let sq_results = sq.recall("Rust systems", 10).await.unwrap(); + let sq_dur = start.elapsed(); + + let start = Instant::now(); + let md_results = md.recall("Rust systems", 10).await.unwrap(); + let md_dur = start.elapsed(); + + println!("\n============================================================"); + println!("RECALL from {n} entries (query: \"Rust systems\", limit 10):"); + println!(" SQLite: {:?} → {} results", sq_dur, sq_results.len()); + println!(" Markdown: {:?} → {} results", md_dur, md_results.len()); + + // Both should find results + assert!(!sq_results.is_empty()); + assert!(!md_results.is_empty()); +} + +// ── Test 4: Persistence (SQLite wins by design) ──────────────── + +#[tokio::test] +async fn compare_persistence() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + + // Store in both, then drop and re-open + { + let sq = sqlite_backend(tmp_sq.path()); + sq.store("persist_test", "I should survive", MemoryCategory::Core) + .await + .unwrap(); + } + { + let md = markdown_backend(tmp_md.path()); + md.store("persist_test", "I should survive", MemoryCategory::Core) + .await + .unwrap(); + } + + // Re-open + let sq2 = sqlite_backend(tmp_sq.path()); + let md2 = markdown_backend(tmp_md.path()); + + let sq_entry = sq2.get("persist_test").await.unwrap(); + let md_entry = md2.get("persist_test").await.unwrap(); + + println!("\n============================================================"); + println!("PERSISTENCE (store → drop → re-open → get):"); + println!( + " SQLite: {}", + if sq_entry.is_some() { + "✅ Survived" + } else { + "❌ Lost" + } + ); + println!( + " Markdown: {}", + if md_entry.is_some() { + "✅ Survived" + } else { + "❌ Lost" + } + ); + + // SQLite should always persist by key + assert!(sq_entry.is_some()); + assert_eq!(sq_entry.unwrap().content, "I should survive"); + + // Markdown persists content to files (get uses content search) + assert!(md_entry.is_some()); +} + +// ── Test 5: Upsert / update behavior ────────────────────────── + +#[tokio::test] +async fn compare_upsert() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + let sq = sqlite_backend(tmp_sq.path()); + let md = markdown_backend(tmp_md.path()); + + // Store twice with same key, different content + sq.store("pref", "likes Rust", MemoryCategory::Core) + .await + .unwrap(); + sq.store("pref", "loves Rust", MemoryCategory::Core) + .await + .unwrap(); + + md.store("pref", "likes Rust", MemoryCategory::Core) + .await + .unwrap(); + md.store("pref", "loves Rust", MemoryCategory::Core) + .await + .unwrap(); + + let sq_count = sq.count().await.unwrap(); + let md_count = md.count().await.unwrap(); + + let sq_entry = sq.get("pref").await.unwrap(); + let md_results = md.recall("loves Rust", 5).await.unwrap(); + + println!("\n============================================================"); + println!("UPSERT (store same key twice):"); + println!(" SQLite: count={sq_count}, latest=\"{}\"", + sq_entry.as_ref().map_or("none", |e| &e.content)); + println!(" Markdown: count={md_count} (append-only, both entries kept)"); + println!(" Can still find latest: {}", !md_results.is_empty()); + + // SQLite: upsert replaces, count stays at 1 + assert_eq!(sq_count, 1); + assert_eq!(sq_entry.unwrap().content, "loves Rust"); + + // Markdown: append-only, count increases + assert!(md_count >= 2, "Markdown should keep both entries"); +} + +// ── Test 6: Forget / delete capability ───────────────────────── + +#[tokio::test] +async fn compare_forget() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + let sq = sqlite_backend(tmp_sq.path()); + let md = markdown_backend(tmp_md.path()); + + sq.store("secret", "API key: sk-1234", MemoryCategory::Core) + .await + .unwrap(); + md.store("secret", "API key: sk-1234", MemoryCategory::Core) + .await + .unwrap(); + + let sq_forgot = sq.forget("secret").await.unwrap(); + let md_forgot = md.forget("secret").await.unwrap(); + + println!("\n============================================================"); + println!("FORGET (delete sensitive data):"); + println!( + " SQLite: {} (count={})", + if sq_forgot { "✅ Deleted" } else { "❌ Kept" }, + sq.count().await.unwrap() + ); + println!( + " Markdown: {} (append-only by design)", + if md_forgot { "✅ Deleted" } else { "⚠️ Cannot delete (audit trail)" }, + ); + + // SQLite can delete + assert!(sq_forgot); + assert_eq!(sq.count().await.unwrap(), 0); + + // Markdown cannot delete (by design) + assert!(!md_forgot); +} + +// ── Test 7: Category filtering ───────────────────────────────── + +#[tokio::test] +async fn compare_category_filter() { + let tmp_sq = TempDir::new().unwrap(); + let tmp_md = TempDir::new().unwrap(); + let sq = sqlite_backend(tmp_sq.path()); + let md = markdown_backend(tmp_md.path()); + + // Mix of categories + sq.store("a", "core fact 1", MemoryCategory::Core).await.unwrap(); + sq.store("b", "core fact 2", MemoryCategory::Core).await.unwrap(); + sq.store("c", "daily note", MemoryCategory::Daily).await.unwrap(); + sq.store("d", "convo msg", MemoryCategory::Conversation).await.unwrap(); + + md.store("a", "core fact 1", MemoryCategory::Core).await.unwrap(); + md.store("b", "core fact 2", MemoryCategory::Core).await.unwrap(); + md.store("c", "daily note", MemoryCategory::Daily).await.unwrap(); + + let sq_core = sq.list(Some(&MemoryCategory::Core)).await.unwrap(); + let sq_daily = sq.list(Some(&MemoryCategory::Daily)).await.unwrap(); + let sq_conv = sq.list(Some(&MemoryCategory::Conversation)).await.unwrap(); + let sq_all = sq.list(None).await.unwrap(); + + let md_core = md.list(Some(&MemoryCategory::Core)).await.unwrap(); + let md_daily = md.list(Some(&MemoryCategory::Daily)).await.unwrap(); + let md_all = md.list(None).await.unwrap(); + + println!("\n============================================================"); + println!("CATEGORY FILTERING:"); + println!(" SQLite: core={}, daily={}, conv={}, all={}", + sq_core.len(), sq_daily.len(), sq_conv.len(), sq_all.len()); + println!(" Markdown: core={}, daily={}, all={}", + md_core.len(), md_daily.len(), md_all.len()); + + // SQLite: precise category filtering via SQL WHERE + assert_eq!(sq_core.len(), 2); + assert_eq!(sq_daily.len(), 1); + assert_eq!(sq_conv.len(), 1); + assert_eq!(sq_all.len(), 4); + + // Markdown: categories determined by file location + assert!(!md_core.is_empty()); + assert!(!md_all.is_empty()); +} diff --git a/zeroclaw.png b/zeroclaw.png new file mode 100644 index 0000000..2e12fcd Binary files /dev/null and b/zeroclaw.png differ