Merge branch 'master' into feature/terminal-ui

This commit is contained in:
SimianAstronaut7 2026-03-12 00:20:03 +00:00 committed by GitHub
commit 658a8eb744
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1303 additions and 360 deletions

View File

@ -59,6 +59,7 @@ PROVIDER=openrouter
# ZAI_API_KEY=...
# SYNTHETIC_API_KEY=...
# OPENCODE_API_KEY=...
# OPENCODE_GO_API_KEY=...
# VERCEL_API_KEY=...
# CLOUDFLARE_API_KEY=...

32
.github/CODEOWNERS vendored
View File

@ -1,5 +1,5 @@
# Default owner for all files
* @JordanTheJet @SimianAstronaut7
* @theonlyhennygod @JordanTheJet @SimianAstronaut7
# Important functional modules
/src/agent/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
@ -13,20 +13,20 @@
/Cargo.lock @theonlyhennygod @JordanTheJet @SimianAstronaut7
# Security / tests / CI-CD ownership
/src/security/** @JordanTheJet @SimianAstronaut7
/tests/** @JordanTheJet @SimianAstronaut7
/.github/** @JordanTheJet @SimianAstronaut7
/.github/workflows/** @JordanTheJet @SimianAstronaut7
/.github/codeql/** @JordanTheJet @SimianAstronaut7
/.github/dependabot.yml @JordanTheJet @SimianAstronaut7
/SECURITY.md @JordanTheJet @SimianAstronaut7
/docs/actions-source-policy.md @JordanTheJet @SimianAstronaut7
/docs/ci-map.md @JordanTheJet @SimianAstronaut7
/src/security/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
/tests/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
/.github/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
/.github/workflows/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
/.github/codeql/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
/.github/dependabot.yml @theonlyhennygod @JordanTheJet @SimianAstronaut7
/SECURITY.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
/docs/actions-source-policy.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
/docs/ci-map.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
# Docs & governance
/docs/** @JordanTheJet @SimianAstronaut7
/AGENTS.md @JordanTheJet @SimianAstronaut7
/CLAUDE.md @JordanTheJet @SimianAstronaut7
/CONTRIBUTING.md @JordanTheJet @SimianAstronaut7
/docs/pr-workflow.md @JordanTheJet @SimianAstronaut7
/docs/reviewer-playbook.md @JordanTheJet @SimianAstronaut7
/docs/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
/AGENTS.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
/CLAUDE.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
/CONTRIBUTING.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
/docs/pr-workflow.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
/docs/reviewer-playbook.md @theonlyhennygod @JordanTheJet @SimianAstronaut7

View File

@ -12,7 +12,7 @@ Use this with:
ZeroClaw uses a single default branch: `master`. All contributor PRs target `master` directly. There is no `dev` or promotion branch.
Current maintainers with PR approval authority: `theonlyhennygod` and `jordanthejet`.
Current maintainers with PR approval authority: `theonlyhennygod`, `JordanTheJet`, and `SimianAstronaut7`.
## Active Workflows
@ -43,7 +43,7 @@ Current maintainers with PR approval authority: `theonlyhennygod` and `jordanthe
- `security` job: runs `cargo audit` and `cargo deny check licenses sources`.
- Concurrency group cancels in-progress runs for the same PR on new pushes.
3. All jobs must pass before merge.
4. Maintainer (`theonlyhennygod` or `jordanthejet`) merges PR once checks and review policy are satisfied.
4. Maintainer (`theonlyhennygod`, `JordanTheJet`, or `SimianAstronaut7`) merges PR once checks and review policy are satisfied.
5. Merge emits a `push` event on `master` (see section 2).
### 2) Push to `master` (including after merge)

435
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclaw"
version = "0.1.7"
version = "0.1.9"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.7
# ── Stage 1: Build ────────────────────────────────────────────
FROM rust:1.94-slim@sha256:d6782f2b326a10eaf593eb90cafc34a03a287b4a25fe4d0c693c90304b06f6d7 AS builder
FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder
WORKDIR /app

View File

@ -14,9 +14,6 @@
<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://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>
</p>

View File

@ -14,9 +14,6 @@
<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://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 : Officiel" /></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.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>
@ -94,7 +91,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), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (groupe)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), et [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 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/zeroclaw), 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

View File

@ -13,9 +13,6 @@
<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://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.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>

View File

@ -14,9 +14,6 @@
<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://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.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>
@ -94,7 +91,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), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (Group)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), and [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 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/zeroclaw), 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

View File

@ -13,9 +13,6 @@
<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://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.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>

View File

@ -14,9 +14,6 @@
<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://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.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>
@ -94,7 +91,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), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), và [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) để 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/zeroclaw), [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

View File

@ -13,9 +13,6 @@
<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://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.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>

View File

@ -13,7 +13,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `.github/workflows/ci-run.yml` (`CI`)
- Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines)
- Additional behavior: for Rust-impacting PRs and pushes, `CI Required Gate` requires `lint` + `test` + `build` (no PR build-only bypass)
- Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,jordanthejet`)
- Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
- Additional behavior: lint gates run before `test`/`build`; when lint/docs gates fail on PRs, CI posts an actionable feedback comment with failing gate names and local fix commands
- Merge gate: `CI Required Gate`
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)

View File

@ -13,7 +13,7 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
- `.github/workflows/ci-run.yml` (`CI`)
- Mục đích: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate trên các dòng Rust thay đổi, `test`, kiểm tra smoke release build) + kiểm tra chất lượng tài liệu khi tài liệu thay đổi (`markdownlint` chỉ chặn các vấn đề trên dòng thay đổi; link check chỉ quét các link mới được thêm trên dòng thay đổi)
- Hành vi bổ sung: đối với PR và push ảnh hưởng Rust, `CI Required Gate` yêu cầu `lint` + `test` + `build` (không có shortcut chỉ build trên PR)
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,jordanthejet`)
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
- Hành vi bổ sung: lint gate chạy trước `test`/`build`; khi lint/docs gate thất bại trên PR, CI đăng comment phản hồi hành động được với tên gate thất bại và các lệnh sửa cục bộ
- Merge gate: `CI Required Gate`
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)

View File

@ -2,7 +2,7 @@
Tài liệu này liệt kê các provider ID, alias và biến môi trường chứa thông tin xác thực.
Cập nhật lần cuối: **2026-02-19**.
Cập nhật lần cuối: **2026-03-10**.
## Cách liệt kê các Provider
@ -36,6 +36,7 @@ Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi pro
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | Không | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
| `synthetic` | — | Không | `SYNTHETIC_API_KEY` |
| `opencode` | `opencode-zen` | Không | `OPENCODE_API_KEY` |
| `opencode-go` | — | Không | `OPENCODE_GO_API_KEY` |
| `zai` | `z.ai` | Không | `ZAI_API_KEY` |
| `glm` | `zhipu` | Không | `GLM_API_KEY` |
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | Không | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |

View File

@ -38,6 +38,7 @@ credential is not reused for fallback providers.
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | No | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
| `synthetic` | — | No | `SYNTHETIC_API_KEY` |
| `opencode` | `opencode-zen` | No | `OPENCODE_API_KEY` |
| `opencode-go` | — | No | `OPENCODE_GO_API_KEY` |
| `zai` | `z.ai` | No | `ZAI_API_KEY` |
| `glm` | `zhipu` | No | `GLM_API_KEY` |
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | No | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |

View File

@ -13,7 +13,7 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
- `.github/workflows/ci-run.yml` (`CI`)
- Mục đích: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate trên các dòng Rust thay đổi, `test`, kiểm tra smoke release build) + kiểm tra chất lượng tài liệu khi tài liệu thay đổi (`markdownlint` chỉ chặn các vấn đề trên dòng thay đổi; link check chỉ quét các link mới được thêm trên dòng thay đổi)
- Hành vi bổ sung: đối với PR và push ảnh hưởng Rust, `CI Required Gate` yêu cầu `lint` + `test` + `build` (không có shortcut chỉ build trên PR)
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,jordanthejet`)
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
- Hành vi bổ sung: lint gate chạy trước `test`/`build`; khi lint/docs gate thất bại trên PR, CI đăng comment phản hồi hành động được với tên gate thất bại và các lệnh sửa cục bộ
- Merge gate: `CI Required Gate`
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)

View File

@ -2,7 +2,7 @@
Tài liệu này liệt kê các provider ID, alias và biến môi trường chứa thông tin xác thực.
Cập nhật lần cuối: **2026-02-19**.
Cập nhật lần cuối: **2026-03-10**.
## Cách liệt kê các Provider
@ -36,6 +36,7 @@ Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi pro
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | Không | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
| `synthetic` | — | Không | `SYNTHETIC_API_KEY` |
| `opencode` | `opencode-zen` | Không | `OPENCODE_API_KEY` |
| `opencode-go` | — | Không | `OPENCODE_GO_API_KEY` |
| `zai` | `z.ai` | Không | `ZAI_API_KEY` |
| `glm` | `zhipu` | Không | `GLM_API_KEY` |
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | Không | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |

View File

@ -1920,6 +1920,18 @@ fn build_assistant_history_with_tool_calls(text: &str, tool_calls: &[ToolCall])
parts.join("\n")
}
fn resolve_display_text(response_text: &str, parsed_text: &str, has_tool_calls: bool) -> String {
if has_tool_calls {
return parsed_text.to_string();
}
if parsed_text.is_empty() {
response_text.to_string()
} else {
parsed_text.to_string()
}
}
#[derive(Debug, Clone)]
struct ParsedToolCall {
name: String,
@ -1986,8 +1998,17 @@ async fn execute_one_tool(
observer: &dyn Observer,
cancellation_token: Option<&CancellationToken>,
) -> Result<ToolExecutionOutcome> {
let args_summary = {
let raw = call_arguments.to_string();
if raw.len() > 300 {
format!("{}", &raw[..300])
} else {
raw
}
};
observer.record_event(&ObserverEvent::ToolCallStart {
tool: call_name.to_string(),
arguments: Some(args_summary),
});
let start = Instant::now();
@ -2399,11 +2420,8 @@ pub(crate) async fn run_tool_call_loop(
}
};
let display_text = if parsed_text.is_empty() {
response_text.clone()
} else {
parsed_text
};
let display_text =
resolve_display_text(&response_text, &parsed_text, !tool_calls.is_empty());
let display_text = strip_tool_result_blocks(&display_text);
// ── Progress: LLM responded ─────────────────────────────
@ -4152,6 +4170,32 @@ mod tests {
);
}
#[test]
fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
let display = resolve_display_text(
"<tool_call>{\"name\":\"memory_store\"}</tool_call>",
"",
true,
);
assert!(display.is_empty());
}
#[test]
fn resolve_display_text_keeps_plain_text_for_tool_turns() {
let display = resolve_display_text(
"<tool_call>{\"name\":\"shell\"}</tool_call>",
"Let me check that.",
true,
);
assert_eq!(display, "Let me check that.");
}
#[test]
fn resolve_display_text_uses_response_text_for_final_turns() {
let display = resolve_display_text("Final answer", "", false);
assert_eq!(display, "Final answer");
}
#[test]
fn parse_tool_calls_extracts_single_call() {
let response = r#"Let me check that.

View File

@ -4,16 +4,21 @@ use matrix_sdk::{
authentication::matrix::MatrixSession,
config::SyncSettings,
ruma::{
events::reaction::ReactionEventContent,
events::relation::{Annotation, InReplyTo, Thread},
events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
},
OwnedRoomId, OwnedUserId,
events::room::MediaSource,
OwnedEventId, OwnedRoomId, OwnedUserId,
},
Client as MatrixSdkClient, LoopCtrl, Room, RoomState, SessionMeta, SessionTokens,
};
use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex, OnceCell, RwLock};
@ -31,6 +36,8 @@ pub struct MatrixChannel {
resolved_room_id_cache: Arc<RwLock<Option<String>>>,
sdk_client: Arc<OnceCell<MatrixSdkClient>>,
http_client: Client,
reaction_events: Arc<RwLock<HashMap<String, String>>>,
voice_mode: Arc<AtomicBool>,
}
impl std::fmt::Debug for MatrixChannel {
@ -163,6 +170,8 @@ impl MatrixChannel {
resolved_room_id_cache: Arc::new(RwLock::new(None)),
sdk_client: Arc::new(OnceCell::new()),
http_client: Client::new(),
reaction_events: Arc::new(RwLock::new(HashMap::new())),
voice_mode: Arc::new(AtomicBool::new(false)),
}
}
@ -530,7 +539,16 @@ impl Channel for MatrixChannel {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let client = self.matrix_client().await?;
let target_room_id = self.target_room_id().await?;
let target_room_id = if message.recipient.contains("||") {
message
.recipient
.splitn(2, "||")
.nth(1)
.unwrap()
.to_string()
} else {
self.target_room_id().await?
};
let target_room: OwnedRoomId = target_room_id.parse()?;
let mut room = client.get_room(&target_room);
@ -547,8 +565,94 @@ impl Channel for MatrixChannel {
anyhow::bail!("Matrix room '{}' is not in joined state", target_room_id);
}
room.send(RoomMessageEventContent::text_markdown(&message.content))
.await?;
let mut content = RoomMessageEventContent::text_markdown(&message.content);
if let Some(ref thread_ts) = message.thread_ts {
if let Ok(thread_root) = thread_ts.parse::<OwnedEventId>() {
content.relates_to = Some(Relation::Thread(Thread::plain(
thread_root.clone(),
thread_root,
)));
}
}
room.send(content).await?;
// Voice reply: generate TTS audio and send as m.audio when voice_mode is active
if self.voice_mode.load(Ordering::Relaxed) {
self.voice_mode.store(false, Ordering::Relaxed);
tracing::info!("Voice mode active, generating TTS reply");
let voice_work = std::path::PathBuf::from("/tmp/zeroclaw-voice");
let _ = tokio::fs::create_dir_all(&voice_work).await;
let mp3_path = voice_work.join("reply.mp3");
let tts_text = message
.content
.replace("**", "")
.replace("*", "")
.replace("`", "")
.replace("# ", "");
let tts_ok = tokio::process::Command::new("edge-tts")
.arg("--text")
.arg(&tts_text)
.arg("--write-media")
.arg(&mp3_path)
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if tts_ok && mp3_path.exists() {
if let Ok(audio_data) = tokio::fs::read(&mp3_path).await {
let upload_url = format!(
"{}/_matrix/media/v3/upload?filename=voice-reply.mp3",
self.homeserver
);
if let Ok(resp) = self
.http_client
.post(&upload_url)
.header("Authorization", self.auth_header_value())
.header("Content-Type", "audio/mpeg")
.body(audio_data)
.send()
.await
{
if resp.status().is_success() {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(content_uri) = body["content_uri"].as_str() {
let encoded_room = Self::encode_path_segment(&target_room_id);
let txn_id = format!(
"voice_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
let audio_msg = serde_json::json!({
"msgtype": "m.audio",
"body": "Voice reply",
"url": content_uri,
"info": { "mimetype": "audio/mpeg" }
});
let send_url = format!(
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
self.homeserver, encoded_room, txn_id
);
let _ = self
.http_client
.put(&send_url)
.header("Authorization", self.auth_header_value())
.json(&audio_msg)
.send()
.await;
}
}
}
}
}
}
}
Ok(())
}
@ -593,6 +697,9 @@ impl Channel for MatrixChannel {
let my_user_id_for_handler = my_user_id.clone();
let allowed_users_for_handler = self.allowed_users.clone();
let dedupe_for_handler = Arc::clone(&recent_event_cache);
let homeserver_for_handler = self.homeserver.clone();
let access_token_for_handler = self.access_token.clone();
let voice_mode_for_handler = Arc::clone(&self.voice_mode);
client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
let tx = tx_handler.clone();
@ -600,9 +707,14 @@ impl Channel for MatrixChannel {
let my_user_id = my_user_id_for_handler.clone();
let allowed_users = allowed_users_for_handler.clone();
let dedupe = Arc::clone(&dedupe_for_handler);
let homeserver = homeserver_for_handler.clone();
let access_token = access_token_for_handler.clone();
let voice_mode = Arc::clone(&voice_mode_for_handler);
async move {
if room.room_id().as_str() != target_room.as_str() {
if false
/* multi-room: room_id filter disabled */
{
return;
}
@ -615,12 +727,124 @@ impl Channel for MatrixChannel {
return;
}
let body = match &event.content.msgtype {
MessageType::Text(content) => content.body.clone(),
MessageType::Notice(content) => content.body.clone(),
// Helper: extract mxc:// download URL and filename for media types
let media_info = |source: &MediaSource, name: &str| -> Option<(String, String)> {
match source {
MediaSource::Plain(mxc) => {
let rest = mxc.as_str().strip_prefix("mxc://")?;
let url =
format!("{}/_matrix/client/v1/media/download/{}", homeserver, rest);
Some((url, name.to_string()))
}
_ => None,
}
};
let (body, media_download) = match &event.content.msgtype {
MessageType::Text(content) => (content.body.clone(), None),
MessageType::Notice(content) => (content.body.clone(), None),
MessageType::Image(content) => {
let dl = media_info(&content.source, &content.body);
(format!("[image: {}]", content.body), dl)
}
MessageType::File(content) => {
let dl = media_info(&content.source, &content.body);
(format!("[file: {}]", content.body), dl)
}
MessageType::Audio(content) => {
let dl = media_info(&content.source, &content.body);
(format!("[audio: {}]", content.body), dl)
}
MessageType::Video(content) => {
let dl = media_info(&content.source, &content.body);
(format!("[video: {}]", content.body), dl)
}
_ => return,
};
// Download media to workspace if present
let body = if let Some((url, filename)) = media_download {
let workspace = std::path::PathBuf::from(
std::env::var("ZEROCLAW_WORKSPACE")
.unwrap_or_else(|_| "/tmp/zeroclaw-uploads".to_string()),
);
let _ = tokio::fs::create_dir_all(&workspace).await;
let dest = workspace.join(&filename);
let client = reqwest::Client::new();
match client
.get(&url)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
{
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(bytes) => match tokio::fs::write(&dest, &bytes).await {
Ok(_) => format!("{} — saved to {}", body, dest.display()),
Err(_) => format!("{} — failed to write to disk", body),
},
Err(_) => format!("{} — download failed", body),
},
_ => format!("{} — download failed (auth error?)", body),
}
} else {
body
};
// Voice transcription: if this was an audio message, transcribe it
let body = if body.starts_with("[audio:") {
if let Some(path_start) = body.find("saved to ") {
let audio_path = body[path_start + 9..].to_string();
let wav_path = format!("{}.16k.wav", audio_path);
let convert_ok = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-i",
&audio_path,
"-ar",
"16000",
"-ac",
"1",
"-f",
"wav",
&wav_path,
])
.stderr(std::process::Stdio::null())
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if convert_ok {
let transcription = tokio::process::Command::new("whisper-cpp")
.args([
"-m",
"/tmp/ggml-base.en.bin",
"-f",
&wav_path,
"--no-timestamps",
"-nt",
])
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
if let Some(text) = transcription {
voice_mode.store(true, Ordering::Relaxed);
format!("[Voice message]: {}", text)
} else {
body
}
} else {
body
}
} else {
body
}
} else {
body
};
if !MatrixChannel::has_non_empty_body(&body) {
return;
}
@ -634,17 +858,21 @@ impl Channel for MatrixChannel {
}
}
let thread_ts = match &event.content.relates_to {
Some(Relation::Thread(thread)) => Some(thread.event_id.to_string()),
_ => None,
};
let msg = ChannelMessage {
id: event_id,
sender: sender.clone(),
reply_target: sender,
reply_target: format!("{}||{}", sender, room.room_id()),
content: body,
channel: "matrix".to_string(),
channel: format!("matrix:{}", room.room_id()),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
thread_ts,
};
let _ = tx.send(msg).await;
@ -684,6 +912,179 @@ impl Channel for MatrixChannel {
self.matrix_client().await.is_ok()
}
async fn add_reaction(
&self,
_channel_id: &str,
message_id: &str,
emoji: &str,
) -> anyhow::Result<()> {
let client = self.matrix_client().await?;
let target_room_id = self.target_room_id().await?;
let target_room: OwnedRoomId = target_room_id.parse()?;
let room = client
.get_room(&target_room)
.ok_or_else(|| anyhow::anyhow!("Matrix room not found for reaction"))?;
let event_id: OwnedEventId = message_id
.parse()
.map_err(|_| anyhow::anyhow!("Invalid event ID for reaction: {}", message_id))?;
let reaction = ReactionEventContent::new(Annotation::new(event_id, emoji.to_string()));
let response = room.send(reaction).await?;
let key = format!("{}:{}", message_id, emoji);
self.reaction_events
.write()
.await
.insert(key, response.event_id.to_string());
Ok(())
}
async fn remove_reaction(
&self,
_channel_id: &str,
message_id: &str,
emoji: &str,
) -> anyhow::Result<()> {
let key = format!("{}:{}", message_id, emoji);
let reaction_event_id = self.reaction_events.write().await.remove(&key);
if let Some(reaction_event_id) = reaction_event_id {
let client = self.matrix_client().await?;
let target_room_id = self.target_room_id().await?;
let target_room: OwnedRoomId = target_room_id.parse()?;
let room = client
.get_room(&target_room)
.ok_or_else(|| anyhow::anyhow!("Matrix room not found for reaction removal"))?;
let event_id: OwnedEventId = reaction_event_id
.parse()
.map_err(|_| anyhow::anyhow!("Invalid reaction event ID: {}", reaction_event_id))?;
room.redact(&event_id, None, None).await?;
}
Ok(())
}
async fn pin_message(&self, _channel_id: &str, message_id: &str) -> anyhow::Result<()> {
let room_id = self.target_room_id().await?;
let encoded_room = Self::encode_path_segment(&room_id);
let url = format!(
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
self.homeserver, encoded_room
);
let resp = self
.http_client
.get(&url)
.header("Authorization", self.auth_header_value())
.send()
.await?;
let mut pinned: Vec<String> = if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
body.get("pinned")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let msg_id = message_id.to_string();
if pinned.contains(&msg_id) {
return Ok(());
}
pinned.push(msg_id);
let put_url = format!(
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
self.homeserver, encoded_room
);
let body = serde_json::json!({ "pinned": pinned });
let resp = self
.http_client
.put(&put_url)
.header("Authorization", self.auth_header_value())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Matrix pin_message failed: {err}");
}
Ok(())
}
async fn unpin_message(&self, _channel_id: &str, message_id: &str) -> anyhow::Result<()> {
let room_id = self.target_room_id().await?;
let encoded_room = Self::encode_path_segment(&room_id);
let url = format!(
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
self.homeserver, encoded_room
);
let resp = self
.http_client
.get(&url)
.header("Authorization", self.auth_header_value())
.send()
.await?;
if !resp.status().is_success() {
return Ok(());
}
let body: serde_json::Value = resp.json().await?;
let mut pinned: Vec<String> = body
.get("pinned")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let msg_id = message_id.to_string();
let original_len = pinned.len();
pinned.retain(|id| id != &msg_id);
if pinned.len() == original_len {
return Ok(());
}
let put_url = format!(
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
self.homeserver, encoded_room
);
let body = serde_json::json!({ "pinned": pinned });
let resp = self
.http_client
.put(&put_url)
.header("Authorization", self.auth_header_value())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Matrix unpin_message failed: {err}");
}
Ok(())
}
}
#[cfg(test)]

View File

@ -74,6 +74,7 @@ use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop, scrub_cre
use crate::config::Config;
use crate::identity;
use crate::memory::{self, Memory};
use crate::observability::traits::{ObserverEvent, ObserverMetric};
use crate::observability::{self, runtime_trace, Observer};
use crate::providers::{self, ChatMessage, Provider};
use crate::runtime;
@ -91,6 +92,66 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
use tokio_util::sync::CancellationToken;
/// Observer wrapper that forwards tool-call events to a channel sender
/// for real-time threaded notifications.
struct ChannelNotifyObserver {
inner: Arc<dyn Observer>,
tx: tokio::sync::mpsc::UnboundedSender<String>,
tools_used: AtomicBool,
}
impl Observer for ChannelNotifyObserver {
fn record_event(&self, event: &ObserverEvent) {
if let ObserverEvent::ToolCallStart { tool, arguments } = event {
self.tools_used.store(true, Ordering::Relaxed);
let detail = match arguments {
Some(args) if !args.is_empty() => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
format!(": `{}`", if cmd.len() > 200 { &cmd[..200] } else { cmd })
} else if let Some(q) = v.get("query").and_then(|c| c.as_str()) {
format!(": {}", if q.len() > 200 { &q[..200] } else { q })
} else if let Some(p) = v.get("path").and_then(|c| c.as_str()) {
format!(": {p}")
} else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
format!(": {u}")
} else {
let s = args.to_string();
if s.len() > 120 {
format!(": {}", &s[..120])
} else {
format!(": {s}")
}
}
} else {
let s = args.to_string();
if s.len() > 120 {
format!(": {}", &s[..120])
} else {
format!(": {s}")
}
}
}
_ => String::new(),
};
let _ = self.tx.send(format!("\u{1F527} `{tool}`{detail}"));
}
self.inner.record_event(event);
}
fn record_metric(&self, metric: &ObserverMetric) {
self.inner.record_metric(metric);
}
fn flush(&self) {
self.inner.flush();
}
fn name(&self) -> &str {
"channel-notify"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
/// Per-sender conversation history for channel messages.
type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
/// Maximum history messages to keep per sender.
@ -401,6 +462,13 @@ fn strip_tool_call_tags(message: &str) -> String {
fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
match channel_name {
"matrix" => Some(
"When responding on Matrix:\n\
- Use Markdown formatting (bold, italic, code blocks)\n\
- Be concise and direct\n\
- When you receive a [Voice message], the user spoke to you. Respond naturally as in conversation.\n\
- Your text reply will automatically be converted to audio and sent back as a voice message.\n",
),
"telegram" => Some(
"When responding on Telegram:\n\
- Include media markers for files or URLs that should be sent as attachments\n\
@ -426,6 +494,25 @@ fn build_channel_system_prompt(
) -> String {
let mut prompt = base_prompt.to_string();
// Refresh the stale datetime in the cached system prompt
{
let now = chrono::Local::now();
let fresh = format!(
"## Current Date & Time\n\n{} ({})\n",
now.format("%Y-%m-%d %H:%M:%S"),
now.format("%Z"),
);
if let Some(start) = prompt.find("## Current Date & Time\n\n") {
// Find the end of this section (next "## " heading or end of string)
let rest = &prompt[start + 24..]; // skip past "## Current Date & Time\n\n"
let section_end = rest
.find("\n## ")
.map(|i| start + 24 + i)
.unwrap_or(prompt.len());
prompt.replace_range(start..section_end, fresh.trim_end());
}
}
if let Some(instructions) = channel_delivery_instructions(channel_name) {
if prompt.is_empty() {
prompt = instructions.to_string();
@ -1582,7 +1669,7 @@ async fn process_channel_message(
);
// ── Hook: on_message_received (modifying) ────────────
let msg = if let Some(hooks) = &ctx.hooks {
let mut msg = if let Some(hooks) = &ctx.hooks {
match hooks.run_on_message_received(msg).await {
crate::hooks::HookResult::Cancel(reason) => {
tracing::info!(%reason, "incoming message dropped by hook");
@ -1764,6 +1851,37 @@ async fn process_channel_message(
_ => None,
};
// Wrap observer to forward tool events as live thread messages
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let notify_observer: Arc<ChannelNotifyObserver> = Arc::new(ChannelNotifyObserver {
inner: Arc::clone(&ctx.observer),
tx: notify_tx,
tools_used: AtomicBool::new(false),
});
let notify_observer_flag = Arc::clone(&notify_observer);
let notify_channel = target_channel.clone();
let notify_reply_target = msg.reply_target.clone();
let notify_thread_root = msg.id.clone();
let notify_task = if msg.channel == "cli" {
Some(tokio::spawn(async move {
while notify_rx.recv().await.is_some() {}
}))
} else {
Some(tokio::spawn(async move {
let thread_ts = Some(notify_thread_root);
while let Some(text) = notify_rx.recv().await {
if let Some(ref ch) = notify_channel {
let _ = ch
.send(
&SendMessage::new(&text, &notify_reply_target)
.in_thread(thread_ts.clone()),
)
.await;
}
}
}))
};
// Record history length before tool loop so we can extract tool context after.
let history_len_before_tools = history.len();
@ -1782,7 +1900,7 @@ async fn process_channel_message(
active_provider.as_ref(),
&mut history,
ctx.tools_registry.as_ref(),
ctx.observer.as_ref(),
notify_observer.as_ref() as &dyn Observer,
route.provider.as_str(),
route.model.as_str(),
runtime_defaults.temperature,
@ -1807,6 +1925,17 @@ async fn process_channel_message(
let _ = handle.await;
}
// Thread the final reply only if tools were used (multi-message response)
if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
msg.thread_ts = Some(msg.id.clone());
}
// Drop the notify sender so the forwarder task finishes
drop(notify_observer);
drop(notify_observer_flag);
if let Some(handle) = notify_task {
let _ = handle.await;
}
if let Some(token) = typing_cancellation.as_ref() {
token.cancel();
}
@ -4186,11 +4315,12 @@ BTC is currently around $65,000 based on latest tool output."#
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-42:"));
assert!(sent_messages[0].contains("BTC is currently around"));
assert!(!sent_messages[0].contains("\"tool_calls\""));
assert!(!sent_messages[0].contains("mock_price"));
assert!(!sent_messages.is_empty());
let reply = sent_messages.last().unwrap();
assert!(reply.starts_with("chat-42:"));
assert!(reply.contains("BTC is currently around"));
assert!(!reply.contains("\"tool_calls\""));
assert!(!reply.contains("mock_price"));
}
#[tokio::test]
@ -4246,8 +4376,9 @@ BTC is currently around $65,000 based on latest tool output."#
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].contains("BTC is currently around"));
assert!(!sent_messages.is_empty());
let reply = sent_messages.last().unwrap();
assert!(reply.contains("BTC is currently around"));
let histories = runtime_ctx
.conversation_histories
@ -4380,11 +4511,12 @@ BTC is currently around $65,000 based on latest tool output."#
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-84:"));
assert!(sent_messages[0].contains("alias-tag flow resolved"));
assert!(!sent_messages[0].contains("<toolcall>"));
assert!(!sent_messages[0].contains("mock_price"));
assert!(!sent_messages.is_empty());
let reply = sent_messages.last().unwrap();
assert!(reply.starts_with("chat-84:"));
assert!(reply.contains("alias-tag flow resolved"));
assert!(!reply.contains("<toolcall>"));
assert!(!reply.contains("mock_price"));
}
#[tokio::test]
@ -4770,10 +4902,11 @@ BTC is currently around $65,000 based on latest tool output."#
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-iter-success:"));
assert!(sent_messages[0].contains("Completed after 11 tool iterations."));
assert!(!sent_messages[0].contains("⚠️ Error:"));
assert!(!sent_messages.is_empty());
let reply = sent_messages.last().unwrap();
assert!(reply.starts_with("chat-iter-success:"));
assert!(reply.contains("Completed after 11 tool iterations."));
assert!(!reply.contains("⚠️ Error:"));
}
#[tokio::test]
@ -4831,9 +4964,10 @@ BTC is currently around $65,000 based on latest tool output."#
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-iter-fail:"));
assert!(sent_messages[0].contains("⚠️ Error: Agent exceeded maximum tool iterations (3)"));
assert!(!sent_messages.is_empty());
let reply = sent_messages.last().unwrap();
assert!(reply.starts_with("chat-iter-fail:"));
assert!(reply.contains("⚠️ Error: Agent exceeded maximum tool iterations (3)"));
}
struct NoopMemory;

View File

@ -142,6 +142,16 @@ pub trait Channel: Send + Sync {
) -> anyhow::Result<()> {
Ok(())
}
/// Pin a message in the channel.
async fn pin_message(&self, _channel_id: &str, _message_id: &str) -> anyhow::Result<()> {
Ok(())
}
/// Unpin a previously pinned message.
async fn unpin_message(&self, _channel_id: &str, _message_id: &str) -> anyhow::Result<()> {
Ok(())
}
}
#[cfg(test)]

View File

@ -4284,6 +4284,192 @@ impl Config {
)?;
}
// Decrypt channel secrets
if let Some(ref mut tg) = config.channels_config.telegram {
decrypt_secret(
&store,
&mut tg.bot_token,
"config.channels_config.telegram.bot_token",
)?;
}
if let Some(ref mut dc) = config.channels_config.discord {
decrypt_secret(
&store,
&mut dc.bot_token,
"config.channels_config.discord.bot_token",
)?;
}
if let Some(ref mut sl) = config.channels_config.slack {
decrypt_secret(
&store,
&mut sl.bot_token,
"config.channels_config.slack.bot_token",
)?;
decrypt_optional_secret(
&store,
&mut sl.app_token,
"config.channels_config.slack.app_token",
)?;
}
if let Some(ref mut mm) = config.channels_config.mattermost {
decrypt_secret(
&store,
&mut mm.bot_token,
"config.channels_config.mattermost.bot_token",
)?;
}
if let Some(ref mut mx) = config.channels_config.matrix {
decrypt_secret(
&store,
&mut mx.access_token,
"config.channels_config.matrix.access_token",
)?;
}
if let Some(ref mut wa) = config.channels_config.whatsapp {
decrypt_optional_secret(
&store,
&mut wa.access_token,
"config.channels_config.whatsapp.access_token",
)?;
decrypt_optional_secret(
&store,
&mut wa.app_secret,
"config.channels_config.whatsapp.app_secret",
)?;
decrypt_optional_secret(
&store,
&mut wa.verify_token,
"config.channels_config.whatsapp.verify_token",
)?;
}
if let Some(ref mut lq) = config.channels_config.linq {
decrypt_secret(
&store,
&mut lq.api_token,
"config.channels_config.linq.api_token",
)?;
decrypt_optional_secret(
&store,
&mut lq.signing_secret,
"config.channels_config.linq.signing_secret",
)?;
}
if let Some(ref mut wt) = config.channels_config.wati {
decrypt_secret(
&store,
&mut wt.api_token,
"config.channels_config.wati.api_token",
)?;
}
if let Some(ref mut nc) = config.channels_config.nextcloud_talk {
decrypt_secret(
&store,
&mut nc.app_token,
"config.channels_config.nextcloud_talk.app_token",
)?;
decrypt_optional_secret(
&store,
&mut nc.webhook_secret,
"config.channels_config.nextcloud_talk.webhook_secret",
)?;
}
if let Some(ref mut em) = config.channels_config.email {
decrypt_secret(
&store,
&mut em.password,
"config.channels_config.email.password",
)?;
}
if let Some(ref mut irc) = config.channels_config.irc {
decrypt_optional_secret(
&store,
&mut irc.server_password,
"config.channels_config.irc.server_password",
)?;
decrypt_optional_secret(
&store,
&mut irc.nickserv_password,
"config.channels_config.irc.nickserv_password",
)?;
decrypt_optional_secret(
&store,
&mut irc.sasl_password,
"config.channels_config.irc.sasl_password",
)?;
}
if let Some(ref mut lk) = config.channels_config.lark {
decrypt_secret(
&store,
&mut lk.app_secret,
"config.channels_config.lark.app_secret",
)?;
decrypt_optional_secret(
&store,
&mut lk.encrypt_key,
"config.channels_config.lark.encrypt_key",
)?;
decrypt_optional_secret(
&store,
&mut lk.verification_token,
"config.channels_config.lark.verification_token",
)?;
}
if let Some(ref mut fs) = config.channels_config.feishu {
decrypt_secret(
&store,
&mut fs.app_secret,
"config.channels_config.feishu.app_secret",
)?;
decrypt_optional_secret(
&store,
&mut fs.encrypt_key,
"config.channels_config.feishu.encrypt_key",
)?;
decrypt_optional_secret(
&store,
&mut fs.verification_token,
"config.channels_config.feishu.verification_token",
)?;
}
if let Some(ref mut dt) = config.channels_config.dingtalk {
decrypt_secret(
&store,
&mut dt.client_secret,
"config.channels_config.dingtalk.client_secret",
)?;
}
if let Some(ref mut qq) = config.channels_config.qq {
decrypt_secret(
&store,
&mut qq.app_secret,
"config.channels_config.qq.app_secret",
)?;
}
if let Some(ref mut wh) = config.channels_config.webhook {
decrypt_optional_secret(
&store,
&mut wh.secret,
"config.channels_config.webhook.secret",
)?;
}
if let Some(ref mut ct) = config.channels_config.clawdtalk {
decrypt_secret(
&store,
&mut ct.api_key,
"config.channels_config.clawdtalk.api_key",
)?;
decrypt_optional_secret(
&store,
&mut ct.webhook_secret,
"config.channels_config.clawdtalk.webhook_secret",
)?;
}
// Decrypt gateway paired tokens
for token in &mut config.gateway.paired_tokens {
decrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
}
config.apply_env_overrides();
config.validate()?;
tracing::info!(
@ -4934,6 +5120,192 @@ impl Config {
)?;
}
// Encrypt channel secrets
if let Some(ref mut tg) = config_to_save.channels_config.telegram {
encrypt_secret(
&store,
&mut tg.bot_token,
"config.channels_config.telegram.bot_token",
)?;
}
if let Some(ref mut dc) = config_to_save.channels_config.discord {
encrypt_secret(
&store,
&mut dc.bot_token,
"config.channels_config.discord.bot_token",
)?;
}
if let Some(ref mut sl) = config_to_save.channels_config.slack {
encrypt_secret(
&store,
&mut sl.bot_token,
"config.channels_config.slack.bot_token",
)?;
encrypt_optional_secret(
&store,
&mut sl.app_token,
"config.channels_config.slack.app_token",
)?;
}
if let Some(ref mut mm) = config_to_save.channels_config.mattermost {
encrypt_secret(
&store,
&mut mm.bot_token,
"config.channels_config.mattermost.bot_token",
)?;
}
if let Some(ref mut mx) = config_to_save.channels_config.matrix {
encrypt_secret(
&store,
&mut mx.access_token,
"config.channels_config.matrix.access_token",
)?;
}
if let Some(ref mut wa) = config_to_save.channels_config.whatsapp {
encrypt_optional_secret(
&store,
&mut wa.access_token,
"config.channels_config.whatsapp.access_token",
)?;
encrypt_optional_secret(
&store,
&mut wa.app_secret,
"config.channels_config.whatsapp.app_secret",
)?;
encrypt_optional_secret(
&store,
&mut wa.verify_token,
"config.channels_config.whatsapp.verify_token",
)?;
}
if let Some(ref mut lq) = config_to_save.channels_config.linq {
encrypt_secret(
&store,
&mut lq.api_token,
"config.channels_config.linq.api_token",
)?;
encrypt_optional_secret(
&store,
&mut lq.signing_secret,
"config.channels_config.linq.signing_secret",
)?;
}
if let Some(ref mut wt) = config_to_save.channels_config.wati {
encrypt_secret(
&store,
&mut wt.api_token,
"config.channels_config.wati.api_token",
)?;
}
if let Some(ref mut nc) = config_to_save.channels_config.nextcloud_talk {
encrypt_secret(
&store,
&mut nc.app_token,
"config.channels_config.nextcloud_talk.app_token",
)?;
encrypt_optional_secret(
&store,
&mut nc.webhook_secret,
"config.channels_config.nextcloud_talk.webhook_secret",
)?;
}
if let Some(ref mut em) = config_to_save.channels_config.email {
encrypt_secret(
&store,
&mut em.password,
"config.channels_config.email.password",
)?;
}
if let Some(ref mut irc) = config_to_save.channels_config.irc {
encrypt_optional_secret(
&store,
&mut irc.server_password,
"config.channels_config.irc.server_password",
)?;
encrypt_optional_secret(
&store,
&mut irc.nickserv_password,
"config.channels_config.irc.nickserv_password",
)?;
encrypt_optional_secret(
&store,
&mut irc.sasl_password,
"config.channels_config.irc.sasl_password",
)?;
}
if let Some(ref mut lk) = config_to_save.channels_config.lark {
encrypt_secret(
&store,
&mut lk.app_secret,
"config.channels_config.lark.app_secret",
)?;
encrypt_optional_secret(
&store,
&mut lk.encrypt_key,
"config.channels_config.lark.encrypt_key",
)?;
encrypt_optional_secret(
&store,
&mut lk.verification_token,
"config.channels_config.lark.verification_token",
)?;
}
if let Some(ref mut fs) = config_to_save.channels_config.feishu {
encrypt_secret(
&store,
&mut fs.app_secret,
"config.channels_config.feishu.app_secret",
)?;
encrypt_optional_secret(
&store,
&mut fs.encrypt_key,
"config.channels_config.feishu.encrypt_key",
)?;
encrypt_optional_secret(
&store,
&mut fs.verification_token,
"config.channels_config.feishu.verification_token",
)?;
}
if let Some(ref mut dt) = config_to_save.channels_config.dingtalk {
encrypt_secret(
&store,
&mut dt.client_secret,
"config.channels_config.dingtalk.client_secret",
)?;
}
if let Some(ref mut qq) = config_to_save.channels_config.qq {
encrypt_secret(
&store,
&mut qq.app_secret,
"config.channels_config.qq.app_secret",
)?;
}
if let Some(ref mut wh) = config_to_save.channels_config.webhook {
encrypt_optional_secret(
&store,
&mut wh.secret,
"config.channels_config.webhook.secret",
)?;
}
if let Some(ref mut ct) = config_to_save.channels_config.clawdtalk {
encrypt_secret(
&store,
&mut ct.api_key,
"config.channels_config.clawdtalk.api_key",
)?;
encrypt_optional_secret(
&store,
&mut ct.webhook_secret,
"config.channels_config.clawdtalk.webhook_secret",
)?;
}
// Encrypt gateway paired tokens
for token in &mut config_to_save.gateway.paired_tokens {
encrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
}
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;

View File

@ -1933,16 +1933,24 @@ mod tests {
.await
.unwrap();
let saved = tokio::fs::read_to_string(config_path).await.unwrap();
let parsed: Config = toml::from_str(&saved).unwrap();
assert_eq!(parsed.gateway.paired_tokens.len(), 1);
let persisted = &parsed.gateway.paired_tokens[0];
assert_eq!(persisted.len(), 64);
assert!(persisted.chars().all(|c| c.is_ascii_hexdigit()));
// In-memory tokens should remain as plaintext 64-char hex hashes.
let plaintext = {
let in_memory = shared_config.lock();
assert_eq!(in_memory.gateway.paired_tokens.len(), 1);
in_memory.gateway.paired_tokens[0].clone()
};
assert_eq!(plaintext.len(), 64);
assert!(plaintext.chars().all(|c: char| c.is_ascii_hexdigit()));
let in_memory = shared_config.lock();
assert_eq!(in_memory.gateway.paired_tokens.len(), 1);
assert_eq!(&in_memory.gateway.paired_tokens[0], persisted);
// On disk, the token should be encrypted (secrets.encrypt defaults to true).
let saved = tokio::fs::read_to_string(config_path).await.unwrap();
let raw_parsed: Config = toml::from_str(&saved).unwrap();
assert_eq!(raw_parsed.gateway.paired_tokens.len(), 1);
let on_disk = &raw_parsed.gateway.paired_tokens[0];
assert!(
crate::security::SecretStore::is_encrypted(on_disk),
"paired_token should be encrypted on disk"
);
}
#[test]

View File

@ -98,7 +98,7 @@ impl crate::observability::Observer for BroadcastObserver {
"success": success,
"timestamp": chrono::Utc::now().to_rfc3339(),
}),
crate::observability::ObserverEvent::ToolCallStart { tool } => serde_json::json!({
crate::observability::ObserverEvent::ToolCallStart { tool, .. } => serde_json::json!({
"type": "tool_call_start",
"tool": tool,
"timestamp": chrono::Utc::now().to_rfc3339(),

View File

@ -364,6 +364,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
}
},
},
IntegrationEntry {
name: "OpenCode Go",
description: "Subsidized Code-focused AI models",
category: IntegrationCategory::AiModel,
status_fn: |c| {
if c.default_provider.as_deref() == Some("opencode-go") {
IntegrationStatus::Active
} else {
IntegrationStatus::Available
}
},
},
IntegrationEntry {
name: "Z.AI",
description: "Z.AI inference",

View File

@ -27,7 +27,7 @@ impl Observer for LogObserver {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
info!(provider = %provider, model = %model, duration_ms = ms, tokens = ?tokens_used, cost_usd = ?cost_usd, "agent.end");
}
ObserverEvent::ToolCallStart { tool } => {
ObserverEvent::ToolCallStart { tool, .. } => {
info!(tool = %tool, "tool.start");
}
ObserverEvent::ToolCall {

View File

@ -434,6 +434,7 @@ mod tests {
});
obs.record_event(&ObserverEvent::ToolCallStart {
tool: "shell".into(),
arguments: None,
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),

View File

@ -221,7 +221,7 @@ impl Observer for PrometheusObserver {
.inc_by(*output);
}
}
ObserverEvent::ToolCallStart { tool: _ }
ObserverEvent::ToolCallStart { .. }
| ObserverEvent::TurnComplete
| ObserverEvent::LlmRequest { .. } => {}
ObserverEvent::ToolCall {

View File

@ -40,7 +40,10 @@ pub enum ObserverEvent {
cost_usd: Option<f64>,
},
/// A tool call is about to be executed.
ToolCallStart { tool: String },
ToolCallStart {
tool: String,
arguments: Option<String>,
},
/// A tool call has completed with a success/failure outcome.
ToolCall {
tool: String,

View File

@ -33,7 +33,7 @@ impl Observer for VerboseObserver {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
eprintln!("< Receive (success={success}, duration_ms={ms})");
}
ObserverEvent::ToolCallStart { tool } => {
ObserverEvent::ToolCallStart { tool, .. } => {
eprintln!("> Tool {tool}");
}
ObserverEvent::ToolCall {
@ -92,6 +92,7 @@ mod tests {
});
obs.record_event(&ObserverEvent::ToolCallStart {
tool: "shell".into(),
arguments: None,
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),

View File

@ -711,7 +711,7 @@ fn default_model_for_provider(provider: &str) -> String {
"qwen-code" => "qwen3-coder-plus".into(),
"ollama" => "llama3.2".into(),
"llamacpp" => "ggml-org/gpt-oss-20b-GGUF".into(),
"sglang" | "vllm" | "osaurus" => "default".into(),
"sglang" | "vllm" | "osaurus" | "opencode-go" => "default".into(),
"gemini" => "gemini-2.5-pro".into(),
"kimi-code" => "kimi-for-coding".into(),
"bedrock" => "anthropic.claude-sonnet-4-5-20250929-v1:0".into(),
@ -1163,6 +1163,7 @@ fn supports_live_model_fetch(provider_name: &str) -> bool {
| "zai"
| "qwen"
| "nvidia"
| "opencode-go"
)
}
@ -1194,6 +1195,7 @@ fn models_endpoint_for_provider(provider_name: &str) -> Option<&'static str> {
"sglang" => Some("http://localhost:30000/v1/models"),
"vllm" => Some("http://localhost:8000/v1/models"),
"osaurus" => Some("http://localhost:1337/v1/models"),
"opencode-go" => Some("https://opencode.ai/zen/go/v1/models"),
_ => None,
},
}
@ -2195,6 +2197,7 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String,
("zai-cn", "Z.AI — China coding endpoint (open.bigmodel.cn)"),
("synthetic", "Synthetic — Synthetic AI models"),
("opencode", "OpenCode Zen — code-focused AI"),
("opencode-go", "OpenCode Go — Subsidized code-focused AI"),
("cohere", "Cohere — Command R+ & embeddings"),
],
4 => local_provider_choices(),
@ -2879,6 +2882,7 @@ fn provider_env_var(name: &str) -> &'static str {
"zai" => "ZAI_API_KEY",
"synthetic" => "SYNTHETIC_API_KEY",
"opencode" | "opencode-zen" => "OPENCODE_API_KEY",
"opencode-go" => "OPENCODE_GO_API_KEY",
"vercel" | "vercel-ai" => "VERCEL_API_KEY",
"cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY",
"bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID",
@ -7051,6 +7055,7 @@ mod tests {
assert_eq!(provider_env_var("nvidia-nim"), "NVIDIA_API_KEY"); // alias
assert_eq!(provider_env_var("build.nvidia.com"), "NVIDIA_API_KEY"); // alias
assert_eq!(provider_env_var("astrai"), "ASTRAI_API_KEY");
assert_eq!(provider_env_var("opencode-go"), "OPENCODE_GO_API_KEY");
}
#[test]

View File

@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
/// A provider that speaks the OpenAI-compatible chat completions API.
/// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot,
/// Synthetic, `OpenCode` Zen, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc.
/// Synthetic, `OpenCode` Zen, `OpenCode` Go, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc.
#[allow(clippy::struct_excessive_bools)]
pub struct OpenAiCompatibleProvider {
pub(crate) name: String,
@ -2164,6 +2164,16 @@ mod tests {
);
}
#[test]
fn chat_completions_url_opencode_go() {
// OpenCode Go uses /zen/go/v1 base path
let p = make_provider("opencode-go", "https://opencode.ai/zen/go/v1", None);
assert_eq!(
p.chat_completions_url(),
"https://opencode.ai/zen/go/v1/chat/completions"
);
}
#[test]
fn parse_native_response_preserves_tool_call_id() {
let message = ResponseMessage {

View File

@ -839,6 +839,7 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
"synthetic" => vec!["SYNTHETIC_API_KEY"],
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
"opencode-go" => vec!["OPENCODE_GO_API_KEY"],
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
"ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
@ -1099,6 +1100,9 @@ fn create_provider_with_url_and_options(
"opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new(
"OpenCode Zen", "https://opencode.ai/zen/v1", key, AuthStyle::Bearer,
))),
"opencode-go" => Ok(Box::new(OpenAiCompatibleProvider::new(
"OpenCode Go", "https://opencode.ai/zen/go/v1", key, AuthStyle::Bearer,
))),
name if zai_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
"Z.AI",
zai_base_url(name).expect("checked in guard"),
@ -1608,6 +1612,12 @@ pub fn list_providers() -> Vec<ProviderInfo> {
aliases: &["opencode-zen"],
local: false,
},
ProviderInfo {
name: "opencode-go",
display_name: "OpenCode Go",
aliases: &[],
local: false,
},
ProviderInfo {
name: "zai",
display_name: "Z.AI",
@ -2141,6 +2151,22 @@ mod tests {
assert!(create_provider("opencode-zen", Some("key")).is_ok());
}
#[test]
fn factory_opencode_go() {
assert!(create_provider("opencode-go", Some("key")).is_ok());
}
#[test]
fn resolve_provider_credential_opencode_go_env() {
let _env_lock = env_lock();
let _provider_guard = EnvGuard::set("OPENCODE_GO_API_KEY", Some("go-test-key"));
let _generic_guard = EnvGuard::set("API_KEY", None);
let _zeroclaw_guard = EnvGuard::set("ZEROCLAW_API_KEY", None);
let resolved = resolve_provider_credential("opencode-go", None);
assert_eq!(resolved.as_deref(), Some("go-test-key"));
}
#[test]
fn factory_zai() {
assert!(create_provider("zai", Some("key")).is_ok());
@ -2663,6 +2689,7 @@ mod tests {
"kimi-code",
"synthetic",
"opencode",
"opencode-go",
"zai",
"zai-cn",
"glm",

View File

@ -304,6 +304,11 @@ fn factory_resolves_opencode_provider() {
assert_provider_ok("opencode", Some("test-key"), None);
}
#[test]
fn factory_resolves_opencode_go_provider() {
assert_provider_ok("opencode-go", Some("test-key"), None);
}
#[test]
fn factory_resolves_astrai_provider() {
assert_provider_ok("astrai", Some("test-key"), None);

View File

@ -16,6 +16,14 @@ export default defineConfig({
},
server: {
proxy: {
"/health": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/pair": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/api": {
target: "http://localhost:5555",
changeOrigin: true,