Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0623236e0b | |||
| 959b933841 | |||
| caf7c7194f | |||
| ee7d542da6 | |||
| d51ec4b43f | |||
| 3d92b2a652 | |||
| 3255051426 | |||
| dcaf330848 | |||
| 7f8de5cb17 | |||
| 1341cfb296 | |||
| 9da620a5aa | |||
| d016e6b1a0 | |||
| 9b6360ad71 | |||
| dc50ca9171 | |||
| 67edd2bc60 | |||
| dcf66175e4 | |||
| b3bb79d805 | |||
| c857b64bb4 | |||
| c051f0323e | |||
| dea5c67ab0 | |||
| a14afd7ef9 | |||
| 4455b24056 | |||
| 8ec6522759 | |||
| a818edb782 | |||
| e0af3d98dd | |||
| 48bdbde26c | |||
| dc495a105f | |||
| fe9addcfe0 | |||
| 5bfa5f18e1 | |||
| 72b7e1e647 | |||
| 413c94befe | |||
| 5aa6026fa1 | |||
| 6eca841bd7 | |||
| 50e8d4f5f8 | |||
| fc2aac7c94 | |||
| 4caa3f7e6f | |||
| 3bc6ec3cf5 | |||
| f3fbd1b094 | |||
| 79e8252d7a | |||
| 924521c927 | |||
| 07ca270f03 | |||
| e08091a2e2 | |||
| 1f1123d071 | |||
| d5bc46238a | |||
| 843973762a | |||
| 5f8d7d7347 | |||
| 7b3bea8d01 | |||
| ac461dc704 | |||
| f04e56d9a1 | |||
| 1d6f482b04 | |||
| ba6d0a4df9 | |||
| 3cf873ab85 | |||
| 025724913d | |||
| 49dd4cd9da | |||
| 0664a5e854 | |||
| acd09fbd86 | |||
| 0f7d1fceeb | |||
| 01e13ac92d |
@@ -0,0 +1,10 @@
|
||||
# cargo-audit configuration
|
||||
# https://rustsec.org/
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# wasmtime vulns via extism 1.13.0 — no upstream fix; plugins feature-gated
|
||||
"RUSTSEC-2026-0006", # wasmtime f64.copysign segfault on x86-64
|
||||
"RUSTSEC-2026-0020", # WASI guest-controlled resource exhaustion
|
||||
"RUSTSEC-2026-0021", # WASI http fields panic
|
||||
]
|
||||
@@ -64,3 +64,12 @@ LICENSE
|
||||
*.profdata
|
||||
coverage
|
||||
lcov.info
|
||||
|
||||
# Firmware and hardware crates (not needed for Docker runtime)
|
||||
firmware/
|
||||
crates/robot-kit/
|
||||
|
||||
# Application and script directories (not needed for Docker runtime)
|
||||
apps/
|
||||
python/
|
||||
scripts/
|
||||
|
||||
@@ -74,4 +74,4 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -294,10 +294,44 @@ jobs:
|
||||
name: Push Docker Image
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-x86_64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-aarch64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- name: Prepare Docker context with pre-built binaries
|
||||
run: |
|
||||
mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
|
||||
tar xzf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
|
||||
tar xzf artifacts/zeroclaw-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64
|
||||
|
||||
mkdir -p docker-ctx/zeroclaw-data/.zeroclaw docker-ctx/zeroclaw-data/workspace
|
||||
printf '%s\n' \
|
||||
'workspace_dir = "/zeroclaw-data/workspace"' \
|
||||
'config_path = "/zeroclaw-data/.zeroclaw/config.toml"' \
|
||||
'api_key = ""' \
|
||||
'default_provider = "openrouter"' \
|
||||
'default_model = "anthropic/claude-sonnet-4-20250514"' \
|
||||
'default_temperature = 0.7' \
|
||||
'' \
|
||||
'[gateway]' \
|
||||
'port = 42617' \
|
||||
'host = "[::]"' \
|
||||
'allow_public_bind = true' \
|
||||
> docker-ctx/zeroclaw-data/.zeroclaw/config.toml
|
||||
|
||||
cp Dockerfile.ci docker-ctx/Dockerfile
|
||||
cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
@@ -309,24 +343,24 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
context: docker-ctx
|
||||
push: true
|
||||
build-args: |
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ── Post-publish: tweet after release + website are live ──────────────
|
||||
# Docker is slow (multi-platform) and can be cancelled by concurrency;
|
||||
# don't let it block the tweet.
|
||||
tweet:
|
||||
name: Tweet Release
|
||||
needs: [version, publish, redeploy-website]
|
||||
if: ${{ !cancelled() && needs.publish.result == 'success' }}
|
||||
uses: ./.github/workflows/tweet-release.yml
|
||||
with:
|
||||
release_tag: ${{ needs.version.outputs.tag }}
|
||||
release_url: https://github.com/zeroclaw-labs/zeroclaw/releases/tag/${{ needs.version.outputs.tag }}
|
||||
secrets: inherit
|
||||
- name: Build and push Debian compatibility image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: docker-ctx
|
||||
file: docker-ctx/Dockerfile.debian
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}-debian
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta-debian
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# Tweet removed — only stable releases should tweet (see tweet-release.yml).
|
||||
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -337,10 +337,44 @@ jobs:
|
||||
name: Push Docker Image
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-x86_64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-aarch64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- name: Prepare Docker context with pre-built binaries
|
||||
run: |
|
||||
mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
|
||||
tar xzf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
|
||||
tar xzf artifacts/zeroclaw-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64
|
||||
|
||||
mkdir -p docker-ctx/zeroclaw-data/.zeroclaw docker-ctx/zeroclaw-data/workspace
|
||||
printf '%s\n' \
|
||||
'workspace_dir = "/zeroclaw-data/workspace"' \
|
||||
'config_path = "/zeroclaw-data/.zeroclaw/config.toml"' \
|
||||
'api_key = ""' \
|
||||
'default_provider = "openrouter"' \
|
||||
'default_model = "anthropic/claude-sonnet-4-20250514"' \
|
||||
'default_temperature = 0.7' \
|
||||
'' \
|
||||
'[gateway]' \
|
||||
'port = 42617' \
|
||||
'host = "[::]"' \
|
||||
'allow_public_bind = true' \
|
||||
> docker-ctx/zeroclaw-data/.zeroclaw/config.toml
|
||||
|
||||
cp Dockerfile.ci docker-ctx/Dockerfile
|
||||
cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
@@ -352,14 +386,25 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
context: docker-ctx
|
||||
push: true
|
||||
build-args: |
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Debian compatibility image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: docker-ctx
|
||||
file: docker-ctx/Dockerfile.debian
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}-debian
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:debian
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# ── Post-publish: package manager auto-sync ─────────────────────────
|
||||
scoop:
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag (e.g. v0.3.0 or v0.3.0-beta.42)"
|
||||
description: "Stable release tag (e.g. v0.3.0)"
|
||||
required: true
|
||||
type: string
|
||||
release_url:
|
||||
@@ -61,9 +61,10 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# For betas: find the PREVIOUS release tag to check for new features
|
||||
# Find the previous STABLE release tag (exclude betas) to check for new features
|
||||
PREV_TAG=$(git tag --sort=-creatordate \
|
||||
| grep -v "^${RELEASE_TAG}$" \
|
||||
| grep -vE '\-beta\.' \
|
||||
| head -1 || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
@@ -97,53 +98,37 @@ jobs:
|
||||
if [ -n "$MANUAL_TEXT" ]; then
|
||||
TWEET="$MANUAL_TEXT"
|
||||
else
|
||||
# For features: diff against the PREVIOUS release (including betas)
|
||||
# This prevents duplicate feature lists across consecutive betas
|
||||
PREV_RELEASE=$(git tag --sort=-creatordate \
|
||||
| grep -v "^${RELEASE_TAG}$" \
|
||||
| head -1 || echo "")
|
||||
|
||||
# For contributors: diff against the last STABLE release
|
||||
# This captures everyone across the full release cycle
|
||||
# Diff against the last STABLE release (exclude betas) to capture
|
||||
# ALL features accumulated across the full beta cycle
|
||||
PREV_STABLE=$(git tag --sort=-creatordate \
|
||||
| grep -v "^${RELEASE_TAG}$" \
|
||||
| grep -vE '\-beta\.' \
|
||||
| head -1 || echo "")
|
||||
|
||||
FEAT_RANGE="${PREV_RELEASE:+${PREV_RELEASE}..}${RELEASE_TAG}"
|
||||
CONTRIB_RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
|
||||
RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
|
||||
|
||||
# Extract NEW features only since the last release
|
||||
FEATURES=$(git log "$FEAT_RANGE" --pretty=format:"%s" --no-merges \
|
||||
# Extract ALL features since the last stable release
|
||||
FEATURES=$(git log "$RANGE" --pretty=format:"%s" --no-merges \
|
||||
| grep -iE '^feat(\(|:)' \
|
||||
| sed 's/^feat(\([^)]*\)): /\1: /' \
|
||||
| sed 's/^feat: //' \
|
||||
| sed 's/ (#[0-9]*)$//' \
|
||||
| sort -uf \
|
||||
| head -4 \
|
||||
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
|
||||
|
||||
if [ -z "$FEATURES" ]; then
|
||||
FEATURES="🚀 Incremental improvements and polish"
|
||||
fi
|
||||
|
||||
# Count ALL contributors across the full release cycle
|
||||
GIT_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%an" --no-merges | sort -uf || true)
|
||||
CO_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%b" --no-merges \
|
||||
| grep -ioE 'Co-Authored-By: *[^<]+' \
|
||||
| sed 's/Co-Authored-By: *//i' \
|
||||
| sed 's/ *$//' \
|
||||
| sort -uf || true)
|
||||
|
||||
TOTAL_COUNT=$(printf "%s\n%s" "$GIT_AUTHORS" "$CO_AUTHORS" \
|
||||
| sort -uf \
|
||||
| grep -v '^$' \
|
||||
| grep -viE '\[bot\]$|^dependabot|^github-actions|^copilot|^ZeroClaw Bot|^ZeroClaw Runner|^ZeroClaw Agent|^blacksmith' \
|
||||
| grep -c . || echo "0")
|
||||
FEAT_COUNT=$(echo "$FEATURES" | grep -c . || echo "0")
|
||||
|
||||
# Build tweet — new features, contributor count, hashtags
|
||||
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\n🙌 %s contributors\n\n%s\n\n#zeroclaw #rust #ai #opensource" \
|
||||
"$RELEASE_TAG" "$FEATURES" "$TOTAL_COUNT" "$RELEASE_URL")
|
||||
# Format top features with rocket emoji (limit to 6 for tweet space)
|
||||
FEAT_LIST=$(echo "$FEATURES" \
|
||||
| head -6 \
|
||||
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
|
||||
|
||||
if [ -z "$FEAT_LIST" ]; then
|
||||
FEAT_LIST="🚀 Incremental improvements and polish"
|
||||
fi
|
||||
|
||||
# Build tweet — feature-focused style
|
||||
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\nZero overhead. Zero compromise. 100%% Rust.\n\n#zeroclaw #rust #ai #opensource" \
|
||||
"$RELEASE_TAG" "$FEAT_LIST")
|
||||
fi
|
||||
|
||||
# X/Twitter counts any URL as 23 chars (t.co shortening).
|
||||
|
||||
Generated
+1263
-22
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.4.3"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
authors = ["theonlyhennygod"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -31,6 +31,7 @@ include = [
|
||||
"/LICENSE*",
|
||||
"/README.md",
|
||||
"/web/dist/**/*",
|
||||
"/tool_descriptions/**/*",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
@@ -53,6 +54,7 @@ matrix-sdk = { version = "0.16", optional = true, default-features = false, feat
|
||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||
serde_json = { version = "1.0", default-features = false, features = ["std"] }
|
||||
serde_ignored = "0.1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Config
|
||||
directories = "6.0"
|
||||
@@ -82,6 +84,12 @@ nanohtml2text = "0.2"
|
||||
# Optional Rust-native browser automation backend
|
||||
fantoccini = { version = "0.22.1", optional = true, default-features = false, features = ["rustls-tls"] }
|
||||
|
||||
# Progress bars (update pipeline)
|
||||
indicatif = "0.17"
|
||||
|
||||
# Temp files (update pipeline rollback)
|
||||
tempfile = "3.26"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
@@ -183,6 +191,9 @@ probe-rs = { version = "0.31", optional = true }
|
||||
# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf)
|
||||
pdf-extract = { version = "0.10", optional = true }
|
||||
|
||||
# WASM plugin runtime (extism)
|
||||
extism = { version = "1.9", optional = true }
|
||||
|
||||
# Terminal QR rendering for WhatsApp Web pairing flow.
|
||||
qrcode = { version = "0.14", optional = true }
|
||||
|
||||
@@ -232,6 +243,8 @@ probe = ["dep:probe-rs"]
|
||||
rag-pdf = ["dep:pdf-extract"]
|
||||
# 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"]
|
||||
# WASM plugin system (extism-based)
|
||||
plugins-wasm = ["dep:extism"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
|
||||
+31
-28
@@ -1,9 +1,18 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ── Stage 0: Frontend build ─────────────────────────────────────
|
||||
FROM node:22-alpine AS web-builder
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install --ignore-scripts
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────
|
||||
FROM rust:1.94-slim@sha256:7d3701660d2aa7101811ba0c54920021452aa60e5bae073b79c2b137a432b2f4 AS builder
|
||||
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG ZEROCLAW_CARGO_FEATURES=""
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -14,43 +23,29 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
# 1. Copy manifests to cache dependencies
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
|
||||
# Remove robot-kit from workspace members — it is excluded by .dockerignore
|
||||
# and is not needed for the Docker build (hardware-only crate).
|
||||
RUN sed -i 's/members = \[".", "crates\/robot-kit"\]/members = ["."]/' Cargo.toml
|
||||
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
|
||||
RUN mkdir -p src benches crates/robot-kit/src \
|
||||
RUN mkdir -p src benches \
|
||||
&& echo "fn main() {}" > src/main.rs \
|
||||
&& echo "" > src/lib.rs \
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
|
||||
&& echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked
|
||||
RUN rm -rf src benches crates/robot-kit/src
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi
|
||||
RUN rm -rf src benches
|
||||
|
||||
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
|
||||
COPY src/ src/
|
||||
COPY benches/ benches/
|
||||
COPY crates/ crates/
|
||||
COPY firmware/ firmware/
|
||||
COPY web/ web/
|
||||
COPY --from=web-builder /web/dist web/dist
|
||||
COPY *.rs .
|
||||
# Keep release builds resilient when frontend dist assets are not prebuilt in Git.
|
||||
RUN mkdir -p web/dist && \
|
||||
if [ ! -f web/dist/index.html ]; then \
|
||||
printf '%s\n' \
|
||||
'<!doctype html>' \
|
||||
'<html lang="en">' \
|
||||
' <head>' \
|
||||
' <meta charset="utf-8" />' \
|
||||
' <meta name="viewport" content="width=device-width,initial-scale=1" />' \
|
||||
' <title>ZeroClaw Dashboard</title>' \
|
||||
' </head>' \
|
||||
' <body>' \
|
||||
' <h1>ZeroClaw Dashboard Unavailable</h1>' \
|
||||
' <p>Frontend assets are not bundled in this build. Build the web UI to populate <code>web/dist</code>.</p>' \
|
||||
' </body>' \
|
||||
'</html>' > web/dist/index.html; \
|
||||
fi
|
||||
RUN touch src/main.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
@@ -58,7 +53,11 @@ RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/regist
|
||||
rm -rf target/release/.fingerprint/zeroclawlabs-* \
|
||||
target/release/deps/zeroclawlabs-* \
|
||||
target/release/incremental/zeroclawlabs-* && \
|
||||
cargo build --release --locked && \
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi && \
|
||||
cp target/release/zeroclaw /app/zeroclaw && \
|
||||
strip /app/zeroclaw
|
||||
RUN size=$(stat -c%s /app/zeroclaw 2>/dev/null || stat -f%z /app/zeroclaw) && \
|
||||
@@ -114,6 +113,8 @@ ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD ["zeroclaw", "status", "--format=exit-code"]
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -138,5 +139,7 @@ ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD ["zeroclaw", "status", "--format=exit-code"]
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Dockerfile.ci — CI/release image using pre-built binaries.
|
||||
# Used by release workflows to skip the ~60 min Rust compilation.
|
||||
# The main Dockerfile is still used for local dev builds.
|
||||
|
||||
# ── Runtime (Distroless) ─────────────────────────────────────
|
||||
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
# Copy the pre-built binary for this platform (amd64 or arm64)
|
||||
COPY bin/${TARGETARCH}/zeroclaw /usr/local/bin/zeroclaw
|
||||
|
||||
# Runtime directory structure and default config
|
||||
COPY --chown=65534:65534 zeroclaw-data/ /zeroclaw-data/
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
+30
-29
@@ -1,5 +1,13 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ── Stage 0: Frontend build ─────────────────────────────────────
|
||||
FROM node:22-alpine AS web-builder
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install --ignore-scripts
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# Dockerfile.debian — Shell-equipped variant of the ZeroClaw container.
|
||||
#
|
||||
# The default Dockerfile produces a distroless "release" image with no shell,
|
||||
@@ -15,10 +23,11 @@
|
||||
# Or with docker compose:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.debian.yml up
|
||||
|
||||
# ── Stage 1: Build (identical to main Dockerfile) ───────────
|
||||
FROM rust:1.94-slim@sha256:7d3701660d2aa7101811ba0c54920021452aa60e5bae073b79c2b137a432b2f4 AS builder
|
||||
# ── Stage 1: Build (match runtime glibc baseline) ───────────
|
||||
FROM rust:1.94-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG ZEROCLAW_CARGO_FEATURES=""
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -29,47 +38,37 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
# 1. Copy manifests to cache dependencies
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
|
||||
# Remove robot-kit from workspace members — it is excluded by .dockerignore
|
||||
# and is not needed for the Docker build (hardware-only crate).
|
||||
RUN sed -i 's/members = \[".", "crates\/robot-kit"\]/members = ["."]/' Cargo.toml
|
||||
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
|
||||
RUN mkdir -p src benches crates/robot-kit/src \
|
||||
RUN mkdir -p src benches \
|
||||
&& echo "fn main() {}" > src/main.rs \
|
||||
&& echo "" > src/lib.rs \
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
|
||||
&& echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked
|
||||
RUN rm -rf src benches crates/robot-kit/src
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi
|
||||
RUN rm -rf src benches
|
||||
|
||||
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
|
||||
COPY src/ src/
|
||||
COPY benches/ benches/
|
||||
COPY crates/ crates/
|
||||
COPY firmware/ firmware/
|
||||
COPY web/ web/
|
||||
# Keep release builds resilient when frontend dist assets are not prebuilt in Git.
|
||||
RUN mkdir -p web/dist && \
|
||||
if [ ! -f web/dist/index.html ]; then \
|
||||
printf '%s\n' \
|
||||
'<!doctype html>' \
|
||||
'<html lang="en">' \
|
||||
' <head>' \
|
||||
' <meta charset="utf-8" />' \
|
||||
' <meta name="viewport" content="width=device-width,initial-scale=1" />' \
|
||||
' <title>ZeroClaw Dashboard</title>' \
|
||||
' </head>' \
|
||||
' <body>' \
|
||||
' <h1>ZeroClaw Dashboard Unavailable</h1>' \
|
||||
' <p>Frontend assets are not bundled in this build. Build the web UI to populate <code>web/dist</code>.</p>' \
|
||||
' </body>' \
|
||||
'</html>' > web/dist/index.html; \
|
||||
fi
|
||||
COPY --from=web-builder /web/dist web/dist
|
||||
RUN touch src/main.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked && \
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi && \
|
||||
cp target/release/zeroclaw /app/zeroclaw && \
|
||||
strip /app/zeroclaw
|
||||
RUN size=$(stat -c%s /app/zeroclaw 2>/dev/null || stat -f%z /app/zeroclaw) && \
|
||||
@@ -120,5 +119,7 @@ ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD ["zeroclaw", "status", "--format=exit-code"]
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Dockerfile.debian.ci — CI/release Debian image using pre-built binaries.
|
||||
# Mirrors Dockerfile.ci but uses debian:bookworm-slim with shell tools
|
||||
# so the agent can use shell-based tools (pwd, ls, git, curl, etc.).
|
||||
# Used by release workflows to skip ~60 min QEMU cross-compilation.
|
||||
|
||||
# ── Runtime (Debian with shell) ────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install essential tools for agent shell operations
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the pre-built binary for this platform (amd64 or arm64)
|
||||
COPY bin/${TARGETARCH}/zeroclaw /usr/local/bin/zeroclaw
|
||||
|
||||
# Runtime directory structure and default config
|
||||
COPY --chown=65534:65534 zeroclaw-data/ /zeroclaw-data/
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
+5
-2
@@ -16,7 +16,10 @@
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center" dir="rtl">
|
||||
@@ -103,7 +106,7 @@
|
||||
| التاريخ (UTC) | المستوى | الإشعار | الإجراء |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _حرج_ | **نحن غير مرتبطين** بـ `openagen/zeroclaw` أو `zeroclaw.org`. نطاق `zeroclaw.org` يشير حاليًا إلى الفرع `openagen/zeroclaw`، وهذا النطاق/المستودع ينتحل شخصية موقعنا/مشروعنا الرسمي. | لا تثق بالمعلومات أو الملفات الثنائية أو جمع التبرعات أو الإعلانات من هذه المصادر. استخدم فقط [هذا المستودع](https://github.com/zeroclaw-labs/zeroclaw) وحساباتنا الموثقة على وسائل التواصل الاجتماعي. |
|
||||
| 2026-02-21 | _مهم_ | موقعنا الرسمي أصبح متاحًا الآن: [zeroclawlabs.ai](https://zeroclawlabs.ai). شكرًا لصبرك أثناء الانتظار. لا نزال نكتشف محاولات الانتحال: لا تشارك في أي نشاط استثمار/تمويل باسم ZeroClaw إذا لم يتم نشره عبر قنواتنا الرسمية. | استخدم [هذا المستودع](https://github.com/zeroclaw-labs/zeroclaw) كمصدر وحيد للحقيقة. تابع [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)، [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs)، [Facebook (مجموعة)](https://www.facebook.com/groups/zeroclaw)، [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/)، و[Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) للتحديثات الرسمية. |
|
||||
| 2026-02-21 | _مهم_ | موقعنا الرسمي أصبح متاحًا الآن: [zeroclawlabs.ai](https://zeroclawlabs.ai). شكرًا لصبرك أثناء الانتظار. لا نزال نكتشف محاولات الانتحال: لا تشارك في أي نشاط استثمار/تمويل باسم ZeroClaw إذا لم يتم نشره عبر قنواتنا الرسمية. | استخدم [هذا المستودع](https://github.com/zeroclaw-labs/zeroclaw) كمصدر وحيد للحقيقة. تابع [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)، [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs)، [Facebook (مجموعة)](https://www.facebook.com/groups/zeroclawlabs)، [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/)، و[Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) للتحديثات الرسمية. |
|
||||
| 2026-02-19 | _مهم_ | قامت Anthropic بتحديث شروط استخدام المصادقة وبيانات الاعتماد في 2026-02-19. مصادقة OAuth (Free، Pro، Max) حصريًا لـ Claude Code و Claude.ai؛ استخدام رموز Claude Free/Pro/Max OAuth في أي منتج أو أداة أو خدمة أخرى (بما في ذلك Agent SDK) غير مسموح به وقد ينتهك شروط استخدام المستهلك. | يرجى تجنب مؤقتًا تكاملات Claude Code OAuth لمنع أي خسارة محتملة. البند الأصلي: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ الميزات
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ channels:
|
||||
## কমিউনিটি
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Použijte tuto tabulku pro důležitá oznámení (změny kompatibility, bezpeč
|
||||
| Datum (UTC) | Úroveň | Oznámení | Akce |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritické_ | **Nejsme propojeni** s `openagen/zeroclaw` nebo `zeroclaw.org`. Doména `zeroclaw.org` aktuálně směřuje na fork `openagen/zeroclaw`, a tato doména/repoziťář se vydává za náš oficiální web/projekt. | Nevěřte informacím, binárním souborům, fundraisingu nebo oznámením z těchto zdrojů. Používejte pouze [tento repoziťář](https://github.com/zeroclaw-labs/zeroclaw) a naše ověřené sociální účty. |
|
||||
| 2026-02-21 | _Důležité_ | Náš oficiální web je nyní online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Děkujeme za trpělivost během čekání. Stále detekujeme pokusy o vydávání se: neúčastněte žádné investiční/fundraisingové aktivity ve jménu ZeroClaw pokud není publikována přes naše oficiální kanály. | Používejte [tento repoziťář](https://github.com/zeroclaw-labs/zeroclaw) jako jediný zdroj pravdy. Sledujte [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (skupina)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), a [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) pro oficiální aktualizace. |
|
||||
| 2026-02-21 | _Důležité_ | Náš oficiální web je nyní online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Děkujeme za trpělivost během čekání. Stále detekujeme pokusy o vydávání se: neúčastněte žádné investiční/fundraisingové aktivity ve jménu ZeroClaw pokud není publikována přes naše oficiální kanály. | Používejte [tento repoziťář](https://github.com/zeroclaw-labs/zeroclaw) jako jediný zdroj pravdy. Sledujte [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (skupina)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), a [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) pro oficiální aktualizace. |
|
||||
| 2026-02-19 | _Důležité_ | Anthropic aktualizoval podmínky použití autentizace a přihlašovacích údajů dne 2026-02-19. OAuth autentizace (Free, Pro, Max) je výhradně pro Claude Code a Claude.ai; použití Claude Free/Pro/Max OAuth tokenů v jakémkoliv jiném produktu, nástroji nebo službě (včetně Agent SDK) není povoleno a může porušit Podmínky použití spotřebitele. | Prosím dočasně se vyhněte Claude Code OAuth integracím pro předcházení potenciálním ztrátám. Původní klauzule: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funkce
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Se [LICENSE-APACHE](LICENSE-APACHE) og [LICENSE-MIT](LICENSE-MIT) for detaljer.
|
||||
## Fællesskab
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -107,7 +110,7 @@ Verwende diese Tabelle für wichtige Hinweise (Kompatibilitätsänderungen, Sich
|
||||
| Datum (UTC) | Ebene | Hinweis | Aktion |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritisch_ | Wir sind **nicht verbunden** mit `openagen/zeroclaw` oder `zeroclaw.org`. Die Domain `zeroclaw.org` zeigt derzeit auf den Fork `openagen/zeroclaw`, und diese Domain/Repository fälscht unsere offizielle Website/Projekt. | Vertraue keinen Informationen, Binärdateien, Fundraising oder Ankündigungen aus diesen Quellen. Verwende nur [dieses Repository](https://github.com/zeroclaw-labs/zeroclaw) und unsere verifizierten Social-Media-Konten. |
|
||||
| 2026-02-21 | _Wichtig_ | Unsere offizielle Website ist jetzt online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Danke für deine Geduld während der Wartezeit. Wir erkennen weiterhin Fälschungsversuche: nimm an keiner Investitions-/Finanzierungsaktivität im Namen von ZeroClaw teil, wenn sie nicht über unsere offiziellen Kanäle veröffentlicht wird. | Verwende [dieses Repository](https://github.com/zeroclaw-labs/zeroclaw) als einzige Quelle der Wahrheit. Folge [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (Gruppe)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), und [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) für offizielle Updates. |
|
||||
| 2026-02-21 | _Wichtig_ | Unsere offizielle Website ist jetzt online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Danke für deine Geduld während der Wartezeit. Wir erkennen weiterhin Fälschungsversuche: nimm an keiner Investitions-/Finanzierungsaktivität im Namen von ZeroClaw teil, wenn sie nicht über unsere offiziellen Kanäle veröffentlicht wird. | Verwende [dieses Repository](https://github.com/zeroclaw-labs/zeroclaw) als einzige Quelle der Wahrheit. Folge [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (Gruppe)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), und [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) für offizielle Updates. |
|
||||
| 2026-02-19 | _Wichtig_ | Anthropic hat die Nutzungsbedingungen für Authentifizierung und Anmeldedaten am 2026-02-19 aktualisiert. Die OAuth-Authentifizierung (Free, Pro, Max) ist ausschließlich für Claude Code und Claude.ai; die Verwendung von Claude Free/Pro/Max OAuth-Token in einem anderen Produkt, Tool oder Dienst (einschließlich Agent SDK) ist nicht erlaubt und kann gegen die Verbrauchernutzungsbedingungen verstoßen. | Bitte vermeide vorübergehend Claude Code OAuth-Integrationen, um potenzielle Verluste zu verhindern. Originalklausel: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funktionen
|
||||
|
||||
+5
-2
@@ -14,7 +14,10 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -176,7 +179,7 @@ channels:
|
||||
## Κοινότητα
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Usa esta tabla para avisos importantes (cambios de compatibilidad, avisos de seg
|
||||
| Fecha (UTC) | Nivel | Aviso | Acción |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Crítico_ | **No estamos afiliados** con `openagen/zeroclaw` o `zeroclaw.org`. El dominio `zeroclaw.org` apunta actualmente al fork `openagen/zeroclaw`, y este dominio/repositorio está suplantando nuestro sitio web/proyecto oficial. | No confíes en información, binarios, recaudaciones de fondos o anuncios de estas fuentes. Usa solo [este repositorio](https://github.com/zeroclaw-labs/zeroclaw) y nuestras cuentas sociales verificadas. |
|
||||
| 2026-02-21 | _Importante_ | Nuestro sitio web oficial ahora está en línea: [zeroclawlabs.ai](https://zeroclawlabs.ai). Gracias por tu paciencia durante la espera. Todavía detectamos intentos de suplantación: no participes en ninguna actividad de inversión/financiamiento en nombre de ZeroClaw si no se publica a través de nuestros canales oficiales. | Usa [este repositorio](https://github.com/zeroclaw-labs/zeroclaw) como la única fuente de verdad. Sigue [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), y [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para actualizaciones oficiales. |
|
||||
| 2026-02-21 | _Importante_ | Nuestro sitio web oficial ahora está en línea: [zeroclawlabs.ai](https://zeroclawlabs.ai). Gracias por tu paciencia durante la espera. Todavía detectamos intentos de suplantación: no participes en ninguna actividad de inversión/financiamiento en nombre de ZeroClaw si no se publica a través de nuestros canales oficiales. | Usa [este repositorio](https://github.com/zeroclaw-labs/zeroclaw) como la única fuente de verdad. Sigue [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), y [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para actualizaciones oficiales. |
|
||||
| 2026-02-19 | _Importante_ | Anthropic actualizó los términos de uso de autenticación y credenciales el 2026-02-19. La autenticación OAuth (Free, Pro, Max) es exclusivamente para Claude Code y Claude.ai; el uso de tokens OAuth de Claude Free/Pro/Max en cualquier otro producto, herramienta o servicio (incluyendo Agent SDK) no está permitido y puede violar los Términos de Uso del Consumidor. | Por favor, evita temporalmente las integraciones OAuth de Claude Code para prevenir cualquier pérdida potencial. Cláusula original: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Características
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Katso [LICENSE-APACHE](LICENSE-APACHE) ja [LICENSE-MIT](LICENSE-MIT) yksityiskoh
|
||||
## Yhteisö
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -14,7 +14,10 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributeurs" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Offrez-moi un café" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X : @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit : r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -101,7 +104,7 @@ Utilisez ce tableau pour les avis importants (changements incompatibles, avis de
|
||||
| Date (UTC) | Niveau | Avis | Action |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Critique_ | Nous ne sommes **pas affiliés** à `openagen/zeroclaw` ou `zeroclaw.org`. Le domaine `zeroclaw.org` pointe actuellement vers le fork `openagen/zeroclaw`, et ce domaine/dépôt usurpe l'identité de notre site web/projet officiel. | Ne faites pas confiance aux informations, binaires, levées de fonds ou annonces provenant de ces sources. Utilisez uniquement [ce dépôt](https://github.com/zeroclaw-labs/zeroclaw) et nos comptes sociaux vérifiés. |
|
||||
| 2026-02-21 | _Important_ | Notre site officiel est désormais en ligne : [zeroclawlabs.ai](https://zeroclawlabs.ai). Merci pour votre patience pendant cette attente. Nous constatons toujours des tentatives d'usurpation : ne participez à aucune activité d'investissement/financement au nom de ZeroClaw si elle n'est pas publiée via nos canaux officiels. | Utilisez [ce dépôt](https://github.com/zeroclaw-labs/zeroclaw) comme source unique de vérité. Suivez [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (groupe)](https://www.facebook.com/groups/zeroclaw), et [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) pour les mises à jour officielles. |
|
||||
| 2026-02-21 | _Important_ | Notre site officiel est désormais en ligne : [zeroclawlabs.ai](https://zeroclawlabs.ai). Merci pour votre patience pendant cette attente. Nous constatons toujours des tentatives d'usurpation : ne participez à aucune activité d'investissement/financement au nom de ZeroClaw si elle n'est pas publiée via nos canaux officiels. | Utilisez [ce dépôt](https://github.com/zeroclaw-labs/zeroclaw) comme source unique de vérité. Suivez [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (groupe)](https://www.facebook.com/groups/zeroclawlabs), et [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) pour les mises à jour officielles. |
|
||||
| 2026-02-19 | _Important_ | Anthropic a mis à jour les conditions d'utilisation de l'authentification et des identifiants le 2026-02-19. L'authentification OAuth (Free, Pro, Max) est exclusivement destinée à Claude Code et Claude.ai ; l'utilisation de tokens OAuth de Claude Free/Pro/Max dans tout autre produit, outil ou service (y compris Agent SDK) n'est pas autorisée et peut violer les Conditions d'utilisation grand public. | Veuillez temporairement éviter les intégrations OAuth de Claude Code pour prévenir toute perte potentielle. Clause originale : [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Fonctionnalités
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center" dir="rtl">
|
||||
@@ -193,7 +196,7 @@ channels:
|
||||
## קהילה
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ channels:
|
||||
## समुदाय
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Részletekért lásd a [LICENSE-APACHE](LICENSE-APACHE) és [LICENSE-MIT](LICENS
|
||||
## Közösség
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Lihat [LICENSE-APACHE](LICENSE-APACHE) dan [LICENSE-MIT](LICENSE-MIT) untuk deta
|
||||
## Komunitas
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Usa questa tabella per avvisi importanti (cambiamenti di compatibilità, avvisi
|
||||
| Data (UTC) | Livello | Avviso | Azione |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Critico_ | **Non siamo affiliati** con `openagen/zeroclaw` o `zeroclaw.org`. Il dominio `zeroclaw.org` punta attualmente al fork `openagen/zeroclaw`, e questo dominio/repository sta contraffacendo il nostro sito web/progetto ufficiale. | Non fidarti di informazioni, binari, raccolte fondi o annunci da queste fonti. Usa solo [questo repository](https://github.com/zeroclaw-labs/zeroclaw) e i nostri account social verificati. |
|
||||
| 2026-02-21 | _Importante_ | Il nostro sito ufficiale è ora online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Grazie per la pazienza durante l'attesa. Rileviamo ancora tentativi di contraffazione: non partecipare ad alcuna attività di investimento/finanziamento a nome di ZeroClaw se non pubblicata tramite i nostri canali ufficiali. | Usa [questo repository](https://github.com/zeroclaw-labs/zeroclaw) come unica fonte di verità. Segui [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (gruppo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) per aggiornamenti ufficiali. |
|
||||
| 2026-02-21 | _Importante_ | Il nostro sito ufficiale è ora online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Grazie per la pazienza durante l'attesa. Rileviamo ancora tentativi di contraffazione: non partecipare ad alcuna attività di investimento/finanziamento a nome di ZeroClaw se non pubblicata tramite i nostri canali ufficiali. | Usa [questo repository](https://github.com/zeroclaw-labs/zeroclaw) come unica fonte di verità. Segui [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (gruppo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) per aggiornamenti ufficiali. |
|
||||
| 2026-02-19 | _Importante_ | Anthropic ha aggiornato i termini di utilizzo di autenticazione e credenziali il 2026-02-19. L'autenticazione OAuth (Free, Pro, Max) è esclusivamente per Claude Code e Claude.ai; l'uso di token OAuth di Claude Free/Pro/Max in qualsiasi altro prodotto, strumento o servizio (incluso Agent SDK) non è consentito e può violare i Termini di Utilizzo del Consumatore. | Si prega di evitare temporaneamente le integrazioni OAuth di Claude Code per prevenire qualsiasi potenziale perdita. Clausola originale: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funzionalità
|
||||
|
||||
+5
-2
@@ -13,7 +13,10 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
| 日付 (UTC) | レベル | お知らせ | 対応 |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _緊急_ | 私たちは `openagen/zeroclaw` および `zeroclaw.org` とは**一切関係ありません**。`zeroclaw.org` は現在 `openagen/zeroclaw` の fork を指しており、そのドメイン/リポジトリは当プロジェクトの公式サイト・公式プロジェクトを装っています。 | これらの情報源による案内、バイナリ、資金調達情報、公式発表は信頼しないでください。必ず[本リポジトリ](https://github.com/zeroclaw-labs/zeroclaw)と認証済み公式SNSのみを参照してください。 |
|
||||
| 2026-02-21 | _重要_ | 公式サイトを公開しました: [zeroclawlabs.ai](https://zeroclawlabs.ai)。公開までお待ちいただきありがとうございました。引き続きなりすましの試みを確認しているため、ZeroClaw 名義の投資・資金調達などの案内は、公式チャネルで確認できない限り参加しないでください。 | 情報は[本リポジトリ](https://github.com/zeroclaw-labs/zeroclaw)を最優先で確認し、[X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(グループ)](https://www.facebook.com/groups/zeroclaw)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) と [小紅書アカウント](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) で公式更新を確認してください。 |
|
||||
| 2026-02-21 | _重要_ | 公式サイトを公開しました: [zeroclawlabs.ai](https://zeroclawlabs.ai)。公開までお待ちいただきありがとうございました。引き続きなりすましの試みを確認しているため、ZeroClaw 名義の投資・資金調達などの案内は、公式チャネルで確認できない限り参加しないでください。 | 情報は[本リポジトリ](https://github.com/zeroclaw-labs/zeroclaw)を最優先で確認し、[X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(グループ)](https://www.facebook.com/groups/zeroclawlabs)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) と [小紅書アカウント](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) で公式更新を確認してください。 |
|
||||
| 2026-02-19 | _重要_ | Anthropic は 2026-02-19 に Authentication and Credential Use を更新しました。条文では、OAuth authentication(Free/Pro/Max)は Claude Code と Claude.ai 専用であり、Claude Free/Pro/Max で取得した OAuth トークンを他の製品・ツール・サービス(Agent SDK を含む)で使用することは許可されず、Consumer Terms of Service 違反に該当すると明記されています。 | 損失回避のため、当面は Claude Code OAuth 連携を試さないでください。原文: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)。 |
|
||||
|
||||
## 概要
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Harvard, MIT, 그리고 Sundai.Club 커뮤니티의 학생들과 멤버들이
|
||||
| 날짜 (UTC) | 수준 | 공지 | 조치 |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _중요_ | 우리는 `openagen/zeroclaw` 또는 `zeroclaw.org`와 **관련이 없습니다**. `zeroclaw.org` 도메인은 현재 `openagen/zeroclaw` 포크를 가리키고 있으며, 이 도메인/저장소는 우리의 공식 웹사이트/프로젝트를 사칭하고 있습니다. | 이 소스의 정보, 바이너리, 펀딩, 공지를 신뢰하지 마세요. [이 저장소](https://github.com/zeroclaw-labs/zeroclaw)와 우리의 확인된 소셜 계정만 사용하세요. |
|
||||
| 2026-02-21 | _중요_ | 우리의 공식 웹사이트가 이제 온라인입니다: [zeroclawlabs.ai](https://zeroclawlabs.ai). 기다려주셔서 감사합니다. 여전히 사칭 시도가 감지되고 있습니다: 공식 채널을 통해 게시되지 않은 ZeroClaw 이름의 모든 투자/펀딩 활동에 참여하지 마세요. | [이 저장소](https://github.com/zeroclaw-labs/zeroclaw)를 유일한 진실의 원천으로 사용하세요. [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (그룹)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), 그리고 [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)를 팔로우하여 공식 업데이트를 받으세요. |
|
||||
| 2026-02-21 | _중요_ | 우리의 공식 웹사이트가 이제 온라인입니다: [zeroclawlabs.ai](https://zeroclawlabs.ai). 기다려주셔서 감사합니다. 여전히 사칭 시도가 감지되고 있습니다: 공식 채널을 통해 게시되지 않은 ZeroClaw 이름의 모든 투자/펀딩 활동에 참여하지 마세요. | [이 저장소](https://github.com/zeroclaw-labs/zeroclaw)를 유일한 진실의 원천으로 사용하세요. [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (그룹)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), 그리고 [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)를 팔로우하여 공식 업데이트를 받으세요. |
|
||||
| 2026-02-19 | _중요_ | Anthropic이 2026-02-19에 인증 및 자격증명 사용 약관을 업데이트했습니다. OAuth 인증(Free, Pro, Max)은 Claude Code 및 Claude.ai 전용입니다. 다른 제품, 도구 또는 서비스(Agent SDK 포함)에서 Claude Free/Pro/Max OAuth 토큰을 사용하는 것은 허용되지 않으며 소비자 이용약관을 위반할 수 있습니다. | 잠재적인 손실을 방지하기 위해 일시적으로 Claude Code OAuth 통합을 피하세요. 원본 조항: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ 기능
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
<a href="https://github.com/zeroclaw-labs/zeroclaw/graphs/contributors"><img src="https://img.shields.io/github/contributors/zeroclaw-labs/zeroclaw?color=green" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -94,7 +97,7 @@ Use this board for important notices (breaking changes, security advisories, mai
|
||||
| Date (UTC) | Level | Notice | Action |
|
||||
| ---------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Critical_ | We are **not affiliated** with `openagen/zeroclaw`, `zeroclaw.org` or `zeroclaw.net`. The `zeroclaw.org` and `zeroclaw.net` domains currently points to the `openagen/zeroclaw` fork, and that domain/repository are impersonating our official website/project. | Do not trust information, binaries, fundraising, or announcements from those sources. Use only [this repository](https://github.com/zeroclaw-labs/zeroclaw) and our verified social accounts. |
|
||||
| 2026-02-21 | _Important_ | Our official website is now live: [zeroclawlabs.ai](https://zeroclawlabs.ai). Thanks for your patience while we prepared the launch. We are still seeing impersonation attempts, so do **not** join any investment or fundraising activity claiming the ZeroClaw name unless it is published through our official channels. | Use [this repository](https://github.com/zeroclaw-labs/zeroclaw) as the single source of truth. Follow [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (Group)](https://www.facebook.com/groups/zeroclaw), and [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) for official updates. |
|
||||
| 2026-02-21 | _Important_ | Our official website is now live: [zeroclawlabs.ai](https://zeroclawlabs.ai). Thanks for your patience while we prepared the launch. We are still seeing impersonation attempts, so do **not** join any investment or fundraising activity claiming the ZeroClaw name unless it is published through our official channels. | Use [this repository](https://github.com/zeroclaw-labs/zeroclaw) as the single source of truth. Follow [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (Group)](https://www.facebook.com/groups/zeroclawlabs), and [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) for official updates. |
|
||||
| 2026-02-19 | _Important_ | Anthropic updated the Authentication and Credential Use terms on 2026-02-19. Claude Code OAuth tokens (Free, Pro, Max) are intended exclusively for Claude Code and Claude.ai; using OAuth tokens from Claude Free/Pro/Max in any other product, tool, or service (including Agent SDK) is not permitted and may violate the Consumer Terms of Service. | Please temporarily avoid Claude Code OAuth integrations to prevent potential loss. Original clause: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Features
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Se [LICENSE-APACHE](LICENSE-APACHE) og [LICENSE-MIT](LICENSE-MIT) for detaljer.
|
||||
## Fellesskap
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Gebruik deze tabel voor belangrijke aankondigingen (compatibiliteitswijzigingen,
|
||||
| Datum (UTC) | Niveau | Aankondiging | Actie |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritiek_ | **We zijn niet gelieerd** met `openagen/zeroclaw` of `zeroclaw.org`. Het domein `zeroclaw.org` wijst momenteel naar de fork `openagen/zeroclaw`, en dit domein/repository imiteert onze officiële website/project. | Vertrouw geen informatie, binaire bestanden, fondsenwerving of aankondigingen van deze bronnen. Gebruik alleen [deze repository](https://github.com/zeroclaw-labs/zeroclaw) en onze geverifieerde sociale media accounts. |
|
||||
| 2026-02-21 | _Belangrijk_ | Onze officiële website is nu online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bedankt voor je geduld tijdens het wachten. We detecteren nog steeds imitatiepogingen: neem niet deel aan enige investering/fondsenwerving activiteit in naam van ZeroClaw als deze niet via onze officiële kanalen wordt gepubliceerd. | Gebruik [deze repository](https://github.com/zeroclaw-labs/zeroclaw) als de enige bron van waarheid. Volg [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (groep)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), en [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) voor officiële updates. |
|
||||
| 2026-02-21 | _Belangrijk_ | Onze officiële website is nu online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bedankt voor je geduld tijdens het wachten. We detecteren nog steeds imitatiepogingen: neem niet deel aan enige investering/fondsenwerving activiteit in naam van ZeroClaw als deze niet via onze officiële kanalen wordt gepubliceerd. | Gebruik [deze repository](https://github.com/zeroclaw-labs/zeroclaw) als de enige bron van waarheid. Volg [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (groep)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), en [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) voor officiële updates. |
|
||||
| 2026-02-19 | _Belangrijk_ | Anthropic heeft de gebruiksvoorwaarden voor authenticatie en inloggegevens bijgewerkt op 2026-02-19. OAuth authenticatie (Free, Pro, Max) is exclusief voor Claude Code en Claude.ai; het gebruik van Claude Free/Pro/Max OAuth tokens in enig ander product, tool of service (inclusief Agent SDK) is niet toegestaan en kan in strijd zijn met de Consumenten Gebruiksvoorwaarden. | Vermijd tijdelijk Claude Code OAuth integraties om potentiële verliezen te voorkomen. Originele clausule: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Functies
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Użyj tej tabeli dla ważnych ogłoszeń (zmiany kompatybilności, powiadomienia
|
||||
| Data (UTC) | Poziom | Ogłoszenie | Działanie |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Krytyczny_ | **Nie jesteśmy powiązani** z `openagen/zeroclaw` lub `zeroclaw.org`. Domena `zeroclaw.org` obecnie wskazuje na fork `openagen/zeroclaw`, i ta domena/repozytorium podszywa się pod naszą oficjalną stronę/projekt. | Nie ufaj informacjom, plikom binarnym, zbiórkom funduszy lub ogłoszeniom z tych źródeł. Używaj tylko [tego repozytorium](https://github.com/zeroclaw-labs/zeroclaw) i naszych zweryfikowanych kont społecznościowych. |
|
||||
| 2026-02-21 | _Ważne_ | Nasza oficjalna strona jest teraz online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Dziękujemy za cierpliwość podczas oczekiwania. Nadal wykrywamy próby podszywania się: nie uczestnicz w żadnej działalności inwestycyjnej/finansowej w imieniu ZeroClaw jeśli nie jest opublikowana przez nasze oficjalne kanały. | Używaj [tego repozytorium](https://github.com/zeroclaw-labs/zeroclaw) jako jedynego źródła prawdy. Śledź [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupa)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), i [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) dla oficjalnych aktualizacji. |
|
||||
| 2026-02-21 | _Ważne_ | Nasza oficjalna strona jest teraz online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Dziękujemy za cierpliwość podczas oczekiwania. Nadal wykrywamy próby podszywania się: nie uczestnicz w żadnej działalności inwestycyjnej/finansowej w imieniu ZeroClaw jeśli nie jest opublikowana przez nasze oficjalne kanały. | Używaj [tego repozytorium](https://github.com/zeroclaw-labs/zeroclaw) jako jedynego źródła prawdy. Śledź [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupa)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), i [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) dla oficjalnych aktualizacji. |
|
||||
| 2026-02-19 | _Ważne_ | Anthropic zaktualizował warunki używania uwierzytelniania i poświadczeń 2026-02-19. Uwierzytelnianie OAuth (Free, Pro, Max) jest wyłącznie dla Claude Code i Claude.ai; używanie tokenów OAuth Claude Free/Pro/Max w jakimkolwiek innym produkcie, narzędziu lub usłudze (w tym Agent SDK) nie jest dozwolone i może naruszać Warunki Użytkowania Konsumenta. | Prosimy tymczasowo unikać integracji OAuth Claude Code aby zapobiec potencjalnym stratom. Oryginalna klauzula: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funkcje
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Use esta tabela para avisos importantes (mudanças de compatibilidade, avisos de
|
||||
| Data (UTC) | Nível | Aviso | Ação |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Crítico_ | **Não somos afiliados** ao `openagen/zeroclaw` ou `zeroclaw.org`. O domínio `zeroclaw.org` atualmente aponta para o fork `openagen/zeroclaw`, e este domínio/repositório está falsificando nosso site/projeto oficial. | Não confie em informações, binários, arrecadações ou anúncios dessas fontes. Use apenas [este repositório](https://github.com/zeroclaw-labs/zeroclaw) e nossas contas sociais verificadas. |
|
||||
| 2026-02-21 | _Importante_ | Nosso site oficial agora está online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Obrigado pela paciência durante a espera. Ainda detectamos tentativas de falsificação: não participe de nenhuma atividade de investimento/financiamento em nome do ZeroClaw se não for publicada através de nossos canais oficiais. | Use [este repositório](https://github.com/zeroclaw-labs/zeroclaw) como a única fonte de verdade. Siga [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para atualizações oficiais. |
|
||||
| 2026-02-21 | _Importante_ | Nosso site oficial agora está online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Obrigado pela paciência durante a espera. Ainda detectamos tentativas de falsificação: não participe de nenhuma atividade de investimento/financiamento em nome do ZeroClaw se não for publicada através de nossos canais oficiais. | Use [este repositório](https://github.com/zeroclaw-labs/zeroclaw) como a única fonte de verdade. Siga [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para atualizações oficiais. |
|
||||
| 2026-02-19 | _Importante_ | A Anthropic atualizou os termos de uso de autenticação e credenciais em 2026-02-19. A autenticação OAuth (Free, Pro, Max) é exclusivamente para Claude Code e Claude.ai; o uso de tokens OAuth do Claude Free/Pro/Max em qualquer outro produto, ferramenta ou serviço (incluindo Agent SDK) não é permitido e pode violar os Termos de Uso do Consumidor. | Por favor, evite temporariamente as integrações OAuth do Claude Code para prevenir qualquer perda potencial. Cláusula original: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funcionalidades
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Vezi [LICENSE-APACHE](LICENSE-APACHE) și [LICENSE-MIT](LICENSE-MIT) pentru deta
|
||||
## Comunitate
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -13,7 +13,10 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
| Дата (UTC) | Уровень | Объявление | Действие |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _Срочно_ | Мы **не аффилированы** с `openagen/zeroclaw` и `zeroclaw.org`. Домен `zeroclaw.org` сейчас указывает на fork `openagen/zeroclaw`, и этот домен/репозиторий выдают себя за наш официальный сайт и проект. | Не доверяйте информации, бинарникам, сборам средств и «официальным» объявлениям из этих источников. Используйте только [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw) и наши верифицированные соцсети. |
|
||||
| 2026-02-21 | _Важно_ | Наш официальный сайт уже запущен: [zeroclawlabs.ai](https://zeroclawlabs.ai). Спасибо, что дождались запуска. При этом попытки выдавать себя за ZeroClaw продолжаются, поэтому не участвуйте в инвестициях, сборах средств и похожих активностях, если они не подтверждены через наши официальные каналы. | Ориентируйтесь только на [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw); также следите за [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (группа)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) и [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) для официальных обновлений. |
|
||||
| 2026-02-21 | _Важно_ | Наш официальный сайт уже запущен: [zeroclawlabs.ai](https://zeroclawlabs.ai). Спасибо, что дождались запуска. При этом попытки выдавать себя за ZeroClaw продолжаются, поэтому не участвуйте в инвестициях, сборах средств и похожих активностях, если они не подтверждены через наши официальные каналы. | Ориентируйтесь только на [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw); также следите за [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (группа)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) и [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) для официальных обновлений. |
|
||||
| 2026-02-19 | _Важно_ | Anthropic обновил раздел Authentication and Credential Use 2026-02-19. В нем указано, что OAuth authentication (Free/Pro/Max) предназначена только для Claude Code и Claude.ai; использование OAuth-токенов, полученных через Claude Free/Pro/Max, в любых других продуктах, инструментах или сервисах (включая Agent SDK), не допускается и может считаться нарушением Consumer Terms of Service. | Чтобы избежать потерь, временно не используйте Claude Code OAuth-интеграции. Оригинал: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
## О проекте
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ Se [LICENSE-APACHE](LICENSE-APACHE) och [LICENSE-MIT](LICENSE-MIT) för detaljer
|
||||
## Community
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ channels:
|
||||
## ชุมชน
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Gamitin ang talahanayang ito para sa mahahalagang paunawa (compatibility changes
|
||||
| Petsa (UTC) | Antas | Paunawa | Aksyon |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritikal_ | **Hindi kami kaugnay** sa `openagen/zeroclaw` o `zeroclaw.org`. Ang domain na `zeroclaw.org` ay kasalukuyang tumuturo sa fork na `openagen/zeroclaw`, at ang domain/repository na ito ay nanggagaya sa aming opisyal na website/proyekto. | Huwag magtiwala sa impormasyon, binaries, fundraising, o mga anunsyo mula sa mga pinagmulang ito. Gamitin lamang [ang repository na ito](https://github.com/zeroclaw-labs/zeroclaw) at aming mga verified social media accounts. |
|
||||
| 2026-02-21 | _Mahalaga_ | Ang aming opisyal na website ay ngayon online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Salamat sa iyong pasensya sa panahon ng paghihintay. Nakikita pa rin namin ang mga pagtatangka ng panliliko: huwag lumahok sa anumang investment/funding activity sa ngalan ng ZeroClaw kung hindi ito nai-publish sa pamamagitan ng aming mga opisyal na channel. | Gamitin [ang repository na ito](https://github.com/zeroclaw-labs/zeroclaw) bilang nag-iisang source of truth. Sundan [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), at [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para sa mga opisyal na update. |
|
||||
| 2026-02-21 | _Mahalaga_ | Ang aming opisyal na website ay ngayon online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Salamat sa iyong pasensya sa panahon ng paghihintay. Nakikita pa rin namin ang mga pagtatangka ng panliliko: huwag lumahok sa anumang investment/funding activity sa ngalan ng ZeroClaw kung hindi ito nai-publish sa pamamagitan ng aming mga opisyal na channel. | Gamitin [ang repository na ito](https://github.com/zeroclaw-labs/zeroclaw) bilang nag-iisang source of truth. Sundan [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), at [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para sa mga opisyal na update. |
|
||||
| 2026-02-19 | _Mahalaga_ | In-update ng Anthropic ang authentication at credential use terms noong 2026-02-19. Ang OAuth authentication (Free, Pro, Max) ay eksklusibo para sa Claude Code at Claude.ai; ang paggamit ng Claude Free/Pro/Max OAuth tokens sa anumang iba pang produkto, tool, o serbisyo (kasama ang Agent SDK) ay hindi pinapayagan at maaaring lumabag sa Consumer Terms of Use. | Mangyaring pansamantalang iwasan ang Claude Code OAuth integrations upang maiwasan ang anumang potensyal na pagkawala. Orihinal na clause: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Mga Tampok
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +106,7 @@ Harvard, MIT ve Sundai.Club topluluklarının öğrencileri ve üyeleri tarafın
|
||||
| Tarih (UTC) | Seviye | Duyuru | Eylem |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritik_ | **`openagen/zeroclaw` veya `zeroclaw.org` ile bağlantılı değiliz.** `zeroclaw.org` alanı şu anda `openagen/zeroclaw` fork'una işaret ediyor ve bu alan/depo taklitçiliğini yapıyor. | Bu kaynaklardan bilgi, ikili dosyalar, bağış toplama veya duyurulara güvenmeyin. Sadece [bu depoyu](https://github.com/zeroclaw-labs/zeroclaw) ve doğrulanmış sosyal medya hesaplarımızı kullanın. |
|
||||
| 2026-02-21 | _Önemli_ | Resmi web sitemiz artık çevrimiçi: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bekleme sürecinde sabırlarınız için teşekkürler. Hala taklit girişimleri tespit ediyoruz: ZeroClaw adına resmi kanallarımız aracılığıyla yayınlanmayan herhangi bir yatırım/bağış faaliyetine katılmayın. | [Bu depoyu](https://github.com/zeroclaw-labs/zeroclaw) tek doğruluk kaynağı olarak kullanın. Resmi güncellemeler için [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grup)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) ve [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)'u takip edin. |
|
||||
| 2026-02-21 | _Önemli_ | Resmi web sitemiz artık çevrimiçi: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bekleme sürecinde sabırlarınız için teşekkürler. Hala taklit girişimleri tespit ediyoruz: ZeroClaw adına resmi kanallarımız aracılığıyla yayınlanmayan herhangi bir yatırım/bağış faaliyetine katılmayın. | [Bu depoyu](https://github.com/zeroclaw-labs/zeroclaw) tek doğruluk kaynağı olarak kullanın. Resmi güncellemeler için [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grup)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) ve [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)'u takip edin. |
|
||||
| 2026-02-19 | _Önemli_ | Anthropic, 2026-02-19 tarihinde kimlik doğrulama ve kimlik bilgileri kullanım şartlarını güncelledi. OAuth kimlik doğrulaması (Free, Pro, Max) yalnızca Claude Code ve Claude.ai içindir; Claude Free/Pro/Max OAuth belirteçlerini başka herhangi bir ürün, araç veya hizmette (Agent SDK dahil) kullanmak yasaktır ve Tüketici Kullanım Şartlarını ihlal edebilir. | Olası kayıpları önlemek için lütfen geçici olarak Claude Code OAuth entegrasyonlarından kaçının. Orijinal madde: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Özellikler
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -177,7 +180,7 @@ channels:
|
||||
## Спільнота
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center" dir="rtl">
|
||||
@@ -193,7 +196,7 @@ channels:
|
||||
## کمیونٹی
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -14,7 +14,10 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -101,7 +104,7 @@ Bảng này dành cho các thông báo quan trọng (thay đổi không tương
|
||||
| Ngày (UTC) | Mức độ | Thông báo | Hành động |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _Nghiêm trọng_ | Chúng tôi **không có liên kết** với `openagen/zeroclaw` hoặc `zeroclaw.org`. Tên miền `zeroclaw.org` hiện đang trỏ đến fork `openagen/zeroclaw`, và tên miền/repository đó đang mạo danh website/dự án chính thức của chúng tôi. | Không tin tưởng thông tin, binary, gây quỹ, hay thông báo từ các nguồn đó. Chỉ sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) và các tài khoản mạng xã hội đã được xác minh của chúng tôi. |
|
||||
| 2026-02-21 | _Quan trọng_ | Website chính thức của chúng tôi đã ra mắt: [zeroclawlabs.ai](https://zeroclawlabs.ai). Cảm ơn mọi người đã kiên nhẫn chờ đợi. Chúng tôi vẫn đang ghi nhận các nỗ lực mạo danh, vì vậy **không** tham gia bất kỳ hoạt động đầu tư hoặc gây quỹ nào nhân danh ZeroClaw nếu thông tin đó không được công bố qua các kênh chính thức của chúng tôi. | Sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) làm nguồn thông tin duy nhất đáng tin cậy. Theo dõi [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclaw), và [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) để nhận cập nhật chính thức. |
|
||||
| 2026-02-21 | _Quan trọng_ | Website chính thức của chúng tôi đã ra mắt: [zeroclawlabs.ai](https://zeroclawlabs.ai). Cảm ơn mọi người đã kiên nhẫn chờ đợi. Chúng tôi vẫn đang ghi nhận các nỗ lực mạo danh, vì vậy **không** tham gia bất kỳ hoạt động đầu tư hoặc gây quỹ nào nhân danh ZeroClaw nếu thông tin đó không được công bố qua các kênh chính thức của chúng tôi. | Sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) làm nguồn thông tin duy nhất đáng tin cậy. Theo dõi [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclawlabs), và [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) để nhận cập nhật chính thức. |
|
||||
| 2026-02-19 | _Quan trọng_ | Anthropic đã cập nhật điều khoản Xác thực và Sử dụng Thông tin xác thực vào ngày 2026-02-19. Xác thực OAuth (Free, Pro, Max) được dành riêng cho Claude Code và Claude.ai; việc sử dụng OAuth token từ Claude Free/Pro/Max trong bất kỳ sản phẩm, công cụ hay dịch vụ nào khác (bao gồm Agent SDK) đều không được phép và có thể vi phạm Điều khoản Dịch vụ cho Người tiêu dùng. | Vui lòng tạm thời tránh tích hợp Claude Code OAuth để ngăn ngừa khả năng mất mát. Điều khoản gốc: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Tính năng
|
||||
|
||||
+5
-2
@@ -13,7 +13,10 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
| 日期(UTC) | 级别 | 通知 | 处理建议 |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _紧急_ | 我们与 `openagen/zeroclaw` 及 `zeroclaw.org` **没有任何关系**。`zeroclaw.org` 当前会指向 `openagen/zeroclaw` 这个 fork,并且该域名/仓库正在冒充我们的官网与官方项目。 | 请不要相信上述来源发布的任何信息、二进制、募资活动或官方声明。请仅以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)和已验证官方社媒为准。 |
|
||||
| 2026-02-21 | _重要_ | 我们的官网现已上线:[zeroclawlabs.ai](https://zeroclawlabs.ai)。感谢大家一直以来的耐心等待。我们仍在持续发现冒充行为,请勿参与任何未经我们官方渠道发布、但打着 ZeroClaw 名义进行的投资、募资或类似活动。 | 一切信息请以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)为准;也可关注 [X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(群组)](https://www.facebook.com/groups/zeroclaw)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) 与 [小红书账号](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 获取官方最新动态。 |
|
||||
| 2026-02-21 | _重要_ | 我们的官网现已上线:[zeroclawlabs.ai](https://zeroclawlabs.ai)。感谢大家一直以来的耐心等待。我们仍在持续发现冒充行为,请勿参与任何未经我们官方渠道发布、但打着 ZeroClaw 名义进行的投资、募资或类似活动。 | 一切信息请以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)为准;也可关注 [X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(群组)](https://www.facebook.com/groups/zeroclawlabs)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) 与 [小红书账号](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 获取官方最新动态。 |
|
||||
| 2026-02-19 | _重要_ | Anthropic 于 2026-02-19 更新了 Authentication and Credential Use 条款。条款明确:OAuth authentication(用于 Free、Pro、Max)仅适用于 Claude Code 与 Claude.ai;将 Claude Free/Pro/Max 账号获得的 OAuth token 用于其他任何产品、工具或服务(包括 Agent SDK)不被允许,并可能构成对 Consumer Terms of Service 的违规。 | 为避免损失,请暂时不要尝试 Claude Code OAuth 集成;原文见:[Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)。 |
|
||||
|
||||
## 项目简介
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn main() {
|
||||
let dist_dir = Path::new("web/dist");
|
||||
let web_dir = Path::new("web");
|
||||
|
||||
// Tell Cargo to re-run this script when web source files change.
|
||||
// Tell Cargo to re-run this script when web sources or bundled assets change.
|
||||
println!("cargo:rerun-if-changed=web/src");
|
||||
println!("cargo:rerun-if-changed=web/public");
|
||||
println!("cargo:rerun-if-changed=web/index.html");
|
||||
println!("cargo:rerun-if-changed=web/package.json");
|
||||
println!("cargo:rerun-if-changed=web/package-lock.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.app.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.node.json");
|
||||
println!("cargo:rerun-if-changed=web/vite.config.ts");
|
||||
println!("cargo:rerun-if-changed=web/dist");
|
||||
|
||||
// Attempt to build the web frontend if npm is available and web/dist is
|
||||
// missing or stale. The build is best-effort: when Node.js is not
|
||||
// installed (e.g. CI containers, cross-compilation, minimal dev setups)
|
||||
// we fall back to the existing stub/empty dist directory so the Rust
|
||||
// build still succeeds.
|
||||
let needs_build = !dist_dir.join("index.html").exists();
|
||||
let needs_build = web_build_required(web_dir, dist_dir);
|
||||
|
||||
if needs_build && web_dir.join("package.json").exists() {
|
||||
if let Ok(npm) = which_npm() {
|
||||
@@ -77,6 +85,49 @@ fn main() {
|
||||
ensure_dist_dir(dist_dir);
|
||||
}
|
||||
|
||||
fn web_build_required(web_dir: &Path, dist_dir: &Path) -> bool {
|
||||
let Some(dist_mtime) = latest_modified(dist_dir) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
[
|
||||
web_dir.join("src"),
|
||||
web_dir.join("public"),
|
||||
web_dir.join("index.html"),
|
||||
web_dir.join("package.json"),
|
||||
web_dir.join("package-lock.json"),
|
||||
web_dir.join("tsconfig.json"),
|
||||
web_dir.join("tsconfig.app.json"),
|
||||
web_dir.join("tsconfig.node.json"),
|
||||
web_dir.join("vite.config.ts"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|path| latest_modified(&path))
|
||||
.any(|mtime| mtime > dist_mtime)
|
||||
}
|
||||
|
||||
fn latest_modified(path: &Path) -> Option<SystemTime> {
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
if metadata.is_file() {
|
||||
return metadata.modified().ok();
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut latest = metadata.modified().ok();
|
||||
let entries = fs::read_dir(path).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
if let Some(child_mtime) = latest_modified(&entry.path()) {
|
||||
latest = Some(match latest {
|
||||
Some(current) if current >= child_mtime => current,
|
||||
_ => child_mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
/// Ensure the dist directory exists so `rust-embed` does not fail at compile
|
||||
/// time even when the web frontend is not built.
|
||||
fn ensure_dist_dir(dist_dir: &Path) {
|
||||
|
||||
@@ -12,6 +12,13 @@ ignore = [
|
||||
# bincode v2.0.1 via probe-rs — project ceased but 1.3.3 considered complete
|
||||
"RUSTSEC-2025-0141",
|
||||
{ id = "RUSTSEC-2024-0384", reason = "Reported to `rust-nostr/nostr` and it's WIP" },
|
||||
{ id = "RUSTSEC-2024-0388", reason = "derivative via extism → wasmtime transitive dep" },
|
||||
{ id = "RUSTSEC-2025-0057", reason = "fxhash via extism → wasmtime transitive dep" },
|
||||
{ id = "RUSTSEC-2025-0119", reason = "number_prefix via indicatif — cosmetic dep" },
|
||||
# wasmtime vulns via extism 1.13.0 — no upstream fix yet; plugins feature-gated
|
||||
{ id = "RUSTSEC-2026-0006", reason = "wasmtime segfault via extism; awaiting extism upgrade" },
|
||||
{ id = "RUSTSEC-2026-0020", reason = "WASI resource exhaustion via extism; awaiting extism upgrade" },
|
||||
{ id = "RUSTSEC-2026-0021", reason = "WASI http fields panic via extism; awaiting extism upgrade" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
+6
-3
@@ -10,6 +10,9 @@
|
||||
services:
|
||||
zeroclaw:
|
||||
image: ghcr.io/zeroclaw-labs/zeroclaw:latest
|
||||
# For ARM64 environments where the distroless image exits immediately,
|
||||
# switch to the Debian compatibility image instead:
|
||||
# image: ghcr.io/zeroclaw-labs/zeroclaw:debian
|
||||
# Or build locally (distroless, no shell):
|
||||
# build: .
|
||||
# Or build the Debian variant (includes bash, git, curl):
|
||||
@@ -50,15 +53,15 @@ services:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
memory: 32M
|
||||
|
||||
# Health check — uses lightweight status instead of full diagnostics.
|
||||
# For images with curl, prefer: curl -f http://localhost:42617/health
|
||||
healthcheck:
|
||||
test: ["CMD", "zeroclaw", "status"]
|
||||
test: ["CMD", "zeroclaw", "status", "--format=exit-code"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -31,7 +31,7 @@ Build with `--features hardware` to include Uno Q support.
|
||||
|
||||
### 1.1 Configure Uno Q via App Lab
|
||||
|
||||
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux).
|
||||
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (tar.gz on Linux).
|
||||
2. Connect Uno Q via USB, power it on.
|
||||
3. Open App Lab, connect to the board.
|
||||
4. Follow the setup wizard:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "zeroclaw-weather-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
extism-pdk = "1.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "weather"
|
||||
version = "0.1.0"
|
||||
description = "Example weather tool plugin for ZeroClaw"
|
||||
author = "ZeroClaw Labs"
|
||||
wasm_path = "target/wasm32-wasip1/release/zeroclaw_weather_plugin.wasm"
|
||||
|
||||
capabilities = ["tool"]
|
||||
permissions = ["http_client"]
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Example ZeroClaw weather plugin.
|
||||
//!
|
||||
//! Demonstrates how to create a WASM tool plugin using extism-pdk.
|
||||
//! Build with: cargo build --target wasm32-wasip1 --release
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WeatherInput {
|
||||
location: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WeatherOutput {
|
||||
location: String,
|
||||
temperature: f64,
|
||||
unit: String,
|
||||
condition: String,
|
||||
humidity: u32,
|
||||
}
|
||||
|
||||
/// Get weather for a location (mock implementation for demonstration).
|
||||
#[plugin_fn]
|
||||
pub fn get_weather(input: String) -> FnResult<String> {
|
||||
let params: WeatherInput =
|
||||
serde_json::from_str(&input).map_err(|e| Error::msg(format!("invalid input: {e}")))?;
|
||||
|
||||
// Mock weather data for demonstration
|
||||
let output = WeatherOutput {
|
||||
location: params.location,
|
||||
temperature: 22.5,
|
||||
unit: "celsius".to_string(),
|
||||
condition: "Partly cloudy".to_string(),
|
||||
humidity: 65,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&output)
|
||||
.map_err(|e| Error::msg(format!("serialization error: {e}")))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
+45
-3
@@ -177,11 +177,29 @@ get_available_disk_mb() {
|
||||
fi
|
||||
}
|
||||
|
||||
is_musl_linux() {
|
||||
[[ "$(uname -s)" == "Linux" ]] || return 1
|
||||
|
||||
if [[ -f /etc/alpine-release ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if have_cmd ldd && ldd --version 2>&1 | grep -qi 'musl'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_release_target() {
|
||||
local os arch
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
if is_musl_linux; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$os:$arch" in
|
||||
Linux:x86_64)
|
||||
echo "x86_64-unknown-linux-gnu"
|
||||
@@ -283,6 +301,12 @@ install_prebuilt_binary() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if is_musl_linux; then
|
||||
warn "Pre-built release binaries are not published for musl/Alpine yet."
|
||||
warn "Falling back to source build."
|
||||
return 1
|
||||
fi
|
||||
|
||||
target="$(detect_release_target || true)"
|
||||
if [[ -z "$target" ]]; then
|
||||
warn "No pre-built binary target mapping for $(uname -s)/$(uname -m)."
|
||||
@@ -1145,7 +1169,11 @@ if [[ "$FORCE_SOURCE_BUILD" == false ]]; then
|
||||
SKIP_BUILD=true
|
||||
SKIP_INSTALL=true
|
||||
elif [[ "$PREBUILT_ONLY" == true ]]; then
|
||||
error "Pre-built-only mode requested, but no compatible release asset is available."
|
||||
if is_musl_linux; then
|
||||
error "Pre-built-only mode is not supported on musl/Alpine because releases do not include musl assets yet."
|
||||
else
|
||||
error "Pre-built-only mode requested, but no compatible release asset is available."
|
||||
fi
|
||||
error "Try again later, or run with --force-source-build on a machine with enough RAM/disk."
|
||||
exit 1
|
||||
else
|
||||
@@ -1290,8 +1318,14 @@ if [[ -n "$ZEROCLAW_BIN" ]]; then
|
||||
step_ok "Gateway service restarted"
|
||||
|
||||
# Fetch and display pairing code from running gateway
|
||||
sleep 1 # brief wait for service to start
|
||||
if PAIR_CODE=$("$ZEROCLAW_BIN" gateway get-paircode 2>/dev/null | grep -oE '[0-9]{6}'); then
|
||||
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
|
||||
@@ -1300,6 +1334,7 @@ if [[ -n "$ZEROCLAW_BIN" ]]; then
|
||||
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"
|
||||
@@ -1331,6 +1366,13 @@ else
|
||||
echo -e "${BOLD_BLUE}${CRAB} ZeroClaw installed successfully!${RESET}"
|
||||
fi
|
||||
|
||||
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]] && ! have_cmd zeroclaw; then
|
||||
echo
|
||||
warn "zeroclaw is installed in $HOME/.cargo/bin, but that directory is not in PATH for this shell."
|
||||
warn 'Run: export PATH="$HOME/.cargo/bin:$PATH"'
|
||||
step_dot "To persist it, add that export line to ~/.bashrc, ~/.zshrc, or your shell profile, then open a new shell."
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_MODE" == "upgrade" ]]; then
|
||||
step_dot "Upgrade complete"
|
||||
fi
|
||||
|
||||
+166
-1
@@ -4,6 +4,7 @@ use crate::agent::dispatcher::{
|
||||
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
|
||||
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
|
||||
use crate::config::Config;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::observability::{self, Observer, ObserverEvent};
|
||||
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
|
||||
@@ -40,6 +41,7 @@ pub struct Agent {
|
||||
route_model_by_hint: HashMap<String, String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
tool_descriptions: Option<ToolDescriptions>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder {
|
||||
@@ -64,6 +66,7 @@ pub struct AgentBuilder {
|
||||
route_model_by_hint: Option<HashMap<String, String>>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
tool_descriptions: Option<ToolDescriptions>,
|
||||
}
|
||||
|
||||
impl AgentBuilder {
|
||||
@@ -90,6 +93,7 @@ impl AgentBuilder {
|
||||
route_model_by_hint: None,
|
||||
allowed_tools: None,
|
||||
response_cache: None,
|
||||
tool_descriptions: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +211,11 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tool_descriptions(mut self, tool_descriptions: Option<ToolDescriptions>) -> Self {
|
||||
self.tool_descriptions = tool_descriptions;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Agent> {
|
||||
let mut tools = self
|
||||
.tools
|
||||
@@ -257,6 +266,7 @@ impl AgentBuilder {
|
||||
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
|
||||
allowed_tools: allowed,
|
||||
response_cache: self.response_cache,
|
||||
tool_descriptions: self.tool_descriptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -278,6 +288,25 @@ impl Agent {
|
||||
self.memory_session_id = session_id;
|
||||
}
|
||||
|
||||
/// Hydrate the agent with prior chat messages (e.g. from a session backend).
|
||||
///
|
||||
/// Ensures a system prompt is prepended if history is empty, then appends all
|
||||
/// non-system messages from the seed. System messages in the seed are skipped
|
||||
/// to avoid duplicating the system prompt.
|
||||
pub fn seed_history(&mut self, messages: &[ChatMessage]) {
|
||||
if self.history.is_empty() {
|
||||
if let Ok(sys) = self.build_system_prompt() {
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::system(sys)));
|
||||
}
|
||||
}
|
||||
for msg in messages {
|
||||
if msg.role != "system" {
|
||||
self.history.push(ConversationMessage::Chat(msg.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_config(config: &Config) -> Result<Self> {
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
@@ -331,13 +360,16 @@ impl Agent {
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514")
|
||||
.to_string();
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider(
|
||||
let provider_runtime_options = providers::provider_runtime_options_from_config(config);
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
provider_name,
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&model_name,
|
||||
&provider_runtime_options,
|
||||
)?;
|
||||
|
||||
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
|
||||
@@ -434,6 +466,7 @@ impl Agent {
|
||||
skills_prompt_mode: self.skills_prompt_mode,
|
||||
identity_config: Some(&self.identity_config),
|
||||
dispatcher_instructions: &instructions,
|
||||
tool_descriptions: self.tool_descriptions.as_ref(),
|
||||
};
|
||||
self.prompt_builder.build(&ctx)
|
||||
}
|
||||
@@ -1006,6 +1039,92 @@ mod tests {
|
||||
assert_eq!(seen.as_slice(), &["hint:fast".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_config_passes_extra_headers_to_custom_provider() {
|
||||
use axum::{http::HeaderMap, routing::post, Json, Router};
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let captured_headers: Arc<std::sync::Mutex<Option<HashMap<String, String>>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let captured_headers_clone = captured_headers.clone();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/chat/completions",
|
||||
post(
|
||||
move |headers: HeaderMap, Json(_body): Json<serde_json::Value>| {
|
||||
let captured_headers = captured_headers_clone.clone();
|
||||
async move {
|
||||
let collected = headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|value| (name.as_str().to_string(), value.to_string()))
|
||||
})
|
||||
.collect();
|
||||
*captured_headers.lock().unwrap() = Some(collected);
|
||||
Json(serde_json::json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "hello from mock"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let tmp = TempDir::new().expect("temp dir");
|
||||
let workspace_dir = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir).unwrap();
|
||||
|
||||
let mut config = crate::config::Config::default();
|
||||
config.workspace_dir = workspace_dir;
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config.api_key = Some("test-key".to_string());
|
||||
config.default_provider = Some(format!("custom:http://{addr}"));
|
||||
config.default_model = Some("test-model".to_string());
|
||||
config.memory.backend = "none".to_string();
|
||||
config.memory.auto_save = false;
|
||||
config.extra_headers.insert(
|
||||
"User-Agent".to_string(),
|
||||
"zeroclaw-web-test/1.0".to_string(),
|
||||
);
|
||||
config
|
||||
.extra_headers
|
||||
.insert("X-Title".to_string(), "zeroclaw-web".to_string());
|
||||
|
||||
let mut agent = Agent::from_config(&config).expect("agent from config");
|
||||
let response = agent.turn("hello").await.expect("agent turn");
|
||||
|
||||
assert_eq!(response, "hello from mock");
|
||||
|
||||
let headers = captured_headers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("captured headers");
|
||||
assert_eq!(
|
||||
headers.get("user-agent").map(String::as_str),
|
||||
Some("zeroclaw-web-test/1.0")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-title").map(String::as_str),
|
||||
Some("zeroclaw-web")
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_allowed_tools_none_keeps_all_tools() {
|
||||
let provider = Box::new(MockProvider {
|
||||
@@ -1069,4 +1188,50 @@ mod tests {
|
||||
"No tools should match a non-existent allowlist entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_history_prepends_system_and_skips_system_from_seed() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||
.expect("memory creation should succeed with valid config"),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.build()
|
||||
.expect("agent builder should succeed with valid config");
|
||||
|
||||
let seed = vec![
|
||||
ChatMessage::system("old system prompt"),
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi there"),
|
||||
];
|
||||
agent.seed_history(&seed);
|
||||
|
||||
let history = agent.history();
|
||||
// First message should be a freshly built system prompt (not the seed one)
|
||||
assert!(matches!(&history[0], ConversationMessage::Chat(m) if m.role == "system"));
|
||||
// System message from seed should be skipped, so next is user
|
||||
assert!(
|
||||
matches!(&history[1], ConversationMessage::Chat(m) if m.role == "user" && m.content == "hello")
|
||||
);
|
||||
assert!(
|
||||
matches!(&history[2], ConversationMessage::Chat(m) if m.role == "assistant" && m.content == "hi there")
|
||||
);
|
||||
assert_eq!(history.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+247
-49
@@ -1,5 +1,6 @@
|
||||
use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
|
||||
use crate::config::Config;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::multimodal;
|
||||
use crate::observability::{self, runtime_trace, Observer, ObserverEvent};
|
||||
@@ -2131,8 +2132,12 @@ pub(crate) async fn agent_turn(
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
silent: bool,
|
||||
channel_name: &str,
|
||||
multimodal_config: &crate::config::MultimodalConfig,
|
||||
max_tool_iterations: usize,
|
||||
excluded_tools: &[String],
|
||||
dedup_exempt_tools: &[String],
|
||||
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
|
||||
) -> Result<String> {
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
@@ -2144,15 +2149,15 @@ pub(crate) async fn agent_turn(
|
||||
temperature,
|
||||
silent,
|
||||
None,
|
||||
"channel",
|
||||
channel_name,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
excluded_tools,
|
||||
dedup_exempt_tools,
|
||||
activated_tools,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2174,7 +2179,7 @@ async fn execute_one_tool(
|
||||
|
||||
let static_tool = find_tool(tools_registry, call_name);
|
||||
let activated_arc = if static_tool.is_none() {
|
||||
activated_tools.and_then(|at| at.lock().unwrap().get(call_name))
|
||||
activated_tools.and_then(|at| at.lock().unwrap().get_resolved(call_name))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -3000,7 +3005,10 @@ pub(crate) async fn run_tool_call_loop(
|
||||
|
||||
/// Build the tool instruction block for the system prompt so the LLM knows
|
||||
/// how to invoke tools.
|
||||
pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
|
||||
pub(crate) fn build_tool_instructions(
|
||||
tools_registry: &[Box<dyn Tool>],
|
||||
tool_descriptions: Option<&ToolDescriptions>,
|
||||
) -> String {
|
||||
let mut instructions = String::new();
|
||||
instructions.push_str("\n## Tool Use Protocol\n\n");
|
||||
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
|
||||
@@ -3016,11 +3024,14 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> Strin
|
||||
instructions.push_str("### Available Tools\n\n");
|
||||
|
||||
for tool in tools_registry {
|
||||
let desc = tool_descriptions
|
||||
.and_then(|td| td.get(tool.name()))
|
||||
.unwrap_or_else(|| tool.description());
|
||||
let _ = writeln!(
|
||||
instructions,
|
||||
"**{}**: {}\nParameters: `{}`\n",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
desc,
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
@@ -3205,16 +3216,7 @@ pub async fn run(
|
||||
.or(config.default_model.as_deref())
|
||||
.unwrap_or("anthropic/claude-sonnet-4");
|
||||
|
||||
let provider_runtime_options = providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: config.api_url.clone(),
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
extra_headers: config.extra_headers.clone(),
|
||||
api_path: config.api_path.clone(),
|
||||
};
|
||||
let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
provider_name,
|
||||
@@ -3251,6 +3253,16 @@ pub async fn run(
|
||||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
// ── Load locale-aware tool descriptions ────────────────────────
|
||||
let i18n_locale = config
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(crate::i18n::detect_locale);
|
||||
let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
|
||||
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
|
||||
|
||||
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
||||
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
@@ -3380,7 +3392,7 @@ pub async fn run(
|
||||
|
||||
// Append structured tool-use instructions with schemas (only for non-native providers)
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry));
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
|
||||
}
|
||||
|
||||
// Append deferred MCP tool names so the LLM knows what is available
|
||||
@@ -3743,6 +3755,10 @@ pub async fn process_message(
|
||||
// NOTE: Same ordering contract as the CLI path above — MCP tools must be
|
||||
// injected after filter_primary_agent_tools_or_fail (or equivalent built-in
|
||||
// tool allow/deny filtering) to avoid MCP tools being silently dropped.
|
||||
let mut deferred_section = String::new();
|
||||
let mut activated_handle_pm: Option<
|
||||
std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
|
||||
> = None;
|
||||
if config.mcp.enabled && !config.mcp.servers.is_empty() {
|
||||
tracing::info!(
|
||||
"Initializing MCP client — {} server(s) configured",
|
||||
@@ -3751,28 +3767,50 @@ pub async fn process_message(
|
||||
match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
|
||||
Ok(registry) => {
|
||||
let registry = std::sync::Arc::new(registry);
|
||||
let names = registry.tool_names();
|
||||
let mut registered = 0usize;
|
||||
for name in names {
|
||||
if let Some(def) = registry.get_tool_def(&name).await {
|
||||
let wrapper: std::sync::Arc<dyn Tool> =
|
||||
std::sync::Arc::new(crate::tools::McpToolWrapper::new(
|
||||
name,
|
||||
def,
|
||||
std::sync::Arc::clone(®istry),
|
||||
));
|
||||
if let Some(ref handle) = delegate_handle_pm {
|
||||
handle.write().push(std::sync::Arc::clone(&wrapper));
|
||||
if config.mcp.deferred_loading {
|
||||
let deferred_set = crate::tools::DeferredMcpToolSet::from_registry(
|
||||
std::sync::Arc::clone(®istry),
|
||||
)
|
||||
.await;
|
||||
tracing::info!(
|
||||
"MCP deferred: {} tool stub(s) from {} server(s)",
|
||||
deferred_set.len(),
|
||||
registry.server_count()
|
||||
);
|
||||
deferred_section =
|
||||
crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
|
||||
let activated = std::sync::Arc::new(std::sync::Mutex::new(
|
||||
crate::tools::ActivatedToolSet::new(),
|
||||
));
|
||||
activated_handle_pm = Some(std::sync::Arc::clone(&activated));
|
||||
tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
|
||||
deferred_set,
|
||||
activated,
|
||||
)));
|
||||
} else {
|
||||
let names = registry.tool_names();
|
||||
let mut registered = 0usize;
|
||||
for name in names {
|
||||
if let Some(def) = registry.get_tool_def(&name).await {
|
||||
let wrapper: std::sync::Arc<dyn Tool> =
|
||||
std::sync::Arc::new(crate::tools::McpToolWrapper::new(
|
||||
name,
|
||||
def,
|
||||
std::sync::Arc::clone(®istry),
|
||||
));
|
||||
if let Some(ref handle) = delegate_handle_pm {
|
||||
handle.write().push(std::sync::Arc::clone(&wrapper));
|
||||
}
|
||||
tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
|
||||
registered += 1;
|
||||
}
|
||||
tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
|
||||
registered += 1;
|
||||
}
|
||||
tracing::info!(
|
||||
"MCP: {} tool(s) registered from {} server(s)",
|
||||
registered,
|
||||
registry.server_count()
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"MCP: {} tool(s) registered from {} server(s)",
|
||||
registered,
|
||||
registry.server_count()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("MCP registry failed to initialize: {e:#}");
|
||||
@@ -3785,16 +3823,7 @@ pub async fn process_message(
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
let provider_runtime_options = providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: config.api_url.clone(),
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
extra_headers: config.extra_headers.clone(),
|
||||
api_path: config.api_path.clone(),
|
||||
};
|
||||
let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
provider_name,
|
||||
config.api_key.as_deref(),
|
||||
@@ -3820,6 +3849,16 @@ pub async fn process_message(
|
||||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
// ── Load locale-aware tool descriptions ────────────────────────
|
||||
let i18n_locale = config
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(crate::i18n::detect_locale);
|
||||
let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
|
||||
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
|
||||
|
||||
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
("shell", "Execute terminal commands."),
|
||||
@@ -3885,7 +3924,11 @@ pub async fn process_message(
|
||||
config.skills.prompt_injection_mode,
|
||||
);
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry));
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
|
||||
}
|
||||
if !deferred_section.is_empty() {
|
||||
system_prompt.push('\n');
|
||||
system_prompt.push_str(&deferred_section);
|
||||
}
|
||||
|
||||
let mem_context = build_context(
|
||||
@@ -3912,6 +3955,8 @@ pub async fn process_message(
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&enriched),
|
||||
];
|
||||
let excluded_tools =
|
||||
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message);
|
||||
|
||||
agent_turn(
|
||||
provider.as_ref(),
|
||||
@@ -3922,8 +3967,12 @@ pub async fn process_message(
|
||||
&model_name,
|
||||
config.default_temperature,
|
||||
true,
|
||||
"daemon",
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle_pm.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4021,6 +4070,36 @@ mod tests {
|
||||
assert!(outcome.output.contains("Unknown tool: unknown_tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
|
||||
let observer = NoopObserver;
|
||||
let invocations = Arc::new(AtomicUsize::new(0));
|
||||
let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
|
||||
let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
|
||||
"docker-mcp__extract_text",
|
||||
Arc::clone(&invocations),
|
||||
));
|
||||
activated
|
||||
.lock()
|
||||
.unwrap()
|
||||
.activate("docker-mcp__extract_text".into(), activated_tool);
|
||||
|
||||
let outcome = execute_one_tool(
|
||||
"extract_text",
|
||||
serde_json::json!({ "value": "ok" }),
|
||||
&[],
|
||||
Some(&activated),
|
||||
&observer,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("suffix alias should execute the unique activated tool");
|
||||
|
||||
assert!(outcome.success);
|
||||
assert_eq!(outcome.output, "counted:ok");
|
||||
assert_eq!(invocations.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||
use crate::observability::NoopObserver;
|
||||
use crate::providers::traits::ProviderCapabilities;
|
||||
@@ -4659,6 +4738,68 @@ mod tests {
|
||||
assert!(tool_results.content.contains("Skipped duplicate tool call"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"shell","arguments":{"command":"echo hello"}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let tmp = TempDir::new().expect("temp dir");
|
||||
let security = Arc::new(crate::security::SecurityPolicy {
|
||||
autonomy: crate::security::AutonomyLevel::Supervised,
|
||||
workspace_dir: tmp.path().to_path_buf(),
|
||||
..crate::security::SecurityPolicy::default()
|
||||
});
|
||||
let runtime: Arc<dyn crate::runtime::RuntimeAdapter> =
|
||||
Arc::new(crate::runtime::NativeRuntime::new());
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
|
||||
crate::tools::shell::ShellTool::new(security, runtime),
|
||||
)];
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("run shell"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
let approval_mgr =
|
||||
ApprovalManager::for_non_interactive(&crate::config::AutonomyConfig::default());
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
Some(&approval_mgr),
|
||||
"telegram",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("non-interactive shell should succeed for low-risk command");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
|
||||
let tool_results = history
|
||||
.iter()
|
||||
.find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
|
||||
.expect("tool results message should be present");
|
||||
assert!(tool_results.content.contains("hello"));
|
||||
assert!(!tool_results.content.contains("Denied by user."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
@@ -4856,6 +4997,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_turn_executes_activated_tool_from_wrapper() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime should initialize");
|
||||
|
||||
runtime.block_on(async {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let invocations = Arc::new(AtomicUsize::new(0));
|
||||
let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
|
||||
let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
|
||||
"pixel__get_api_health",
|
||||
Arc::clone(&invocations),
|
||||
));
|
||||
activated
|
||||
.lock()
|
||||
.unwrap()
|
||||
.activate("pixel__get_api_health".into(), activated_tool);
|
||||
|
||||
let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("use the activated MCP tool"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
|
||||
let result = agent_turn(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
"daemon",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
&[],
|
||||
&[],
|
||||
Some(&activated),
|
||||
)
|
||||
.await
|
||||
.expect("wrapper path should execute activated tools");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
assert_eq!(invocations.load(Ordering::SeqCst), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
|
||||
let display = resolve_display_text(
|
||||
@@ -5425,7 +5623,7 @@ Tail"#;
|
||||
std::path::Path::new("/tmp"),
|
||||
));
|
||||
let tools = tools::default_tools(security);
|
||||
let instructions = build_tool_instructions(&tools);
|
||||
let instructions = build_tool_instructions(&tools, None);
|
||||
|
||||
assert!(instructions.contains("## Tool Use Protocol"));
|
||||
assert!(instructions.contains("<tool_call>"));
|
||||
|
||||
+15
-1
@@ -1,4 +1,5 @@
|
||||
use crate::config::IdentityConfig;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::identity;
|
||||
use crate::skills::Skill;
|
||||
use crate::tools::Tool;
|
||||
@@ -17,6 +18,9 @@ pub struct PromptContext<'a> {
|
||||
pub skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
|
||||
pub identity_config: Option<&'a IdentityConfig>,
|
||||
pub dispatcher_instructions: &'a str,
|
||||
/// Locale-aware tool descriptions. When present, tool descriptions in
|
||||
/// prompts are resolved from the locale file instead of hardcoded values.
|
||||
pub tool_descriptions: Option<&'a ToolDescriptions>,
|
||||
}
|
||||
|
||||
pub trait PromptSection: Send + Sync {
|
||||
@@ -124,11 +128,15 @@ impl PromptSection for ToolsSection {
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::from("## Tools\n\n");
|
||||
for tool in ctx.tools {
|
||||
let desc = ctx
|
||||
.tool_descriptions
|
||||
.and_then(|td: &ToolDescriptions| td.get(tool.name()))
|
||||
.unwrap_or_else(|| tool.description());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
desc,
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
@@ -317,6 +325,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: Some(&identity_config),
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let section = IdentitySection;
|
||||
@@ -345,6 +354,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
assert!(prompt.contains("## Tools"));
|
||||
@@ -380,6 +390,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let output = SkillsSection.build(&ctx).unwrap();
|
||||
@@ -418,6 +429,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Compact,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let output = SkillsSection.build(&ctx).unwrap();
|
||||
@@ -439,6 +451,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let rendered = DateTimeSection.build(&ctx).unwrap();
|
||||
@@ -477,6 +490,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
|
||||
@@ -126,6 +126,15 @@ impl ApprovalManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Channel-driven shell execution is still guarded by the shell tool's
|
||||
// own command allowlist and risk policy. Skipping the outer approval
|
||||
// gate here lets low-risk allowlisted commands (e.g. `ls`) work in
|
||||
// non-interactive channels without silently allowing medium/high-risk
|
||||
// commands.
|
||||
if self.non_interactive && tool_name == "shell" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// auto_approve skips the prompt.
|
||||
if self.auto_approve.contains(tool_name) {
|
||||
return false;
|
||||
@@ -456,6 +465,12 @@ mod tests {
|
||||
assert!(!mgr.needs_approval("memory_recall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_shell_skips_outer_approval_by_default() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&AutonomyConfig::default());
|
||||
assert!(!mgr.needs_approval("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_always_ask_tools_need_approval() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
|
||||
+76
-19
@@ -227,6 +227,10 @@ fn channel_message_timeout_budget_secs(
|
||||
struct ChannelRouteSelection {
|
||||
provider: String,
|
||||
model: String,
|
||||
/// Route-specific API key override. When set, this takes precedence over
|
||||
/// the global `api_key` in [`ChannelRuntimeContext`] when creating the
|
||||
/// provider for this route.
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -904,6 +908,7 @@ fn default_route_selection(ctx: &ChannelRuntimeContext) -> ChannelRouteSelection
|
||||
ChannelRouteSelection {
|
||||
provider: defaults.default_provider,
|
||||
model: defaults.model,
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,21 +1127,43 @@ fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<S
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build a cache key that includes the provider name and, when a
|
||||
/// route-specific API key is supplied, a hash of that key. This prevents
|
||||
/// cache poisoning when multiple routes target the same provider with
|
||||
/// different credentials.
|
||||
fn provider_cache_key(provider_name: &str, route_api_key: Option<&str>) -> String {
|
||||
match route_api_key {
|
||||
Some(key) => {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
format!("{provider_name}@{:x}", hasher.finish())
|
||||
}
|
||||
None => provider_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_create_provider(
|
||||
ctx: &ChannelRuntimeContext,
|
||||
provider_name: &str,
|
||||
route_api_key: Option<&str>,
|
||||
) -> anyhow::Result<Arc<dyn Provider>> {
|
||||
let cache_key = provider_cache_key(provider_name, route_api_key);
|
||||
|
||||
if let Some(existing) = ctx
|
||||
.provider_cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(provider_name)
|
||||
.get(&cache_key)
|
||||
.cloned()
|
||||
{
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
if provider_name == ctx.default_provider.as_str() {
|
||||
// Only return the pre-built default provider when there is no
|
||||
// route-specific credential override — otherwise the default was
|
||||
// created with the global key and would be wrong.
|
||||
if route_api_key.is_none() && provider_name == ctx.default_provider.as_str() {
|
||||
return Ok(Arc::clone(&ctx.provider));
|
||||
}
|
||||
|
||||
@@ -1147,9 +1174,14 @@ async fn get_or_create_provider(
|
||||
None
|
||||
};
|
||||
|
||||
// Prefer route-specific credential; fall back to the global key.
|
||||
let effective_api_key = route_api_key
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| ctx.api_key.clone());
|
||||
|
||||
let provider = create_resilient_provider_nonblocking(
|
||||
provider_name,
|
||||
ctx.api_key.clone(),
|
||||
effective_api_key,
|
||||
api_url.map(ToString::to_string),
|
||||
ctx.reliability.as_ref().clone(),
|
||||
ctx.provider_runtime_options.clone(),
|
||||
@@ -1163,7 +1195,7 @@ async fn get_or_create_provider(
|
||||
|
||||
let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cached = cache
|
||||
.entry(provider_name.to_string())
|
||||
.entry(cache_key)
|
||||
.or_insert_with(|| Arc::clone(&provider));
|
||||
Ok(Arc::clone(cached))
|
||||
}
|
||||
@@ -1279,25 +1311,27 @@ async fn handle_runtime_command_if_needed(
|
||||
ChannelRuntimeCommand::ShowProviders => build_providers_help_response(¤t),
|
||||
ChannelRuntimeCommand::SetProvider(raw_provider) => {
|
||||
match resolve_provider_alias(&raw_provider) {
|
||||
Some(provider_name) => match get_or_create_provider(ctx, &provider_name).await {
|
||||
Ok(_) => {
|
||||
if provider_name != current.provider {
|
||||
current.provider = provider_name.clone();
|
||||
set_route_selection(ctx, &sender_key, current.clone());
|
||||
}
|
||||
Some(provider_name) => {
|
||||
match get_or_create_provider(ctx, &provider_name, None).await {
|
||||
Ok(_) => {
|
||||
if provider_name != current.provider {
|
||||
current.provider = provider_name.clone();
|
||||
set_route_selection(ctx, &sender_key, current.clone());
|
||||
}
|
||||
|
||||
format!(
|
||||
format!(
|
||||
"Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model <model-id>` to set a provider-compatible model.",
|
||||
current.model
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
let safe_err = providers::sanitize_api_error(&err.to_string());
|
||||
format!(
|
||||
}
|
||||
Err(err) => {
|
||||
let safe_err = providers::sanitize_api_error(&err.to_string());
|
||||
format!(
|
||||
"Failed to initialize provider `{provider_name}`. Route unchanged.\nDetails: {safe_err}"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
None => format!(
|
||||
"Unknown provider `{raw_provider}`. Use `/models` to list valid providers."
|
||||
),
|
||||
@@ -1317,6 +1351,7 @@ async fn handle_runtime_command_if_needed(
|
||||
}) {
|
||||
current.provider = route.provider.clone();
|
||||
current.model = route.model.clone();
|
||||
current.api_key = route.api_key.clone();
|
||||
} else {
|
||||
current.model = model.clone();
|
||||
}
|
||||
@@ -1922,12 +1957,19 @@ async fn process_channel_message(
|
||||
route = ChannelRouteSelection {
|
||||
provider: matched_route.provider.clone(),
|
||||
model: matched_route.model.clone(),
|
||||
api_key: matched_route.api_key.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let runtime_defaults = runtime_defaults_snapshot(ctx.as_ref());
|
||||
let active_provider = match get_or_create_provider(ctx.as_ref(), &route.provider).await {
|
||||
let active_provider = match get_or_create_provider(
|
||||
ctx.as_ref(),
|
||||
&route.provider,
|
||||
route.api_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
let safe_err = providers::sanitize_api_error(&err.to_string());
|
||||
@@ -3737,6 +3779,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
reasoning_effort: config.runtime.reasoning_effort.clone(),
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
extra_headers: config.extra_headers.clone(),
|
||||
api_path: config.api_path.clone(),
|
||||
@@ -3887,6 +3930,16 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
|
||||
let skills = crate::skills::load_skills_with_config(&workspace, &config);
|
||||
|
||||
// ── Load locale-aware tool descriptions ────────────────────────
|
||||
let i18n_locale = config
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(crate::i18n::detect_locale);
|
||||
let i18n_search_dirs = crate::i18n::default_search_dirs(&workspace);
|
||||
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
|
||||
|
||||
// Collect tool descriptions for the prompt
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
(
|
||||
@@ -3966,7 +4019,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
config.skills.prompt_injection_mode,
|
||||
);
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
|
||||
system_prompt.push_str(&build_tool_instructions(
|
||||
tools_registry.as_ref(),
|
||||
Some(&i18n_descs),
|
||||
));
|
||||
}
|
||||
|
||||
// Append deferred MCP tool names so the LLM knows what is available
|
||||
@@ -5601,6 +5657,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
ChannelRouteSelection {
|
||||
provider: "openrouter".to_string(),
|
||||
model: "route-model".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6715,7 +6772,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
"build_system_prompt should not emit protocol block directly"
|
||||
);
|
||||
|
||||
prompt.push_str(&build_tool_instructions(&[]));
|
||||
prompt.push_str(&build_tool_instructions(&[], None));
|
||||
|
||||
assert_eq!(
|
||||
prompt.matches("## Tool Use Protocol").count(),
|
||||
|
||||
@@ -76,6 +76,11 @@ pub trait SessionBackend: Send + Sync {
|
||||
fn search(&self, _query: &SessionQuery) -> Vec<SessionMetadata> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Delete all messages for a session. Returns `true` if the session existed.
|
||||
fn delete_session(&self, _session_key: &str) -> std::io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -288,6 +288,39 @@ impl SessionBackend for SqliteSessionBackend {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn delete_session(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
// Check if session exists
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM session_metadata WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Delete messages (FTS5 trigger handles sessions_fts cleanup)
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// Delete metadata
|
||||
conn.execute(
|
||||
"DELETE FROM session_metadata WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn search(&self, query: &SessionQuery) -> Vec<SessionMetadata> {
|
||||
let Some(keyword) = &query.keyword else {
|
||||
return self.list_sessions_with_metadata();
|
||||
@@ -473,6 +506,28 @@ mod tests {
|
||||
assert_eq!(sessions[0], "new_session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_all_data() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend.append("s1", &ChatMessage::user("hello")).unwrap();
|
||||
backend.append("s1", &ChatMessage::assistant("hi")).unwrap();
|
||||
backend.append("s2", &ChatMessage::user("other")).unwrap();
|
||||
|
||||
assert!(backend.delete_session("s1").unwrap());
|
||||
assert!(backend.load("s1").is_empty());
|
||||
assert_eq!(backend.list_sessions().len(), 1);
|
||||
assert_eq!(backend.list_sessions()[0], "s2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_returns_false_for_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
assert!(!backend.delete_session("nonexistent").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_from_jsonl_imports_and_renames() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod self_test;
|
||||
pub mod update;
|
||||
@@ -0,0 +1,281 @@
|
||||
//! `zeroclaw self-test` — quick and full diagnostic checks.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
/// Result of a single diagnostic check.
|
||||
pub struct CheckResult {
|
||||
pub name: &'static str,
|
||||
pub passed: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl CheckResult {
|
||||
fn pass(name: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
passed: true,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(name: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
passed: false,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the quick self-test suite (no network required).
|
||||
pub async fn run_quick(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 1. Config file exists and parses
|
||||
results.push(check_config(config));
|
||||
|
||||
// 2. Workspace directory is writable
|
||||
results.push(check_workspace(&config.workspace_dir).await);
|
||||
|
||||
// 3. SQLite memory backend opens
|
||||
results.push(check_sqlite(&config.workspace_dir));
|
||||
|
||||
// 4. Provider registry has entries
|
||||
results.push(check_provider_registry());
|
||||
|
||||
// 5. Tool registry has entries
|
||||
results.push(check_tool_registry(config));
|
||||
|
||||
// 6. Channel registry loads
|
||||
results.push(check_channel_config(config));
|
||||
|
||||
// 7. Security policy parses
|
||||
results.push(check_security_policy(config));
|
||||
|
||||
// 8. Version sanity
|
||||
results.push(check_version());
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Run the full self-test suite (includes network checks).
|
||||
pub async fn run_full(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
|
||||
let mut results = run_quick(config).await?;
|
||||
|
||||
// 9. Gateway health endpoint
|
||||
results.push(check_gateway_health(config).await);
|
||||
|
||||
// 10. Memory write/read round-trip
|
||||
results.push(check_memory_roundtrip(config).await);
|
||||
|
||||
// 11. WebSocket handshake
|
||||
results.push(check_websocket_handshake(config).await);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Print results in a formatted table.
|
||||
pub fn print_results(results: &[CheckResult]) {
|
||||
let total = results.len();
|
||||
let passed = results.iter().filter(|r| r.passed).count();
|
||||
let failed = total - passed;
|
||||
|
||||
println!();
|
||||
for (i, r) in results.iter().enumerate() {
|
||||
let icon = if r.passed {
|
||||
"\x1b[32m✓\x1b[0m"
|
||||
} else {
|
||||
"\x1b[31m✗\x1b[0m"
|
||||
};
|
||||
println!(" {} {}/{} {} — {}", icon, i + 1, total, r.name, r.detail);
|
||||
}
|
||||
println!();
|
||||
if failed == 0 {
|
||||
println!(" \x1b[32mAll {total} checks passed.\x1b[0m");
|
||||
} else {
|
||||
println!(" \x1b[31m{failed}/{total} checks failed.\x1b[0m");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn check_config(config: &crate::config::Config) -> CheckResult {
|
||||
if config.config_path.exists() {
|
||||
CheckResult::pass(
|
||||
"config",
|
||||
format!("loaded from {}", config.config_path.display()),
|
||||
)
|
||||
} else {
|
||||
CheckResult::fail("config", "config file not found (using defaults)")
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_workspace(workspace_dir: &Path) -> CheckResult {
|
||||
match tokio::fs::metadata(workspace_dir).await {
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
// Try writing a temp file
|
||||
let test_file = workspace_dir.join(".selftest_probe");
|
||||
match tokio::fs::write(&test_file, b"ok").await {
|
||||
Ok(()) => {
|
||||
let _ = tokio::fs::remove_file(&test_file).await;
|
||||
CheckResult::pass(
|
||||
"workspace",
|
||||
format!("{} (writable)", workspace_dir.display()),
|
||||
)
|
||||
}
|
||||
Err(e) => CheckResult::fail(
|
||||
"workspace",
|
||||
format!("{} (not writable: {e})", workspace_dir.display()),
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) => CheckResult::fail(
|
||||
"workspace",
|
||||
format!("{} exists but is not a directory", workspace_dir.display()),
|
||||
),
|
||||
Err(e) => CheckResult::fail(
|
||||
"workspace",
|
||||
format!("{} (error: {e})", workspace_dir.display()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_sqlite(workspace_dir: &Path) -> CheckResult {
|
||||
let db_path = workspace_dir.join("memory.db");
|
||||
match rusqlite::Connection::open(&db_path) {
|
||||
Ok(conn) => match conn.execute_batch("SELECT 1") {
|
||||
Ok(()) => CheckResult::pass("sqlite", "memory.db opens and responds"),
|
||||
Err(e) => CheckResult::fail("sqlite", format!("query failed: {e}")),
|
||||
},
|
||||
Err(e) => CheckResult::fail("sqlite", format!("cannot open memory.db: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_provider_registry() -> CheckResult {
|
||||
let providers = crate::providers::list_providers();
|
||||
if providers.is_empty() {
|
||||
CheckResult::fail("providers", "no providers registered")
|
||||
} else {
|
||||
CheckResult::pass(
|
||||
"providers",
|
||||
format!("{} providers available", providers.len()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_tool_registry(config: &crate::config::Config) -> CheckResult {
|
||||
let security = std::sync::Arc::new(crate::security::SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let tools = crate::tools::default_tools(security);
|
||||
if tools.is_empty() {
|
||||
CheckResult::fail("tools", "no tools registered")
|
||||
} else {
|
||||
CheckResult::pass("tools", format!("{} core tools available", tools.len()))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_channel_config(config: &crate::config::Config) -> CheckResult {
|
||||
let channels = config.channels_config.channels();
|
||||
let configured = channels.iter().filter(|(_, c)| *c).count();
|
||||
CheckResult::pass(
|
||||
"channels",
|
||||
format!(
|
||||
"{} channel types, {} configured",
|
||||
channels.len(),
|
||||
configured
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_security_policy(config: &crate::config::Config) -> CheckResult {
|
||||
let _policy =
|
||||
crate::security::SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
CheckResult::pass(
|
||||
"security",
|
||||
format!("autonomy level: {:?}", config.autonomy.level),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_version() -> CheckResult {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
CheckResult::pass("version", format!("v{version}"))
|
||||
}
|
||||
|
||||
async fn check_gateway_health(config: &crate::config::Config) -> CheckResult {
|
||||
let port = config.gateway.port;
|
||||
let host = if config.gateway.host == "[::]" || config.gateway.host == "0.0.0.0" {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
&config.gateway.host
|
||||
};
|
||||
let url = format!("http://{host}:{port}/health");
|
||||
match reqwest::Client::new()
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
CheckResult::pass("gateway", format!("health OK at {url}"))
|
||||
}
|
||||
Ok(resp) => CheckResult::fail("gateway", format!("health returned {}", resp.status())),
|
||||
Err(e) => CheckResult::fail("gateway", format!("not reachable at {url}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_memory_roundtrip(config: &crate::config::Config) -> CheckResult {
|
||||
let mem = match crate::memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return CheckResult::fail("memory", format!("cannot create backend: {e}")),
|
||||
};
|
||||
|
||||
let test_key = "__selftest_probe__";
|
||||
let test_value = "selftest_ok";
|
||||
|
||||
if let Err(e) = mem
|
||||
.store(
|
||||
test_key,
|
||||
test_value,
|
||||
crate::memory::MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return CheckResult::fail("memory", format!("write failed: {e}"));
|
||||
}
|
||||
|
||||
match mem.recall(test_key, 1, None).await {
|
||||
Ok(entries) if !entries.is_empty() => {
|
||||
let _ = mem.forget(test_key).await;
|
||||
CheckResult::pass("memory", "write/read/delete round-trip OK")
|
||||
}
|
||||
Ok(_) => {
|
||||
let _ = mem.forget(test_key).await;
|
||||
CheckResult::fail("memory", "no entries returned after round-trip")
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = mem.forget(test_key).await;
|
||||
CheckResult::fail("memory", format!("read failed: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_websocket_handshake(config: &crate::config::Config) -> CheckResult {
|
||||
let port = config.gateway.port;
|
||||
let host = if config.gateway.host == "[::]" || config.gateway.host == "0.0.0.0" {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
&config.gateway.host
|
||||
};
|
||||
let url = format!("ws://{host}:{port}/ws/chat");
|
||||
|
||||
match tokio_tungstenite::connect_async(&url).await {
|
||||
Ok((_, _)) => CheckResult::pass("websocket", format!("handshake OK at {url}")),
|
||||
Err(e) => CheckResult::fail("websocket", format!("handshake failed at {url}: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! `zeroclaw update` — self-update pipeline with rollback.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const GITHUB_RELEASES_LATEST_URL: &str =
|
||||
"https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/latest";
|
||||
const GITHUB_RELEASES_TAG_URL: &str =
|
||||
"https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/tags";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateInfo {
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub download_url: Option<String>,
|
||||
pub is_newer: bool,
|
||||
}
|
||||
|
||||
/// Check for available updates without downloading.
|
||||
///
|
||||
/// If `target_version` is `Some`, fetch that specific release tag instead of latest.
|
||||
pub async fn check(target_version: Option<&str>) -> Result<UpdateInfo> {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(format!("zeroclaw/{current}"))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?;
|
||||
|
||||
let url = match target_version {
|
||||
Some(v) => {
|
||||
let tag = if v.starts_with('v') {
|
||||
v.to_string()
|
||||
} else {
|
||||
format!("v{v}")
|
||||
};
|
||||
format!("{GITHUB_RELEASES_TAG_URL}/{tag}")
|
||||
}
|
||||
None => GITHUB_RELEASES_LATEST_URL.to_string(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to reach GitHub releases API")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!("GitHub API returned {}", resp.status());
|
||||
}
|
||||
|
||||
let release: serde_json::Value = resp.json().await?;
|
||||
let tag = release["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
|
||||
let download_url = find_asset_url(&release);
|
||||
let is_newer = version_is_newer(¤t, &tag);
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current_version: current,
|
||||
latest_version: tag,
|
||||
download_url,
|
||||
is_newer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the full 6-phase update pipeline.
|
||||
///
|
||||
/// If `target_version` is `Some`, fetch that specific version instead of latest.
|
||||
pub async fn run(target_version: Option<&str>) -> Result<()> {
|
||||
// Phase 1: Preflight
|
||||
info!("Phase 1/6: Preflight checks...");
|
||||
let update_info = check(target_version).await?;
|
||||
|
||||
if !update_info.is_newer {
|
||||
println!("Already up to date (v{}).", update_info.current_version);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Update available: v{} -> v{}",
|
||||
update_info.current_version, update_info.latest_version
|
||||
);
|
||||
|
||||
let download_url = update_info
|
||||
.download_url
|
||||
.context("no suitable binary found for this platform")?;
|
||||
|
||||
let current_exe =
|
||||
std::env::current_exe().context("cannot determine current executable path")?;
|
||||
|
||||
// Phase 2: Download
|
||||
info!("Phase 2/6: Downloading...");
|
||||
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
|
||||
let download_path = temp_dir.path().join("zeroclaw_new");
|
||||
download_binary(&download_url, &download_path).await?;
|
||||
|
||||
// Phase 3: Backup
|
||||
info!("Phase 3/6: Creating backup...");
|
||||
let backup_path = current_exe.with_extension("bak");
|
||||
tokio::fs::copy(¤t_exe, &backup_path)
|
||||
.await
|
||||
.context("failed to backup current binary")?;
|
||||
|
||||
// Phase 4: Validate
|
||||
info!("Phase 4/6: Validating download...");
|
||||
validate_binary(&download_path).await?;
|
||||
|
||||
// Phase 5: Swap
|
||||
info!("Phase 5/6: Swapping binary...");
|
||||
if let Err(e) = swap_binary(&download_path, ¤t_exe).await {
|
||||
// Rollback
|
||||
warn!("Swap failed, rolling back: {e}");
|
||||
if let Err(rollback_err) = tokio::fs::copy(&backup_path, ¤t_exe).await {
|
||||
eprintln!("CRITICAL: Rollback also failed: {rollback_err}");
|
||||
eprintln!(
|
||||
"Manual recovery: cp {} {}",
|
||||
backup_path.display(),
|
||||
current_exe.display()
|
||||
);
|
||||
}
|
||||
bail!("Update failed during swap: {e}");
|
||||
}
|
||||
|
||||
// Phase 6: Smoke test
|
||||
info!("Phase 6/6: Smoke test...");
|
||||
match smoke_test(¤t_exe).await {
|
||||
Ok(()) => {
|
||||
// Cleanup backup on success
|
||||
let _ = tokio::fs::remove_file(&backup_path).await;
|
||||
println!("Successfully updated to v{}!", update_info.latest_version);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Smoke test failed, rolling back: {e}");
|
||||
tokio::fs::copy(&backup_path, ¤t_exe)
|
||||
.await
|
||||
.context("rollback after smoke test failure")?;
|
||||
bail!("Update rolled back — smoke test failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_asset_url(release: &serde_json::Value) -> Option<String> {
|
||||
let target = if cfg!(target_os = "macos") {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-apple-darwin"
|
||||
}
|
||||
} else if cfg!(target_os = "linux") {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-unknown-linux"
|
||||
} else {
|
||||
"x86_64-unknown-linux"
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
release["assets"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
asset["name"]
|
||||
.as_str()
|
||||
.map(|name| name.contains(target))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|asset| asset["browser_download_url"].as_str().map(String::from))
|
||||
}
|
||||
|
||||
fn version_is_newer(current: &str, candidate: &str) -> bool {
|
||||
let parse = |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse().ok()).collect() };
|
||||
let cur = parse(current);
|
||||
let cand = parse(candidate);
|
||||
cand > cur
|
||||
}
|
||||
|
||||
async fn download_binary(url: &str, dest: &Path) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(format!("zeroclaw/{}", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("download request failed")?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("download returned {}", resp.status());
|
||||
}
|
||||
|
||||
let bytes = resp.bytes().await.context("failed to read download body")?;
|
||||
tokio::fs::write(dest, &bytes)
|
||||
.await
|
||||
.context("failed to write downloaded binary")?;
|
||||
|
||||
// Make executable on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o755);
|
||||
tokio::fs::set_permissions(dest, perms).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_binary(path: &Path) -> Result<()> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
if meta.len() < 1_000_000 {
|
||||
bail!(
|
||||
"downloaded binary too small ({} bytes), likely corrupt",
|
||||
meta.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Quick check: try running --version
|
||||
let output = tokio::process::Command::new(path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.context("cannot execute downloaded binary")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("downloaded binary --version check failed");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.contains("zeroclaw") {
|
||||
bail!("downloaded binary does not appear to be zeroclaw");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn swap_binary(new: &Path, target: &Path) -> Result<()> {
|
||||
tokio::fs::copy(new, target)
|
||||
.await
|
||||
.context("failed to overwrite binary")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn smoke_test(binary: &Path) -> Result<()> {
|
||||
let output = tokio::process::Command::new(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.context("smoke test: cannot execute updated binary")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("smoke test: updated binary returned non-zero exit code");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_comparison() {
|
||||
assert!(version_is_newer("0.4.3", "0.5.0"));
|
||||
assert!(version_is_newer("0.4.3", "0.4.4"));
|
||||
assert!(!version_is_newer("0.5.0", "0.4.3"));
|
||||
assert!(!version_is_newer("0.4.3", "0.4.3"));
|
||||
assert!(version_is_newer("1.0.0", "2.0.0"));
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -19,13 +19,14 @@ pub use schema::{
|
||||
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
|
||||
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
|
||||
ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig,
|
||||
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig,
|
||||
ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
|
||||
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
||||
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
|
||||
StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy,
|
||||
TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig,
|
||||
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
|
||||
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig, ProjectIntelConfig,
|
||||
ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode,
|
||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
||||
SwarmConfig, SwarmStrategy, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
|
||||
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
WorkspaceConfig,
|
||||
};
|
||||
|
||||
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
|
||||
+222
-3
@@ -335,6 +335,21 @@ pub struct Config {
|
||||
/// LinkedIn integration configuration (`[linkedin]`).
|
||||
#[serde(default)]
|
||||
pub linkedin: LinkedInConfig,
|
||||
|
||||
/// Plugin system configuration (`[plugins]`).
|
||||
#[serde(default)]
|
||||
pub plugins: PluginsConfig,
|
||||
|
||||
/// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
|
||||
///
|
||||
/// When set, tool descriptions shown in system prompts are loaded from
|
||||
/// `tool_descriptions/<locale>.toml`. Falls back to English, then to
|
||||
/// hardcoded descriptions.
|
||||
///
|
||||
/// If omitted or empty, the locale is auto-detected from `ZEROCLAW_LOCALE`,
|
||||
/// `LANG`, or `LC_ALL` environment variables (defaulting to `"en"`).
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
/// Multi-client workspace isolation configuration.
|
||||
@@ -524,6 +539,28 @@ where
|
||||
validate_temperature(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
|
||||
_ => Err(format!(
|
||||
"reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_reasoning_effort_opt<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value: Option<String> = Option::deserialize(deserializer)?;
|
||||
value
|
||||
.map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn default_max_depth() -> u32 {
|
||||
3
|
||||
}
|
||||
@@ -1415,6 +1452,7 @@ impl Default for PeripheralBoardConfig {
|
||||
///
|
||||
/// Controls the HTTP gateway for webhook and pairing endpoints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct GatewayConfig {
|
||||
/// Gateway port (default: 42617)
|
||||
#[serde(default = "default_gateway_port")]
|
||||
@@ -1456,6 +1494,18 @@ pub struct GatewayConfig {
|
||||
/// Maximum distinct idempotency keys retained in memory.
|
||||
#[serde(default = "default_gateway_idempotency_max_keys")]
|
||||
pub idempotency_max_keys: usize,
|
||||
|
||||
/// Persist gateway WebSocket chat sessions to SQLite. Default: true.
|
||||
#[serde(default = "default_true")]
|
||||
pub session_persistence: bool,
|
||||
|
||||
/// Auto-archive stale gateway sessions older than N hours. 0 = disabled. Default: 0.
|
||||
#[serde(default)]
|
||||
pub session_ttl_hours: u32,
|
||||
|
||||
/// Pairing dashboard configuration
|
||||
#[serde(default)]
|
||||
pub pairing_dashboard: PairingDashboardConfig,
|
||||
}
|
||||
|
||||
fn default_gateway_port() -> u16 {
|
||||
@@ -1490,6 +1540,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
impl Default for GatewayConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -1504,6 +1558,57 @@ impl Default for GatewayConfig {
|
||||
rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
|
||||
idempotency_ttl_secs: default_idempotency_ttl_secs(),
|
||||
idempotency_max_keys: default_gateway_idempotency_max_keys(),
|
||||
session_persistence: true,
|
||||
session_ttl_hours: 0,
|
||||
pairing_dashboard: PairingDashboardConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairing dashboard configuration (`[gateway.pairing_dashboard]`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PairingDashboardConfig {
|
||||
/// Length of pairing codes (default: 8)
|
||||
#[serde(default = "default_pairing_code_length")]
|
||||
pub code_length: usize,
|
||||
/// Time-to-live for pending pairing codes in seconds (default: 3600)
|
||||
#[serde(default = "default_pairing_ttl")]
|
||||
pub code_ttl_secs: u64,
|
||||
/// Maximum concurrent pending pairing codes (default: 3)
|
||||
#[serde(default = "default_max_pending_codes")]
|
||||
pub max_pending_codes: usize,
|
||||
/// Maximum failed pairing attempts before lockout (default: 5)
|
||||
#[serde(default = "default_max_failed_attempts")]
|
||||
pub max_failed_attempts: u32,
|
||||
/// Lockout duration in seconds after max attempts (default: 300)
|
||||
#[serde(default = "default_pairing_lockout_secs")]
|
||||
pub lockout_secs: u64,
|
||||
}
|
||||
|
||||
fn default_pairing_code_length() -> usize {
|
||||
8
|
||||
}
|
||||
fn default_pairing_ttl() -> u64 {
|
||||
3600
|
||||
}
|
||||
fn default_max_pending_codes() -> usize {
|
||||
3
|
||||
}
|
||||
fn default_max_failed_attempts() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_pairing_lockout_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
impl Default for PairingDashboardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
code_length: default_pairing_code_length(),
|
||||
code_ttl_secs: default_pairing_ttl(),
|
||||
max_pending_codes: default_max_pending_codes(),
|
||||
max_failed_attempts: default_max_failed_attempts(),
|
||||
lockout_secs: default_pairing_lockout_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2267,6 +2372,42 @@ fn default_linkedin_api_version() -> String {
|
||||
"202602".to_string()
|
||||
}
|
||||
|
||||
/// Plugin system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PluginsConfig {
|
||||
/// Enable the plugin system (default: false)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Directory where plugins are stored
|
||||
#[serde(default = "default_plugins_dir")]
|
||||
pub plugins_dir: String,
|
||||
/// Auto-discover and load plugins on startup
|
||||
#[serde(default)]
|
||||
pub auto_discover: bool,
|
||||
/// Maximum number of plugins that can be loaded
|
||||
#[serde(default = "default_max_plugins")]
|
||||
pub max_plugins: usize,
|
||||
}
|
||||
|
||||
fn default_plugins_dir() -> String {
|
||||
"~/.zeroclaw/plugins".to_string()
|
||||
}
|
||||
|
||||
fn default_max_plugins() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
impl Default for PluginsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
plugins_dir: default_plugins_dir(),
|
||||
auto_discover: false,
|
||||
max_plugins: default_max_plugins(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content strategy configuration for LinkedIn auto-posting (`[linkedin.content]`).
|
||||
///
|
||||
/// The agent reads this via the `linkedin get_content_strategy` action to know
|
||||
@@ -3425,6 +3566,7 @@ impl Default for WebhookAuditConfig {
|
||||
/// Controls what the agent is allowed to do: shell commands, filesystem access,
|
||||
/// risk approval gates, and per-policy budgets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct AutonomyConfig {
|
||||
/// Autonomy level: `read_only`, `supervised` (default), or `full`.
|
||||
pub level: AutonomyLevel,
|
||||
@@ -3566,6 +3708,9 @@ pub struct RuntimeConfig {
|
||||
/// - `Some(false)`: disable reasoning/thinking when supported
|
||||
#[serde(default)]
|
||||
pub reasoning_enabled: Option<bool>,
|
||||
/// Optional reasoning effort for providers that expose a level control.
|
||||
#[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
/// Docker runtime configuration (`[runtime.docker]` section).
|
||||
@@ -3640,6 +3785,7 @@ impl Default for RuntimeConfig {
|
||||
kind: default_runtime_kind(),
|
||||
docker: DockerRuntimeConfig::default(),
|
||||
reasoning_enabled: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4169,8 +4315,8 @@ pub struct ChannelsConfig {
|
||||
pub ack_reactions: bool,
|
||||
/// Whether to send tool-call notification messages (e.g. `🔧 web_search_tool: …`)
|
||||
/// to channel users. When `false`, tool calls are still logged server-side but
|
||||
/// not forwarded as individual channel messages. Default: `true`.
|
||||
#[serde(default = "default_true")]
|
||||
/// not forwarded as individual channel messages. Default: `false`.
|
||||
#[serde(default = "default_false")]
|
||||
pub show_tool_calls: bool,
|
||||
/// Persist channel conversation history to JSONL files so sessions survive
|
||||
/// daemon restarts. Files are stored in `{workspace}/sessions/`. Default: `true`.
|
||||
@@ -4332,7 +4478,7 @@ impl Default for ChannelsConfig {
|
||||
bluesky: None,
|
||||
message_timeout_secs: default_channel_message_timeout_secs(),
|
||||
ack_reactions: true,
|
||||
show_tool_calls: true,
|
||||
show_tool_calls: false,
|
||||
session_persistence: true,
|
||||
session_backend: default_session_backend(),
|
||||
session_ttl_hours: 0,
|
||||
@@ -5855,6 +6001,8 @@ impl Default for Config {
|
||||
node_transport: NodeTransportConfig::default(),
|
||||
knowledge: KnowledgeConfig::default(),
|
||||
linkedin: LinkedInConfig::default(),
|
||||
plugins: PluginsConfig::default(),
|
||||
locale: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7310,6 +7458,16 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(raw) = std::env::var("ZEROCLAW_REASONING_EFFORT")
|
||||
.or_else(|_| std::env::var("REASONING_EFFORT"))
|
||||
.or_else(|_| std::env::var("ZEROCLAW_CODEX_REASONING_EFFORT"))
|
||||
{
|
||||
match normalize_reasoning_effort(&raw) {
|
||||
Ok(effort) => self.runtime.reasoning_effort = Some(effort),
|
||||
Err(message) => tracing::warn!("Ignoring reasoning effort env override: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Web search enabled: ZEROCLAW_WEB_SEARCH_ENABLED or WEB_SEARCH_ENABLED
|
||||
if let Ok(enabled) = std::env::var("ZEROCLAW_WEB_SEARCH_ENABLED")
|
||||
.or_else(|_| std::env::var("WEB_SEARCH_ENABLED"))
|
||||
@@ -8141,6 +8299,7 @@ default_temperature = 0.7
|
||||
assert!(c.cli);
|
||||
assert!(c.telegram.is_none());
|
||||
assert!(c.discord.is_none());
|
||||
assert!(!c.show_tool_calls);
|
||||
}
|
||||
|
||||
// ── Serde round-trip ─────────────────────────────────────
|
||||
@@ -8279,6 +8438,8 @@ default_temperature = 0.7
|
||||
node_transport: NodeTransportConfig::default(),
|
||||
knowledge: KnowledgeConfig::default(),
|
||||
linkedin: LinkedInConfig::default(),
|
||||
plugins: PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
@@ -8473,6 +8634,32 @@ reasoning_enabled = false
|
||||
assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn runtime_reasoning_effort_deserializes() {
|
||||
let raw = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[runtime]
|
||||
reasoning_effort = "HIGH"
|
||||
"#;
|
||||
|
||||
let parsed: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn runtime_reasoning_effort_rejects_invalid_values() {
|
||||
let raw = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[runtime]
|
||||
reasoning_effort = "turbo"
|
||||
"#;
|
||||
|
||||
let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
|
||||
assert!(error.to_string().contains("reasoning_effort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn agent_config_defaults() {
|
||||
let cfg = AgentConfig::default();
|
||||
@@ -8585,6 +8772,8 @@ tool_dispatcher = "xml"
|
||||
node_transport: NodeTransportConfig::default(),
|
||||
knowledge: KnowledgeConfig::default(),
|
||||
linkedin: LinkedInConfig::default(),
|
||||
plugins: PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
config.save().await.unwrap();
|
||||
@@ -9321,10 +9510,15 @@ channel_id = "C123"
|
||||
rate_limit_max_keys: 2048,
|
||||
idempotency_ttl_secs: 600,
|
||||
idempotency_max_keys: 4096,
|
||||
session_persistence: true,
|
||||
session_ttl_hours: 0,
|
||||
pairing_dashboard: PairingDashboardConfig::default(),
|
||||
};
|
||||
let toml_str = toml::to_string(&g).unwrap();
|
||||
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert!(parsed.require_pairing);
|
||||
assert!(parsed.session_persistence);
|
||||
assert_eq!(parsed.session_ttl_hours, 0);
|
||||
assert!(!parsed.allow_public_bind);
|
||||
assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
|
||||
assert_eq!(parsed.pair_rate_limit_per_minute, 12);
|
||||
@@ -10454,6 +10648,31 @@ default_model = "legacy-model"
|
||||
std::env::remove_var("ZEROCLAW_REASONING_ENABLED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn env_override_reasoning_effort() {
|
||||
let _env_guard = env_override_lock().await;
|
||||
let mut config = Config::default();
|
||||
assert_eq!(config.runtime.reasoning_effort, None);
|
||||
|
||||
std::env::set_var("ZEROCLAW_REASONING_EFFORT", "HIGH");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("high"));
|
||||
|
||||
std::env::remove_var("ZEROCLAW_REASONING_EFFORT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn env_override_reasoning_effort_legacy_codex_env() {
|
||||
let _env_guard = env_override_lock().await;
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_CODEX_REASONING_EFFORT", "minimal");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("minimal"));
|
||||
|
||||
std::env::remove_var("ZEROCLAW_CODEX_REASONING_EFFORT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn env_override_invalid_port_ignored() {
|
||||
let _env_guard = env_override_lock().await;
|
||||
|
||||
+16
-2
@@ -242,6 +242,15 @@ async fn persist_job_result(
|
||||
if success {
|
||||
if let Err(e) = remove_job(config, &job.id) {
|
||||
tracing::warn!("Failed to remove one-shot cron job after success: {e}");
|
||||
// Fall back to disabling the job so it won't re-trigger.
|
||||
let _ = update_job(
|
||||
config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
enabled: Some(false),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = record_last_run(config, &job.id, finished_at, false, output);
|
||||
@@ -1038,7 +1047,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_not_deleted() {
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp).await;
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
@@ -1060,8 +1069,13 @@ mod tests {
|
||||
let success = persist_job_result(&config, &job, true, "ok", started, finished).await;
|
||||
assert!(success);
|
||||
|
||||
// After reschedule_after_run, At schedule jobs should be disabled
|
||||
// to prevent re-execution with a past next_run timestamp.
|
||||
let updated = cron::get_job(&config, &job.id).unwrap();
|
||||
assert!(updated.enabled);
|
||||
assert!(
|
||||
!updated.enabled,
|
||||
"At schedule job should be disabled after execution via reschedule"
|
||||
);
|
||||
assert_eq!(updated.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
|
||||
+67
-17
@@ -285,26 +285,41 @@ pub fn reschedule_after_run(
|
||||
output: &str,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
let status = if success { "ok" } else { "error" };
|
||||
let bounded_output = truncate_cron_output(output);
|
||||
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
// One-shot `At` schedules have no future occurrence — record the run
|
||||
// result and disable the job so it won't be picked up again.
|
||||
if matches!(job.schedule, Schedule::At { .. }) {
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET enabled = 0, last_run = ?1, last_status = ?2, last_output = ?3
|
||||
WHERE id = ?4",
|
||||
params![now.to_rfc3339(), status, bounded_output, job.id],
|
||||
)
|
||||
.context("Failed to disable completed one-shot cron job")?;
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_run(
|
||||
@@ -852,6 +867,41 @@ mod tests {
|
||||
assert!(stored.len() <= MAX_CRON_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, true, "done").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job_on_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, false, "failed").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule even on failure"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("error"));
|
||||
assert_eq!(stored.last_output.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_truncates_last_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -127,6 +127,9 @@ pub async fn run(config: Config, host: String, port: u16) -> Result<()> {
|
||||
println!("🧠 ZeroClaw daemon started");
|
||||
println!(" Gateway: http://{host}:{port}");
|
||||
println!(" Components: gateway, channels, heartbeat, scheduler");
|
||||
if config.gateway.require_pairing {
|
||||
println!(" Pairing: enabled (code appears in gateway output above)");
|
||||
}
|
||||
println!(" Ctrl+C or SIGTERM to stop");
|
||||
|
||||
// Wait for shutdown signal (SIGINT or SIGTERM)
|
||||
|
||||
@@ -1076,6 +1076,76 @@ fn hydrate_config_for_save(
|
||||
incoming
|
||||
}
|
||||
|
||||
// ── Session API handlers ─────────────────────────────────────────
|
||||
|
||||
/// GET /api/sessions — list gateway sessions
|
||||
pub async fn handle_api_sessions_list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let Some(ref backend) = state.session_backend else {
|
||||
return Json(serde_json::json!({
|
||||
"sessions": [],
|
||||
"message": "Session persistence is disabled"
|
||||
}))
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let all_metadata = backend.list_sessions_with_metadata();
|
||||
let gw_sessions: Vec<serde_json::Value> = all_metadata
|
||||
.into_iter()
|
||||
.filter_map(|meta| {
|
||||
let session_id = meta.key.strip_prefix("gw_")?;
|
||||
Some(serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"created_at": meta.created_at.to_rfc3339(),
|
||||
"last_activity": meta.last_activity.to_rfc3339(),
|
||||
"message_count": meta.message_count,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(serde_json::json!({ "sessions": gw_sessions })).into_response()
|
||||
}
|
||||
|
||||
/// DELETE /api/sessions/{id} — delete a gateway session
|
||||
pub async fn handle_api_session_delete(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let Some(ref backend) = state.session_backend else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Session persistence is disabled"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let session_key = format!("gw_{id}");
|
||||
match backend.delete_session(&session_key) {
|
||||
Ok(true) => Json(serde_json::json!({"deleted": true, "session_id": id})).into_response(),
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Session not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to delete session: {e}")})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Device management and pairing API handlers.
|
||||
|
||||
use super::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Json},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata about a paired device.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub device_type: Option<String>,
|
||||
pub paired_at: DateTime<Utc>,
|
||||
pub last_seen: DateTime<Utc>,
|
||||
pub ip_address: Option<String>,
|
||||
}
|
||||
|
||||
/// Registry of paired devices backed by SQLite.
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
cache: Mutex<HashMap<String, DeviceInfo>>,
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
pub fn new(workspace_dir: &Path) -> Self {
|
||||
let db_path = workspace_dir.join("devices.db");
|
||||
let conn = Connection::open(&db_path).expect("Failed to open device registry database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS devices (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
device_type TEXT,
|
||||
paired_at TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
ip_address TEXT
|
||||
)",
|
||||
)
|
||||
.expect("Failed to create devices table");
|
||||
|
||||
// Warm the in-memory cache from DB
|
||||
let mut cache = HashMap::new();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT token_hash, id, name, device_type, paired_at, last_seen, ip_address FROM devices")
|
||||
.expect("Failed to prepare device select");
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let token_hash: String = row.get(0)?;
|
||||
let id: String = row.get(1)?;
|
||||
let name: Option<String> = row.get(2)?;
|
||||
let device_type: Option<String> = row.get(3)?;
|
||||
let paired_at_str: String = row.get(4)?;
|
||||
let last_seen_str: String = row.get(5)?;
|
||||
let ip_address: Option<String> = row.get(6)?;
|
||||
let paired_at = DateTime::parse_from_rfc3339(&paired_at_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
let last_seen = DateTime::parse_from_rfc3339(&last_seen_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
Ok((
|
||||
token_hash,
|
||||
DeviceInfo {
|
||||
id,
|
||||
name,
|
||||
device_type,
|
||||
paired_at,
|
||||
last_seen,
|
||||
ip_address,
|
||||
},
|
||||
))
|
||||
})
|
||||
.expect("Failed to query devices");
|
||||
for (hash, info) in rows.flatten() {
|
||||
cache.insert(hash, info);
|
||||
}
|
||||
|
||||
Self {
|
||||
cache: Mutex::new(cache),
|
||||
db_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_db(&self) -> Connection {
|
||||
Connection::open(&self.db_path).expect("Failed to open device registry database")
|
||||
}
|
||||
|
||||
pub fn register(&self, token_hash: String, info: DeviceInfo) {
|
||||
let conn = self.open_db();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO devices (token_hash, id, name, device_type, paired_at, last_seen, ip_address) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
token_hash,
|
||||
info.id,
|
||||
info.name,
|
||||
info.device_type,
|
||||
info.paired_at.to_rfc3339(),
|
||||
info.last_seen.to_rfc3339(),
|
||||
info.ip_address,
|
||||
],
|
||||
)
|
||||
.expect("Failed to insert device");
|
||||
self.cache.lock().insert(token_hash, info);
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<DeviceInfo> {
|
||||
let conn = self.open_db();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT token_hash, id, name, device_type, paired_at, last_seen, ip_address FROM devices")
|
||||
.expect("Failed to prepare device select");
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let id: String = row.get(1)?;
|
||||
let name: Option<String> = row.get(2)?;
|
||||
let device_type: Option<String> = row.get(3)?;
|
||||
let paired_at_str: String = row.get(4)?;
|
||||
let last_seen_str: String = row.get(5)?;
|
||||
let ip_address: Option<String> = row.get(6)?;
|
||||
let paired_at = DateTime::parse_from_rfc3339(&paired_at_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
let last_seen = DateTime::parse_from_rfc3339(&last_seen_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
Ok(DeviceInfo {
|
||||
id,
|
||||
name,
|
||||
device_type,
|
||||
paired_at,
|
||||
last_seen,
|
||||
ip_address,
|
||||
})
|
||||
})
|
||||
.expect("Failed to query devices");
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
pub fn revoke(&self, device_id: &str) -> bool {
|
||||
let conn = self.open_db();
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM devices WHERE id = ?1",
|
||||
rusqlite::params![device_id],
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if deleted > 0 {
|
||||
let mut cache = self.cache.lock();
|
||||
let key = cache
|
||||
.iter()
|
||||
.find(|(_, v)| v.id == device_id)
|
||||
.map(|(k, _)| k.clone());
|
||||
if let Some(key) = key {
|
||||
cache.remove(&key);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_last_seen(&self, token_hash: &str) {
|
||||
let now = Utc::now();
|
||||
let conn = self.open_db();
|
||||
conn.execute(
|
||||
"UPDATE devices SET last_seen = ?1 WHERE token_hash = ?2",
|
||||
rusqlite::params![now.to_rfc3339(), token_hash],
|
||||
)
|
||||
.ok();
|
||||
if let Some(device) = self.cache.lock().get_mut(token_hash) {
|
||||
device.last_seen = now;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_count(&self) -> usize {
|
||||
self.cache.lock().len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Store for pending pairing requests.
|
||||
#[derive(Debug)]
|
||||
pub struct PairingStore {
|
||||
pending: Mutex<Vec<PendingPairing>>,
|
||||
max_pending: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PendingPairing {
|
||||
code: String,
|
||||
created_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
client_ip: Option<String>,
|
||||
attempts: u32,
|
||||
}
|
||||
|
||||
impl PairingStore {
|
||||
pub fn new(max_pending: usize) -> Self {
|
||||
Self {
|
||||
pending: Mutex::new(Vec::new()),
|
||||
max_pending,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending_count(&self) -> usize {
|
||||
let mut pending = self.pending.lock();
|
||||
pending.retain(|p| p.expires_at > Utc::now());
|
||||
pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
fn require_auth(state: &AppState, headers: &HeaderMap) -> Result<(), (StatusCode, &'static str)> {
|
||||
if state.pairing.require_pairing() {
|
||||
let token = extract_bearer(headers).unwrap_or("");
|
||||
if !state.pairing.is_authenticated(token) {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Unauthorized"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/pairing/initiate — initiate a new pairing session
|
||||
pub async fn initiate_pairing(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
match state.pairing.generate_new_pairing_code() {
|
||||
Some(code) => Json(serde_json::json!({
|
||||
"pairing_code": code,
|
||||
"message": "New pairing code generated"
|
||||
}))
|
||||
.into_response(),
|
||||
None => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Pairing is disabled or not available",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/pair — submit pairing code (for new device pairing)
|
||||
pub async fn submit_pairing_enhanced(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let code = body["code"].as_str().unwrap_or("");
|
||||
let device_name = body["device_name"].as_str().map(String::from);
|
||||
let device_type = body["device_type"].as_str().map(String::from);
|
||||
|
||||
let client_id = headers
|
||||
.get("X-Forwarded-For")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
match state.pairing.try_pair(code, &client_id).await {
|
||||
Ok(Some(token)) => {
|
||||
// Register the new device
|
||||
let token_hash = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(token.as_bytes());
|
||||
hex::encode(hash)
|
||||
};
|
||||
if let Some(ref registry) = state.device_registry {
|
||||
registry.register(
|
||||
token_hash,
|
||||
DeviceInfo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: device_name,
|
||||
device_type,
|
||||
paired_at: Utc::now(),
|
||||
last_seen: Utc::now(),
|
||||
ip_address: Some(client_id),
|
||||
},
|
||||
);
|
||||
}
|
||||
Json(serde_json::json!({
|
||||
"token": token,
|
||||
"message": "Pairing successful"
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (StatusCode::BAD_REQUEST, "Invalid or expired pairing code").into_response(),
|
||||
Err(lockout_secs) => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("Too many attempts. Locked out for {lockout_secs}s"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/devices — list paired devices
|
||||
pub async fn list_devices(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let devices = state
|
||||
.device_registry
|
||||
.as_ref()
|
||||
.map(|r| r.list())
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = devices.len();
|
||||
Json(serde_json::json!({
|
||||
"devices": devices,
|
||||
"count": count
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// DELETE /api/devices/{id} — revoke a paired device
|
||||
pub async fn revoke_device(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Path(device_id): axum::extract::Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let revoked = state
|
||||
.device_registry
|
||||
.as_ref()
|
||||
.map(|r| r.revoke(&device_id))
|
||||
.unwrap_or(false);
|
||||
|
||||
if revoked {
|
||||
Json(serde_json::json!({
|
||||
"message": "Device revoked",
|
||||
"device_id": device_id
|
||||
}))
|
||||
.into_response()
|
||||
} else {
|
||||
(StatusCode::NOT_FOUND, "Device not found").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/devices/{id}/token/rotate — rotate a device's token
|
||||
pub async fn rotate_token(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Path(device_id): axum::extract::Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
// Generate a new pairing code for re-pairing
|
||||
match state.pairing.generate_new_pairing_code() {
|
||||
Some(code) => Json(serde_json::json!({
|
||||
"device_id": device_id,
|
||||
"pairing_code": code,
|
||||
"message": "Use this code to re-pair the device"
|
||||
}))
|
||||
.into_response(),
|
||||
None => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Cannot generate new pairing code",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Plugin management API routes (requires `plugins-wasm` feature).
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
pub mod plugin_routes {
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Json},
|
||||
};
|
||||
|
||||
use super::super::AppState;
|
||||
|
||||
/// `GET /api/plugins` — list loaded plugins and their status.
|
||||
pub async fn list_plugins(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// Auth check
|
||||
if state.pairing.require_pairing() {
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if !state.pairing.is_authenticated(token) {
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let config = state.config.lock();
|
||||
let plugins_enabled = config.plugins.enabled;
|
||||
let plugins_dir = config.plugins.plugins_dir.clone();
|
||||
drop(config);
|
||||
|
||||
let plugins: Vec<serde_json::Value> = if plugins_enabled {
|
||||
let plugin_path = if plugins_dir.starts_with("~/") {
|
||||
directories::UserDirs::new()
|
||||
.map(|u| u.home_dir().join(&plugins_dir[2..]))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(&plugins_dir))
|
||||
} else {
|
||||
std::path::PathBuf::from(&plugins_dir)
|
||||
};
|
||||
|
||||
if plugin_path.exists() {
|
||||
match crate::plugins::host::PluginHost::new(
|
||||
plugin_path.parent().unwrap_or(&plugin_path),
|
||||
) {
|
||||
Ok(host) => host
|
||||
.list_plugins()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"name": p.name,
|
||||
"version": p.version,
|
||||
"description": p.description,
|
||||
"capabilities": p.capabilities,
|
||||
"loaded": p.loaded,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"plugins_enabled": plugins_enabled,
|
||||
"plugins_dir": plugins_dir,
|
||||
"plugins": plugins,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
+102
-1
@@ -8,13 +8,17 @@
|
||||
//! - Header sanitization (handled by axum/hyper)
|
||||
|
||||
pub mod api;
|
||||
pub mod api_pairing;
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
pub mod api_plugins;
|
||||
pub mod nodes;
|
||||
pub mod sse;
|
||||
pub mod static_files;
|
||||
pub mod ws;
|
||||
|
||||
use crate::channels::{
|
||||
Channel, LinqChannel, NextcloudTalkChannel, SendMessage, WatiChannel, WhatsAppChannel,
|
||||
session_backend::SessionBackend, session_sqlite::SqliteSessionBackend, Channel, LinqChannel,
|
||||
NextcloudTalkChannel, SendMessage, WatiChannel, WhatsAppChannel,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::cost::CostTracker;
|
||||
@@ -331,6 +335,12 @@ pub struct AppState {
|
||||
pub shutdown_tx: tokio::sync::watch::Sender<bool>,
|
||||
/// Registry of dynamically connected nodes
|
||||
pub node_registry: Arc<nodes::NodeRegistry>,
|
||||
/// Session backend for persisting gateway WS chat sessions
|
||||
pub session_backend: Option<Arc<dyn SessionBackend>>,
|
||||
/// Device registry for paired device management
|
||||
pub device_registry: Option<Arc<api_pairing::DeviceRegistry>>,
|
||||
/// Pending pairing request store
|
||||
pub pending_pairings: Option<Arc<api_pairing::PairingStore>>,
|
||||
}
|
||||
|
||||
/// Run the HTTP gateway using axum with proper HTTP/1.1 compliance.
|
||||
@@ -370,6 +380,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
reasoning_effort: config.runtime.reasoning_effort.clone(),
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
extra_headers: config.extra_headers.clone(),
|
||||
api_path: config.api_path.clone(),
|
||||
@@ -553,6 +564,29 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
})
|
||||
.map(Arc::from);
|
||||
|
||||
// ── Session persistence for WS chat ─────────────────────
|
||||
let session_backend: Option<Arc<dyn SessionBackend>> = if config.gateway.session_persistence {
|
||||
match SqliteSessionBackend::new(&config.workspace_dir) {
|
||||
Ok(b) => {
|
||||
tracing::info!("Gateway session persistence enabled (SQLite)");
|
||||
if config.gateway.session_ttl_hours > 0 {
|
||||
if let Ok(cleaned) = b.cleanup_stale(config.gateway.session_ttl_hours) {
|
||||
if cleaned > 0 {
|
||||
tracing::info!("Cleaned up {cleaned} stale gateway sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Arc::new(b))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Session persistence disabled: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Pairing guard ──────────────────────────────────────
|
||||
let pairing = Arc::new(PairingGuard::new(
|
||||
config.gateway.require_pairing,
|
||||
@@ -631,6 +665,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
println!(" Send: POST /pair with header X-Pairing-Code: {code}");
|
||||
} else if pairing.require_pairing() {
|
||||
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
|
||||
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
|
||||
} else {
|
||||
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
|
||||
}
|
||||
@@ -655,6 +690,22 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
// Node registry for dynamic node discovery
|
||||
let node_registry = Arc::new(nodes::NodeRegistry::new(config.nodes.max_nodes));
|
||||
|
||||
// Device registry and pairing store (only when pairing is required)
|
||||
let device_registry = if config.gateway.require_pairing {
|
||||
Some(Arc::new(api_pairing::DeviceRegistry::new(
|
||||
&config.workspace_dir,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pending_pairings = if config.gateway.require_pairing {
|
||||
Some(Arc::new(api_pairing::PairingStore::new(
|
||||
config.gateway.pairing_dashboard.max_pending_codes,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
config: config_state,
|
||||
provider,
|
||||
@@ -680,6 +731,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
event_tx,
|
||||
shutdown_tx,
|
||||
node_registry,
|
||||
session_backend,
|
||||
device_registry,
|
||||
pending_pairings,
|
||||
};
|
||||
|
||||
// Config PUT needs larger body limit (1MB)
|
||||
@@ -727,6 +781,26 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/api/cost", get(api::handle_api_cost))
|
||||
.route("/api/cli-tools", get(api::handle_api_cli_tools))
|
||||
.route("/api/health", get(api::handle_api_health))
|
||||
.route("/api/sessions", get(api::handle_api_sessions_list))
|
||||
.route("/api/sessions/{id}", delete(api::handle_api_session_delete))
|
||||
// ── Pairing + Device management API ──
|
||||
.route("/api/pairing/initiate", post(api_pairing::initiate_pairing))
|
||||
.route("/api/pair", post(api_pairing::submit_pairing_enhanced))
|
||||
.route("/api/devices", get(api_pairing::list_devices))
|
||||
.route("/api/devices/{id}", delete(api_pairing::revoke_device))
|
||||
.route(
|
||||
"/api/devices/{id}/token/rotate",
|
||||
post(api_pairing::rotate_token),
|
||||
);
|
||||
|
||||
// ── Plugin management API (requires plugins-wasm feature) ──
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
let app = app.route(
|
||||
"/api/plugins",
|
||||
get(api_plugins::plugin_routes::list_plugins),
|
||||
);
|
||||
|
||||
let app = app
|
||||
// ── SSE event stream ──
|
||||
.route("/api/events", get(sse::handle_sse_events))
|
||||
// ── WebSocket agent chat ──
|
||||
@@ -1829,6 +1903,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let response = handle_metrics(State(state)).await.into_response();
|
||||
@@ -1881,6 +1958,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let response = handle_metrics(State(state)).await.into_response();
|
||||
@@ -2257,6 +2337,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -2323,6 +2406,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let headers = HeaderMap::new();
|
||||
@@ -2401,6 +2487,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let response = handle_webhook(
|
||||
@@ -2451,6 +2540,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -2506,6 +2598,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -2566,6 +2661,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let response = Box::pin(handle_nextcloud_talk_webhook(
|
||||
@@ -2622,6 +2720,9 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
+177
-44
@@ -20,6 +20,28 @@ use axum::{
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
/// Optional connection parameters sent as the first WebSocket message.
|
||||
///
|
||||
/// If the first message after upgrade is `{"type":"connect",...}`, these
|
||||
/// parameters are extracted and an acknowledgement is sent back. Old clients
|
||||
/// that send `{"type":"message",...}` as the first frame still work — the
|
||||
/// message is processed normally (backward-compatible).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConnectParams {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
/// Client-chosen session ID for memory persistence
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
/// Device name for device registry tracking
|
||||
#[serde(default)]
|
||||
device_name: Option<String>,
|
||||
/// Client capabilities
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
/// The sub-protocol we support for the chat WebSocket.
|
||||
const WS_PROTOCOL: &str = "zeroclaw.v1";
|
||||
@@ -111,14 +133,21 @@ pub async fn handle_ws_chat(
|
||||
ws
|
||||
};
|
||||
|
||||
let session_id = params.session_id.clone();
|
||||
let session_id = params.session_id;
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, session_id))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Gateway session key prefix to avoid collisions with channel sessions.
|
||||
const GW_SESSION_PREFIX: &str = "gw_";
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState, session_id: Option<String>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Resolve session ID: use provided or generate a new UUID
|
||||
let session_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let session_key = format!("{GW_SESSION_PREFIX}{session_id}");
|
||||
|
||||
// Build a persistent Agent for this connection so history is maintained across turns.
|
||||
let config = state.config.lock().clone();
|
||||
let mut agent = match crate::agent::Agent::from_config(&config) {
|
||||
@@ -129,7 +158,90 @@ async fn handle_socket(socket: WebSocket, state: AppState, session_id: Option<St
|
||||
return;
|
||||
}
|
||||
};
|
||||
agent.set_memory_session_id(session_id.clone());
|
||||
agent.set_memory_session_id(Some(session_id.clone()));
|
||||
|
||||
// Hydrate agent from persisted session (if available)
|
||||
let mut resumed = false;
|
||||
let mut message_count: usize = 0;
|
||||
if let Some(ref backend) = state.session_backend {
|
||||
let messages = backend.load(&session_key);
|
||||
if !messages.is_empty() {
|
||||
message_count = messages.len();
|
||||
agent.seed_history(&messages);
|
||||
resumed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Send session_start message to client
|
||||
let session_start = serde_json::json!({
|
||||
"type": "session_start",
|
||||
"session_id": session_id,
|
||||
"resumed": resumed,
|
||||
"message_count": message_count,
|
||||
});
|
||||
let _ = sender
|
||||
.send(Message::Text(session_start.to_string().into()))
|
||||
.await;
|
||||
|
||||
// ── Optional connect handshake ──────────────────────────────────
|
||||
// The first message may be a `{"type":"connect",...}` frame carrying
|
||||
// connection parameters. If it is, we extract the params, send an
|
||||
// ack, and proceed to the normal message loop. If the first message
|
||||
// is a regular `{"type":"message",...}` frame, we fall through and
|
||||
// process it immediately (backward-compatible).
|
||||
let mut first_msg_fallback: Option<String> = None;
|
||||
|
||||
if let Some(first) = receiver.next().await {
|
||||
match first {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Ok(cp) = serde_json::from_str::<ConnectParams>(&text) {
|
||||
if cp.msg_type == "connect" {
|
||||
debug!(
|
||||
session_id = ?cp.session_id,
|
||||
device_name = ?cp.device_name,
|
||||
capabilities = ?cp.capabilities,
|
||||
"WebSocket connect params received"
|
||||
);
|
||||
// Override session_id if provided in connect params
|
||||
if let Some(sid) = &cp.session_id {
|
||||
agent.set_memory_session_id(Some(sid.clone()));
|
||||
}
|
||||
let ack = serde_json::json!({
|
||||
"type": "connected",
|
||||
"message": "Connection established"
|
||||
});
|
||||
let _ = sender.send(Message::Text(ack.to_string().into())).await;
|
||||
} else {
|
||||
// Not a connect message — fall through to normal processing
|
||||
first_msg_fallback = Some(text.to_string());
|
||||
}
|
||||
} else {
|
||||
// Not parseable as ConnectParams — fall through
|
||||
first_msg_fallback = Some(text.to_string());
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => return,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the first message if it was not a connect frame
|
||||
if let Some(ref text) = first_msg_fallback {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
if parsed["type"].as_str() == Some("message") {
|
||||
let content = parsed["content"].as_str().unwrap_or("").to_string();
|
||||
if !content.is_empty() {
|
||||
// Persist user message
|
||||
if let Some(ref backend) = state.session_backend {
|
||||
let user_msg = crate::providers::ChatMessage::user(&content);
|
||||
let _ = backend.append(&session_key, &user_msg);
|
||||
}
|
||||
process_chat_message(&state, &mut agent, &mut sender, &content, &session_key)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(msg) = receiver.next().await {
|
||||
let msg = match msg {
|
||||
@@ -158,53 +270,74 @@ async fn handle_socket(socket: WebSocket, state: AppState, session_id: Option<St
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process message with the LLM provider
|
||||
let provider_label = state
|
||||
.config
|
||||
.lock()
|
||||
.default_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
// Persist user message
|
||||
if let Some(ref backend) = state.session_backend {
|
||||
let user_msg = crate::providers::ChatMessage::user(&content);
|
||||
let _ = backend.append(&session_key, &user_msg);
|
||||
}
|
||||
|
||||
// Broadcast agent_start event
|
||||
let _ = state.event_tx.send(serde_json::json!({
|
||||
"type": "agent_start",
|
||||
"provider": provider_label,
|
||||
"model": state.model,
|
||||
}));
|
||||
process_chat_message(&state, &mut agent, &mut sender, &content, &session_key).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-turn chat via persistent Agent (history is maintained across turns)
|
||||
match agent.turn(&content).await {
|
||||
Ok(response) => {
|
||||
// Send the full response as a done message
|
||||
let done = serde_json::json!({
|
||||
"type": "done",
|
||||
"full_response": response,
|
||||
});
|
||||
let _ = sender.send(Message::Text(done.to_string().into())).await;
|
||||
/// Process a single chat message through the agent and send the response.
|
||||
async fn process_chat_message(
|
||||
state: &AppState,
|
||||
agent: &mut crate::agent::Agent,
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
content: &str,
|
||||
session_key: &str,
|
||||
) {
|
||||
let provider_label = state
|
||||
.config
|
||||
.lock()
|
||||
.default_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Broadcast agent_end event
|
||||
let _ = state.event_tx.send(serde_json::json!({
|
||||
"type": "agent_end",
|
||||
"provider": provider_label,
|
||||
"model": state.model,
|
||||
}));
|
||||
// Broadcast agent_start event
|
||||
let _ = state.event_tx.send(serde_json::json!({
|
||||
"type": "agent_start",
|
||||
"provider": provider_label,
|
||||
"model": state.model,
|
||||
}));
|
||||
|
||||
// Multi-turn chat via persistent Agent (history is maintained across turns)
|
||||
match agent.turn(content).await {
|
||||
Ok(response) => {
|
||||
// Persist assistant response
|
||||
if let Some(ref backend) = state.session_backend {
|
||||
let assistant_msg = crate::providers::ChatMessage::assistant(&response);
|
||||
let _ = backend.append(session_key, &assistant_msg);
|
||||
}
|
||||
Err(e) => {
|
||||
let sanitized = crate::providers::sanitize_api_error(&e.to_string());
|
||||
let err = serde_json::json!({
|
||||
"type": "error",
|
||||
"message": sanitized,
|
||||
});
|
||||
let _ = sender.send(Message::Text(err.to_string().into())).await;
|
||||
|
||||
// Broadcast error event
|
||||
let _ = state.event_tx.send(serde_json::json!({
|
||||
"type": "error",
|
||||
"component": "ws_chat",
|
||||
"message": sanitized,
|
||||
}));
|
||||
}
|
||||
let done = serde_json::json!({
|
||||
"type": "done",
|
||||
"full_response": response,
|
||||
});
|
||||
let _ = sender.send(Message::Text(done.to_string().into())).await;
|
||||
|
||||
// Broadcast agent_end event
|
||||
let _ = state.event_tx.send(serde_json::json!({
|
||||
"type": "agent_end",
|
||||
"provider": provider_label,
|
||||
"model": state.model,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
let sanitized = crate::providers::sanitize_api_error(&e.to_string());
|
||||
let err = serde_json::json!({
|
||||
"type": "error",
|
||||
"message": sanitized,
|
||||
});
|
||||
let _ = sender.send(Message::Text(err.to_string().into())).await;
|
||||
|
||||
// Broadcast error event
|
||||
let _ = state.event_tx.send(serde_json::json!({
|
||||
"type": "error",
|
||||
"component": "ws_chat",
|
||||
"message": sanitized,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
//! Internationalization support for tool descriptions.
|
||||
//!
|
||||
//! Loads tool descriptions from TOML locale files in `tool_descriptions/`.
|
||||
//! Falls back to English when a locale file or specific key is missing,
|
||||
//! and ultimately falls back to the hardcoded `tool.description()` value
|
||||
//! if no file-based description exists.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::debug;
|
||||
|
||||
/// Container for locale-specific tool descriptions loaded from TOML files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDescriptions {
|
||||
/// Descriptions from the requested locale (may be empty if file missing).
|
||||
locale_descriptions: HashMap<String, String>,
|
||||
/// English fallback descriptions (always loaded when locale != "en").
|
||||
english_fallback: HashMap<String, String>,
|
||||
/// The resolved locale tag (e.g. "en", "zh-CN").
|
||||
locale: String,
|
||||
}
|
||||
|
||||
/// TOML structure: `[tools]` table mapping tool name -> description string.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct DescriptionFile {
|
||||
#[serde(default)]
|
||||
tools: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ToolDescriptions {
|
||||
/// Load descriptions for the given locale.
|
||||
///
|
||||
/// `search_dirs` lists directories to probe for `tool_descriptions/<locale>.toml`.
|
||||
/// The first directory containing a matching file wins.
|
||||
///
|
||||
/// Resolution:
|
||||
/// 1. Look up tool name in the locale file.
|
||||
/// 2. If missing (or locale file absent), look up in `en.toml`.
|
||||
/// 3. If still missing, callers fall back to `tool.description()`.
|
||||
pub fn load(locale: &str, search_dirs: &[PathBuf]) -> Self {
|
||||
let locale_descriptions = load_locale_file(locale, search_dirs);
|
||||
|
||||
let english_fallback = if locale == "en" {
|
||||
HashMap::new()
|
||||
} else {
|
||||
load_locale_file("en", search_dirs)
|
||||
};
|
||||
|
||||
debug!(
|
||||
locale = locale,
|
||||
locale_keys = locale_descriptions.len(),
|
||||
english_keys = english_fallback.len(),
|
||||
"tool descriptions loaded"
|
||||
);
|
||||
|
||||
Self {
|
||||
locale_descriptions,
|
||||
english_fallback,
|
||||
locale: locale.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the description for a tool by name.
|
||||
///
|
||||
/// Returns `Some(description)` if found in the locale file or English fallback.
|
||||
/// Returns `None` if neither file contains the key (caller should use hardcoded).
|
||||
pub fn get(&self, tool_name: &str) -> Option<&str> {
|
||||
self.locale_descriptions
|
||||
.get(tool_name)
|
||||
.or_else(|| self.english_fallback.get(tool_name))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// The resolved locale tag.
|
||||
pub fn locale(&self) -> &str {
|
||||
&self.locale
|
||||
}
|
||||
|
||||
/// Create an empty instance that always returns `None` (hardcoded fallback).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
locale_descriptions: HashMap::new(),
|
||||
english_fallback: HashMap::new(),
|
||||
locale: "en".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the user's preferred locale from environment variables.
|
||||
///
|
||||
/// Checks `ZEROCLAW_LOCALE`, then `LANG`, then `LC_ALL`.
|
||||
/// Returns "en" if none are set or parseable.
|
||||
pub fn detect_locale() -> String {
|
||||
if let Ok(val) = std::env::var("ZEROCLAW_LOCALE") {
|
||||
let val = val.trim().to_string();
|
||||
if !val.is_empty() {
|
||||
return normalize_locale(&val);
|
||||
}
|
||||
}
|
||||
for var in &["LANG", "LC_ALL"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
let locale = normalize_locale(&val);
|
||||
if locale != "C" && locale != "POSIX" && !locale.is_empty() {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
/// Normalize a raw locale string (e.g. "zh_CN.UTF-8") to a tag we use
|
||||
/// for file lookup (e.g. "zh-CN").
|
||||
fn normalize_locale(raw: &str) -> String {
|
||||
// Strip encoding suffix (.UTF-8, .utf8, etc.)
|
||||
let base = raw.split('.').next().unwrap_or(raw);
|
||||
// Replace underscores with hyphens for BCP-47-ish consistency
|
||||
base.replace('_', "-")
|
||||
}
|
||||
|
||||
/// Build the default set of search directories for locale files.
|
||||
///
|
||||
/// 1. The workspace directory itself (for project-local overrides).
|
||||
/// 2. The binary's parent directory (for installed distributions).
|
||||
/// 3. The compile-time `CARGO_MANIFEST_DIR` as a final fallback during dev.
|
||||
pub fn default_search_dirs(workspace_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![workspace_dir.to_path_buf()];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
dirs.push(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// During development, also check the project root (where Cargo.toml lives).
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if !dirs.contains(&manifest_dir) {
|
||||
dirs.push(manifest_dir);
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Try to load and parse a locale TOML file from the first matching search dir.
|
||||
fn load_locale_file(locale: &str, search_dirs: &[PathBuf]) -> HashMap<String, String> {
|
||||
let filename = format!("tool_descriptions/{locale}.toml");
|
||||
|
||||
for dir in search_dirs {
|
||||
let path = dir.join(&filename);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => match toml::from_str::<DescriptionFile>(&contents) {
|
||||
Ok(parsed) => {
|
||||
debug!(path = %path.display(), keys = parsed.tools.len(), "loaded locale file");
|
||||
return parsed.tools;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(path = %path.display(), error = %e, "failed to parse locale file");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
// File not found in this directory, try next.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
locale = locale,
|
||||
"no locale file found in any search directory"
|
||||
);
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Helper: create a temp dir with a `tool_descriptions/<locale>.toml` file.
|
||||
fn write_locale_file(dir: &Path, locale: &str, content: &str) {
|
||||
let td = dir.join("tool_descriptions");
|
||||
fs::create_dir_all(&td).unwrap();
|
||||
fs::write(td.join(format!("{locale}.toml")), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_english_descriptions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
file_read = "Read file contents"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
|
||||
assert_eq!(descs.get("file_read"), Some("Read file contents"));
|
||||
assert_eq!(descs.get("nonexistent"), None);
|
||||
assert_eq!(descs.locale(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_english_when_locale_key_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
file_read = "Read file contents"
|
||||
"#,
|
||||
);
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"zh-CN",
|
||||
r#"[tools]
|
||||
shell = "在工作区目录中执行 shell 命令"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("zh-CN", &[tmp.path().to_path_buf()]);
|
||||
// Translated key returns Chinese.
|
||||
assert_eq!(descs.get("shell"), Some("在工作区目录中执行 shell 命令"));
|
||||
// Missing key falls back to English.
|
||||
assert_eq!(descs.get("file_read"), Some("Read file contents"));
|
||||
assert_eq!(descs.locale(), "zh-CN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_locale_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
"#,
|
||||
);
|
||||
// Request a locale that has no file.
|
||||
let descs = ToolDescriptions::load("fr", &[tmp.path().to_path_buf()]);
|
||||
// Falls back to English.
|
||||
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
|
||||
assert_eq!(descs.locale(), "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_no_files_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(descs.get("shell"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_always_returns_none() {
|
||||
let descs = ToolDescriptions::empty();
|
||||
assert_eq!(descs.get("shell"), None);
|
||||
assert_eq!(descs.locale(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_locale_from_env() {
|
||||
// Save and restore env.
|
||||
let saved = std::env::var("ZEROCLAW_LOCALE").ok();
|
||||
let saved_lang = std::env::var("LANG").ok();
|
||||
|
||||
std::env::set_var("ZEROCLAW_LOCALE", "ja-JP");
|
||||
assert_eq!(detect_locale(), "ja-JP");
|
||||
|
||||
std::env::remove_var("ZEROCLAW_LOCALE");
|
||||
std::env::set_var("LANG", "zh_CN.UTF-8");
|
||||
assert_eq!(detect_locale(), "zh-CN");
|
||||
|
||||
// Restore.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("ZEROCLAW_LOCALE", v),
|
||||
None => std::env::remove_var("ZEROCLAW_LOCALE"),
|
||||
}
|
||||
match saved_lang {
|
||||
Some(v) => std::env::set_var("LANG", v),
|
||||
None => std::env::remove_var("LANG"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_locale_strips_encoding() {
|
||||
assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
|
||||
assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
|
||||
assert_eq!(normalize_locale("fr"), "fr");
|
||||
assert_eq!(normalize_locale("pt_BR"), "pt-BR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_locale_overrides_env() {
|
||||
// This tests the precedence logic: if config provides a locale,
|
||||
// it should be used instead of detect_locale().
|
||||
// The actual override happens at the call site in prompt.rs / loop_.rs,
|
||||
// so here we just verify ToolDescriptions works with an explicit locale.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"de",
|
||||
r#"[tools]
|
||||
shell = "Einen Shell-Befehl im Arbeitsverzeichnis ausführen"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("de", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(
|
||||
descs.get("shell"),
|
||||
Some("Einen Shell-Befehl im Arbeitsverzeichnis ausführen")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub mod agent;
|
||||
pub(crate) mod approval;
|
||||
pub(crate) mod auth;
|
||||
pub mod channels;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub(crate) mod cost;
|
||||
pub(crate) mod cron;
|
||||
@@ -53,6 +54,7 @@ pub(crate) mod hardware;
|
||||
pub(crate) mod health;
|
||||
pub(crate) mod heartbeat;
|
||||
pub mod hooks;
|
||||
pub mod i18n;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod integrations;
|
||||
pub mod memory;
|
||||
@@ -72,6 +74,9 @@ pub mod tools;
|
||||
pub(crate) mod tunnel;
|
||||
pub(crate) mod util;
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
pub mod plugins;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
/// Gateway management subcommands
|
||||
|
||||
+198
-25
@@ -75,6 +75,7 @@ mod agent;
|
||||
mod approval;
|
||||
mod auth;
|
||||
mod channels;
|
||||
mod commands;
|
||||
mod rag {
|
||||
pub use zeroclaw::rag::*;
|
||||
}
|
||||
@@ -88,6 +89,7 @@ mod hardware;
|
||||
mod health;
|
||||
mod heartbeat;
|
||||
mod hooks;
|
||||
mod i18n;
|
||||
mod identity;
|
||||
mod integrations;
|
||||
mod memory;
|
||||
@@ -96,6 +98,8 @@ mod multimodal;
|
||||
mod observability;
|
||||
mod onboard;
|
||||
mod peripherals;
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
mod plugins;
|
||||
mod providers;
|
||||
mod runtime;
|
||||
mod security;
|
||||
@@ -282,7 +286,11 @@ Examples:
|
||||
},
|
||||
|
||||
/// Show system status (full details)
|
||||
Status,
|
||||
Status {
|
||||
/// Output format: "exit-code" exits 0 if healthy, 1 otherwise (for Docker HEALTHCHECK)
|
||||
#[arg(long)]
|
||||
format: Option<String>,
|
||||
},
|
||||
|
||||
/// Engage, inspect, and resume emergency-stop states.
|
||||
///
|
||||
@@ -462,6 +470,52 @@ Examples:
|
||||
config_command: ConfigCommands,
|
||||
},
|
||||
|
||||
/// Check for and apply updates
|
||||
#[command(long_about = "\
|
||||
Check for and apply ZeroClaw updates.
|
||||
|
||||
By default, downloads and installs the latest release with a \
|
||||
6-phase pipeline: preflight, download, backup, validate, swap, \
|
||||
and smoke test. Automatic rollback on failure.
|
||||
|
||||
Use --check to only check for updates without installing.
|
||||
Use --force to skip the confirmation prompt.
|
||||
Use --version to target a specific release instead of latest.
|
||||
|
||||
Examples:
|
||||
zeroclaw update # download and install latest
|
||||
zeroclaw update --check # check only, don't install
|
||||
zeroclaw update --force # install without confirmation
|
||||
zeroclaw update --version 0.6.0 # install specific version")]
|
||||
Update {
|
||||
/// Only check for updates, don't install
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Target version (default: latest)
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
},
|
||||
|
||||
/// Run diagnostic self-tests
|
||||
#[command(long_about = "\
|
||||
Run diagnostic self-tests to verify the ZeroClaw installation.
|
||||
|
||||
By default, runs the full test suite including network checks \
|
||||
(gateway health, memory round-trip). Use --quick to skip network \
|
||||
checks for faster offline validation.
|
||||
|
||||
Examples:
|
||||
zeroclaw self-test # full suite
|
||||
zeroclaw self-test --quick # quick checks only (no network)")]
|
||||
SelfTest {
|
||||
/// Run quick checks only (no network)
|
||||
#[arg(long)]
|
||||
quick: bool,
|
||||
},
|
||||
|
||||
/// Generate shell completion script to stdout
|
||||
#[command(long_about = "\
|
||||
Generate shell completion scripts for `zeroclaw`.
|
||||
@@ -477,6 +531,35 @@ Examples:
|
||||
#[arg(value_enum)]
|
||||
shell: CompletionShell,
|
||||
},
|
||||
|
||||
/// Manage WASM plugins
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
Plugin {
|
||||
#[command(subcommand)]
|
||||
plugin_command: PluginCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum PluginCommands {
|
||||
/// List installed plugins
|
||||
List,
|
||||
/// Install a plugin from a directory or URL
|
||||
Install {
|
||||
/// Path to plugin directory or manifest
|
||||
source: String,
|
||||
},
|
||||
/// Remove an installed plugin
|
||||
Remove {
|
||||
/// Plugin name
|
||||
name: String,
|
||||
},
|
||||
/// Show information about a plugin
|
||||
Info {
|
||||
/// Plugin name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -816,30 +899,12 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
}?;
|
||||
|
||||
// Display pairing code — user enters it in the dashboard to pair securely.
|
||||
// The code is one-time use and brute-force protected (5 attempts → lockout).
|
||||
// No auth material is placed in URLs to prevent leakage via browser history,
|
||||
// Referer headers, clipboard, or proxy logs.
|
||||
if config.gateway.require_pairing {
|
||||
let pairing = security::PairingGuard::new(true, &config.gateway.paired_tokens);
|
||||
if let Some(code) = pairing.pairing_code() {
|
||||
println!();
|
||||
println!(" \x1b[1;34m🦀 Gateway Pairing Code\x1b[0m");
|
||||
println!();
|
||||
println!(" \x1b[1;34m┌──────────────┐\x1b[0m");
|
||||
println!(" \x1b[1;34m│\x1b[0m \x1b[1m{code}\x1b[0m \x1b[1;34m│\x1b[0m");
|
||||
println!(" \x1b[1;34m└──────────────┘\x1b[0m");
|
||||
println!();
|
||||
println!(" Enter this code in the dashboard to pair your device.");
|
||||
println!(" The code is single-use and expires after pairing.");
|
||||
println!();
|
||||
println!(
|
||||
" \x1b[2mDashboard: http://127.0.0.1:{}\x1b[0m",
|
||||
config.gateway.port
|
||||
);
|
||||
println!(" \x1b[2mDocs: https://www.zeroclawlabs.ai/docs\x1b[0m");
|
||||
println!();
|
||||
}
|
||||
println!();
|
||||
println!(" Pairing is enabled. A one-time pairing code will be");
|
||||
println!(" displayed when the gateway starts.");
|
||||
println!(" Dashboard: http://127.0.0.1:{}", config.gateway.port);
|
||||
println!();
|
||||
}
|
||||
|
||||
// Auto-start channels if user said yes during wizard
|
||||
@@ -1002,7 +1067,30 @@ async fn main() -> Result<()> {
|
||||
Box::pin(daemon::run(config, host, port)).await
|
||||
}
|
||||
|
||||
Commands::Status => {
|
||||
Commands::Status { format } => {
|
||||
if format.as_deref() == Some("exit-code") {
|
||||
// Lightweight health probe for Docker HEALTHCHECK
|
||||
let port = config.gateway.port;
|
||||
let host = if config.gateway.host == "[::]" || config.gateway.host == "0.0.0.0" {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
&config.gateway.host
|
||||
};
|
||||
let url = format!("http://{}:{}/health", host, port);
|
||||
match reqwest::Client::new()
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("🦀 ZeroClaw Status");
|
||||
println!();
|
||||
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -1224,6 +1312,41 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
}
|
||||
|
||||
Commands::Update {
|
||||
check,
|
||||
force: _force,
|
||||
version,
|
||||
} => {
|
||||
if check {
|
||||
let info = commands::update::check(version.as_deref()).await?;
|
||||
if info.is_newer {
|
||||
println!(
|
||||
"Update available: v{} -> v{}",
|
||||
info.current_version, info.latest_version
|
||||
);
|
||||
} else {
|
||||
println!("Already up to date (v{}).", info.current_version);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
commands::update::run(version.as_deref()).await
|
||||
}
|
||||
}
|
||||
|
||||
Commands::SelfTest { quick } => {
|
||||
let results = if quick {
|
||||
commands::self_test::run_quick(&config).await?
|
||||
} else {
|
||||
commands::self_test::run_full(&config).await?
|
||||
};
|
||||
commands::self_test::print_results(&results);
|
||||
let failed = results.iter().filter(|r| !r.passed).count();
|
||||
if failed > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Commands::Config { config_command } => match config_command {
|
||||
ConfigCommands::Schema => {
|
||||
let schema = schemars::schema_for!(config::Config);
|
||||
@@ -1234,6 +1357,56 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
Commands::Plugin { plugin_command } => match plugin_command {
|
||||
PluginCommands::List => {
|
||||
let host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
let plugins = host.list_plugins();
|
||||
if plugins.is_empty() {
|
||||
println!("No plugins installed.");
|
||||
} else {
|
||||
println!("Installed plugins:");
|
||||
for p in &plugins {
|
||||
println!(
|
||||
" {} v{} — {}",
|
||||
p.name,
|
||||
p.version,
|
||||
p.description.as_deref().unwrap_or("(no description)")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
PluginCommands::Install { source } => {
|
||||
let mut host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
host.install(&source)?;
|
||||
println!("Plugin installed from {source}");
|
||||
Ok(())
|
||||
}
|
||||
PluginCommands::Remove { name } => {
|
||||
let mut host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
host.remove(&name)?;
|
||||
println!("Plugin '{name}' removed.");
|
||||
Ok(())
|
||||
}
|
||||
PluginCommands::Info { name } => {
|
||||
let host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
match host.get_plugin(&name) {
|
||||
Some(info) => {
|
||||
println!("Plugin: {} v{}", info.name, info.version);
|
||||
if let Some(desc) = &info.description {
|
||||
println!("Description: {desc}");
|
||||
}
|
||||
println!("Capabilities: {:?}", info.capabilities);
|
||||
println!("Permissions: {:?}", info.permissions);
|
||||
println!("WASM: {}", info.wasm_path.display());
|
||||
}
|
||||
None => println!("Plugin '{name}' not found."),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
node_transport: crate::config::NodeTransportConfig::default(),
|
||||
knowledge: crate::config::KnowledgeConfig::default(),
|
||||
linkedin: crate::config::LinkedInConfig::default(),
|
||||
plugins: crate::config::PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
println!(
|
||||
@@ -565,6 +567,8 @@ async fn run_quick_setup_with_home(
|
||||
node_transport: crate::config::NodeTransportConfig::default(),
|
||||
knowledge: crate::config::KnowledgeConfig::default(),
|
||||
linkedin: crate::config::LinkedInConfig::default(),
|
||||
plugins: crate::config::PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
config.save().await?;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Plugin error types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PluginError {
|
||||
#[error("plugin not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("invalid manifest: {0}")]
|
||||
InvalidManifest(String),
|
||||
|
||||
#[error("failed to load WASM module: {0}")]
|
||||
LoadFailed(String),
|
||||
|
||||
#[error("plugin execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
|
||||
#[error("permission denied: plugin '{plugin}' requires '{permission}'")]
|
||||
PermissionDenied { plugin: String, permission: String },
|
||||
|
||||
#[error("plugin '{0}' is already loaded")]
|
||||
AlreadyLoaded(String),
|
||||
|
||||
#[error("plugin capability not supported: {0}")]
|
||||
UnsupportedCapability(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("TOML parse error: {0}")]
|
||||
TomlParse(#[from] toml::de::Error),
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Plugin host: discovery, loading, lifecycle management.
|
||||
|
||||
use super::error::PluginError;
|
||||
use super::{PluginCapability, PluginInfo, PluginManifest};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Manages the lifecycle of WASM plugins.
|
||||
pub struct PluginHost {
|
||||
plugins_dir: PathBuf,
|
||||
loaded: HashMap<String, LoadedPlugin>,
|
||||
}
|
||||
|
||||
struct LoadedPlugin {
|
||||
manifest: PluginManifest,
|
||||
wasm_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Create a new plugin host with the given plugins directory.
|
||||
pub fn new(workspace_dir: &Path) -> Result<Self, PluginError> {
|
||||
let plugins_dir = workspace_dir.join("plugins");
|
||||
if !plugins_dir.exists() {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
}
|
||||
|
||||
let mut host = Self {
|
||||
plugins_dir,
|
||||
loaded: HashMap::new(),
|
||||
};
|
||||
|
||||
host.discover()?;
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Discover plugins in the plugins directory.
|
||||
fn discover(&mut self) -> Result<(), PluginError> {
|
||||
if !self.plugins_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&self.plugins_dir)?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let manifest_path = path.join("manifest.toml");
|
||||
if manifest_path.exists() {
|
||||
if let Ok(manifest) = self.load_manifest(&manifest_path) {
|
||||
let wasm_path = path.join(&manifest.wasm_path);
|
||||
self.loaded.insert(
|
||||
manifest.name.clone(),
|
||||
LoadedPlugin {
|
||||
manifest,
|
||||
wasm_path,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_manifest(&self, path: &Path) -> Result<PluginManifest, PluginError> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let manifest: PluginManifest = toml::from_str(&content)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// List all discovered plugins.
|
||||
pub fn list_plugins(&self) -> Vec<PluginInfo> {
|
||||
self.loaded
|
||||
.values()
|
||||
.map(|p| PluginInfo {
|
||||
name: p.manifest.name.clone(),
|
||||
version: p.manifest.version.clone(),
|
||||
description: p.manifest.description.clone(),
|
||||
capabilities: p.manifest.capabilities.clone(),
|
||||
permissions: p.manifest.permissions.clone(),
|
||||
wasm_path: p.wasm_path.clone(),
|
||||
loaded: p.wasm_path.exists(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get info about a specific plugin.
|
||||
pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
|
||||
self.loaded.get(name).map(|p| PluginInfo {
|
||||
name: p.manifest.name.clone(),
|
||||
version: p.manifest.version.clone(),
|
||||
description: p.manifest.description.clone(),
|
||||
capabilities: p.manifest.capabilities.clone(),
|
||||
permissions: p.manifest.permissions.clone(),
|
||||
wasm_path: p.wasm_path.clone(),
|
||||
loaded: p.wasm_path.exists(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a plugin from a directory path.
|
||||
pub fn install(&mut self, source: &str) -> Result<(), PluginError> {
|
||||
let source_path = PathBuf::from(source);
|
||||
let manifest_path = if source_path.is_dir() {
|
||||
source_path.join("manifest.toml")
|
||||
} else {
|
||||
source_path.clone()
|
||||
};
|
||||
|
||||
if !manifest_path.exists() {
|
||||
return Err(PluginError::NotFound(format!(
|
||||
"manifest.toml not found at {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let manifest = self.load_manifest(&manifest_path)?;
|
||||
let source_dir = manifest_path
|
||||
.parent()
|
||||
.ok_or_else(|| PluginError::InvalidManifest("no parent directory".into()))?;
|
||||
|
||||
let wasm_source = source_dir.join(&manifest.wasm_path);
|
||||
if !wasm_source.exists() {
|
||||
return Err(PluginError::NotFound(format!(
|
||||
"WASM file not found: {}",
|
||||
wasm_source.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if self.loaded.contains_key(&manifest.name) {
|
||||
return Err(PluginError::AlreadyLoaded(manifest.name));
|
||||
}
|
||||
|
||||
// Copy plugin to plugins directory
|
||||
let dest_dir = self.plugins_dir.join(&manifest.name);
|
||||
std::fs::create_dir_all(&dest_dir)?;
|
||||
|
||||
// Copy manifest
|
||||
std::fs::copy(&manifest_path, dest_dir.join("manifest.toml"))?;
|
||||
|
||||
// Copy WASM file
|
||||
let wasm_dest = dest_dir.join(&manifest.wasm_path);
|
||||
if let Some(parent) = wasm_dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&wasm_source, &wasm_dest)?;
|
||||
|
||||
self.loaded.insert(
|
||||
manifest.name.clone(),
|
||||
LoadedPlugin {
|
||||
manifest,
|
||||
wasm_path: wasm_dest,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a plugin by name.
|
||||
pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
if self.loaded.remove(name).is_none() {
|
||||
return Err(PluginError::NotFound(name.to_string()));
|
||||
}
|
||||
|
||||
let plugin_dir = self.plugins_dir.join(name);
|
||||
if plugin_dir.exists() {
|
||||
std::fs::remove_dir_all(plugin_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get tool-capable plugins.
|
||||
pub fn tool_plugins(&self) -> Vec<&PluginManifest> {
|
||||
self.loaded
|
||||
.values()
|
||||
.filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
|
||||
.map(|p| &p.manifest)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get channel-capable plugins.
|
||||
pub fn channel_plugins(&self) -> Vec<&PluginManifest> {
|
||||
self.loaded
|
||||
.values()
|
||||
.filter(|p| p.manifest.capabilities.contains(&PluginCapability::Channel))
|
||||
.map(|p| &p.manifest)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the plugins directory path.
|
||||
pub fn plugins_dir(&self) -> &Path {
|
||||
&self.plugins_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_empty_plugin_dir() {
|
||||
let dir = tempdir().unwrap();
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
assert!(host.list_plugins().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_with_manifest() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugin_dir = dir.path().join("plugins").join("test-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "test-plugin"
|
||||
version = "0.1.0"
|
||||
description = "A test plugin"
|
||||
wasm_path = "plugin.wasm"
|
||||
capabilities = ["tool"]
|
||||
permissions = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
let plugins = host.list_plugins();
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "test-plugin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_plugins_filter() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugins_base = dir.path().join("plugins");
|
||||
|
||||
// Tool plugin
|
||||
let tool_dir = plugins_base.join("my-tool");
|
||||
std::fs::create_dir_all(&tool_dir).unwrap();
|
||||
std::fs::write(
|
||||
tool_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "my-tool"
|
||||
version = "0.1.0"
|
||||
wasm_path = "tool.wasm"
|
||||
capabilities = ["tool"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Channel plugin
|
||||
let chan_dir = plugins_base.join("my-channel");
|
||||
std::fs::create_dir_all(&chan_dir).unwrap();
|
||||
std::fs::write(
|
||||
chan_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "my-channel"
|
||||
version = "0.1.0"
|
||||
wasm_path = "channel.wasm"
|
||||
capabilities = ["channel"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
assert_eq!(host.list_plugins().len(), 2);
|
||||
assert_eq!(host.tool_plugins().len(), 1);
|
||||
assert_eq!(host.channel_plugins().len(), 1);
|
||||
assert_eq!(host.tool_plugins()[0].name, "my-tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_plugin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugin_dir = dir.path().join("plugins").join("lookup-test");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "lookup-test"
|
||||
version = "1.0.0"
|
||||
description = "Lookup test"
|
||||
wasm_path = "plugin.wasm"
|
||||
capabilities = ["tool"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
assert!(host.get_plugin("lookup-test").is_some());
|
||||
assert!(host.get_plugin("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_plugin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugin_dir = dir.path().join("plugins").join("removable");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "removable"
|
||||
version = "0.1.0"
|
||||
wasm_path = "plugin.wasm"
|
||||
capabilities = ["tool"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut host = PluginHost::new(dir.path()).unwrap();
|
||||
assert_eq!(host.list_plugins().len(), 1);
|
||||
|
||||
host.remove("removable").unwrap();
|
||||
assert!(host.list_plugins().is_empty());
|
||||
assert!(!plugin_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_nonexistent_returns_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut host = PluginHost::new(dir.path()).unwrap();
|
||||
assert!(host.remove("ghost").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! WASM plugin system for ZeroClaw.
|
||||
//!
|
||||
//! Plugins are WebAssembly modules loaded via Extism that can extend
|
||||
//! ZeroClaw with custom tools and channels. Enable with `--features plugins-wasm`.
|
||||
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod wasm_channel;
|
||||
pub mod wasm_tool;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A plugin's declared manifest (loaded from manifest.toml alongside the .wasm).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
/// Plugin name (unique identifier)
|
||||
pub name: String,
|
||||
/// Plugin version
|
||||
pub version: String,
|
||||
/// Human-readable description
|
||||
pub description: Option<String>,
|
||||
/// Author name or organization
|
||||
pub author: Option<String>,
|
||||
/// Path to the .wasm file (relative to manifest)
|
||||
pub wasm_path: String,
|
||||
/// Capabilities this plugin provides
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// Permissions this plugin requests
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
}
|
||||
|
||||
/// What a plugin can do.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginCapability {
|
||||
/// Provides one or more tools
|
||||
Tool,
|
||||
/// Provides a channel implementation
|
||||
Channel,
|
||||
/// Provides a memory backend
|
||||
Memory,
|
||||
/// Provides an observer/metrics backend
|
||||
Observer,
|
||||
}
|
||||
|
||||
/// Permissions a plugin may request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginPermission {
|
||||
/// Can make HTTP requests
|
||||
HttpClient,
|
||||
/// Can read from the filesystem (within sandbox)
|
||||
FileRead,
|
||||
/// Can write to the filesystem (within sandbox)
|
||||
FileWrite,
|
||||
/// Can access environment variables
|
||||
EnvRead,
|
||||
/// Can read agent memory
|
||||
MemoryRead,
|
||||
/// Can write agent memory
|
||||
MemoryWrite,
|
||||
}
|
||||
|
||||
/// Information about a loaded plugin.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PluginInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
pub wasm_path: PathBuf,
|
||||
pub loaded: bool,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Bridge between WASM plugins and the Channel trait.
|
||||
|
||||
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// A channel backed by a WASM plugin.
|
||||
pub struct WasmChannel {
|
||||
name: String,
|
||||
plugin_name: String,
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
pub fn new(name: String, plugin_name: String) -> Self {
|
||||
Self { name, plugin_name }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for WasmChannel {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
// TODO: Wire to WASM plugin send function
|
||||
tracing::warn!(
|
||||
"WasmChannel '{}' (plugin: {}) send not yet connected: {}",
|
||||
self.name,
|
||||
self.plugin_name,
|
||||
message.content
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
// TODO: Wire to WASM plugin receive/listen function
|
||||
tracing::warn!(
|
||||
"WasmChannel '{}' (plugin: {}) listen not yet connected",
|
||||
self.name,
|
||||
self.plugin_name,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Bridge between WASM plugins and the Tool trait.
|
||||
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A tool backed by a WASM plugin function.
|
||||
pub struct WasmTool {
|
||||
name: String,
|
||||
description: String,
|
||||
plugin_name: String,
|
||||
function_name: String,
|
||||
parameters_schema: Value,
|
||||
}
|
||||
|
||||
impl WasmTool {
|
||||
pub fn new(
|
||||
name: String,
|
||||
description: String,
|
||||
plugin_name: String,
|
||||
function_name: String,
|
||||
parameters_schema: Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
plugin_name,
|
||||
function_name,
|
||||
parameters_schema,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WasmTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
self.parameters_schema.clone()
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
// TODO: Call into Extism plugin runtime
|
||||
// For now, return a placeholder indicating the plugin system is available
|
||||
// but not yet wired to actual WASM execution.
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!(
|
||||
"[plugin:{}/{}] WASM execution not yet connected. Args: {}",
|
||||
self.plugin_name,
|
||||
self.function_name,
|
||||
serde_json::to_string(&args).unwrap_or_default()
|
||||
),
|
||||
error: Some("WASM execution bridge not yet implemented".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,6 @@
|
||||
//!
|
||||
//! # Limitations
|
||||
//!
|
||||
//! - **Conversation history**: Only the system prompt (if present) and the last
|
||||
//! user message are forwarded. Full multi-turn history is not preserved because
|
||||
//! the CLI accepts a single prompt per invocation.
|
||||
//! - **System prompt**: The system prompt is prepended to the user message with a
|
||||
//! blank-line separator, as the CLI does not provide a dedicated system-prompt flag.
|
||||
//! - **Temperature**: The CLI does not expose a temperature parameter.
|
||||
@@ -34,7 +31,7 @@
|
||||
//!
|
||||
//! - `CLAUDE_CODE_PATH` — override the path to the `claude` binary (default: `"claude"`)
|
||||
|
||||
use crate::providers::traits::{ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use crate::providers::traits::{ChatMessage, ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -212,6 +209,54 @@ impl Provider for ClaudeCodeProvider {
|
||||
self.invoke_cli(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Self::validate_temperature(temperature)?;
|
||||
|
||||
// Separate system prompt from conversation messages.
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.as_str());
|
||||
|
||||
// Build conversation turns (skip system messages).
|
||||
let turns: Vec<&ChatMessage> = messages.iter().filter(|m| m.role != "system").collect();
|
||||
|
||||
// If there's only one user message, use the simple path.
|
||||
if turns.len() <= 1 {
|
||||
let last_user = turns.first().map(|m| m.content.as_str()).unwrap_or("");
|
||||
let full_message = match system {
|
||||
Some(s) if !s.is_empty() => format!("{s}\n\n{last_user}"),
|
||||
_ => last_user.to_string(),
|
||||
};
|
||||
return self.invoke_cli(&full_message, model).await;
|
||||
}
|
||||
|
||||
// Format multi-turn conversation into a single prompt.
|
||||
let mut parts = Vec::new();
|
||||
if let Some(s) = system {
|
||||
if !s.is_empty() {
|
||||
parts.push(format!("[system]\n{s}"));
|
||||
}
|
||||
}
|
||||
for msg in &turns {
|
||||
let label = match msg.role.as_str() {
|
||||
"user" => "[user]",
|
||||
"assistant" => "[assistant]",
|
||||
other => other,
|
||||
};
|
||||
parts.push(format!("{label}\n{}", msg.content));
|
||||
}
|
||||
parts.push("[assistant]".to_string());
|
||||
|
||||
let full_message = parts.join("\n\n");
|
||||
self.invoke_cli(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
@@ -327,4 +372,105 @@ mod tests {
|
||||
"unexpected error message: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: create a provider that uses a shell script echoing stdin back.
|
||||
/// The script ignores CLI flags (`--print`, `--model`, `-`) and just cats stdin.
|
||||
///
|
||||
/// Uses `OnceLock` to write the script file exactly once, avoiding
|
||||
/// "Text file busy" (ETXTBSY) races when parallel tests try to
|
||||
/// overwrite a script that another test is currently executing.
|
||||
fn echo_provider() -> ClaudeCodeProvider {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SCRIPT_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
let script = SCRIPT_PATH.get_or_init(|| {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_claude_code");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("fake_claude.sh");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(f, "#!/bin/sh\ncat /dev/stdin").unwrap();
|
||||
drop(f);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
path
|
||||
});
|
||||
ClaudeCodeProvider {
|
||||
binary_path: script.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_single_user_message() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![ChatMessage::user("hello")];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_single_user_with_system() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are helpful."),
|
||||
ChatMessage::user("hello"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "You are helpful.\n\nhello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_multi_turn_includes_all_messages() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::system("Be concise."),
|
||||
ChatMessage::user("What is 2+2?"),
|
||||
ChatMessage::assistant("4"),
|
||||
ChatMessage::user("And 3+3?"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.contains("[system]\nBe concise."));
|
||||
assert!(result.contains("[user]\nWhat is 2+2?"));
|
||||
assert!(result.contains("[assistant]\n4"));
|
||||
assert!(result.contains("[user]\nAnd 3+3?"));
|
||||
assert!(result.ends_with("[assistant]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_multi_turn_without_system() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::user("hi"),
|
||||
ChatMessage::assistant("hello"),
|
||||
ChatMessage::user("bye"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.contains("[system]"));
|
||||
assert!(result.contains("[user]\nhi"));
|
||||
assert!(result.contains("[assistant]\nhello"));
|
||||
assert!(result.contains("[user]\nbye"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_rejects_bad_temperature() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![ChatMessage::user("test")];
|
||||
let result = provider.chat_with_history(&messages, "default", 0.5).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct OpenAiCompatibleProvider {
|
||||
timeout_secs: u64,
|
||||
/// Extra HTTP headers to include in all API requests.
|
||||
extra_headers: std::collections::HashMap<String, String>,
|
||||
/// Optional reasoning effort for GPT-5/Codex-compatible backends.
|
||||
reasoning_effort: Option<String>,
|
||||
/// Custom API path suffix (e.g. "/v2/generate").
|
||||
/// When set, overrides the default `/chat/completions` path detection.
|
||||
api_path: Option<String>,
|
||||
@@ -179,6 +181,7 @@ impl OpenAiCompatibleProvider {
|
||||
native_tool_calling: !merge_system_into_user,
|
||||
timeout_secs: 120,
|
||||
extra_headers: std::collections::HashMap::new(),
|
||||
reasoning_effort: None,
|
||||
api_path: None,
|
||||
}
|
||||
}
|
||||
@@ -198,6 +201,12 @@ impl OpenAiCompatibleProvider {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set reasoning effort for GPT-5/Codex-compatible chat-completions APIs.
|
||||
pub fn with_reasoning_effort(mut self, reasoning_effort: Option<String>) -> Self {
|
||||
self.reasoning_effort = reasoning_effort;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a custom API path suffix for this provider.
|
||||
/// When set, replaces the default `/chat/completions` path.
|
||||
pub fn with_api_path(mut self, api_path: Option<String>) -> Self {
|
||||
@@ -363,6 +372,14 @@ impl OpenAiCompatibleProvider {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reasoning_effort_for_model(&self, model: &str) -> Option<String> {
|
||||
let id = model.rsplit('/').next().unwrap_or(model);
|
||||
let supports_reasoning_effort = id.starts_with("gpt-5") || id.contains("codex");
|
||||
supports_reasoning_effort
|
||||
.then(|| self.reasoning_effort.clone())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -373,6 +390,8 @@ struct ApiChatRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_effort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
@@ -569,6 +588,8 @@ struct NativeChatRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_effort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
@@ -1181,6 +1202,8 @@ impl OpenAiCompatibleProvider {
|
||||
"does not support tools",
|
||||
"function calling is not supported",
|
||||
"tool_choice",
|
||||
"tool call validation failed",
|
||||
"was not in request",
|
||||
]
|
||||
.iter()
|
||||
.any(|hint| lower.contains(hint))
|
||||
@@ -1240,6 +1263,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
messages,
|
||||
temperature,
|
||||
stream: Some(false),
|
||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
@@ -1362,6 +1386,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
messages: api_messages,
|
||||
temperature,
|
||||
stream: Some(false),
|
||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
@@ -1472,6 +1497,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
messages: api_messages,
|
||||
temperature,
|
||||
stream: Some(false),
|
||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||
tools: if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -1577,6 +1603,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
),
|
||||
temperature,
|
||||
stream: Some(false),
|
||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools,
|
||||
};
|
||||
@@ -1720,6 +1747,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
messages,
|
||||
temperature,
|
||||
stream: Some(options.enabled),
|
||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
@@ -1861,6 +1889,7 @@ mod tests {
|
||||
],
|
||||
temperature: 0.4,
|
||||
stream: Some(false),
|
||||
reasoning_effort: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
@@ -2418,6 +2447,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tool_schema_unsupported_detects_groq_tool_validation_error() {
|
||||
assert!(OpenAiCompatibleProvider::is_native_tool_schema_unsupported(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall={\"limit\":5}' which was not in request"}}"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_guided_tool_fallback_injects_system_instruction() {
|
||||
let input = vec![ChatMessage::user("check status")];
|
||||
@@ -2441,6 +2478,22 @@ mod tests {
|
||||
assert!(output[0].content.contains("shell_exec"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_only_applies_to_gpt5_and_codex_models() {
|
||||
let provider = make_provider("test", "https://example.com", None)
|
||||
.with_reasoning_effort(Some("high".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
provider.reasoning_effort_for_model("gpt-5.3-codex"),
|
||||
Some("high".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
provider.reasoning_effort_for_model("openai/gpt-5"),
|
||||
Some("high".to_string())
|
||||
);
|
||||
assert_eq!(provider.reasoning_effort_for_model("llama-3.3-70b"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warmup_without_key_is_noop() {
|
||||
let provider = make_provider("test", "https://example.com", None);
|
||||
@@ -2617,6 +2670,7 @@ mod tests {
|
||||
}],
|
||||
temperature: 0.7,
|
||||
stream: Some(false),
|
||||
reasoning_effort: None,
|
||||
tools: Some(tools),
|
||||
tool_choice: Some("auto".to_string()),
|
||||
};
|
||||
|
||||
@@ -680,6 +680,7 @@ pub struct ProviderRuntimeOptions {
|
||||
pub zeroclaw_dir: Option<PathBuf>,
|
||||
pub secrets_encrypt: bool,
|
||||
pub reasoning_enabled: Option<bool>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// HTTP request timeout in seconds for LLM provider API calls.
|
||||
/// `None` uses the provider's built-in default (120s for compatible providers).
|
||||
pub provider_timeout_secs: Option<u64>,
|
||||
@@ -699,6 +700,7 @@ impl Default for ProviderRuntimeOptions {
|
||||
zeroclaw_dir: None,
|
||||
secrets_encrypt: true,
|
||||
reasoning_enabled: None,
|
||||
reasoning_effort: None,
|
||||
provider_timeout_secs: None,
|
||||
extra_headers: std::collections::HashMap::new(),
|
||||
api_path: None,
|
||||
@@ -706,6 +708,22 @@ impl Default for ProviderRuntimeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider_runtime_options_from_config(
|
||||
config: &crate::config::Config,
|
||||
) -> ProviderRuntimeOptions {
|
||||
ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: config.api_url.clone(),
|
||||
zeroclaw_dir: config.config_path.parent().map(PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
reasoning_effort: config.runtime.reasoning_effort.clone(),
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
extra_headers: config.extra_headers.clone(),
|
||||
api_path: config.api_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_secret_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
|
||||
}
|
||||
@@ -1036,6 +1054,7 @@ fn create_provider_with_url_and_options(
|
||||
// headers to OpenAI-compatible providers before boxing them as trait objects.
|
||||
let compat = {
|
||||
let timeout = options.provider_timeout_secs;
|
||||
let reasoning_effort = options.reasoning_effort.clone();
|
||||
let extra_headers = options.extra_headers.clone();
|
||||
let api_path = options.api_path.clone();
|
||||
move |p: OpenAiCompatibleProvider| -> Box<dyn Provider> {
|
||||
@@ -1043,6 +1062,9 @@ fn create_provider_with_url_and_options(
|
||||
if let Some(t) = timeout {
|
||||
p = p.with_timeout_secs(t);
|
||||
}
|
||||
if let Some(ref effort) = reasoning_effort {
|
||||
p = p.with_reasoning_effort(Some(effort.clone()));
|
||||
}
|
||||
if !extra_headers.is_empty() {
|
||||
p = p.with_extra_headers(extra_headers.clone());
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct OpenAiCodexProvider {
|
||||
responses_url: String,
|
||||
custom_endpoint: bool,
|
||||
gateway_api_key: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
@@ -105,6 +106,7 @@ impl OpenAiCodexProvider {
|
||||
custom_endpoint: !is_default_responses_url(&responses_url),
|
||||
responses_url,
|
||||
gateway_api_key: gateway_api_key.map(ToString::to_string),
|
||||
reasoning_effort: options.reasoning_effort.clone(),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
@@ -304,9 +306,10 @@ fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
|
||||
effort.to_string()
|
||||
}
|
||||
|
||||
fn resolve_reasoning_effort(model_id: &str) -> String {
|
||||
let raw = std::env::var("ZEROCLAW_CODEX_REASONING_EFFORT")
|
||||
.ok()
|
||||
fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String {
|
||||
let raw = configured
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| std::env::var("ZEROCLAW_CODEX_REASONING_EFFORT").ok())
|
||||
.and_then(|value| first_nonempty(Some(&value)))
|
||||
.unwrap_or_else(|| "xhigh".to_string())
|
||||
.to_ascii_lowercase();
|
||||
@@ -663,7 +666,10 @@ impl OpenAiCodexProvider {
|
||||
verbosity: "medium".to_string(),
|
||||
},
|
||||
reasoning: ResponsesReasoningOptions {
|
||||
effort: resolve_reasoning_effort(normalized_model),
|
||||
effort: resolve_reasoning_effort(
|
||||
normalized_model,
|
||||
self.reasoning_effort.as_deref(),
|
||||
),
|
||||
summary: "auto".to_string(),
|
||||
},
|
||||
include: vec!["reasoning.encrypted_content".to_string()],
|
||||
@@ -951,6 +957,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reasoning_effort_prefers_configured_override() {
|
||||
let _guard = EnvGuard::set("ZEROCLAW_CODEX_REASONING_EFFORT", Some("low"));
|
||||
assert_eq!(
|
||||
resolve_reasoning_effort("gpt-5-codex", Some("high")),
|
||||
"high".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reasoning_effort_uses_legacy_env_when_unconfigured() {
|
||||
let _guard = EnvGuard::set("ZEROCLAW_CODEX_REASONING_EFFORT", Some("minimal"));
|
||||
assert_eq!(
|
||||
resolve_reasoning_effort("gpt-5-codex", None),
|
||||
"low".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_text_reads_output_text_delta() {
|
||||
let payload = r#"data: {"type":"response.created","response":{"id":"resp_123"}}
|
||||
@@ -1125,6 +1149,7 @@ data: [DONE]
|
||||
secrets_encrypt: false,
|
||||
auth_profile_override: None,
|
||||
reasoning_enabled: None,
|
||||
reasoning_effort: None,
|
||||
provider_timeout_secs: None,
|
||||
extra_headers: std::collections::HashMap::new(),
|
||||
api_path: None,
|
||||
|
||||
+19
-2
@@ -55,7 +55,9 @@ impl PairingGuard {
|
||||
/// Create a new pairing guard.
|
||||
///
|
||||
/// If `require_pairing` is true and no tokens exist yet, a fresh
|
||||
/// pairing code is generated and returned via `pairing_code()`.
|
||||
/// pairing code is generated and printed to the terminal. Once
|
||||
/// paired, no code is generated on restart — operators can use
|
||||
/// `generate_new_pairing_code()` or the CLI to create one on demand.
|
||||
///
|
||||
/// Existing tokens are accepted in both forms:
|
||||
/// - Plaintext (`zc_...`): hashed on load for backward compatibility
|
||||
@@ -84,7 +86,7 @@ impl PairingGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-time pairing code (only set when no tokens exist yet).
|
||||
/// The one-time pairing code (generated only on first startup when no tokens exist).
|
||||
pub fn pairing_code(&self) -> Option<String> {
|
||||
self.pairing_code.lock().clone()
|
||||
}
|
||||
@@ -229,6 +231,21 @@ impl PairingGuard {
|
||||
*self.pairing_code.lock() = Some(new_code.clone());
|
||||
Some(new_code)
|
||||
}
|
||||
|
||||
/// Get the token hash for a given plaintext token (for device registry lookup).
|
||||
pub fn token_hash(token: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
/// Check if a token is paired and return its hash.
|
||||
pub fn authenticate_and_hash(&self, token: &str) -> Option<String> {
|
||||
if self.is_authenticated(token) {
|
||||
Some(Self::token_hash(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a client identifier: trim whitespace, map empty to `"unknown"`.
|
||||
|
||||
+33
-13
@@ -287,6 +287,21 @@ fn audit_markdown_link_target(
|
||||
match linked_path.canonicalize() {
|
||||
Ok(canonical_target) => {
|
||||
if !canonical_target.starts_with(root) {
|
||||
// Allow cross-skill markdown references that stay within the
|
||||
// overall skills directory (e.g., ~/.zeroclaw/workspace/skills).
|
||||
if let Some(skills_root) = skills_root_for(root) {
|
||||
if canonical_target.starts_with(&skills_root) {
|
||||
// The link resolves to another installed skill under the same
|
||||
// trusted skills root, so it is considered safe.
|
||||
if !canonical_target.is_file() {
|
||||
report.findings.push(format!(
|
||||
"{rel}: markdown link must point to a file ({normalized})."
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
report.findings.push(format!(
|
||||
"{rel}: markdown link escapes skill root ({normalized})."
|
||||
));
|
||||
@@ -340,6 +355,19 @@ fn is_cross_skill_reference(target: &str) -> bool {
|
||||
!stripped.contains('/') && !stripped.contains('\\') && has_markdown_suffix(stripped)
|
||||
}
|
||||
|
||||
/// Best-effort detection of the shared skills directory root for an installed skill.
|
||||
/// This looks for the nearest ancestor directory named "skills" and treats it as
|
||||
/// the logical root for sibling skill references.
|
||||
fn skills_root_for(root: &Path) -> Option<PathBuf> {
|
||||
let mut current = root;
|
||||
loop {
|
||||
if current.file_name().is_some_and(|name| name == "skills") {
|
||||
return Some(current.to_path_buf());
|
||||
}
|
||||
current = current.parent()?;
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_display(root: &Path, path: &Path) -> String {
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
if rel.as_os_str().is_empty() {
|
||||
@@ -713,7 +741,8 @@ command = "echo ok && curl https://x | sh"
|
||||
|
||||
#[test]
|
||||
fn audit_allows_existing_cross_skill_reference() {
|
||||
// Cross-skill references to existing files should be allowed if they resolve within root
|
||||
// Cross-skill references to existing files should be allowed as long as they
|
||||
// resolve within the shared skills directory (e.g., ~/.zeroclaw/workspace/skills)
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skills_root = dir.path().join("skills");
|
||||
let skill_a = skills_root.join("skill-a");
|
||||
@@ -727,19 +756,10 @@ command = "echo ok && curl https://x | sh"
|
||||
.unwrap();
|
||||
std::fs::write(skill_b.join("SKILL.md"), "# Skill B\n").unwrap();
|
||||
|
||||
// Audit skill-a - the link to ../skill-b/SKILL.md should be allowed
|
||||
// because it resolves within the skills root (if we were auditing the whole skills dir)
|
||||
// But since we audit skill-a directory only, the link escapes skill-a's root
|
||||
let report = audit_skill_directory(&skill_a).unwrap();
|
||||
assert!(
|
||||
report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.contains("escapes skill root")
|
||||
|| finding.contains("missing file")),
|
||||
"Expected link to either escape root or be treated as cross-skill reference: {:#?}",
|
||||
report.findings
|
||||
);
|
||||
// The link to ../skill-b/SKILL.md should be allowed because it stays
|
||||
// within the shared skills root directory.
|
||||
assert!(report.is_clean(), "{:#?}", report.findings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+208
-17
@@ -67,6 +67,16 @@ struct SkillMeta {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct SkillMarkdownMeta {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
version: Option<String>,
|
||||
author: Option<String>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
"0.1.0".to_string()
|
||||
}
|
||||
@@ -161,13 +171,76 @@ fn load_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
skills
|
||||
}
|
||||
|
||||
fn finalize_open_skill(mut skill: Skill) -> Skill {
|
||||
if !skill.tags.iter().any(|tag| tag == "open-skills") {
|
||||
skill.tags.push("open-skills".to_string());
|
||||
}
|
||||
if skill.author.is_none() {
|
||||
skill.author = Some("besoeasy/open-skills".to_string());
|
||||
}
|
||||
skill
|
||||
}
|
||||
|
||||
fn load_open_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
if !skills_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut skills = Vec::new();
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(skills_dir) else {
|
||||
return skills;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match audit::audit_skill_directory(&path) {
|
||||
Ok(report) if report.is_clean() => {}
|
||||
Ok(report) => {
|
||||
tracing::warn!(
|
||||
"skipping insecure open-skill directory {}: {}",
|
||||
path.display(),
|
||||
report.summary()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"skipping unauditable open-skill directory {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let manifest_path = path.join("SKILL.toml");
|
||||
let md_path = path.join("SKILL.md");
|
||||
|
||||
if manifest_path.exists() {
|
||||
if let Ok(skill) = load_skill_toml(&manifest_path) {
|
||||
skills.push(finalize_open_skill(skill));
|
||||
}
|
||||
} else if md_path.exists() {
|
||||
if let Ok(skill) = load_open_skill_md(&md_path) {
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
fn load_open_skills(repo_dir: &Path) -> Vec<Skill> {
|
||||
// Modern open-skills layout stores skill packages in `skills/<name>/SKILL.md`.
|
||||
// Prefer that structure to avoid treating repository docs (e.g. CONTRIBUTING.md)
|
||||
// as executable skills.
|
||||
let nested_skills_dir = repo_dir.join("skills");
|
||||
if nested_skills_dir.is_dir() {
|
||||
return load_skills_from_directory(&nested_skills_dir);
|
||||
return load_open_skills_from_directory(&nested_skills_dir);
|
||||
}
|
||||
|
||||
let mut skills = Vec::new();
|
||||
@@ -420,6 +493,7 @@ fn load_skill_toml(path: &Path) -> Result<Skill> {
|
||||
/// Load a skill from a SKILL.md file (simpler format)
|
||||
fn load_skill_md(path: &Path, dir: &Path) -> Result<Skill> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let parsed = parse_skill_markdown(&content);
|
||||
let name = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
@@ -427,35 +501,90 @@ fn load_skill_md(path: &Path, dir: &Path) -> Result<Skill> {
|
||||
.to_string();
|
||||
|
||||
Ok(Skill {
|
||||
name,
|
||||
description: extract_description(&content),
|
||||
version: "0.1.0".to_string(),
|
||||
author: None,
|
||||
tags: Vec::new(),
|
||||
name: parsed.meta.name.unwrap_or(name),
|
||||
description: parsed
|
||||
.meta
|
||||
.description
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| extract_description(&parsed.body)),
|
||||
version: parsed.meta.version.unwrap_or_else(default_version),
|
||||
author: parsed.meta.author,
|
||||
tags: parsed.meta.tags,
|
||||
tools: Vec::new(),
|
||||
prompts: vec![content],
|
||||
prompts: vec![parsed.body],
|
||||
location: Some(path.to_path_buf()),
|
||||
})
|
||||
}
|
||||
|
||||
fn load_open_skill_md(path: &Path) -> Result<Skill> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let name = path
|
||||
let parsed = parse_skill_markdown(&content);
|
||||
let file_stem = path
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("open-skill")
|
||||
.to_string();
|
||||
|
||||
Ok(Skill {
|
||||
name,
|
||||
description: extract_description(&content),
|
||||
version: "open-skills".to_string(),
|
||||
author: Some("besoeasy/open-skills".to_string()),
|
||||
tags: vec!["open-skills".to_string()],
|
||||
let name = if file_stem.eq_ignore_ascii_case("skill") {
|
||||
path.parent()
|
||||
.and_then(|dir| dir.file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&file_stem)
|
||||
.to_string()
|
||||
} else {
|
||||
file_stem
|
||||
};
|
||||
Ok(finalize_open_skill(Skill {
|
||||
name: parsed.meta.name.unwrap_or(name),
|
||||
description: parsed
|
||||
.meta
|
||||
.description
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| extract_description(&parsed.body)),
|
||||
version: parsed
|
||||
.meta
|
||||
.version
|
||||
.unwrap_or_else(|| "open-skills".to_string()),
|
||||
author: parsed
|
||||
.meta
|
||||
.author
|
||||
.or_else(|| Some("besoeasy/open-skills".to_string())),
|
||||
tags: parsed.meta.tags,
|
||||
tools: Vec::new(),
|
||||
prompts: vec![content],
|
||||
prompts: vec![parsed.body],
|
||||
location: Some(path.to_path_buf()),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
struct ParsedSkillMarkdown {
|
||||
meta: SkillMarkdownMeta,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn parse_skill_markdown(content: &str) -> ParsedSkillMarkdown {
|
||||
if let Some((frontmatter, body)) = split_skill_frontmatter(content) {
|
||||
if let Ok(meta) = serde_yaml::from_str::<SkillMarkdownMeta>(&frontmatter) {
|
||||
return ParsedSkillMarkdown { meta, body };
|
||||
}
|
||||
}
|
||||
|
||||
ParsedSkillMarkdown {
|
||||
meta: SkillMarkdownMeta::default(),
|
||||
body: content.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_skill_frontmatter(content: &str) -> Option<(String, String)> {
|
||||
let normalized = content.replace("\r\n", "\n");
|
||||
let rest = normalized.strip_prefix("---\n")?;
|
||||
if let Some(idx) = rest.find("\n---\n") {
|
||||
let frontmatter = rest[..idx].to_string();
|
||||
let body = rest[idx + 5..].to_string();
|
||||
return Some((frontmatter, body));
|
||||
}
|
||||
if let Some(frontmatter) = rest.strip_suffix("\n---") {
|
||||
return Some((frontmatter.to_string(), String::new()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_description(content: &str) -> String {
|
||||
@@ -620,6 +749,7 @@ pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> {
|
||||
```\n\n\
|
||||
## SKILL.md format (simpler)\n\n\
|
||||
Just write a markdown file with instructions for the agent.\n\
|
||||
Optional YAML frontmatter is supported for `name`, `description`, `version`, `author`, and `tags`.\n\
|
||||
The agent will read it and follow the instructions.\n\n\
|
||||
## Installing community skills\n\n\
|
||||
```bash\n\
|
||||
@@ -1059,6 +1189,30 @@ command = "echo hello"
|
||||
assert!(skills[0].description.contains("cool things"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skill_from_md_frontmatter_uses_metadata_and_body() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skills_dir = dir.path().join("skills");
|
||||
let skill_dir = skills_dir.join("md-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: pdf\ndescription: Use this skill for PDFs\nversion: 1.2.3\nauthor: maintainer\ntags:\n - docs\n - pdf\n---\n# PDF Processing Guide\nExtract text carefully.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = load_skills(dir.path());
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "pdf");
|
||||
assert_eq!(skills[0].description, "Use this skill for PDFs");
|
||||
assert_eq!(skills[0].version, "1.2.3");
|
||||
assert_eq!(skills[0].author.as_deref(), Some("maintainer"));
|
||||
assert_eq!(skills[0].tags, vec!["docs", "pdf"]);
|
||||
assert!(skills[0].prompts[0].contains("# PDF Processing Guide"));
|
||||
assert!(!skills[0].prompts[0].contains("name: pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_to_prompt_empty() {
|
||||
let prompt = skills_to_prompt(&[], Path::new("/tmp"));
|
||||
@@ -1471,6 +1625,43 @@ description = "Bare minimum"
|
||||
assert_eq!(skills[0].name, "http_request");
|
||||
assert_ne!(skills[0].name, "CONTRIBUTING");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_open_skill_md_frontmatter_uses_metadata_and_strips_block() {
|
||||
let _env_guard = open_skills_env_lock().lock().unwrap();
|
||||
let _enabled_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_ENABLED");
|
||||
let _dir_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_DIR");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace_dir = dir.path().join("workspace");
|
||||
fs::create_dir_all(workspace_dir.join("skills")).unwrap();
|
||||
|
||||
let open_skills_dir = dir.path().join("open-skills-local");
|
||||
fs::create_dir_all(open_skills_dir.join("skills/pdf")).unwrap();
|
||||
fs::write(
|
||||
open_skills_dir.join("skills/pdf/SKILL.md"),
|
||||
"---\nname: pdf\ndescription: Use this skill whenever the user needs PDF help.\nauthor: community\ntags:\n - parser\n---\n# PDF Guide\nInspect files safely.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut config = crate::config::Config::default();
|
||||
config.workspace_dir = workspace_dir.clone();
|
||||
config.skills.open_skills_enabled = true;
|
||||
config.skills.open_skills_dir = Some(open_skills_dir.to_string_lossy().to_string());
|
||||
|
||||
let skills = load_skills_with_config(&workspace_dir, &config);
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "pdf");
|
||||
assert_eq!(
|
||||
skills[0].description,
|
||||
"Use this skill whenever the user needs PDF help."
|
||||
);
|
||||
assert_eq!(skills[0].author.as_deref(), Some("community"));
|
||||
assert!(skills[0].tags.iter().any(|tag| tag == "parser"));
|
||||
assert!(skills[0].tags.iter().any(|tag| tag == "open-skills"));
|
||||
assert!(skills[0].prompts[0].contains("# PDF Guide"));
|
||||
assert!(!skills[0].prompts[0].contains("description: Use this skill"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -184,6 +184,36 @@ impl ActivatedToolSet {
|
||||
self.tools.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Resolve an activated tool by exact name first, then by unique MCP suffix.
|
||||
///
|
||||
/// Some providers occasionally strip the `<server>__` prefix when calling a
|
||||
/// deferred MCP tool after `tool_search` activation. When the suffix maps to
|
||||
/// exactly one activated tool, allow that call to proceed.
|
||||
pub fn get_resolved(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
||||
if let Some(tool) = self.get(name) {
|
||||
return Some(tool);
|
||||
}
|
||||
if name.contains("__") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut resolved = None;
|
||||
for (tool_name, tool) in &self.tools {
|
||||
let Some((_, suffix)) = tool_name.split_once("__") else {
|
||||
continue;
|
||||
};
|
||||
if suffix != name {
|
||||
continue;
|
||||
}
|
||||
if resolved.is_some() {
|
||||
return None;
|
||||
}
|
||||
resolved = Some(Arc::clone(tool));
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
pub fn tool_specs(&self) -> Vec<ToolSpec> {
|
||||
self.tools.values().map(|t| t.spec()).collect()
|
||||
}
|
||||
@@ -281,6 +311,75 @@ mod tests {
|
||||
assert_eq!(set.tool_specs().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activated_set_resolves_unique_suffix() {
|
||||
use crate::tools::traits::ToolResult;
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct FakeTool;
|
||||
#[async_trait]
|
||||
impl Tool for FakeTool {
|
||||
fn name(&self) -> &str {
|
||||
"docker-mcp__extract_text"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"fake tool"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: String::new(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut set = ActivatedToolSet::new();
|
||||
set.activate("docker-mcp__extract_text".into(), Arc::new(FakeTool));
|
||||
assert!(set.get_resolved("extract_text").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activated_set_rejects_ambiguous_suffix() {
|
||||
use crate::tools::traits::ToolResult;
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct FakeTool(&'static str);
|
||||
#[async_trait]
|
||||
impl Tool for FakeTool {
|
||||
fn name(&self) -> &str {
|
||||
self.0
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"fake tool"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: String::new(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut set = ActivatedToolSet::new();
|
||||
set.activate(
|
||||
"docker-mcp__extract_text".into(),
|
||||
Arc::new(FakeTool("docker-mcp__extract_text")),
|
||||
);
|
||||
set.activate(
|
||||
"ocr-mcp__extract_text".into(),
|
||||
Arc::new(FakeTool("ocr-mcp__extract_text")),
|
||||
);
|
||||
assert!(set.get_resolved("extract_text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_deferred_section_empty_when_no_stubs() {
|
||||
let set = DeferredMcpToolSet {
|
||||
|
||||
@@ -576,6 +576,7 @@ pub fn all_tools_with_runtime(
|
||||
.map(std::path::PathBuf::from),
|
||||
secrets_encrypt: root_config.secrets.encrypt,
|
||||
reasoning_enabled: root_config.runtime.reasoning_enabled,
|
||||
reasoning_effort: root_config.runtime.reasoning_effort.clone(),
|
||||
provider_timeout_secs: Some(root_config.provider_timeout_secs),
|
||||
extra_headers: root_config.extra_headers.clone(),
|
||||
api_path: root_config.api_path.clone(),
|
||||
@@ -633,6 +634,53 @@ pub fn all_tools_with_runtime(
|
||||
)));
|
||||
}
|
||||
|
||||
// ── WASM plugin tools (requires plugins-wasm feature) ──
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
{
|
||||
let plugin_dir = config.plugins.plugins_dir.clone();
|
||||
let plugin_path = if plugin_dir.starts_with("~/") {
|
||||
let home = directories::UserDirs::new()
|
||||
.map(|u| u.home_dir().to_path_buf())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
home.join(&plugin_dir[2..])
|
||||
} else {
|
||||
std::path::PathBuf::from(&plugin_dir)
|
||||
};
|
||||
|
||||
if plugin_path.exists() && config.plugins.enabled {
|
||||
match crate::plugins::host::PluginHost::new(
|
||||
plugin_path.parent().unwrap_or(&plugin_path),
|
||||
) {
|
||||
Ok(host) => {
|
||||
let tool_manifests = host.tool_plugins();
|
||||
let count = tool_manifests.len();
|
||||
for manifest in tool_manifests {
|
||||
tool_arcs.push(Arc::new(crate::plugins::wasm_tool::WasmTool::new(
|
||||
manifest.name.clone(),
|
||||
manifest.description.clone().unwrap_or_default(),
|
||||
manifest.name.clone(),
|
||||
"call".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "Input for the plugin"
|
||||
}
|
||||
},
|
||||
"required": ["input"]
|
||||
}),
|
||||
)));
|
||||
}
|
||||
tracing::info!("Loaded {count} WASM plugin tools");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load WASM plugins: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(boxed_registry_from_arcs(tool_arcs), delegate_handle)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ async fn openai_codex_second_vision_support() -> Result<()> {
|
||||
zeroclaw_dir: None,
|
||||
secrets_encrypt: false,
|
||||
reasoning_enabled: None,
|
||||
reasoning_effort: None,
|
||||
provider_timeout_secs: None,
|
||||
extra_headers: std::collections::HashMap::new(),
|
||||
api_path: None,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# English tool descriptions (default locale)
|
||||
#
|
||||
# Each key under [tools] matches the tool's name() return value.
|
||||
# Values are the human-readable descriptions shown in system prompts.
|
||||
|
||||
[tools]
|
||||
backup = "Create, list, verify, and restore workspace backups"
|
||||
browser = "Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions."
|
||||
browser_delegate = "Delegate browser-based tasks to a browser-capable CLI for interacting with web applications like Teams, Outlook, Jira, Confluence"
|
||||
browser_open = "Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
|
||||
cloud_ops = "Cloud transformation advisory tool. Analyzes IaC plans, assesses migration paths, reviews costs, and checks architecture against Well-Architected Framework pillars. Read-only: does not create or modify cloud resources."
|
||||
cloud_patterns = "Cloud pattern library. Given a workload description, suggests applicable cloud-native architectural patterns (containerization, serverless, database modernization, etc.)."
|
||||
composio = "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to see available actions (includes parameter names). action='execute' with action_name/tool_slug and params to run an action. If you are unsure of the exact params, pass 'text' instead with a natural-language description of what you want (Composio will resolve the correct parameters via NLP). action='list_accounts' or action='connected_accounts' to list OAuth-connected accounts. action='connect' with app/auth_config_id to get OAuth URL. connected_account_id is auto-resolved when omitted."
|
||||
content_search = "Search file contents by regex pattern within the workspace. Supports ripgrep (rg) with grep fallback. Output modes: 'content' (matching lines with context), 'files_with_matches' (file paths only), 'count' (match counts per file). Example: pattern='fn main', include='*.rs', output_mode='content'."
|
||||
cron_add = """Create a scheduled cron job (shell or agent) with cron/at/every schedules. Use job_type='agent' with a prompt to run the AI agent on schedule. To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix), set delivery={"mode":"announce","channel":"discord","to":"<channel_id_or_chat_id>"}. This is the preferred tool for sending scheduled/delayed messages to users via channels."""
|
||||
cron_list = "List all scheduled cron jobs"
|
||||
cron_remove = "Remove a cron job by id"
|
||||
cron_run = "Force-run a cron job immediately and record run history"
|
||||
cron_runs = "List recent run history for a cron job"
|
||||
cron_update = "Patch an existing cron job (schedule, command, prompt, enabled, delivery, model, etc.)"
|
||||
data_management = "Workspace data retention, purge, and storage statistics"
|
||||
delegate = "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single prompt by default; with agentic=true it can iterate with a filtered tool-call loop."
|
||||
file_edit = "Edit a file by replacing an exact string match with new content"
|
||||
file_read = "Read file contents with line numbers. Supports partial reading via offset and limit. Extracts text from PDF; other binary files are read with lossy UTF-8 conversion."
|
||||
file_write = "Write contents to a file in the workspace"
|
||||
git_operations = "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls."
|
||||
glob_search = "Search for files matching a glob pattern within the workspace. Returns a sorted list of matching file paths relative to the workspace root. Examples: '**/*.rs' (all Rust files), 'src/**/mod.rs' (all mod.rs in src)."
|
||||
google_workspace = "Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) via the gws CLI. Requires gws to be installed and authenticated."
|
||||
hardware_board_info = "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'."
|
||||
hardware_memory_map = "Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets."
|
||||
hardware_memory_read = "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)."
|
||||
http_request = "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
|
||||
image_info = "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data."
|
||||
knowledge = "Manage a knowledge graph of architecture decisions, solution patterns, lessons learned, and experts. Actions: capture, search, relate, suggest, expert_find, lessons_extract, graph_stats."
|
||||
linkedin = "Manage LinkedIn: create posts, list your posts, comment, react, delete posts, view engagement, get profile info, and read the configured content strategy. Requires LINKEDIN_* credentials in .env file."
|
||||
memory_forget = "Remove a memory by key. Use to delete outdated facts or sensitive data. Returns whether the memory was found and removed."
|
||||
memory_recall = "Search long-term memory for relevant facts, preferences, or context. Returns scored results ranked by relevance."
|
||||
memory_store = "Store a fact, preference, or note in long-term memory. Use category 'core' for permanent facts, 'daily' for session notes, 'conversation' for chat context, or a custom category name."
|
||||
microsoft365 = "Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, OneDrive files, and SharePoint search via Microsoft Graph API"
|
||||
model_routing_config = "Manage default model settings, scenario-based provider/model routes, classification rules, and delegate sub-agent profiles"
|
||||
notion = "Interact with Notion: query databases, read/create/update pages, and search the workspace."
|
||||
pdf_read = "Extract plain text from a PDF file in the workspace. Returns all readable text. Image-only or encrypted PDFs return an empty result. Requires the 'rag-pdf' build feature."
|
||||
project_intel = "Project delivery intelligence: generate status reports, detect risks, draft client updates, summarize sprints, and estimate effort. Read-only analysis tool."
|
||||
proxy_config = "Manage ZeroClaw proxy settings (scope: environment | zeroclaw | services), including runtime and process env application"
|
||||
pushover = "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file."
|
||||
schedule = """Manage scheduled shell-only tasks. Actions: create/add/once/list/get/cancel/remove/pause/resume. WARNING: This tool creates shell jobs whose output is only logged, NOT delivered to any channel. To send a scheduled message to Discord/Telegram/Slack/Matrix, use the cron_add tool with job_type='agent' and a delivery config like {"mode":"announce","channel":"discord","to":"<channel_id>"}."""
|
||||
screenshot = "Capture a screenshot of the current screen. Returns the file path and base64-encoded PNG data."
|
||||
security_ops = "Security operations tool for managed cybersecurity services. Actions: triage_alert (classify/prioritize alerts), run_playbook (execute incident response steps), parse_vulnerability (parse scan results), generate_report (create security posture reports), list_playbooks (list available playbooks), alert_stats (summarize alert metrics)."
|
||||
shell = "Execute a shell command in the workspace directory"
|
||||
sop_advance = "Report the result of the current SOP step and advance to the next step. Provide the run_id, whether the step succeeded or failed, and a brief output summary."
|
||||
sop_approve = "Approve a pending SOP step that is waiting for operator approval. Returns the step instruction to execute. Use sop_status to see which runs are waiting."
|
||||
sop_execute = "Manually trigger a Standard Operating Procedure (SOP) by name. Returns the run ID and first step instruction. Use sop_list to see available SOPs."
|
||||
sop_list = "List all loaded Standard Operating Procedures (SOPs) with their triggers, priority, step count, and active run count. Optionally filter by name or priority."
|
||||
sop_status = "Query SOP execution status. Provide run_id for a specific run, or sop_name to list runs for that SOP. With no arguments, shows all active runs."
|
||||
swarm = "Orchestrate a swarm of agents to collaboratively handle a task. Supports sequential (pipeline), parallel (fan-out/fan-in), and router (LLM-selected) strategies."
|
||||
tool_search = """Fetch full schema definitions for deferred MCP tools so they can be called. Use "select:name1,name2" for exact match or keywords to search."""
|
||||
web_fetch = "Fetch a web page and return its content as clean plain text. HTML pages are automatically converted to readable text. JSON and plain text responses are returned as-is. Only GET requests; follows redirects. Security: allowlist-only domains, no local/private hosts."
|
||||
web_search_tool = "Search the web for information. Returns relevant search results with titles, URLs, and descriptions. Use this to find current information, news, or research topics."
|
||||
workspace = "Manage multi-client workspaces. Subcommands: list, switch, create, info, export. Each workspace provides isolated memory, audit, secrets, and tool restrictions."
|
||||
@@ -0,0 +1,60 @@
|
||||
# 中文工具描述 (简体中文)
|
||||
#
|
||||
# [tools] 下的每个键对应工具的 name() 返回值。
|
||||
# 值是显示在系统提示中的人类可读描述。
|
||||
# 缺少的键将回退到英文 (en.toml) 描述。
|
||||
|
||||
[tools]
|
||||
backup = "创建、列出、验证和恢复工作区备份"
|
||||
browser = "基于可插拔后端(agent-browser、rust-native、computer_use)的网页/浏览器自动化。支持 DOM 操作以及通过 computer-use 辅助工具进行的可选系统级操作(mouse_move、mouse_click、mouse_drag、key_type、key_press、screen_capture)。使用 'snapshot' 将交互元素映射到引用(@e1、@e2)。对 open 操作强制执行 browser.allowed_domains。"
|
||||
browser_delegate = "将基于浏览器的任务委派给具有浏览器功能的 CLI,用于与 Teams、Outlook、Jira、Confluence 等 Web 应用交互"
|
||||
browser_open = "在系统浏览器中打开经批准的 HTTPS URL。安全约束:仅允许列表域名,禁止本地/私有主机,禁止抓取。"
|
||||
cloud_ops = "云转型咨询工具。分析 IaC 计划、评估迁移路径、审查成本,并根据良好架构框架支柱检查架构。只读:不创建或修改云资源。"
|
||||
cloud_patterns = "云模式库。根据工作负载描述,建议适用的云原生架构模式(容器化、无服务器、数据库现代化等)。"
|
||||
composio = "通过 Composio 在 1000 多个应用上执行操作(Gmail、Notion、GitHub、Slack 等)。使用 action='list' 查看可用操作(包含参数名称)。使用 action='execute' 配合 action_name/tool_slug 和 params 运行操作。如果不确定具体参数,可传入 'text' 并用自然语言描述需求(Composio 将通过 NLP 解析正确参数)。使用 action='list_accounts' 或 action='connected_accounts' 列出 OAuth 已连接账户。使用 action='connect' 配合 app/auth_config_id 获取 OAuth URL。省略时自动解析 connected_account_id。"
|
||||
content_search = "在工作区内按正则表达式搜索文件内容。支持 ripgrep (rg),可回退到 grep。输出模式:'content'(带上下文的匹配行)、'files_with_matches'(仅文件路径)、'count'(每个文件的匹配计数)。"
|
||||
cron_add = "创建带有 cron/at/every 计划的定时任务(shell 或 agent)。使用 job_type='agent' 配合 prompt 按计划运行 AI 代理。要将输出发送到频道(Discord、Telegram、Slack、Mattermost、Matrix),请设置 delivery 配置。这是通过频道向用户发送定时/延迟消息的首选工具。"
|
||||
cron_list = "列出所有已计划的 cron 任务"
|
||||
cron_remove = "按 ID 删除 cron 任务"
|
||||
cron_run = "立即强制运行 cron 任务并记录运行历史"
|
||||
cron_runs = "列出 cron 任务的最近运行历史"
|
||||
cron_update = "修改现有 cron 任务(计划、命令、提示、启用状态、投递配置、模型等)"
|
||||
data_management = "工作区数据保留、清理和存储统计"
|
||||
delegate = "将子任务委派给专用代理。适用场景:任务受益于不同模型(如快速摘要、深度推理、代码生成)。子代理默认运行单个提示;设置 agentic=true 后可通过过滤的工具调用循环进行迭代。"
|
||||
file_edit = "通过替换精确匹配的字符串来编辑文件"
|
||||
file_read = "读取带行号的文件内容。支持通过 offset 和 limit 进行部分读取。可从 PDF 提取文本;其他二进制文件使用有损 UTF-8 转换读取。"
|
||||
file_write = "将内容写入工作区中的文件"
|
||||
git_operations = "执行结构化的 Git 操作(status、diff、log、branch、commit、add、checkout、stash)。提供解析后的 JSON 输出,并与安全策略集成以实现自主控制。"
|
||||
glob_search = "在工作区内搜索匹配 glob 模式的文件。返回相对于工作区根目录的排序文件路径列表。示例:'**/*.rs'(所有 Rust 文件)、'src/**/mod.rs'(src 中所有 mod.rs)。"
|
||||
google_workspace = "与 Google Workspace 服务(Drive、Gmail、Calendar、Sheets、Docs 等)交互。通过 gws CLI 操作,需要 gws 已安装并认证。"
|
||||
hardware_board_info = "返回已连接硬件的完整板卡信息(芯片、架构、内存映射)。适用场景:用户询问板卡信息、连接的硬件、芯片信息等。"
|
||||
hardware_memory_map = "返回已连接硬件的内存映射(Flash 和 RAM 地址范围)。适用场景:用户询问内存地址、地址空间或可读地址。返回数据手册中的 Flash/RAM 范围。"
|
||||
hardware_memory_read = "通过 USB 从 Nucleo 读取实际内存/寄存器值。适用场景:用户要求读取寄存器值、读取内存地址、转储内存等。返回十六进制转储。需要 Nucleo 通过 USB 连接并启用 probe 功能。"
|
||||
http_request = "向外部 API 发送 HTTP 请求。支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法。安全约束:仅允许列表域名,禁止本地/私有主机,可配置超时和响应大小限制。"
|
||||
image_info = "读取图片文件元数据(格式、尺寸、大小),可选返回 base64 编码数据。"
|
||||
knowledge = "管理架构决策、解决方案模式、经验教训和专家的知识图谱。操作:capture、search、relate、suggest、expert_find、lessons_extract、graph_stats。"
|
||||
linkedin = "管理 LinkedIn:创建帖子、列出帖子、评论、点赞、删除帖子、查看互动数据、获取个人资料信息,以及阅读配置的内容策略。需要在 .env 文件中配置 LINKEDIN_* 凭据。"
|
||||
memory_forget = "按键删除记忆。用于删除过时事实或敏感数据。返回记忆是否被找到并删除。"
|
||||
memory_recall = "在长期记忆中搜索相关事实、偏好或上下文。返回按相关性排名的评分结果。"
|
||||
memory_store = "在长期记忆中存储事实、偏好或笔记。使用类别 'core' 存储永久事实,'daily' 存储会话笔记,'conversation' 存储聊天上下文,或使用自定义类别名称。"
|
||||
microsoft365 = "Microsoft 365 集成:通过 Microsoft Graph API 管理 Outlook 邮件、Teams 消息、日历事件、OneDrive 文件和 SharePoint 搜索"
|
||||
model_routing_config = "管理默认模型设置、基于场景的提供商/模型路由、分类规则和委派子代理配置"
|
||||
notion = "与 Notion 交互:查询数据库、读取/创建/更新页面、搜索工作区。"
|
||||
pdf_read = "从工作区中的 PDF 文件提取纯文本。返回所有可读文本。仅图片或加密的 PDF 返回空结果。需要 'rag-pdf' 构建功能。"
|
||||
project_intel = "项目交付智能:生成状态报告、检测风险、起草客户更新、总结冲刺、估算工作量。只读分析工具。"
|
||||
proxy_config = "管理 ZeroClaw 代理设置(范围:environment | zeroclaw | services),包括运行时和进程环境应用"
|
||||
pushover = "向设备发送 Pushover 通知。需要在 .env 文件中配置 PUSHOVER_TOKEN 和 PUSHOVER_USER_KEY。"
|
||||
schedule = "管理仅限 shell 的定时任务。操作:create/add/once/list/get/cancel/remove/pause/resume。警告:此工具创建的 shell 任务输出仅记录日志,不会发送到任何频道。要向 Discord/Telegram/Slack/Matrix 发送定时消息,请使用 cron_add 工具。"
|
||||
screenshot = "捕获当前屏幕截图。返回文件路径和 base64 编码的 PNG 数据。"
|
||||
security_ops = "托管网络安全服务的安全运营工具。操作:triage_alert(分类/优先级排序警报)、run_playbook(执行事件响应步骤)、parse_vulnerability(解析扫描结果)、generate_report(创建安全态势报告)、list_playbooks(列出可用剧本)、alert_stats(汇总警报指标)。"
|
||||
shell = "在工作区目录中执行 shell 命令"
|
||||
sop_advance = "报告当前 SOP 步骤的结果并前进到下一步。提供 run_id、步骤是否成功或失败,以及简短的输出摘要。"
|
||||
sop_approve = "批准等待操作员批准的待处理 SOP 步骤。返回要执行的步骤指令。使用 sop_status 查看哪些运行正在等待。"
|
||||
sop_execute = "按名称手动触发标准操作程序 (SOP)。返回运行 ID 和第一步指令。使用 sop_list 查看可用 SOP。"
|
||||
sop_list = "列出所有已加载的标准操作程序 (SOP),包括触发器、优先级、步骤数和活跃运行数。可按名称或优先级筛选。"
|
||||
sop_status = "查询 SOP 执行状态。提供 run_id 查看特定运行,或提供 sop_name 列出该 SOP 的所有运行。无参数时显示所有活跃运行。"
|
||||
swarm = "编排代理群以协作处理任务。支持顺序(管道)、并行(扇出/扇入)和路由器(LLM 选择)策略。"
|
||||
tool_search = "获取延迟 MCP 工具的完整 schema 定义以便调用。使用 \"select:name1,name2\" 精确匹配或关键词搜索。"
|
||||
web_fetch = "获取网页并以纯文本形式返回内容。HTML 页面自动转换为可读文本。JSON 和纯文本响应按原样返回。仅 GET 请求;跟随重定向。安全:仅允许列表域名,禁止本地/私有主机。"
|
||||
web_search_tool = "搜索网络获取信息。返回包含标题、URL 和描述的相关搜索结果。用于查找当前信息、新闻或研究主题。"
|
||||
workspace = "管理多客户端工作区。子命令:list、switch、create、info、export。每个工作区提供隔离的记忆、审计、密钥和工具限制。"
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
|
||||
|
||||
""
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
+41
-3
@@ -12,9 +12,11 @@ import Config from './pages/Config';
|
||||
import Cost from './pages/Cost';
|
||||
import Logs from './pages/Logs';
|
||||
import Doctor from './pages/Doctor';
|
||||
import Pairing from './pages/Pairing';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { DraftContext, useDraftStore } from './hooks/useDraft';
|
||||
import { setLocale, type Locale } from './lib/i18n';
|
||||
import { getAdminPairCode } from './lib/api';
|
||||
|
||||
// Locale context
|
||||
interface LocaleContextType {
|
||||
@@ -23,7 +25,7 @@ interface LocaleContextType {
|
||||
}
|
||||
|
||||
export const LocaleContext = createContext<LocaleContextType>({
|
||||
locale: 'tr',
|
||||
locale: 'en',
|
||||
setAppLocale: () => {},
|
||||
});
|
||||
|
||||
@@ -88,6 +90,26 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [displayCode, setDisplayCode] = useState<string | null>(null);
|
||||
const [codeLoading, setCodeLoading] = useState(true);
|
||||
|
||||
// Fetch the current pairing code from the admin endpoint (localhost only)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAdminPairCode()
|
||||
.then((data) => {
|
||||
if (!cancelled && data.pairing_code) {
|
||||
setDisplayCode(data.pairing_code);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Admin endpoint not reachable (non-localhost) — user must check terminal
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCodeLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -119,8 +141,23 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
style={{ boxShadow: '0 0 30px rgba(0,128,255,0.3)' }}
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-gradient-blue mb-2">ZeroClaw</h1>
|
||||
<p className="text-[#556080] text-sm">Enter the pairing code from your terminal</p>
|
||||
{displayCode ? (
|
||||
<p className="text-[#556080] text-sm">Your pairing code</p>
|
||||
) : (
|
||||
<p className="text-[#556080] text-sm">Enter the pairing code from your terminal</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show the pairing code if available (localhost) */}
|
||||
{!codeLoading && displayCode && (
|
||||
<div className="mb-6 p-4 rounded-xl text-center" style={{ background: 'rgba(0,128,255,0.08)', border: '1px solid rgba(0,128,255,0.2)' }}>
|
||||
<div className="text-4xl font-mono font-bold tracking-[0.4em] text-white py-2">
|
||||
{displayCode}
|
||||
</div>
|
||||
<p className="text-[#556080] text-xs mt-2">Enter this code below or on another device</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -154,7 +191,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, requiresPairing, loading, pair, logout } = useAuth();
|
||||
const [locale, setLocaleState] = useState('tr');
|
||||
const [locale, setLocaleState] = useState('en');
|
||||
const draftStore = useDraftStore();
|
||||
|
||||
const setAppLocale = (newLocale: string) => {
|
||||
@@ -201,6 +238,7 @@ function AppContent() {
|
||||
<Route path="/cost" element={<Cost />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/doctor" element={<Doctor />} />
|
||||
<Route path="/pairing" element={<Pairing />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -26,7 +26,9 @@ export default function Header() {
|
||||
const pageTitle = t(titleKey);
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setAppLocale(locale === 'en' ? 'tr' : 'en');
|
||||
// Cycle through: en -> zh -> tr -> en
|
||||
const nextLocale = locale === 'en' ? 'zh' : locale === 'zh' ? 'tr' : 'en';
|
||||
setAppLocale(nextLocale);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string | null;
|
||||
device_type: string | null;
|
||||
paired_at: string;
|
||||
last_seen: string;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export function useDevices() {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const token = localStorage.getItem('zeroclaw_token') || '';
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/devices', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDevices(data.devices || []);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(`HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [fetchDevices]);
|
||||
|
||||
return { devices, loading, error, refetch: fetchDevices };
|
||||
}
|
||||
@@ -93,6 +93,14 @@ export async function pair(code: string): Promise<{ token: string }> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminPairCode(): Promise<{ pairing_code: string | null; pairing_required: boolean }> {
|
||||
const response = await fetch('/admin/paircode');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch pairing code (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<{ pairing_code: string | null; pairing_required: boolean }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public health (no auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+642
-105
@@ -5,9 +5,308 @@ import { getStatus } from './api';
|
||||
// Translation dictionaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Locale = 'en' | 'tr';
|
||||
export type Locale = 'en' | 'zh' | 'tr';
|
||||
|
||||
const translations: Record<Locale, Record<string, string>> = {
|
||||
zh: {
|
||||
// Navigation
|
||||
'nav.dashboard': '仪表盘',
|
||||
'nav.agent': '智能体',
|
||||
'nav.tools': '工具',
|
||||
'nav.cron': '定时任务',
|
||||
'nav.integrations': '集成',
|
||||
'nav.memory': '记忆',
|
||||
'nav.config': '配置',
|
||||
'nav.cost': '成本追踪',
|
||||
'nav.logs': '日志',
|
||||
'nav.doctor': '诊断',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': '仪表盘',
|
||||
'dashboard.provider': '提供商',
|
||||
'dashboard.model': '模型',
|
||||
'dashboard.uptime': '运行时间',
|
||||
'dashboard.temperature': '温度',
|
||||
'dashboard.gateway_port': '网关端口',
|
||||
'dashboard.memory_backend': '记忆后端',
|
||||
'dashboard.paired': '已配对',
|
||||
'dashboard.channels': '频道',
|
||||
'dashboard.health': '健康状态',
|
||||
'dashboard.status': '状态',
|
||||
'dashboard.overview': '概览',
|
||||
'dashboard.system_info': '系统信息',
|
||||
'dashboard.quick_actions': '快速操作',
|
||||
|
||||
// Agent / Chat
|
||||
'agent.title': '智能体对话',
|
||||
'agent.send': '发送',
|
||||
'agent.placeholder': '输入消息...',
|
||||
'agent.start_conversation': '发送消息开始对话',
|
||||
'agent.type_message': '输入消息...',
|
||||
'agent.connecting': '连接中...',
|
||||
'agent.connected': '已连接',
|
||||
'agent.disconnected': '已断开',
|
||||
'agent.reconnecting': '重新连接中...',
|
||||
'agent.thinking': '思考中...',
|
||||
'agent.tool_call': '工具调用',
|
||||
'agent.tool_result': '工具结果',
|
||||
'agent.connection_error': '连接错误,正在尝试重连...',
|
||||
'agent.tool_call_prefix': '[工具调用]',
|
||||
'agent.tool_result_prefix': '[工具结果]',
|
||||
'agent.error_prefix': '[错误]',
|
||||
'agent.unknown_error': '未知错误',
|
||||
'agent.send_error': '发送消息失败,请重试。',
|
||||
'agent.copy_message': '复制消息',
|
||||
'agent.connected_status': '已连接',
|
||||
'agent.disconnected_status': '已断开',
|
||||
|
||||
// Tools
|
||||
'tools.title': '可用工具',
|
||||
'tools.name': '名称',
|
||||
'tools.description': '描述',
|
||||
'tools.parameters': '参数',
|
||||
'tools.search': '搜索工具...',
|
||||
'tools.empty': '暂无可用工具。',
|
||||
'tools.count': '工具总数',
|
||||
'tools.agent_tools': '智能体工具箱',
|
||||
'tools.cli_tools': 'CLI 工具箱',
|
||||
'tools.parameter_schema': '参数结构',
|
||||
'tools.path': '路径',
|
||||
'tools.version': '版本',
|
||||
'tools.category': '类别',
|
||||
'tools.load_error': '加载工具失败',
|
||||
|
||||
// Cron
|
||||
'cron.title': '定时任务',
|
||||
'cron.scheduled_tasks': '定时任务',
|
||||
'cron.add': '添加任务',
|
||||
'cron.add_job': '添加任务',
|
||||
'cron.add_modal_title': '添加 Cron 任务',
|
||||
'cron.delete': '删除',
|
||||
'cron.enable': '启用',
|
||||
'cron.disable': '禁用',
|
||||
'cron.name': '名称',
|
||||
'cron.name_optional': '名称(可选)',
|
||||
'cron.command': '命令',
|
||||
'cron.command_required': '命令',
|
||||
'cron.schedule': '计划',
|
||||
'cron.schedule_required': '计划',
|
||||
'cron.next_run': '下次执行',
|
||||
'cron.last_run': '上次执行',
|
||||
'cron.last_status': '上次状态',
|
||||
'cron.enabled': '已启用',
|
||||
'cron.enabled_status': '启用',
|
||||
'cron.disabled_status': '禁用',
|
||||
'cron.empty': '暂无定时任务。',
|
||||
'cron.confirm_delete': '确定要删除此任务吗?',
|
||||
'cron.load_error': '加载定时任务失败',
|
||||
'cron.validation_error': '计划和命令是必需的。',
|
||||
'cron.add_error': '添加任务失败',
|
||||
'cron.delete_error': '删除任务失败',
|
||||
'cron.cancel': '取消',
|
||||
'cron.adding': '添加中...',
|
||||
'cron.id': 'ID',
|
||||
'cron.actions': '操作',
|
||||
'cron.loading_run_history': '加载运行历史...',
|
||||
'cron.load_run_history_error': '加载运行历史失败',
|
||||
'cron.no_runs': '暂无运行记录。',
|
||||
'cron.recent_runs': '最近运行',
|
||||
'cron.yes': '是',
|
||||
'cron.no': '否',
|
||||
|
||||
// Integrations
|
||||
'integrations.title': '集成',
|
||||
'integrations.available': '可用',
|
||||
'integrations.active': '活跃',
|
||||
'integrations.coming_soon': '即将推出',
|
||||
'integrations.category': '类别',
|
||||
'integrations.status': '状态',
|
||||
'integrations.search': '搜索集成...',
|
||||
'integrations.empty': '未找到集成。',
|
||||
'integrations.activate': '激活',
|
||||
'integrations.deactivate': '停用',
|
||||
'integrations.load_error': '加载集成失败',
|
||||
'integrations.status_active': '活跃',
|
||||
'integrations.status_available': '可用',
|
||||
'integrations.status_coming_soon': '即将推出',
|
||||
|
||||
// Memory
|
||||
'memory.title': '记忆存储',
|
||||
'memory.memory_title': '记忆',
|
||||
'memory.search': '搜索记忆...',
|
||||
'memory.search_placeholder': '搜索记忆条目...',
|
||||
'memory.add': '存储记忆',
|
||||
'memory.add_memory': '添加记忆',
|
||||
'memory.add_modal_title': '添加记忆',
|
||||
'memory.delete': '删除',
|
||||
'memory.key': '键',
|
||||
'memory.key_required': '键',
|
||||
'memory.content': '内容',
|
||||
'memory.content_required': '内容',
|
||||
'memory.category': '类别',
|
||||
'memory.category_optional': '类别(可选)',
|
||||
'memory.timestamp': '时间戳',
|
||||
'memory.session': '会话',
|
||||
'memory.score': '分数',
|
||||
'memory.empty': '未找到记忆条目。',
|
||||
'memory.confirm_delete': '确定要删除此记忆条目吗?',
|
||||
'memory.all_categories': '所有类别',
|
||||
'memory.search_button': '搜索',
|
||||
'memory.load_error': '加载记忆失败',
|
||||
'memory.saving': '保存中...',
|
||||
'memory.validation_error': '键和内容是必需的。',
|
||||
'memory.store_error': '保存记忆失败',
|
||||
'memory.delete_error': '删除记忆失败',
|
||||
'memory.delete_confirm': '删除?',
|
||||
'memory.yes': '是',
|
||||
'memory.no': '否',
|
||||
'memory.cancel': '取消',
|
||||
|
||||
// Config
|
||||
'config.title': '配置',
|
||||
'config.save': '保存',
|
||||
'config.saving': '保存中...',
|
||||
'config.reset': '重置',
|
||||
'config.saved': '配置保存成功。',
|
||||
'config.error': '配置保存失败。',
|
||||
'config.loading': '加载配置中...',
|
||||
'config.editor_placeholder': 'TOML 配置...',
|
||||
'config.configuration_title': '配置',
|
||||
'config.sensitive_title': '敏感字段已隐藏',
|
||||
'config.sensitive_hint': 'API 密钥、令牌和密码已隐藏以保护安全。要更新已隐藏的字段,请将整个隐藏值替换为您的新值。',
|
||||
'config.save_success': '配置保存成功。',
|
||||
'config.save_error': '保存配置失败',
|
||||
'config.toml_label': 'TOML 配置',
|
||||
'config.lines': '行',
|
||||
|
||||
// Cost
|
||||
'cost.title': '成本追踪',
|
||||
'cost.session': '会话成本',
|
||||
'cost.daily': '每日成本',
|
||||
'cost.monthly': '每月成本',
|
||||
'cost.total_tokens': '总 Tokens',
|
||||
'cost.request_count': '请求数',
|
||||
'cost.by_model': '按模型统计',
|
||||
'cost.model': '模型',
|
||||
'cost.tokens': 'Token',
|
||||
'cost.requests': '请求',
|
||||
'cost.usd': '成本(美元)',
|
||||
'cost.load_error': '加载成本数据失败',
|
||||
'cost.session_cost': '会话成本',
|
||||
'cost.daily_cost': '每日成本',
|
||||
'cost.monthly_cost': '每月成本',
|
||||
'cost.total_requests': '总请求数',
|
||||
'cost.token_statistics': 'Token 统计',
|
||||
'cost.avg_tokens_per_request': '平均 Token / 请求',
|
||||
'cost.cost_per_1k_tokens': '每 1K Token 成本',
|
||||
'cost.model_breakdown': '模型细分',
|
||||
'cost.no_model_data': '没有模型数据可用。',
|
||||
'cost.cost': '成本',
|
||||
'cost.share': '占比',
|
||||
|
||||
// Logs
|
||||
'logs.title': '实时日志',
|
||||
'logs.live_logs': '实时日志',
|
||||
'logs.clear': '清除',
|
||||
'logs.pause': '暂停',
|
||||
'logs.resume': '继续',
|
||||
'logs.filter': '筛选日志...',
|
||||
'logs.filter_label': '筛选',
|
||||
'logs.empty': '暂无日志条目。',
|
||||
'logs.connected': '已连接',
|
||||
'logs.disconnected': '已断开',
|
||||
'logs.events': '事件',
|
||||
'logs.jump_to_bottom': '跳转到底部',
|
||||
'logs.paused_hint': '日志流已暂停。',
|
||||
'logs.waiting_hint': '等待事件...',
|
||||
|
||||
// Doctor
|
||||
'doctor.title': '系统诊断',
|
||||
'doctor.diagnostics_title': '系统诊断',
|
||||
'doctor.run': '运行诊断',
|
||||
'doctor.run_diagnostics': '运行诊断',
|
||||
'doctor.running': '正在运行诊断...',
|
||||
'doctor.running_btn': '运行中...',
|
||||
'doctor.running_desc': '正在运行诊断...',
|
||||
'doctor.running_hint': '这可能需要几秒钟。',
|
||||
'doctor.ok': '正常',
|
||||
'doctor.warn': '警告',
|
||||
'doctor.error': '错误',
|
||||
'doctor.severity': '严重程度',
|
||||
'doctor.category': '类别',
|
||||
'doctor.message': '消息',
|
||||
'doctor.empty': '尚未运行诊断。',
|
||||
'doctor.summary': '诊断摘要',
|
||||
'doctor.issues_found': '发现问题',
|
||||
'doctor.warnings_summary': '警告',
|
||||
'doctor.all_clear': '一切正常',
|
||||
'doctor.system_diagnostics': '系统诊断',
|
||||
'doctor.empty_hint': '点击"运行诊断"检查您的 ZeroClaw 安装。',
|
||||
|
||||
// Auth / Pairing
|
||||
'auth.pair': '配对设备',
|
||||
'auth.pairing_code': '配对码',
|
||||
'auth.pair_button': '配对',
|
||||
'auth.logout': '退出',
|
||||
'auth.pairing_success': '配对成功!',
|
||||
'auth.pairing_failed': '配对失败,请重试。',
|
||||
'auth.enter_code': '请输入配对码以连接到智能体。',
|
||||
|
||||
// Common
|
||||
'common.loading': '加载中...',
|
||||
'common.error': '发生错误。',
|
||||
'common.retry': '重试',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确认',
|
||||
'common.save': '保存',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.close': '关闭',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.search': '搜索...',
|
||||
'common.no_data': '暂无数据。',
|
||||
'common.refresh': '刷新',
|
||||
'common.back': '返回',
|
||||
'common.actions': '操作',
|
||||
'common.name': '名称',
|
||||
'common.description': '描述',
|
||||
'common.status': '状态',
|
||||
'common.created': '创建时间',
|
||||
'common.updated': '更新时间',
|
||||
|
||||
// Health
|
||||
'health.title': '系统健康',
|
||||
'health.component': '组件',
|
||||
'health.status': '状态',
|
||||
'health.last_ok': '上次正常',
|
||||
'health.last_error': '上次错误',
|
||||
'health.restart_count': '重启次数',
|
||||
'health.pid': '进程 ID',
|
||||
'health.uptime': '运行时间',
|
||||
'health.updated_at': '最后更新',
|
||||
|
||||
// Dashboard specific labels
|
||||
'dashboard.provider_model': '提供商 / 模型',
|
||||
'dashboard.since_last_restart': '自上次重启',
|
||||
'dashboard.paired_yes': '是',
|
||||
'dashboard.paired_no': '否',
|
||||
'dashboard.cost_overview': '成本概览',
|
||||
'dashboard.active_channels': '活跃频道',
|
||||
'dashboard.component_health': '组件健康',
|
||||
'dashboard.load_error': '加载仪表盘失败',
|
||||
'dashboard.session_label': '会话',
|
||||
'dashboard.daily_label': '每日',
|
||||
'dashboard.monthly_label': '每月',
|
||||
'dashboard.total_tokens_label': '总 Tokens',
|
||||
'dashboard.requests_label': '请求',
|
||||
'dashboard.no_channels': '未配置频道',
|
||||
'dashboard.active': '活跃',
|
||||
'dashboard.inactive': '非活跃',
|
||||
'dashboard.no_components': '没有组件报告',
|
||||
'dashboard.restarts': '重启次数',
|
||||
},
|
||||
|
||||
en: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Dashboard',
|
||||
@@ -28,7 +327,6 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'dashboard.uptime': 'Uptime',
|
||||
'dashboard.temperature': 'Temperature',
|
||||
'dashboard.gateway_port': 'Gateway Port',
|
||||
'dashboard.locale': 'Locale',
|
||||
'dashboard.memory_backend': 'Memory Backend',
|
||||
'dashboard.paired': 'Paired',
|
||||
'dashboard.channels': 'Channels',
|
||||
@@ -42,6 +340,8 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'agent.title': 'Agent Chat',
|
||||
'agent.send': 'Send',
|
||||
'agent.placeholder': 'Type a message...',
|
||||
'agent.start_conversation': 'Send a message to start the conversation',
|
||||
'agent.type_message': 'Type a message...',
|
||||
'agent.connecting': 'Connecting...',
|
||||
'agent.connected': 'Connected',
|
||||
'agent.disconnected': 'Disconnected',
|
||||
@@ -49,6 +349,15 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'agent.thinking': 'Thinking...',
|
||||
'agent.tool_call': 'Tool Call',
|
||||
'agent.tool_result': 'Tool Result',
|
||||
'agent.connection_error': 'Connection error. Attempting to reconnect...',
|
||||
'agent.tool_call_prefix': '[Tool Call]',
|
||||
'agent.tool_result_prefix': '[Tool Result]',
|
||||
'agent.error_prefix': '[Error]',
|
||||
'agent.unknown_error': 'Unknown error',
|
||||
'agent.send_error': 'Failed to send message. Please try again.',
|
||||
'agent.copy_message': 'Copy message',
|
||||
'agent.connected_status': 'Connected',
|
||||
'agent.disconnected_status': 'Disconnected',
|
||||
|
||||
// Tools
|
||||
'tools.title': 'Available Tools',
|
||||
@@ -58,22 +367,51 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'tools.search': 'Search tools...',
|
||||
'tools.empty': 'No tools available.',
|
||||
'tools.count': 'Total tools',
|
||||
'tools.agent_tools': 'Agent Tools',
|
||||
'tools.cli_tools': 'CLI Tools',
|
||||
'tools.parameter_schema': 'Parameter Schema',
|
||||
'tools.path': 'Path',
|
||||
'tools.version': 'Version',
|
||||
'tools.category': 'Category',
|
||||
'tools.load_error': 'Failed to load tools',
|
||||
|
||||
// Cron
|
||||
'cron.title': 'Scheduled Jobs',
|
||||
'cron.scheduled_tasks': 'Scheduled Tasks',
|
||||
'cron.add': 'Add Job',
|
||||
'cron.add_job': 'Add Job',
|
||||
'cron.add_modal_title': 'Add Cron Job',
|
||||
'cron.delete': 'Delete',
|
||||
'cron.enable': 'Enable',
|
||||
'cron.disable': 'Disable',
|
||||
'cron.name': 'Name',
|
||||
'cron.name_optional': 'Name (optional)',
|
||||
'cron.command': 'Command',
|
||||
'cron.command_required': 'Command',
|
||||
'cron.schedule': 'Schedule',
|
||||
'cron.schedule_required': 'Schedule',
|
||||
'cron.next_run': 'Next Run',
|
||||
'cron.last_run': 'Last Run',
|
||||
'cron.last_status': 'Last Status',
|
||||
'cron.enabled': 'Enabled',
|
||||
'cron.enabled_status': 'Enabled',
|
||||
'cron.disabled_status': 'Disabled',
|
||||
'cron.empty': 'No scheduled jobs.',
|
||||
'cron.confirm_delete': 'Are you sure you want to delete this job?',
|
||||
'cron.load_error': 'Failed to load cron jobs',
|
||||
'cron.validation_error': 'Schedule and command are required.',
|
||||
'cron.add_error': 'Failed to add job',
|
||||
'cron.delete_error': 'Failed to delete job',
|
||||
'cron.cancel': 'Cancel',
|
||||
'cron.adding': 'Adding...',
|
||||
'cron.id': 'ID',
|
||||
'cron.actions': 'Actions',
|
||||
'cron.loading_run_history': 'Loading run history...',
|
||||
'cron.load_run_history_error': 'Failed to load run history',
|
||||
'cron.no_runs': 'No runs recorded yet.',
|
||||
'cron.recent_runs': 'Recent Runs',
|
||||
'cron.yes': 'Yes',
|
||||
'cron.no': 'No',
|
||||
|
||||
// Integrations
|
||||
'integrations.title': 'Integrations',
|
||||
@@ -86,30 +424,59 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'integrations.empty': 'No integrations found.',
|
||||
'integrations.activate': 'Activate',
|
||||
'integrations.deactivate': 'Deactivate',
|
||||
'integrations.load_error': 'Failed to load integrations',
|
||||
'integrations.status_active': 'Active',
|
||||
'integrations.status_available': 'Available',
|
||||
'integrations.status_coming_soon': 'Coming Soon',
|
||||
|
||||
// Memory
|
||||
'memory.title': 'Memory Store',
|
||||
'memory.memory_title': 'Memory',
|
||||
'memory.search': 'Search memory...',
|
||||
'memory.search_placeholder': 'Search memory entries...',
|
||||
'memory.add': 'Store Memory',
|
||||
'memory.add_memory': 'Add Memory',
|
||||
'memory.add_modal_title': 'Add Memory',
|
||||
'memory.delete': 'Delete',
|
||||
'memory.key': 'Key',
|
||||
'memory.key_required': 'Key',
|
||||
'memory.content': 'Content',
|
||||
'memory.content_required': 'Content',
|
||||
'memory.category': 'Category',
|
||||
'memory.category_optional': 'Category (optional)',
|
||||
'memory.timestamp': 'Timestamp',
|
||||
'memory.session': 'Session',
|
||||
'memory.score': 'Score',
|
||||
'memory.empty': 'No memory entries found.',
|
||||
'memory.confirm_delete': 'Are you sure you want to delete this memory entry?',
|
||||
'memory.all_categories': 'All Categories',
|
||||
'memory.search_button': 'Search',
|
||||
'memory.load_error': 'Failed to load memory',
|
||||
'memory.saving': 'Saving...',
|
||||
'memory.validation_error': 'Key and content are required.',
|
||||
'memory.store_error': 'Failed to store memory',
|
||||
'memory.delete_error': 'Failed to delete memory',
|
||||
'memory.delete_confirm': 'Delete?',
|
||||
'memory.yes': 'Yes',
|
||||
'memory.no': 'No',
|
||||
'memory.cancel': 'Cancel',
|
||||
|
||||
// Config
|
||||
'config.title': 'Configuration',
|
||||
'config.save': 'Save',
|
||||
'config.saving': 'Saving...',
|
||||
'config.reset': 'Reset',
|
||||
'config.saved': 'Configuration saved successfully.',
|
||||
'config.error': 'Failed to save configuration.',
|
||||
'config.loading': 'Loading configuration...',
|
||||
'config.editor_placeholder': 'TOML configuration...',
|
||||
'config.configuration_title': 'Configuration',
|
||||
'config.sensitive_title': 'Sensitive fields are masked',
|
||||
'config.sensitive_hint': 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.',
|
||||
'config.save_success': 'Configuration saved successfully.',
|
||||
'config.save_error': 'Failed to save configuration',
|
||||
'config.toml_label': 'TOML Configuration',
|
||||
'config.lines': 'lines',
|
||||
|
||||
// Cost
|
||||
'cost.title': 'Cost Tracker',
|
||||
@@ -123,21 +490,44 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'cost.tokens': 'Tokens',
|
||||
'cost.requests': 'Requests',
|
||||
'cost.usd': 'Cost (USD)',
|
||||
'cost.load_error': 'Failed to load cost data',
|
||||
'cost.session_cost': 'Session Cost',
|
||||
'cost.daily_cost': 'Daily Cost',
|
||||
'cost.monthly_cost': 'Monthly Cost',
|
||||
'cost.total_requests': 'Total Requests',
|
||||
'cost.token_statistics': 'Token Statistics',
|
||||
'cost.avg_tokens_per_request': 'Avg Tokens / Request',
|
||||
'cost.cost_per_1k_tokens': 'Cost per 1K Tokens',
|
||||
'cost.model_breakdown': 'Model Breakdown',
|
||||
'cost.no_model_data': 'No model data available.',
|
||||
'cost.cost': 'Cost',
|
||||
'cost.share': 'Share',
|
||||
|
||||
// Logs
|
||||
'logs.title': 'Live Logs',
|
||||
'logs.live_logs': 'Live Logs',
|
||||
'logs.clear': 'Clear',
|
||||
'logs.pause': 'Pause',
|
||||
'logs.resume': 'Resume',
|
||||
'logs.filter': 'Filter logs...',
|
||||
'logs.filter_label': 'Filter',
|
||||
'logs.empty': 'No log entries.',
|
||||
'logs.connected': 'Connected to event stream.',
|
||||
'logs.disconnected': 'Disconnected from event stream.',
|
||||
'logs.connected': 'Connected',
|
||||
'logs.disconnected': 'Disconnected',
|
||||
'logs.events': 'events',
|
||||
'logs.jump_to_bottom': 'Jump to bottom',
|
||||
'logs.paused_hint': 'Log streaming is paused.',
|
||||
'logs.waiting_hint': 'Waiting for events...',
|
||||
|
||||
// Doctor
|
||||
'doctor.title': 'System Diagnostics',
|
||||
'doctor.diagnostics_title': 'Diagnostics',
|
||||
'doctor.run': 'Run Diagnostics',
|
||||
'doctor.run_diagnostics': 'Run Diagnostics',
|
||||
'doctor.running': 'Running diagnostics...',
|
||||
'doctor.running_btn': 'Running...',
|
||||
'doctor.running_desc': 'Running diagnostics...',
|
||||
'doctor.running_hint': 'This may take a few seconds.',
|
||||
'doctor.ok': 'OK',
|
||||
'doctor.warn': 'Warning',
|
||||
'doctor.error': 'Error',
|
||||
@@ -146,6 +536,11 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'doctor.message': 'Message',
|
||||
'doctor.empty': 'No diagnostics have been run yet.',
|
||||
'doctor.summary': 'Diagnostic Summary',
|
||||
'doctor.issues_found': 'Issues Found',
|
||||
'doctor.warnings_summary': 'Warnings',
|
||||
'doctor.all_clear': 'All Clear',
|
||||
'doctor.system_diagnostics': 'System Diagnostics',
|
||||
'doctor.empty_hint': 'Click "Run Diagnostics" to check your ZeroClaw installation.',
|
||||
|
||||
// Auth / Pairing
|
||||
'auth.pair': 'Pair Device',
|
||||
@@ -189,189 +584,325 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'health.pid': 'Process ID',
|
||||
'health.uptime': 'Uptime',
|
||||
'health.updated_at': 'Last Updated',
|
||||
|
||||
// Dashboard specific labels
|
||||
'dashboard.provider_model': 'Provider / Model',
|
||||
'dashboard.since_last_restart': 'Since last restart',
|
||||
'dashboard.paired_yes': 'Yes',
|
||||
'dashboard.paired_no': 'No',
|
||||
'dashboard.cost_overview': 'Cost Overview',
|
||||
'dashboard.active_channels': 'Active Channels',
|
||||
'dashboard.component_health': 'Component Health',
|
||||
'dashboard.load_error': 'Failed to load dashboard',
|
||||
'dashboard.session_label': 'Session',
|
||||
'dashboard.daily_label': 'Daily',
|
||||
'dashboard.monthly_label': 'Monthly',
|
||||
'dashboard.total_tokens_label': 'Total Tokens',
|
||||
'dashboard.requests_label': 'Requests',
|
||||
'dashboard.no_channels': 'No channels configured',
|
||||
'dashboard.active': 'Active',
|
||||
'dashboard.inactive': 'Inactive',
|
||||
'dashboard.no_components': 'No components reporting',
|
||||
'dashboard.restarts': 'Restarts',
|
||||
},
|
||||
|
||||
tr: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Kontrol Paneli',
|
||||
'nav.agent': 'Ajan',
|
||||
'nav.tools': 'Araclar',
|
||||
'nav.cron': 'Zamanlanmis Gorevler',
|
||||
'nav.tools': 'Araçlar',
|
||||
'nav.cron': 'Zamanlanmış Görevler',
|
||||
'nav.integrations': 'Entegrasyonlar',
|
||||
'nav.memory': 'Hafiza',
|
||||
'nav.config': 'Yapilandirma',
|
||||
'nav.memory': 'Hafıza',
|
||||
'nav.config': 'Yapılandırma',
|
||||
'nav.cost': 'Maliyet Takibi',
|
||||
'nav.logs': 'Kayitlar',
|
||||
'nav.logs': 'Kayıtlar',
|
||||
'nav.doctor': 'Doktor',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Kontrol Paneli',
|
||||
'dashboard.provider': 'Saglayici',
|
||||
'dashboard.provider': 'Sağlayıcı',
|
||||
'dashboard.model': 'Model',
|
||||
'dashboard.uptime': 'Calisma Suresi',
|
||||
'dashboard.temperature': 'Sicaklik',
|
||||
'dashboard.gateway_port': 'Gecit Portu',
|
||||
'dashboard.locale': 'Yerel Ayar',
|
||||
'dashboard.memory_backend': 'Hafiza Motoru',
|
||||
'dashboard.paired': 'Eslestirilmis',
|
||||
'dashboard.uptime': 'Çalışma Süresi',
|
||||
'dashboard.temperature': 'Sıcaklık',
|
||||
'dashboard.gateway_port': 'Ağ Geçidi Portu',
|
||||
'dashboard.locale': 'Dil',
|
||||
'dashboard.memory_backend': 'Hafıza Motoru',
|
||||
'dashboard.paired': 'Eşleştirilmiş',
|
||||
'dashboard.channels': 'Kanallar',
|
||||
'dashboard.health': 'Saglik',
|
||||
'dashboard.health': 'Sağlık',
|
||||
'dashboard.status': 'Durum',
|
||||
'dashboard.overview': 'Genel Bakis',
|
||||
'dashboard.overview': 'Genel Bakış',
|
||||
'dashboard.system_info': 'Sistem Bilgisi',
|
||||
'dashboard.quick_actions': 'Hizli Islemler',
|
||||
'dashboard.quick_actions': 'Hızlı İşlemler',
|
||||
'dashboard.provider_model': 'Sağlayıcı / Model',
|
||||
'dashboard.since_last_restart': 'Son Yeniden Başlatmadan Beri',
|
||||
'dashboard.paired_yes': 'Evet',
|
||||
'dashboard.paired_no': 'Hayır',
|
||||
'dashboard.cost_overview': 'Maliyet Genel Bakışı',
|
||||
'dashboard.active_channels': 'Aktif Kanallar',
|
||||
'dashboard.component_health': 'Bileşen Sağlığı',
|
||||
'dashboard.load_error': 'Kontrol paneli yüklenemedi',
|
||||
'dashboard.session_label': 'Oturum',
|
||||
'dashboard.daily_label': 'Günlük',
|
||||
'dashboard.monthly_label': 'Aylık',
|
||||
'dashboard.total_tokens_label': 'Toplam Token',
|
||||
'dashboard.requests_label': 'İstekler',
|
||||
'dashboard.no_channels': 'Kanal yapılandırılmamış',
|
||||
'dashboard.active': 'Aktif',
|
||||
'dashboard.inactive': 'Aktif Değil',
|
||||
'dashboard.no_components': 'Bileşen raporlamıyor',
|
||||
'dashboard.restarts': 'Yeniden Başlatmalar',
|
||||
|
||||
// Agent / Chat
|
||||
'agent.title': 'Ajan Sohbet',
|
||||
'agent.send': 'Gonder',
|
||||
'agent.placeholder': 'Bir mesaj yazin...',
|
||||
'agent.connecting': 'Baglaniyor...',
|
||||
'agent.connected': 'Bagli',
|
||||
'agent.disconnected': 'Baglanti Kesildi',
|
||||
'agent.reconnecting': 'Yeniden Baglaniyor...',
|
||||
'agent.thinking': 'Dusunuyor...',
|
||||
'agent.tool_call': 'Arac Cagrisi',
|
||||
'agent.tool_result': 'Arac Sonucu',
|
||||
'agent.title': 'Ajan Sohbeti',
|
||||
'agent.send': 'Gönder',
|
||||
'agent.placeholder': 'Bir mesaj yazın...',
|
||||
'agent.start_conversation': 'Sohbeti başlatmak için mesaj gönderin',
|
||||
'agent.type_message': 'Bir mesaj yazın...',
|
||||
'agent.connecting': 'Bağlanıyor...',
|
||||
'agent.connected': 'Bağlandı',
|
||||
'agent.disconnected': 'Bağlantı kesildi',
|
||||
'agent.reconnecting': 'Yeniden bağlanıyor...',
|
||||
'agent.thinking': 'Düşünüyor...',
|
||||
'agent.tool_call': 'Araç Çağrısı',
|
||||
'agent.tool_result': 'Araç Sonucu',
|
||||
'agent.connection_error': 'Bağlantı hatası. Yeniden bağlanmaya çalışılıyor...',
|
||||
'agent.tool_call_prefix': '[Araç Çağrısı]',
|
||||
'agent.tool_result_prefix': '[Araç Sonucu]',
|
||||
'agent.error_prefix': '[Hata]',
|
||||
'agent.unknown_error': 'Bilinmeyen hata',
|
||||
'agent.send_error': 'Mesaj gönderilemedi. Lütfen tekrar deneyin.',
|
||||
'agent.copy_message': 'Mesajı kopyala',
|
||||
'agent.connected_status': 'Bağlandı',
|
||||
'agent.disconnected_status': 'Bağlantı kesildi',
|
||||
|
||||
// Tools
|
||||
'tools.title': 'Mevcut Araclar',
|
||||
'tools.title': 'Mevcut Araçlar',
|
||||
'tools.name': 'Ad',
|
||||
'tools.description': 'Aciklama',
|
||||
'tools.description': 'Açıklama',
|
||||
'tools.parameters': 'Parametreler',
|
||||
'tools.search': 'Arac ara...',
|
||||
'tools.empty': 'Mevcut arac yok.',
|
||||
'tools.count': 'Toplam arac',
|
||||
'tools.search': 'Araç ara...',
|
||||
'tools.empty': 'Araç bulunamadı.',
|
||||
'tools.count': 'Toplam araç',
|
||||
'tools.agent_tools': 'Ajan Araçları',
|
||||
'tools.cli_tools': 'CLI Araçları',
|
||||
'tools.parameter_schema': 'Parametre Şeması',
|
||||
'tools.path': 'Yol',
|
||||
'tools.version': 'Sürüm',
|
||||
'tools.category': 'Kategori',
|
||||
'tools.load_error': 'Araçlar yüklenemedi',
|
||||
|
||||
// Cron
|
||||
'cron.title': 'Zamanlanmis Gorevler',
|
||||
'cron.add': 'Gorev Ekle',
|
||||
'cron.title': 'Zamanlanmış Görevler',
|
||||
'cron.scheduled_tasks': 'Zamanlanmış Görevler',
|
||||
'cron.add': 'Görev Ekle',
|
||||
'cron.add_job': 'Görev Ekle',
|
||||
'cron.add_modal_title': 'Cron Görevi Ekle',
|
||||
'cron.delete': 'Sil',
|
||||
'cron.enable': 'Etkinlestir',
|
||||
'cron.disable': 'Devre Disi Birak',
|
||||
'cron.enable': 'Etkinleştir',
|
||||
'cron.disable': 'Devre Dışı Bırak',
|
||||
'cron.name': 'Ad',
|
||||
'cron.name_optional': 'Ad (isteğe bağlı)',
|
||||
'cron.command': 'Komut',
|
||||
'cron.command_required': 'Komut',
|
||||
'cron.schedule': 'Zamanlama',
|
||||
'cron.next_run': 'Sonraki Calistirma',
|
||||
'cron.last_run': 'Son Calistirma',
|
||||
'cron.schedule_required': 'Zamanlama',
|
||||
'cron.next_run': 'Sonraki Çalıştırma',
|
||||
'cron.last_run': 'Son Çalıştırma',
|
||||
'cron.last_status': 'Son Durum',
|
||||
'cron.enabled': 'Etkin',
|
||||
'cron.empty': 'Zamanlanmis gorev yok.',
|
||||
'cron.confirm_delete': 'Bu gorevi silmek istediginizden emin misiniz?',
|
||||
'cron.enabled_status': 'Etkin',
|
||||
'cron.disabled_status': 'Devre Dışı',
|
||||
'cron.empty': 'Zamanlanmış görev bulunamadı.',
|
||||
'cron.confirm_delete': 'Bu görevi silmek istediğinizden emin misiniz?',
|
||||
'cron.load_error': 'Cron görevleri yüklenemedi',
|
||||
'cron.validation_error': 'Zamanlama ve komut gereklidir.',
|
||||
'cron.add_error': 'Görev eklenemedi',
|
||||
'cron.delete_error': 'Görev silinemedi',
|
||||
'cron.cancel': 'İptal',
|
||||
'cron.adding': 'Ekleniyor...',
|
||||
'cron.id': 'ID',
|
||||
'cron.actions': 'İşlemler',
|
||||
'cron.loading_run_history': 'Çalıştırma geçmişi yükleniyor...',
|
||||
'cron.load_run_history_error': 'Çalıştırma geçmişi yüklenemedi',
|
||||
'cron.no_runs': 'Henüz çalıştırma kaydı yok.',
|
||||
'cron.recent_runs': 'Son Çalıştırmalar',
|
||||
'cron.yes': 'Evet',
|
||||
'cron.no': 'Hayır',
|
||||
|
||||
// Integrations
|
||||
'integrations.title': 'Entegrasyonlar',
|
||||
'integrations.available': 'Mevcut',
|
||||
'integrations.active': 'Aktif',
|
||||
'integrations.coming_soon': 'Yakinda',
|
||||
'integrations.coming_soon': 'Yakında',
|
||||
'integrations.category': 'Kategori',
|
||||
'integrations.status': 'Durum',
|
||||
'integrations.search': 'Entegrasyon ara...',
|
||||
'integrations.empty': 'Entegrasyon bulunamadi.',
|
||||
'integrations.activate': 'Etkinlestir',
|
||||
'integrations.deactivate': 'Devre Disi Birak',
|
||||
'integrations.empty': 'Entegrasyon bulunamadı.',
|
||||
'integrations.activate': 'Etkinleştir',
|
||||
'integrations.deactivate': 'Devre Dışı Bırak',
|
||||
'integrations.load_error': 'Entegrasyonlar yüklenemedi',
|
||||
'integrations.all': 'Tümü',
|
||||
'integrations.status_active': 'Aktif',
|
||||
'integrations.status_available': 'Mevcut',
|
||||
'integrations.status_coming_soon': 'Yakında',
|
||||
|
||||
// Memory
|
||||
'memory.title': 'Hafiza Deposu',
|
||||
'memory.search': 'Hafizada ara...',
|
||||
'memory.add': 'Hafiza Kaydet',
|
||||
'memory.title': 'Hafıza Deposu',
|
||||
'memory.memory_title': 'Hafıza',
|
||||
'memory.search': 'Hafıza ara...',
|
||||
'memory.search_placeholder': 'Hafıza girişleri ara...',
|
||||
'memory.add': 'Hafıza Ekle',
|
||||
'memory.add_memory': 'Hafıza Ekle',
|
||||
'memory.add_modal_title': 'Hafıza Ekle',
|
||||
'memory.delete': 'Sil',
|
||||
'memory.key': 'Anahtar',
|
||||
'memory.content': 'Icerik',
|
||||
'memory.key_required': 'Anahtar',
|
||||
'memory.content': 'İçerik',
|
||||
'memory.content_required': 'İçerik',
|
||||
'memory.category': 'Kategori',
|
||||
'memory.timestamp': 'Zaman Damgasi',
|
||||
'memory.category_optional': 'Kategori (isteğe bağlı)',
|
||||
'memory.timestamp': 'Zaman Damgası',
|
||||
'memory.session': 'Oturum',
|
||||
'memory.score': 'Skor',
|
||||
'memory.empty': 'Hafiza kaydi bulunamadi.',
|
||||
'memory.confirm_delete': 'Bu hafiza kaydini silmek istediginizden emin misiniz?',
|
||||
'memory.all_categories': 'Tum Kategoriler',
|
||||
'memory.score': 'Puan',
|
||||
'memory.empty': 'Hafıza girişi bulunamadı.',
|
||||
'memory.confirm_delete': 'Bu hafıza girişini silmek istediğinizden emin misiniz?',
|
||||
'memory.all_categories': 'Tüm Kategoriler',
|
||||
'memory.search_button': 'Ara',
|
||||
'memory.load_error': 'Hafıza yüklenemedi',
|
||||
'memory.saving': 'Kaydediliyor...',
|
||||
'memory.validation_error': 'Anahtar ve içerik gereklidir.',
|
||||
'memory.store_error': 'Hafıza kaydedilemedi',
|
||||
'memory.delete_error': 'Hafıza silinemedi',
|
||||
'memory.delete_confirm': 'Sil?',
|
||||
'memory.yes': 'Evet',
|
||||
'memory.no': 'Hayır',
|
||||
'memory.cancel': 'İptal',
|
||||
|
||||
// Config
|
||||
'config.title': 'Yapilandirma',
|
||||
'config.title': 'Yapılandırma',
|
||||
'config.save': 'Kaydet',
|
||||
'config.reset': 'Sifirla',
|
||||
'config.saved': 'Yapilandirma basariyla kaydedildi.',
|
||||
'config.error': 'Yapilandirma kaydedilemedi.',
|
||||
'config.loading': 'Yapilandirma yukleniyor...',
|
||||
'config.editor_placeholder': 'TOML yapilandirmasi...',
|
||||
'config.saving': 'Kaydediliyor...',
|
||||
'config.reset': 'Sıfırla',
|
||||
'config.saved': 'Yapılandırma başarıyla kaydedildi.',
|
||||
'config.error': 'Yapılandırma kaydedilemedi.',
|
||||
'config.loading': 'Yapılandırma yükleniyor...',
|
||||
'config.editor_placeholder': 'TOML yapılandırması...',
|
||||
'config.configuration_title': 'Yapılandırma',
|
||||
'config.sensitive_title': 'Hassas alanlar gizlendi',
|
||||
'config.sensitive_hint': 'API anahtarları, belirteçler ve parolalar güvenlik için gizlendi. Maskeli bir alanı güncellemek için, tüm maskeli değeri yeni değerinizle değiştirin.',
|
||||
'config.save_success': 'Yapılandırma başarıyla kaydedildi.',
|
||||
'config.save_error': 'Yapılandırma kaydedilemedi',
|
||||
'config.toml_label': 'TOML Yapılandırması',
|
||||
'config.lines': 'satır',
|
||||
|
||||
// Cost
|
||||
'cost.title': 'Maliyet Takibi',
|
||||
'cost.session': 'Oturum Maliyeti',
|
||||
'cost.daily': 'Gunluk Maliyet',
|
||||
'cost.monthly': 'Aylik Maliyet',
|
||||
'cost.daily': 'Günlük Maliyet',
|
||||
'cost.monthly': 'Aylık Maliyet',
|
||||
'cost.total_tokens': 'Toplam Token',
|
||||
'cost.request_count': 'Istekler',
|
||||
'cost.by_model': 'Modele Gore Maliyet',
|
||||
'cost.request_count': 'İstek Sayısı',
|
||||
'cost.by_model': 'Modele Göre Maliyet',
|
||||
'cost.model': 'Model',
|
||||
'cost.tokens': 'Token',
|
||||
'cost.requests': 'Istekler',
|
||||
'cost.requests': 'İstekler',
|
||||
'cost.usd': 'Maliyet (USD)',
|
||||
'cost.load_error': 'Maliyet verileri yüklenemedi',
|
||||
'cost.session_cost': 'Oturum Maliyeti',
|
||||
'cost.daily_cost': 'Günlük Maliyet',
|
||||
'cost.monthly_cost': 'Aylık Maliyet',
|
||||
'cost.total_requests': 'Toplam İstek',
|
||||
'cost.token_statistics': 'Token İstatistikleri',
|
||||
'cost.avg_tokens_per_request': 'Ortalama Token / İstek',
|
||||
'cost.cost_per_1k_tokens': '1K Token Başına Maliyet',
|
||||
'cost.model_breakdown': 'Model Detayı',
|
||||
'cost.no_model_data': 'Model verisi mevcut değil.',
|
||||
'cost.cost': 'Maliyet',
|
||||
'cost.share': 'Pay',
|
||||
|
||||
// Logs
|
||||
'logs.title': 'Canli Kayitlar',
|
||||
'logs.title': 'Canlı Kayıtlar',
|
||||
'logs.live_logs': 'Canlı Kayıtlar',
|
||||
'logs.clear': 'Temizle',
|
||||
'logs.pause': 'Duraklat',
|
||||
'logs.resume': 'Devam Et',
|
||||
'logs.filter': 'Kayitlari filtrele...',
|
||||
'logs.empty': 'Kayit girisi yok.',
|
||||
'logs.connected': 'Olay akisina baglandi.',
|
||||
'logs.disconnected': 'Olay akisi baglantisi kesildi.',
|
||||
'logs.filter': 'Kayıtları filtrele...',
|
||||
'logs.filter_label': 'Filtre',
|
||||
'logs.empty': 'Kayıt girişi bulunamadı.',
|
||||
'logs.connected': 'Bağlandı',
|
||||
'logs.disconnected': 'Bağlantı kesildi',
|
||||
'logs.events': 'olay',
|
||||
'logs.jump_to_bottom': 'En alta atla',
|
||||
'logs.paused_hint': 'Kayıt akışı duraklatıldı.',
|
||||
'logs.waiting_hint': 'Olay bekleniyor...',
|
||||
|
||||
// Doctor
|
||||
'doctor.title': 'Sistem Teshisleri',
|
||||
'doctor.run': 'Teshis Calistir',
|
||||
'doctor.running': 'Teshisler calistiriliyor...',
|
||||
'doctor.title': 'Sistem Tanıları',
|
||||
'doctor.diagnostics_title': 'Tanılar',
|
||||
'doctor.run': 'Tanı Çalıştır',
|
||||
'doctor.run_diagnostics': 'Tanı Çalıştır',
|
||||
'doctor.running': 'Tanı çalıştırılıyor...',
|
||||
'doctor.running_btn': 'Çalıştırılıyor...',
|
||||
'doctor.running_desc': 'Tanı çalıştırılıyor...',
|
||||
'doctor.running_hint': 'Bu birkaç saniye sürebilir.',
|
||||
'doctor.ok': 'Tamam',
|
||||
'doctor.warn': 'Uyari',
|
||||
'doctor.warn': 'Uyarı',
|
||||
'doctor.error': 'Hata',
|
||||
'doctor.severity': 'Ciddiyet',
|
||||
'doctor.severity': 'Şiddet',
|
||||
'doctor.category': 'Kategori',
|
||||
'doctor.message': 'Mesaj',
|
||||
'doctor.empty': 'Henuz teshis calistirilmadi.',
|
||||
'doctor.summary': 'Teshis Ozeti',
|
||||
'doctor.empty': 'Henüz tanı çalıştırılmadı.',
|
||||
'doctor.summary': 'Tanı Özeti',
|
||||
'doctor.issues_found': 'Sorunlar Bulundu',
|
||||
'doctor.warnings_summary': 'Uyarılar',
|
||||
'doctor.all_clear': 'Her Şey Yolunda',
|
||||
'doctor.system_diagnostics': 'Sistem Tanıları',
|
||||
'doctor.empty_hint': 'ZeroClaw kurulumunuzu kontrol etmek için "Tanı Çalıştır" düğmesine tıklayın.',
|
||||
|
||||
// Auth / Pairing
|
||||
'auth.pair': 'Cihaz Esle',
|
||||
'auth.pairing_code': 'Eslestirme Kodu',
|
||||
'auth.pair_button': 'Esle',
|
||||
'auth.logout': 'Cikis Yap',
|
||||
'auth.pairing_success': 'Eslestirme basarili!',
|
||||
'auth.pairing_failed': 'Eslestirme basarisiz. Lutfen tekrar deneyin.',
|
||||
'auth.enter_code': 'Ajana baglanmak icin eslestirme kodunuzu girin.',
|
||||
'auth.pair': 'Cihaz Eşleştir',
|
||||
'auth.pairing_code': 'Eşleştirme Kodu',
|
||||
'auth.pair_button': 'Eşleştir',
|
||||
'auth.logout': 'Çıkış Yap',
|
||||
'auth.pairing_success': 'Eşleştirme başarılı!',
|
||||
'auth.pairing_failed': 'Eşleştirme başarısız. Lütfen tekrar deneyin.',
|
||||
'auth.enter_code': 'Akıllı birine bağlanmak için eşleştirme kodunuzu girin.',
|
||||
|
||||
// Common
|
||||
'common.loading': 'Yukleniyor...',
|
||||
'common.error': 'Bir hata olustu.',
|
||||
'common.loading': 'Yükleniyor...',
|
||||
'common.error': 'Bir hata oluştu.',
|
||||
'common.retry': 'Tekrar Dene',
|
||||
'common.cancel': 'Iptal',
|
||||
'common.cancel': 'İptal',
|
||||
'common.confirm': 'Onayla',
|
||||
'common.save': 'Kaydet',
|
||||
'common.delete': 'Sil',
|
||||
'common.edit': 'Duzenle',
|
||||
'common.edit': 'Düzenle',
|
||||
'common.close': 'Kapat',
|
||||
'common.yes': 'Evet',
|
||||
'common.no': 'Hayir',
|
||||
'common.no': 'Hayır',
|
||||
'common.search': 'Ara...',
|
||||
'common.no_data': 'Veri mevcut degil.',
|
||||
'common.no_data': 'Veri mevcut değil.',
|
||||
'common.refresh': 'Yenile',
|
||||
'common.back': 'Geri',
|
||||
'common.actions': 'Islemler',
|
||||
'common.actions': 'İşlemler',
|
||||
'common.name': 'Ad',
|
||||
'common.description': 'Aciklama',
|
||||
'common.description': 'Açıklama',
|
||||
'common.status': 'Durum',
|
||||
'common.created': 'Olusturulma',
|
||||
'common.updated': 'Guncellenme',
|
||||
'common.created': 'Oluşturulma',
|
||||
'common.updated': 'Güncellenme',
|
||||
|
||||
// Health
|
||||
'health.title': 'Sistem Sagligi',
|
||||
'health.component': 'Bilesen',
|
||||
'health.title': 'Sistem Sağlığı',
|
||||
'health.component': 'Bileşen',
|
||||
'health.status': 'Durum',
|
||||
'health.last_ok': 'Son Basarili',
|
||||
'health.last_ok': 'Son Başarılı',
|
||||
'health.last_error': 'Son Hata',
|
||||
'health.restart_count': 'Yeniden Baslatmalar',
|
||||
'health.pid': 'Islem Kimligi',
|
||||
'health.uptime': 'Calisma Suresi',
|
||||
'health.updated_at': 'Son Guncelleme',
|
||||
'health.restart_count': 'Yeniden Başlatmalar',
|
||||
'health.pid': 'İşlem Kimliği',
|
||||
'health.uptime': 'Çalışma Süresi',
|
||||
'health.updated_at': 'Son Güncelleme',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -426,9 +957,15 @@ export function useLocale(): { locale: Locale; t: (key: string) => string } {
|
||||
getStatus()
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
const detected = status.locale?.toLowerCase().startsWith('tr')
|
||||
? 'tr'
|
||||
: 'en';
|
||||
const localeStr = status.locale?.toLowerCase() ?? '';
|
||||
let detected: Locale;
|
||||
if (localeStr.startsWith('zh')) {
|
||||
detected = 'zh';
|
||||
} else if (localeStr.startsWith('tr')) {
|
||||
detected = 'tr';
|
||||
} else {
|
||||
detected = 'en';
|
||||
}
|
||||
setLocale(detected);
|
||||
setLocaleState(detected);
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { WsMessage } from '@/types/api';
|
||||
import { WebSocketClient } from '@/lib/ws';
|
||||
import { generateUUID } from '@/lib/uuid';
|
||||
import { useDraft } from '@/hooks/useDraft';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
@@ -46,7 +47,7 @@ export default function AgentChat() {
|
||||
};
|
||||
|
||||
ws.onError = () => {
|
||||
setError('Connection error. Attempting to reconnect...');
|
||||
setError(t('agent.connection_error'));
|
||||
};
|
||||
|
||||
ws.onMessage = (msg: WsMessage) => {
|
||||
@@ -81,7 +82,7 @@ export default function AgentChat() {
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
|
||||
content: `${t('agent.tool_call_prefix')} ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -93,7 +94,7 @@ export default function AgentChat() {
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content: `[Tool Result] ${msg.output ?? ''}`,
|
||||
content: `${t('agent.tool_result_prefix')} ${msg.output ?? ''}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -105,7 +106,7 @@ export default function AgentChat() {
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content: `[Error] ${msg.message ?? 'Unknown error'}`,
|
||||
content: `${t('agent.error_prefix')} ${msg.message ?? t('agent.unknown_error')}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -146,7 +147,7 @@ export default function AgentChat() {
|
||||
setTyping(true);
|
||||
pendingContentRef.current = '';
|
||||
} catch {
|
||||
setError('Failed to send message. Please try again.');
|
||||
setError(t('agent.send_error'));
|
||||
}
|
||||
|
||||
setInput('');
|
||||
@@ -195,7 +196,7 @@ export default function AgentChat() {
|
||||
<Bot className="h-8 w-8 text-[#0080ff]" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white mb-1">ZeroClaw Agent</p>
|
||||
<p className="text-sm text-[#556080]">Send a message to start the conversation</p>
|
||||
<p className="text-sm text-[#556080]">{t('agent.start_conversation')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -249,7 +250,7 @@ export default function AgentChat() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(msg.id, msg.content)}
|
||||
aria-label="Copy message"
|
||||
aria-label={t('agent.copy_message')}
|
||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-all duration-300 p-1.5 rounded-lg bg-[#0a0a18] border border-[#1a1a3e] text-[#556080] hover:text-white hover:border-[#0080ff40]"
|
||||
>
|
||||
{copiedId === msg.id ? (
|
||||
@@ -290,7 +291,7 @@ export default function AgentChat() {
|
||||
value={input}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={connected ? 'Type a message...' : 'Connecting...'}
|
||||
placeholder={connected ? t('agent.type_message') : t('agent.connecting')}
|
||||
disabled={!connected}
|
||||
className="input-electric w-full px-4 py-3 text-sm resize-none overflow-y-auto disabled:opacity-40"
|
||||
style={{ minHeight: '44px', maxHeight: '200px' }}
|
||||
@@ -311,7 +312,7 @@ export default function AgentChat() {
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[10px] text-[#334060]">
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
{connected ? t('agent.connected_status') : t('agent.disconnected_status')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user