Compare commits

..

2 Commits

Author SHA1 Message Date
argenis de la rosa
303c182a4f fix(channel): add missing proxy_url fields in test initializers 2026-03-21 07:31:28 -04:00
simianastronaut
6cdea12508 feat(channel): add per-channel proxy_url support for HTTP/SOCKS5 proxies
Allow each channel to optionally specify a `proxy_url` in its config,
enabling users behind restrictive networks to route channel traffic
through HTTP or SOCKS5 proxies. When set, the per-channel proxy takes
precedence over the global `[proxy]` config; when absent, the channel
falls back to the existing runtime proxy behavior.

Adds `proxy_url: Option<String>` to all 12 channel config structs
(Telegram, Discord, Slack, Mattermost, Signal, WhatsApp, Wati,
NextcloudTalk, DingTalk, QQ, Lark, Feishu) and introduces
`build_channel_proxy_client`, `build_channel_proxy_client_with_timeouts`,
and `apply_channel_proxy_to_builder` helpers that normalize proxy URLs
and integrate with the existing client cache.

Closes #3262

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:13:20 -04:00
244 changed files with 3743 additions and 37714 deletions

View File

@ -0,0 +1,97 @@
# Mem0 Integration: Dual-Scope Recall + Per-Turn Memory
## Context
Mem0 auto-save works but the integration is missing key features from mem0 best practices: per-turn recall, multi-level scoping, and proper context injection. This causes the bot to "forget" on follow-up turns and not differentiate users.
## What's Missing (vs mem0 docs)
1. **Per-turn recall** — only first turn gets memory context, follow-ups get nothing
2. **Dual-scope** — no sender vs group distinction. All memories use single hardcoded `user_id`
3. **System prompt injection** — memory prepended to user message (pollutes session history)
4. **`agent_id` scoping** — mem0 supports agent-level patterns, not used
## Changes
### 1. `src/memory/mem0.rs` — Use session_id for multi-level scoping
Map zeroclaw's `session_id` param to mem0's `user_id`. This enables per-user and per-group memory namespaces without changing the `Memory` trait.
```rust
// Add helper:
fn effective_user_id(&self, session_id: Option<&str>) -> &str {
session_id.filter(|s| !s.is_empty()).unwrap_or(&self.user_id)
}
// In store(): use effective_user_id(session_id) as mem0 user_id
// In recall(): use effective_user_id(session_id) as mem0 user_id
// In list(): use effective_user_id(session_id) as mem0 user_id
```
### 2. `src/channels/mod.rs` ~line 2229 — Per-turn dual-scope recall
Remove `if !had_prior_history` gate. Always recall from both sender scope and group scope (for group chats).
```rust
// Detect group chat
let is_group = msg.reply_target.contains("@g.us")
|| msg.reply_target.starts_with("group:");
// Sender-scope recall (always)
let sender_context = build_memory_context(
ctx.memory.as_ref(), &msg.content, ctx.min_relevance_score,
Some(&msg.sender),
).await;
// Group-scope recall (groups only)
let group_context = if is_group {
build_memory_context(
ctx.memory.as_ref(), &msg.content, ctx.min_relevance_score,
Some(&history_key),
).await
} else {
String::new()
};
// Merge (deduplicate by checking substring overlap)
let memory_context = merge_memory_contexts(&sender_context, &group_context);
```
### 3. `src/channels/mod.rs` ~line 2244 — Inject into system prompt
Move memory context from user message to system prompt. Re-fetched each turn, doesn't pollute session.
```rust
let mut system_prompt = build_channel_system_prompt(...);
if !memory_context.is_empty() {
system_prompt.push_str(&format!("\n\n{memory_context}"));
}
let mut history = vec![ChatMessage::system(system_prompt)];
```
### 4. `src/channels/mod.rs` — Dual-scope auto-save
Find existing auto-save call. For group messages, store twice:
- `store(key, content, category, Some(&msg.sender))` — personal facts
- `store(key, content, category, Some(&history_key))` — group context
Both async, non-blocking. DMs only store to sender scope.
### 5. `src/memory/mem0.rs` — Add `agent_id` support (optional)
Pass `self.app_name` as `agent_id` param to mem0 API for agent behavior tracking.
## Files to Modify
1. `src/memory/mem0.rs` — session_id → user_id mapping
2. `src/channels/mod.rs` — per-turn recall, dual-scope, system prompt injection, dual-scope save
## Verification
1. `cargo check --features whatsapp-web,memory-mem0`
2. `cargo test --features whatsapp-web,memory-mem0`
3. Deploy to Synology
4. Test DM: "我鍾意食壽司" → next turn "我鍾意食咩" → should recall
5. Test group: Joe says "我鍾意食壽司" → someone else asks "Joe 鍾意食咩" → should recall from group scope
6. Check mem0 server logs: GET with `user_id=sender` AND `user_id=group_key`
7. Check mem0 server logs: POST with both user_ids for group messages

View File

@ -118,7 +118,3 @@ PROVIDER=openrouter
# Optional: Brave Search (requires API key from https://brave.com/search/api)
# WEB_SEARCH_PROVIDER=brave
# BRAVE_API_KEY=your-brave-search-api-key
#
# Optional: SearXNG (self-hosted, requires instance URL)
# WEB_SEARCH_PROVIDER=searxng
# SEARXNG_INSTANCE_URL=https://searx.example.com

301
.github/labeler.yml vendored
View File

@ -36,145 +36,6 @@
- any-glob-to-any-file:
- "src/channels/**"
"channel:bluesky":
- changed-files:
- any-glob-to-any-file:
- "src/channels/bluesky.rs"
"channel:clawdtalk":
- changed-files:
- any-glob-to-any-file:
- "src/channels/clawdtalk.rs"
"channel:cli":
- changed-files:
- any-glob-to-any-file:
- "src/channels/cli.rs"
"channel:dingtalk":
- changed-files:
- any-glob-to-any-file:
- "src/channels/dingtalk.rs"
"channel:discord":
- changed-files:
- any-glob-to-any-file:
- "src/channels/discord.rs"
- "src/channels/discord_history.rs"
"channel:email":
- changed-files:
- any-glob-to-any-file:
- "src/channels/email_channel.rs"
- "src/channels/gmail_push.rs"
"channel:imessage":
- changed-files:
- any-glob-to-any-file:
- "src/channels/imessage.rs"
"channel:irc":
- changed-files:
- any-glob-to-any-file:
- "src/channels/irc.rs"
"channel:lark":
- changed-files:
- any-glob-to-any-file:
- "src/channels/lark.rs"
"channel:linq":
- changed-files:
- any-glob-to-any-file:
- "src/channels/linq.rs"
"channel:matrix":
- changed-files:
- any-glob-to-any-file:
- "src/channels/matrix.rs"
"channel:mattermost":
- changed-files:
- any-glob-to-any-file:
- "src/channels/mattermost.rs"
"channel:mochat":
- changed-files:
- any-glob-to-any-file:
- "src/channels/mochat.rs"
"channel:mqtt":
- changed-files:
- any-glob-to-any-file:
- "src/channels/mqtt.rs"
"channel:nextcloud-talk":
- changed-files:
- any-glob-to-any-file:
- "src/channels/nextcloud_talk.rs"
"channel:nostr":
- changed-files:
- any-glob-to-any-file:
- "src/channels/nostr.rs"
"channel:notion":
- changed-files:
- any-glob-to-any-file:
- "src/channels/notion.rs"
"channel:qq":
- changed-files:
- any-glob-to-any-file:
- "src/channels/qq.rs"
"channel:reddit":
- changed-files:
- any-glob-to-any-file:
- "src/channels/reddit.rs"
"channel:signal":
- changed-files:
- any-glob-to-any-file:
- "src/channels/signal.rs"
"channel:slack":
- changed-files:
- any-glob-to-any-file:
- "src/channels/slack.rs"
"channel:telegram":
- changed-files:
- any-glob-to-any-file:
- "src/channels/telegram.rs"
"channel:twitter":
- changed-files:
- any-glob-to-any-file:
- "src/channels/twitter.rs"
"channel:wati":
- changed-files:
- any-glob-to-any-file:
- "src/channels/wati.rs"
"channel:webhook":
- changed-files:
- any-glob-to-any-file:
- "src/channels/webhook.rs"
"channel:wecom":
- changed-files:
- any-glob-to-any-file:
- "src/channels/wecom.rs"
"channel:whatsapp":
- changed-files:
- any-glob-to-any-file:
- "src/channels/whatsapp.rs"
- "src/channels/whatsapp_storage.rs"
- "src/channels/whatsapp_web.rs"
"gateway":
- changed-files:
- any-glob-to-any-file:
@ -240,73 +101,6 @@
- any-glob-to-any-file:
- "src/providers/**"
"provider:anthropic":
- changed-files:
- any-glob-to-any-file:
- "src/providers/anthropic.rs"
"provider:azure-openai":
- changed-files:
- any-glob-to-any-file:
- "src/providers/azure_openai.rs"
"provider:bedrock":
- changed-files:
- any-glob-to-any-file:
- "src/providers/bedrock.rs"
"provider:claude-code":
- changed-files:
- any-glob-to-any-file:
- "src/providers/claude_code.rs"
"provider:compatible":
- changed-files:
- any-glob-to-any-file:
- "src/providers/compatible.rs"
"provider:copilot":
- changed-files:
- any-glob-to-any-file:
- "src/providers/copilot.rs"
"provider:gemini":
- changed-files:
- any-glob-to-any-file:
- "src/providers/gemini.rs"
- "src/providers/gemini_cli.rs"
"provider:glm":
- changed-files:
- any-glob-to-any-file:
- "src/providers/glm.rs"
"provider:kilocli":
- changed-files:
- any-glob-to-any-file:
- "src/providers/kilocli.rs"
"provider:ollama":
- changed-files:
- any-glob-to-any-file:
- "src/providers/ollama.rs"
"provider:openai":
- changed-files:
- any-glob-to-any-file:
- "src/providers/openai.rs"
- "src/providers/openai_codex.rs"
"provider:openrouter":
- changed-files:
- any-glob-to-any-file:
- "src/providers/openrouter.rs"
"provider:telnyx":
- changed-files:
- any-glob-to-any-file:
- "src/providers/telnyx.rs"
"service":
- changed-files:
- any-glob-to-any-file:
@ -327,101 +121,6 @@
- any-glob-to-any-file:
- "src/tools/**"
"tool:browser":
- changed-files:
- any-glob-to-any-file:
- "src/tools/browser.rs"
- "src/tools/browser_delegate.rs"
- "src/tools/browser_open.rs"
- "src/tools/text_browser.rs"
- "src/tools/screenshot.rs"
"tool:composio":
- changed-files:
- any-glob-to-any-file:
- "src/tools/composio.rs"
"tool:cron":
- changed-files:
- any-glob-to-any-file:
- "src/tools/cron_add.rs"
- "src/tools/cron_list.rs"
- "src/tools/cron_remove.rs"
- "src/tools/cron_run.rs"
- "src/tools/cron_runs.rs"
- "src/tools/cron_update.rs"
"tool:file":
- changed-files:
- any-glob-to-any-file:
- "src/tools/file_edit.rs"
- "src/tools/file_read.rs"
- "src/tools/file_write.rs"
- "src/tools/glob_search.rs"
- "src/tools/content_search.rs"
"tool:google-workspace":
- changed-files:
- any-glob-to-any-file:
- "src/tools/google_workspace.rs"
"tool:mcp":
- changed-files:
- any-glob-to-any-file:
- "src/tools/mcp_client.rs"
- "src/tools/mcp_deferred.rs"
- "src/tools/mcp_protocol.rs"
- "src/tools/mcp_tool.rs"
- "src/tools/mcp_transport.rs"
"tool:memory":
- changed-files:
- any-glob-to-any-file:
- "src/tools/memory_forget.rs"
- "src/tools/memory_recall.rs"
- "src/tools/memory_store.rs"
"tool:microsoft365":
- changed-files:
- any-glob-to-any-file:
- "src/tools/microsoft365/**"
"tool:shell":
- changed-files:
- any-glob-to-any-file:
- "src/tools/shell.rs"
- "src/tools/node_tool.rs"
- "src/tools/cli_discovery.rs"
"tool:sop":
- changed-files:
- any-glob-to-any-file:
- "src/tools/sop_advance.rs"
- "src/tools/sop_approve.rs"
- "src/tools/sop_execute.rs"
- "src/tools/sop_list.rs"
- "src/tools/sop_status.rs"
"tool:web":
- changed-files:
- any-glob-to-any-file:
- "src/tools/web_fetch.rs"
- "src/tools/web_search_tool.rs"
- "src/tools/web_search_provider_routing.rs"
- "src/tools/http_request.rs"
"tool:security":
- changed-files:
- any-glob-to-any-file:
- "src/tools/security_ops.rs"
- "src/tools/verifiable_intent.rs"
"tool:cloud":
- changed-files:
- any-glob-to-any-file:
- "src/tools/cloud_ops.rs"
- "src/tools/cloud_patterns.rs"
"tunnel":
- changed-files:
- any-glob-to-any-file:

View File

@ -7,7 +7,7 @@ on:
branches: [master]
concurrency:
group: ci-${{ github.event.pull_request.number || 'push-master' }}
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
@ -154,7 +154,7 @@ jobs:
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Check all features
run: cargo check --features ci-all --locked
run: cargo check --all-features --locked
docs-quality:
name: Docs Quality

View File

@ -1,19 +0,0 @@
name: PR Path Labeler
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Apply path labels
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
sync-labels: true

View File

@ -1,22 +1,6 @@
name: Pub Homebrew Core
on:
workflow_call:
inputs:
release_tag:
description: "Existing release tag to publish (vX.Y.Z)"
required: true
type: string
dry_run:
description: "Patch formula only (no push/PR)"
required: false
default: false
type: boolean
secrets:
HOMEBREW_UPSTREAM_PR_TOKEN:
required: false
HOMEBREW_CORE_BOT_TOKEN:
required: false
workflow_dispatch:
inputs:
release_tag:

View File

@ -19,7 +19,6 @@ env:
jobs:
detect-version-change:
name: Detect Version Bump
if: github.repository == 'zeroclaw-labs/zeroclaw'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
@ -41,14 +40,6 @@ jobs:
echo "Current version: ${current}"
echo "Previous version: ${previous}"
# Skip if stable release workflow will handle this version
# (indicated by an existing or imminent stable tag)
if git ls-remote --exit-code --tags origin "refs/tags/v${current}" >/dev/null 2>&1; then
echo "Stable tag v${current} exists — stable release workflow handles crates.io"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$current" != "$previous" && -n "$current" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "version=${current}" >> "$GITHUB_OUTPUT"
@ -111,22 +102,6 @@ jobs:
- name: Clean web build artifacts
run: rm -rf web/node_modules web/src web/package.json web/package-lock.json web/tsconfig*.json web/vite.config.ts web/index.html
- name: Publish aardvark-sys to crates.io
shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify -p aardvark-sys 2>&1) && exit 0
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q 'already exists'; then
echo "::notice::aardvark-sys already on crates.io — skipping"
exit 0
fi
exit 1
- name: Wait for aardvark-sys to index
run: sleep 15
- name: Publish to crates.io
shell: bash
env:

View File

@ -67,24 +67,6 @@ jobs:
- name: Clean web build artifacts
run: rm -rf web/node_modules web/src web/package.json web/package-lock.json web/tsconfig*.json web/vite.config.ts web/index.html
- name: Publish aardvark-sys to crates.io
if: "!inputs.dry_run"
shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify -p aardvark-sys 2>&1) && exit 0
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q 'already exists'; then
echo "::notice::aardvark-sys already on crates.io — skipping"
exit 0
fi
exit 1
- name: Wait for aardvark-sys to index
if: "!inputs.dry_run"
run: sleep 15
- name: Publish (dry run)
if: inputs.dry_run
run: cargo publish --dry-run --locked --allow-dirty --no-verify

View File

@ -21,48 +21,25 @@ env:
jobs:
version:
name: Resolve Version
if: github.repository == 'zeroclaw-labs/zeroclaw'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
skip: ${{ steps.ver.outputs.skip }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 2
- name: Compute beta version
id: ver
shell: bash
run: |
set -euo pipefail
base_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
# Skip beta if this is a version bump commit (stable release handles it)
commit_msg=$(git log -1 --pretty=format:"%s")
if [[ "$commit_msg" =~ ^chore:\ bump\ version ]]; then
echo "Version bump commit detected — skipping beta release"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Skip beta if a stable tag already exists for this version
if git ls-remote --exit-code --tags origin "refs/tags/v${base_version}" >/dev/null 2>&1; then
echo "Stable tag v${base_version} exists — skipping beta release"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
beta_tag="v${base_version}-beta.${GITHUB_RUN_NUMBER}"
echo "version=${base_version}" >> "$GITHUB_OUTPUT"
echo "tag=${beta_tag}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Beta release: ${beta_tag}"
release-notes:
name: Generate Release Notes
needs: [version]
if: github.repository == 'zeroclaw-labs/zeroclaw' && needs.version.outputs.skip != 'true'
runs-on: ubuntu-latest
outputs:
notes: ${{ steps.notes.outputs.body }}
@ -153,8 +130,6 @@ jobs:
web:
name: Build Web Dashboard
needs: [version]
if: github.repository == 'zeroclaw-labs/zeroclaw' && needs.version.outputs.skip != 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@ -266,65 +241,9 @@ jobs:
path: zeroclaw-${{ matrix.target }}.${{ matrix.ext }}
retention-days: 7
build-desktop:
name: Build Desktop App (macOS Universal)
needs: [version]
if: needs.version.outputs.skip != 'true'
runs-on: macos-14
timeout-minutes: 40
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
prefix-key: macos-tauri
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Tauri CLI
run: cargo install tauri-cli --locked
- name: Sync Tauri version with Cargo.toml
shell: bash
run: |
VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
cd apps/tauri
if command -v jq >/dev/null 2>&1; then
jq --arg v "$VERSION" '.version = $v' tauri.conf.json > tmp.json && mv tmp.json tauri.conf.json
else
sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" tauri.conf.json
fi
echo "Tauri version set to: $VERSION"
- name: Build Tauri app (universal binary)
working-directory: apps/tauri
run: cargo tauri build --target universal-apple-darwin
- name: Prepare desktop release assets
run: |
mkdir -p desktop-assets
find target -name '*.dmg' -exec cp {} desktop-assets/ZeroClaw.dmg \; 2>/dev/null || true
find target -name '*.app.tar.gz' -exec cp {} desktop-assets/ZeroClaw-macos.app.tar.gz \; 2>/dev/null || true
find target -name '*.app.tar.gz.sig' -exec cp {} desktop-assets/ZeroClaw-macos.app.tar.gz.sig \; 2>/dev/null || true
echo "--- Desktop assets ---"
ls -lh desktop-assets/
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-macos
path: desktop-assets/*
retention-days: 7
publish:
name: Publish Beta Release
needs: [version, release-notes, build, build-desktop]
needs: [version, release-notes, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -334,21 +253,16 @@ jobs:
pattern: zeroclaw-*
path: artifacts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: desktop-macos
path: artifacts/desktop-macos
- name: Generate checksums
run: |
cd artifacts
find . -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' \) -exec sha256sum {} + | sed 's| \./[^/]*/| |' > SHA256SUMS
find . -type f \( -name '*.tar.gz' -o -name '*.zip' \) -exec sha256sum {} + | sed 's| \./[^/]*/| |' > SHA256SUMS
cat SHA256SUMS
- name: Collect release assets
run: |
mkdir -p release-assets
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' -o -name 'SHA256SUMS' \) -exec cp {} release-assets/ \;
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name 'SHA256SUMS' \) -exec cp {} release-assets/ \;
cp install.sh release-assets/
echo "--- Assets ---"
ls -lh release-assets/

View File

@ -1,9 +1,6 @@
name: Release Stable
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+" # stable tags only (no -beta suffix)
workflow_dispatch:
inputs:
version:
@ -36,22 +33,11 @@ jobs:
- name: Validate semver and Cargo.toml match
id: check
shell: bash
env:
INPUT_VERSION: ${{ inputs.version || '' }}
REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
input_version="${{ inputs.version }}"
cargo_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
# Resolve version from tag push or manual input
if [[ "$EVENT_NAME" == "push" ]]; then
# Tag push: extract version from tag name (v0.5.9 -> 0.5.9)
input_version="${REF_NAME#v}"
else
input_version="$INPUT_VERSION"
fi
if [[ ! "$input_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version must be semver (X.Y.Z). Got: ${input_version}"
exit 1
@ -63,13 +49,9 @@ jobs:
fi
tag="v${input_version}"
# Only check tag existence for manual dispatch (tag push means it already exists)
if [[ "$EVENT_NAME" != "push" ]]; then
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
echo "::error::Tag ${tag} already exists."
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
echo "::error::Tag ${tag} already exists."
exit 1
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
@ -273,64 +255,9 @@ jobs:
path: zeroclaw-${{ matrix.target }}.${{ matrix.ext }}
retention-days: 14
build-desktop:
name: Build Desktop App (macOS Universal)
needs: [validate]
runs-on: macos-14
timeout-minutes: 40
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
prefix-key: macos-tauri
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Tauri CLI
run: cargo install tauri-cli --locked
- name: Sync Tauri version with Cargo.toml
shell: bash
run: |
VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
cd apps/tauri
if command -v jq >/dev/null 2>&1; then
jq --arg v "$VERSION" '.version = $v' tauri.conf.json > tmp.json && mv tmp.json tauri.conf.json
else
sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" tauri.conf.json
fi
echo "Tauri version set to: $VERSION"
- name: Build Tauri app (universal binary)
working-directory: apps/tauri
run: cargo tauri build --target universal-apple-darwin
- name: Prepare desktop release assets
run: |
mkdir -p desktop-assets
find target -name '*.dmg' -exec cp {} desktop-assets/ZeroClaw.dmg \; 2>/dev/null || true
find target -name '*.app.tar.gz' -exec cp {} desktop-assets/ZeroClaw-macos.app.tar.gz \; 2>/dev/null || true
find target -name '*.app.tar.gz.sig' -exec cp {} desktop-assets/ZeroClaw-macos.app.tar.gz.sig \; 2>/dev/null || true
echo "--- Desktop assets ---"
ls -lh desktop-assets/
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-macos
path: desktop-assets/*
retention-days: 14
publish:
name: Publish Stable Release
needs: [validate, release-notes, build, build-desktop]
needs: [validate, release-notes, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -340,21 +267,16 @@ jobs:
pattern: zeroclaw-*
path: artifacts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: desktop-macos
path: artifacts/desktop-macos
- name: Generate checksums
run: |
cd artifacts
find . -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' \) -exec sha256sum {} + | sed 's| \./[^/]*/| |' > SHA256SUMS
find . -type f \( -name '*.tar.gz' -o -name '*.zip' \) -exec sha256sum {} + | sed 's| \./[^/]*/| |' > SHA256SUMS
cat SHA256SUMS
- name: Collect release assets
run: |
mkdir -p release-assets
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' -o -name 'SHA256SUMS' \) -exec cp {} release-assets/ \;
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name 'SHA256SUMS' \) -exec cp {} release-assets/ \;
cp install.sh release-assets/
echo "--- Assets ---"
ls -lh release-assets/
@ -364,14 +286,6 @@ jobs:
NOTES: ${{ needs.release-notes.outputs.notes }}
run: printf '%s\n' "$NOTES" > release-notes.md
- name: Create tag if manual dispatch
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ needs.validate.outputs.tag }}
run: |
git tag -a "$TAG" -m "zeroclaw $TAG"
git push origin "$TAG"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
@ -409,21 +323,6 @@ jobs:
- name: Clean web build artifacts
run: rm -rf web/node_modules web/src web/package.json web/package-lock.json web/tsconfig*.json web/vite.config.ts web/index.html
- name: Publish aardvark-sys to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify -p aardvark-sys 2>&1) && exit 0
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q 'already exists'; then
echo "::notice::aardvark-sys already on crates.io — skipping"
exit 0
fi
exit 1
- name: Wait for aardvark-sys to index
run: sleep 15
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
@ -547,16 +446,6 @@ jobs:
dry_run: false
secrets: inherit
homebrew:
name: Update Homebrew Core
needs: [validate, publish]
if: ${{ !cancelled() && needs.publish.result == 'success' }}
uses: ./.github/workflows/pub-homebrew-core.yml
with:
release_tag: ${{ needs.validate.outputs.tag }}
dry_run: false
secrets: inherit
# ── Post-publish: tweet after release + website are live ──────────────
# Docker push can be slow; don't let it block the tweet.
tweet:

View File

@ -1,92 +0,0 @@
# AGENTS.md — ZeroClaw
Cross-tool agent instructions for any AI coding assistant working on this repository.
## Commands
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test
```
Full pre-PR validation (recommended):
```bash
./dev/ci.sh all
```
Docs-only changes: run markdown lint and link-integrity checks. If touching bootstrap scripts: `bash -n install.sh`.
## Project Snapshot
ZeroClaw is a Rust-first autonomous agent runtime optimized for performance, efficiency, stability, extensibility, sustainability, and security.
Core architecture is trait-driven and modular. Extend by implementing traits and registering in factory modules.
Key extension points:
- `src/providers/traits.rs` (`Provider`)
- `src/channels/traits.rs` (`Channel`)
- `src/tools/traits.rs` (`Tool`)
- `src/memory/traits.rs` (`Memory`)
- `src/observability/traits.rs` (`Observer`)
- `src/runtime/traits.rs` (`RuntimeAdapter`)
- `src/peripherals/traits.rs` (`Peripheral`) — hardware boards (STM32, RPi GPIO)
## Repository Map
- `src/main.rs` — CLI entrypoint and command routing
- `src/lib.rs` — module exports and shared command enums
- `src/config/` — schema + config loading/merging
- `src/agent/` — orchestration loop
- `src/gateway/` — webhook/gateway server
- `src/security/` — policy, pairing, secret store
- `src/memory/` — markdown/sqlite memory backends + embeddings/vector merge
- `src/providers/` — model providers and resilient wrapper
- `src/channels/` — Telegram/Discord/Slack/etc channels
- `src/tools/` — tool execution surface (shell, file, memory, browser)
- `src/peripherals/` — hardware peripherals (STM32, RPi GPIO)
- `src/runtime/` — runtime adapters (currently native)
- `docs/` — topic-based documentation (setup-guides, reference, ops, security, hardware, contributing, maintainers)
- `.github/` — CI, templates, automation workflows
## Risk Tiers
- **Low risk**: docs/chore/tests-only changes
- **Medium risk**: most `src/**` behavior changes without boundary/security impact
- **High risk**: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`, access-control boundaries
When uncertain, classify as higher risk.
## Workflow
1. **Read before write** — inspect existing module, factory wiring, and adjacent tests before editing.
2. **One concern per PR** — avoid mixed feature+refactor+infra patches.
3. **Implement minimal patch** — no speculative abstractions, no config keys without a concrete use case.
4. **Validate by risk tier** — docs-only: lightweight checks. Code changes: full relevant checks.
5. **Document impact** — update PR notes for behavior, risk, side effects, and rollback.
6. **Queue hygiene** — stacked PR: declare `Depends on #...`. Replacing old PR: declare `Supersedes #...`.
Branch/commit/PR rules:
- Work from a non-`master` branch. Open a PR to `master`; do not push directly.
- Use conventional commit titles. Prefer small PRs (`size: XS/S/M`).
- Follow `.github/pull_request_template.md` fully.
- Never commit secrets, personal data, or real identity information (see `@docs/contributing/pr-discipline.md`).
## Anti-Patterns
- Do not add heavy dependencies for minor convenience.
- Do not silently weaken security policy or access constraints.
- Do not add speculative config/feature flags "just in case".
- Do not mix massive formatting-only changes with functional changes.
- Do not modify unrelated modules "while here".
- Do not bypass failing checks without explicit explanation.
- Do not hide behavior-changing side effects in refactor commits.
- Do not include personal identity or sensitive information in test data, examples, docs, or commits.
## Linked References
- `@docs/contributing/change-playbooks.md` — adding providers, channels, tools, peripherals; security/gateway changes; architecture boundaries
- `@docs/contributing/pr-discipline.md` — privacy rules, superseded-PR attribution/templates, handoff template
- `@docs/contributing/docs-contract.md` — docs system contract, i18n rules, locale parity

1
AGENTS.md Symbolic link
View File

@ -0,0 +1 @@
CLAUDE.md

View File

@ -1,16 +1,90 @@
# CLAUDE.md — ZeroClaw (Claude Code)
# CLAUDE.md — ZeroClaw
> **Shared instructions live in [`AGENTS.md`](./AGENTS.md).**
> This file contains only Claude Code-specific directives.
## Commands
## Claude Code Settings
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test
```
Claude Code should read and follow all instructions in `AGENTS.md` at the repository root for project conventions, commands, risk tiers, workflow rules, and anti-patterns.
Full pre-PR validation (recommended):
## Hooks
```bash
./dev/ci.sh all
```
_No custom hooks defined yet._
Docs-only changes: run markdown lint and link-integrity checks. If touching bootstrap scripts: `bash -n install.sh`.
## Slash Commands
## Project Snapshot
_No custom slash commands defined yet._
ZeroClaw is a Rust-first autonomous agent runtime optimized for performance, efficiency, stability, extensibility, sustainability, and security.
Core architecture is trait-driven and modular. Extend by implementing traits and registering in factory modules.
Key extension points:
- `src/providers/traits.rs` (`Provider`)
- `src/channels/traits.rs` (`Channel`)
- `src/tools/traits.rs` (`Tool`)
- `src/memory/traits.rs` (`Memory`)
- `src/observability/traits.rs` (`Observer`)
- `src/runtime/traits.rs` (`RuntimeAdapter`)
- `src/peripherals/traits.rs` (`Peripheral`) — hardware boards (STM32, RPi GPIO)
## Repository Map
- `src/main.rs` — CLI entrypoint and command routing
- `src/lib.rs` — module exports and shared command enums
- `src/config/` — schema + config loading/merging
- `src/agent/` — orchestration loop
- `src/gateway/` — webhook/gateway server
- `src/security/` — policy, pairing, secret store
- `src/memory/` — markdown/sqlite memory backends + embeddings/vector merge
- `src/providers/` — model providers and resilient wrapper
- `src/channels/` — Telegram/Discord/Slack/etc channels
- `src/tools/` — tool execution surface (shell, file, memory, browser)
- `src/peripherals/` — hardware peripherals (STM32, RPi GPIO)
- `src/runtime/` — runtime adapters (currently native)
- `docs/` — topic-based documentation (setup-guides, reference, ops, security, hardware, contributing, maintainers)
- `.github/` — CI, templates, automation workflows
## Risk Tiers
- **Low risk**: docs/chore/tests-only changes
- **Medium risk**: most `src/**` behavior changes without boundary/security impact
- **High risk**: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`, access-control boundaries
When uncertain, classify as higher risk.
## Workflow
1. **Read before write** — inspect existing module, factory wiring, and adjacent tests before editing.
2. **One concern per PR** — avoid mixed feature+refactor+infra patches.
3. **Implement minimal patch** — no speculative abstractions, no config keys without a concrete use case.
4. **Validate by risk tier** — docs-only: lightweight checks. Code changes: full relevant checks.
5. **Document impact** — update PR notes for behavior, risk, side effects, and rollback.
6. **Queue hygiene** — stacked PR: declare `Depends on #...`. Replacing old PR: declare `Supersedes #...`.
Branch/commit/PR rules:
- Work from a non-`master` branch. Open a PR to `master`; do not push directly.
- Use conventional commit titles. Prefer small PRs (`size: XS/S/M`).
- Follow `.github/pull_request_template.md` fully.
- Never commit secrets, personal data, or real identity information (see `@docs/contributing/pr-discipline.md`).
## Anti-Patterns
- Do not add heavy dependencies for minor convenience.
- Do not silently weaken security policy or access constraints.
- Do not add speculative config/feature flags "just in case".
- Do not mix massive formatting-only changes with functional changes.
- Do not modify unrelated modules "while here".
- Do not bypass failing checks without explicit explanation.
- Do not hide behavior-changing side effects in refactor commits.
- Do not include personal identity or sensitive information in test data, examples, docs, or commits.
## Linked References
- `@docs/contributing/change-playbooks.md` — adding providers, channels, tools, peripherals; security/gateway changes; architecture boundaries
- `@docs/contributing/pr-discipline.md` — privacy rules, superseded-PR attribution/templates, handoff template
- `@docs/contributing/docs-contract.md` — docs system contract, i18n rules, locale parity

3543
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
[workspace]
members = [".", "crates/robot-kit", "crates/aardvark-sys", "apps/tauri"]
members = [".", "crates/robot-kit", "crates/aardvark-sys"]
resolver = "2"
[package]
name = "zeroclawlabs"
version = "0.6.1"
version = "0.5.4"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
@ -97,7 +97,7 @@ anyhow = "1.0"
thiserror = "2.0"
# Aardvark I2C/SPI/GPIO USB adapter (Total Phase) — stub when SDK absent
aardvark-sys = { path = "crates/aardvark-sys", version = "0.1.0" }
aardvark-sys = { path = "crates/aardvark-sys" }
# UUID generation
uuid = { version = "1.22", default-features = false, features = ["v4", "std"] }
@ -150,7 +150,6 @@ which = "8.0"
# WebSocket client channels (Discord/Lark/DingTalk/Nostr)
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
tokio-socks = "0.5"
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
nostr-sdk = { version = "0.44", default-features = false, features = ["nip04", "nip59"], optional = true }
regex = "1.10"
@ -200,9 +199,6 @@ pdf-extract = { version = "0.10", optional = true }
# WASM plugin runtime (extism)
extism = { version = "1.20", optional = true }
# Cross-platform audio capture for voice wake word detection (optional, enable with --features voice-wake)
cpal = { version = "0.15", optional = true }
# Terminal QR rendering for WhatsApp Web pairing flow.
qrcode = { version = "0.14", optional = true }
@ -225,13 +221,15 @@ landlock = { version = "0.4", optional = true }
libc = "0.2"
[features]
default = ["observability-prometheus", "channel-nostr", "channel-lark", "skill-creation"]
default = ["observability-prometheus", "channel-nostr", "skill-creation"]
channel-nostr = ["dep:nostr-sdk"]
hardware = ["nusb", "tokio-serial"]
channel-matrix = ["dep:matrix-sdk"]
channel-lark = ["dep:prost"]
channel-feishu = ["channel-lark"] # Alias for Feishu users (Lark and Feishu are the same platform)
memory-postgres = ["dep:postgres"]
# memory-mem0 = Mem0 (OpenMemory) memory backend via REST API
memory-mem0 = []
observability-prometheus = ["dep:prometheus"]
observability-otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp"]
peripheral-rpi = ["rppal"]
@ -254,30 +252,8 @@ rag-pdf = ["dep:pdf-extract"]
skill-creation = []
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
# voice-wake = Voice wake word detection via microphone (cpal)
voice-wake = ["dep:cpal"]
# WASM plugin system (extism-based)
plugins-wasm = ["dep:extism"]
# Meta-feature for CI: all features except those requiring system C libraries
# not available on standard CI runners (e.g., voice-wake needs libasound2-dev).
ci-all = [
"channel-nostr",
"hardware",
"channel-matrix",
"channel-lark",
"memory-postgres",
"observability-prometheus",
"observability-otel",
"peripheral-rpi",
"browser-native",
"sandbox-landlock",
"sandbox-bubblewrap",
"probe",
"rag-pdf",
"skill-creation",
"whatsapp-web",
"plugins-wasm",
]
[profile.release]
opt-level = "z" # Optimize for size

View File

@ -12,7 +12,7 @@ RUN npm run build
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres,channel-lark"
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@ -27,7 +27,6 @@ COPY Cargo.toml Cargo.lock ./
# Previously we used sed to drop `crates/robot-kit`, which made the manifest disagree
# with the lockfile and caused `cargo --locked` to fail (Cargo refused to rewrite the lock).
COPY crates/robot-kit/ crates/robot-kit/
COPY crates/aardvark-sys/ crates/aardvark-sys/
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
RUN mkdir -p src benches \
&& echo "fn main() {}" > src/main.rs \
@ -79,10 +78,6 @@ RUN mkdir -p /zeroclaw-data/.zeroclaw /zeroclaw-data/workspace && \
'port = 42617' \
'host = "[::]"' \
'allow_public_bind = true' \
'' \
'[autonomy]' \
'level = "supervised"' \
'auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]' \
> /zeroclaw-data/.zeroclaw/config.toml && \
chown -R 65534:65534 /zeroclaw-data

View File

@ -27,7 +27,7 @@ RUN npm run build
FROM rust:1.94-bookworm AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres,channel-lark"
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@ -89,10 +89,6 @@ RUN mkdir -p /zeroclaw-data/.zeroclaw /zeroclaw-data/workspace && \
'port = 42617' \
'host = "[::]"' \
'allow_public_bind = true' \
'' \
'[autonomy]' \
'level = "supervised"' \
'auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]' \
> /zeroclaw-data/.zeroclaw/config.toml && \
chown -R 65534:65534 /zeroclaw-data

View File

@ -1,29 +0,0 @@
[package]
name = "zeroclaw-desktop"
version = "0.1.0"
edition = "2021"
description = "ZeroClaw Desktop — Tauri-powered system tray app"
publish = false
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "2.0", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2.0"
tauri-plugin-store = "2.0"
tauri-plugin-single-instance = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1.50", features = ["rt-multi-thread", "macros", "sync", "time"] }
anyhow = "1.0"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", features = ["NSData"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

View File

@ -1,3 +0,0 @@
fn main() {
tauri_build::build();
}

View File

@ -1,14 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability set for ZeroClaw Desktop",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"store:allow-get",
"store:allow-set",
"store:allow-save",
"store:allow-load"
]
}

View File

@ -1,14 +0,0 @@
{
"identifier": "desktop",
"description": "Desktop-specific permissions for ZeroClaw",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"shell:allow-execute",
"store:allow-get",
"store:allow-set",
"store:allow-save",
"store:allow-load"
]
}

View File

@ -1,8 +0,0 @@
{
"identifier": "mobile",
"description": "Mobile-specific permissions for ZeroClaw",
"windows": ["main"],
"permissions": [
"core:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="16" fill="#DC322F"/>
<text x="64" y="80" font-size="64" font-family="monospace" font-weight="bold" fill="white" text-anchor="middle">Z</text>
</svg>

Before

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 B

View File

@ -1,17 +0,0 @@
use crate::gateway_client::GatewayClient;
use crate::state::SharedState;
use tauri::State;
#[tauri::command]
pub async fn send_message(
state: State<'_, SharedState>,
message: String,
) -> Result<serde_json::Value, String> {
let s = state.read().await;
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
drop(s);
client
.send_webhook_message(&message)
.await
.map_err(|e| e.to_string())
}

View File

@ -1,11 +0,0 @@
use crate::gateway_client::GatewayClient;
use crate::state::SharedState;
use tauri::State;
#[tauri::command]
pub async fn list_channels(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
let s = state.read().await;
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
drop(s);
client.get_status().await.map_err(|e| e.to_string())
}

View File

@ -1,19 +0,0 @@
use crate::gateway_client::GatewayClient;
use crate::state::SharedState;
use tauri::State;
#[tauri::command]
pub async fn get_status(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
let s = state.read().await;
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
drop(s);
client.get_status().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_health(state: State<'_, SharedState>) -> Result<bool, String> {
let s = state.read().await;
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
drop(s);
client.get_health().await.map_err(|e| e.to_string())
}

View File

@ -1,4 +0,0 @@
pub mod agent;
pub mod channels;
pub mod gateway;
pub mod pairing;

View File

@ -1,19 +0,0 @@
use crate::gateway_client::GatewayClient;
use crate::state::SharedState;
use tauri::State;
#[tauri::command]
pub async fn initiate_pairing(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
let s = state.read().await;
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
drop(s);
client.initiate_pairing().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_devices(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
let s = state.read().await;
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
drop(s);
client.get_devices().await.map_err(|e| e.to_string())
}

View File

@ -1,213 +0,0 @@
//! HTTP client for communicating with the ZeroClaw gateway.
use anyhow::{Context, Result};
pub struct GatewayClient {
pub(crate) base_url: String,
pub(crate) token: Option<String>,
client: reqwest::Client,
}
impl GatewayClient {
pub fn new(base_url: &str, token: Option<&str>) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
Self {
base_url: base_url.to_string(),
token: token.map(String::from),
client,
}
}
pub(crate) fn auth_header(&self) -> Option<String> {
self.token.as_ref().map(|t| format!("Bearer {t}"))
}
pub async fn get_status(&self) -> Result<serde_json::Value> {
let mut req = self.client.get(format!("{}/api/status", self.base_url));
if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let resp = req.send().await.context("status request failed")?;
Ok(resp.json().await?)
}
pub async fn get_health(&self) -> Result<bool> {
match self
.client
.get(format!("{}/health", self.base_url))
.send()
.await
{
Ok(resp) => Ok(resp.status().is_success()),
Err(_) => Ok(false),
}
}
pub async fn get_devices(&self) -> Result<serde_json::Value> {
let mut req = self.client.get(format!("{}/api/devices", self.base_url));
if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let resp = req.send().await.context("devices request failed")?;
Ok(resp.json().await?)
}
pub async fn initiate_pairing(&self) -> Result<serde_json::Value> {
let mut req = self
.client
.post(format!("{}/api/pairing/initiate", self.base_url));
if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let resp = req.send().await.context("pairing request failed")?;
Ok(resp.json().await?)
}
/// Check whether the gateway requires pairing.
pub async fn requires_pairing(&self) -> Result<bool> {
let resp = self
.client
.get(format!("{}/health", self.base_url))
.send()
.await
.context("health request failed")?;
let body: serde_json::Value = resp.json().await?;
Ok(body["require_pairing"].as_bool().unwrap_or(false))
}
/// Request a new pairing code from the gateway (localhost-only admin endpoint).
pub async fn request_new_paircode(&self) -> Result<String> {
let resp = self
.client
.post(format!("{}/admin/paircode/new", self.base_url))
.send()
.await
.context("paircode request failed")?;
let body: serde_json::Value = resp.json().await?;
body["pairing_code"]
.as_str()
.map(String::from)
.context("no pairing_code in response")
}
/// Exchange a pairing code for a bearer token.
pub async fn pair_with_code(&self, code: &str) -> Result<String> {
let resp = self
.client
.post(format!("{}/pair", self.base_url))
.header("X-Pairing-Code", code)
.send()
.await
.context("pair request failed")?;
if !resp.status().is_success() {
anyhow::bail!("pair request returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await?;
body["token"]
.as_str()
.map(String::from)
.context("no token in pair response")
}
/// Validate an existing token by calling a protected endpoint.
pub async fn validate_token(&self) -> Result<bool> {
let mut req = self.client.get(format!("{}/api/status", self.base_url));
if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
match req.send().await {
Ok(resp) => Ok(resp.status().is_success()),
Err(_) => Ok(false),
}
}
/// Auto-pair with the gateway: request a new code and exchange it for a token.
pub async fn auto_pair(&self) -> Result<String> {
let code = self.request_new_paircode().await?;
self.pair_with_code(&code).await
}
pub async fn send_webhook_message(&self, message: &str) -> Result<serde_json::Value> {
let mut req = self
.client
.post(format!("{}/webhook", self.base_url))
.json(&serde_json::json!({ "message": message }));
if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let resp = req.send().await.context("webhook request failed")?;
Ok(resp.json().await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_creation_no_token() {
let client = GatewayClient::new("http://127.0.0.1:42617", None);
assert_eq!(client.base_url, "http://127.0.0.1:42617");
assert!(client.token.is_none());
assert!(client.auth_header().is_none());
}
#[test]
fn client_creation_with_token() {
let client = GatewayClient::new("http://localhost:8080", Some("test-token"));
assert_eq!(client.base_url, "http://localhost:8080");
assert_eq!(client.token.as_deref(), Some("test-token"));
assert_eq!(client.auth_header().unwrap(), "Bearer test-token");
}
#[test]
fn client_custom_url() {
let client = GatewayClient::new("https://zeroclaw.example.com:9999", None);
assert_eq!(client.base_url, "https://zeroclaw.example.com:9999");
}
#[test]
fn auth_header_format() {
let client = GatewayClient::new("http://localhost", Some("zc_abc123"));
assert_eq!(client.auth_header().unwrap(), "Bearer zc_abc123");
}
#[tokio::test]
async fn health_returns_false_for_unreachable_host() {
// Connect to a port that should not be listening.
let client = GatewayClient::new("http://127.0.0.1:1", None);
let result = client.get_health().await.unwrap();
assert!(!result, "health should be false for unreachable host");
}
#[tokio::test]
async fn status_fails_for_unreachable_host() {
let client = GatewayClient::new("http://127.0.0.1:1", None);
let result = client.get_status().await;
assert!(result.is_err(), "status should fail for unreachable host");
}
#[tokio::test]
async fn devices_fails_for_unreachable_host() {
let client = GatewayClient::new("http://127.0.0.1:1", None);
let result = client.get_devices().await;
assert!(result.is_err(), "devices should fail for unreachable host");
}
#[tokio::test]
async fn pairing_fails_for_unreachable_host() {
let client = GatewayClient::new("http://127.0.0.1:1", None);
let result = client.initiate_pairing().await;
assert!(result.is_err(), "pairing should fail for unreachable host");
}
#[tokio::test]
async fn webhook_fails_for_unreachable_host() {
let client = GatewayClient::new("http://127.0.0.1:1", None);
let result = client.send_webhook_message("hello").await;
assert!(result.is_err(), "webhook should fail for unreachable host");
}
}

View File

@ -1,40 +0,0 @@
//! Background health polling for the ZeroClaw gateway.
use crate::gateway_client::GatewayClient;
use crate::state::SharedState;
use crate::tray::icon;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Runtime};
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Spawn a background task that polls gateway health and updates state + tray.
pub fn spawn_health_poller<R: Runtime>(app: AppHandle<R>, state: SharedState) {
tauri::async_runtime::spawn(async move {
loop {
let (url, token) = {
let s = state.read().await;
(s.gateway_url.clone(), s.token.clone())
};
let client = GatewayClient::new(&url, token.as_deref());
let healthy = client.get_health().await.unwrap_or(false);
let (connected, agent_status) = {
let mut s = state.write().await;
s.connected = healthy;
(s.connected, s.agent_status)
};
// Update the tray icon and tooltip to reflect current state.
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_icon(Some(icon::icon_for_state(connected, agent_status)));
let _ = tray.set_tooltip(Some(icon::tooltip_for_state(connected, agent_status)));
}
let _ = app.emit("zeroclaw://status-changed", healthy);
tokio::time::sleep(POLL_INTERVAL).await;
}
});
}

View File

@ -1,136 +0,0 @@
//! ZeroClaw Desktop — Tauri application library.
pub mod commands;
pub mod gateway_client;
pub mod health;
pub mod state;
pub mod tray;
use gateway_client::GatewayClient;
use state::shared_state;
use tauri::{Manager, RunEvent};
/// Attempt to auto-pair with the gateway so the WebView has a valid token
/// before the React frontend mounts. Runs on localhost so the admin endpoints
/// are accessible without auth.
async fn auto_pair(state: &state::SharedState) -> Option<String> {
let url = {
let s = state.read().await;
s.gateway_url.clone()
};
let client = GatewayClient::new(&url, None);
// Check if gateway is reachable and requires pairing.
if !client.requires_pairing().await.unwrap_or(false) {
return None; // Pairing disabled — no token needed.
}
// Check if we already have a valid token in state.
{
let s = state.read().await;
if let Some(ref token) = s.token {
let authed = GatewayClient::new(&url, Some(token));
if authed.validate_token().await.unwrap_or(false) {
return Some(token.clone()); // Existing token is valid.
}
}
}
// No valid token — auto-pair by requesting a new code and exchanging it.
let client = GatewayClient::new(&url, None);
match client.auto_pair().await {
Ok(token) => {
let mut s = state.write().await;
s.token = Some(token.clone());
Some(token)
}
Err(_) => None, // Gateway may not be ready yet; health poller will retry.
}
}
/// Inject a bearer token into the WebView's localStorage so the React app
/// skips the pairing dialog. Uses Tauri's WebviewWindow scripting API.
fn inject_token_into_webview<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, token: &str) {
let escaped = token.replace('\\', "\\\\").replace('\'', "\\'");
let script = format!("localStorage.setItem('zeroclaw_token', '{escaped}')");
// WebviewWindow scripting is the standard Tauri API for running JS in the WebView.
let _ = window.eval(&script);
}
/// Set the macOS dock icon programmatically so it shows even in dev builds
/// (which don't have a proper .app bundle).
#[cfg(target_os = "macos")]
fn set_dock_icon() {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::NSApplication;
use objc2_app_kit::NSImage;
use objc2_foundation::NSData;
let icon_bytes = include_bytes!("../icons/128x128.png");
// Safety: setup() runs on the main thread in Tauri.
let mtm = unsafe { MainThreadMarker::new_unchecked() };
let data = NSData::with_bytes(icon_bytes);
if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
let app = NSApplication::sharedApplication(mtm);
unsafe { app.setApplicationIconImage(Some(&image)) };
}
}
/// Configure and run the Tauri application.
pub fn run() {
let shared = shared_state();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// When a second instance launches, focus the existing window.
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.manage(shared.clone())
.invoke_handler(tauri::generate_handler![
commands::gateway::get_status,
commands::gateway::get_health,
commands::channels::list_channels,
commands::pairing::initiate_pairing,
commands::pairing::get_devices,
commands::agent::send_message,
])
.setup(move |app| {
// Set macOS dock icon (needed for dev builds without .app bundle).
#[cfg(target_os = "macos")]
set_dock_icon();
// Set up the system tray.
let _ = tray::setup_tray(app);
// Auto-pair with gateway and inject token into the WebView.
let app_handle = app.handle().clone();
let pair_state = shared.clone();
tauri::async_runtime::spawn(async move {
if let Some(token) = auto_pair(&pair_state).await {
if let Some(window) = app_handle.get_webview_window("main") {
inject_token_into_webview(&window, &token);
}
}
});
// Start background health polling.
health::spawn_health_poller(app.handle().clone(), shared.clone());
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app, event| {
// Keep the app running in the background when all windows are closed.
// This is the standard pattern for menu bar / tray apps.
if let RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
}
});
}

View File

@ -1,8 +0,0 @@
//! ZeroClaw Desktop — main entry point.
//!
//! Prevents an additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
zeroclaw_desktop::run();
}

View File

@ -1,6 +0,0 @@
//! Mobile entry point for ZeroClaw Desktop (iOS/Android).
#[tauri::mobile_entry_point]
fn main() {
zeroclaw_desktop::run();
}

View File

@ -1,99 +0,0 @@
//! Shared application state for Tauri.
use std::sync::Arc;
use tokio::sync::RwLock;
/// Agent status as reported by the gateway.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
Idle,
Working,
Error,
}
/// Shared application state behind an `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct AppState {
pub gateway_url: String,
pub token: Option<String>,
pub connected: bool,
pub agent_status: AgentStatus,
}
impl Default for AppState {
fn default() -> Self {
Self {
gateway_url: "http://127.0.0.1:42617".to_string(),
token: None,
connected: false,
agent_status: AgentStatus::Idle,
}
}
}
/// Thread-safe wrapper around `AppState`.
pub type SharedState = Arc<RwLock<AppState>>;
/// Create the default shared state.
pub fn shared_state() -> SharedState {
Arc::new(RwLock::new(AppState::default()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_state() {
let state = AppState::default();
assert_eq!(state.gateway_url, "http://127.0.0.1:42617");
assert!(state.token.is_none());
assert!(!state.connected);
assert_eq!(state.agent_status, AgentStatus::Idle);
}
#[test]
fn shared_state_is_cloneable() {
let s1 = shared_state();
let s2 = s1.clone();
// Both references point to the same allocation.
assert!(Arc::ptr_eq(&s1, &s2));
}
#[tokio::test]
async fn shared_state_concurrent_read_write() {
let state = shared_state();
// Write from one handle.
{
let mut s = state.write().await;
s.connected = true;
s.agent_status = AgentStatus::Working;
s.token = Some("zc_test".to_string());
}
// Read from cloned handle.
let state2 = state.clone();
let s = state2.read().await;
assert!(s.connected);
assert_eq!(s.agent_status, AgentStatus::Working);
assert_eq!(s.token.as_deref(), Some("zc_test"));
}
#[test]
fn agent_status_serialization() {
assert_eq!(
serde_json::to_string(&AgentStatus::Idle).unwrap(),
"\"idle\""
);
assert_eq!(
serde_json::to_string(&AgentStatus::Working).unwrap(),
"\"working\""
);
assert_eq!(
serde_json::to_string(&AgentStatus::Error).unwrap(),
"\"error\""
);
}
}

View File

@ -1,25 +0,0 @@
//! Tray menu event handling.
use tauri::{menu::MenuEvent, AppHandle, Manager, Runtime};
pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, event: MenuEvent) {
match event.id().as_ref() {
"show" => show_main_window(app, None),
"chat" => show_main_window(app, Some("/agent")),
"quit" => {
app.exit(0);
}
_ => {}
}
}
fn show_main_window<R: Runtime>(app: &AppHandle<R>, navigate_to: Option<&str>) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
if let Some(path) = navigate_to {
let script = format!("window.location.hash = '{path}'");
let _ = window.eval(&script);
}
}
}

View File

@ -1,105 +0,0 @@
//! Tray icon management — swap icon based on connection/agent status.
use crate::state::AgentStatus;
use tauri::image::Image;
/// Embedded tray icon PNGs (22x22, RGBA).
const ICON_IDLE: &[u8] = include_bytes!("../../icons/tray-idle.png");
const ICON_WORKING: &[u8] = include_bytes!("../../icons/tray-working.png");
const ICON_ERROR: &[u8] = include_bytes!("../../icons/tray-error.png");
const ICON_DISCONNECTED: &[u8] = include_bytes!("../../icons/tray-disconnected.png");
/// Select the appropriate tray icon for the current state.
pub fn icon_for_state(connected: bool, status: AgentStatus) -> Image<'static> {
let bytes: &[u8] = if !connected {
ICON_DISCONNECTED
} else {
match status {
AgentStatus::Idle => ICON_IDLE,
AgentStatus::Working => ICON_WORKING,
AgentStatus::Error => ICON_ERROR,
}
};
Image::from_bytes(bytes).expect("embedded tray icon is a valid PNG")
}
/// Tooltip text for the current state.
pub fn tooltip_for_state(connected: bool, status: AgentStatus) -> &'static str {
if !connected {
return "ZeroClaw — Disconnected";
}
match status {
AgentStatus::Idle => "ZeroClaw — Idle",
AgentStatus::Working => "ZeroClaw — Working",
AgentStatus::Error => "ZeroClaw — Error",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn icon_disconnected_when_not_connected() {
// Should not panic — icon bytes are valid PNGs.
let _img = icon_for_state(false, AgentStatus::Idle);
let _img = icon_for_state(false, AgentStatus::Working);
let _img = icon_for_state(false, AgentStatus::Error);
}
#[test]
fn icon_connected_variants() {
let _idle = icon_for_state(true, AgentStatus::Idle);
let _working = icon_for_state(true, AgentStatus::Working);
let _error = icon_for_state(true, AgentStatus::Error);
}
#[test]
fn tooltip_disconnected() {
assert_eq!(
tooltip_for_state(false, AgentStatus::Idle),
"ZeroClaw — Disconnected"
);
// Agent status is irrelevant when disconnected.
assert_eq!(
tooltip_for_state(false, AgentStatus::Working),
"ZeroClaw — Disconnected"
);
assert_eq!(
tooltip_for_state(false, AgentStatus::Error),
"ZeroClaw — Disconnected"
);
}
#[test]
fn tooltip_connected_variants() {
assert_eq!(
tooltip_for_state(true, AgentStatus::Idle),
"ZeroClaw — Idle"
);
assert_eq!(
tooltip_for_state(true, AgentStatus::Working),
"ZeroClaw — Working"
);
assert_eq!(
tooltip_for_state(true, AgentStatus::Error),
"ZeroClaw — Error"
);
}
#[test]
fn embedded_icons_are_valid_png() {
// Verify the PNG signature (first 8 bytes) of each embedded icon.
let png_sig: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
assert!(ICON_IDLE.starts_with(png_sig), "idle icon not valid PNG");
assert!(
ICON_WORKING.starts_with(png_sig),
"working icon not valid PNG"
);
assert!(ICON_ERROR.starts_with(png_sig), "error icon not valid PNG");
assert!(
ICON_DISCONNECTED.starts_with(png_sig),
"disconnected icon not valid PNG"
);
}
}

View File

@ -1,19 +0,0 @@
//! Tray menu construction.
use tauri::{
menu::{Menu, MenuItemBuilder, PredefinedMenuItem},
App, Runtime,
};
pub fn create_tray_menu<R: Runtime>(app: &App<R>) -> Result<Menu<R>, tauri::Error> {
let show = MenuItemBuilder::with_id("show", "Show Dashboard").build(app)?;
let chat = MenuItemBuilder::with_id("chat", "Agent Chat").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let status = MenuItemBuilder::with_id("status", "Status: Checking...")
.enabled(false)
.build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit ZeroClaw").build(app)?;
Menu::with_items(app, &[&show, &chat, &sep1, &status, &sep2, &quit])
}

View File

@ -1,34 +0,0 @@
//! System tray integration for ZeroClaw Desktop.
pub mod events;
pub mod icon;
pub mod menu;
use tauri::{
tray::{TrayIcon, TrayIconBuilder, TrayIconEvent},
App, Manager, Runtime,
};
/// Set up the system tray icon and menu.
pub fn setup_tray<R: Runtime>(app: &App<R>) -> Result<TrayIcon<R>, tauri::Error> {
let menu = menu::create_tray_menu(app)?;
TrayIconBuilder::with_id("main")
.tooltip("ZeroClaw — Disconnected")
.icon(icon::icon_for_state(false, crate::state::AgentStatus::Idle))
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(events::handle_menu_event)
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button, .. } = event {
if button == tauri::tray::MouseButton::Left {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
}
})
.build(app)
}

View File

@ -1,35 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/config.schema.json",
"productName": "ZeroClaw",
"version": "0.6.1",
"identifier": "ai.zeroclawlabs.desktop",
"build": {
"devUrl": "http://127.0.0.1:42617/_app/",
"frontendDist": "http://127.0.0.1:42617/_app/"
},
"app": {
"windows": [
{
"title": "ZeroClaw",
"width": 1200,
"height": 800,
"resizable": true,
"fullscreen": false,
"visible": false
}
],
"security": {
"csp": "default-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' http://127.0.0.1:*; style-src 'self' 'unsafe-inline' http://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -263,7 +263,7 @@ fn bench_memory_operations(c: &mut Criterion) {
c.bench_function("memory_recall_top10", |b| {
b.iter(|| {
rt.block_on(async {
mem.recall(black_box("zeroclaw agent"), 10, None, None, None)
mem.recall(black_box("zeroclaw agent"), 10, None)
.await
.unwrap()
})

80
deploy/mem0/mem0-gpu-start.sh Executable file
View File

@ -0,0 +1,80 @@
#!/bin/bash
# Start mem0 + reranker GPU container for ZeroClaw memory backend.
#
# Required env vars:
# MEM0_LLM_API_KEY or ZAI_API_KEY — API key for the LLM used in fact extraction
#
# Optional env vars (with defaults):
# MEM0_LLM_PROVIDER — mem0 LLM provider (default: "openai" i.e. OpenAI-compatible)
# MEM0_LLM_MODEL — LLM model for fact extraction (default: "glm-5-turbo")
# MEM0_LLM_BASE_URL — LLM API base URL (default: "https://api.z.ai/api/coding/paas/v4")
# MEM0_EMBEDDER_MODEL — embedding model (default: "BAAI/bge-m3")
# MEM0_EMBEDDER_DIMS — embedding dimensions (default: "1024")
# MEM0_EMBEDDER_DEVICE — "cuda", "cpu", or "auto" (default: "cuda")
# MEM0_VECTOR_COLLECTION — Qdrant collection name (default: "zeroclaw_mem0")
# RERANKER_MODEL — reranker model (default: "BAAI/bge-reranker-v2-m3")
# RERANKER_DEVICE — "cuda" or "cpu" (default: "cuda")
# MEM0_PORT — mem0 server port (default: 8765)
# RERANKER_PORT — reranker server port (default: 8678)
# CONTAINER_IMAGE — base container image (default: docker.io/kyuz0/amd-strix-halo-comfyui:latest)
# CONTAINER_NAME — container name (default: mem0-gpu)
# DATA_DIR — host path for Qdrant data (default: ~/mem0-data)
# SCRIPT_DIR — host path for server scripts (default: directory of this script)
set -e
# Resolve script directory for mounting server scripts
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}"
# API key — accept either name
export MEM0_LLM_API_KEY="${MEM0_LLM_API_KEY:-${ZAI_API_KEY:?MEM0_LLM_API_KEY or ZAI_API_KEY must be set}}"
# Defaults
MEM0_LLM_MODEL="${MEM0_LLM_MODEL:-glm-5-turbo}"
MEM0_LLM_BASE_URL="${MEM0_LLM_BASE_URL:-https://api.z.ai/api/coding/paas/v4}"
MEM0_PORT="${MEM0_PORT:-8765}"
RERANKER_PORT="${RERANKER_PORT:-8678}"
CONTAINER_IMAGE="${CONTAINER_IMAGE:-docker.io/kyuz0/amd-strix-halo-comfyui:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-mem0-gpu}"
DATA_DIR="${DATA_DIR:-$HOME/mem0-data}"
# Stop existing CPU services (if any)
kill -9 $(pgrep -f "mem0-server.py") 2>/dev/null || true
kill -9 $(pgrep -f "reranker-server.py") 2>/dev/null || true
# Stop existing container
podman stop "$CONTAINER_NAME" 2>/dev/null || true
podman rm "$CONTAINER_NAME" 2>/dev/null || true
podman run -d --name "$CONTAINER_NAME" \
--device /dev/dri --device /dev/kfd \
--group-add video --group-add render \
--restart unless-stopped \
-p "$MEM0_PORT:$MEM0_PORT" -p "$RERANKER_PORT:$RERANKER_PORT" \
-v "$DATA_DIR":/root/mem0-data:Z \
-v "$SCRIPT_DIR/mem0-server.py":/app/mem0-server.py:ro,Z \
-v "$SCRIPT_DIR/reranker-server.py":/app/reranker-server.py:ro,Z \
-v "$HOME/.cache/huggingface":/root/.cache/huggingface:Z \
-e MEM0_LLM_API_KEY="$MEM0_LLM_API_KEY" \
-e ZAI_API_KEY="$MEM0_LLM_API_KEY" \
-e MEM0_LLM_MODEL="$MEM0_LLM_MODEL" \
-e MEM0_LLM_BASE_URL="$MEM0_LLM_BASE_URL" \
${MEM0_LLM_PROVIDER:+-e MEM0_LLM_PROVIDER="$MEM0_LLM_PROVIDER"} \
${MEM0_EMBEDDER_MODEL:+-e MEM0_EMBEDDER_MODEL="$MEM0_EMBEDDER_MODEL"} \
${MEM0_EMBEDDER_DIMS:+-e MEM0_EMBEDDER_DIMS="$MEM0_EMBEDDER_DIMS"} \
${MEM0_EMBEDDER_DEVICE:+-e MEM0_EMBEDDER_DEVICE="$MEM0_EMBEDDER_DEVICE"} \
${MEM0_VECTOR_COLLECTION:+-e MEM0_VECTOR_COLLECTION="$MEM0_VECTOR_COLLECTION"} \
${RERANKER_MODEL:+-e RERANKER_MODEL="$RERANKER_MODEL"} \
${RERANKER_DEVICE:+-e RERANKER_DEVICE="$RERANKER_DEVICE"} \
-e RERANKER_PORT="$RERANKER_PORT" \
-e RERANKER_URL="http://127.0.0.1:$RERANKER_PORT/rerank" \
-e TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 \
-e HOME=/root \
"$CONTAINER_IMAGE" \
bash -c "pip install -q FlagEmbedding mem0ai flask httpx qdrant-client 2>&1 | tail -3; echo '=== Starting reranker (GPU) on :$RERANKER_PORT ==='; python3 /app/reranker-server.py & sleep 3; echo '=== Starting mem0 (GPU) on :$MEM0_PORT ==='; exec python3 /app/mem0-server.py"
echo "Container started, waiting for init..."
sleep 15
echo "=== Container logs ==="
podman logs "$CONTAINER_NAME" 2>&1 | tail -25
echo "=== Port check ==="
ss -tlnp | grep "$MEM0_PORT\|$RERANKER_PORT" || echo "Ports not yet ready, check: podman logs $CONTAINER_NAME"

288
deploy/mem0/mem0-server.py Normal file
View File

@ -0,0 +1,288 @@
"""Minimal OpenMemory-compatible REST server wrapping mem0 Python SDK."""
import asyncio
import json, os, uuid, httpx
from datetime import datetime, timezone
from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import Optional
from mem0 import Memory
app = FastAPI()
RERANKER_URL = os.environ.get("RERANKER_URL", "http://127.0.0.1:8678/rerank")
CUSTOM_EXTRACTION_PROMPT = """You are a memory extraction specialist for a Cantonese/Chinese chat assistant.
Extract ONLY important, persistent facts from the conversation. Rules:
1. Extract personal preferences, habits, relationships, names, locations
2. Extract decisions, plans, and commitments people make
3. SKIP small talk, greetings, reactions ("ok", "哈哈", "係呀")
4. SKIP temporary states ("我依家食緊飯") unless they reveal a habit
5. Keep facts in the ORIGINAL language (Cantonese/Chinese/English)
6. For each fact, note WHO it's about (use their name or identifier if known)
7. Merge/update existing facts rather than creating duplicates
Return a list of facts in JSON format: {"facts": ["fact1", "fact2", ...]}
"""
PROCEDURAL_EXTRACTION_PROMPT = """You are a procedural memory specialist for an AI assistant.
Extract HOW-TO patterns and reusable procedures from the conversation trace. Rules:
1. Identify step-by-step procedures the assistant followed to accomplish a task
2. Extract tool usage patterns: which tools were called, in what order, with what arguments
3. Capture decision points: why the assistant chose one approach over another
4. Note error-recovery patterns: what failed, how it was fixed
5. Keep the procedure generic enough to apply to similar future tasks
6. Preserve technical details (commands, file paths, API calls) that are reusable
7. SKIP greetings, small talk, and conversational filler
8. Format each procedure as: "To [goal]: [step1] -> [step2] -> ... -> [result]"
Return a list of procedures in JSON format: {"facts": ["procedure1", "procedure2", ...]}
"""
# ── Configurable via environment variables ─────────────────────────
# LLM (for fact extraction when infer=true)
MEM0_LLM_PROVIDER = os.environ.get("MEM0_LLM_PROVIDER", "openai") # "openai" (compatible), "anthropic", etc.
MEM0_LLM_MODEL = os.environ.get("MEM0_LLM_MODEL", "glm-5-turbo")
MEM0_LLM_API_KEY = os.environ.get("MEM0_LLM_API_KEY") or os.environ.get("ZAI_API_KEY", "")
MEM0_LLM_BASE_URL = os.environ.get("MEM0_LLM_BASE_URL", "https://api.z.ai/api/coding/paas/v4")
# Embedder
MEM0_EMBEDDER_PROVIDER = os.environ.get("MEM0_EMBEDDER_PROVIDER", "huggingface") # "huggingface", "openai", etc.
MEM0_EMBEDDER_MODEL = os.environ.get("MEM0_EMBEDDER_MODEL", "BAAI/bge-m3")
MEM0_EMBEDDER_DIMS = int(os.environ.get("MEM0_EMBEDDER_DIMS", "1024"))
MEM0_EMBEDDER_DEVICE = os.environ.get("MEM0_EMBEDDER_DEVICE", "cuda") # "cuda", "cpu", "auto"
# Vector store
MEM0_VECTOR_PROVIDER = os.environ.get("MEM0_VECTOR_PROVIDER", "qdrant") # "qdrant", "chroma", etc.
MEM0_VECTOR_COLLECTION = os.environ.get("MEM0_VECTOR_COLLECTION", "zeroclaw_mem0")
MEM0_VECTOR_PATH = os.environ.get("MEM0_VECTOR_PATH", os.path.expanduser("~/mem0-data/qdrant"))
config = {
"llm": {
"provider": MEM0_LLM_PROVIDER,
"config": {
"model": MEM0_LLM_MODEL,
"api_key": MEM0_LLM_API_KEY,
"openai_base_url": MEM0_LLM_BASE_URL,
},
},
"embedder": {
"provider": MEM0_EMBEDDER_PROVIDER,
"config": {
"model": MEM0_EMBEDDER_MODEL,
"embedding_dims": MEM0_EMBEDDER_DIMS,
"model_kwargs": {"device": MEM0_EMBEDDER_DEVICE},
},
},
"vector_store": {
"provider": MEM0_VECTOR_PROVIDER,
"config": {
"collection_name": MEM0_VECTOR_COLLECTION,
"embedding_model_dims": MEM0_EMBEDDER_DIMS,
"path": MEM0_VECTOR_PATH,
},
},
"custom_fact_extraction_prompt": CUSTOM_EXTRACTION_PROMPT,
}
m = Memory.from_config(config)
def rerank_results(query: str, items: list, top_k: int = 10) -> list:
"""Rerank search results using bge-reranker-v2-m3."""
if not items:
return items
documents = [item.get("memory", "") for item in items]
try:
resp = httpx.post(
RERANKER_URL,
json={"query": query, "documents": documents, "top_k": top_k},
timeout=10.0,
)
resp.raise_for_status()
ranked = resp.json().get("results", [])
return [items[r["index"]] for r in ranked]
except Exception as e:
print(f"Reranker failed, using original order: {e}")
return items
class AddMemoryRequest(BaseModel):
user_id: str
text: str
metadata: Optional[dict] = None
infer: bool = True
app: Optional[str] = None
custom_instructions: Optional[str] = None
@app.post("/api/v1/memories/")
async def add_memory(req: AddMemoryRequest):
# Use client-supplied prompt, fall back to server default, then mem0 SDK default
prompt = req.custom_instructions or CUSTOM_EXTRACTION_PROMPT
result = await asyncio.to_thread(m.add, req.text, user_id=req.user_id, metadata=req.metadata or {}, prompt=prompt)
return {"id": str(uuid.uuid4()), "status": "ok", "result": result}
class ProceduralMemoryRequest(BaseModel):
user_id: str
messages: list[dict]
metadata: Optional[dict] = None
@app.post("/api/v1/memories/procedural")
async def add_procedural_memory(req: ProceduralMemoryRequest):
"""Store a conversation trace as procedural memory.
Accepts a list of messages (role/content dicts) representing a full
conversation turn including tool calls, then uses mem0's native
procedural memory extraction to learn reusable "how to" patterns.
"""
# Build metadata with procedural type marker
meta = {"type": "procedural"}
if req.metadata:
meta.update(req.metadata)
# Use mem0's native message list support + procedural prompt
result = await asyncio.to_thread(m.add,
req.messages,
user_id=req.user_id,
metadata=meta,
prompt=PROCEDURAL_EXTRACTION_PROMPT,
)
return {"id": str(uuid.uuid4()), "status": "ok", "result": result}
def _parse_mem0_results(raw_results) -> list:
raw = raw_results.get("results", raw_results) if isinstance(raw_results, dict) else raw_results
items = []
for r in raw:
item = r if isinstance(r, dict) else {"memory": str(r)}
items.append({
"id": item.get("id", str(uuid.uuid4())),
"memory": item.get("memory", item.get("text", "")),
"created_at": item.get("created_at", datetime.now(timezone.utc).isoformat()),
"metadata_": item.get("metadata", {}),
})
return items
def _parse_iso_timestamp(value: str) -> Optional[datetime]:
"""Parse an ISO 8601 timestamp string, returning None on failure."""
try:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _item_created_at(item: dict) -> Optional[datetime]:
"""Extract created_at from an item as a timezone-aware datetime."""
raw = item.get("created_at")
if raw is None:
return None
if isinstance(raw, datetime):
if raw.tzinfo is None:
raw = raw.replace(tzinfo=timezone.utc)
return raw
return _parse_iso_timestamp(str(raw))
def _apply_post_filters(
items: list,
created_after: Optional[str],
created_before: Optional[str],
) -> list:
"""Filter items by created_after / created_before timestamps (post-query)."""
after_dt = _parse_iso_timestamp(created_after) if created_after else None
before_dt = _parse_iso_timestamp(created_before) if created_before else None
if after_dt is None and before_dt is None:
return items
filtered = []
for item in items:
ts = _item_created_at(item)
if ts is None:
# Keep items without a parseable timestamp
filtered.append(item)
continue
if after_dt and ts < after_dt:
continue
if before_dt and ts > before_dt:
continue
filtered.append(item)
return filtered
@app.get("/api/v1/memories/")
async def list_or_search_memories(
user_id: str = Query(...),
search_query: Optional[str] = Query(None),
size: int = Query(10),
rerank: bool = Query(True),
created_after: Optional[str] = Query(None),
created_before: Optional[str] = Query(None),
metadata_filter: Optional[str] = Query(None),
):
# Build mem0 SDK filters dict from metadata_filter JSON param
sdk_filters = None
if metadata_filter:
try:
sdk_filters = json.loads(metadata_filter)
except json.JSONDecodeError:
sdk_filters = None
if search_query:
# Fetch more results than needed so reranker has candidates to work with
fetch_size = min(size * 3, 50)
results = await asyncio.to_thread(m.search,
search_query,
user_id=user_id,
limit=fetch_size,
filters=sdk_filters,
)
items = _parse_mem0_results(results)
items = _apply_post_filters(items, created_after, created_before)
if rerank and items:
items = rerank_results(search_query, items, top_k=size)
else:
items = items[:size]
return {"items": items, "total": len(items)}
else:
results = await asyncio.to_thread(m.get_all,user_id=user_id, filters=sdk_filters)
items = _parse_mem0_results(results)
items = _apply_post_filters(items, created_after, created_before)
return {"items": items, "total": len(items)}
@app.delete("/api/v1/memories/{memory_id}")
async def delete_memory(memory_id: str):
try:
await asyncio.to_thread(m.delete, memory_id)
except Exception:
pass
return {"status": "ok"}
@app.get("/api/v1/memories/{memory_id}/history")
async def get_memory_history(memory_id: str):
"""Return the edit history of a specific memory."""
try:
history = await asyncio.to_thread(m.history, memory_id)
# Normalize to list of dicts
entries = []
raw = history if isinstance(history, list) else history.get("results", history) if isinstance(history, dict) else [history]
for h in raw:
entry = h if isinstance(h, dict) else {"event": str(h)}
entries.append(entry)
return {"memory_id": memory_id, "history": entries}
except Exception as e:
return {"memory_id": memory_id, "history": [], "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8765)

View File

@ -0,0 +1,50 @@
from flask import Flask, request, jsonify
from FlagEmbedding import FlagReranker
import os, torch
app = Flask(__name__)
reranker = None
# ── Configurable via environment variables ─────────────────────────
RERANKER_MODEL = os.environ.get("RERANKER_MODEL", "BAAI/bge-reranker-v2-m3")
RERANKER_DEVICE = os.environ.get("RERANKER_DEVICE", "cuda" if torch.cuda.is_available() else "cpu")
RERANKER_PORT = int(os.environ.get("RERANKER_PORT", "8678"))
def get_reranker():
global reranker
if reranker is None:
reranker = FlagReranker(RERANKER_MODEL, use_fp16=True, device=RERANKER_DEVICE)
return reranker
@app.route('/rerank', methods=['POST'])
def rerank():
data = request.json
query = data.get('query', '')
documents = data.get('documents', [])
top_k = data.get('top_k', len(documents))
if not query or not documents:
return jsonify({'error': 'query and documents required'}), 400
pairs = [[query, doc] for doc in documents]
scores = get_reranker().compute_score(pairs)
if isinstance(scores, float):
scores = [scores]
results = sorted(
[{'index': i, 'document': doc, 'score': score}
for i, (doc, score) in enumerate(zip(documents, scores))],
key=lambda x: x['score'], reverse=True
)[:top_k]
return jsonify({'results': results})
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok', 'model': RERANKER_MODEL, 'device': RERANKER_DEVICE})
if __name__ == '__main__':
print(f'Loading reranker model ({RERANKER_MODEL}) on {RERANKER_DEVICE}...')
get_reranker()
print(f'Reranker server ready on :{RERANKER_PORT}')
app.run(host='0.0.0.0', port=RERANKER_PORT)

View File

@ -10,22 +10,3 @@ default_temperature = 0.7
port = 42617
host = "[::]"
allow_public_bind = true
# Cost tracking and budget enforcement configuration
# Enable to track API usage costs and enforce spending limits
[cost]
enabled = false
daily_limit_usd = 10.0
monthly_limit_usd = 100.0
warn_at_percent = 80
allow_override = false
# Per-model pricing (USD per 1M tokens)
# Uncomment and customize to override default pricing
# [cost.prices."anthropic/claude-sonnet-4-20250514"]
# input = 3.0
# output = 15.0
#
# [cost.prices."openai/gpt-4o"]
# input = 5.0
# output = 15.0

4
dist/aur/.SRCINFO vendored
View File

@ -1,6 +1,6 @@
pkgbase = zeroclaw
pkgdesc = Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.
pkgver = 0.5.9
pkgver = 0.5.4
pkgrel = 1
url = https://github.com/zeroclaw-labs/zeroclaw
arch = x86_64
@ -10,7 +10,7 @@ pkgbase = zeroclaw
makedepends = git
depends = gcc-libs
depends = openssl
source = zeroclaw-0.5.9.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v0.5.9.tar.gz
source = zeroclaw-0.5.4.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v0.5.4.tar.gz
sha256sums = SKIP
pkgname = zeroclaw

2
dist/aur/PKGBUILD vendored
View File

@ -1,6 +1,6 @@
# Maintainer: zeroclaw-labs <bot@zeroclaw.dev>
pkgname=zeroclaw
pkgver=0.5.9
pkgver=0.5.4
pkgrel=1
pkgdesc="Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant."
arch=('x86_64')

View File

@ -1,11 +1,11 @@
{
"version": "0.5.9",
"version": "0.5.4",
"description": "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.",
"homepage": "https://github.com/zeroclaw-labs/zeroclaw",
"license": "MIT|Apache-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.5.9/zeroclaw-x86_64-pc-windows-msvc.zip",
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.5.4/zeroclaw-x86_64-pc-windows-msvc.zip",
"hash": "",
"bin": "zeroclaw.exe"
}

View File

@ -1,202 +0,0 @@
# ADR-004: Tool Shared State Ownership Contract
**Status:** Accepted
**Date:** 2026-03-22
**Issue:** [#4057](https://github.com/zeroclaw/zeroclaw/issues/4057)
## Context
ZeroClaw tools execute in a multi-client environment where a single daemon
process serves requests from multiple connected clients simultaneously. Several
tools already maintain long-lived shared state:
- **`DelegateParentToolsHandle`** (`src/tools/mod.rs`):
`Arc<RwLock<Vec<Arc<dyn Tool>>>>` — holds parent tools for delegate agents
with no per-client isolation.
- **`ChannelMapHandle`** (`src/tools/reaction.rs`):
`Arc<RwLock<HashMap<String, Arc<dyn Channel>>>>` — global channel map shared
across all clients.
- **`CanvasStore`** (`src/tools/canvas.rs`):
`Arc<RwLock<HashMap<String, CanvasEntry>>>` — canvas IDs are plain strings
with no client namespace.
These patterns emerged organically. As the tool surface grows and more clients
connect concurrently, we need a clear contract governing ownership, identity,
isolation, lifecycle, and reload behavior for tool-held shared state. Without
this contract, new tools risk introducing data leaks between clients, stale
state after config reloads, or inconsistent initialization timing.
Additional context:
- The tool registry is immutable after startup, built once in
`all_tools_with_runtime()`.
- Client identity is currently derived from IP address only
(`src/gateway/mod.rs`), which is insufficient for reliable namespacing.
- `SecurityPolicy` is scoped per agent, not per client.
- `WorkspaceManager` provides some isolation but workspace switching is global.
## Decision
### 1. Ownership: May tools own long-lived shared state?
**Yes.** Tools MAY own long-lived shared state, provided they follow the
established **handle pattern**: wrap the state in `Arc<RwLock<T>>` (or
`Arc<parking_lot::RwLock<T>>`) and expose a cloneable handle type.
This pattern is already proven by three independent implementations:
| Handle | Location | Inner type |
|--------|----------|-----------|
| `DelegateParentToolsHandle` | `src/tools/mod.rs` | `Vec<Arc<dyn Tool>>` |
| `ChannelMapHandle` | `src/tools/reaction.rs` | `HashMap<String, Arc<dyn Channel>>` |
| `CanvasStore` | `src/tools/canvas.rs` | `HashMap<String, CanvasEntry>` |
Tools that need shared state MUST:
- Define a named handle type alias (e.g., `pub type FooHandle = Arc<RwLock<T>>`).
- Accept the handle at construction time rather than creating global state.
- Document the concurrency contract in the handle type's doc comment.
Tools MUST NOT use static mutable state (`lazy_static!`, `OnceCell` with
interior mutability) for per-request or per-client data.
### 2. Identity assignment: Who constructs identity keys?
**The daemon SHOULD provide identity.** Tools MUST NOT construct their own
client identity keys.
A new `ClientId` type should be introduced (opaque, `Clone + Eq + Hash + Send + Sync`)
that the daemon assigns at connection time. This replaces the current approach
of using raw IP addresses (`src/gateway/mod.rs:259-306`), which breaks when
multiple clients share a NAT address or when proxied connections arrive.
`ClientId` is passed to tools that require per-client state namespacing as part
of the tool execution context. Tools that do not need per-client isolation
(e.g., the immutable tool registry) may ignore it.
The `ClientId` contract:
- Generated by the gateway layer at connection establishment.
- Opaque to tools — tools must not parse or derive meaning from the value.
- Stable for the lifetime of a single client session.
- Passed through the execution context, not stored globally.
### 3. Lifecycle: When may tools run startup-style validation?
**Validation runs once at first registration, and again when config changes
are detected.**
The lifecycle phases are:
1. **Construction** — tool is instantiated with handles and config. No I/O or
validation occurs here.
2. **Registration** — tool is registered in the tool registry via
`all_tools_with_runtime()`. At this point the tool MAY perform one-time
startup validation (e.g., checking that required credentials exist, verifying
external service connectivity).
3. **Execution** — tool handles individual requests. No re-validation unless
the config-change signal fires (see Reload Semantics below).
4. **Shutdown** — daemon is stopping. Tools with open resources SHOULD clean up
gracefully via `Drop` or an explicit shutdown method.
Tools MUST NOT perform blocking validation during execution-phase calls.
Validation results SHOULD be cached in the tool's handle state and checked
via a fast path during execution.
### 4. Isolation: What must be isolated per client?
State falls into two categories with different isolation requirements:
**MUST be isolated per client:**
- Security-sensitive state: credentials, API keys, quotas, rate-limit counters,
per-client authorization decisions.
- User-specific session data: conversation context, user preferences,
workspace-scoped file paths.
Isolation mechanism: tools holding per-client state MUST key their internal
maps by `ClientId`. The handle pattern naturally supports this by using
`HashMap<ClientId, T>` inside the `RwLock`.
**MAY be shared across clients (with namespace prefixing):**
- Broadcast/display state: canvas frames (`CanvasStore`), notification channels
(`ChannelMapHandle`).
- Read-only reference data: tool registry, static configuration, model
metadata.
When shared state uses string keys (e.g., canvas IDs, channel names), tools
SHOULD support optional namespace prefixing (e.g., `{client_id}:{canvas_name}`)
to allow per-client isolation when needed without mandating it for broadcast
use cases.
Tools MUST NOT store per-client secrets in shared (non-isolated) state
structures.
### 5. Reload semantics: What invalidates prior shared state on config change?
**Config changes detected via hash comparison MUST invalidate cached
validation state.**
The reload contract:
- The daemon computes a hash of the tool-relevant config section at startup and
after each config reload event.
- When the hash changes, the daemon signals affected tools to re-run their
registration-phase validation.
- Tools MUST treat their cached validation result as stale when signaled and
re-validate before the next execution.
Specific invalidation rules:
| Config change | Invalidation scope |
|--------------|-------------------|
| Credential/secret rotation | Per-tool validation cache; per-client credential state |
| Tool enable/disable | Full tool registry rebuild via `all_tools_with_runtime()` |
| Security policy change | `SecurityPolicy` re-derivation; per-agent policy state |
| Workspace directory change | `WorkspaceManager` state; file-path-dependent tool state |
| Provider config change | Provider-dependent tools re-validate connectivity |
Tools MAY retain non-security shared state (e.g., canvas content, channel
subscriptions) across config reloads unless the reload explicitly affects that
state's validity.
## Consequences
### Positive
- **Consistency:** All new tools follow the same handle pattern, making shared
state discoverable and auditable.
- **Safety:** Per-client isolation of security-sensitive state prevents data
leaks in multi-tenant scenarios.
- **Clarity:** Explicit lifecycle phases eliminate ambiguity about when
validation runs.
- **Evolvability:** The `ClientId` abstraction decouples tools from transport
details, supporting future identity mechanisms (tokens, certificates).
### Negative
- **Migration cost:** Existing tools (`CanvasStore`, `ReactionTool`) may need
refactoring to accept `ClientId` and namespace their state.
- **Complexity:** Tools that were simple singletons now need to consider
multi-client semantics even if they currently have one client.
- **Performance:** Per-client keying adds a hash lookup on each access, though
this is negligible compared to I/O costs.
### Neutral
- The tool registry remains immutable after startup; this ADR does not change
that invariant.
- `SecurityPolicy` remains per-agent; this ADR documents that client isolation
is orthogonal to agent-level policy.
## References
- `src/tools/mod.rs``DelegateParentToolsHandle`, `all_tools_with_runtime()`
- `src/tools/reaction.rs``ChannelMapHandle`, `ReactionTool`
- `src/tools/canvas.rs``CanvasStore`, `CanvasEntry`
- `src/tools/traits.rs``Tool` trait
- `src/gateway/mod.rs` — client IP extraction (`forwarded_client_ip`, `resolve_client_ip`)
- `src/security/``SecurityPolicy`

View File

@ -1,215 +0,0 @@
# Browser Automation Setup Guide
This guide covers setting up browser automation capabilities in ZeroClaw, including both headless automation and GUI access via VNC.
## Overview
ZeroClaw supports multiple browser access methods:
| Method | Use Case | Requirements |
|--------|----------|--------------|
| **agent-browser CLI** | Headless automation, AI agents | npm, Chrome |
| **VNC + noVNC** | GUI access, debugging | Xvfb, x11vnc, noVNC |
| **Chrome Remote Desktop** | Remote GUI via Google | XFCE, Google account |
## Quick Start: Headless Automation
### 1. Install agent-browser
```bash
# Install CLI
npm install -g agent-browser
# Download Chrome for Testing
agent-browser install --with-deps # Linux (includes system deps)
agent-browser install # macOS/Windows
```
### 2. Verify ZeroClaw Config
The browser tool is enabled by default. To verify or customize, edit
`~/.zeroclaw/config.toml`:
```toml
[browser]
enabled = true # default: true
allowed_domains = ["*"] # default: ["*"] (all public hosts)
backend = "agent_browser" # default: "agent_browser"
native_headless = true # default: true
```
To restrict domains or disable the browser tool:
```toml
[browser]
enabled = false # disable entirely
# or restrict to specific domains:
allowed_domains = ["example.com", "docs.example.com"]
```
### 3. Test
```bash
echo "Open https://example.com and tell me what it says" | zeroclaw agent
```
## VNC Setup (GUI Access)
For debugging or when you need visual browser access:
### Install Dependencies
```bash
# Ubuntu/Debian
apt-get install -y xvfb x11vnc fluxbox novnc websockify
# Optional: Desktop environment for Chrome Remote Desktop
apt-get install -y xfce4 xfce4-goodies
```
### Start VNC Server
```bash
#!/bin/bash
# Start virtual display with VNC access
DISPLAY_NUM=99
VNC_PORT=5900
NOVNC_PORT=6080
RESOLUTION=1920x1080x24
# Start Xvfb
Xvfb :$DISPLAY_NUM -screen 0 $RESOLUTION -ac &
sleep 1
# Start window manager
fluxbox -display :$DISPLAY_NUM &
sleep 1
# Start x11vnc
x11vnc -display :$DISPLAY_NUM -rfbport $VNC_PORT -forever -shared -nopw -bg
sleep 1
# Start noVNC (web-based VNC)
websockify --web=/usr/share/novnc $NOVNC_PORT localhost:$VNC_PORT &
echo "VNC available at:"
echo " VNC Client: localhost:$VNC_PORT"
echo " Web Browser: http://localhost:$NOVNC_PORT/vnc.html"
```
### VNC Access
- **VNC Client**: Connect to `localhost:5900`
- **Web Browser**: Open `http://localhost:6080/vnc.html`
### Start Browser on VNC Display
```bash
DISPLAY=:99 google-chrome --no-sandbox https://example.com &
```
## Chrome Remote Desktop
### Install
```bash
# Download and install
wget https://dl.google.com/linux/direct/chrome-remote-desktop_current_amd64.deb
apt-get install -y ./chrome-remote-desktop_current_amd64.deb
# Configure session
echo "xfce4-session" > ~/.chrome-remote-desktop-session
chmod +x ~/.chrome-remote-desktop-session
```
### Setup
1. Visit <https://remotedesktop.google.com/headless>
2. Copy the "Debian Linux" setup command
3. Run it on your server
4. Start the service: `systemctl --user start chrome-remote-desktop`
### Remote Access
Go to <https://remotedesktop.google.com/access> from any device.
## Testing
### CLI Tests
```bash
# Basic open and close
agent-browser open https://example.com
agent-browser get title
agent-browser close
# Snapshot with refs
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser close
# Screenshot
agent-browser open https://example.com
agent-browser screenshot /tmp/test.png
agent-browser close
```
### ZeroClaw Integration Tests
```bash
# Content extraction
echo "Open https://example.com and summarize it" | zeroclaw agent
# Navigation
echo "Go to https://github.com/trending and list the top 3 repos" | zeroclaw agent
# Form interaction
echo "Go to Wikipedia, search for 'Rust programming language', and summarize" | zeroclaw agent
```
## Troubleshooting
### "Element not found"
The page may not be fully loaded. Add a wait:
```bash
agent-browser open https://slow-site.com
agent-browser wait --load networkidle
agent-browser snapshot -i
```
### Cookie dialogs blocking access
Handle cookie consent first:
```bash
agent-browser open https://site-with-cookies.com
agent-browser snapshot -i
agent-browser click @accept_cookies # Click the accept button
agent-browser snapshot -i # Now get the actual content
```
### Docker sandbox network restrictions
If `web_fetch` fails inside Docker sandbox, use agent-browser instead:
```bash
# Instead of web_fetch, use:
agent-browser open https://example.com
agent-browser get text body
```
## Security Notes
- `agent-browser` runs Chrome in headless mode with sandboxing
- For sensitive sites, use `--session-name` to persist auth state
- The `--allowed-domains` config restricts navigation to specific domains
- VNC ports (5900, 6080) should be behind a firewall or Tailscale
## Related
- [agent-browser Documentation](https://github.com/vercel-labs/agent-browser)
- [ZeroClaw Configuration Reference](./config-reference.md)
- [Skills Documentation](../skills/)

View File

@ -20,7 +20,6 @@ Selected allowlist (all actions currently used across Quality Gate, Release Beta
| `docker/setup-buildx-action@v3` | release, promote-release | Docker Buildx setup |
| `docker/login-action@v3` | release, promote-release | GHCR authentication |
| `docker/build-push-action@v6` | release, promote-release | Multi-platform Docker image build and push |
| `actions/labeler@v5` | pr-path-labeler | Apply path/scope labels from `labeler.yml` |
Equivalent allowlist patterns:
@ -37,7 +36,6 @@ Equivalent allowlist patterns:
| Quality Gate | `.github/workflows/checks-on-pr.yml` | Pull requests to `master` |
| Release Beta | `.github/workflows/release-beta-on-push.yml` | Push to `master` |
| Release Stable | `.github/workflows/release-stable-manual.yml` | Manual `workflow_dispatch` |
| PR Path Labeler | `.github/workflows/pr-path-labeler.yml` | `pull_request_target` (opened, synchronize, reopened) |
## Change Control
@ -64,7 +62,6 @@ gh api repos/zeroclaw-labs/zeroclaw/actions/permissions/selected-actions
## Change Log
- 2026-03-23: Added PR Path Labeler (`pr-path-labeler.yml`) using `actions/labeler@v5`. No allowlist change needed — covered by existing `actions/*` pattern.
- 2026-03-10: Renamed workflows — CI → Quality Gate (`checks-on-pr.yml`), Beta Release → Release Beta (`release-beta-on-push.yml`), Promote Release → Release Stable (`release-stable-manual.yml`). Added `lint` and `security` jobs to Quality Gate. Added Cross-Platform Build (`cross-platform-build-manual.yml`).
- 2026-03-05: Complete workflow overhaul — replaced 22 workflows with 3 (CI, Beta Release, Promote Release)
- Removed patterns no longer in use: `DavidAnson/markdownlint-cli2-action@*`, `lycheeverse/lychee-action@*`, `EmbarkStudios/cargo-deny-action@*`, `rustsec/audit-check@*`, `rhysd/actionlint@*`, `sigstore/cosign-installer@*`, `Checkmarx/vorpal-reviewdog-github-action@*`, `useblacksmith/*`

View File

@ -45,15 +45,6 @@ For complete code examples of each extension trait, see [extension-examples.md](
- Keep multilingual entry-point parity for all supported locales (`en`, `zh-CN`, `ja`, `ru`, `fr`, `vi`) when nav or key wording changes.
- When shared docs wording changes, sync corresponding localized docs in the same PR (or explicitly document deferral and follow-up PR).
## Tool Shared State
- Follow the `Arc<RwLock<T>>` handle pattern for any tool that owns long-lived shared state.
- Accept handles at construction; do not create global/static mutable state.
- Use `ClientId` (provided by the daemon) to namespace per-client state — never construct identity keys inside the tool.
- Isolate security-sensitive state (credentials, quotas) per client; broadcast/display state may be shared with optional namespace prefixing.
- Cached validation is invalidated on config change — tools must re-validate before the next execution when signaled.
- See [ADR-004: Tool Shared State Ownership](../architecture/adr-004-tool-shared-state-ownership.md) for the full contract.
## Architecture Boundary Rules
- Extend capabilities by adding trait implementations + factory wiring first; avoid cross-module rewrites for isolated features.

View File

@ -1,213 +0,0 @@
# Label Registry
Single reference for every label used on PRs and issues. Labels are grouped by category. Each entry lists the label name, definition, and how it is applied.
Sources consolidated here:
- `.github/labeler.yml` (path-label config for `actions/labeler`)
- `.github/label-policy.json` (contributor tier thresholds)
- `docs/contributing/pr-workflow.md` (size, risk, and triage label definitions)
- `docs/contributing/ci-map.md` (automation behavior and high-risk path heuristics)
Note: The CI was simplified to 4 workflows (`ci.yml`, `release.yml`, `ci-full.yml`, `promote-release.yml`). Workflows that previously automated size, risk, contributor tier, and triage labels (`pr-labeler.yml`, `pr-auto-response.yml`, `pr-check-stale.yml`, and supporting scripts) were removed. Only path labels via `pr-path-labeler.yml` are currently automated.
---
## Path labels
Applied automatically by `pr-path-labeler.yml` using `actions/labeler`. Matches changed files against glob patterns in `.github/labeler.yml`.
### Base scope labels
| Label | Matches |
|---|---|
| `docs` | `docs/**`, `**/*.md`, `**/*.mdx`, `LICENSE`, `.markdownlint-cli2.yaml` |
| `dependencies` | `Cargo.toml`, `Cargo.lock`, `deny.toml`, `.github/dependabot.yml` |
| `ci` | `.github/**`, `.githooks/**` |
| `core` | `src/*.rs` |
| `agent` | `src/agent/**` |
| `channel` | `src/channels/**` |
| `gateway` | `src/gateway/**` |
| `config` | `src/config/**` |
| `cron` | `src/cron/**` |
| `daemon` | `src/daemon/**` |
| `doctor` | `src/doctor/**` |
| `health` | `src/health/**` |
| `heartbeat` | `src/heartbeat/**` |
| `integration` | `src/integrations/**` |
| `memory` | `src/memory/**` |
| `security` | `src/security/**` |
| `runtime` | `src/runtime/**` |
| `onboard` | `src/onboard/**` |
| `provider` | `src/providers/**` |
| `service` | `src/service/**` |
| `skillforge` | `src/skillforge/**` |
| `skills` | `src/skills/**` |
| `tool` | `src/tools/**` |
| `tunnel` | `src/tunnel/**` |
| `observability` | `src/observability/**` |
| `tests` | `tests/**` |
| `scripts` | `scripts/**` |
| `dev` | `dev/**` |
### Per-component channel labels
Each channel gets a specific label in addition to the base `channel` label.
| Label | Matches |
|---|---|
| `channel:bluesky` | `bluesky.rs` |
| `channel:clawdtalk` | `clawdtalk.rs` |
| `channel:cli` | `cli.rs` |
| `channel:dingtalk` | `dingtalk.rs` |
| `channel:discord` | `discord.rs`, `discord_history.rs` |
| `channel:email` | `email_channel.rs`, `gmail_push.rs` |
| `channel:imessage` | `imessage.rs` |
| `channel:irc` | `irc.rs` |
| `channel:lark` | `lark.rs` |
| `channel:linq` | `linq.rs` |
| `channel:matrix` | `matrix.rs` |
| `channel:mattermost` | `mattermost.rs` |
| `channel:mochat` | `mochat.rs` |
| `channel:mqtt` | `mqtt.rs` |
| `channel:nextcloud-talk` | `nextcloud_talk.rs` |
| `channel:nostr` | `nostr.rs` |
| `channel:notion` | `notion.rs` |
| `channel:qq` | `qq.rs` |
| `channel:reddit` | `reddit.rs` |
| `channel:signal` | `signal.rs` |
| `channel:slack` | `slack.rs` |
| `channel:telegram` | `telegram.rs` |
| `channel:twitter` | `twitter.rs` |
| `channel:wati` | `wati.rs` |
| `channel:webhook` | `webhook.rs` |
| `channel:wecom` | `wecom.rs` |
| `channel:whatsapp` | `whatsapp.rs`, `whatsapp_storage.rs`, `whatsapp_web.rs` |
### Per-component provider labels
| Label | Matches |
|---|---|
| `provider:anthropic` | `anthropic.rs` |
| `provider:azure-openai` | `azure_openai.rs` |
| `provider:bedrock` | `bedrock.rs` |
| `provider:claude-code` | `claude_code.rs` |
| `provider:compatible` | `compatible.rs` |
| `provider:copilot` | `copilot.rs` |
| `provider:gemini` | `gemini.rs`, `gemini_cli.rs` |
| `provider:glm` | `glm.rs` |
| `provider:kilocli` | `kilocli.rs` |
| `provider:ollama` | `ollama.rs` |
| `provider:openai` | `openai.rs`, `openai_codex.rs` |
| `provider:openrouter` | `openrouter.rs` |
| `provider:telnyx` | `telnyx.rs` |
### Per-group tool labels
Tools are grouped by logical function rather than one label per file.
| Label | Matches |
|---|---|
| `tool:browser` | `browser.rs`, `browser_delegate.rs`, `browser_open.rs`, `text_browser.rs`, `screenshot.rs` |
| `tool:cloud` | `cloud_ops.rs`, `cloud_patterns.rs` |
| `tool:composio` | `composio.rs` |
| `tool:cron` | `cron_add.rs`, `cron_list.rs`, `cron_remove.rs`, `cron_run.rs`, `cron_runs.rs`, `cron_update.rs` |
| `tool:file` | `file_edit.rs`, `file_read.rs`, `file_write.rs`, `glob_search.rs`, `content_search.rs` |
| `tool:google-workspace` | `google_workspace.rs` |
| `tool:mcp` | `mcp_client.rs`, `mcp_deferred.rs`, `mcp_protocol.rs`, `mcp_tool.rs`, `mcp_transport.rs` |
| `tool:memory` | `memory_forget.rs`, `memory_recall.rs`, `memory_store.rs` |
| `tool:microsoft365` | `microsoft365/**` |
| `tool:security` | `security_ops.rs`, `verifiable_intent.rs` |
| `tool:shell` | `shell.rs`, `node_tool.rs`, `cli_discovery.rs` |
| `tool:sop` | `sop_advance.rs`, `sop_approve.rs`, `sop_execute.rs`, `sop_list.rs`, `sop_status.rs` |
| `tool:web` | `web_fetch.rs`, `web_search_tool.rs`, `web_search_provider_routing.rs`, `http_request.rs` |
---
## Size labels
Defined in `pr-workflow.md` §6.1. Based on effective changed line count, normalized for docs-only and lockfile-heavy PRs.
| Label | Threshold |
|---|---|
| `size: XS` | <= 80 lines |
| `size: S` | <= 250 lines |
| `size: M` | <= 500 lines |
| `size: L` | <= 1000 lines |
| `size: XL` | > 1000 lines |
**Applied by:** manual. The workflows that previously computed size labels (`pr-labeler.yml` and supporting scripts) were removed during CI simplification.
---
## Risk labels
Defined in `pr-workflow.md` §13.2 and `ci-map.md`. Based on a heuristic combining touched paths and change size.
| Label | Meaning |
|---|---|
| `risk: low` | No high-risk paths touched, small change |
| `risk: medium` | Behavioral `src/**` changes without boundary/security impact |
| `risk: high` | Touches high-risk paths (see below) or large security-adjacent change |
| `risk: manual` | Maintainer override that freezes automated risk recalculation |
High-risk paths: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`.
The boundary between low and medium is not formally defined beyond "no high-risk paths."
**Applied by:** manual. Previously automated via `pr-labeler.yml`; removed during CI simplification.
---
## Contributor tier labels
Defined in `.github/label-policy.json`. Based on the author's merged PR count queried from the GitHub API.
| Label | Minimum merged PRs |
|---|---|
| `trusted contributor` | 5 |
| `experienced contributor` | 10 |
| `principal contributor` | 20 |
| `distinguished contributor` | 50 |
**Applied by:** manual. Previously automated via `pr-labeler.yml` and `pr-auto-response.yml`; removed during CI simplification.
---
## Response and triage labels
Defined in `pr-workflow.md` §8. Applied manually.
| Label | Purpose | Applied by |
|---|---|---|
| `r:needs-repro` | Incomplete bug report; request deterministic repro | Manual |
| `r:support` | Usage/help item better handled outside bug backlog | Manual |
| `invalid` | Not a valid bug/feature request | Manual |
| `duplicate` | Duplicate of existing issue | Manual |
| `stale-candidate` | Dormant PR/issue; candidate for closing | Manual |
| `superseded` | Replaced by a newer PR | Manual |
| `no-stale` | Exempt from stale automation; accepted but blocked work | Manual |
**Automation:** none currently. The workflows that handled label-driven issue closing (`pr-auto-response.yml`) and stale detection (`pr-check-stale.yml`) were removed during CI simplification.
---
## Implementation status
| Category | Count | Automated | Workflow |
|---|---|---|---|
| Path (base scope) | 27 | Yes | `pr-path-labeler.yml` |
| Path (per-component) | 52 | Yes | `pr-path-labeler.yml` |
| Size | 5 | No | Manual |
| Risk | 4 | No | Manual |
| Contributor tier | 4 | No | Manual |
| Response/triage | 7 | No | Manual |
| **Total** | **99** | | |
---
## Maintenance
- **Owner:** maintainers responsible for label policy and PR triage automation.
- **Update trigger:** new channels, providers, or tools added to the source tree; label policy changes; triage workflow changes.
- **Source of truth:** this document consolidates definitions from the four source files listed at the top. When definitions conflict, update the source file first, then sync this registry.

View File

@ -411,6 +411,30 @@ allowed_roots = [\"~/Desktop/projects\", \"/opt/shared-repo\"]
- 内存上下文注入忽略旧的 `assistant_resp*` 自动保存键,以防止旧模型生成的摘要被视为事实。
### `[memory.mem0]`
Mem0 (OpenMemory) 后端 — 连接自托管 mem0 服务器,提供基于向量的记忆存储和 LLM 事实提取。构建时需要 `memory-mem0` feature flag配置需设置 `backend = "mem0"`
| 键 | 默认值 | 环境变量 | 用途 |
|---|---|---|---|
| `url` | `http://localhost:8765` | `MEM0_URL` | OpenMemory 服务器地址 |
| `user_id` | `zeroclaw` | `MEM0_USER_ID` | 记忆作用域的用户 ID |
| `app_name` | `zeroclaw` | `MEM0_APP_NAME` | 在 mem0 中注册的应用名称 |
| `infer` | `true` | — | 使用 LLM 从存储文本中提取事实 (`true`) 或原样存储 (`false`) |
| `extraction_prompt` | 未设置 | `MEM0_EXTRACTION_PROMPT` | 自定义 LLM 事实提取提示词(如适用于非英文内容) |
```toml
[memory]
backend = "mem0"
[memory.mem0]
url = "http://192.168.0.171:8765"
user_id = "zeroclaw-bot"
extraction_prompt = "用原始语言提取事实..."
```
服务器部署脚本位于 `deploy/mem0/`
## `[[model_routes]]``[[embedding_routes]]`
使用路由提示,以便集成可以在模型 ID 演变时保持稳定的名称。

View File

@ -12,6 +12,8 @@ SOP 审计条目通过 `SopAuditLogger` 持久化到配置的内存后端的 `so
- `sop_step_{run_id}_{step_number}`:单步结果
- `sop_approval_{run_id}_{step_number}`:操作员审批记录
- `sop_timeout_approve_{run_id}_{step_number}`:超时自动审批记录
- `sop_gate_decision_{gate_id}_{timestamp_ms}`:门评估器决策记录(启用 `ampersona-gates` 时)
- `sop_phase_state`:持久化的信任阶段状态快照(启用 `ampersona-gates` 时)
## 2. 检查路径

View File

@ -122,34 +122,6 @@ tools = ["mcp_browser_*"]
keywords = ["browse", "navigate", "open url", "screenshot"]
```
## `[pacing]`
Pacing controls for slow/local LLM workloads (Ollama, llama.cpp, vLLM). All keys are optional; when absent, existing behavior is preserved.
| Key | Default | Purpose |
|---|---|---|
| `step_timeout_secs` | _none_ | Per-step timeout: maximum seconds for a single LLM inference turn. Catches a truly hung model without terminating the overall task loop |
| `loop_detection_min_elapsed_secs` | _none_ | Minimum elapsed seconds before loop detection activates. Tasks completing under this threshold get aggressive loop protection; longer-running tasks receive a grace period |
| `loop_ignore_tools` | `[]` | Tool names excluded from identical-output loop detection. Useful for browser workflows where `browser_screenshot` structurally resembles a loop |
| `message_timeout_scale_max` | `4` | Override for the hardcoded timeout scaling cap. The channel message timeout budget is `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)` |
Notes:
- These settings are intended for local/slow LLM deployments. Cloud-provider users typically do not need them.
- `step_timeout_secs` operates independently of the total channel message timeout budget. A step timeout abort does not consume the overall budget; the loop simply stops.
- `loop_detection_min_elapsed_secs` delays loop-detection counting, not the task itself. Loop protection remains fully active for short tasks (the default).
- `loop_ignore_tools` only suppresses tool-output-based loop detection for the listed tools. Other safety features (max iterations, overall timeout) remain active.
- `message_timeout_scale_max` must be >= 1. Setting it higher than `max_tool_iterations` has no additional effect (the formula uses `min()`).
- Example configuration for a slow local Ollama deployment:
```toml
[pacing]
step_timeout_secs = 120
loop_detection_min_elapsed_secs = 60
loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
message_timeout_scale_max = 8
```
## `[security.otp]`
| Key | Default | Purpose |
@ -213,15 +185,12 @@ Delegate sub-agent configurations. Each key under `[agents]` defines a named sub
| `max_iterations` | `10` | Max tool-call iterations for agentic mode |
| `timeout_secs` | `120` | Timeout in seconds for non-agentic provider calls (13600) |
| `agentic_timeout_secs` | `300` | Timeout in seconds for agentic sub-agent loops (13600) |
| `skills_directory` | unset | Optional skills directory path (workspace-relative) for scoped skill loading |
Notes:
- `agentic = false` preserves existing single prompt→response delegate behavior.
- `agentic = true` requires at least one matching entry in `allowed_tools`.
- The `delegate` tool is excluded from sub-agent allowlists to prevent re-entrant delegation loops.
- Sub-agents receive an enriched system prompt containing: tools section (allowed tools with parameters), skills section (from scoped or default directory), workspace path, current date/time, safety constraints, and shell policy when `shell` is in the effective tool list.
- When `skills_directory` is unset or empty, the sub-agent loads skills from the default workspace `skills/` directory. When set, skills are loaded exclusively from that directory (relative to workspace root), enabling per-agent scoped skill sets.
```toml
[agents.researcher]
@ -239,14 +208,6 @@ provider = "ollama"
model = "qwen2.5-coder:32b"
temperature = 0.2
timeout_secs = 60
[agents.code_reviewer]
provider = "anthropic"
model = "claude-opus-4-5"
system_prompt = "You are an expert code reviewer focused on security and performance."
agentic = true
allowed_tools = ["file_read", "shell"]
skills_directory = "skills/code-review"
```
## `[runtime]`
@ -453,12 +414,6 @@ Notes:
| `port` | `42617` | gateway listen port |
| `require_pairing` | `true` | require pairing before bearer auth |
| `allow_public_bind` | `false` | block accidental public exposure |
| `path_prefix` | _(none)_ | URL path prefix for reverse-proxy deployments (e.g. `"/zeroclaw"`) |
When deploying behind a reverse proxy that maps ZeroClaw to a sub-path,
set `path_prefix` to that sub-path (e.g. `"/zeroclaw"`). All gateway
routes will be served under this prefix. The value must start with `/`
and must not end with `/`.
## `[autonomy]`
@ -508,6 +463,30 @@ Notes:
- Memory context injection ignores legacy `assistant_resp*` auto-save keys to prevent old model-authored summaries from being treated as facts.
### `[memory.mem0]`
Mem0 (OpenMemory) backend — connects to a self-hosted mem0 server for vector-based memory with LLM-powered fact extraction. Requires feature flag `memory-mem0` at build time and `backend = "mem0"` in config.
| Key | Default | Env var | Purpose |
|---|---|---|---|
| `url` | `http://localhost:8765` | `MEM0_URL` | OpenMemory server URL |
| `user_id` | `zeroclaw` | `MEM0_USER_ID` | User ID for scoping memories |
| `app_name` | `zeroclaw` | `MEM0_APP_NAME` | Application name registered in mem0 |
| `infer` | `true` | — | Use LLM to extract facts from stored text (`true`) or store raw (`false`) |
| `extraction_prompt` | unset | `MEM0_EXTRACTION_PROMPT` | Custom prompt for LLM fact extraction (e.g. for non-English content) |
```toml
[memory]
backend = "mem0"
[memory.mem0]
url = "http://192.168.0.171:8765"
user_id = "zeroclaw-bot"
extraction_prompt = "Extract facts in the original language..."
```
Server deployment scripts are in `deploy/mem0/`.
## `[[model_routes]]` and `[[embedding_routes]]`
Use route hints so integrations can keep stable names while model IDs evolve.
@ -607,7 +586,7 @@ Top-level channel options are configured under `channels_config`.
| Key | Default | Purpose |
|---|---|---|
| `message_timeout_secs` | `300` | Base timeout in seconds for channel message processing; runtime scales this with tool-loop depth (up to 4x, overridable via `[pacing].message_timeout_scale_max`) |
| `message_timeout_secs` | `300` | Base timeout in seconds for channel message processing; runtime scales this with tool-loop depth (up to 4x) |
Examples:
@ -622,7 +601,7 @@ Examples:
Notes:
- Default `300s` is optimized for on-device LLMs (Ollama) which are slower than cloud APIs.
- Runtime timeout budget is `message_timeout_secs * scale`, where `scale = min(max_tool_iterations, cap)` and a minimum of `1`. The default cap is `4`; override with `[pacing].message_timeout_scale_max`.
- Runtime timeout budget is `message_timeout_secs * scale`, where `scale = min(max_tool_iterations, 4)` and a minimum of `1`.
- This scaling avoids false timeouts when the first LLM turn is slow/retried but later tool-loop turns still need to complete.
- If using cloud APIs (OpenAI, Anthropic, etc.), you can reduce this to `60` or lower.
- Values below `30` are clamped to `30` to avoid immediate timeout churn.

View File

@ -12,6 +12,8 @@ Common key patterns:
- `sop_step_{run_id}_{step_number}`: per-step result
- `sop_approval_{run_id}_{step_number}`: operator approval record
- `sop_timeout_approve_{run_id}_{step_number}`: timeout auto-approval record
- `sop_gate_decision_{gate_id}_{timestamp_ms}`: gate evaluator decision record (when `ampersona-gates` is enabled)
- `sop_phase_state`: persisted trust-phase state snapshot (when `ampersona-gates` is enabled)
## 2. Inspection Paths

View File

@ -337,6 +337,30 @@ Lưu ý:
- Chèn ngữ cảnh memory bỏ qua khóa auto-save `assistant_resp*` kiểu cũ để tránh tóm tắt do model tạo bị coi là sự thật.
### `[memory.mem0]`
Backend Mem0 (OpenMemory) — kết nối đến server mem0 tự host, cung cấp bộ nhớ vector với trích xuất sự kiện bằng LLM. Cần feature flag `memory-mem0` khi build và `backend = "mem0"` trong config.
| Khóa | Mặc định | Biến môi trường | Mục đích |
|---|---|---|---|
| `url` | `http://localhost:8765` | `MEM0_URL` | URL server OpenMemory |
| `user_id` | `zeroclaw` | `MEM0_USER_ID` | User ID để phân vùng memory |
| `app_name` | `zeroclaw` | `MEM0_APP_NAME` | Tên ứng dụng đăng ký trong mem0 |
| `infer` | `true` | — | Dùng LLM trích xuất sự kiện từ text (`true`) hoặc lưu nguyên (`false`) |
| `extraction_prompt` | chưa đặt | `MEM0_EXTRACTION_PROMPT` | Prompt tùy chỉnh cho trích xuất sự kiện LLM (vd: cho nội dung không phải tiếng Anh) |
```toml
[memory]
backend = "mem0"
[memory.mem0]
url = "http://192.168.0.171:8765"
user_id = "zeroclaw-bot"
extraction_prompt = "Trích xuất sự kiện bằng ngôn ngữ gốc..."
```
Script triển khai server nằm trong `deploy/mem0/`.
## `[[model_routes]]``[[embedding_routes]]`
Route hint giúp tên tích hợp ổn định khi model ID thay đổi.

View File

@ -38,82 +38,3 @@ allowed_tools = ["read", "edit", "exec"]
max_iterations = 15
# Optional: use longer timeout for complex coding tasks
agentic_timeout_secs = 600
# ── Cron Configuration ────────────────────────────────────────
[cron]
# Enable the cron subsystem. Default: true
enabled = true
# Run all overdue jobs at scheduler startup. Default: true
catch_up_on_startup = true
# Maximum number of historical cron run records to retain. Default: 50
max_run_history = 50
# ── Declarative Cron Jobs ─────────────────────────────────────
# Define cron jobs directly in config. These are synced to the database
# at scheduler startup. Each job needs a stable `id` for merge semantics.
# Shell job: runs a shell command on a cron schedule
[[cron.jobs]]
id = "daily-backup"
name = "Daily Backup"
job_type = "shell"
command = "tar czf /tmp/backup.tar.gz /data"
schedule = { kind = "cron", expr = "0 2 * * *" }
# Agent job: runs an agent prompt on an interval
[[cron.jobs]]
id = "health-check"
name = "Health Check"
job_type = "agent"
prompt = "Check server health: disk space, memory, CPU load"
model = "anthropic/claude-sonnet-4"
allowed_tools = ["shell", "file_read"]
schedule = { kind = "every", every_ms = 300000 }
# Cron job with timezone and delivery
# [[cron.jobs]]
# id = "morning-report"
# name = "Morning Report"
# job_type = "agent"
# prompt = "Generate a daily summary of system metrics"
# schedule = { kind = "cron", expr = "0 9 * * 1-5", tz = "America/New_York" }
# [cron.jobs.delivery]
# mode = "announce"
# channel = "telegram"
# to = "123456789"
# ── Cost Tracking Configuration ────────────────────────────────
[cost]
# Enable cost tracking and budget enforcement. Default: false
enabled = false
# Daily spending limit in USD. Default: 10.0
daily_limit_usd = 10.0
# Monthly spending limit in USD. Default: 100.0
monthly_limit_usd = 100.0
# Warn when spending reaches this percentage of limit. Default: 80
warn_at_percent = 80
# Allow requests to exceed budget with --override flag. Default: false
allow_override = false
# Per-model pricing (USD per 1M tokens).
# Built-in defaults exist for popular models; add overrides here.
# [cost.prices."anthropic/claude-opus-4-20250514"]
# input = 15.0
# output = 75.0
# [cost.prices."anthropic/claude-sonnet-4-20250514"]
# input = 3.0
# output = 15.0
# [cost.prices."openai/gpt-4o"]
# input = 5.0
# output = 15.0
# [cost.prices."openai/gpt-4o-mini"]
# input = 0.15
# output = 0.60
# ── Voice Transcription ─────────────────────────────────────────
# [transcription]
# enabled = true
# default_provider = "groq"
# Also transcribe non-PTT (forwarded / regular) audio on WhatsApp.
# Default: false (only voice notes are transcribed).
# transcribe_non_ptt_audio = false

View File

@ -230,49 +230,6 @@ detect_release_target() {
esac
}
detect_device_class() {
# Containers are never desktops
if _is_container_runtime; then
echo "container"
return
fi
# Termux / Android
if [[ -n "${TERMUX_VERSION:-}" || -d "/data/data/com.termux" ]]; then
echo "mobile"
return
fi
local os arch
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Darwin)
# macOS is always a desktop
echo "desktop"
;;
Linux)
# Raspberry Pi / ARM SBCs — treat as embedded (typically headless)
case "$arch" in
armv6l|armv7l)
echo "embedded"
return
;;
esac
# Check for a display server (X11 or Wayland)
if [[ -n "${DISPLAY:-}" || -n "${WAYLAND_DISPLAY:-}" || -n "${XDG_SESSION_TYPE:-}" ]]; then
echo "desktop"
else
echo "server"
fi
;;
*)
echo "server"
;;
esac
}
should_attempt_prebuilt_for_resources() {
local workspace="${1:-.}"
local min_ram_mb min_disk_mb total_ram_mb free_disk_mb low_resource
@ -611,31 +568,6 @@ then re-run bootstrap.
MSG
exit 0
fi
# Detect un-accepted Xcode/CLT license (causes `cc` to exit 69).
# xcrun --show-sdk-path can succeed even without an accepted license,
# so we test-compile a trivial C file which reliably triggers the error.
_xcode_test_file="$(mktemp /tmp/zeroclaw-xcode-check.XXXXXX.c)"
printf 'int main(){return 0;}\n' > "$_xcode_test_file"
if ! cc -x c "$_xcode_test_file" -o /dev/null 2>/dev/null; then
rm -f "$_xcode_test_file"
warn "Xcode/CLT license has not been accepted. Attempting to accept it now..."
_xcode_accept_ok=false
if [[ "$(id -u)" -eq 0 ]]; then
xcodebuild -license accept && _xcode_accept_ok=true
elif [[ -c /dev/tty ]] && have_cmd sudo; then
sudo xcodebuild -license accept < /dev/tty && _xcode_accept_ok=true
fi
if [[ "$_xcode_accept_ok" == true ]]; then
step_ok "Xcode license accepted"
else
error "Could not accept Xcode license. Run manually:"
error " sudo xcodebuild -license accept"
error "then re-run this installer."
exit 1
fi
else
rm -f "$_xcode_test_file"
fi
if ! have_cmd git; then
warn "git is not available. Install git (e.g., Homebrew) and re-run bootstrap."
fi
@ -1198,9 +1130,6 @@ while [[ $# -gt 0 ]]; do
done
OS_NAME="$(uname -s)"
DEVICE_CLASS="$(detect_device_class)"
step_dot "Device: $OS_NAME/$(uname -m) ($DEVICE_CLASS)"
if [[ "$GUIDED_MODE" == "auto" ]]; then
if [[ "$OS_NAME" == "Linux" && "$ORIGINAL_ARG_COUNT" -eq 0 && -t 0 && -t 1 ]]; then
GUIDED_MODE="on"
@ -1239,43 +1168,6 @@ else
install_system_deps
fi
# Always check Xcode/CLT license on macOS, regardless of --install-system-deps.
# An un-accepted license causes `cc` to exit 69, breaking all Rust builds.
if [[ "$OS_NAME" == "Darwin" ]]; then
_xcode_test_file="$(mktemp /tmp/zeroclaw-xcode-check.XXXXXX.c)"
printf 'int main(){return 0;}\n' > "$_xcode_test_file"
if ! cc -x c "$_xcode_test_file" -o /dev/null 2>/dev/null; then
rm -f "$_xcode_test_file"
warn "Xcode/CLT license has not been accepted. Attempting to accept it now..."
# Use /dev/tty so sudo can prompt for a password even in a curl|bash pipe.
_xcode_accept_ok=false
if [[ "$(id -u)" -eq 0 ]]; then
xcodebuild -license accept && _xcode_accept_ok=true
elif [[ -c /dev/tty ]] && have_cmd sudo; then
sudo xcodebuild -license accept < /dev/tty && _xcode_accept_ok=true
fi
if [[ "$_xcode_accept_ok" == true ]]; then
step_ok "Xcode license accepted"
# Re-test compilation to confirm it's fixed.
_xcode_test_file="$(mktemp /tmp/zeroclaw-xcode-check.XXXXXX.c)"
printf 'int main(){return 0;}\n' > "$_xcode_test_file"
if ! cc -x c "$_xcode_test_file" -o /dev/null 2>/dev/null; then
rm -f "$_xcode_test_file"
error "C compiler still failing after license accept. Check your Xcode/CLT installation."
exit 1
fi
rm -f "$_xcode_test_file"
else
error "Could not accept Xcode license. Run manually:"
error " sudo xcodebuild -license accept"
error "then re-run this installer."
exit 1
fi
else
rm -f "$_xcode_test_file"
fi
fi
if [[ "$INSTALL_RUST" == true ]]; then
install_rust_toolchain
fi
@ -1462,20 +1354,8 @@ if [[ "$SKIP_BUILD" == false ]]; then
step_dot "Cleaning stale build cache (upgrade detected)"
cargo clean --release 2>/dev/null || true
fi
# Determine cargo feature flags — disable prometheus on 32-bit targets
# (prometheus crate requires AtomicU64, unavailable on armv7l/armv6l)
CARGO_FEATURE_FLAGS=""
_build_arch="$(uname -m)"
case "$_build_arch" in
armv7l|armv6l|armhf)
step_dot "32-bit ARM detected ($_build_arch) — disabling prometheus (requires 64-bit atomics)"
CARGO_FEATURE_FLAGS="--no-default-features --features channel-nostr,skill-creation"
;;
esac
step_dot "Building release binary"
cargo build --release --locked $CARGO_FEATURE_FLAGS
cargo build --release --locked
step_ok "Release binary built"
else
step_dot "Skipping build"
@ -1494,7 +1374,7 @@ if [[ "$SKIP_INSTALL" == false ]]; then
fi
fi
cargo install --path "$WORK_DIR" --force --locked $CARGO_FEATURE_FLAGS
cargo install --path "$WORK_DIR" --force --locked
step_ok "ZeroClaw installed"
# Sync binary to ~/.local/bin so PATH lookups find the fresh version
@ -1506,85 +1386,6 @@ else
step_dot "Skipping install"
fi
# --- Build web dashboard ---
if [[ "$SKIP_BUILD" == false && -d "$WORK_DIR/web" ]]; then
if have_cmd node && have_cmd npm; then
step_dot "Building web dashboard"
if (cd "$WORK_DIR/web" && npm ci --ignore-scripts 2>/dev/null && npm run build 2>/dev/null); then
step_ok "Web dashboard built"
else
warn "Web dashboard build failed — dashboard will not be available"
fi
else
warn "node/npm not found — skipping web dashboard build"
warn "Install Node.js (>=18) and re-run, or build manually: cd web && npm ci && npm run build"
fi
else
if [[ "$SKIP_BUILD" == true ]]; then
step_dot "Skipping web dashboard build"
fi
fi
# --- Companion desktop app (device-class-aware) ---
# The desktop app is a pre-built download from the website, not built from source.
# This keeps the one-liner install fast and the CLI binary small.
DESKTOP_DOWNLOAD_URL="https://www.zeroclawlabs.ai/download"
DESKTOP_APP_DETECTED=false
if [[ "$DEVICE_CLASS" == "desktop" ]]; then
# Check if the companion app is already installed
case "$OS_NAME" in
Darwin)
if [[ -d "/Applications/ZeroClaw.app" ]] || [[ -d "$HOME/Applications/ZeroClaw.app" ]]; then
DESKTOP_APP_DETECTED=true
step_ok "Companion app found (ZeroClaw.app)"
fi
;;
Linux)
if have_cmd zeroclaw-desktop; then
DESKTOP_APP_DETECTED=true
step_ok "Companion app found (zeroclaw-desktop)"
elif [[ -x "$HOME/.local/bin/zeroclaw-desktop" ]]; then
DESKTOP_APP_DETECTED=true
step_ok "Companion app found (~/.local/bin/zeroclaw-desktop)"
fi
;;
esac
if [[ "$DESKTOP_APP_DETECTED" == false ]]; then
echo
echo -e "${BOLD}Companion App${RESET}"
echo -e " Menu bar access to your ZeroClaw agent."
echo -e " Works alongside the CLI — connects to the same gateway."
echo
case "$OS_NAME" in
Darwin)
echo -e " ${BOLD}Download for macOS:${RESET} ${BLUE}${DESKTOP_DOWNLOAD_URL}${RESET}"
;;
Linux)
echo -e " ${BOLD}Download for Linux:${RESET} ${BLUE}${DESKTOP_DOWNLOAD_URL}${RESET}"
;;
esac
echo -e " ${DIM}Or run: zeroclaw desktop --install${RESET}"
fi
elif [[ "$DEVICE_CLASS" != "desktop" ]]; then
# Non-desktop device — explain why companion app is not offered
case "$DEVICE_CLASS" in
mobile)
step_dot "Mobile device — use the web dashboard at http://127.0.0.1:42617"
;;
embedded)
step_dot "Embedded device ($(uname -m)) — use the web dashboard"
;;
container)
step_dot "Container runtime — use the web dashboard"
;;
server)
step_dot "Headless server — use the web dashboard"
;;
esac
fi
ZEROCLAW_BIN=""
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
ZEROCLAW_BIN="$HOME/.cargo/bin/zeroclaw"
@ -1659,6 +1460,25 @@ if [[ -n "$ZEROCLAW_BIN" ]]; then
if "$ZEROCLAW_BIN" service restart 2>/dev/null; then
step_ok "Gateway service restarted"
# Fetch and display pairing code from running gateway
PAIR_CODE=""
for i in 1 2 3 4 5; do
sleep 2
if PAIR_CODE=$("$ZEROCLAW_BIN" gateway get-paircode 2>/dev/null | grep -oE '[0-9]{6}'); then
break
fi
done
if [[ -n "$PAIR_CODE" ]]; then
echo
echo -e " ${BOLD_BLUE}🔐 Gateway Pairing Code${RESET}"
echo
echo -e " ${BOLD_BLUE}┌──────────────┐${RESET}"
echo -e " ${BOLD_BLUE}${RESET} ${BOLD}${PAIR_CODE}${RESET} ${BOLD_BLUE}${RESET}"
echo -e " ${BOLD_BLUE}└──────────────┘${RESET}"
echo
echo -e " ${DIM}Enter this code in the dashboard to pair your device.${RESET}"
echo -e " ${DIM}Run 'zeroclaw gateway get-paircode --new' anytime to generate a fresh code.${RESET}"
fi
else
step_fail "Gateway service restart failed — re-run with zeroclaw service start"
fi
@ -1705,6 +1525,7 @@ GATEWAY_PORT=42617
DASHBOARD_URL="http://127.0.0.1:${GATEWAY_PORT}"
echo
echo -e "${BOLD}Dashboard URL:${RESET} ${BLUE}${DASHBOARD_URL}${RESET}"
echo -e "${DIM} Run 'zeroclaw gateway get-paircode' to get your pairing code.${RESET}"
# --- Copy to clipboard ---
COPIED_TO_CLIPBOARD=false
@ -1751,13 +1572,6 @@ echo -e "${BOLD}Next steps:${RESET}"
echo -e " ${DIM}zeroclaw status${RESET}"
echo -e " ${DIM}zeroclaw agent -m \"Hello, ZeroClaw!\"${RESET}"
echo -e " ${DIM}zeroclaw gateway${RESET}"
if [[ "$DEVICE_CLASS" == "desktop" ]]; then
if [[ "$DESKTOP_APP_DETECTED" == true ]]; then
echo -e " ${DIM}zeroclaw desktop${RESET} ${DIM}# Launch the menu bar app${RESET}"
else
echo -e " ${DIM}zeroclaw desktop --install${RESET} ${DIM}# Download the companion app${RESET}"
fi
fi
echo
echo -e "${BOLD}Docs:${RESET} ${BLUE}https://www.zeroclawlabs.ai/docs${RESET}"
echo

View File

@ -1,21 +0,0 @@
#!/bin/bash
# Start a browser on a virtual display
# Usage: ./start-browser.sh [display_num] [url]
set -e
DISPLAY_NUM=${1:-99}
URL=${2:-"https://google.com"}
export DISPLAY=:$DISPLAY_NUM
# Check if display is running
if ! xdpyinfo -display :$DISPLAY_NUM &>/dev/null; then
echo "Error: Display :$DISPLAY_NUM not running."
echo "Start VNC first: ./start-vnc.sh"
exit 1
fi
google-chrome --no-sandbox --disable-gpu --disable-setuid-sandbox "$URL" &
echo "Chrome started on display :$DISPLAY_NUM"
echo "View via VNC or noVNC"

View File

@ -1,52 +0,0 @@
#!/bin/bash
# Start virtual display with VNC access for browser GUI
# Usage: ./start-vnc.sh [display_num] [vnc_port] [novnc_port] [resolution]
set -e
DISPLAY_NUM=${1:-99}
VNC_PORT=${2:-5900}
NOVNC_PORT=${3:-6080}
RESOLUTION=${4:-1920x1080x24}
echo "Starting virtual display :$DISPLAY_NUM at $RESOLUTION"
# Kill any existing sessions
pkill -f "Xvfb :$DISPLAY_NUM" 2>/dev/null || true
pkill -f "x11vnc.*:$DISPLAY_NUM" 2>/dev/null || true
pkill -f "websockify.*$NOVNC_PORT" 2>/dev/null || true
sleep 1
# Start Xvfb (virtual framebuffer)
Xvfb :$DISPLAY_NUM -screen 0 $RESOLUTION -ac &
XVFB_PID=$!
sleep 1
# Set DISPLAY
export DISPLAY=:$DISPLAY_NUM
# Start window manager
fluxbox -display :$DISPLAY_NUM 2>/dev/null &
sleep 1
# Start x11vnc
x11vnc -display :$DISPLAY_NUM -rfbport $VNC_PORT -forever -shared -nopw -bg 2>/dev/null
sleep 1
# Start noVNC (web-based VNC client)
websockify --web=/usr/share/novnc $NOVNC_PORT localhost:$VNC_PORT &
NOVNC_PID=$!
echo ""
echo "==================================="
echo "VNC Server started!"
echo "==================================="
echo "VNC Direct: localhost:$VNC_PORT"
echo "noVNC Web: http://localhost:$NOVNC_PORT/vnc.html"
echo "Display: :$DISPLAY_NUM"
echo "==================================="
echo ""
echo "To start a browser, run:"
echo " DISPLAY=:$DISPLAY_NUM google-chrome &"
echo ""
echo "To stop, run: pkill -f 'Xvfb :$DISPLAY_NUM'"

View File

@ -1,11 +0,0 @@
#!/bin/bash
# Stop virtual display and VNC server
# Usage: ./stop-vnc.sh [display_num]
DISPLAY_NUM=${1:-99}
pkill -f "Xvfb :$DISPLAY_NUM" 2>/dev/null || true
pkill -f "x11vnc.*:$DISPLAY_NUM" 2>/dev/null || true
pkill -f "websockify.*6080" 2>/dev/null || true
echo "VNC server stopped"

View File

@ -77,9 +77,7 @@ echo "Created annotated tag: $TAG"
if [[ "$PUSH_TAG" == "true" ]]; then
git push origin "$TAG"
echo "Pushed tag to origin: $TAG"
echo "Release Stable workflow will auto-trigger via tag push."
echo "Monitor: gh workflow view 'Release Stable' --web"
echo "GitHub release pipeline will run via .github/workflows/pub-release.yml"
else
echo "Next step: git push origin $TAG"
echo "This will auto-trigger the Release Stable workflow (builds, Docker, crates.io, website, Scoop, AUR, Homebrew, tweet)."
fi

View File

@ -1,122 +0,0 @@
---
name: browser
description: Headless browser automation using agent-browser CLI
metadata: {"zeroclaw":{"emoji":"🌐","requires":{"bins":["agent-browser"]}}}
---
# Browser Skill
Control a headless browser for web automation, scraping, and testing.
## Prerequisites
- `agent-browser` CLI installed globally (`npm install -g agent-browser`)
- Chrome downloaded (`agent-browser install`)
## Installation
```bash
# Install agent-browser CLI
npm install -g agent-browser
# Download Chrome for Testing
agent-browser install --with-deps # Linux
agent-browser install # macOS/Windows
```
## Usage
### Navigate and snapshot
```bash
agent-browser open https://example.com
agent-browser snapshot -i
```
### Interact with elements
```bash
agent-browser click @e1 # Click by ref
agent-browser fill @e2 "text" # Fill input
agent-browser press Enter # Press key
```
### Extract data
```bash
agent-browser get text @e1 # Get text content
agent-browser get url # Get current URL
agent-browser screenshot page.png # Take screenshot
```
### Session management
```bash
agent-browser close # Close browser
```
## Common Workflows
### Login flow
```bash
agent-browser open https://site.com/login
agent-browser snapshot -i
agent-browser fill @email "user@example.com"
agent-browser fill @password "secretpass"
agent-browser click @submit
agent-browser wait --text "Welcome"
```
### Scrape page content
```bash
agent-browser open https://news.ycombinator.com
agent-browser snapshot -i
agent-browser get text @e1
```
### Take screenshots
```bash
agent-browser open https://google.com
agent-browser screenshot --full page.png
```
## Options
- `--json` - JSON output for parsing
- `--headed` - Show browser window (for debugging)
- `--session-name <name>` - Persist session cookies
- `--profile <path>` - Use persistent browser profile
## Configuration
The browser tool is enabled by default with `allowed_domains = ["*"]` and
`backend = "agent_browser"`. To customize, edit `~/.zeroclaw/config.toml`:
```toml
[browser]
enabled = true # default: true
allowed_domains = ["*"] # default: ["*"] (all public hosts)
backend = "agent_browser" # default: "agent_browser"
native_headless = true # default: true
```
To restrict domains or disable the browser tool:
```toml
[browser]
enabled = false # disable entirely
# or restrict to specific domains:
allowed_domains = ["example.com", "docs.example.com"]
```
## Full Command Reference
Run `agent-browser --help` for all available commands.
## Related
- [agent-browser GitHub](https://github.com/vercel-labs/agent-browser)
- [VNC Setup Guide](../docs/browser-setup.md)

View File

@ -1,3 +0,0 @@
# Browser skill tests
# Format: command | expected_exit_code | expected_output_pattern
echo "browser skill loaded" | 0 | browser skill loaded

View File

@ -12,29 +12,11 @@ use crate::runtime;
use crate::security::SecurityPolicy;
use crate::tools::{self, Tool, ToolSpec};
use anyhow::Result;
use chrono::{Datelike, Timelike};
use std::collections::HashMap;
use std::io::Write as IoWrite;
use std::sync::Arc;
use std::time::Instant;
/// Events emitted during a streamed agent turn.
///
/// Consumers receive these through a `tokio::sync::mpsc::Sender<TurnEvent>`
/// passed to [`Agent::turn_streamed`].
#[derive(Debug, Clone)]
pub enum TurnEvent {
/// A text chunk from the LLM response (may arrive many times).
Chunk { delta: String },
/// The agent is invoking a tool.
ToolCall {
name: String,
args: serde_json::Value,
},
/// A tool has returned a result.
ToolResult { name: String, output: String },
}
pub struct Agent {
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
@ -377,23 +359,21 @@ impl Agent {
None
};
let (mut tools, delegate_handle, _reaction_handle, _channel_map_handle, _ask_user_handle) =
tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
runtime,
memory.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.web_fetch,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
config,
None,
);
let (mut tools, delegate_handle) = tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
runtime,
memory.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.web_fetch,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
config,
);
// ── Wire MCP tools (non-fatal) ─────────────────────────────
// Replicates the same MCP initialization logic used in the CLI
@ -653,24 +633,6 @@ impl Agent {
return format!("hint:{}", decision.hint);
}
}
// Fallback: auto-classify by complexity when no rule matched.
if let Some(ref ac) = self.config.auto_classify {
let tier = super::eval::estimate_complexity(user_message);
if let Some(hint) = ac.hint_for(tier) {
if self.available_hints.contains(&hint.to_string()) {
tracing::info!(
target: "query_classification",
hint = hint,
complexity = ?tier,
message_length = user_message.len(),
"Auto-classified by complexity"
);
return format!("hint:{hint}");
}
}
}
self.model_name.clone()
}
@ -705,17 +667,11 @@ impl Agent {
.await;
}
let now = chrono::Local::now();
let (year, month, day) = (now.year(), now.month(), now.day());
let (hour, minute, second) = (now.hour(), now.minute(), now.second());
let tz = now.format("%Z");
let date_str =
format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} {tz}");
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
let enriched = if context.is_empty() {
format!("[CURRENT DATE & TIME: {date_str}]\n\n{user_message}")
format!("[{now}] {user_message}")
} else {
format!("[CURRENT DATE & TIME: {date_str}]\n\n{context}\n\n{user_message}")
format!("{context}[{now}] {user_message}")
};
self.history
@ -841,254 +797,6 @@ impl Agent {
)
}
/// Execute a single agent turn while streaming intermediate events.
///
/// Behaves identically to [`turn`](Self::turn) but forwards [`TurnEvent`]s
/// through the provided channel so callers (e.g. the WebSocket gateway)
/// can relay incremental updates to clients.
///
/// The returned `String` is the final, complete assistant response — the
/// same value that `turn` would return.
pub async fn turn_streamed(
&mut self,
user_message: &str,
event_tx: tokio::sync::mpsc::Sender<TurnEvent>,
) -> Result<String> {
// ── Preamble (identical to turn) ───────────────────────────────
if self.history.is_empty() {
let system_prompt = self.build_system_prompt()?;
self.history
.push(ConversationMessage::Chat(ChatMessage::system(
system_prompt,
)));
}
let context = self
.memory_loader
.load_context(
self.memory.as_ref(),
user_message,
self.memory_session_id.as_deref(),
)
.await
.unwrap_or_default();
if self.auto_save {
let _ = self
.memory
.store(
"user_msg",
user_message,
MemoryCategory::Conversation,
self.memory_session_id.as_deref(),
)
.await;
}
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
let enriched = if context.is_empty() {
format!("[{now}] {user_message}")
} else {
format!("{context}[{now}] {user_message}")
};
self.history
.push(ConversationMessage::Chat(ChatMessage::user(enriched)));
let effective_model = self.classify_model(user_message);
// ── Turn loop ──────────────────────────────────────────────────
for _ in 0..self.config.max_tool_iterations {
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
// Response cache check (same as turn)
let cache_key = if self.temperature == 0.0 {
self.response_cache.as_ref().map(|_| {
let last_user = messages
.iter()
.rfind(|m| m.role == "user")
.map(|m| m.content.as_str())
.unwrap_or("");
let system = messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.as_str());
crate::memory::response_cache::ResponseCache::cache_key(
&effective_model,
system,
last_user,
)
})
} else {
None
};
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
if let Ok(Some(cached)) = cache.get(key) {
self.observer.record_event(&ObserverEvent::CacheHit {
cache_type: "response".into(),
tokens_saved: 0,
});
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
cached.clone(),
)));
self.trim_history();
return Ok(cached);
}
self.observer.record_event(&ObserverEvent::CacheMiss {
cache_type: "response".into(),
});
}
// ── Streaming LLM call ────────────────────────────────────
// Try streaming first; if the provider returns content we
// forward deltas. Otherwise fall back to non-streaming chat.
use futures_util::StreamExt;
let stream_opts = crate::providers::traits::StreamOptions::new(true);
let mut stream = self.provider.stream_chat_with_history(
&messages,
&effective_model,
self.temperature,
stream_opts,
);
let mut streamed_text = String::new();
let mut got_stream = false;
while let Some(item) = stream.next().await {
match item {
Ok(chunk) => {
if !chunk.delta.is_empty() {
got_stream = true;
streamed_text.push_str(&chunk.delta);
let _ = event_tx.send(TurnEvent::Chunk { delta: chunk.delta }).await;
}
}
Err(_) => break,
}
}
// Drop the stream so we release the borrow on provider.
drop(stream);
// If streaming produced text, use it as the response and
// check for tool calls via the dispatcher.
let response = if got_stream {
// Build a synthetic ChatResponse from streamed text
crate::providers::ChatResponse {
text: Some(streamed_text),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
}
} else {
// Fall back to non-streaming chat
match self
.provider
.chat(
ChatRequest {
messages: &messages,
tools: if self.tool_dispatcher.should_send_tool_specs() {
Some(&self.tool_specs)
} else {
None
},
},
&effective_model,
self.temperature,
)
.await
{
Ok(resp) => resp,
Err(err) => return Err(err),
}
};
let (text, calls) = self.tool_dispatcher.parse_response(&response);
if calls.is_empty() {
let final_text = if text.is_empty() {
response.text.unwrap_or_default()
} else {
text
};
// Store in response cache
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
let token_count = response
.usage
.as_ref()
.and_then(|u| u.output_tokens)
.unwrap_or(0);
#[allow(clippy::cast_possible_truncation)]
let _ = cache.put(key, &effective_model, &final_text, token_count as u32);
}
// If we didn't stream, send the full response as a single chunk
if !got_stream && !final_text.is_empty() {
let _ = event_tx
.send(TurnEvent::Chunk {
delta: final_text.clone(),
})
.await;
}
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
final_text.clone(),
)));
self.trim_history();
return Ok(final_text);
}
// ── Tool calls ─────────────────────────────────────────────
if !text.is_empty() {
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
text.clone(),
)));
}
self.history.push(ConversationMessage::AssistantToolCalls {
text: response.text.clone(),
tool_calls: response.tool_calls.clone(),
reasoning_content: response.reasoning_content.clone(),
});
// Notify about each tool call
for call in &calls {
let _ = event_tx
.send(TurnEvent::ToolCall {
name: call.name.clone(),
args: call.arguments.clone(),
})
.await;
}
let results = self.execute_tools(&calls).await;
// Notify about each tool result
for result in &results {
let _ = event_tx
.send(TurnEvent::ToolResult {
name: result.name.clone(),
output: result.output.clone(),
})
.await;
}
let formatted = self.tool_dispatcher.format_results(&results);
self.history.push(formatted);
self.trim_history();
}
anyhow::bail!(
"Agent exceeded maximum tool iterations ({})",
self.config.max_tool_iterations
)
}
pub async fn run_single(&mut self, message: &str) -> Result<String> {
self.turn(message).await
}

View File

@ -1,155 +0,0 @@
use crate::providers::traits::ChatMessage;
use std::collections::HashSet;
/// Signals extracted from conversation context to guide tool filtering.
#[derive(Debug, Clone)]
pub struct ContextSignals {
/// Tool names likely needed. Empty vec means no filtering.
pub suggested_tools: Vec<String>,
/// Whether full history is relevant.
pub history_relevant: bool,
}
/// Analyze context to determine which tools are likely needed.
pub fn analyze_turn_context(
history: &[ChatMessage],
_user_message: &str,
iteration: usize,
last_tool_calls: &[String],
) -> ContextSignals {
if iteration == 0 {
return ContextSignals {
suggested_tools: Vec::new(),
history_relevant: true,
};
}
let mut tools: HashSet<String> = HashSet::new();
for tool in last_tool_calls {
tools.insert(tool.clone());
}
if let Some(last_assistant) = history.iter().rev().find(|m| m.role == "assistant") {
for word in last_assistant.content.split_whitespace() {
for tool_name in tools_for_keyword(word) {
tools.insert(tool_name.to_string());
}
}
}
let mut suggested: Vec<String> = tools.into_iter().collect();
suggested.sort();
ContextSignals {
suggested_tools: suggested,
history_relevant: true,
}
}
fn tools_for_keyword(keyword: &str) -> &'static [&'static str] {
match keyword.to_lowercase().as_str() {
"file" | "read" | "write" | "edit" | "path" | "directory" => {
&["file_read", "file_write", "file_edit", "glob_search"]
}
"shell" | "command" | "run" | "execute" | "install" | "build" => &["shell"],
"memory" | "remember" | "recall" | "store" | "forget" => &["memory_store", "memory_recall"],
"search" | "find" | "grep" | "look" => {
&["content_search", "glob_search", "web_search_tool"]
}
"browser" | "website" | "url" | "http" | "fetch" => &["web_fetch", "web_search_tool"],
"image" | "screenshot" | "picture" => &["image_info"],
"git" | "commit" | "branch" | "push" | "pull" => &["git_operations", "shell"],
_ => &[],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_message(role: &str, content: &str) -> ChatMessage {
ChatMessage {
role: role.to_string(),
content: content.to_string(),
}
}
#[test]
fn iteration_zero_returns_empty_suggestions() {
let history = vec![make_message("user", "hello")];
let signals = analyze_turn_context(&history, "do something", 0, &[]);
assert!(signals.suggested_tools.is_empty());
assert!(signals.history_relevant);
}
#[test]
fn iteration_one_includes_last_tools() {
let history = vec![
make_message("user", "hello"),
make_message("assistant", "sure"),
];
let last_tools = vec!["shell".to_string(), "file_read".to_string()];
let signals = analyze_turn_context(&history, "next step", 1, &last_tools);
assert!(signals.suggested_tools.contains(&"shell".to_string()));
assert!(signals.suggested_tools.contains(&"file_read".to_string()));
}
#[test]
fn keyword_extraction_from_assistant_message() {
let history = vec![
make_message("user", "help me"),
make_message("assistant", "I will read the file at that path"),
];
let signals = analyze_turn_context(&history, "ok", 1, &[]);
assert!(signals.suggested_tools.contains(&"file_read".to_string()));
}
#[test]
fn shell_keywords_suggest_shell_tool() {
let history = vec![
make_message("user", "build the project"),
make_message("assistant", "I will run the build command"),
];
let signals = analyze_turn_context(&history, "go", 1, &[]);
assert!(signals.suggested_tools.contains(&"shell".to_string()));
}
#[test]
fn memory_keywords_suggest_memory_tools() {
let history = vec![
make_message("user", "save this"),
make_message("assistant", "I will store that in memory"),
];
let signals = analyze_turn_context(&history, "ok", 1, &[]);
assert!(signals
.suggested_tools
.contains(&"memory_store".to_string()));
assert!(signals
.suggested_tools
.contains(&"memory_recall".to_string()));
}
#[test]
fn combined_keywords_merge_tools() {
let history = vec![
make_message("user", "do stuff"),
make_message(
"assistant",
"I need to read the file and run a shell command to search",
),
];
let signals = analyze_turn_context(&history, "go", 1, &[]);
assert!(signals.suggested_tools.contains(&"file_read".to_string()));
assert!(signals.suggested_tools.contains(&"shell".to_string()));
assert!(signals
.suggested_tools
.contains(&"content_search".to_string()));
}
#[test]
fn empty_history_iteration_one() {
let history: Vec<ChatMessage> = vec![];
let signals = analyze_turn_context(&history, "hello", 1, &[]);
assert!(signals.suggested_tools.is_empty());
}
}

View File

@ -1,415 +0,0 @@
use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
// ── Complexity estimation ───────────────────────────────────────
/// Coarse complexity tier for a user message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComplexityTier {
/// Short, simple query (greetings, yes/no, lookups).
Simple,
/// Typical request — not trivially simple, not deeply complex.
Standard,
/// Long or reasoning-heavy request (code, multi-step, analysis).
Complex,
}
/// Heuristic keywords that signal reasoning complexity.
const REASONING_KEYWORDS: &[&str] = &[
"explain",
"why",
"analyze",
"compare",
"design",
"implement",
"refactor",
"debug",
"optimize",
"architecture",
"trade-off",
"tradeoff",
"reasoning",
"step by step",
"think through",
"evaluate",
"critique",
"pros and cons",
];
/// Estimate the complexity of a user message without an LLM call.
///
/// Rules (applied in order):
/// - **Complex**: message > 200 chars, OR contains a code fence, OR ≥ 2
/// reasoning keywords.
/// - **Simple**: message < 50 chars AND no reasoning keywords.
/// - **Standard**: everything else.
pub fn estimate_complexity(message: &str) -> ComplexityTier {
let lower = message.to_lowercase();
let len = message.len();
let keyword_count = REASONING_KEYWORDS
.iter()
.filter(|kw| lower.contains(**kw))
.count();
let has_code_fence = message.contains("```");
if len > 200 || has_code_fence || keyword_count >= 2 {
return ComplexityTier::Complex;
}
if len < 50 && keyword_count == 0 {
return ComplexityTier::Simple;
}
ComplexityTier::Standard
}
// ── Auto-classify config ────────────────────────────────────────
/// Configuration for automatic complexity-based classification.
///
/// When the rule-based classifier in `QueryClassificationConfig` produces no
/// match, the eval layer can fall back to `estimate_complexity` and map the
/// resulting tier to a routing hint.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct AutoClassifyConfig {
/// Hint to use for `Simple` complexity tier (e.g. `"fast"`).
#[serde(default)]
pub simple_hint: Option<String>,
/// Hint to use for `Standard` complexity tier.
#[serde(default)]
pub standard_hint: Option<String>,
/// Hint to use for `Complex` complexity tier (e.g. `"reasoning"`).
#[serde(default)]
pub complex_hint: Option<String>,
}
impl AutoClassifyConfig {
/// Map a complexity tier to the configured hint, if any.
pub fn hint_for(&self, tier: ComplexityTier) -> Option<&str> {
match tier {
ComplexityTier::Simple => self.simple_hint.as_deref(),
ComplexityTier::Standard => self.standard_hint.as_deref(),
ComplexityTier::Complex => self.complex_hint.as_deref(),
}
}
}
// ── Post-response eval ──────────────────────────────────────────
/// Configuration for the post-response quality evaluator.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EvalConfig {
/// Enable the eval quality gate.
#[serde(default)]
pub enabled: bool,
/// Minimum quality score (0.01.0) to accept a response.
/// Below this threshold, a retry with a higher-tier model is suggested.
#[serde(default = "default_min_quality_score")]
pub min_quality_score: f64,
/// Maximum retries with escalated models before accepting whatever we get.
#[serde(default = "default_max_retries")]
pub max_retries: u32,
}
fn default_min_quality_score() -> f64 {
0.5
}
fn default_max_retries() -> u32 {
1
}
impl Default for EvalConfig {
fn default() -> Self {
Self {
enabled: false,
min_quality_score: default_min_quality_score(),
max_retries: default_max_retries(),
}
}
}
/// Result of evaluating a response against quality heuristics.
#[derive(Debug, Clone)]
pub struct EvalResult {
/// Aggregate quality score from 0.0 (terrible) to 1.0 (excellent).
pub score: f64,
/// Individual check outcomes (for observability).
pub checks: Vec<EvalCheck>,
/// If score < threshold, the suggested higher-tier hint for retry.
pub retry_hint: Option<String>,
}
#[derive(Debug, Clone)]
pub struct EvalCheck {
pub name: &'static str,
pub passed: bool,
pub weight: f64,
}
/// Code-related keywords in user queries.
const CODE_KEYWORDS: &[&str] = &[
"code",
"function",
"implement",
"class",
"struct",
"module",
"script",
"program",
"bug",
"error",
"compile",
"syntax",
"refactor",
];
/// Evaluate a response against heuristic quality checks. No LLM call.
///
/// Checks:
/// 1. **Non-empty**: response must not be empty.
/// 2. **Not a cop-out**: response must not be just "I don't know" or similar.
/// 3. **Sufficient length**: response length should be proportional to query complexity.
/// 4. **Code presence**: if the query mentions code keywords, the response should
/// contain a code block.
pub fn evaluate_response(
query: &str,
response: &str,
complexity: ComplexityTier,
auto_classify: Option<&AutoClassifyConfig>,
) -> EvalResult {
let mut checks = Vec::new();
// Check 1: Non-empty
let non_empty = !response.trim().is_empty();
checks.push(EvalCheck {
name: "non_empty",
passed: non_empty,
weight: 0.3,
});
// Check 2: Not a cop-out
let lower_resp = response.to_lowercase();
let cop_out_phrases = [
"i don't know",
"i'm not sure",
"i cannot",
"i can't help",
"as an ai",
];
let is_cop_out = cop_out_phrases
.iter()
.any(|phrase| lower_resp.starts_with(phrase));
let not_cop_out = !is_cop_out || response.len() > 200; // long responses with caveats are fine
checks.push(EvalCheck {
name: "not_cop_out",
passed: not_cop_out,
weight: 0.25,
});
// Check 3: Sufficient length for complexity
let min_len = match complexity {
ComplexityTier::Simple => 5,
ComplexityTier::Standard => 20,
ComplexityTier::Complex => 50,
};
let sufficient_length = response.len() >= min_len;
checks.push(EvalCheck {
name: "sufficient_length",
passed: sufficient_length,
weight: 0.2,
});
// Check 4: Code presence when expected
let query_lower = query.to_lowercase();
let expects_code = CODE_KEYWORDS.iter().any(|kw| query_lower.contains(kw));
let has_code = response.contains("```") || response.contains(" "); // code block or indented
let code_check_passed = !expects_code || has_code;
checks.push(EvalCheck {
name: "code_presence",
passed: code_check_passed,
weight: 0.25,
});
// Compute weighted score
let total_weight: f64 = checks.iter().map(|c| c.weight).sum();
let earned: f64 = checks.iter().filter(|c| c.passed).map(|c| c.weight).sum();
let score = if total_weight > 0.0 {
earned / total_weight
} else {
1.0
};
// Determine retry hint: if score is low, suggest escalating
let retry_hint = if score <= default_min_quality_score() {
// Try to escalate: Simple→Standard→Complex
let next_tier = match complexity {
ComplexityTier::Simple => Some(ComplexityTier::Standard),
ComplexityTier::Standard => Some(ComplexityTier::Complex),
ComplexityTier::Complex => None, // already at max
};
next_tier.and_then(|tier| {
auto_classify
.and_then(|ac| ac.hint_for(tier))
.map(String::from)
})
} else {
None
};
EvalResult {
score,
checks,
retry_hint,
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── estimate_complexity ─────────────────────────────────────
#[test]
fn simple_short_message() {
assert_eq!(estimate_complexity("hi"), ComplexityTier::Simple);
assert_eq!(estimate_complexity("hello"), ComplexityTier::Simple);
assert_eq!(estimate_complexity("yes"), ComplexityTier::Simple);
}
#[test]
fn complex_long_message() {
let long = "a".repeat(201);
assert_eq!(estimate_complexity(&long), ComplexityTier::Complex);
}
#[test]
fn complex_code_fence() {
let msg = "Here is some code:\n```rust\nfn main() {}\n```";
assert_eq!(estimate_complexity(msg), ComplexityTier::Complex);
}
#[test]
fn complex_multiple_reasoning_keywords() {
let msg = "Please explain why this design is better and analyze the trade-off";
assert_eq!(estimate_complexity(msg), ComplexityTier::Complex);
}
#[test]
fn standard_medium_message() {
// 50+ chars but no code fence, < 2 reasoning keywords
let msg = "Can you help me find a good restaurant in this area please?";
assert_eq!(estimate_complexity(msg), ComplexityTier::Standard);
}
#[test]
fn standard_short_with_one_keyword() {
// < 50 chars but has 1 reasoning keyword → still not Simple
let msg = "explain this";
assert_eq!(estimate_complexity(msg), ComplexityTier::Standard);
}
// ── auto_classify ───────────────────────────────────────────
#[test]
fn auto_classify_maps_tiers_to_hints() {
let ac = AutoClassifyConfig {
simple_hint: Some("fast".into()),
standard_hint: None,
complex_hint: Some("reasoning".into()),
};
assert_eq!(ac.hint_for(ComplexityTier::Simple), Some("fast"));
assert_eq!(ac.hint_for(ComplexityTier::Standard), None);
assert_eq!(ac.hint_for(ComplexityTier::Complex), Some("reasoning"));
}
// ── evaluate_response ───────────────────────────────────────
#[test]
fn empty_response_scores_low() {
let result = evaluate_response("hello", "", ComplexityTier::Simple, None);
assert!(result.score <= 0.5, "empty response should score low");
}
#[test]
fn good_response_scores_high() {
let result = evaluate_response(
"what is 2+2?",
"The answer is 4.",
ComplexityTier::Simple,
None,
);
assert!(
result.score >= 0.9,
"good simple response should score high, got {}",
result.score
);
}
#[test]
fn cop_out_response_penalized() {
let result = evaluate_response(
"explain quantum computing",
"I don't know much about that.",
ComplexityTier::Standard,
None,
);
assert!(
result.score < 1.0,
"cop-out should be penalized, got {}",
result.score
);
}
#[test]
fn code_query_without_code_response_penalized() {
let result = evaluate_response(
"write a function to sort an array",
"You should use a sorting algorithm.",
ComplexityTier::Standard,
None,
);
// "code_presence" check should fail
let code_check = result.checks.iter().find(|c| c.name == "code_presence");
assert!(
code_check.is_some() && !code_check.unwrap().passed,
"code check should fail"
);
}
#[test]
fn retry_hint_escalation() {
let ac = AutoClassifyConfig {
simple_hint: Some("fast".into()),
standard_hint: Some("default".into()),
complex_hint: Some("reasoning".into()),
};
// Empty response for a Simple query → should suggest Standard hint
let result = evaluate_response("hello", "", ComplexityTier::Simple, Some(&ac));
assert_eq!(result.retry_hint, Some("default".into()));
}
#[test]
fn no_retry_when_already_complex() {
let ac = AutoClassifyConfig {
simple_hint: Some("fast".into()),
standard_hint: Some("default".into()),
complex_hint: Some("reasoning".into()),
};
// Empty response for Complex → no escalation possible
let result =
evaluate_response("explain everything", "", ComplexityTier::Complex, Some(&ac));
assert_eq!(result.retry_hint, None);
}
#[test]
fn max_retries_defaults() {
let config = EvalConfig::default();
assert!(!config.enabled);
assert_eq!(config.max_retries, 1);
assert!((config.min_quality_score - 0.5).abs() < f64::EPSILON);
}
}

View File

@ -1,283 +0,0 @@
use crate::providers::traits::ChatMessage;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
fn default_max_tokens() -> usize {
8192
}
fn default_keep_recent() -> usize {
4
}
fn default_collapse() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HistoryPrunerConfig {
/// Enable history pruning. Default: false.
#[serde(default)]
pub enabled: bool,
/// Maximum estimated tokens for message history. Default: 8192.
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
/// Keep the N most recent messages untouched. Default: 4.
#[serde(default = "default_keep_recent")]
pub keep_recent: usize,
/// Collapse old tool call/result pairs into short summaries. Default: true.
#[serde(default = "default_collapse")]
pub collapse_tool_results: bool,
}
impl Default for HistoryPrunerConfig {
fn default() -> Self {
Self {
enabled: false,
max_tokens: 8192,
keep_recent: 4,
collapse_tool_results: true,
}
}
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PruneStats {
pub messages_before: usize,
pub messages_after: usize,
pub collapsed_pairs: usize,
pub dropped_messages: usize,
}
// ---------------------------------------------------------------------------
// Token estimation
// ---------------------------------------------------------------------------
fn estimate_tokens(messages: &[ChatMessage]) -> usize {
messages.iter().map(|m| m.content.len() / 4).sum()
}
// ---------------------------------------------------------------------------
// Protected-index helpers
// ---------------------------------------------------------------------------
fn protected_indices(messages: &[ChatMessage], keep_recent: usize) -> Vec<bool> {
let len = messages.len();
let mut protected = vec![false; len];
for (i, msg) in messages.iter().enumerate() {
if msg.role == "system" {
protected[i] = true;
}
}
let recent_start = len.saturating_sub(keep_recent);
for p in protected.iter_mut().skip(recent_start) {
*p = true;
}
protected
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
pub fn prune_history(messages: &mut Vec<ChatMessage>, config: &HistoryPrunerConfig) -> PruneStats {
let messages_before = messages.len();
if !config.enabled || messages.is_empty() {
return PruneStats {
messages_before,
messages_after: messages_before,
collapsed_pairs: 0,
dropped_messages: 0,
};
}
let mut collapsed_pairs: usize = 0;
// Phase 1 collapse assistant+tool pairs
if config.collapse_tool_results {
let mut i = 0;
while i + 1 < messages.len() {
let protected = protected_indices(messages, config.keep_recent);
if messages[i].role == "assistant"
&& messages[i + 1].role == "tool"
&& !protected[i]
&& !protected[i + 1]
{
let tool_content = &messages[i + 1].content;
let truncated: String = tool_content.chars().take(100).collect();
let summary = format!("[Tool result: {truncated}...]");
messages[i] = ChatMessage {
role: "assistant".to_string(),
content: summary,
};
messages.remove(i + 1);
collapsed_pairs += 1;
} else {
i += 1;
}
}
}
// Phase 2 budget enforcement
let mut dropped_messages: usize = 0;
while estimate_tokens(messages) > config.max_tokens {
let protected = protected_indices(messages, config.keep_recent);
if let Some(idx) = protected
.iter()
.enumerate()
.find(|(_, &p)| !p)
.map(|(i, _)| i)
{
messages.remove(idx);
dropped_messages += 1;
} else {
break;
}
}
PruneStats {
messages_before,
messages_after: messages.len(),
collapsed_pairs,
dropped_messages,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(role: &str, content: &str) -> ChatMessage {
ChatMessage {
role: role.to_string(),
content: content.to_string(),
}
}
#[test]
fn prune_disabled_is_noop() {
let mut messages = vec![
msg("system", "You are helpful."),
msg("user", "Hello"),
msg("assistant", "Hi there!"),
];
let config = HistoryPrunerConfig {
enabled: false,
..Default::default()
};
let stats = prune_history(&mut messages, &config);
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].content, "You are helpful.");
assert_eq!(stats.messages_before, 3);
assert_eq!(stats.messages_after, 3);
assert_eq!(stats.collapsed_pairs, 0);
}
#[test]
fn prune_under_budget_no_change() {
let mut messages = vec![
msg("system", "You are helpful."),
msg("user", "Hello"),
msg("assistant", "Hi!"),
];
let config = HistoryPrunerConfig {
enabled: true,
max_tokens: 8192,
keep_recent: 2,
collapse_tool_results: false,
};
let stats = prune_history(&mut messages, &config);
assert_eq!(messages.len(), 3);
assert_eq!(stats.collapsed_pairs, 0);
assert_eq!(stats.dropped_messages, 0);
}
#[test]
fn prune_collapses_tool_pairs() {
let tool_result = "a".repeat(160);
let mut messages = vec![
msg("system", "sys"),
msg("assistant", "calling tool X"),
msg("tool", &tool_result),
msg("user", "thanks"),
msg("assistant", "done"),
];
let config = HistoryPrunerConfig {
enabled: true,
max_tokens: 100_000,
keep_recent: 2,
collapse_tool_results: true,
};
let stats = prune_history(&mut messages, &config);
assert_eq!(stats.collapsed_pairs, 1);
assert_eq!(messages.len(), 4);
assert_eq!(messages[1].role, "assistant");
assert!(messages[1].content.starts_with("[Tool result: "));
}
#[test]
fn prune_preserves_system_and_recent() {
let big = "x".repeat(40_000);
let mut messages = vec![
msg("system", "system prompt"),
msg("user", &big),
msg("assistant", "old reply"),
msg("user", "recent1"),
msg("assistant", "recent2"),
];
let config = HistoryPrunerConfig {
enabled: true,
max_tokens: 100,
keep_recent: 2,
collapse_tool_results: false,
};
let stats = prune_history(&mut messages, &config);
assert!(messages.iter().any(|m| m.role == "system"));
assert!(messages.iter().any(|m| m.content == "recent1"));
assert!(messages.iter().any(|m| m.content == "recent2"));
assert!(stats.dropped_messages > 0);
}
#[test]
fn prune_drops_oldest_when_over_budget() {
let filler = "y".repeat(400);
let mut messages = vec![
msg("system", "sys"),
msg("user", &filler),
msg("assistant", &filler),
msg("user", "recent-user"),
msg("assistant", "recent-assistant"),
];
let config = HistoryPrunerConfig {
enabled: true,
max_tokens: 150,
keep_recent: 2,
collapse_tool_results: false,
};
let stats = prune_history(&mut messages, &config);
assert!(stats.dropped_messages >= 1);
assert_eq!(messages[0].role, "system");
assert!(messages.iter().any(|m| m.content == "recent-user"));
assert!(messages.iter().any(|m| m.content == "recent-assistant"));
}
#[test]
fn prune_empty_messages() {
let mut messages: Vec<ChatMessage> = vec![];
let config = HistoryPrunerConfig {
enabled: true,
..Default::default()
};
let stats = prune_history(&mut messages, &config);
assert_eq!(stats.messages_before, 0);
assert_eq!(stats.messages_after, 0);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,696 +0,0 @@
//! Loop detection guardrail for the agent tool-call loop.
//!
//! Monitors a sliding window of recent tool calls and their results to detect
//! three repetitive patterns that indicate the agent is stuck:
//!
//! 1. **Exact repeat** — same tool + args called 3+ times consecutively.
//! 2. **Ping-pong** — two tools alternating (A->B->A->B) for 4+ cycles.
//! 3. **No progress** — same tool called 5+ times with different args but
//! identical result hash each time.
//!
//! Detection triggers escalating responses: `Warning` -> `Block` -> `Break`.
use std::collections::hash_map::DefaultHasher;
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
// ── Configuration ────────────────────────────────────────────────
/// Configuration for the loop detector, typically derived from
/// `PacingConfig` fields at the call site.
#[derive(Debug, Clone)]
pub(crate) struct LoopDetectorConfig {
/// Master switch. When `false`, `record` always returns `Ok`.
pub enabled: bool,
/// Number of recent calls retained for pattern analysis.
pub window_size: usize,
/// How many consecutive exact-repeat calls before escalation starts.
pub max_repeats: usize,
}
impl Default for LoopDetectorConfig {
fn default() -> Self {
Self {
enabled: true,
window_size: 20,
max_repeats: 3,
}
}
}
// ── Result enum ──────────────────────────────────────────────────
/// Outcome of a loop-detection check after recording a tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LoopDetectionResult {
/// No pattern detected — continue normally.
Ok,
/// A suspicious pattern was detected; the caller should inject a
/// system-level nudge message into the conversation.
Warning(String),
/// The tool call should be refused (output replaced with an error).
Block(String),
/// The agent turn should be terminated immediately.
Break(String),
}
// ── Internal types ───────────────────────────────────────────────
/// A single recorded tool invocation inside the sliding window.
#[derive(Debug, Clone)]
struct ToolCallRecord {
/// Tool name.
name: String,
/// Hash of the serialised arguments.
args_hash: u64,
/// Hash of the tool's output/result.
result_hash: u64,
}
/// Produce a deterministic hash for a JSON value by recursively sorting
/// object keys before serialisation. This ensures `{"a":1,"b":2}` and
/// `{"b":2,"a":1}` hash identically.
fn hash_value(value: &serde_json::Value) -> u64 {
let mut hasher = DefaultHasher::new();
let canonical = serde_json::to_string(&canonicalise(value)).unwrap_or_default();
canonical.hash(&mut hasher);
hasher.finish()
}
/// Return a clone of `value` with all object keys sorted recursively.
fn canonicalise(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: Vec<(&String, &serde_json::Value)> = map.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
let new_map: serde_json::Map<String, serde_json::Value> = sorted
.into_iter()
.map(|(k, v)| (k.clone(), canonicalise(v)))
.collect();
serde_json::Value::Object(new_map)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(canonicalise).collect())
}
other => other.clone(),
}
}
fn hash_str(s: &str) -> u64 {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
// ── Detector ─────────────────────────────────────────────────────
/// Stateful loop detector that lives for the duration of a single
/// `run_tool_call_loop` invocation.
pub(crate) struct LoopDetector {
config: LoopDetectorConfig,
window: VecDeque<ToolCallRecord>,
}
impl LoopDetector {
pub fn new(config: LoopDetectorConfig) -> Self {
Self {
window: VecDeque::with_capacity(config.window_size),
config,
}
}
/// Record a completed tool call and check for loop patterns.
///
/// * `name` — tool name (e.g. `"shell"`, `"file_read"`).
/// * `args` — the arguments JSON value sent to the tool.
/// * `result` — the tool's textual output.
pub fn record(
&mut self,
name: &str,
args: &serde_json::Value,
result: &str,
) -> LoopDetectionResult {
if !self.config.enabled {
return LoopDetectionResult::Ok;
}
let record = ToolCallRecord {
name: name.to_string(),
args_hash: hash_value(args),
result_hash: hash_str(result),
};
// Maintain sliding window.
if self.window.len() >= self.config.window_size {
self.window.pop_front();
}
self.window.push_back(record);
// Run detectors in escalation order (most severe first).
if let Some(result) = self.detect_exact_repeat() {
return result;
}
if let Some(result) = self.detect_ping_pong() {
return result;
}
if let Some(result) = self.detect_no_progress() {
return result;
}
LoopDetectionResult::Ok
}
/// Pattern 1: Same tool + same args called N+ times consecutively.
///
/// Escalation:
/// - N == max_repeats -> Warning
/// - N == max_repeats + 1 -> Block
/// - N >= max_repeats + 2 -> Break (circuit breaker)
fn detect_exact_repeat(&self) -> Option<LoopDetectionResult> {
let max = self.config.max_repeats;
if self.window.len() < max {
return None;
}
let last = self.window.back()?;
let consecutive = self
.window
.iter()
.rev()
.take_while(|r| r.name == last.name && r.args_hash == last.args_hash)
.count();
if consecutive >= max + 2 {
Some(LoopDetectionResult::Break(format!(
"Circuit breaker: tool '{}' called {} times consecutively with identical arguments",
last.name, consecutive
)))
} else if consecutive > max {
Some(LoopDetectionResult::Block(format!(
"Blocked: tool '{}' called {} times consecutively with identical arguments",
last.name, consecutive
)))
} else if consecutive >= max {
Some(LoopDetectionResult::Warning(format!(
"Warning: tool '{}' has been called {} times consecutively with identical arguments. \
Try a different approach.",
last.name, consecutive
)))
} else {
None
}
}
/// Pattern 2: Two tools alternating (A->B->A->B) for 4+ full cycles
/// (i.e. 8 consecutive entries following the pattern).
fn detect_ping_pong(&self) -> Option<LoopDetectionResult> {
const MIN_CYCLES: usize = 4;
let needed = MIN_CYCLES * 2; // each cycle = 2 calls
if self.window.len() < needed {
return None;
}
let tail: Vec<&ToolCallRecord> = self.window.iter().rev().take(needed).collect();
// tail[0] is most recent; pattern: A, B, A, B, ...
let a_name = &tail[0].name;
let b_name = &tail[1].name;
if a_name == b_name {
return None;
}
let is_ping_pong = tail.iter().enumerate().all(|(i, r)| {
if i % 2 == 0 {
&r.name == a_name
} else {
&r.name == b_name
}
});
if !is_ping_pong {
return None;
}
// Count total alternating length for escalation.
let mut cycles = MIN_CYCLES;
let extended: Vec<&ToolCallRecord> = self.window.iter().rev().collect();
for extra_pair in extended.chunks(2).skip(MIN_CYCLES) {
if extra_pair.len() == 2
&& &extra_pair[0].name == a_name
&& &extra_pair[1].name == b_name
{
cycles += 1;
} else {
break;
}
}
if cycles >= MIN_CYCLES + 2 {
Some(LoopDetectionResult::Break(format!(
"Circuit breaker: tools '{}' and '{}' have been alternating for {} cycles",
a_name, b_name, cycles
)))
} else if cycles > MIN_CYCLES {
Some(LoopDetectionResult::Block(format!(
"Blocked: tools '{}' and '{}' have been alternating for {} cycles",
a_name, b_name, cycles
)))
} else {
Some(LoopDetectionResult::Warning(format!(
"Warning: tools '{}' and '{}' appear to be alternating ({} cycles). \
Consider a different strategy.",
a_name, b_name, cycles
)))
}
}
/// Pattern 3: Same tool called 5+ times (with different args each time)
/// but producing the exact same result hash every time.
fn detect_no_progress(&self) -> Option<LoopDetectionResult> {
const MIN_CALLS: usize = 5;
if self.window.len() < MIN_CALLS {
return None;
}
let last = self.window.back()?;
let same_tool_same_result: Vec<&ToolCallRecord> = self
.window
.iter()
.rev()
.take_while(|r| r.name == last.name && r.result_hash == last.result_hash)
.collect();
let count = same_tool_same_result.len();
if count < MIN_CALLS {
return None;
}
// Verify they have *different* args (otherwise exact_repeat handles it).
let unique_args: std::collections::HashSet<u64> =
same_tool_same_result.iter().map(|r| r.args_hash).collect();
if unique_args.len() < 2 {
// All same args — this is exact-repeat territory, not no-progress.
return None;
}
if count >= MIN_CALLS + 2 {
Some(LoopDetectionResult::Break(format!(
"Circuit breaker: tool '{}' called {} times with different arguments but identical results — no progress",
last.name, count
)))
} else if count > MIN_CALLS {
Some(LoopDetectionResult::Block(format!(
"Blocked: tool '{}' called {} times with different arguments but identical results",
last.name, count
)))
} else {
Some(LoopDetectionResult::Warning(format!(
"Warning: tool '{}' called {} times with different arguments but identical results. \
The current approach may not be making progress.",
last.name, count
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn default_config() -> LoopDetectorConfig {
LoopDetectorConfig::default()
}
fn config_with_repeats(max_repeats: usize) -> LoopDetectorConfig {
LoopDetectorConfig {
enabled: true,
window_size: 20,
max_repeats,
}
}
// ── Exact repeat tests ───────────────────────────────────────
#[test]
fn exact_repeat_warning_at_threshold() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"path": "/tmp/foo"});
assert_eq!(
det.record("file_read", &args, "contents"),
LoopDetectionResult::Ok
);
assert_eq!(
det.record("file_read", &args, "contents"),
LoopDetectionResult::Ok
);
// 3rd consecutive = warning
match det.record("file_read", &args, "contents") {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("file_read"));
assert!(msg.contains("3 times"));
}
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn exact_repeat_block_at_threshold_plus_one() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"cmd": "ls"});
for _ in 0..3 {
det.record("shell", &args, "output");
}
match det.record("shell", &args, "output") {
LoopDetectionResult::Block(msg) => {
assert!(msg.contains("shell"));
assert!(msg.contains("4 times"));
}
other => panic!("expected Block, got {other:?}"),
}
}
#[test]
fn exact_repeat_break_at_threshold_plus_two() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"q": "test"});
for _ in 0..4 {
det.record("search", &args, "no results");
}
match det.record("search", &args, "no results") {
LoopDetectionResult::Break(msg) => {
assert!(msg.contains("Circuit breaker"));
assert!(msg.contains("search"));
}
other => panic!("expected Break, got {other:?}"),
}
}
#[test]
fn exact_repeat_resets_on_different_call() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"x": 1});
det.record("tool_a", &args, "r1");
det.record("tool_a", &args, "r1");
// Interject a different tool — resets the streak.
det.record("tool_b", &json!({}), "r2");
det.record("tool_a", &args, "r1");
det.record("tool_a", &args, "r1");
// Only 2 consecutive now, should be Ok.
assert_eq!(
det.record("tool_a", &json!({"x": 999}), "r1"),
LoopDetectionResult::Ok
);
}
// ── Ping-pong tests ──────────────────────────────────────────
#[test]
fn ping_pong_warning_at_four_cycles() {
let mut det = LoopDetector::new(default_config());
let args = json!({});
// 4 full cycles = 8 calls: A B A B A B A B
for i in 0..8 {
let name = if i % 2 == 0 { "read" } else { "write" };
let result = det.record(name, &args, &format!("r{i}"));
if i < 7 {
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
} else {
match result {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("read"));
assert!(msg.contains("write"));
assert!(msg.contains("4 cycles"));
}
other => panic!("expected Warning at cycle 4, got {other:?}"),
}
}
}
}
#[test]
fn ping_pong_escalates_with_more_cycles() {
let mut det = LoopDetector::new(default_config());
let args = json!({});
// 5 cycles = 10 calls. The 10th call (completing cycle 5) triggers Block.
for i in 0..10 {
let name = if i % 2 == 0 { "fetch" } else { "parse" };
det.record(name, &args, &format!("r{i}"));
}
// 11th call extends to 5.5 cycles; detector still counts 5 full -> Block.
let r = det.record("fetch", &args, "r10");
match r {
LoopDetectionResult::Block(msg) => {
assert!(msg.contains("fetch"));
assert!(msg.contains("parse"));
assert!(msg.contains("5 cycles"));
}
other => panic!("expected Block at 5 cycles, got {other:?}"),
}
}
#[test]
fn ping_pong_not_triggered_for_same_tool() {
let mut det = LoopDetector::new(default_config());
let args = json!({});
// Same tool repeated is not ping-pong.
for _ in 0..10 {
det.record("read", &args, "data");
}
// The exact_repeat detector fires, not ping_pong.
// Verify by checking message content doesn't mention "alternating".
let r = det.record("read", &args, "data");
if let LoopDetectionResult::Break(msg) | LoopDetectionResult::Block(msg) = r {
assert!(
!msg.contains("alternating"),
"should be exact-repeat, not ping-pong"
);
}
}
// ── No-progress tests ────────────────────────────────────────
#[test]
fn no_progress_warning_at_five_different_args_same_result() {
let mut det = LoopDetector::new(default_config());
for i in 0..5 {
let args = json!({"query": format!("attempt_{i}")});
let result = det.record("search", &args, "no results found");
if i < 4 {
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
} else {
match result {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("search"));
assert!(msg.contains("identical results"));
}
other => panic!("expected Warning, got {other:?}"),
}
}
}
}
#[test]
fn no_progress_escalates_to_block_and_break() {
let mut det = LoopDetector::new(default_config());
// 6 calls with different args, same result.
for i in 0..6 {
let args = json!({"q": format!("v{i}")});
det.record("web_fetch", &args, "timeout");
}
// 7th call: count=7 which is >= MIN_CALLS(5)+2 -> Break.
let r7 = det.record("web_fetch", &json!({"q": "v6"}), "timeout");
match r7 {
LoopDetectionResult::Break(msg) => {
assert!(msg.contains("web_fetch"));
assert!(msg.contains("7 times"));
assert!(msg.contains("no progress"));
}
other => panic!("expected Break at 7 calls, got {other:?}"),
}
}
#[test]
fn no_progress_not_triggered_when_results_differ() {
let mut det = LoopDetector::new(default_config());
for i in 0..8 {
let args = json!({"q": format!("v{i}")});
let result = det.record("search", &args, &format!("result_{i}"));
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
}
}
#[test]
fn no_progress_not_triggered_when_all_args_identical() {
// If args are all the same, exact_repeat should fire, not no_progress.
let mut det = LoopDetector::new(config_with_repeats(6));
let args = json!({"q": "same"});
for _ in 0..5 {
det.record("search", &args, "no results");
}
// 6th call = exact repeat at threshold (max_repeats=6) -> Warning.
// no_progress requires >=2 unique args, so it must NOT fire.
let r = det.record("search", &args, "no results");
match r {
LoopDetectionResult::Warning(msg) => {
assert!(
msg.contains("identical arguments"),
"should be exact-repeat Warning, got: {msg}"
);
}
other => panic!("expected exact-repeat Warning, got {other:?}"),
}
}
// ── Disabled / config tests ──────────────────────────────────
#[test]
fn disabled_detector_always_returns_ok() {
let config = LoopDetectorConfig {
enabled: false,
..Default::default()
};
let mut det = LoopDetector::new(config);
let args = json!({"x": 1});
for _ in 0..20 {
assert_eq!(det.record("tool", &args, "same"), LoopDetectionResult::Ok);
}
}
#[test]
fn window_size_limits_memory() {
let config = LoopDetectorConfig {
enabled: true,
window_size: 5,
max_repeats: 3,
};
let mut det = LoopDetector::new(config);
let args = json!({"x": 1});
// Fill window with 5 different tools.
for i in 0..5 {
det.record(&format!("tool_{i}"), &args, "result");
}
assert_eq!(det.window.len(), 5);
// Adding one more evicts the oldest.
det.record("tool_5", &args, "result");
assert_eq!(det.window.len(), 5);
assert_eq!(det.window.front().unwrap().name, "tool_1");
}
// ── Ping-pong with varying args ─────────────────────────────
#[test]
fn ping_pong_detects_alternation_with_varying_args() {
let mut det = LoopDetector::new(default_config());
// A->B->A->B with different args each time — ping-pong cares only
// about tool names, not argument equality.
for i in 0..8 {
let name = if i % 2 == 0 { "read" } else { "write" };
let args = json!({"attempt": i});
let result = det.record(name, &args, &format!("r{i}"));
if i < 7 {
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
} else {
match result {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("read"));
assert!(msg.contains("write"));
assert!(msg.contains("4 cycles"));
}
other => panic!("expected Warning at cycle 4, got {other:?}"),
}
}
}
}
// ── Window eviction test ────────────────────────────────────
#[test]
fn window_eviction_prevents_stale_pattern_detection() {
let config = LoopDetectorConfig {
enabled: true,
window_size: 6,
max_repeats: 3,
};
let mut det = LoopDetector::new(config);
let args = json!({"x": 1});
// 2 consecutive calls of "tool_a".
det.record("tool_a", &args, "r");
det.record("tool_a", &args, "r");
// Fill the rest of the window with different tools (evicting the
// first "tool_a" calls as the window is only 6).
for i in 0..5 {
det.record(&format!("other_{i}"), &json!({}), "ok");
}
// Now "tool_a" again — only 1 consecutive, not 3.
let r = det.record("tool_a", &args, "r");
assert_eq!(
r,
LoopDetectionResult::Ok,
"stale entries should be evicted"
);
}
// ── hash_value key-order independence ────────────────────────
#[test]
fn hash_value_is_key_order_independent() {
let a = json!({"alpha": 1, "beta": 2});
let b = json!({"beta": 2, "alpha": 1});
assert_eq!(
hash_value(&a),
hash_value(&b),
"hash_value must produce identical hashes regardless of JSON key order"
);
}
#[test]
fn hash_value_nested_key_order_independent() {
let a = json!({"outer": {"x": 1, "y": 2}, "z": [1, 2]});
let b = json!({"z": [1, 2], "outer": {"y": 2, "x": 1}});
assert_eq!(
hash_value(&a),
hash_value(&b),
"nested objects must also be key-order independent"
);
}
// ── Escalation order tests ───────────────────────────────────
#[test]
fn exact_repeat_takes_priority_over_no_progress() {
// If tool+args are identical, exact_repeat fires before no_progress.
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"q": "same"});
det.record("s", &args, "r");
det.record("s", &args, "r");
let r = det.record("s", &args, "r");
match r {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("identical arguments"));
}
other => panic!("expected exact-repeat Warning, got {other:?}"),
}
}
}

View File

@ -1,4 +1,4 @@
use crate::memory::{self, decay, Memory};
use crate::memory::{self, Memory};
use async_trait::async_trait;
use std::fmt::Write;
@ -43,16 +43,11 @@ impl MemoryLoader for DefaultMemoryLoader {
user_message: &str,
session_id: Option<&str>,
) -> anyhow::Result<String> {
let mut entries = memory
.recall(user_message, self.limit, session_id, None, None)
.await?;
let entries = memory.recall(user_message, self.limit, session_id).await?;
if entries.is_empty() {
return Ok(String::new());
}
// Apply time decay: older non-Core memories score lower
decay::apply_time_decay(&mut entries, decay::DEFAULT_HALF_LIFE_DAYS);
let mut context = String::from("[Memory context]\n");
for entry in entries {
if memory::is_assistant_autosave_key(&entry.key) {
@ -107,8 +102,6 @@ mod tests {
_query: &str,
limit: usize,
_session_id: Option<&str>,
_since: Option<&str>,
_until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
if limit == 0 {
return Ok(vec![]);
@ -121,9 +114,6 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score: None,
namespace: "default".into(),
importance: None,
superseded_by: None,
}])
}
@ -173,8 +163,6 @@ mod tests {
_query: &str,
_limit: usize,
_session_id: Option<&str>,
_since: Option<&str>,
_until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(self.entries.as_ref().clone())
}
@ -232,9 +220,6 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score: Some(0.95),
namespace: "default".into(),
importance: None,
superseded_by: None,
},
MemoryEntry {
id: "2".into(),
@ -244,9 +229,6 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score: Some(0.9),
namespace: "default".into(),
importance: None,
superseded_by: None,
},
]),
};

View File

@ -1,20 +1,15 @@
#[allow(clippy::module_inception)]
pub mod agent;
pub mod classifier;
pub mod context_analyzer;
pub mod dispatcher;
pub mod eval;
pub mod history_pruner;
pub mod loop_;
pub mod loop_detector;
pub mod memory_loader;
pub mod prompt;
pub mod thinking;
#[cfg(test)]
mod tests;
#[allow(unused_imports)]
pub use agent::{Agent, AgentBuilder, TurnEvent};
pub use agent::{Agent, AgentBuilder};
#[allow(unused_imports)]
pub use loop_::{process_message, run};

View File

@ -5,7 +5,7 @@ use crate::security::AutonomyLevel;
use crate::skills::Skill;
use crate::tools::Tool;
use anyhow::Result;
use chrono::{Datelike, Local, Timelike};
use chrono::Local;
use std::fmt::Write;
use std::path::Path;
@ -47,13 +47,13 @@ impl SystemPromptBuilder {
pub fn with_defaults() -> Self {
Self {
sections: vec![
Box::new(DateTimeSection),
Box::new(IdentitySection),
Box::new(ToolHonestySection),
Box::new(ToolsSection),
Box::new(SafetySection),
Box::new(SkillsSection),
Box::new(WorkspaceSection),
Box::new(DateTimeSection),
Box::new(RuntimeSection),
Box::new(ChannelMediaSection),
],
@ -278,19 +278,10 @@ impl PromptSection for DateTimeSection {
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
let now = Local::now();
// Force Gregorian year to avoid confusion with local calendars (e.g. Buddhist calendar).
let (year, month, day) = (now.year(), now.month(), now.day());
let (hour, minute, second) = (now.hour(), now.minute(), now.second());
let tz = now.format("%Z");
Ok(format!(
"## CRITICAL CONTEXT: CURRENT DATE & TIME\n\n\
The following is the ABSOLUTE TRUTH regarding the current date and time. \
Use this for all relative time calculations (e.g. \"last 7 days\").\n\n\
Date: {year:04}-{month:02}-{day:02}\n\
Time: {hour:02}:{minute:02}:{second:02} ({tz})\n\
ISO 8601: {year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{}",
now.format("%:z")
"## Current Date & Time\n\n{} ({})",
now.format("%Y-%m-%d %H:%M:%S"),
now.format("%Z")
))
}
}
@ -482,9 +473,8 @@ mod tests {
assert!(output.contains("<available_skills>"));
assert!(output.contains("<name>deploy</name>"));
assert!(output.contains("<instruction>Run smoke tests before deploy.</instruction>"));
// Registered tools (shell kind) appear under <callable_tools> with prefixed names
assert!(output.contains("<callable_tools"));
assert!(output.contains("<name>deploy.release_checklist</name>"));
assert!(output.contains("<name>release_checklist</name>"));
assert!(output.contains("<kind>shell</kind>"));
}
#[test]
@ -526,10 +516,10 @@ mod tests {
assert!(output.contains("<location>skills/deploy/SKILL.md</location>"));
assert!(output.contains("read_skill(name)"));
assert!(!output.contains("<instruction>Run smoke tests before deploy.</instruction>"));
// Compact mode should still include tools so the LLM knows about them.
// Registered tools (shell kind) appear under <callable_tools> with prefixed names.
assert!(output.contains("<callable_tools"));
assert!(output.contains("<name>deploy.release_checklist</name>"));
// Compact mode should still include tools so the LLM knows about them
assert!(output.contains("<tools>"));
assert!(output.contains("<name>release_checklist</name>"));
assert!(output.contains("<kind>shell</kind>"));
}
#[test]
@ -549,12 +539,12 @@ mod tests {
};
let rendered = DateTimeSection.build(&ctx).unwrap();
assert!(rendered.starts_with("## CRITICAL CONTEXT: CURRENT DATE & TIME\n\n"));
assert!(rendered.starts_with("## Current Date & Time\n\n"));
let payload = rendered.trim_start_matches("## CRITICAL CONTEXT: CURRENT DATE & TIME\n\n");
let payload = rendered.trim_start_matches("## Current Date & Time\n\n");
assert!(payload.chars().any(|c| c.is_ascii_digit()));
assert!(payload.contains("Date:"));
assert!(payload.contains("Time:"));
assert!(payload.contains(" ("));
assert!(payload.ends_with(')'));
}
#[test]

View File

@ -1,424 +0,0 @@
//! Thinking/Reasoning Level Control
//!
//! Allows users to control how deeply the model reasons per message,
//! trading speed for depth. Levels range from `Off` (fastest, most concise)
//! to `Max` (deepest reasoning, slowest).
//!
//! Users can set the level via:
//! - Inline directive: `/think:high` at the start of a message
//! - Agent config: `[agent.thinking]` section with `default_level`
//!
//! Resolution hierarchy (highest priority first):
//! 1. Inline directive (`/think:<level>`)
//! 2. Session override (reserved for future use)
//! 3. Agent config (`agent.thinking.default_level`)
//! 4. Global default (`Medium`)
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// How deeply the model should reason for a given message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ThinkingLevel {
/// No chain-of-thought. Fastest, most concise responses.
Off,
/// Minimal reasoning. Brief, direct answers.
Minimal,
/// Light reasoning. Short explanations when needed.
Low,
/// Balanced reasoning (default). Moderate depth.
#[default]
Medium,
/// Deep reasoning. Thorough analysis and step-by-step thinking.
High,
/// Maximum reasoning depth. Exhaustive analysis.
Max,
}
impl ThinkingLevel {
/// Parse a thinking level from a string (case-insensitive).
pub fn from_str_insensitive(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"off" | "none" => Some(Self::Off),
"minimal" | "min" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" | "med" | "default" => Some(Self::Medium),
"high" => Some(Self::High),
"max" | "maximum" => Some(Self::Max),
_ => None,
}
}
}
/// Configuration for thinking/reasoning level control.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThinkingConfig {
/// Default thinking level when no directive is present.
#[serde(default)]
pub default_level: ThinkingLevel,
}
impl Default for ThinkingConfig {
fn default() -> Self {
Self {
default_level: ThinkingLevel::Medium,
}
}
}
/// Parameters derived from a thinking level, applied to the LLM request.
#[derive(Debug, Clone, PartialEq)]
pub struct ThinkingParams {
/// Temperature adjustment (added to the base temperature, clamped to 0.0..=2.0).
pub temperature_adjustment: f64,
/// Maximum tokens adjustment (added to any existing max_tokens setting).
pub max_tokens_adjustment: i64,
/// Optional system prompt prefix injected before the existing system prompt.
pub system_prompt_prefix: Option<String>,
}
/// Parse a `/think:<level>` directive from the start of a message.
///
/// Returns `Some((level, remaining_message))` if a directive is found,
/// or `None` if no directive is present. The remaining message has
/// leading whitespace after the directive trimmed.
pub fn parse_thinking_directive(message: &str) -> Option<(ThinkingLevel, String)> {
let trimmed = message.trim_start();
if !trimmed.starts_with("/think:") {
return None;
}
// Extract the level token (everything between `/think:` and the next whitespace or end).
let after_prefix = &trimmed["/think:".len()..];
let level_end = after_prefix
.find(|c: char| c.is_whitespace())
.unwrap_or(after_prefix.len());
let level_str = &after_prefix[..level_end];
let level = ThinkingLevel::from_str_insensitive(level_str)?;
let remaining = after_prefix[level_end..].trim_start().to_string();
Some((level, remaining))
}
/// Convert a `ThinkingLevel` into concrete parameters for the LLM request.
pub fn apply_thinking_level(level: ThinkingLevel) -> ThinkingParams {
match level {
ThinkingLevel::Off => ThinkingParams {
temperature_adjustment: -0.2,
max_tokens_adjustment: -1000,
system_prompt_prefix: Some(
"Be extremely concise. Give direct answers without explanation \
unless explicitly asked. No preamble."
.into(),
),
},
ThinkingLevel::Minimal => ThinkingParams {
temperature_adjustment: -0.1,
max_tokens_adjustment: -500,
system_prompt_prefix: Some(
"Be concise and fast. Keep explanations brief. \
Prioritize speed over thoroughness."
.into(),
),
},
ThinkingLevel::Low => ThinkingParams {
temperature_adjustment: -0.05,
max_tokens_adjustment: 0,
system_prompt_prefix: Some("Keep reasoning light. Explain only when helpful.".into()),
},
ThinkingLevel::Medium => ThinkingParams {
temperature_adjustment: 0.0,
max_tokens_adjustment: 0,
system_prompt_prefix: None,
},
ThinkingLevel::High => ThinkingParams {
temperature_adjustment: 0.05,
max_tokens_adjustment: 1000,
system_prompt_prefix: Some(
"Think step by step. Provide thorough analysis and \
consider edge cases before answering."
.into(),
),
},
ThinkingLevel::Max => ThinkingParams {
temperature_adjustment: 0.1,
max_tokens_adjustment: 2000,
system_prompt_prefix: Some(
"Think very carefully and exhaustively. Break down the problem \
into sub-problems, consider all angles, verify your reasoning, \
and provide the most thorough analysis possible."
.into(),
),
},
}
}
/// Resolve the effective thinking level using the priority hierarchy:
/// 1. Inline directive (if present)
/// 2. Session override (reserved, currently always `None`)
/// 3. Agent config default
/// 4. Global default (`Medium`)
pub fn resolve_thinking_level(
inline_directive: Option<ThinkingLevel>,
session_override: Option<ThinkingLevel>,
config: &ThinkingConfig,
) -> ThinkingLevel {
inline_directive
.or(session_override)
.unwrap_or(config.default_level)
}
/// Clamp a temperature value to the valid range `[0.0, 2.0]`.
pub fn clamp_temperature(temp: f64) -> f64 {
temp.clamp(0.0, 2.0)
}
#[cfg(test)]
mod tests {
use super::*;
// ── ThinkingLevel parsing ────────────────────────────────────
#[test]
fn thinking_level_from_str_canonical_names() {
assert_eq!(
ThinkingLevel::from_str_insensitive("off"),
Some(ThinkingLevel::Off)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("minimal"),
Some(ThinkingLevel::Minimal)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("low"),
Some(ThinkingLevel::Low)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("medium"),
Some(ThinkingLevel::Medium)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("high"),
Some(ThinkingLevel::High)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("max"),
Some(ThinkingLevel::Max)
);
}
#[test]
fn thinking_level_from_str_aliases() {
assert_eq!(
ThinkingLevel::from_str_insensitive("none"),
Some(ThinkingLevel::Off)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("min"),
Some(ThinkingLevel::Minimal)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("med"),
Some(ThinkingLevel::Medium)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("default"),
Some(ThinkingLevel::Medium)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("maximum"),
Some(ThinkingLevel::Max)
);
}
#[test]
fn thinking_level_from_str_case_insensitive() {
assert_eq!(
ThinkingLevel::from_str_insensitive("HIGH"),
Some(ThinkingLevel::High)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("Max"),
Some(ThinkingLevel::Max)
);
assert_eq!(
ThinkingLevel::from_str_insensitive("OFF"),
Some(ThinkingLevel::Off)
);
}
#[test]
fn thinking_level_from_str_invalid_returns_none() {
assert_eq!(ThinkingLevel::from_str_insensitive("turbo"), None);
assert_eq!(ThinkingLevel::from_str_insensitive(""), None);
assert_eq!(ThinkingLevel::from_str_insensitive("super-high"), None);
}
// ── Directive parsing ────────────────────────────────────────
#[test]
fn parse_directive_extracts_level_and_remaining_message() {
let result = parse_thinking_directive("/think:high What is Rust?");
assert!(result.is_some());
let (level, remaining) = result.unwrap();
assert_eq!(level, ThinkingLevel::High);
assert_eq!(remaining, "What is Rust?");
}
#[test]
fn parse_directive_handles_directive_only() {
let result = parse_thinking_directive("/think:off");
assert!(result.is_some());
let (level, remaining) = result.unwrap();
assert_eq!(level, ThinkingLevel::Off);
assert_eq!(remaining, "");
}
#[test]
fn parse_directive_strips_leading_whitespace() {
let result = parse_thinking_directive(" /think:low Tell me about Rust");
assert!(result.is_some());
let (level, remaining) = result.unwrap();
assert_eq!(level, ThinkingLevel::Low);
assert_eq!(remaining, "Tell me about Rust");
}
#[test]
fn parse_directive_returns_none_for_no_directive() {
assert!(parse_thinking_directive("Hello world").is_none());
assert!(parse_thinking_directive("").is_none());
assert!(parse_thinking_directive("/think").is_none());
}
#[test]
fn parse_directive_returns_none_for_invalid_level() {
assert!(parse_thinking_directive("/think:turbo What?").is_none());
}
#[test]
fn parse_directive_not_triggered_mid_message() {
assert!(parse_thinking_directive("Hello /think:high world").is_none());
}
// ── Level application ────────────────────────────────────────
#[test]
fn apply_thinking_level_off_is_concise() {
let params = apply_thinking_level(ThinkingLevel::Off);
assert!(params.temperature_adjustment < 0.0);
assert!(params.max_tokens_adjustment < 0);
assert!(params.system_prompt_prefix.is_some());
assert!(params
.system_prompt_prefix
.unwrap()
.to_lowercase()
.contains("concise"));
}
#[test]
fn apply_thinking_level_medium_is_neutral() {
let params = apply_thinking_level(ThinkingLevel::Medium);
assert!((params.temperature_adjustment - 0.0).abs() < f64::EPSILON);
assert_eq!(params.max_tokens_adjustment, 0);
assert!(params.system_prompt_prefix.is_none());
}
#[test]
fn apply_thinking_level_high_adds_step_by_step() {
let params = apply_thinking_level(ThinkingLevel::High);
assert!(params.temperature_adjustment > 0.0);
assert!(params.max_tokens_adjustment > 0);
let prefix = params.system_prompt_prefix.unwrap();
assert!(prefix.to_lowercase().contains("step by step"));
}
#[test]
fn apply_thinking_level_max_is_most_thorough() {
let params = apply_thinking_level(ThinkingLevel::Max);
assert!(params.temperature_adjustment > 0.0);
assert!(params.max_tokens_adjustment > 0);
let prefix = params.system_prompt_prefix.unwrap();
assert!(prefix.to_lowercase().contains("exhaustively"));
}
// ── Resolution hierarchy ─────────────────────────────────────
#[test]
fn resolve_inline_directive_takes_priority() {
let config = ThinkingConfig {
default_level: ThinkingLevel::Low,
};
let result =
resolve_thinking_level(Some(ThinkingLevel::Max), Some(ThinkingLevel::High), &config);
assert_eq!(result, ThinkingLevel::Max);
}
#[test]
fn resolve_session_override_takes_priority_over_config() {
let config = ThinkingConfig {
default_level: ThinkingLevel::Low,
};
let result = resolve_thinking_level(None, Some(ThinkingLevel::High), &config);
assert_eq!(result, ThinkingLevel::High);
}
#[test]
fn resolve_falls_back_to_config_default() {
let config = ThinkingConfig {
default_level: ThinkingLevel::Minimal,
};
let result = resolve_thinking_level(None, None, &config);
assert_eq!(result, ThinkingLevel::Minimal);
}
#[test]
fn resolve_default_config_uses_medium() {
let config = ThinkingConfig::default();
let result = resolve_thinking_level(None, None, &config);
assert_eq!(result, ThinkingLevel::Medium);
}
// ── Temperature clamping ─────────────────────────────────────
#[test]
fn clamp_temperature_within_range() {
assert!((clamp_temperature(0.7) - 0.7).abs() < f64::EPSILON);
assert!((clamp_temperature(0.0) - 0.0).abs() < f64::EPSILON);
assert!((clamp_temperature(2.0) - 2.0).abs() < f64::EPSILON);
}
#[test]
fn clamp_temperature_below_minimum() {
assert!((clamp_temperature(-0.5) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn clamp_temperature_above_maximum() {
assert!((clamp_temperature(3.0) - 2.0).abs() < f64::EPSILON);
}
// ── Serde round-trip ─────────────────────────────────────────
#[test]
fn thinking_config_deserializes_from_toml() {
let toml_str = r#"default_level = "high""#;
let config: ThinkingConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.default_level, ThinkingLevel::High);
}
#[test]
fn thinking_config_default_level_deserializes() {
let toml_str = "";
let config: ThinkingConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.default_level, ThinkingLevel::Medium);
}
#[test]
fn thinking_level_serializes_lowercase() {
let level = ThinkingLevel::High;
let json = serde_json::to_string(&level).unwrap();
assert_eq!(json, "\"high\"");
}
}

View File

@ -122,7 +122,7 @@ impl ApprovalManager {
}
// always_ask overrides everything.
if self.always_ask.contains("*") || self.always_ask.contains(tool_name) {
if self.always_ask.contains(tool_name) {
return true;
}
@ -136,7 +136,7 @@ impl ApprovalManager {
}
// auto_approve skips the prompt.
if self.auto_approve.contains("*") || self.auto_approve.contains(tool_name) {
if self.auto_approve.contains(tool_name) {
return false;
}
@ -562,50 +562,4 @@ mod tests {
let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tool_name, "shell");
}
// ── Regression: #4247 default approved tools in channels ──
#[test]
fn non_interactive_allows_default_auto_approve_tools() {
let config = AutonomyConfig::default();
let mgr = ApprovalManager::for_non_interactive(&config);
for tool in &config.auto_approve {
assert!(
!mgr.needs_approval(tool),
"default auto_approve tool '{tool}' should not need approval in non-interactive mode"
);
}
}
#[test]
fn non_interactive_denies_unknown_tools() {
let config = AutonomyConfig::default();
let mgr = ApprovalManager::for_non_interactive(&config);
assert!(
mgr.needs_approval("some_unknown_tool"),
"unknown tool should need approval"
);
}
#[test]
fn non_interactive_weather_is_auto_approved() {
let config = AutonomyConfig::default();
let mgr = ApprovalManager::for_non_interactive(&config);
assert!(
!mgr.needs_approval("weather"),
"weather tool must not need approval — it is in the default auto_approve list"
);
}
#[test]
fn always_ask_overrides_auto_approve() {
let mut config = AutonomyConfig::default();
config.always_ask = vec!["weather".into()];
let mgr = ApprovalManager::for_non_interactive(&config);
assert!(
mgr.needs_approval("weather"),
"always_ask must override auto_approve"
);
}
}

View File

@ -252,7 +252,6 @@ impl BlueskyChannel {
timestamp,
thread_ts: Some(notif.uri.clone()),
interruption_scope_id: None,
attachments: vec![],
})
}

View File

@ -49,7 +49,6 @@ impl Channel for CliChannel {
.as_secs(),
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
if tx.send(msg).await.is_err() {
@ -114,7 +113,6 @@ mod tests {
timestamp: 1_234_567_890,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
assert_eq!(msg.id, "test-id");
assert_eq!(msg.sender, "user");
@ -135,7 +133,6 @@ mod tests {
timestamp: 0,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
let cloned = msg.clone();
assert_eq!(cloned.id, msg.id);

View File

@ -162,12 +162,7 @@ impl Channel for DingTalkChannel {
let ws_url = format!("{}?ticket={}", gw.endpoint, gw.ticket);
tracing::info!("DingTalk: connecting to stream WebSocket...");
let (ws_stream, _) = crate::config::ws_connect_with_proxy(
&ws_url,
"channel.dingtalk",
self.proxy_url.as_deref(),
)
.await?;
let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
let (mut write, mut read) = ws_stream.split();
tracing::info!("DingTalk: connected and listening for messages...");
@ -290,7 +285,6 @@ impl Channel for DingTalkChannel {
.as_secs(),
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
if tx.send(channel_msg).await.is_err() {

View File

@ -20,10 +20,6 @@ pub struct DiscordChannel {
typing_handles: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
/// Voice transcription config — when set, audio attachments are
/// downloaded, transcribed, and their text inlined into the message.
transcription: Option<crate::config::TranscriptionConfig>,
transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
}
impl DiscordChannel {
@ -42,8 +38,6 @@ impl DiscordChannel {
mention_only,
typing_handles: Mutex::new(HashMap::new()),
proxy_url: None,
transcription: None,
transcription_manager: None,
}
}
@ -53,25 +47,6 @@ impl DiscordChannel {
self
}
/// Configure voice transcription for audio attachments.
pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
if !config.enabled {
return self;
}
match super::transcription::TranscriptionManager::new(&config) {
Ok(m) => {
self.transcription_manager = Some(std::sync::Arc::new(m));
self.transcription = Some(config);
}
Err(e) => {
tracing::warn!(
"transcription manager init failed, voice transcription disabled: {e}"
);
}
}
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_channel_proxy_client("channel.discord", self.proxy_url.as_deref())
}
@ -138,88 +113,6 @@ async fn process_attachments(
parts.join("\n---\n")
}
/// Audio file extensions accepted for voice transcription.
const DISCORD_AUDIO_EXTENSIONS: &[&str] = &[
"flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
];
/// Check if a content type or filename indicates an audio file.
fn is_discord_audio_attachment(content_type: &str, filename: &str) -> bool {
if content_type.starts_with("audio/") {
return true;
}
if let Some(ext) = filename.rsplit('.').next() {
return DISCORD_AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str());
}
false
}
/// Download and transcribe audio attachments from a Discord message.
///
/// Returns transcribed text blocks for any audio attachments found.
/// Non-audio attachments and failures are silently skipped.
async fn transcribe_discord_audio_attachments(
attachments: &[serde_json::Value],
client: &reqwest::Client,
manager: &super::transcription::TranscriptionManager,
) -> String {
let mut parts: Vec<String> = Vec::new();
for att in attachments {
let ct = att
.get("content_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let name = att
.get("filename")
.and_then(|v| v.as_str())
.unwrap_or("file");
if !is_discord_audio_attachment(ct, name) {
continue;
}
let Some(url) = att.get("url").and_then(|v| v.as_str()) else {
continue;
};
let audio_data = match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(bytes) => bytes.to_vec(),
Err(e) => {
tracing::warn!(name, error = %e, "discord: failed to read audio attachment bytes");
continue;
}
},
Ok(resp) => {
tracing::warn!(name, status = %resp.status(), "discord: audio attachment download failed");
continue;
}
Err(e) => {
tracing::warn!(name, error = %e, "discord: audio attachment fetch error");
continue;
}
};
match manager.transcribe(&audio_data, name).await {
Ok(text) => {
let trimmed = text.trim();
if !trimmed.is_empty() {
tracing::info!(
"Discord: transcribed audio attachment {} ({} chars)",
name,
trimmed.len()
);
parts.push(format!("[Voice] {trimmed}"));
}
}
Err(e) => {
tracing::warn!(name, error = %e, "discord: voice transcription failed");
}
}
}
parts.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DiscordAttachmentKind {
Image,
@ -675,12 +568,7 @@ impl Channel for DiscordChannel {
let ws_url = format!("{gw_url}/?v=10&encoding=json");
tracing::info!("Discord: connecting to gateway...");
let (ws_stream, _) = crate::config::ws_connect_with_proxy(
&ws_url,
"channel.discord",
self.proxy_url.as_deref(),
)
.await?;
let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
let (mut write, mut read) = ws_stream.split();
// Read Hello (opcode 10)
@ -849,28 +737,7 @@ impl Channel for DiscordChannel {
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default();
let client = self.http_client();
let mut text_parts = process_attachments(&atts, &client).await;
// Transcribe audio attachments when transcription is configured
if let Some(ref transcription_manager) = self.transcription_manager {
let voice_text = transcribe_discord_audio_attachments(
&atts,
&client,
transcription_manager,
)
.await;
if !voice_text.is_empty() {
if text_parts.is_empty() {
text_parts = voice_text;
} else {
text_parts = format!("{text_parts}
{voice_text}");
}
}
}
text_parts
process_attachments(&atts, &self.http_client()).await
};
let final_content = if attachment_text.is_empty() {
clean_content
@ -932,7 +799,6 @@ impl Channel for DiscordChannel {
.as_secs(),
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
if tx.send(channel_msg).await.is_err() {

View File

@ -1,555 +0,0 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use parking_lot::Mutex;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
use crate::memory::{Memory, MemoryCategory};
/// Discord History channel — connects via Gateway WebSocket, stores ALL non-bot messages
/// to a dedicated discord.db, and forwards @mention messages to the agent.
pub struct DiscordHistoryChannel {
bot_token: String,
guild_id: Option<String>,
allowed_users: Vec<String>,
/// Channel IDs to watch. Empty = watch all channels.
channel_ids: Vec<String>,
/// Dedicated discord.db memory backend.
discord_memory: Arc<dyn Memory>,
typing_handles: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
proxy_url: Option<String>,
/// When false, DM messages are not stored in discord.db.
store_dms: bool,
/// When false, @mentions in DMs are not forwarded to the agent.
respond_to_dms: bool,
}
impl DiscordHistoryChannel {
pub fn new(
bot_token: String,
guild_id: Option<String>,
allowed_users: Vec<String>,
channel_ids: Vec<String>,
discord_memory: Arc<dyn Memory>,
store_dms: bool,
respond_to_dms: bool,
) -> Self {
Self {
bot_token,
guild_id,
allowed_users,
channel_ids,
discord_memory,
typing_handles: Mutex::new(HashMap::new()),
proxy_url: None,
store_dms,
respond_to_dms,
}
}
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_channel_proxy_client(
"channel.discord_history",
self.proxy_url.as_deref(),
)
}
fn is_user_allowed(&self, user_id: &str) -> bool {
if self.allowed_users.is_empty() {
return true; // default open for logging channel
}
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
fn is_channel_watched(&self, channel_id: &str) -> bool {
self.channel_ids.is_empty() || self.channel_ids.iter().any(|c| c == channel_id)
}
fn bot_user_id_from_token(token: &str) -> Option<String> {
let part = token.split('.').next()?;
base64_decode(part)
}
async fn resolve_channel_name(&self, channel_id: &str) -> String {
// 1. Check persistent database (via discord_memory)
let cache_key = format!("cache:channel_name:{}", channel_id);
if let Ok(Some(cached_mem)) = self.discord_memory.get(&cache_key).await {
// Check if it's still fresh (e.g., less than 24 hours old)
// Note: cached_mem.timestamp is an RFC3339 string
let is_fresh =
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&cached_mem.timestamp) {
chrono::Utc::now().signed_duration_since(ts.with_timezone(&chrono::Utc))
< chrono::Duration::hours(24)
} else {
false
};
if is_fresh {
return cached_mem.content.clone();
}
}
// 2. Fetch from API (either not in DB or stale)
let url = format!("https://discord.com/api/v10/channels/{channel_id}");
let resp = self
.http_client()
.get(&url)
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
.await;
let name = if let Ok(r) = resp {
if let Ok(json) = r.json::<serde_json::Value>().await {
json.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
.or_else(|| {
// For DMs, there might not be a 'name', use the recipient's username if available
json.get("recipients")
.and_then(|r| r.as_array())
.and_then(|a| a.first())
.and_then(|u| u.get("username"))
.and_then(|un| un.as_str())
.map(|s| format!("dm-{}", s))
})
} else {
None
}
} else {
None
};
let resolved = name.unwrap_or_else(|| channel_id.to_string());
// 3. Store in persistent database
let _ = self
.discord_memory
.store(
&cache_key,
&resolved,
crate::memory::MemoryCategory::Custom("channel_cache".to_string()),
Some(channel_id),
)
.await;
resolved
}
}
const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
#[allow(clippy::cast_possible_truncation)]
fn base64_decode(input: &str) -> Option<String> {
let padded = match input.len() % 4 {
2 => format!("{input}=="),
3 => format!("{input}="),
_ => input.to_string(),
};
let mut bytes = Vec::new();
let chars: Vec<u8> = 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()
}
fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
if bot_user_id.is_empty() {
return false;
}
content.contains(&format!("<@{bot_user_id}>"))
|| content.contains(&format!("<@!{bot_user_id}>"))
}
fn strip_bot_mention(content: &str, bot_user_id: &str) -> String {
let mut result = content.to_string();
for tag in [format!("<@{bot_user_id}>"), format!("<@!{bot_user_id}>")] {
result = result.replace(&tag, " ");
}
result.trim().to_string()
}
#[async_trait]
impl Channel for DiscordHistoryChannel {
fn name(&self) -> &str {
"discord_history"
}
/// Send a reply back to Discord (used when agent responds to @mention).
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let content = super::strip_tool_call_tags(&message.content);
let url = format!(
"https://discord.com/api/v10/channels/{}/messages",
message.recipient
);
self.http_client()
.post(&url)
.header("Authorization", format!("Bot {}", self.bot_token))
.json(&json!({"content": content}))
.send()
.await?;
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> 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
.http_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!("DiscordHistory: connecting to gateway...");
let (ws_stream, _) = crate::config::ws_connect_with_proxy(
&ws_url,
"channel.discord",
self.proxy_url.as_deref(),
)
.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);
// Identify with intents for guild + DM messages + message content
let identify = json!({
"op": 2,
"d": {
"token": self.bot_token,
"intents": 37377,
"properties": {
"os": "linux",
"browser": "zeroclaw",
"device": "zeroclaw"
}
}
});
write
.send(Message::Text(identify.to_string().into()))
.await?;
tracing::info!("DiscordHistory: connected and identified");
let mut sequence: i64 = -1;
let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_millis(heartbeat_interval));
loop {
interval.tick().await;
if hb_tx.send(()).await.is_err() {
break;
}
}
});
let guild_filter = self.guild_id.clone();
let discord_memory = Arc::clone(&self.discord_memory);
let store_dms = self.store_dms;
let respond_to_dms = self.respond_to_dms;
loop {
tokio::select! {
_ = hb_rx.recv() => {
let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
let hb = json!({"op": 1, "d": d});
if write.send(Message::Text(hb.to_string().into())).await.is_err() {
break;
}
}
msg = read.next() => {
let msg = match msg {
Some(Ok(Message::Text(t))) => t,
Some(Ok(Message::Ping(payload))) => {
if write.send(Message::Pong(payload)).await.is_err() {
break;
}
continue;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => {
tracing::warn!("DiscordHistory: websocket error: {e}");
break;
}
_ => continue,
};
let event: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
Ok(e) => e,
Err(_) => continue,
};
if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) {
sequence = s;
}
let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0);
match op {
1 => {
let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
let hb = json!({"op": 1, "d": d});
if write.send(Message::Text(hb.to_string().into())).await.is_err() {
break;
}
continue;
}
7 => { tracing::warn!("DiscordHistory: Reconnect (op 7)"); break; }
9 => { tracing::warn!("DiscordHistory: Invalid Session (op 9)"); break; }
_ => {}
}
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("");
let username = d
.get("author")
.and_then(|a| a.get("username"))
.and_then(|i| i.as_str())
.unwrap_or(author_id);
if author_id == bot_user_id {
continue;
}
// Skip other bots
if d.get("author")
.and_then(|a| a.get("bot"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
continue;
}
let channel_id = d
.get("channel_id")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
// DM detection: DMs have no guild_id
let is_dm_event = d.get("guild_id").and_then(serde_json::Value::as_str).is_none();
// Resolve channel name (with cache)
let channel_display = if is_dm_event {
"dm".to_string()
} else {
self.resolve_channel_name(&channel_id).await
};
if is_dm_event && !store_dms && !respond_to_dms {
continue;
}
// Guild filter
if let Some(ref gid) = guild_filter {
let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
if let Some(g) = msg_guild {
if g != gid {
continue;
}
}
}
// Channel filter
if !self.is_channel_watched(&channel_id) {
continue;
}
if !self.is_user_allowed(author_id) {
continue;
}
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
let is_mention = contains_bot_mention(content, &bot_user_id);
// Collect attachment URLs
let attachments: Vec<String> = d
.get("attachments")
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("url").and_then(|u| u.as_str()))
.map(|u| u.to_string())
.collect()
})
.unwrap_or_default();
// Store messages to discord.db (skip DMs if store_dms=false)
if (!is_dm_event || store_dms) && (!content.is_empty() || !attachments.is_empty()) {
let ts = chrono::Utc::now().to_rfc3339();
let mut mem_content = format!(
"@{username} in #{channel_display} at {ts}: {content}"
);
if !attachments.is_empty() {
mem_content.push_str(" [attachments: ");
mem_content.push_str(&attachments.join(", "));
mem_content.push(']');
}
let mem_key = format!(
"discord_{}",
if message_id.is_empty() {
Uuid::new_v4().to_string()
} else {
message_id.to_string()
}
);
let channel_id_for_session = if channel_id.is_empty() {
None
} else {
Some(channel_id.as_str())
};
if let Err(err) = discord_memory
.store(
&mem_key,
&mem_content,
MemoryCategory::Custom("discord".to_string()),
channel_id_for_session,
)
.await
{
tracing::warn!("discord_history: failed to store message: {err}");
} else {
tracing::debug!(
"discord_history: stored message from @{username} in #{channel_display}"
);
}
}
// Forward @mention to agent (skip DMs if respond_to_dms=false)
if is_mention && (!is_dm_event || respond_to_dms) {
let clean_content = strip_bot_mention(content, &bot_user_id);
if clean_content.is_empty() {
continue;
}
let channel_msg = ChannelMessage {
id: if message_id.is_empty() {
Uuid::new_v4().to_string()
} else {
format!("discord_{message_id}")
},
sender: author_id.to_string(),
reply_target: if channel_id.is_empty() {
author_id.to_string()
} else {
channel_id.clone()
},
content: clean_content,
channel: "discord_history".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
interruption_scope_id: None,
attachments: Vec::new(),
};
if tx.send(channel_msg).await.is_err() {
break;
}
}
}
}
}
Ok(())
}
async fn health_check(&self) -> bool {
self.http_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)
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
let mut guard = self.typing_handles.lock();
if let Some(h) = guard.remove(recipient) {
h.abort();
}
let client = self.http_client();
let token = self.bot_token.clone();
let channel_id = recipient.to_string();
let handle = tokio::spawn(async move {
let url = format!("https://discord.com/api/v10/channels/{channel_id}/typing");
loop {
let _ = client
.post(&url)
.header("Authorization", format!("Bot {token}"))
.send()
.await;
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
}
});
guard.insert(recipient.to_string(), handle);
Ok(())
}
async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
let mut guard = self.typing_handles.lock();
if let Some(handle) = guard.remove(recipient) {
handle.abort();
}
Ok(())
}
}

View File

@ -468,7 +468,6 @@ impl EmailChannel {
timestamp: email.timestamp,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
if tx.send(msg).await.is_err() {

File diff suppressed because it is too large Load Diff

View File

@ -295,7 +295,6 @@ end tell"#
.as_secs(),
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
if tx.send(msg).await.is_err() {

View File

@ -581,7 +581,6 @@ impl Channel for IrcChannel {
.as_secs(),
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
if tx.send(channel_msg).await.is_err() {

File diff suppressed because it is too large Load Diff

View File

@ -1,462 +0,0 @@
//! Link enricher: auto-detects URLs in inbound messages, fetches their content,
//! and prepends summaries so the agent has link context without explicit tool calls.
use regex::Regex;
use std::net::IpAddr;
use std::sync::LazyLock;
use std::time::Duration;
/// Configuration for the link enricher pipeline stage.
#[derive(Debug, Clone)]
pub struct LinkEnricherConfig {
pub enabled: bool,
pub max_links: usize,
pub timeout_secs: u64,
}
impl Default for LinkEnricherConfig {
fn default() -> Self {
Self {
enabled: false,
max_links: 3,
timeout_secs: 10,
}
}
}
/// URL regex: matches http:// and https:// URLs, stopping at whitespace, angle
/// brackets, or double-quotes.
static URL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"https?://[^\s<>"']+"#).expect("URL regex must compile"));
/// Extract URLs from message text, returning up to `max` unique URLs.
pub fn extract_urls(text: &str, max: usize) -> Vec<String> {
let mut seen = Vec::new();
for m in URL_RE.find_iter(text) {
let url = m.as_str().to_string();
if !seen.contains(&url) {
seen.push(url);
if seen.len() >= max {
break;
}
}
}
seen
}
/// Returns `true` if the URL points to a private/local address that should be
/// blocked for SSRF protection.
pub fn is_ssrf_target(url: &str) -> bool {
let host = match extract_host(url) {
Some(h) => h,
None => return true, // unparseable URLs are rejected
};
// Check hostname-based locals
if host == "localhost"
|| host.ends_with(".localhost")
|| host.ends_with(".local")
|| host == "local"
{
return true;
}
// Check IP-based private ranges
if let Ok(ip) = host.parse::<IpAddr>() {
return is_private_ip(ip);
}
false
}
/// Extract the host portion from a URL string.
fn extract_host(url: &str) -> Option<String> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let authority = rest.split(['/', '?', '#']).next()?;
if authority.is_empty() {
return None;
}
// Strip port
let host = if authority.starts_with('[') {
// IPv6 in brackets — reject for simplicity
return None;
} else {
authority.split(':').next().unwrap_or(authority)
};
Some(host.to_lowercase())
}
/// Check if an IP address falls within private/reserved ranges.
fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback() // 127.0.0.0/8
|| v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|| v4.is_link_local() // 169.254.0.0/16
|| v4.is_unspecified() // 0.0.0.0
|| v4.is_broadcast() // 255.255.255.255
|| v4.is_multicast() // 224.0.0.0/4
}
IpAddr::V6(v6) => {
v6.is_loopback() // ::1
|| v6.is_unspecified() // ::
|| v6.is_multicast()
// Check for IPv4-mapped IPv6 addresses
|| v6.to_ipv4_mapped().is_some_and(|v4| {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
})
}
}
}
/// Extract the `<title>` tag content from HTML.
pub fn extract_title(html: &str) -> Option<String> {
// Case-insensitive search for <title>...</title>
let lower = html.to_lowercase();
let start = lower.find("<title")? + "<title".len();
// Skip attributes if any (e.g. <title lang="en">)
let start = lower[start..].find('>')? + start + 1;
let end = lower[start..].find("</title")? + start;
let title = lower[start..end].trim().to_string();
if title.is_empty() {
None
} else {
Some(html_entity_decode_basic(&title))
}
}
/// Extract the first `max_chars` of visible body text from HTML.
pub fn extract_body_text(html: &str, max_chars: usize) -> String {
let text = nanohtml2text::html2text(html);
let trimmed = text.trim();
if trimmed.len() <= max_chars {
trimmed.to_string()
} else {
let mut result: String = trimmed.chars().take(max_chars).collect();
result.push_str("...");
result
}
}
/// Basic HTML entity decoding for title content.
fn html_entity_decode_basic(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
}
/// Summary of a fetched link.
struct LinkSummary {
title: String,
snippet: String,
}
/// Fetch a single URL and extract a summary. Returns `None` on any failure.
async fn fetch_link_summary(url: &str, timeout_secs: u64) -> Option<LinkSummary> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("ZeroClaw/0.1 (link-enricher)")
.build()
.ok()?;
let response = client.get(url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
// Only process text/html responses
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase();
if !content_type.contains("text/html") && !content_type.is_empty() {
return None;
}
// Read up to 256KB to extract title and snippet
let max_bytes: usize = 256 * 1024;
let bytes = response.bytes().await.ok()?;
let body = if bytes.len() > max_bytes {
String::from_utf8_lossy(&bytes[..max_bytes]).into_owned()
} else {
String::from_utf8_lossy(&bytes).into_owned()
};
let title = extract_title(&body).unwrap_or_else(|| "Untitled".to_string());
let snippet = extract_body_text(&body, 200);
Some(LinkSummary { title, snippet })
}
/// Enrich a message by prepending link summaries for any URLs found in the text.
///
/// This is the main entry point called from the channel message processing pipeline.
/// If the enricher is disabled or no URLs are found, the original message is returned
/// unchanged.
pub async fn enrich_message(content: &str, config: &LinkEnricherConfig) -> String {
if !config.enabled || config.max_links == 0 {
return content.to_string();
}
let urls = extract_urls(content, config.max_links);
if urls.is_empty() {
return content.to_string();
}
// Filter out SSRF targets
let safe_urls: Vec<&str> = urls
.iter()
.filter(|u| !is_ssrf_target(u))
.map(|u| u.as_str())
.collect();
if safe_urls.is_empty() {
return content.to_string();
}
let mut enrichments = Vec::new();
for url in safe_urls {
match fetch_link_summary(url, config.timeout_secs).await {
Some(summary) => {
enrichments.push(format!("[Link: {}{}]", summary.title, summary.snippet));
}
None => {
tracing::debug!(url, "Link enricher: failed to fetch or extract summary");
}
}
}
if enrichments.is_empty() {
return content.to_string();
}
let prefix = enrichments.join("\n");
format!("{prefix}\n{content}")
}
#[cfg(test)]
mod tests {
use super::*;
// ── URL extraction ──────────────────────────────────────────────
#[test]
fn extract_urls_finds_http_and_https() {
let text = "Check https://example.com and http://test.org/page for info";
let urls = extract_urls(text, 10);
assert_eq!(urls, vec!["https://example.com", "http://test.org/page",]);
}
#[test]
fn extract_urls_respects_max() {
let text = "https://a.com https://b.com https://c.com https://d.com";
let urls = extract_urls(text, 2);
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "https://a.com");
assert_eq!(urls[1], "https://b.com");
}
#[test]
fn extract_urls_deduplicates() {
let text = "Visit https://example.com and https://example.com again";
let urls = extract_urls(text, 10);
assert_eq!(urls.len(), 1);
}
#[test]
fn extract_urls_handles_no_urls() {
let text = "Just a normal message without links";
let urls = extract_urls(text, 10);
assert!(urls.is_empty());
}
#[test]
fn extract_urls_stops_at_angle_brackets() {
let text = "Link: <https://example.com/path> done";
let urls = extract_urls(text, 10);
assert_eq!(urls, vec!["https://example.com/path"]);
}
#[test]
fn extract_urls_stops_at_quotes() {
let text = r#"href="https://example.com/page" end"#;
let urls = extract_urls(text, 10);
assert_eq!(urls, vec!["https://example.com/page"]);
}
// ── SSRF protection ─────────────────────────────────────────────
#[test]
fn ssrf_blocks_localhost() {
assert!(is_ssrf_target("http://localhost/admin"));
assert!(is_ssrf_target("https://localhost:8080/api"));
}
#[test]
fn ssrf_blocks_loopback_ip() {
assert!(is_ssrf_target("http://127.0.0.1/secret"));
assert!(is_ssrf_target("http://127.0.0.2:9090"));
}
#[test]
fn ssrf_blocks_private_10_network() {
assert!(is_ssrf_target("http://10.0.0.1/internal"));
assert!(is_ssrf_target("http://10.255.255.255"));
}
#[test]
fn ssrf_blocks_private_172_network() {
assert!(is_ssrf_target("http://172.16.0.1/admin"));
assert!(is_ssrf_target("http://172.31.255.255"));
}
#[test]
fn ssrf_blocks_private_192_168_network() {
assert!(is_ssrf_target("http://192.168.1.1/router"));
assert!(is_ssrf_target("http://192.168.0.100:3000"));
}
#[test]
fn ssrf_blocks_link_local() {
assert!(is_ssrf_target("http://169.254.0.1/metadata"));
assert!(is_ssrf_target("http://169.254.169.254/latest"));
}
#[test]
fn ssrf_blocks_ipv6_loopback() {
// IPv6 in brackets is rejected by extract_host
assert!(is_ssrf_target("http://[::1]/admin"));
}
#[test]
fn ssrf_blocks_dot_local() {
assert!(is_ssrf_target("http://myhost.local/api"));
}
#[test]
fn ssrf_allows_public_urls() {
assert!(!is_ssrf_target("https://example.com/page"));
assert!(!is_ssrf_target("https://www.google.com"));
assert!(!is_ssrf_target("http://93.184.216.34/resource"));
}
// ── Title extraction ────────────────────────────────────────────
#[test]
fn extract_title_basic() {
let html = "<html><head><title>My Page Title</title></head><body>Hello</body></html>";
assert_eq!(extract_title(html), Some("my page title".to_string()));
}
#[test]
fn extract_title_with_entities() {
let html = "<title>Tom &amp; Jerry&#39;s Page</title>";
assert_eq!(extract_title(html), Some("tom & jerry's page".to_string()));
}
#[test]
fn extract_title_case_insensitive() {
let html = "<HTML><HEAD><TITLE>Upper Case</TITLE></HEAD></HTML>";
assert_eq!(extract_title(html), Some("upper case".to_string()));
}
#[test]
fn extract_title_multibyte_chars_no_panic() {
// İ (U+0130) lowercases to 2 chars, changing byte length.
// This must not panic or produce wrong offsets.
let html = "<title>İstanbul Guide</title>";
let result = extract_title(html);
assert!(result.is_some());
let title = result.unwrap();
assert!(title.contains("stanbul"));
}
#[test]
fn extract_title_missing() {
let html = "<html><body>No title here</body></html>";
assert_eq!(extract_title(html), None);
}
#[test]
fn extract_title_empty() {
let html = "<title> </title>";
assert_eq!(extract_title(html), None);
}
// ── Body text extraction ────────────────────────────────────────
#[test]
fn extract_body_text_strips_html() {
let html = "<html><body><h1>Header</h1><p>Some content here</p></body></html>";
let text = extract_body_text(html, 200);
assert!(text.contains("Header"));
assert!(text.contains("Some content"));
assert!(!text.contains("<h1>"));
}
#[test]
fn extract_body_text_truncates() {
let html = "<p>A very long paragraph that should be truncated to fit within the limit.</p>";
let text = extract_body_text(html, 20);
assert!(text.len() <= 25); // 20 chars + "..."
assert!(text.ends_with("..."));
}
// ── Config toggle ───────────────────────────────────────────────
#[tokio::test]
async fn enrich_message_disabled_returns_original() {
let config = LinkEnricherConfig {
enabled: false,
max_links: 3,
timeout_secs: 10,
};
let msg = "Check https://example.com for details";
let result = enrich_message(msg, &config).await;
assert_eq!(result, msg);
}
#[tokio::test]
async fn enrich_message_no_urls_returns_original() {
let config = LinkEnricherConfig {
enabled: true,
max_links: 3,
timeout_secs: 10,
};
let msg = "No links in this message";
let result = enrich_message(msg, &config).await;
assert_eq!(result, msg);
}
#[tokio::test]
async fn enrich_message_ssrf_urls_returns_original() {
let config = LinkEnricherConfig {
enabled: true,
max_links: 3,
timeout_secs: 10,
};
let msg = "Try http://127.0.0.1/admin and http://192.168.1.1/router";
let result = enrich_message(msg, &config).await;
assert_eq!(result, msg);
}
#[test]
fn default_config_is_disabled() {
let config = LinkEnricherConfig::default();
assert!(!config.enabled);
assert_eq!(config.max_links, 3);
assert_eq!(config.timeout_secs, 10);
}
}

View File

@ -268,7 +268,6 @@ impl LinqChannel {
timestamp,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
});
messages

View File

@ -8,7 +8,6 @@ use matrix_sdk::{
events::reaction::ReactionEventContent,
events::receipt::ReceiptThread,
events::relation::{Annotation, Thread},
events::room::member::StrippedRoomMemberEvent,
events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, Relation, RoomMessageEventContent,
},
@ -33,7 +32,6 @@ pub struct MatrixChannel {
access_token: String,
room_id: String,
allowed_users: Vec<String>,
allowed_rooms: Vec<String>,
session_owner_hint: Option<String>,
session_device_id_hint: Option<String>,
zeroclaw_dir: Option<PathBuf>,
@ -42,8 +40,6 @@ pub struct MatrixChannel {
http_client: Client,
reaction_events: Arc<RwLock<HashMap<String, String>>>,
voice_mode: Arc<AtomicBool>,
transcription: Option<crate::config::TranscriptionConfig>,
transcription_manager: Option<Arc<super::transcription::TranscriptionManager>>,
}
impl std::fmt::Debug for MatrixChannel {
@ -52,7 +48,6 @@ impl std::fmt::Debug for MatrixChannel {
.field("homeserver", &self.homeserver)
.field("room_id", &self.room_id)
.field("allowed_users", &self.allowed_users)
.field("allowed_rooms", &self.allowed_rooms)
.finish_non_exhaustive()
}
}
@ -126,16 +121,7 @@ impl MatrixChannel {
room_id: String,
allowed_users: Vec<String>,
) -> Self {
Self::new_full(
homeserver,
access_token,
room_id,
allowed_users,
vec![],
None,
None,
None,
)
Self::new_with_session_hint(homeserver, access_token, room_id, allowed_users, None, None)
}
pub fn new_with_session_hint(
@ -146,12 +132,11 @@ impl MatrixChannel {
owner_hint: Option<String>,
device_id_hint: Option<String>,
) -> Self {
Self::new_full(
Self::new_with_session_hint_and_zeroclaw_dir(
homeserver,
access_token,
room_id,
allowed_users,
vec![],
owner_hint,
device_id_hint,
None,
@ -166,28 +151,6 @@ impl MatrixChannel {
owner_hint: Option<String>,
device_id_hint: Option<String>,
zeroclaw_dir: Option<PathBuf>,
) -> Self {
Self::new_full(
homeserver,
access_token,
room_id,
allowed_users,
vec![],
owner_hint,
device_id_hint,
zeroclaw_dir,
)
}
pub fn new_full(
homeserver: String,
access_token: String,
room_id: String,
allowed_users: Vec<String>,
allowed_rooms: Vec<String>,
owner_hint: Option<String>,
device_id_hint: Option<String>,
zeroclaw_dir: Option<PathBuf>,
) -> Self {
let homeserver = homeserver.trim_end_matches('/').to_string();
let access_token = access_token.trim().to_string();
@ -197,18 +160,12 @@ impl MatrixChannel {
.map(|user| user.trim().to_string())
.filter(|user| !user.is_empty())
.collect();
let allowed_rooms = allowed_rooms
.into_iter()
.map(|room| room.trim().to_string())
.filter(|room| !room.is_empty())
.collect();
Self {
homeserver,
access_token,
room_id,
allowed_users,
allowed_rooms,
session_owner_hint: Self::normalize_optional_field(owner_hint),
session_device_id_hint: Self::normalize_optional_field(device_id_hint),
zeroclaw_dir,
@ -217,30 +174,9 @@ impl MatrixChannel {
http_client: Client::new(),
reaction_events: Arc::new(RwLock::new(HashMap::new())),
voice_mode: Arc::new(AtomicBool::new(false)),
transcription: None,
transcription_manager: None,
}
}
/// Configure voice transcription for audio messages.
pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
if !config.enabled {
return self;
}
match super::transcription::TranscriptionManager::new(&config) {
Ok(m) => {
self.transcription_manager = Some(Arc::new(m));
self.transcription = Some(config);
}
Err(e) => {
tracing::warn!(
"transcription manager init failed, voice transcription disabled: {e}"
);
}
}
self
}
fn encode_path_segment(value: &str) -> String {
fn should_encode(byte: u8) -> bool {
!matches!(
@ -284,21 +220,6 @@ impl MatrixChannel {
allowed_users.iter().any(|u| u.eq_ignore_ascii_case(sender))
}
/// Check whether a room (by its canonical ID) is in the allowed_rooms list.
/// If allowed_rooms is empty, all rooms are allowed.
fn is_room_allowed_static(allowed_rooms: &[String], room_id: &str) -> bool {
if allowed_rooms.is_empty() {
return true;
}
allowed_rooms
.iter()
.any(|r| r.eq_ignore_ascii_case(room_id))
}
fn is_room_allowed(&self, room_id: &str) -> bool {
Self::is_room_allowed_static(&self.allowed_rooms, room_id)
}
fn is_supported_message_type(msgtype: &str) -> bool {
matches!(msgtype, "m.text" | "m.notice")
}
@ -307,10 +228,6 @@ impl MatrixChannel {
!body.trim().is_empty()
}
fn room_matches_target(target_room_id: &str, incoming_room_id: &str) -> bool {
target_room_id == incoming_room_id
}
fn cache_event_id(
event_id: &str,
recent_order: &mut std::collections::VecDeque<String>,
@ -609,9 +526,8 @@ impl MatrixChannel {
if client.encryption().backups().are_enabled().await {
tracing::info!("Matrix room-key backup is enabled for this device.");
} else {
let _ = client.encryption().backups().disable().await;
tracing::warn!(
"Matrix room-key backup is not enabled for this device; automatic backup attempts have been disabled to suppress recurring warnings. To enable backups, configure server-side key backup and recovery for this device."
"Matrix room-key backup is not enabled for this device; `matrix_sdk_crypto::backups` warnings about missing backup keys may appear until recovery is configured."
);
}
}
@ -781,39 +697,25 @@ impl Channel for MatrixChannel {
let target_room_for_handler = target_room.clone();
let my_user_id_for_handler = my_user_id.clone();
let allowed_users_for_handler = self.allowed_users.clone();
let allowed_rooms_for_handler = self.allowed_rooms.clone();
let dedupe_for_handler = Arc::clone(&recent_event_cache);
let homeserver_for_handler = self.homeserver.clone();
let access_token_for_handler = self.access_token.clone();
let voice_mode_for_handler = Arc::clone(&self.voice_mode);
let transcription_mgr_for_handler = self.transcription_manager.clone();
client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
let tx = tx_handler.clone();
let target_room = target_room_for_handler.clone();
let _target_room = target_room_for_handler.clone();
let my_user_id = my_user_id_for_handler.clone();
let allowed_users = allowed_users_for_handler.clone();
let allowed_rooms = allowed_rooms_for_handler.clone();
let dedupe = Arc::clone(&dedupe_for_handler);
let homeserver = homeserver_for_handler.clone();
let access_token = access_token_for_handler.clone();
let voice_mode = Arc::clone(&voice_mode_for_handler);
let transcription_mgr = transcription_mgr_for_handler.clone();
async move {
if !MatrixChannel::room_matches_target(
target_room.as_str(),
room.room_id().as_str(),
) {
return;
}
// Room allowlist: skip messages from rooms not in the configured list
if !MatrixChannel::is_room_allowed_static(&allowed_rooms, room.room_id().as_ref()) {
tracing::debug!(
"Matrix: ignoring message from room {} (not in allowed_rooms)",
room.room_id()
);
if false
/* multi-room: room_id filter disabled */
{
return;
}
@ -900,36 +802,51 @@ impl Channel for MatrixChannel {
// Voice transcription: if this was an audio message, transcribe it
let body = if body.starts_with("[audio:") {
if let (Some(path_start), Some(ref manager)) = (body.find("saved to "), &transcription_mgr) {
if let Some(path_start) = body.find("saved to ") {
let audio_path = body[path_start + 9..].to_string();
let file_name = audio_path
.rsplit('/')
.next()
.unwrap_or("audio.ogg")
.to_string();
match tokio::fs::read(&audio_path).await {
Ok(audio_data) => {
match manager.transcribe(&audio_data, &file_name).await {
Ok(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
tracing::info!("Matrix: voice transcription returned empty text, skipping");
body
} else {
voice_mode.store(true, Ordering::Relaxed);
format!("[Voice message]: {}", trimmed)
}
}
Err(e) => {
tracing::warn!("Matrix: voice transcription failed: {e}");
body
}
}
}
Err(e) => {
tracing::warn!("Matrix: failed to read audio file {}: {e}", audio_path);
let wav_path = format!("{}.16k.wav", audio_path);
let convert_ok = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-i",
&audio_path,
"-ar",
"16000",
"-ac",
"1",
"-f",
"wav",
&wav_path,
])
.stderr(std::process::Stdio::null())
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if convert_ok {
let transcription = tokio::process::Command::new("whisper-cpp")
.args([
"-m",
"/tmp/ggml-base.en.bin",
"-f",
&wav_path,
"--no-timestamps",
"-nt",
])
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
if let Some(text) = transcription {
voice_mode.store(true, Ordering::Relaxed);
format!("[Voice message]: {}", text)
} else {
body
}
} else {
body
}
} else {
body
@ -984,52 +901,12 @@ impl Channel for MatrixChannel {
.as_secs(),
thread_ts: thread_ts.clone(),
interruption_scope_id: thread_ts,
attachments: vec![],
};
let _ = tx.send(msg).await;
}
});
// Invite handler: auto-accept invites for allowed rooms, auto-reject others
let allowed_rooms_for_invite = self.allowed_rooms.clone();
client.add_event_handler(move |event: StrippedRoomMemberEvent, room: Room| {
let allowed_rooms = allowed_rooms_for_invite.clone();
async move {
// Only process invite events targeting us
if event.content.membership
!= matrix_sdk::ruma::events::room::member::MembershipState::Invite
{
return;
}
let room_id_str = room.room_id().to_string();
if MatrixChannel::is_room_allowed_static(&allowed_rooms, &room_id_str) {
// Room is allowed (or no allowlist configured): auto-accept
tracing::info!(
"Matrix: auto-accepting invite for allowed room {}",
room_id_str
);
if let Err(error) = room.join().await {
tracing::warn!("Matrix: failed to auto-join room {}: {error}", room_id_str);
}
} else {
// Room is NOT in allowlist: auto-reject
tracing::info!(
"Matrix: auto-rejecting invite for room {} (not in allowed_rooms)",
room_id_str
);
if let Err(error) = room.leave().await {
tracing::warn!(
"Matrix: failed to reject invite for room {}: {error}",
room_id_str
);
}
}
}
});
let sync_settings = SyncSettings::new().timeout(std::time::Duration::from_secs(30));
client
.sync_with_result_callback(sync_settings, |sync_result| {
@ -1236,31 +1113,6 @@ impl Channel for MatrixChannel {
Ok(())
}
async fn redact_message(
&self,
_channel_id: &str,
message_id: &str,
reason: Option<String>,
) -> anyhow::Result<()> {
let client = self
.sdk_client
.get()
.ok_or_else(|| anyhow::anyhow!("Matrix SDK client not initialized"))?;
let target_room_id = self.target_room_id().await?;
let target_room: OwnedRoomId = target_room_id.parse()?;
let room = client
.get_room(&target_room)
.ok_or_else(|| anyhow::anyhow!("Matrix room not found for message redaction"))?;
let event_id: OwnedEventId = message_id
.parse()
.map_err(|_| anyhow::anyhow!("Invalid event ID: {}", message_id))?;
room.redact(&event_id, reason.as_deref(), None).await?;
Ok(())
}
}
#[cfg(test)]
@ -1442,22 +1294,6 @@ mod tests {
assert_eq!(value["room"]["timeline"]["limit"], 1);
}
#[test]
fn room_scope_matches_configured_room() {
assert!(MatrixChannel::room_matches_target(
"!ops:matrix.org",
"!ops:matrix.org"
));
}
#[test]
fn room_scope_rejects_other_rooms() {
assert!(!MatrixChannel::room_matches_target(
"!ops:matrix.org",
"!other:matrix.org"
));
}
#[test]
fn event_id_cache_deduplicates_and_evicts_old_entries() {
let mut recent_order = std::collections::VecDeque::new();
@ -1713,79 +1549,4 @@ mod tests {
let resp: SyncResponse = serde_json::from_str(json).unwrap();
assert!(resp.rooms.join.is_empty());
}
#[test]
fn empty_allowed_rooms_permits_all() {
let ch = make_channel();
assert!(ch.is_room_allowed("!any:matrix.org"));
assert!(ch.is_room_allowed("!other:evil.org"));
}
#[test]
fn allowed_rooms_filters_by_id() {
let ch = MatrixChannel::new_full(
"https://m.org".to_string(),
"tok".to_string(),
"!r:m".to_string(),
vec!["@user:m".to_string()],
vec!["!allowed:matrix.org".to_string()],
None,
None,
None,
);
assert!(ch.is_room_allowed("!allowed:matrix.org"));
assert!(!ch.is_room_allowed("!forbidden:matrix.org"));
}
#[test]
fn allowed_rooms_supports_aliases() {
let ch = MatrixChannel::new_full(
"https://m.org".to_string(),
"tok".to_string(),
"!r:m".to_string(),
vec!["@user:m".to_string()],
vec![
"#ops:matrix.org".to_string(),
"!direct:matrix.org".to_string(),
],
None,
None,
None,
);
assert!(ch.is_room_allowed("!direct:matrix.org"));
assert!(ch.is_room_allowed("#ops:matrix.org"));
assert!(!ch.is_room_allowed("!other:matrix.org"));
}
#[test]
fn allowed_rooms_case_insensitive() {
let ch = MatrixChannel::new_full(
"https://m.org".to_string(),
"tok".to_string(),
"!r:m".to_string(),
vec![],
vec!["!Room:Matrix.org".to_string()],
None,
None,
None,
);
assert!(ch.is_room_allowed("!room:matrix.org"));
assert!(ch.is_room_allowed("!ROOM:MATRIX.ORG"));
}
#[test]
fn allowed_rooms_trims_whitespace() {
let ch = MatrixChannel::new_full(
"https://m.org".to_string(),
"tok".to_string(),
"!r:m".to_string(),
vec![],
vec![" !room:matrix.org ".to_string(), " ".to_string()],
None,
None,
None,
);
assert_eq!(ch.allowed_rooms.len(), 1);
assert!(ch.is_room_allowed("!room:matrix.org"));
}
}

Some files were not shown because too many files have changed in this diff Show More