添加中文文档
This commit is contained in:
90
docs/zh/APPLE-CONTAINER-NETWORKING.md
Normal file
90
docs/zh/APPLE-CONTAINER-NETWORKING.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Apple Container 网络设置 (macOS 26)
|
||||
|
||||
Apple Container 的 vmnet 网络需要手动配置,容器才能访问互联网。否则容器可以与宿主机通信,但无法访问外部服务(DNS、HTTPS、API)。
|
||||
|
||||
## 快速设置
|
||||
|
||||
运行以下两条命令(需要 `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. 启用 IP 转发,以便宿主机路由容器流量
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. 启用 NAT,以便容器流量通过你的互联网接口进行伪装
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **注意:** 将 `en0` 替换为你的活跃互联网接口。使用 `route get 8.8.8.8 | grep interface` 检查。
|
||||
|
||||
## 持久化设置
|
||||
|
||||
这些设置在重启后会重置。要使其永久生效:
|
||||
|
||||
**IP 转发**——添加到 `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT 规则**——添加到 `/etc/pf.conf`(在任何现有规则之前):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
然后重新加载:`sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS 问题
|
||||
|
||||
默认情况下,DNS 解析器先返回 IPv6 (AAAA) 记录,然后才返回 IPv4 (A) 记录。由于我们的 NAT 仅处理 IPv4,容器内的 Node.js 应用将首先尝试 IPv6 然后失败。
|
||||
|
||||
容器镜像和运行器通过以下方式配置为优先使用 IPv4:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
这在 `Dockerfile` 中设置,并通过 `-e` 标志在 `container-runner.ts` 中传递。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 检查 IP 转发是否已启用
|
||||
sysctl net.inet.ip.forwarding
|
||||
# 预期输出:net.inet.ip.forwarding: 1
|
||||
|
||||
# 测试容器互联网访问
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# 预期输出:404
|
||||
|
||||
# 检查桥接接口(仅当容器运行时存在)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 症状 | 原因 | 修复方案 |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP 转发未启用 | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP 正常,HTTPS 超时 | IPv6 DNS 解析 | 添加 `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS 未转发 | 检查 bridge100 是否存在,验证 pfctl NAT 规则 |
|
||||
| 容器输出后挂起 | agent-runner 缺少 `process.exit(0)` | 重建容器镜像 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
容器 VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → 网关 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← 宿主机网桥,由 vmnet 在容器运行时创建
|
||||
│
|
||||
├── IP 转发(sysctl)将数据包从 bridge100 路由 → en0
|
||||
│
|
||||
├── NAT(pfctl)将 192.168.64.0/24 伪装 → en0 的 IP
|
||||
│
|
||||
en0(你的 WiFi/以太网)→ 互联网
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — macOS 26 上容器无网络
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — 构建期间无法访问互联网 URL
|
||||
81
docs/zh/BRANCH-FORK-MAINTENANCE.md
Normal file
81
docs/zh/BRANCH-FORK-MAINTENANCE.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 分支与 Fork 维护指南
|
||||
|
||||
## 结构
|
||||
|
||||
**`nanocoai/nanoclaw`**(上游)——核心引擎,包含技能定义(`.claude/skills/`)。`main` 分支上没有频道代码。
|
||||
|
||||
**频道 forks**(`nanoclaw-whatsapp`、`nanoclaw-telegram`、`nanoclaw-slack` 等)——每个 fork = 上游 + 一个频道的代码。用户克隆上游,然后将某个 fork 合并到自己的克隆中以添加频道。
|
||||
|
||||
**上游的 `skill/*` 和 `feat/*` 分支**——添加与频道无关的功能(例如 `skill/compact`、`skill/apple-container`)。用户将这些分支合并到自己的克隆中以添加能力。与 forks 重复的频道特定技能分支(例如 `skill/whatsapp`、`skill/telegram`)为历史遗留分支。
|
||||
|
||||
## 用户如何添加能力
|
||||
|
||||
```
|
||||
用户克隆上游 main
|
||||
├── 合并 nanoclaw-whatsapp fork → 添加 WhatsApp
|
||||
├── 合并 skill/compact 分支 → 添加 /compact 命令
|
||||
└── 合并 skill/apple-container → 切换到 Apple Container
|
||||
```
|
||||
|
||||
## 合并方向
|
||||
|
||||
```
|
||||
上游 main ──→ 频道 forks (向前合并以保持 forks 同步)
|
||||
上游 main ──→ skill 分支 (向前合并以保持分支同步)
|
||||
```
|
||||
|
||||
Forks 和 skill 分支携带已应用的代码变更。用户将它们合并到自己的克隆/forks 中以添加能力。它们永远不会被合并回上游 `main`。
|
||||
|
||||
## 向前合并流程
|
||||
|
||||
```bash
|
||||
# 在你的本地 nanoclaw 检出中
|
||||
git checkout main && git pull
|
||||
|
||||
# 对于一个 fork:
|
||||
git fetch nanoclaw-whatsapp
|
||||
git checkout -B whatsapp-merge nanoclaw-whatsapp/main
|
||||
git merge main
|
||||
# 解决冲突(见下文)
|
||||
# 移除仅上游存在的工作流文件(每次合并都会重新添加,因为 main 上有这些文件):
|
||||
git rm .github/workflows/bump-version.yml .github/workflows/update-tokens.yml 2>/dev/null
|
||||
git push nanoclaw-whatsapp HEAD:main
|
||||
git checkout main && git branch -D whatsapp-merge
|
||||
|
||||
# 对于一个 skill 分支:
|
||||
git checkout -B skill/compact origin/skill/compact
|
||||
git merge main
|
||||
# 解决冲突(见下文)
|
||||
git push origin skill/compact
|
||||
git checkout main && git branch -D skill/compact
|
||||
```
|
||||
|
||||
## 冲突解决
|
||||
|
||||
每次都会冲突的相同文件:
|
||||
|
||||
| 文件 | 解决方式 |
|
||||
|------|------------|
|
||||
| `package.json` | 取 main 的版本 + 保留 fork/分支特定的依赖 |
|
||||
| `pnpm-lock.yaml` | `git checkout main -- pnpm-lock.yaml && pnpm install` |
|
||||
| `.env.example` | 合并:main 的条目 + fork/分支特定的条目 |
|
||||
| `repo-tokens/badge.svg` | 取 main 的版本(自动生成) |
|
||||
|
||||
源码变更(例如 `src/types.ts`、`src/index.ts`)通常能自动干净合并,但如果双方修改了相同行也可能冲突。**每次向前合并后一定要构建和测试**——即使 git 报告没有冲突,自动合并的代码也可能静默错误(例如引用了一个已重命名的函数或使用了一个已移除的参数)。
|
||||
|
||||
## 何时向前合并
|
||||
|
||||
在 main 上任何涉及共享文件的变更之后(`package.json`、`src/index.ts`、`CLAUDE.md` 等)。小而频繁的合并 = 轻松的冲突。大而不频繁的合并 = 痛苦。
|
||||
|
||||
## Fork 设置
|
||||
|
||||
创建新的频道 fork 时:
|
||||
|
||||
1. Fork `nanoclaw` 为 `nanoclaw-{channel}`
|
||||
2. 移除仅上游存在的工作流:`bump-version.yml`、`update-tokens.yml`
|
||||
3. 添加频道代码、依赖、环境变量
|
||||
4. 立即向前合并 main 以建立干净的基准
|
||||
|
||||
## 依赖
|
||||
|
||||
Forks 和分支在上游基础上添加自己的依赖。当上游添加或移除一个依赖时,在下一次向前合并后验证 forks/分支仍能构建——传递依赖的变更可能破坏下游代码。
|
||||
14
docs/zh/README.md
Normal file
14
docs/zh/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# NanoClaw 文档
|
||||
|
||||
官方文档位于 **[docs.nanoclaw.dev](https://docs.nanoclaw.dev)**。
|
||||
|
||||
本目录中的文件是原始设计文档和开发者参考资料。如需获取最新、最准确的信息,请使用文档网站。
|
||||
|
||||
| 本目录 | 文档网站 |
|
||||
|---|---|
|
||||
| [SPEC.md](SPEC.md) | [架构](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [安全模型](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [简介](https://docs.nanoclaw.dev/introduction) |
|
||||
| [skills-as-branches.md](skills-as-branches.md) | [技能系统](https://docs.nanoclaw.dev/integrations/skills-system) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker 沙盒](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [容器运行时](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
187
docs/zh/REQUIREMENTS.md
Normal file
187
docs/zh/REQUIREMENTS.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# NanoClaw 需求
|
||||
|
||||
项目创建者的原始需求和设计决策。
|
||||
|
||||
---
|
||||
|
||||
## 为什么存在这个项目
|
||||
|
||||
这是 OpenClaw(原名 ClawBot)的轻量、安全的替代方案。那个项目变得臃肿至极——4 到 5 个不同的进程运行不同的网关,无穷无尽的配置文件,无穷无尽的集成。它是一个安全噩梦,agent 不在隔离的进程中运行;有各种漏洞百出的变通方案试图阻止它们访问不应访问的系统部分。任何人都不可能真正理解整个代码库。运行它基本上就是在赌运气。
|
||||
|
||||
NanoClaw 为您提供核心功能,没有那些混乱。
|
||||
|
||||
---
|
||||
|
||||
## 理念
|
||||
|
||||
### 小到可以读懂
|
||||
|
||||
整个代码库应该是您可以阅读和理解的东西。一个 Node.js 进程。少量源文件。没有微服务,没有消息队列,没有抽象层。
|
||||
|
||||
### 通过真正的隔离实现安全
|
||||
|
||||
不是通过应用级权限系统试图阻止 agent 访问东西,agent 运行在实际的 Linux 容器中。隔离在操作系统层面。Agent 只能看到显式挂载的内容。Bash 访问是安全的,因为命令在容器内运行,而不是在您的 Mac 上。
|
||||
|
||||
### 为个人用户构建
|
||||
|
||||
这不是一个框架或平台。它是为每个用户量身定制的软件。您 fork 仓库,添加您想要的渠道(WhatsApp、Telegram、Discord、Slack、Gmail),最终得到做您所需事情的干净代码。
|
||||
|
||||
### 定制 = 代码修改
|
||||
|
||||
没有配置膨胀。如果您想要不同的行为,修改代码。代码库足够小,这样做既安全又实用。极小部分内容如触发词在配置中。其他一切——只需修改代码做您想做的事。
|
||||
|
||||
### AI 原生开发
|
||||
|
||||
我不需要安装向导——Claude Code 引导设置。我不需要监控仪表板——我问 Claude Code 发生了什么。我不需要精心设计的日志 UI——我让 Claude 读日志。我不需要调试工具——我描述问题,Claude 修复它。
|
||||
|
||||
代码库假设您有一个 AI 协作者。它不需要过度自文档化或自调试,因为 Claude 始终在那里。
|
||||
|
||||
### Skills 优先于功能
|
||||
|
||||
当人们贡献时,他们不应该在支持 WhatsApp 的同时再添加"Telegram 支持"。他们应该贡献一个 skill,如 `/add-telegram`,来改造代码库。用户 fork 仓库,运行 skills 进行定制,最终得到做他们所需事情的干净代码——而不是一个试图同时支持每个人用例的臃肿系统。
|
||||
|
||||
---
|
||||
|
||||
## RFS(Request for Skills)
|
||||
|
||||
我们希望看到贡献的 skills:
|
||||
|
||||
### 通信渠道
|
||||
- `/add-signal` - 添加 Signal 作为渠道
|
||||
- `/add-matrix` - 添加 Matrix 集成
|
||||
|
||||
> **注意:** Telegram、Slack、Discord、Gmail 和 Apple Container 的 skills 已经存在。完整列表请参见 [skills 文档](https://docs.nanoclaw.dev/integrations/skills-system)。
|
||||
|
||||
---
|
||||
|
||||
## 愿景
|
||||
|
||||
一个可通过消息访问的个人 Claude 助手,使用最少的自定义代码。
|
||||
|
||||
**核心组件:**
|
||||
- **Claude Agent SDK** 作为核心 agent
|
||||
- **容器(Containers)** 用于隔离的 agent 执行(Linux VM)
|
||||
- **多渠道消息**(WhatsApp、Telegram、Discord、Slack、Gmail)——仅添加您需要的渠道
|
||||
- **持久化记忆**——按对话和全局
|
||||
- **定时任务**,运行 Claude 并能回复消息
|
||||
- **Web 访问**,用于搜索和浏览
|
||||
- **浏览器自动化**,通过 agent-browser
|
||||
|
||||
**实现方法:**
|
||||
- 使用现有工具(渠道库、Claude Agent SDK、MCP 服务器)
|
||||
- 最少的胶水代码
|
||||
- 尽可能使用基于文件的系统(CLAUDE.md 用于记忆,目录用于群组)
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
### 消息路由
|
||||
- 路由器监听已连接的渠道,根据配置路由消息
|
||||
- 仅处理来自已注册群组的消息
|
||||
- 触发词:`@Andy` 前缀(不区分大小写),可通过 `ASSISTANT_NAME` 环境变量配置
|
||||
- 未注册的群组完全被忽略
|
||||
|
||||
### 记忆系统
|
||||
- **按群组记忆**:每个群组有一个带自己 `CLAUDE.md` 的目录
|
||||
- **全局记忆**:根 `CLAUDE.md` 供所有群组读取,但只能从"主群组"(自我聊天)写入
|
||||
- **文件**:群组可以在其目录中创建/读取文件并引用它们
|
||||
- Agent 运行在群组的目录中,自动继承两个 CLAUDE.md 文件
|
||||
|
||||
### Session 管理
|
||||
- 每个群组维护一个对话 session(通过 Claude Agent SDK)
|
||||
- Sessions 在上下文过长时自动压缩,保留关键信息
|
||||
|
||||
### 容器隔离
|
||||
- 所有 agent 在容器(轻量级 Linux VM)内运行
|
||||
- 每次 agent 调用启动一个挂载了目录的容器
|
||||
- 容器提供文件系统隔离——agent 只能看到已挂载的路径
|
||||
- Bash 访问安全,因为命令在容器内运行,而非在宿主机上
|
||||
- 通过 agent-browser 和容器中的 Chromium 进行浏览器自动化
|
||||
|
||||
### 定时任务
|
||||
- 用户可以从任何群组要求 Claude 安排定期或一次性任务
|
||||
- 任务在创建它们的群组上下文中作为完整 agent 运行
|
||||
- 任务可以访问包括 Bash 在内的所有工具(在容器中安全)
|
||||
- 任务可以选择性地通过 `send_message` 工具向群组发送消息,或静默完成
|
||||
- 任务运行记录到数据库,包含持续时间和结果
|
||||
- 调度类型:cron 表达式、间隔(毫秒)或一次性(ISO 时间戳)
|
||||
- 从主群组:可以为任何群组安排任务,查看/管理所有任务
|
||||
- 从其他群组:只能管理该群组的任务
|
||||
|
||||
### 群组管理
|
||||
- 新群组通过主渠道显式添加
|
||||
- 群组在 SQLite 中注册(通过主渠道或 IPC `register_group` 命令)
|
||||
- 每个群组在 `groups/` 下获得一个专用目录
|
||||
- 群组可以通过 `containerConfig` 挂载额外目录
|
||||
|
||||
### 主渠道权限
|
||||
- 主渠道是管理员/控制群组(通常是自我聊天)
|
||||
- 可以向全局记忆写入(`groups/CLAUDE.md`)
|
||||
- 可以为任何群组安排定时任务
|
||||
- 可以查看和管理所有群组的任务
|
||||
- 可以为任何群组配置额外的目录挂载
|
||||
|
||||
---
|
||||
|
||||
## 集成点
|
||||
|
||||
### 渠道
|
||||
- WhatsApp (baileys)、Telegram (grammy)、Discord (discord.js)、Slack (@slack/bolt)、Gmail (googleapis)
|
||||
- 每个渠道存于单独的 fork 仓库中,通过 skills(例如 `/add-whatsapp`、`/add-telegram`)添加
|
||||
- 消息存储在 SQLite 中,由路由器轮询
|
||||
- 渠道在启动时自行注册——未配置的渠道被跳过并发出警告
|
||||
|
||||
### 调度器
|
||||
- 内置调度器在宿主机上运行,启动容器执行任务
|
||||
- 自定义 `nanoclaw` MCP 服务器(容器内)提供调度工具
|
||||
- 工具:`schedule_task`、`list_tasks`、`pause_task`、`resume_task`、`cancel_task`、`send_message`
|
||||
- 任务存储在 SQLite 中,带运行历史
|
||||
- 调度器循环每分钟检查到期任务
|
||||
- 任务在容器化的群组上下文中执行 Claude Agent SDK
|
||||
|
||||
### Web 访问
|
||||
- 内置 WebSearch 和 WebFetch 工具
|
||||
- 标准 Claude Agent SDK 能力
|
||||
|
||||
### 浏览器自动化
|
||||
- 容器中的 agent-browser CLI 和 Chromium
|
||||
- 基于快照的交互,带元素引用(@e1、@e2 等)
|
||||
- 截图、PDF、视频录制
|
||||
- 认证状态持久化
|
||||
|
||||
---
|
||||
|
||||
## 设置与定制
|
||||
|
||||
### 理念
|
||||
- 最小化配置文件
|
||||
- 通过 Claude Code 进行设置和定制
|
||||
- 用户克隆仓库并运行 Claude Code 进行配置
|
||||
- 每个用户得到精确匹配其需求的定制设置
|
||||
|
||||
### Skills
|
||||
- `/setup` - 安装依赖,配置渠道,启动服务
|
||||
- `/customize` - 通用 skill,用于添加能力
|
||||
- `/update-nanoclaw` - 拉取上游更改,与定制合并
|
||||
|
||||
### 部署
|
||||
- 运行在 macOS (launchd)、Linux (systemd) 或 Windows (WSL2) 上
|
||||
- 单个 Node.js 进程处理一切
|
||||
|
||||
---
|
||||
|
||||
## 个人配置(参考)
|
||||
|
||||
以下是创建者的设置,存档于此供参考:
|
||||
|
||||
- **触发词**:`@Andy`(不区分大小写)
|
||||
- **响应前缀**:`Andy:`
|
||||
- **角色**:默认 Claude(无自定义个性)
|
||||
- **主渠道**:自我聊天(在 WhatsApp 中给自己发消息)
|
||||
|
||||
---
|
||||
|
||||
## 项目名称
|
||||
|
||||
**NanoClaw** - 引用 Clawdbot(现为 OpenClaw)。
|
||||
643
docs/zh/SDK_DEEP_DIVE.md
Normal file
643
docs/zh/SDK_DEEP_DIVE.md
Normal file
@@ -0,0 +1,643 @@
|
||||
# Claude Agent SDK 深度剖析
|
||||
|
||||
对 `@anthropic-ai/claude-agent-sdk` v0.2.29–0.2.34 的反向工程发现,以理解 `query()` 如何工作、为什么 agent teams 子代理(subagents)被终止,以及如何修复。补充了官方 SDK 参考文档。
|
||||
|
||||
## 架构(Architecture)
|
||||
|
||||
```
|
||||
Agent Runner (我们的代码)
|
||||
└── query() → SDK (sdk.mjs)
|
||||
└── 生成 CLI 子进程 (cli.js)
|
||||
└── Claude API 调用、工具执行
|
||||
└── Task 工具 → 生成子代理子进程
|
||||
```
|
||||
|
||||
SDK 以子进程形式生成 `cli.js`,带有 `--output-format stream-json --input-format stream-json --print --verbose` 标志。通信通过 stdin/stdout 上的 JSON 行进行。
|
||||
|
||||
`query()` 返回一个扩展了 `AsyncGenerator<SDKMessage, void>` 的 `Query` 对象。内部机制:
|
||||
|
||||
- SDK 以子进程形式生成 CLI,通过 stdin/stdout JSON 行通信
|
||||
- SDK 的 `readMessages()` 从 CLI stdout 读取,排入内部流
|
||||
- `readSdkMessages()` 异步生成器从该流中产出
|
||||
- `[Symbol.asyncIterator]` 返回 `readSdkMessages()`
|
||||
- 迭代器仅在 CLI 关闭 stdout 时返回 `done: true`
|
||||
|
||||
V1 (`query()`) 和 V2 (`createSession`/`send`/`stream`) 都使用完全相同的三层架构:
|
||||
|
||||
```
|
||||
SDK (sdk.mjs) CLI 进程 (cli.js)
|
||||
-------------- --------------------
|
||||
XX Transport ------> stdin 读取器 (bd1)
|
||||
(生成 cli.js) |
|
||||
$X Query <------ stdout 写入器
|
||||
(JSON 行) |
|
||||
EZ() 递归生成器
|
||||
|
|
||||
Anthropic Messages API
|
||||
```
|
||||
|
||||
## 核心代理循环 (EZ)
|
||||
|
||||
在 CLI 内部,代理循环是一个名为 `EZ()` 的**递归异步生成器**,而非迭代 while 循环:
|
||||
|
||||
```
|
||||
EZ({ messages, systemPrompt, canUseTool, maxTurns, turnCount=1, ... })
|
||||
```
|
||||
|
||||
每次调用 = 一次对 Claude 的 API 调用(一个"轮次(turn)")。
|
||||
|
||||
### 每轮流程:
|
||||
|
||||
1. **准备消息**——裁剪上下文,必要时运行压缩(compaction)
|
||||
2. **调用 Anthropic API**(通过 `mW1` 流式函数)
|
||||
3. **从响应中提取 tool_use 块**
|
||||
4. **分支:**
|
||||
- 如果**没有 tool_use 块**→ 停止(运行停止钩子,返回)
|
||||
- 如果**存在 tool_use 块** → 执行工具,递增 turnCount,递归
|
||||
|
||||
所有复杂逻辑——代理循环、工具执行、后台任务(background tasks)、队友(teammate)编排——都在 CLI 子进程内运行。`query()` 只是一个薄传输包装器。
|
||||
|
||||
## query() 选项
|
||||
|
||||
官方文档中的完整 `Options` 类型:
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|----------|------|---------|-------------|
|
||||
| `abortController` | `AbortController` | `new AbortController()` | 用于取消操作的控制器 |
|
||||
| `additionalDirectories` | `string[]` | `[]` | Claude 可访问的附加目录 |
|
||||
| `agents` | `Record<string, AgentDefinition>` | `undefined` | 以编程方式定义子代理(不是 agent teams——无编排功能) |
|
||||
| `allowDangerouslySkipPermissions` | `boolean` | `false` | 使用 `permissionMode: 'bypassPermissions'` 时必需 |
|
||||
| `allowedTools` | `string[]` | 所有工具 | 允许的工具名称列表 |
|
||||
| `betas` | `SdkBeta[]` | `[]` | Beta 功能(例如 `['context-1m-2025-08-07']` 用于 1M 上下文) |
|
||||
| `canUseTool` | `CanUseTool` | `undefined` | 自定义工具使用权限函数 |
|
||||
| `continue` | `boolean` | `false` | 继续最近的对话 |
|
||||
| `cwd` | `string` | `process.cwd()` | 当前工作目录 |
|
||||
| `disallowedTools` | `string[]` | `[]` | 禁用的工具名称列表 |
|
||||
| `enableFileCheckpointing` | `boolean` | `false` | 启用文件更改追踪以支持回退 |
|
||||
| `env` | `Dict<string>` | `process.env` | 环境变量 |
|
||||
| `executable` | `'bun' \| 'deno' \| 'node'` | 自动检测 | JavaScript 运行时 |
|
||||
| `fallbackModel` | `string` | `undefined` | 主模型失败时使用的模型 |
|
||||
| `forkSession` | `boolean` | `false` | 恢复时,分叉到新的 session ID 而非继续原始会话 |
|
||||
| `hooks` | `Partial<Record<HookEvent, HookCallbackMatcher[]>>` | `{}` | 事件钩子回调 |
|
||||
| `includePartialMessages` | `boolean` | `false` | 包含部分消息事件(流式) |
|
||||
| `maxBudgetUsd` | `number` | `undefined` | 查询的最大美元预算 |
|
||||
| `maxThinkingTokens` | `number` | `undefined` | 思考过程的最大 token 数 |
|
||||
| `maxTurns` | `number` | `undefined` | 最大对话轮次 |
|
||||
| `mcpServers` | `Record<string, McpServerConfig>` | `{}` | MCP 服务器配置 |
|
||||
| `model` | `string` | CLI 默认值 | 使用的 Claude 模型 |
|
||||
| `outputFormat` | `{ type: 'json_schema', schema: JSONSchema }` | `undefined` | 结构化输出格式 |
|
||||
| `pathToClaudeCodeExecutable` | `string` | 使用内置版本 | Claude Code 可执行文件路径 |
|
||||
| `permissionMode` | `PermissionMode` | `'default'` | 权限模式 |
|
||||
| `plugins` | `SdkPluginConfig[]` | `[]` | 从本地路径加载自定义插件 |
|
||||
| `resume` | `string` | `undefined` | 要恢复的 session ID |
|
||||
| `resumeSessionAt` | `string` | `undefined` | 在特定消息 UUID 处恢复会话 |
|
||||
| `sandbox` | `SandboxSettings` | `undefined` | 沙箱行为配置 |
|
||||
| `settingSources` | `SettingSource[]` | `[]`(无) | 要加载的文件系统设置。必须包含 `'project'` 才能加载 CLAUDE.md |
|
||||
| `stderr` | `(data: string) => void` | `undefined` | stderr 输出回调 |
|
||||
| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | 系统提示词。使用预设可获取 Claude Code 的提示词,可选 `append` |
|
||||
| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | 工具配置 |
|
||||
|
||||
### PermissionMode
|
||||
|
||||
```typescript
|
||||
type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
```
|
||||
|
||||
### SettingSource
|
||||
|
||||
```typescript
|
||||
type SettingSource = 'user' | 'project' | 'local';
|
||||
// 'user' → ~/.claude/settings.json
|
||||
// 'project' → .claude/settings.json(版本控制的)
|
||||
// 'local' → .claude/settings.local.json(gitignored)
|
||||
```
|
||||
|
||||
省略时,SDK 不加载任何文件系统设置(默认隔离)。优先级:local > project > user。编程方式提供的选项始终覆盖文件系统设置。
|
||||
|
||||
### AgentDefinition
|
||||
|
||||
以编程方式定义的子代理(不是 agent teams——这些更简单,无代理间协调):
|
||||
|
||||
```typescript
|
||||
type AgentDefinition = {
|
||||
description: string; // 何时使用此代理
|
||||
tools?: string[]; // 允许的工具(省略则继承全部)
|
||||
prompt: string; // 代理的系统提示词
|
||||
model?: 'sonnet' | 'opus' | 'haiku' | 'inherit';
|
||||
}
|
||||
```
|
||||
|
||||
### McpServerConfig
|
||||
|
||||
```typescript
|
||||
type McpServerConfig =
|
||||
| { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
||||
| { type: 'sse'; url: string; headers?: Record<string, string> }
|
||||
| { type: 'http'; url: string; headers?: Record<string, string> }
|
||||
| { type: 'sdk'; name: string; instance: McpServer } // 进程内
|
||||
```
|
||||
|
||||
### SdkBeta
|
||||
|
||||
```typescript
|
||||
type SdkBeta = 'context-1m-2025-08-07';
|
||||
// 为 Opus 4.6、Sonnet 4.5、Sonnet 4 启用 1M token 上下文窗口
|
||||
```
|
||||
|
||||
### CanUseTool
|
||||
|
||||
```typescript
|
||||
type CanUseTool = (
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
options: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
|
||||
) => Promise<PermissionResult>;
|
||||
|
||||
type PermissionResult =
|
||||
| { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] }
|
||||
| { behavior: 'deny'; message: string; interrupt?: boolean };
|
||||
```
|
||||
|
||||
## SDKMessage 类型
|
||||
|
||||
`query()` 可以产出 16 种消息类型。官方文档展示了一个简化的 7 种联合类型,但 `sdk.d.ts` 包含完整集合:
|
||||
|
||||
| 类型 | 子类型 | 用途 |
|
||||
|------|---------|---------|
|
||||
| `system` | `init` | 会话已初始化,包含 session_id、tools、model |
|
||||
| `system` | `task_notification` | 后台代理已完成/失败/停止 |
|
||||
| `system` | `compact_boundary` | 对话已压缩 |
|
||||
| `system` | `status` | 状态变更(例如正在压缩) |
|
||||
| `system` | `hook_started` | 钩子执行已开始 |
|
||||
| `system` | `hook_progress` | 钩子进度输出 |
|
||||
| `system` | `hook_response` | 钩子已完成 |
|
||||
| `system` | `files_persisted` | 文件已保存 |
|
||||
| `assistant` | — | Claude 的响应(文本 + 工具调用) |
|
||||
| `user` | — | 用户消息(内部) |
|
||||
| `user` (replay) | — | 恢复时重放的用户消息 |
|
||||
| `result` | `success` / `error_*` | 提示词处理轮的最终结果 |
|
||||
| `stream_event` | — | 部分流式内容(当 includePartialMessages 时) |
|
||||
| `tool_progress` | — | 长时间运行工具进度 |
|
||||
| `auth_status` | — | 认证状态变化 |
|
||||
| `tool_use_summary` | — | 前置工具使用的摘要 |
|
||||
|
||||
### SDKTaskNotificationMessage (sdk.d.ts:1507)
|
||||
|
||||
```typescript
|
||||
type SDKTaskNotificationMessage = {
|
||||
type: 'system';
|
||||
subtype: 'task_notification';
|
||||
task_id: string;
|
||||
status: 'completed' | 'failed' | 'stopped';
|
||||
output_file: string;
|
||||
summary: string;
|
||||
uuid: UUID;
|
||||
session_id: string;
|
||||
};
|
||||
```
|
||||
|
||||
### SDKResultMessage (sdk.d.ts:1375)
|
||||
|
||||
两种变体,共享字段:
|
||||
|
||||
```typescript
|
||||
// 两种变体共享的字段:
|
||||
// uuid, session_id, duration_ms, duration_api_ms, is_error, num_turns,
|
||||
// total_cost_usd, usage: NonNullableUsage, modelUsage, permission_denials
|
||||
|
||||
// 成功:
|
||||
type SDKResultSuccess = {
|
||||
type: 'result';
|
||||
subtype: 'success';
|
||||
result: string;
|
||||
structured_output?: unknown;
|
||||
// ...共享字段
|
||||
};
|
||||
|
||||
// 错误:
|
||||
type SDKResultError = {
|
||||
type: 'result';
|
||||
subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries';
|
||||
errors: string[];
|
||||
// ...共享字段
|
||||
};
|
||||
```
|
||||
|
||||
result 上有用的字段:`total_cost_usd`、`duration_ms`、`num_turns`、`modelUsage`(按模型细分的 `costUSD`、`inputTokens`、`outputTokens`、`contextWindow`)。
|
||||
|
||||
### SDKAssistantMessage
|
||||
|
||||
```typescript
|
||||
type SDKAssistantMessage = {
|
||||
type: 'assistant';
|
||||
uuid: UUID;
|
||||
session_id: string;
|
||||
message: APIAssistantMessage; // 来自 Anthropic SDK
|
||||
parent_tool_use_id: string | null; // 非空时来自子代理
|
||||
};
|
||||
```
|
||||
|
||||
### SDKSystemMessage (init)
|
||||
|
||||
```typescript
|
||||
type SDKSystemMessage = {
|
||||
type: 'system';
|
||||
subtype: 'init';
|
||||
uuid: UUID;
|
||||
session_id: string;
|
||||
apiKeySource: ApiKeySource;
|
||||
cwd: string;
|
||||
tools: string[];
|
||||
mcp_servers: { name: string; status: string }[];
|
||||
model: string;
|
||||
permissionMode: PermissionMode;
|
||||
slash_commands: string[];
|
||||
output_style: string;
|
||||
};
|
||||
```
|
||||
|
||||
## 轮次行为:代理何时停止与继续
|
||||
|
||||
### 代理停止时(不再进行 API 调用)
|
||||
|
||||
**1. 响应中没有 tool_use 块(主要情况)**
|
||||
|
||||
Claude 仅以文本响应——它判定已完成任务。API 的 `stop_reason` 将为 `"end_turn"`。SDK 不做此决定——完全由 Claude 的模型输出驱动。
|
||||
|
||||
**2. 超过最大轮次**——导致 `SDKResultError`,`subtype: "error_max_turns"`。
|
||||
|
||||
**3. 中止信号**——通过 `abortController` 用户中断。
|
||||
|
||||
**4. 超出预算**——`totalCost >= maxBudgetUsd` → `"error_max_budget_usd"`。
|
||||
|
||||
**5. 停止钩子阻止继续**——钩子返回 `{preventContinuation: true}`。
|
||||
|
||||
### 代理继续时(进行另一次 API 调用)
|
||||
|
||||
**1. 响应包含 tool_use 块(主要情况)**——执行工具,递增 turnCount,递归进入 EZ。
|
||||
|
||||
**2. max_output_tokens 恢复**——最多 3 次重试,附带"将工作拆分为更小片段"的上下文消息。
|
||||
|
||||
**3. 停止钩子阻塞错误**——错误作为上下文消息反馈,循环继续。
|
||||
|
||||
**4. 模型回退**——使用回退模型重试(一次性)。
|
||||
|
||||
### 决策表
|
||||
|
||||
| 条件 | 动作 | 结果类型 |
|
||||
|-----------|--------|-------------|
|
||||
| 响应有 `tool_use` 块 | 执行工具,递归进入 `EZ` | 继续 |
|
||||
| 响应没有 `tool_use` 块 | 运行停止钩子,返回 | `success` |
|
||||
| `turnCount > maxTurns` | 产出 max_turns_reached | `error_max_turns` |
|
||||
| `totalCost >= maxBudgetUsd` | 产出预算错误 | `error_max_budget_usd` |
|
||||
| `abortController.signal.aborted` | 产出中断消息 | 取决于上下文 |
|
||||
| `stop_reason === "max_tokens"`(输出) | 使用恢复提示词最多重试 3 次 | 继续 |
|
||||
| 停止钩子 `preventContinuation` | 立即返回 | `success` |
|
||||
| 停止钩子阻塞错误 | 反馈错误,递归 | 继续 |
|
||||
| 模型回退错误 | 使用回退模型重试(一次性) | 继续 |
|
||||
|
||||
## 子代理执行模式
|
||||
|
||||
### 情况 1:同步子代理 (`run_in_background: false`)——阻塞
|
||||
|
||||
父代理调用 Task 工具 → `VR()` 为子代理运行 `EZ()` → 父代理等待完整结果 → 工具结果返回给父代理 → 父代理继续。
|
||||
|
||||
子代理运行完整的递归 EZ 循环。父代理的工具执行通过 `await` 挂起。存在一个执行中"提升"机制:同步子代理可以通过 `Promise.race()` 与 `backgroundSignal` promise 竞争被提升为后台。
|
||||
|
||||
### 情况 2:后台任务 (`run_in_background: true`)——不等待
|
||||
|
||||
- **Bash 工具:**命令启动,工具立即返回空结果 + `backgroundTaskId`
|
||||
- **Task/Agent 工具:**子代理在即发即忘(fire-and-forget)包装器 (`g01()`) 中启动,工具立即返回 `status: "async_launched"` + `outputFile` 路径
|
||||
|
||||
在发出 `type: "result"` 消息之前,零"等待后台任务"逻辑。当后台任务完成时,单独发出 `SDKTaskNotificationMessage`。
|
||||
|
||||
### 情况 3:Agent Teams (TeammateTool / SendMessage)——先结果,后轮询
|
||||
|
||||
团队领导者运行其正常的 EZ 循环,其中包括生成队友。当领导者的 EZ 循环完成时,发出 `type: "result"`。然后领导者进入结果后轮询循环:
|
||||
|
||||
```javascript
|
||||
while (true) {
|
||||
// 检查是否没有活跃队友且没有正在运行的任务 → 跳出
|
||||
// 检查来自队友的未读消息 → 作为新提示词重新注入,重启 EZ 循环
|
||||
// 如果 stdin 关闭且有活跃队友 → 注入关闭提示词
|
||||
// 每 500ms 轮询
|
||||
}
|
||||
```
|
||||
|
||||
从 SDK 消费者角度看:你收到初始的 `type: "result"`,但 AsyncGenerator 可能继续产出更多消息,因为团队领导者处理队友响应并重新进入代理循环。生成器只有在所有队友都已关闭时才真正完成。
|
||||
|
||||
## isSingleUserTurn 问题
|
||||
|
||||
来自 sdk.mjs:
|
||||
|
||||
```javascript
|
||||
QK = typeof X === "string" // isSingleUserTurn = true,当 prompt 为字符串时
|
||||
```
|
||||
|
||||
当 `isSingleUserTurn` 为 true 且第一个 `result` 消息到达时:
|
||||
|
||||
```javascript
|
||||
if (this.isSingleUserTurn) {
|
||||
this.transport.endInput(); // 向 CLI 关闭 stdin
|
||||
}
|
||||
```
|
||||
|
||||
这触发连锁反应:
|
||||
|
||||
1. SDK 关闭 CLI stdin
|
||||
2. CLI 检测到 stdin 关闭
|
||||
3. 轮询循环看到 `D = true`(stdin 关闭)且有活跃队友
|
||||
4. 注入关闭提示词 → 领导者向所有队友发送 `shutdown_request`
|
||||
5. **队友在搜索中途被杀死**
|
||||
|
||||
关闭提示词(通过在压缩的 cli.js 中的 `BGq` 变量找到):
|
||||
|
||||
```
|
||||
You are running in non-interactive mode and cannot return a response
|
||||
to the user until your team is shut down.
|
||||
|
||||
You MUST shut down your team before preparing your final response:
|
||||
1. Use requestShutdown to ask each team member to shut down gracefully
|
||||
2. Wait for shutdown approvals
|
||||
3. Use the cleanup operation to clean up the team
|
||||
4. Only then provide your final response to the user
|
||||
```
|
||||
|
||||
### 实际问题
|
||||
|
||||
使用 V1 `query()` + 字符串 prompt + agent teams:
|
||||
|
||||
1. 领导者生成队友,他们开始搜索
|
||||
2. 领导者的 EZ 循环结束("我已经派出了团队,他们正在处理此事")
|
||||
3. 发出 `type: "result"`
|
||||
4. SDK 看到 `isSingleUserTurn = true` → 立即关闭 stdin
|
||||
5. 轮询循环检测到 stdin 关闭 + 活跃队友 → 注入关闭提示词
|
||||
6. 领导者向所有队友发送 `shutdown_request`
|
||||
7. **队友可能刚开始了 10 秒的 5 分钟搜索任务,就被要求停止**
|
||||
|
||||
## 修复方案:流式输入模式
|
||||
|
||||
不传递字符串 prompt(这会设置 `isSingleUserTurn = true`),而是传递一个 `AsyncIterable<SDKUserMessage>`:
|
||||
|
||||
```typescript
|
||||
// 之前(对 agent teams 有问题的写法):
|
||||
query({ prompt: "do something" })
|
||||
|
||||
// 之后(保持 CLI 存活):
|
||||
query({ prompt: asyncIterableOfMessages })
|
||||
```
|
||||
|
||||
当 prompt 是 `AsyncIterable` 时:
|
||||
- `isSingleUserTurn = false`
|
||||
- SDK 在第一个 result 后**不会**关闭 stdin
|
||||
- CLI 保持活跃,继续处理
|
||||
- 后台代理保持运行
|
||||
- `task_notification` 消息流经迭代器
|
||||
- 我们控制何时结束可迭代对象
|
||||
|
||||
### 额外好处:流式传入新消息
|
||||
|
||||
使用异步可迭代对象方案,我们可以在代理仍在工作时将新的 WhatsApp 消息推入可迭代对象。不用将消息排队直到容器退出再生成新容器,我们直接将它们流入正在运行的会话。
|
||||
|
||||
### 使用 Agent Teams 的预期生命周期
|
||||
|
||||
使用异步可迭代对象修复 (`isSingleUserTurn = false`),stdin 保持打开,因此 CLI 永远不会触发队友检查或关闭提示词注入:
|
||||
|
||||
```
|
||||
1. system/init → 会话已初始化
|
||||
2. assistant/user → Claude 推理、工具调用、工具结果
|
||||
3. ... → 更多 assistant/user 轮次(生成子代理等)
|
||||
4. result #1 → 领导代理的第一次响应(捕获)
|
||||
5. task_notification(s) → 后台代理完成/失败/停止
|
||||
6. assistant/user → 领导代理继续(处理子代理结果)
|
||||
7. result #2 → 领导代理的后续响应(捕获)
|
||||
8. [iterator done] → CLI 关闭 stdout,全部完成
|
||||
```
|
||||
|
||||
所有 result 都有意义——捕获每一个,而不仅仅是第一个。
|
||||
|
||||
## V1 与 V2 API
|
||||
|
||||
### V1:`query()`——一次性异步生成器
|
||||
|
||||
```typescript
|
||||
const q = query({ prompt: "...", options: {...} });
|
||||
for await (const msg of q) { /* 处理事件 */ }
|
||||
```
|
||||
|
||||
- 当 `prompt` 是字符串时:`isSingleUserTurn = true` → stdin 在第一个 result 后自动关闭
|
||||
- 对于多轮次:必须传递 `AsyncIterable<SDKUserMessage>` 并自行管理协调
|
||||
|
||||
### V2:`createSession()` + `send()` / `stream()`——持久化会话
|
||||
|
||||
```typescript
|
||||
await using session = unstable_v2_createSession({ model: "..." });
|
||||
await session.send("first message");
|
||||
for await (const msg of session.stream()) { /* 事件 */ }
|
||||
await session.send("follow-up");
|
||||
for await (const msg of session.stream()) { /* 事件 */ }
|
||||
```
|
||||
|
||||
- `isSingleUserTurn = false` 始终 → stdin 保持打开
|
||||
- `send()` 排入异步队列 (`QX`)
|
||||
- `stream()` 从同一个消息生成器产出,遇到 `result` 类型时停止
|
||||
- 多轮次很自然——只需交替 `send()` / `stream()`
|
||||
- V2 在内部**不**调用 V1 `query()`——两者独立创建 Transport + Query
|
||||
|
||||
### 对比表
|
||||
|
||||
| 方面 | V1 | V2 |
|
||||
|--------|----|----|
|
||||
| `isSingleUserTurn` | 字符串 prompt 时为 `true` | 始终 `false` |
|
||||
| 多轮次 | 需要管理 `AsyncIterable` | 只需调用 `send()`/`stream()` |
|
||||
| stdin 生命周期 | 第一个 result 后自动关闭 | 保持打开直到 `close()` |
|
||||
| 代理循环 | 相同的 `EZ()` | 相同的 `EZ()` |
|
||||
| 停止条件 | 相同 | 相同 |
|
||||
| 会话持久化 | 必须向新的 `query()` 传递 `resume` | 通过 session 对象内置 |
|
||||
| API 稳定性 | 稳定 | 不稳定预览(`unstable_v2_*` 前缀) |
|
||||
|
||||
**关键发现:轮次行为零差异。**两者使用相同的 CLI 进程、相同的 `EZ()` 递归生成器和相同的决策逻辑。
|
||||
|
||||
## 钩子事件
|
||||
|
||||
```typescript
|
||||
type HookEvent =
|
||||
| 'PreToolUse' // 工具执行前
|
||||
| 'PostToolUse' // 工具成功执行后
|
||||
| 'PostToolUseFailure' // 工具执行失败后
|
||||
| 'Notification' // 通知消息
|
||||
| 'UserPromptSubmit' // 用户提示词已提交
|
||||
| 'SessionStart' // 会话已启动(启动/恢复/清除/压缩)
|
||||
| 'SessionEnd' // 会话已结束
|
||||
| 'Stop' // 代理正在停止
|
||||
| 'SubagentStart' // 子代理已生成
|
||||
| 'SubagentStop' // 子代理已停止
|
||||
| 'PreCompact' // 对话压缩前
|
||||
| 'PermissionRequest'; // 正在请求权限
|
||||
```
|
||||
|
||||
### 钩子配置
|
||||
|
||||
```typescript
|
||||
interface HookCallbackMatcher {
|
||||
matcher?: string; // 可选的工具名称匹配器
|
||||
hooks: HookCallback[];
|
||||
}
|
||||
|
||||
type HookCallback = (
|
||||
input: HookInput,
|
||||
toolUseID: string | undefined,
|
||||
options: { signal: AbortSignal }
|
||||
) => Promise<HookJSONOutput>;
|
||||
```
|
||||
|
||||
### 钩子返回值
|
||||
|
||||
```typescript
|
||||
type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput;
|
||||
|
||||
type AsyncHookJSONOutput = { async: true; asyncTimeout?: number };
|
||||
|
||||
type SyncHookJSONOutput = {
|
||||
continue?: boolean;
|
||||
suppressOutput?: boolean;
|
||||
stopReason?: string;
|
||||
decision?: 'approve' | 'block';
|
||||
systemMessage?: string;
|
||||
reason?: string;
|
||||
hookSpecificOutput?:
|
||||
| { hookEventName: 'PreToolUse'; permissionDecision?: 'allow' | 'deny' | 'ask'; updatedInput?: Record<string, unknown> }
|
||||
| { hookEventName: 'UserPromptSubmit'; additionalContext?: string }
|
||||
| { hookEventName: 'SessionStart'; additionalContext?: string }
|
||||
| { hookEventName: 'PostToolUse'; additionalContext?: string };
|
||||
};
|
||||
```
|
||||
|
||||
### 子代理钩子(来自 sdk.d.ts)
|
||||
|
||||
```typescript
|
||||
type SubagentStartHookInput = BaseHookInput & {
|
||||
hook_event_name: 'SubagentStart';
|
||||
agent_id: string;
|
||||
agent_type: string;
|
||||
};
|
||||
|
||||
type SubagentStopHookInput = BaseHookInput & {
|
||||
hook_event_name: 'SubagentStop';
|
||||
stop_hook_active: boolean;
|
||||
agent_id: string;
|
||||
agent_transcript_path: string;
|
||||
agent_type: string;
|
||||
};
|
||||
|
||||
// BaseHookInput = { session_id, transcript_path, cwd, permission_mode? }
|
||||
```
|
||||
|
||||
## Query 接口方法
|
||||
|
||||
`Query` 对象 (sdk.d.ts:931)。官方文档列出以下公开方法:
|
||||
|
||||
```typescript
|
||||
interface Query extends AsyncGenerator<SDKMessage, void> {
|
||||
interrupt(): Promise<void>; // 停止当前执行(仅流式输入模式)
|
||||
rewindFiles(userMessageUuid: string): Promise<void>; // 将文件恢复到消息时的状态(需要 enableFileCheckpointing)
|
||||
setPermissionMode(mode: PermissionMode): Promise<void>; // 更改权限(仅流式输入模式)
|
||||
setModel(model?: string): Promise<void>; // 更改模型(仅流式输入模式)
|
||||
setMaxThinkingTokens(max: number | null): Promise<void>; // 更改思考 token 数(仅流式输入模式)
|
||||
supportedCommands(): Promise<SlashCommand[]>; // 可用的斜杠命令
|
||||
supportedModels(): Promise<ModelInfo[]>; // 可用模型
|
||||
mcpServerStatus(): Promise<McpServerStatus[]>; // MCP 服务器连接状态
|
||||
accountInfo(): Promise<AccountInfo>; // 已认证用户信息
|
||||
}
|
||||
```
|
||||
|
||||
在 sdk.d.ts 中存在但官方文档中未出现(可能为内部方法):
|
||||
- `streamInput(stream)`——流式传入额外用户消息
|
||||
- `close()`——强制结束查询
|
||||
- `setMcpServers(servers)`——动态添加/移除 MCP 服务器
|
||||
|
||||
## 沙箱配置
|
||||
|
||||
```typescript
|
||||
type SandboxSettings = {
|
||||
enabled?: boolean;
|
||||
autoAllowBashIfSandboxed?: boolean;
|
||||
excludedCommands?: string[];
|
||||
allowUnsandboxedCommands?: boolean;
|
||||
network?: {
|
||||
allowLocalBinding?: boolean;
|
||||
allowUnixSockets?: string[];
|
||||
allowAllUnixSockets?: boolean;
|
||||
httpProxyPort?: number;
|
||||
socksProxyPort?: number;
|
||||
};
|
||||
ignoreViolations?: {
|
||||
file?: string[];
|
||||
network?: string[];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
当 `allowUnsandboxedCommands` 为 true 时,模型可以在 Bash 工具输入中设置 `dangerouslyDisableSandbox: true`,这会回退到 `canUseTool` 权限处理器。
|
||||
|
||||
## MCP 服务器辅助函数
|
||||
|
||||
### tool()
|
||||
|
||||
使用 Zod schema 创建类型安全的 MCP 工具定义:
|
||||
|
||||
```typescript
|
||||
function tool<Schema extends ZodRawShape>(
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: Schema,
|
||||
handler: (args: z.infer<ZodObject<Schema>>, extra: unknown) => Promise<CallToolResult>
|
||||
): SdkMcpToolDefinition<Schema>
|
||||
```
|
||||
|
||||
### createSdkMcpServer()
|
||||
|
||||
创建进程内 MCP 服务器(我们改用 stdio 以支持子代理继承):
|
||||
|
||||
```typescript
|
||||
function createSdkMcpServer(options: {
|
||||
name: string;
|
||||
version?: string;
|
||||
tools?: Array<SdkMcpToolDefinition<any>>;
|
||||
}): McpSdkServerConfigWithInstance
|
||||
```
|
||||
|
||||
## 内部机制参考
|
||||
|
||||
### 关键的压缩标识符 (sdk.mjs)
|
||||
|
||||
| 压缩名 | 用途 |
|
||||
|----------|---------|
|
||||
| `s_` | V1 `query()` 导出 |
|
||||
| `e_` | `unstable_v2_createSession` |
|
||||
| `Xx` | `unstable_v2_resumeSession` |
|
||||
| `Qx` | `unstable_v2_prompt` |
|
||||
| `U9` | V2 Session 类 (`send`/`stream`/`close`) |
|
||||
| `XX` | ProcessTransport(生成 cli.js) |
|
||||
| `$X` | Query 类(JSON 行路由、异步可迭代) |
|
||||
| `QX` | AsyncQueue(输入流缓冲区) |
|
||||
|
||||
### 关键的压缩标识符 (cli.js)
|
||||
|
||||
| 压缩名 | 用途 |
|
||||
|----------|---------|
|
||||
| `EZ` | 核心递归代理循环(异步生成器) |
|
||||
| `_t4` | 停止钩子处理器(当没有 tool_use 块时运行) |
|
||||
| `PU1` | 流式工具执行器(在 API 响应期间并行) |
|
||||
| `TP6` | 标准工具执行器(API 响应之后) |
|
||||
| `GU1` | 单个工具执行器 |
|
||||
| `lTq` | SDK 会话运行器(直接调用 EZ) |
|
||||
| `bd1` | stdin 读取器(来自传输层的 JSON 行) |
|
||||
| `mW1` | Anthropic API 流式调用器 |
|
||||
|
||||
## 关键文件
|
||||
|
||||
- `sdk.d.ts` — 所有类型定义(1777 行)
|
||||
- `sdk-tools.d.ts` — 工具输入 schema
|
||||
- `sdk.mjs` — SDK 运行时(压缩的,376KB)
|
||||
- `cli.js` — CLI 可执行文件(压缩的,作为子进程运行)
|
||||
161
docs/zh/SECURITY.md
Normal file
161
docs/zh/SECURITY.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# NanoClaw 安全模型
|
||||
|
||||
## 信任模型
|
||||
|
||||
| 实体 | 信任级别 | 理由 |
|
||||
|--------|-------------|-----------|
|
||||
| 主群组(Main group) | 受信任 | 私人自我聊天,管理员控制 |
|
||||
| 非主群组(Non-main groups) | 不受信任 | 其他用户可能是恶意的 |
|
||||
| 容器 agent(Container agents) | 沙箱化 | 隔离的执行环境 |
|
||||
| 入站消息(Incoming messages) | 用户输入 | 潜在的 prompt 注入 |
|
||||
|
||||
## 安全边界
|
||||
|
||||
### 1. 容器隔离(主要边界)
|
||||
|
||||
Agent 在容器(轻量级 Linux VM)中执行,提供:
|
||||
- **进程隔离** - 容器进程无法影响宿主机
|
||||
- **文件系统隔离** - 只有显式挂载的目录可见
|
||||
- **非 root 执行** - 以非特权 `node` 用户(uid 1000)运行
|
||||
- **临时容器** - 每次调用都是全新环境(`--rm`)
|
||||
|
||||
这是主要的安全边界。不是依赖应用级权限检查,而是通过限制挂载的内容来缩小攻击面。
|
||||
|
||||
### 2. 挂载安全
|
||||
|
||||
**外部白名单** - 挂载权限存储在 `~/.config/nanoclaw/mount-allowlist.json`,该文件:
|
||||
- 位于项目根目录之外
|
||||
- 永远不会挂载到容器中
|
||||
- 无法被 agent 修改
|
||||
|
||||
**默认阻止模式:**
|
||||
```
|
||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
||||
private_key, .secret
|
||||
```
|
||||
|
||||
**保护措施:**
|
||||
- 验证前解析符号链接(防止目录遍历攻击)
|
||||
- 容器路径验证(拒绝 `..` 和绝对路径)
|
||||
- `nonMainReadOnly` 选项强制非主群组使用只读
|
||||
|
||||
**只读项目根目录:**
|
||||
|
||||
主群组的项目根目录以只读方式挂载。Agent 需要的可写路径(store、群组目录、IPC、`.claude/`)单独挂载。这防止 agent 修改宿主机应用代码(`src/`、`dist/`、`package.json` 等),否则下次重启时整个沙箱将被完全绕过。`store/` 目录以读写方式挂载,以便主 agent 可以直接访问 SQLite 数据库。
|
||||
|
||||
### 3. Session 隔离
|
||||
|
||||
每个群组在 `data/sessions/{group}/.claude/` 拥有隔离的 Claude session:
|
||||
- 群组无法查看其他群组的对话历史
|
||||
- Session 数据包含完整的消息历史和已读取的文件内容
|
||||
- 防止跨群组信息泄露
|
||||
|
||||
### 4. IPC 授权
|
||||
|
||||
消息和任务操作根据群组身份进行验证:
|
||||
|
||||
| 操作 | 主群组 | 非主群组 |
|
||||
|-----------|------------|----------------|
|
||||
| 向自己的聊天发送消息 | ✓ | ✓ |
|
||||
| 向其他聊天发送消息 | ✓ | ✗ |
|
||||
| 为自己安排定时任务 | ✓ | ✓ |
|
||||
| 为其他群组安排定时任务 | ✓ | ✗ |
|
||||
| 查看所有任务 | ✓ | 仅自己的 |
|
||||
| 管理其他群组 | ✓ | ✗ |
|
||||
|
||||
### 5. 凭证隔离(OneCLI Agent Vault)
|
||||
|
||||
真实的 API 凭证**永远不会进入容器**。NanoClaw 使用 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 来代理出站请求并在网关层面注入凭证。
|
||||
|
||||
**工作原理:**
|
||||
1. 凭证通过 `onecli secrets create` 一次性注册,由 OneCLI 存储和管理
|
||||
2. 当 NanoClaw 启动容器时,调用 `applyContainerConfig()` 将出站 HTTPS 路由通过 OneCLI 网关
|
||||
3. 网关按 host 和 path 匹配请求,注入真实凭证并转发
|
||||
4. Agent 无法发现真实凭证——不在环境变量、stdin、文件或 `/proc` 中
|
||||
|
||||
**按 agent 策略:**
|
||||
每个 NanoClaw 群组拥有自己的 OneCLI agent 身份。这允许按群组设置不同的凭证策略(例如,销售 agent vs. 支持 agent)。OneCLI 支持速率限制,基于时间的访问和审批流程正在规划中。
|
||||
|
||||
**不挂载的内容:**
|
||||
- 渠道认证 session(`store/auth/`)——仅宿主机
|
||||
- 挂载白名单——外部存储,永不挂载
|
||||
- 任何匹配阻止模式的凭证
|
||||
- `.env` 在项目根目录挂载中被 `/dev/null` 遮蔽
|
||||
|
||||
## 权限对比
|
||||
|
||||
| 能力 | 主群组 | 非主群组 |
|
||||
|------------|------------|----------------|
|
||||
| 项目根目录访问 | `/workspace/project`(只读) | 无 |
|
||||
| Store(SQLite DB) | `/workspace/project/store`(读写) | 无 |
|
||||
| 群组目录 | `/workspace/group`(读写) | `/workspace/group`(读写) |
|
||||
| 全局记忆 | 通过项目隐式访问 | `/workspace/global`(只读) |
|
||||
| 额外挂载 | 可配置 | 只读,除非允许 |
|
||||
| 网络访问 | 无限制 | 无限制 |
|
||||
| MCP 工具 | 全部 | 全部 |
|
||||
|
||||
## 安全架构图
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 不受信任区域(UNTRUSTED ZONE) │
|
||||
│ 入站消息(潜在恶意) │
|
||||
└────────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼ 触发检查、输入转义
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 宿主机进程(HOST PROCESS,受信任) │
|
||||
│ • 消息路由 │
|
||||
│ • IPC 授权 │
|
||||
│ • 挂载验证(外部白名单) │
|
||||
│ • 容器生命周期 │
|
||||
│ • OneCLI Agent Vault(注入凭证,执行策略) │
|
||||
└────────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼ 仅显式挂载,无 secrets
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 容器(CONTAINER,隔离/沙箱化) │
|
||||
│ • Agent 执行 │
|
||||
│ • Bash 命令(沙箱内) │
|
||||
│ • 文件操作(限于挂载范围) │
|
||||
│ • API 调用通过 OneCLI Agent Vault 路由 │
|
||||
│ • 环境变量或文件系统中无真实凭证 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 供应链安全(pnpm)
|
||||
|
||||
NanoClaw 使用 pnpm,在 `pnpm-workspace.yaml` 中配置了两道供应链防护:
|
||||
|
||||
### 最低发布龄期
|
||||
|
||||
`minimumReleaseAge: 4320`(3 天)。pnpm 将拒绝解析发布不到 3 天的任何包版本。这可以防御仿冒包和被盗用的维护者账户——大多数恶意发布在 72 小时内会被检测并撤回。
|
||||
|
||||
**将包排除在发布龄期门禁之外**(`minimumReleaseAgeExclude`):
|
||||
|
||||
这种情况应很少发生。当零日修复或关键依赖需要立即更新时:
|
||||
|
||||
1. 排除条目必须经人类维护者审查和批准
|
||||
2. 条目必须锁定被排除的**确切版本**——绝不能使用范围或通配符
|
||||
```yaml
|
||||
minimumReleaseAgeExclude:
|
||||
some-package: "1.2.3" # 由 @user 批准, 2026-04-14 — CVE-XXXX-YYYY 修复
|
||||
```
|
||||
3. 一旦该版本超过阈值(即 3 天后),应移除排除项
|
||||
4. 自动化 agent(Claude、CI 机器人)绝不能在没有人类签署的情况下添加排除项
|
||||
|
||||
### 构建脚本白名单
|
||||
|
||||
`onlyBuiltDependencies` 限制哪些包可以执行 install/postinstall 脚本。只有此列表中的包才被允许在 `pnpm install` 期间运行构建脚本。当前允许的包:
|
||||
|
||||
- `better-sqlite3` — 编译原生 SQLite 绑定
|
||||
- `esbuild` — 下载平台特定的二进制文件
|
||||
- `protobufjs` — 生成 protobuf 绑定(Baileys/libsignal 使用)
|
||||
- `sharp` — 下载平台特定的图像处理二进制文件
|
||||
|
||||
向此列表添加包需要人类批准——构建脚本以安装用户的权限执行任意代码。
|
||||
|
||||
### `.npmrc` 安全网
|
||||
|
||||
`.npmrc` 文件包含 `minReleaseAge=3d` 作为后备。权威设置在 `pnpm-workspace.yaml` 中,但如果 npm 曾被直接调用(例如被不遵循 pnpm 的工具调用),`.npmrc` 提供纵深防御。
|
||||
782
docs/zh/SPEC.md
Normal file
782
docs/zh/SPEC.md
Normal file
@@ -0,0 +1,782 @@
|
||||
# NanoClaw 规范
|
||||
|
||||
一款个人 Claude 助手,支持多渠道、按会话持久化记忆、定时任务以及容器隔离的 agent(智能体)执行。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [架构](#架构)
|
||||
2. [架构:渠道系统](#架构渠道系统)
|
||||
3. [目录结构](#目录结构)
|
||||
4. [配置](#配置)
|
||||
5. [记忆系统](#记忆系统)
|
||||
6. [会话管理](#session-管理)
|
||||
7. [消息流转](#消息流转)
|
||||
8. [命令](#命令)
|
||||
9. [定时任务](#定时任务)
|
||||
10. [MCP 服务器](#mcp-服务器)
|
||||
11. [部署](#部署)
|
||||
12. [安全考量](#安全考量)
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ HOST(宿主机,macOS / Linux) │
|
||||
│ (主 Node.js 进程) │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ Channels │─────────────────▶│ SQLite 数据库 │ │
|
||||
│ │ (渠道,启动时 │◀────────────────│ (messages.db) │ │
|
||||
│ │ 自行注册) │ 存储/发送 └─────────┬──────────┘ │
|
||||
│ └──────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
||||
│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │
|
||||
│ │ (消息轮询循环) │ │ (调度器循环) │ │ (IPC 监听器) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
|
||||
│ │ │ │
|
||||
│ └───────────┬───────────┘ │
|
||||
│ │ 启动容器 │
|
||||
│ ▼ │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ CONTAINER(容器,Linux VM) │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ AGENT RUNNER(智能体运行器) │ │
|
||||
│ │ │ │
|
||||
│ │ 工作目录: /workspace/group(从宿主机挂载) │ │
|
||||
│ │ 卷挂载: │ │
|
||||
│ │ • groups/{name}/ → /workspace/group │ │
|
||||
│ │ • groups/global/ → /workspace/global/(仅非主群组) │ │
|
||||
│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │
|
||||
│ │ • 额外目录 → /workspace/extra/* │ │
|
||||
│ │ │ │
|
||||
│ │ 工具(所有群组): │ │
|
||||
│ │ • Bash(安全——在容器内沙箱化!) │ │
|
||||
│ │ • Read, Write, Edit, Glob, Grep(文件操作) │ │
|
||||
│ │ • WebSearch, WebFetch(互联网访问) │ │
|
||||
│ │ • agent-browser(浏览器自动化) │ │
|
||||
│ │ • mcp__nanoclaw__*(通过 IPC 的调度器工具) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 组件 | 技术 | 用途 |
|
||||
|-----------|------------|---------|
|
||||
| Channel System | Channel registry (`src/channels/registry.ts`) | 渠道在启动时自行注册 |
|
||||
| Message Storage | SQLite (better-sqlite3) | 存储消息供轮询 |
|
||||
| Container Runtime | Containers(Linux VM) | 用于 agent 执行的隔离环境 |
|
||||
| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | 带工具和 MCP 服务器的 Claude 运行环境 |
|
||||
| Browser Automation | agent-browser + Chromium | Web 交互与截图 |
|
||||
| Runtime | Node.js 20+ | 用于路由和调度的宿主机进程 |
|
||||
|
||||
---
|
||||
|
||||
## 架构:渠道系统
|
||||
|
||||
核心不含任何内置渠道——每个渠道(WhatsApp、Telegram、Slack、Discord、Gmail)作为 [Claude Code skill](https://code.claude.com/docs/en/skills) 安装,将渠道代码添加到您的 fork 中。渠道在启动时自行注册;已安装但缺少凭证的渠道会发出 WARN 日志并被跳过。
|
||||
|
||||
### 系统图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Channels["渠道"]
|
||||
WA[WhatsApp]
|
||||
TG[Telegram]
|
||||
SL[Slack]
|
||||
DC[Discord]
|
||||
New["其他渠道 (Signal, Gmail...)"]
|
||||
end
|
||||
|
||||
subgraph Orchestrator["编排器 — index.ts"]
|
||||
ML[Message Loop]
|
||||
GQ[Group Queue]
|
||||
RT[Router]
|
||||
TS[Task Scheduler]
|
||||
DB[(SQLite)]
|
||||
end
|
||||
|
||||
subgraph Execution["容器执行"]
|
||||
CR[Container Runner]
|
||||
LC["Linux Container"]
|
||||
IPC[IPC Watcher]
|
||||
end
|
||||
|
||||
%% Flow
|
||||
WA & TG & SL & DC & New -->|onMessage| ML
|
||||
ML --> GQ
|
||||
GQ -->|并发控制| CR
|
||||
CR --> LC
|
||||
LC -->|文件系统 IPC| IPC
|
||||
IPC -->|任务与消息| RT
|
||||
RT -->|Channel.sendMessage| Channels
|
||||
TS -->|到期任务| CR
|
||||
|
||||
%% DB Connections
|
||||
DB <--> ML
|
||||
DB <--> TS
|
||||
|
||||
%% Styling for the dynamic channel
|
||||
style New stroke-dasharray: 5 5,stroke-width:2px
|
||||
```
|
||||
|
||||
### 渠道注册表
|
||||
|
||||
渠道系统建立在 `src/channels/registry.ts` 中的工厂注册表之上:
|
||||
|
||||
```typescript
|
||||
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
|
||||
|
||||
const registry = new Map<string, ChannelFactory>();
|
||||
|
||||
export function registerChannel(name: string, factory: ChannelFactory): void {
|
||||
registry.set(name, factory);
|
||||
}
|
||||
|
||||
export function getChannelFactory(name: string): ChannelFactory | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function getRegisteredChannelNames(): string[] {
|
||||
return [...registry.keys()];
|
||||
}
|
||||
```
|
||||
|
||||
每个工厂接收 `ChannelOpts`(包含回调 `onMessage`、`onChatMetadata` 和 `registeredGroups`),并返回一个 `Channel` 实例,如果该渠道的凭证未配置则返回 `null`。
|
||||
|
||||
### 渠道接口
|
||||
|
||||
每个渠道实现此接口(定义在 `src/types.ts` 中):
|
||||
|
||||
```typescript
|
||||
interface Channel {
|
||||
name: string;
|
||||
connect(): Promise<void>;
|
||||
sendMessage(jid: string, text: string): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
disconnect(): Promise<void>;
|
||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||
syncGroups?(force: boolean): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 自行注册模式
|
||||
|
||||
渠道使用 barrel 导入模式自行注册:
|
||||
|
||||
1. 每个渠道 skill 向 `src/channels/` 添加一个文件(例如 `whatsapp.ts`、`telegram.ts`),在模块加载时调用 `registerChannel()`:
|
||||
|
||||
```typescript
|
||||
// src/channels/whatsapp.ts
|
||||
import { registerChannel, ChannelOpts } from './registry.js';
|
||||
|
||||
export class WhatsAppChannel implements Channel { /* ... */ }
|
||||
|
||||
registerChannel('whatsapp', (opts: ChannelOpts) => {
|
||||
// 如果凭证缺失则返回 null
|
||||
if (!existsSync(authPath)) return null;
|
||||
return new WhatsAppChannel(opts);
|
||||
});
|
||||
```
|
||||
|
||||
2. barrel 文件 `src/channels/index.ts` 导入所有渠道模块,触发注册:
|
||||
|
||||
```typescript
|
||||
import './whatsapp.js';
|
||||
import './telegram.js';
|
||||
// ... 每个 skill 在此处添加其导入
|
||||
```
|
||||
|
||||
3. 启动时,编排器(`src/index.ts`)循环遍历已注册的渠道,连接返回有效实例的渠道:
|
||||
|
||||
```typescript
|
||||
for (const name of getRegisteredChannelNames()) {
|
||||
const factory = getChannelFactory(name);
|
||||
const channel = factory?.(channelOpts);
|
||||
if (channel) {
|
||||
await channel.connect();
|
||||
channels.push(channel);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|---------|
|
||||
| `src/channels/registry.ts` | 渠道工厂注册表 |
|
||||
| `src/channels/index.ts` | 触发渠道自行注册的 barrel 导入 |
|
||||
| `src/types.ts` | `Channel` 接口、`ChannelOpts`、消息类型 |
|
||||
| `src/index.ts` | 编排器——实例化渠道、运行消息循环 |
|
||||
| `src/router.ts` | 查找 JID 所属的渠道,格式化消息 |
|
||||
|
||||
### 添加新渠道
|
||||
|
||||
要添加新渠道,请向 `.claude/skills/add-<name>/` 贡献一个 skill,该 skill 需要:
|
||||
|
||||
1. 添加一个 `src/channels/<name>.ts` 文件,实现 `Channel` 接口
|
||||
2. 在模块加载时调用 `registerChannel(name, factory)`
|
||||
3. 如果凭证缺失,工厂返回 `null`
|
||||
4. 向 `src/channels/index.ts` 添加一条导入行
|
||||
|
||||
请参考已有的 skills(`/add-whatsapp`、`/add-telegram`、`/add-slack`、`/add-discord`、`/add-gmail`)了解模式。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
nanoclaw/
|
||||
├── CLAUDE.md # Claude Code 的项目上下文
|
||||
├── docs/
|
||||
│ ├── SPEC.md # 本规范文档
|
||||
│ ├── REQUIREMENTS.md # 架构决策
|
||||
│ └── SECURITY.md # 安全模型
|
||||
├── README.md # 用户文档
|
||||
├── package.json # Node.js 依赖
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── .mcp.json # MCP 服务器配置(参考)
|
||||
├── .gitignore
|
||||
│
|
||||
├── src/
|
||||
│ ├── index.ts # 编排器:状态、消息循环、agent 调用
|
||||
│ ├── channels/
|
||||
│ │ ├── registry.ts # 渠道工厂注册表
|
||||
│ │ └── index.ts # 渠道自行注册的 barrel 导入
|
||||
│ ├── ipc.ts # IPC 监听器与任务处理
|
||||
│ ├── router.ts # 消息格式化和出站路由
|
||||
│ ├── config.ts # 配置常量
|
||||
│ ├── types.ts # TypeScript 接口(包含 Channel)
|
||||
│ ├── logger.ts # Pino 日志器配置
|
||||
│ ├── db.ts # SQLite 数据库初始化与查询
|
||||
│ ├── group-queue.ts # 带全局并发限制的按群组队列
|
||||
│ ├── mount-security.ts # 容器挂载白名单验证
|
||||
│ ├── whatsapp-auth.ts # 独立的 WhatsApp 认证
|
||||
│ ├── task-scheduler.ts # 到期时运行定时任务
|
||||
│ └── container-runner.ts # 在容器中启动 agent
|
||||
│
|
||||
├── container/
|
||||
│ ├── Dockerfile # 容器镜像(以 'node' 用户运行,包含 Claude Code CLI)
|
||||
│ ├── build.sh # 容器镜像的构建脚本
|
||||
│ ├── agent-runner/ # 在容器内运行的代码
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # 入口点(查询循环、IPC 轮询、会话恢复)
|
||||
│ │ └── ipc-mcp-stdio.ts # 基于 stdio 的 MCP 服务器,用于宿主机通信
|
||||
│ └── skills/
|
||||
│ └── agent-browser.md # 浏览器自动化 skill
|
||||
│
|
||||
├── dist/ # 编译后的 JavaScript(gitignored)
|
||||
│
|
||||
├── .claude/
|
||||
│ └── skills/
|
||||
│ ├── setup/SKILL.md # /setup - 首次安装
|
||||
│ ├── customize/SKILL.md # /customize - 添加能力
|
||||
│ ├── debug/SKILL.md # /debug - 容器调试
|
||||
│ ├── add-telegram/SKILL.md # /add-telegram - Telegram 渠道
|
||||
│ ├── add-gmail/SKILL.md # /add-gmail - Gmail 集成
|
||||
│ ├── add-voice-transcription/ # /add-voice-transcription - Whisper
|
||||
│ ├── x-integration/SKILL.md # /x-integration - X/Twitter
|
||||
│ ├── convert-to-apple-container/ # /convert-to-apple-container - Apple Container 运行时
|
||||
│ └── add-parallel/SKILL.md # /add-parallel - 并行 agent
|
||||
│
|
||||
├── groups/
|
||||
│ ├── CLAUDE.md # 全局记忆(所有群组都读取此文件)
|
||||
│ ├── {channel}_main/ # 主控制渠道(例如 whatsapp_main/)
|
||||
│ │ ├── CLAUDE.md # 主渠道记忆
|
||||
│ │ └── logs/ # 任务执行日志
|
||||
│ └── {channel}_{group-name}/ # 按群组目录(注册时创建)
|
||||
│ ├── CLAUDE.md # 群组专属记忆
|
||||
│ ├── logs/ # 此群组的任务日志
|
||||
│ └── *.md # agent 创建的文件
|
||||
│
|
||||
├── store/ # 本地数据(gitignored)
|
||||
│ ├── auth/ # WhatsApp 认证状态
|
||||
│ └── messages.db # SQLite 数据库(messages、chats、scheduled_tasks、task_run_logs、registered_groups、sessions、router_state)
|
||||
│
|
||||
├── data/ # 应用状态(gitignored)
|
||||
│ ├── sessions/ # 按群组会话数据(.claude/ 目录,含 JSONL 对话记录)
|
||||
│ ├── env/env # .env 的副本,用于挂载到容器
|
||||
│ └── ipc/ # 容器 IPC(messages/、tasks/)
|
||||
│
|
||||
├── logs/ # 运行时日志(gitignored)
|
||||
│ ├── nanoclaw.log # 宿主机 stdout
|
||||
│ └── nanoclaw.error.log # 宿主机 stderr
|
||||
│ # 注意:每个容器的日志在 groups/{folder}/logs/container-*.log
|
||||
│
|
||||
└── launchd/
|
||||
└── com.nanoclaw.plist # macOS 服务配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
配置常量在 `src/config.ts` 中定义:
|
||||
|
||||
```typescript
|
||||
import path from 'path';
|
||||
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy';
|
||||
|
||||
// 路径必须是绝对路径(容器挂载需要)
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
|
||||
// 容器配置
|
||||
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 默认30分钟
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30分钟——最后一次结果后保持容器存活
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
```
|
||||
|
||||
**注意:** 路径必须是绝对路径,容器卷挂载才能正常工作。
|
||||
|
||||
### 容器配置
|
||||
|
||||
群组可以通过 SQLite `registered_groups` 表中的 `containerConfig`(以 JSON 形式存储在 `container_config` 列中)挂载额外目录。注册示例:
|
||||
|
||||
```typescript
|
||||
setRegisteredGroup("1234567890@g.us", {
|
||||
name: "Dev Team",
|
||||
folder: "whatsapp_dev-team",
|
||||
trigger: "@Andy",
|
||||
added_at: new Date().toISOString(),
|
||||
containerConfig: {
|
||||
additionalMounts: [
|
||||
{
|
||||
hostPath: "~/projects/webapp",
|
||||
containerPath: "webapp",
|
||||
readonly: false,
|
||||
},
|
||||
],
|
||||
timeout: 600000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
目录命名遵循 `{channel}_{group-name}` 约定(例如 `whatsapp_family-chat`、`telegram_dev-team`)。主群组在注册时会设置 `isMain: true`。
|
||||
|
||||
额外挂载在容器内显示为 `/workspace/extra/{containerPath}`。
|
||||
|
||||
**挂载语法说明:** 读写挂载使用 `-v host:container`,但只读挂载需要使用 `--mount "type=bind,source=...,target=...,readonly"`(`:ro` 后缀可能不是所有运行时都支持)。
|
||||
|
||||
### Claude 认证
|
||||
|
||||
在项目根目录的 `.env` 文件中配置认证。有两种选择:
|
||||
|
||||
**选项1:Claude 订阅(OAuth token)**
|
||||
```bash
|
||||
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
```
|
||||
如果您已登录 Claude Code,可以从 `~/.claude/.credentials.json` 中提取 token。
|
||||
|
||||
**选项2:按用量付费的 API Key**
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-api03-...
|
||||
```
|
||||
|
||||
只有认证变量(`CLAUDE_CODE_OAUTH_TOKEN` 和 `ANTHROPIC_API_KEY`)从 `.env` 中提取并写入 `data/env/env`,然后挂载到容器中的 `/workspace/env-dir/env`,由入口点脚本加载。这确保 `.env` 中的其他环境变量不会暴露给 agent。此变通方案是必要的,因为某些容器运行时在使用 `-i`(带管道 stdin 的交互模式)时会丢失 `-e` 环境变量。
|
||||
|
||||
### 更改助手名称
|
||||
|
||||
设置 `ASSISTANT_NAME` 环境变量:
|
||||
|
||||
```bash
|
||||
ASSISTANT_NAME=Bot pnpm start
|
||||
```
|
||||
|
||||
或编辑 `src/config.ts` 中的默认值。这会更改:
|
||||
- 触发模式(消息必须以 `@YourName` 开头)
|
||||
- 响应前缀(自动添加 `YourName:`)
|
||||
|
||||
### launchd 中的占位符值
|
||||
|
||||
包含 `{{PLACEHOLDER}}` 值的文件需要进行配置:
|
||||
- `{{PROJECT_ROOT}}` - nanoclaw 安装的绝对路径
|
||||
- `{{NODE_PATH}}` - node 二进制文件的路径(通过 `which node` 检测)
|
||||
- `{{HOME}}` - 用户的主目录
|
||||
|
||||
---
|
||||
|
||||
## 记忆系统
|
||||
|
||||
NanoClaw 使用基于 CLAUDE.md 文件的层级记忆系统。
|
||||
|
||||
### 记忆层级
|
||||
|
||||
| 层级 | 位置 | 读取者 | 写入者 | 用途 |
|
||||
|-------|----------|---------|------------|---------|
|
||||
| **全局** | `groups/CLAUDE.md` | 所有群组 | 仅主群组 | 在所有对话之间共享的偏好、事实、上下文 |
|
||||
| **群组** | `groups/{name}/CLAUDE.md` | 该群组 | 该群组 | 群组专属上下文、对话记忆 |
|
||||
| **文件** | `groups/{name}/*.md` | 该群组 | 该群组 | 对话过程中创建的笔记、研究、文档 |
|
||||
|
||||
### 记忆如何运作
|
||||
|
||||
1. **Agent 上下文加载**
|
||||
- Agent 运行在 `groups/{group-name}/` 作为 `cwd`
|
||||
- Claude Agent SDK 使用 `settingSources: ['project']` 自动加载:
|
||||
- `../CLAUDE.md`(上级目录 = 全局记忆)
|
||||
- `./CLAUDE.md`(当前目录 = 群组记忆)
|
||||
|
||||
2. **写入记忆**
|
||||
- 当用户说"记住这个",agent 写入 `./CLAUDE.md`
|
||||
- 当用户说"全局记住这个"(仅主渠道),agent 写入 `../CLAUDE.md`
|
||||
- Agent 可以在群组目录中创建 `notes.md`、`research.md` 等文件
|
||||
|
||||
3. **主渠道权限**
|
||||
- 只有"主"群组(自我聊天)可以向全局记忆写入
|
||||
- 主群组可以管理已注册的群组并为任何群组安排定时任务
|
||||
- 主群组可以为任何群组配置额外的目录挂载
|
||||
- 所有群组都有 Bash 访问权限(安全,因为在容器内运行)
|
||||
|
||||
---
|
||||
|
||||
## Session 管理
|
||||
|
||||
Session 实现了对话连续性——Claude 会记住你们之前谈过的内容。
|
||||
|
||||
### Session 如何运作
|
||||
|
||||
1. 每个群组在 SQLite 中有一个 session ID(`sessions` 表,按 `group_folder` 索引)
|
||||
2. Session ID 传递给 Claude Agent SDK 的 `resume` 选项
|
||||
3. Claude 以完整上下文继续对话
|
||||
4. Session 对话记录以 JSONL 文件形式存储在 `data/sessions/{group}/.claude/`
|
||||
|
||||
---
|
||||
|
||||
## 消息流转
|
||||
|
||||
### 入站消息流程
|
||||
|
||||
```
|
||||
1. 用户通过任何已连接的渠道发送消息
|
||||
│
|
||||
▼
|
||||
2. 渠道接收消息(例如 WhatsApp 使用 Baileys,Telegram 使用 Bot API)
|
||||
│
|
||||
▼
|
||||
3. 消息存入 SQLite(store/messages.db)
|
||||
│
|
||||
▼
|
||||
4. 消息循环轮询 SQLite(每2秒)
|
||||
│
|
||||
▼
|
||||
5. 路由检查:
|
||||
├── chat_jid 是否在已注册群组中(SQLite)?→ 否:忽略
|
||||
└── 消息是否匹配触发模式?→ 否:存储但不处理
|
||||
│
|
||||
▼
|
||||
6. 路由追上对话:
|
||||
├── 获取自上次 agent 交互以来的所有消息
|
||||
├── 使用时间戳和发送者名称格式化
|
||||
└── 构建包含完整对话上下文的 prompt
|
||||
│
|
||||
▼
|
||||
7. 路由调用 Claude Agent SDK:
|
||||
├── cwd: groups/{group-name}/
|
||||
├── prompt: 对话历史 + 当前消息
|
||||
├── resume: session_id(用于连续性)
|
||||
└── mcpServers: nanoclaw(调度器)
|
||||
│
|
||||
▼
|
||||
8. Claude 处理消息:
|
||||
├── 读取 CLAUDE.md 文件获取上下文
|
||||
└── 按需使用工具(搜索、邮件等)
|
||||
│
|
||||
▼
|
||||
9. 路由在响应前添加助手名称前缀,并通过所属渠道发送
|
||||
│
|
||||
▼
|
||||
10. 路由更新最后 agent 时间戳并保存 session ID
|
||||
```
|
||||
|
||||
### 触发词匹配
|
||||
|
||||
消息必须以触发模式开头(默认:`@Andy`):
|
||||
- `@Andy what's the weather?` → ✅ 触发 Claude
|
||||
- `@andy help me` → ✅ 触发(不区分大小写)
|
||||
- `Hey @Andy` → ❌ 忽略(触发词不在开头)
|
||||
- `What's up?` → ❌ 忽略(无触发词)
|
||||
|
||||
### 对话追上
|
||||
|
||||
当触发消息到达时,agent 会收到自上次在该聊天中交互以来的所有消息。每条消息都带有时间戳和发送者名称:
|
||||
|
||||
```
|
||||
[Jan 31 2:32 PM] John: hey everyone, should we do pizza tonight?
|
||||
[Jan 31 2:33 PM] Sarah: sounds good to me
|
||||
[Jan 31 2:35 PM] John: @Andy what toppings do you recommend?
|
||||
```
|
||||
|
||||
这使得 agent 能够理解对话上下文,即使它没有在每条消息中被提及。
|
||||
|
||||
---
|
||||
|
||||
## 命令
|
||||
|
||||
### 任何群组中可用的命令
|
||||
|
||||
| 命令 | 示例 | 效果 |
|
||||
|---------|---------|--------|
|
||||
| `@Assistant [message]` | `@Andy what's the weather?` | 与 Claude 对话 |
|
||||
|
||||
### 仅主渠道可用的命令
|
||||
|
||||
| 命令 | 示例 | 效果 |
|
||||
|---------|---------|--------|
|
||||
| `@Assistant add group "Name"` | `@Andy add group "Family Chat"` | 注册新群组 |
|
||||
| `@Assistant remove group "Name"` | `@Andy remove group "Work Team"` | 取消注册群组 |
|
||||
| `@Assistant list groups` | `@Andy list groups` | 显示已注册群组 |
|
||||
| `@Assistant remember [fact]` | `@Andy remember I prefer dark mode` | 添加到全局记忆 |
|
||||
|
||||
---
|
||||
|
||||
## 定时任务
|
||||
|
||||
NanoClaw 内置调度器,将任务作为完整 agent 在其群组上下文中运行。
|
||||
|
||||
### 调度如何运作
|
||||
|
||||
1. **群组上下文**:在群组中创建的任务使用该群组的工作目录和记忆运行
|
||||
2. **完整的 Agent 能力**:定时任务可以访问所有工具(WebSearch、文件操作等)
|
||||
3. **可选的消息发送**:任务可以使用 `send_message` 工具向其群组发送消息,也可以静默完成
|
||||
4. **主渠道权限**:主渠道可以为任何群组安排任务并查看所有任务
|
||||
|
||||
### 调度类型
|
||||
|
||||
| 类型 | 值格式 | 示例 |
|
||||
|------|--------------|---------|
|
||||
| `cron` | Cron 表达式 | `0 9 * * 1`(每周一上午9点) |
|
||||
| `interval` | 毫秒 | `3600000`(每小时) |
|
||||
| `once` | ISO 时间戳 | `2024-12-25T09:00:00Z` |
|
||||
|
||||
### 创建任务
|
||||
|
||||
```
|
||||
User: @Andy remind me every Monday at 9am to review the weekly metrics
|
||||
|
||||
Claude: [调用 mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
|
||||
"schedule_type": "cron",
|
||||
"schedule_value": "0 9 * * 1"
|
||||
}
|
||||
|
||||
Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
|
||||
### 一次性任务
|
||||
|
||||
```
|
||||
User: @Andy at 5pm today, send me a summary of today's emails
|
||||
|
||||
Claude: [调用 mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
|
||||
"schedule_type": "once",
|
||||
"schedule_value": "2024-01-31T17:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 管理任务
|
||||
|
||||
在任何群组中:
|
||||
- `@Andy list my scheduled tasks` - 查看此群组的任务
|
||||
- `@Andy pause task [id]` - 暂停任务
|
||||
- `@Andy resume task [id]` - 恢复暂停的任务
|
||||
- `@Andy cancel task [id]` - 删除任务
|
||||
|
||||
在主渠道中:
|
||||
- `@Andy list all tasks` - 查看所有群组的任务
|
||||
- `@Andy schedule task for "Family Chat": [prompt]` - 为其他群组安排任务
|
||||
|
||||
---
|
||||
|
||||
## MCP 服务器
|
||||
|
||||
### NanoClaw MCP(内置)
|
||||
|
||||
`nanoclaw` MCP 服务器在每次 agent 调用时动态创建,带有当前群组的上下文。
|
||||
|
||||
**可用工具:**
|
||||
| 工具 | 用途 |
|
||||
|------|---------|
|
||||
| `schedule_task` | 安排定期或一次性任务 |
|
||||
| `list_tasks` | 显示任务(本群组的任务,主群组则显示全部) |
|
||||
| `get_task` | 获取任务详情和运行历史 |
|
||||
| `update_task` | 修改任务的 prompt 或调度 |
|
||||
| `pause_task` | 暂停任务 |
|
||||
| `resume_task` | 恢复暂停的任务 |
|
||||
| `cancel_task` | 删除任务 |
|
||||
| `send_message` | 通过群组的渠道发送消息 |
|
||||
|
||||
---
|
||||
|
||||
## 部署
|
||||
|
||||
NanoClaw 作为单个 macOS launchd 服务运行。
|
||||
|
||||
### 启动序列
|
||||
|
||||
NanoClaw 启动时会:
|
||||
1. **确保容器运行时正在运行**——如需要自动启动;终止前次运行遗留的 NanoClaw 孤儿容器
|
||||
2. 初始化 SQLite 数据库(如果存在 JSON 文件则从中迁移)
|
||||
3. 从 SQLite 加载状态(已注册群组、sessions、路由状态)
|
||||
4. **连接渠道**——遍历已注册渠道,实例化有凭证的渠道,对每个调用 `connect()`
|
||||
5. 一旦至少有一个渠道连接:
|
||||
- 启动调度器循环
|
||||
- 启动用于容器消息的 IPC 监听器
|
||||
- 设置按群组队列及 `processGroupMessages`
|
||||
- 恢复关闭前未处理的消息
|
||||
- 启动消息轮询循环
|
||||
|
||||
### 服务:com.nanoclaw
|
||||
|
||||
**launchd/com.nanoclaw.plist:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanoclaw</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{NODE_PATH}}</string>
|
||||
<string>{{PROJECT_ROOT}}/dist/index.js</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{PROJECT_ROOT}}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>{{HOME}}</string>
|
||||
<key>ASSISTANT_NAME</key>
|
||||
<string>Andy</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### 管理服务
|
||||
|
||||
```bash
|
||||
# 安装服务
|
||||
cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/
|
||||
|
||||
# 启动服务
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# 停止服务
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# 检查状态
|
||||
launchctl list | grep nanoclaw
|
||||
|
||||
# 查看日志
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安全考量
|
||||
|
||||
### 容器隔离
|
||||
|
||||
所有 agent 在容器(轻量级 Linux VM)内运行,提供:
|
||||
- **文件系统隔离**:Agent 只能访问已挂载的目录
|
||||
- **安全的 Bash 访问**:命令在容器内运行,而不是在您的 Mac 上
|
||||
- **网络隔离**:可按容器配置(如需要)
|
||||
- **进程隔离**:容器进程无法影响宿主机
|
||||
- **非 root 用户**:容器以非特权 `node` 用户(uid 1000)运行
|
||||
|
||||
### Prompt 注入风险
|
||||
|
||||
WhatsApp 消息可能包含试图操纵 Claude 行为的恶意指令。
|
||||
|
||||
**缓解措施:**
|
||||
- 容器隔离限制爆炸半径
|
||||
- 仅处理已注册的群组
|
||||
- 需要触发词(减少误处理)
|
||||
- Agent 只能访问其群组挂载的目录
|
||||
- 主群组可以按群组配置额外目录
|
||||
- Claude 内置的安全训练
|
||||
|
||||
**建议:**
|
||||
- 仅注册受信任的群组
|
||||
- 仔细审查额外的目录挂载
|
||||
- 定期审查定时任务
|
||||
- 监控日志中的异常活动
|
||||
|
||||
### 凭证存储
|
||||
|
||||
| 凭证 | 存储位置 | 说明 |
|
||||
|------------|------------------|-------|
|
||||
| Claude CLI Auth | data/sessions/{group}/.claude/ | 按群组隔离,挂载到 /home/node/.claude/ |
|
||||
| WhatsApp Session | store/auth/ | 自动创建,持续约20天 |
|
||||
|
||||
### 文件权限
|
||||
|
||||
groups/ 目录包含个人记忆,应加以保护:
|
||||
```bash
|
||||
chmod 700 groups/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|-------|-------|----------|
|
||||
| 消息无响应 | 服务未运行 | 检查 `launchctl list | grep nanoclaw` |
|
||||
| "Claude Code process exited with code 1" | 容器运行时启动失败 | 检查日志;NanoClaw 会自动启动容器运行时,但可能失败 |
|
||||
| "Claude Code process exited with code 1" | Session 挂载路径错误 | 确保挂载到 `/home/node/.claude/` 而非 `/root/.claude/` |
|
||||
| Session 无法继续 | Session ID 未保存 | 检查 SQLite:`sqlite3 store/messages.db "SELECT * FROM sessions"` |
|
||||
| Session 无法继续 | 挂载路径不匹配 | 容器用户是 `node`,HOME=/home/node;sessions 必须位于 `/home/node/.claude/` |
|
||||
| "QR code expired" | WhatsApp session 过期 | 删除 store/auth/ 并重启 |
|
||||
| "No groups registered" | 尚未添加群组 | 在主渠道中使用 `@Andy add group "Name"` |
|
||||
|
||||
### 日志位置
|
||||
|
||||
- `logs/nanoclaw.log` - stdout
|
||||
- `logs/nanoclaw.error.log` - stderr
|
||||
|
||||
### 调试模式
|
||||
|
||||
手动运行以获取详细输出:
|
||||
```bash
|
||||
pnpm run dev
|
||||
# 或
|
||||
node dist/index.js
|
||||
```
|
||||
749
docs/zh/agent-runner-details.md
Normal file
749
docs/zh/agent-runner-details.md
Normal file
@@ -0,0 +1,749 @@
|
||||
# NanoClaw Agent-Runner 详解
|
||||
|
||||
容器内 agent-runner(代理运行器)的实现级细节。高层设计请参见 [architecture.md](architecture.md)。
|
||||
|
||||
## 关注点分离
|
||||
|
||||
agent-runner 分为两层:
|
||||
|
||||
1. **Agent-runner 核心** — 负责 poll loop(轮询循环)、消息格式化、数据库读写、MCP tool(MCP 工具)实现、路由、状态管理、媒体处理。此部分是 NanoClaw 专有的,跨所有 provider(提供器)共享。
|
||||
|
||||
2. **Agent provider(代理提供器)** — 负责 SDK 交互。接收格式化后的提示词,将其推送到 SDK,并回传事件。主干代码内置 `claude` provider;其他 provider(OpenCode、Codex 等)通过 `/add-<provider>` 技能从 `providers` 分支安装。
|
||||
|
||||
边界划分:agent-runner 决定**发送什么**以及**如何处理结果**。provider 决定**如何与 SDK 通信**。
|
||||
|
||||
## AgentProvider 接口
|
||||
|
||||
```typescript
|
||||
interface AgentProvider {
|
||||
/** 启动一个新的查询。返回一个用于流式输入和输出的句柄。 */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
}
|
||||
|
||||
interface QueryInput {
|
||||
/** 初始提示词(已由 agent-runner 格式化)。
|
||||
* 纯文本时使用 String。多模态(图片、PDF、音频)时使用 ContentBlock[]。 */
|
||||
prompt: string | ContentBlock[];
|
||||
|
||||
/** 要恢复的会话 ID(如有) */
|
||||
sessionId?: string;
|
||||
|
||||
/** 从会话的特定位置恢复(provider 特定字段,可能被忽略) */
|
||||
resumeAt?: string;
|
||||
|
||||
/** 容器内的工作目录 */
|
||||
cwd: string;
|
||||
|
||||
/** MCP server(MCP 服务器)配置(标准化格式 — provider 负责转换) */
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
|
||||
/** 系统提示词 / 开发者指令 */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** SDK 进程的环境变量 */
|
||||
env: Record<string, string | undefined>;
|
||||
|
||||
/** agent 可访问的额外目录 */
|
||||
additionalDirectories?: string[];
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AgentQuery {
|
||||
/** 将一条后续消息推送到活动查询中 */
|
||||
push(message: string): void;
|
||||
|
||||
/** 表示不再发送更多输入 */
|
||||
end(): void;
|
||||
|
||||
/** 输出事件流 */
|
||||
events: AsyncIterable<ProviderEvent>;
|
||||
|
||||
/** 强制停止查询(例如容器正在关闭) */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type ProviderEvent =
|
||||
| { type: 'init'; sessionId: string }
|
||||
| { type: 'result'; text: string | null }
|
||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||
| { type: 'progress'; message: string };
|
||||
```
|
||||
|
||||
### 接口不包含的内容
|
||||
|
||||
- **消息格式化** — agent-runner 在传递给 provider 之前格式化消息。provider 接收的是可直接发送的提示词字符串。
|
||||
- **Hooks(钩子)** — Claude 特有功能。Claude provider 在内部注册 hooks(PreCompact、PreToolUse 等)。其他 provider 不需要。
|
||||
- **工具许可列表** — Claude 使用 `allowedTools`。Codex 使用 `approvalPolicy`。OpenCode 使用 `permission`。各 provider 基于相同的意图("允许一切,无需提示")在内部自行配置。
|
||||
- **会话持久化** — Claude 自动将会话持久化到磁盘。Codex 和 OpenCode 管理各自的 session 状态。agent-runner 不控制这一点 — 它只传递 `sessionId` 和 `resumeAt`。
|
||||
- **沙箱配置** — provider 特定。各 provider 在内部配置自己的沙箱。
|
||||
|
||||
### Provider 事件语义
|
||||
|
||||
- **`init`** — 每次查询当 provider 建立或恢复 session 时发送一次。agent-runner 捕获 `sessionId` 用于后续恢复。
|
||||
- **`result`** — 当 agent 生成完整响应时发送。每次查询可发送多次(例如 Claude 的多轮子 agent 交互)。agent-runner 将每个 result 写入 `messages_out`。
|
||||
- **`error`** — 失败时发送。`retryable` 指示 agent-runner 是否应重试。`classification` 是可选的详细分类(如 `'quota'`、`'auth'`、`'transport'`)。
|
||||
- **`progress`** — 可选,用于日志记录。agent-runner 记录这些事件但不据此作出行动。
|
||||
|
||||
## Provider 实现
|
||||
|
||||
主干代码仅内置 `claude` provider。下文的 Codex 和 OpenCode 章节记录了 provider 接口以供参考,以及供安装额外 provider 的技能使用 — 它们并非核心镜像的内置部分。
|
||||
|
||||
### Claude Provider
|
||||
|
||||
封装 `@anthropic-ai/claude-agent-sdk` 的 `query()`。
|
||||
|
||||
```typescript
|
||||
class ClaudeProvider implements AgentProvider {
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const stream = new MessageStream(); // AsyncIterable<SDKUserMessage>
|
||||
stream.push(input.prompt);
|
||||
|
||||
const sdkQuery = query({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: input.cwd,
|
||||
resume: input.sessionId,
|
||||
resumeSessionAt: input.resumeAt,
|
||||
systemPrompt: input.systemPrompt
|
||||
? { type: 'preset', preset: 'claude_code', append: input.systemPrompt }
|
||||
: undefined,
|
||||
mcpServers: input.mcpServers, // 已是正确格式
|
||||
additionalDirectories: input.additionalDirectories,
|
||||
env: input.env,
|
||||
allowedTools: NANOCLAW_TOOL_ALLOWLIST,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [preCompactHook] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [sanitizeBashHook] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
push: (msg) => stream.push(msg),
|
||||
end: () => stream.end(),
|
||||
abort: () => sdkQuery.close(),
|
||||
events: translateClaudeEvents(sdkQuery),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`translateClaudeEvents` 是一个异步生成器,将 SDK 消息映射为 `ProviderEvent`:
|
||||
- `message.type === 'system' && message.subtype === 'init'` → `{ type: 'init', sessionId }`
|
||||
- `message.type === 'result'` → `{ type: 'result', text }`
|
||||
- `message.type === 'system' && message.subtype === 'api_retry'` → `{ type: 'error', retryable: true }`
|
||||
- `message.type === 'system' && message.subtype === 'rate_limit_event'` → `{ type: 'error', retryable: false, classification: 'quota' }`
|
||||
- `message.type === 'system' && message.subtype === 'task_notification'` → `{ type: 'progress', message }`
|
||||
- 其他一切 → 仅记录日志,不发送事件
|
||||
|
||||
**在 provider 内部保留的 Claude 特有功能:**
|
||||
- `MessageStream` 用于异步可迭代输入(基于推送模式)
|
||||
- `resumeSessionAt` 用于在特定消息 UUID 处恢复
|
||||
- PreCompact hook 用于对话转录归档
|
||||
- PreToolUse hook 用于清理 bash 环境变量
|
||||
- 完整的工具许可列表
|
||||
- `additionalDirectories` 用于多目录访问
|
||||
|
||||
### Codex Provider
|
||||
|
||||
封装 `@openai/codex-sdk`。
|
||||
|
||||
```typescript
|
||||
class CodexProvider implements AgentProvider {
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const codex = new Codex(this.buildOptions(input));
|
||||
const thread = input.sessionId
|
||||
? codex.resumeThread(input.sessionId, this.threadOptions(input))
|
||||
: codex.startThread(this.threadOptions(input));
|
||||
|
||||
const abortController = new AbortController();
|
||||
let pendingFollowUp: string | null = null;
|
||||
|
||||
return {
|
||||
push: (msg) => {
|
||||
// Codex 不支持流式输入。
|
||||
// 存储后续消息并中止当前轮次。
|
||||
pendingFollowUp = msg;
|
||||
abortController.abort();
|
||||
},
|
||||
end: () => { /* 无操作 — Codex 轮次自然结束 */ },
|
||||
abort: () => abortController.abort(),
|
||||
events: this.run(thread, input.prompt, abortController, () => pendingFollowUp),
|
||||
};
|
||||
}
|
||||
|
||||
private async *run(thread, prompt, abortController, getPendingFollowUp): AsyncIterable<ProviderEvent> {
|
||||
let currentPrompt = prompt;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const streamed = await thread.runStreamed(currentPrompt, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let resultText = '';
|
||||
|
||||
for await (const event of streamed.events) {
|
||||
if (event.type === 'thread.started') {
|
||||
sessionId = event.thread_id;
|
||||
yield { type: 'init', sessionId };
|
||||
}
|
||||
if (event.type === 'item.completed' && event.item.type === 'agent_message') {
|
||||
resultText = event.item.text || resultText;
|
||||
}
|
||||
if (event.type === 'turn.failed') {
|
||||
yield { type: 'error', message: event.error.message, retryable: false };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'result', text: resultText || null };
|
||||
|
||||
// 检查本轮次中是否有后续消息排入队列
|
||||
const followUp = getPendingFollowUp();
|
||||
if (followUp) {
|
||||
currentPrompt = followUp;
|
||||
// 为下一次迭代重置
|
||||
continue;
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted && getPendingFollowUp()) {
|
||||
// 因后续消息被中止 — 使用新提示词重启
|
||||
currentPrompt = getPendingFollowUp();
|
||||
abortController = new AbortController();
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**在 provider 内部保留的 Codex 特有行为:**
|
||||
- `developer_instructions` 用于系统提示词(从 CLAUDE.md 加载)
|
||||
- 工作区中执行 `git init`(Codex 需要 git 仓库)
|
||||
- 中止+重启模式处理后续消息
|
||||
- 从环境变量中读取 `sandboxMode`、`approvalPolicy`、`networkAccessEnabled`
|
||||
- 对话归档(Codex 没有 PreCompact)
|
||||
|
||||
### OpenCode Provider
|
||||
|
||||
封装 `@opencode-ai/sdk`。
|
||||
|
||||
```typescript
|
||||
class OpenCodeProvider implements AgentProvider {
|
||||
query(input: QueryInput): AgentQuery {
|
||||
// OpenCode 运行本地服务器 — 创建一次,跨查询复用
|
||||
const { client, server } = await createOpencode({ config: this.buildConfig(input) });
|
||||
const { stream } = await client.event.subscribe();
|
||||
|
||||
let aborted = false;
|
||||
let pendingFollowUp: string | null = null;
|
||||
|
||||
return {
|
||||
push: (msg) => {
|
||||
pendingFollowUp = msg;
|
||||
server.close(); // 中断当前查询
|
||||
},
|
||||
end: () => { /* 无操作 */ },
|
||||
abort: () => { aborted = true; server.close(); },
|
||||
events: this.run(client, server, stream, input, () => pendingFollowUp),
|
||||
};
|
||||
}
|
||||
|
||||
private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable<ProviderEvent> {
|
||||
const session = await client.session.create();
|
||||
yield { type: 'init', sessionId: session.data.id };
|
||||
|
||||
await client.session.promptAsync({
|
||||
path: { id: session.data.id },
|
||||
body: { parts: [{ type: 'text', text: input.prompt }] },
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'session.idle') {
|
||||
// 从累积的消息部分中收集结果文本
|
||||
const resultText = this.extractResult(event);
|
||||
yield { type: 'result', text: resultText };
|
||||
|
||||
const followUp = getPendingFollowUp();
|
||||
if (followUp) {
|
||||
await client.session.promptAsync({
|
||||
path: { id: session.data.id },
|
||||
body: { parts: [{ type: 'text', text: followUp }] },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'session.error') {
|
||||
yield { type: 'error', message: event.properties?.error?.data?.message, retryable: false };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**在 provider 内部保留的 OpenCode 特有行为:**
|
||||
- 本地 gRPC/HTTP 服务器生命周期(`server.close()`)
|
||||
- SSE 事件流用于输出
|
||||
- 通过配置选择 provider/model(`OPENCODE_PROVIDER`、`OPENCODE_MODEL`)
|
||||
- MCP config 格式转换(`type: 'local'`、`command: [cmd, ...args]`、`environment`)
|
||||
- 通过提示词文本中以 `<system>` 前缀注入系统提示词
|
||||
- 不支持恢复(session 始终是新建的或按 ID 复用的)
|
||||
|
||||
## Agent-Runner 核心
|
||||
|
||||
以下所有内容均由 agent-runner 处理,而非 provider。
|
||||
|
||||
### 轮询循环
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1. 查询 messages_in 中待处理的行 │
|
||||
│ WHERE status = 'pending' │
|
||||
│ AND (process_after IS NULL │
|
||||
│ OR process_after <= now()) │
|
||||
│ │
|
||||
│ 2. 如果找到行: │
|
||||
│ a. 设置 status = 'processing' │
|
||||
│ b. 按 kind 格式化消息 │
|
||||
│ c. 剥离路由字段 │
|
||||
│ d. 调用 provider.query(prompt) │
|
||||
│ e. 处理 provider 事件 │
|
||||
│ f. 将结果写入 messages_out │
|
||||
│ g. 设置 status = 'completed' │
|
||||
│ │
|
||||
│ 3. 当查询处于活动状态时: │
|
||||
│ - 继续轮询 messages_in │
|
||||
│ - 新消息 → provider.push() │
|
||||
│ │
|
||||
│ 4. 查询结束时: │
|
||||
│ - 回到步骤 1 │
|
||||
│ - 若无消息,休眠并重新轮询 │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**活动查询期间的并发轮询:** 当 provider 正在运行查询时,agent-runner 以短间隔(约 500ms)持续轮询 `messages_in`。新的待处理消息被格式化并通过 `provider.push()` 推送到活动查询中。这使得在 agent 处理过程中后续消息可以到达 — Claude 原生支持此方式,Codex/OpenCode 通过内部的中止+重启来处理。
|
||||
|
||||
**空闲行为:** 当没有待处理消息且没有活动查询时,agent-runner 短暂休眠(1s)并重新轮询。容器保持运行状态直到 host(主机)将其终止(空闲超时)。
|
||||
|
||||
**空闲检测的例外情况:** 在以下情况下容器**不应**被视为空闲:
|
||||
- 一个 `ask_user_question` 工具调用正在等待(等待用户在 `messages_in` 中的响应)
|
||||
- agent 正在活跃工作中(工具调用进行中、子 agent 运行中)
|
||||
|
||||
agent-runner 向 host 发送"忙碌"状态信号。具体机制因 provider 而异 — 对于 Claude,查询 AsyncGenerator 仍在产出事件。对于其他 provider,agent-runner 可以将心跳或状态指示器写入 session DB(会话数据库),host 在终止前会检查该状态。
|
||||
|
||||
### 消息格式化
|
||||
|
||||
agent-runner 将 `messages_in` 行转换为提示词字符串。provider 接收的是可直接发送的字符串 — 它不知道消息的种类或路由信息。
|
||||
|
||||
**路由字段剥离:** `platform_id`、`channel_type`、`thread_id` 永远不会包含在提示词中。它们作为上下文存储,用于写入 `messages_out`。
|
||||
|
||||
**按 kind 分类的单条消息格式化:**
|
||||
|
||||
- **`chat`** — 格式化为消息 XML:
|
||||
```xml
|
||||
<message sender="John" time="2024-01-01 10:00">
|
||||
Check this PR
|
||||
</message>
|
||||
```
|
||||
|
||||
- **`chat-sdk`** — 从序列化的 Chat SDK 消息中提取字段:
|
||||
```xml
|
||||
<message sender="John (john@slack)" time="2024-01-01 10:00">
|
||||
Check this PR
|
||||
[image: screenshot.png — https://signed-url...]
|
||||
</message>
|
||||
```
|
||||
附件以内联方式列出。Claude 原生支持的图片/PDF 以 content block(内容块)方式传递(参见下文的媒体处理部分)。
|
||||
|
||||
- **`task`** — 任务提示词,可选择附带脚本输出:
|
||||
```
|
||||
[SCHEDULED TASK]
|
||||
|
||||
Script output:
|
||||
{"data": ...}
|
||||
|
||||
Instructions:
|
||||
Review open PRs
|
||||
```
|
||||
|
||||
- **`webhook`** — webhook 负载:
|
||||
```
|
||||
[WEBHOOK: github/pull_request]
|
||||
|
||||
{"action": "opened", "pull_request": {...}}
|
||||
```
|
||||
|
||||
- **`system`** — host 操作结果(对先前系统请求的响应):
|
||||
```
|
||||
[SYSTEM RESPONSE]
|
||||
|
||||
Action: register_agent_group
|
||||
Status: success
|
||||
Result: {"agent_group_id": "ag-456"}
|
||||
```
|
||||
|
||||
**批量格式化:** 多条待处理消息合并为一个提示词:
|
||||
|
||||
```xml
|
||||
<context timezone="America/Los_Angeles">
|
||||
<messages>
|
||||
<message sender="John" time="10:00">Check this PR</message>
|
||||
<message sender="Jane" time="10:01">Already on it</message>
|
||||
</messages>
|
||||
```
|
||||
|
||||
混合 kind(例如一条 chat 消息 + 一条 system 响应)用清晰的分隔符组合。每个部分按 kind 标注。
|
||||
|
||||
**命令检测:** 以 `/` 开头的消息会与命令列表进行匹配。识别到的命令会绕过格式化,原样传递给 provider(用于 Claude 的斜杠命令处理),或被 agent-runner 拦截(用于会话重置等 NanoClaw 级别的命令)。
|
||||
|
||||
### 路由
|
||||
|
||||
当 agent-runner 拾取 `messages_in` 行时,它会从批次中捕获路由字段:
|
||||
|
||||
```typescript
|
||||
interface RoutingContext {
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
inReplyTo: string | null; // 触发消息的 messages_in.id
|
||||
}
|
||||
```
|
||||
|
||||
写入 `messages_out`(无论是来自 provider 结果还是 MCP tool 调用)时,agent-runner 默认会复制此路由上下文。agent 永远不会看到路由字段 — 它只生成文本。路由是隐式的:"回复发送消息的人。"
|
||||
|
||||
目标为其他目的地的 MCP tool(例如带显式 channel 参数的 `send_to_agent`、`send_message`)会覆盖该特定 `messages_out` 行的路由上下文。
|
||||
|
||||
### 状态管理
|
||||
|
||||
agent-runner 管理 `messages_in` 上的 `status` 和 `status_changed` 字段:
|
||||
|
||||
```
|
||||
pending → processing → completed
|
||||
→ failed (若 provider 返回错误且已达到最大重试次数)
|
||||
```
|
||||
|
||||
- **拾取:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)`
|
||||
- **完成:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)`
|
||||
- **错误:** agent-runner 不设置 `failed` — 它将消息保持为 `processing`。host 通过 `status_changed` 检测过时的 processing 状态并处理重试逻辑(重置为 pending,带退避策略)。这样将重试策略保留在 host 端。
|
||||
|
||||
### MCP 工具
|
||||
|
||||
agent-runner 运行一个 MCP server(MCP 服务器),向 agent 暴露 NanoClaw 工具。所有工具都写入 session DB。
|
||||
|
||||
**数据库路径:** MCP server 通过环境变量接收 session 数据库路径。它打开第二个连接到同一个 SQLite 文件(WAL 模式允许并发访问)。
|
||||
|
||||
#### send_message
|
||||
|
||||
向当前对话(或指定 destination(目的地))发送聊天消息。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_message',
|
||||
params: {
|
||||
text: string, // 消息内容
|
||||
channel?: string, // 可选:目标 channel 类型(默认:回复来源)
|
||||
platformId?: string, // 可选:目标 platform ID
|
||||
threadId?: string, // 可选:目标 thread ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_out` 行,`kind: 'chat'`。如果提供了 channel/platformId/threadId,则使用这些作为路由。否则,从当前路由上下文中复制。
|
||||
|
||||
#### send_file
|
||||
|
||||
向当前对话发送文件。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_file',
|
||||
params: {
|
||||
path: string, // 文件路径(相对于 /workspace/agent/ 或绝对路径)
|
||||
text?: string, // 可选:附带消息
|
||||
filename?: string, // 显示名称(默认:path 的 basename)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:
|
||||
1. 生成消息 ID
|
||||
2. 创建 `outbox/{messageId}/` 目录
|
||||
3. 将文件复制到 outbox 目录中
|
||||
4. 写入一个 `messages_out` 行,内容中包含 `files: [filename]`
|
||||
|
||||
#### send_card
|
||||
|
||||
发送结构化卡片(交互式或仅展示)。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_card',
|
||||
params: {
|
||||
card: CardElement, // 卡片结构(title、children、actions)
|
||||
fallbackText?: string, // 不支持卡片的平台使用的文本回退
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_out` 行,`kind: 'chat-sdk'`,内容中包含卡片结构。
|
||||
|
||||
#### ask_user_question
|
||||
|
||||
发送一个交互式问题并等待用户响应。这是一个**阻塞式工具调用** — 直到用户响应后工具才会返回。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'ask_user_question',
|
||||
params: {
|
||||
title: string, // 简短卡片标题,例如 "Confirm deletion"
|
||||
question: string,
|
||||
options: (string | { label: string; selectedLabel?: string; value?: string })[],
|
||||
timeout?: number, // 秒(默认:300)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:
|
||||
1. 生成 `questionId`
|
||||
2. 写入一个 `messages_out` 行,包含 `operation: 'ask_question'`、问题、选项和 questionId
|
||||
3. 轮询 `messages_in` 中是否有内容匹配的 `questionId` 的行
|
||||
4. 找到后,将 `selectedOption` 作为工具结果返回
|
||||
5. 如果超时到期,将超时错误作为工具结果返回
|
||||
|
||||
agent 的执行在此工具调用处暂停。provider 的查询继续运行(Claude 保持工具调用打开)。agent-runner 在单独循环中轮询响应。
|
||||
|
||||
#### edit_message
|
||||
|
||||
编辑先前发送的消息。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'edit_message',
|
||||
params: {
|
||||
messageId: string, // 显示给 agent 的整数 ID
|
||||
text: string, // 新内容
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_out` 行,包含 `operation: 'edit'`、消息 ID 和新文本。
|
||||
|
||||
#### add_reaction
|
||||
|
||||
向消息添加表情反应。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'add_reaction',
|
||||
params: {
|
||||
messageId: string, // 显示给 agent 的整数 ID
|
||||
emoji: string, // 表情名称(如 'thumbs_up')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_out` 行,包含 `operation: 'reaction'`。
|
||||
|
||||
#### send_to_agent
|
||||
|
||||
向另一个 agent group(代理组)发送消息。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_to_agent',
|
||||
params: {
|
||||
agentGroupId: string, // 目标 agent group
|
||||
text: string, // 消息内容
|
||||
sessionId?: string, // 可选:目标特定 session
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_out` 行,包含 `channel_type: 'agent'`、`platform_id: agentGroupId`、`thread_id: sessionId`。
|
||||
|
||||
#### schedule_task
|
||||
|
||||
安排一次性或 recurring task(周期性任务)。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'schedule_task',
|
||||
params: {
|
||||
prompt: string, // 任务提示词
|
||||
processAfter: string, // 首次运行的 ISO 时间戳
|
||||
recurrence?: string, // cron 表达式(可选)
|
||||
script?: string, // 前置脚本(可选)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_in` 行(发给自己),包含 `kind: 'task'`、`process_after` 和可选的 `recurrence`。host sweep 在到期时拾取。
|
||||
|
||||
#### list_tasks
|
||||
|
||||
列出活跃的 scheduled/recurring 任务。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'list_tasks',
|
||||
params: {}
|
||||
}
|
||||
```
|
||||
|
||||
实现:查询 `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`。
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
修改已安排的任务。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'cancel_task',
|
||||
params: { taskId: string }
|
||||
}
|
||||
// pause_task: 设置 status = 'paused'(周期性任务的新状态值)
|
||||
// resume_task: 设置 status = 'pending'
|
||||
// update_task: 将 { prompt?, recurrence?, processAfter?, script? } 合并到活动行中
|
||||
```
|
||||
|
||||
实现:cancel/pause/resume 直接更新活动行。update_task 作为 system action 发送 — host 读取当前内容,合并提供的字段,并写回。所有四个操作均通过 `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')` 匹配,因此即使 agent 传入原始(现已完成的)id,也能触及周期性任务的下一个活动实例。
|
||||
|
||||
#### register_agent_group
|
||||
|
||||
注册一个新的 agent group(仅限 admin)。
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'register_agent_group',
|
||||
params: {
|
||||
name: string,
|
||||
folder: string,
|
||||
platformId: string, // 要连接的 messaging group
|
||||
channelType: string,
|
||||
triggerRules?: object,
|
||||
sessionMode?: 'shared' | 'per-thread',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现:写入一个 `messages_out` 行,包含 `kind: 'system'`、`action: 'register_agent_group'`。host 读取、验证 admin 权限、在 central DB 中创建实体行,并写入一个 `system` 类型的 `messages_in` 响应。
|
||||
|
||||
### 媒体处理
|
||||
|
||||
#### 入站(messages_in → agent 提示词)
|
||||
|
||||
agent-runner 检查 chat/chat-sdk 消息中的附件(attachment),并根据类型和 provider 能力进行处理:
|
||||
|
||||
**Provider 原生的 content block:**
|
||||
|
||||
| 类型 | Claude | Codex / OpenCode |
|
||||
|------|--------|------------------|
|
||||
| 图片(JPEG、PNG、GIF、WebP) | 原生图片 content block | 保存到磁盘 |
|
||||
| PDF | 原生文档 content block | 保存到磁盘 |
|
||||
| 音频 | 原生音频 content block | 保存到磁盘 |
|
||||
| 其他文件(代码、数据、视频、存档) | 保存到磁盘 | 保存到磁盘 |
|
||||
|
||||
**"保存到磁盘"** 的含义是:下载到 `/workspace/downloads/{messageId}/`,在提示词文本中引用:
|
||||
|
||||
```
|
||||
<message sender="John" time="10:00">
|
||||
Check this spreadsheet
|
||||
[file available at: /workspace/downloads/msg-123/data.xlsx]
|
||||
</message>
|
||||
```
|
||||
|
||||
agent 可以使用工具(Read、Bash)访问保存的文件。
|
||||
|
||||
对于无法直接下载的 channel(例如 WhatsApp 缓冲流),channel adapter 通过本地 URL 提供媒体内容。agent-runner 从该 URL 下载。
|
||||
|
||||
**Content block 构建(Claude):** agent-runner 构建多部分 `MessageParam` 内容:`[{ type: 'image', source: { type: 'base64', media_type, data } }, { type: 'text', text: '...' }]`。此时传递给 provider 的提示词不是纯字符串 — `QueryInput.prompt` 字段需要支持 Claude 的结构化内容。provider 的 `query()` 方法处理特定格式的构建。
|
||||
|
||||
**Content block 构建(Codex/OpenCode):** 一切皆为文本。文件引用以内联方式出现在提示词字符串中。provider 接收的是纯字符串提示词。
|
||||
|
||||
#### 出站(agent → messages_out)
|
||||
|
||||
通过 `send_file` MCP tool 处理(参见上文)。agent 显式决定发送文件 — agent-runner 不会扫描输出中的文件引用。
|
||||
|
||||
### 任务前置脚本
|
||||
|
||||
对于 `kind` 为 `task` 且内容中包含 `script` 字段的消息:
|
||||
|
||||
1. agent-runner 将脚本写入临时文件
|
||||
2. 使用 `bash` 执行(30s 超时)
|
||||
3. 将 stdout 最后一行解析为 JSON:`{ wakeAgent: boolean, data?: unknown }`
|
||||
4. 如果 `wakeAgent === false`:将消息标记为已完成,不调用 provider
|
||||
5. 如果 `wakeAgent === true`:用脚本输出丰富提示词,然后调用 provider
|
||||
|
||||
### 对话转录归档
|
||||
|
||||
agent-runner 在上下文压缩前归档对话转录。对于 Claude,这通过 PreCompact hook 处理(provider 内部)。对于其他没有 hooks 的 provider,agent-runner 在每次查询完成后基于 provider 的输出进行归档。
|
||||
|
||||
归档位置:`/workspace/agent/conversations/{date}-{summary}.md`
|
||||
|
||||
### 会话恢复
|
||||
|
||||
agent-runner 在查询之间跟踪 `sessionId` 和 `resumeAt`:
|
||||
|
||||
- `sessionId` — 从 `ProviderEvent { type: 'init' }` 中捕获。在下一次查询时传回 `QueryInput.sessionId`。
|
||||
- `resumeAt` — Claude 特有(最后一条 assistant 消息的 UUID)。由 agent-runner 存储,传递给 `QueryInput.resumeAt`。不支持的 provider 会忽略它。
|
||||
|
||||
这些是容器生命周期的临时数据。当容器被终止并重启时,host 会传递从 central DB sessions 表中存储的 `sessionId`。`resumeAt` 在容器重启时丢失(provider 从 session 末尾恢复)。
|
||||
|
||||
### 容器启动
|
||||
|
||||
agent-runner 通过以下方式接收配置:
|
||||
|
||||
- **环境变量:** `AGENT_PROVIDER`(claude/codex/opencode)、`NANOCLAW_ADMIN_USER_ID`、provider 特定变量(API 密钥、模型覆盖)、`TZ`
|
||||
- **固定挂载路径:** Session DB 位于 `/workspace/session.db`。Agent group 文件夹位于 `/workspace/agent/`。系统提示词来自 `/workspace/agent/CLAUDE.md` 和 `/workspace/global/CLAUDE.md`。
|
||||
- **可选启动配置:** 部分配置可能以 JSON 文件形式传递到固定路径(例如 `/workspace/config.json`),用于诸如要恢复的 session ID、assistant 名称和 admin user ID 等内容。这避免了对环境变量的过度使用。
|
||||
|
||||
agent-runner 读取配置,创建 provider,并进入轮询循环。无标准输入,无初始提示词 — 消息已经存在于 session DB 中。
|
||||
|
||||
### Provider 工厂
|
||||
|
||||
```typescript
|
||||
type ProviderName = 'claude' | string;
|
||||
|
||||
function createProvider(name: ProviderName, config: ProviderConfig): AgentProvider {
|
||||
// 主干代码注册 'claude';其他 provider 在通过技能安装时自行注册。
|
||||
const factory = providerRegistry.get(name);
|
||||
if (!factory) throw new Error(`Unknown provider: ${name}`);
|
||||
return factory(config);
|
||||
}
|
||||
```
|
||||
|
||||
provider 名称来自容器的环境变量(`AGENT_PROVIDER` env var),由 host 根据 `agent_groups.agent_provider` 或 `sessions.agent_provider` 设置。
|
||||
|
||||
`ProviderConfig` 包含 provider 特定设置(API 密钥、模型覆盖等),通过环境变量传递 — 而非通过接口。每个 provider 根据自身需要从 `env` 中读取。
|
||||
|
||||
## Agent-Runner 属性
|
||||
|
||||
- MCP server 是由 provider(通过 `mcpServers` 配置)启动的独立 Node 进程
|
||||
- MCP server 二进制文件跨 provider 共享 — 相同的工具、相同的数据库访问
|
||||
- CLAUDE.md 加载(全局 + 每组) — agent-runner 读取并作为 `systemPrompt` 传递
|
||||
- 额外目录发现(`/workspace/extra/*`)
|
||||
- 通过 stderr 进行日志记录(`[agent-runner] ...`)
|
||||
|
||||
## 相关文档
|
||||
|
||||
- **[architecture.md](architecture.md)** — 高层架构(session DB schema、central DB、channel adapter、消息流)
|
||||
- **[api-details.md](api-details.md)** — Channel adapter 接口、消息内容示例、host delivery 逻辑
|
||||
365
docs/zh/api-details.md
Normal file
365
docs/zh/api-details.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# NanoClaw API 详解
|
||||
|
||||
架构的实现级细节。高层设计请参见 [architecture.md](architecture.md)。
|
||||
|
||||
## Channel Adapter 接口
|
||||
|
||||
### NanoClaw Channel 接口
|
||||
|
||||
```typescript
|
||||
interface ChannelSetup {
|
||||
// 来自 central DB 的对话配置 — 在 setup 时传入,不由 adapter 查询
|
||||
conversations: ConversationConfig[];
|
||||
|
||||
// 主机回调
|
||||
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
|
||||
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
|
||||
}
|
||||
|
||||
interface ConversationConfig {
|
||||
platformId: string;
|
||||
agentGroupId: string;
|
||||
triggerPattern?: string; // 正则表达式字符串(用于原生 channel)
|
||||
requiresTrigger: boolean;
|
||||
sessionMode: 'shared' | 'per-thread';
|
||||
}
|
||||
|
||||
interface ChannelAdapter {
|
||||
name: string;
|
||||
channelType: string;
|
||||
|
||||
// 生命周期
|
||||
setup(config: ChannelSetup): Promise<void>;
|
||||
teardown(): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
|
||||
// 出站投递
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
|
||||
|
||||
// 可选
|
||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||
syncConversations?(): Promise<ConversationInfo[]>;
|
||||
updateConversations?(conversations: ConversationConfig[]): void;
|
||||
}
|
||||
|
||||
// 从 adapter 到 host 的入站消息
|
||||
interface InboundMessage {
|
||||
id: string;
|
||||
kind: 'chat' | 'chat-sdk';
|
||||
content: unknown; // JSON blob — NanoClaw chat 格式或 Chat SDK SerializedMessage
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 从 host 到 adapter 的出站消息
|
||||
interface OutboundMessage {
|
||||
kind: 'chat' | 'chat-sdk';
|
||||
content: unknown; // JSON blob — 与 kind 对应
|
||||
}
|
||||
```
|
||||
|
||||
### Chat SDK Bridge(Chat SDK 桥接)
|
||||
|
||||
封装一个 Chat SDK adapter + Chat 实例以符合 NanoClaw `ChannelAdapter` 接口。主干代码仅内置桥接层和 channel registry(通道注册表) — 平台特定的 Chat SDK adapter(Discord、Slack、Telegram 等)和原生 adapter(WhatsApp/Baileys)由 `/add-<channel>` 技能从 `channels` 分支安装。
|
||||
|
||||
```typescript
|
||||
function createChatSdkBridge(
|
||||
adapter: Adapter,
|
||||
chatConfig: { concurrency?: ConcurrencyStrategy }
|
||||
): ChannelAdapter {
|
||||
let chat: Chat;
|
||||
let hostCallbacks: ChannelSetup;
|
||||
|
||||
return {
|
||||
name: adapter.name,
|
||||
channelType: adapter.name,
|
||||
|
||||
async setup(config) {
|
||||
hostCallbacks = config;
|
||||
|
||||
chat = new Chat({
|
||||
adapters: { [adapter.name]: adapter },
|
||||
state: new SqliteStateAdapter(),
|
||||
concurrency: chatConfig.concurrency ?? 'concurrent',
|
||||
});
|
||||
|
||||
// 订阅已注册的对话
|
||||
for (const conv of config.conversations) {
|
||||
if (conv.agentGroupId) {
|
||||
await chat.state.subscribe(conv.platformId);
|
||||
}
|
||||
}
|
||||
|
||||
// 已订阅的线程 → 转发所有消息
|
||||
chat.onSubscribedMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
config.onInbound(channelId, thread.id, {
|
||||
id: message.id,
|
||||
kind: 'chat-sdk',
|
||||
content: message.toJSON(),
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// 未订阅线程中的 @mention → 发现
|
||||
chat.onNewMention(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
config.onInbound(channelId, thread.id, {
|
||||
id: message.id,
|
||||
kind: 'chat-sdk',
|
||||
content: message.toJSON(),
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
});
|
||||
// 订阅以便后续接收该线程的消息
|
||||
await thread.subscribe();
|
||||
});
|
||||
|
||||
// 私信 → 始终转发
|
||||
chat.onDirectMessage(async (thread, message) => {
|
||||
config.onInbound(thread.id, null, {
|
||||
id: message.id,
|
||||
kind: 'chat-sdk',
|
||||
content: message.toJSON(),
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
});
|
||||
await thread.subscribe();
|
||||
});
|
||||
|
||||
await chat.initialize();
|
||||
},
|
||||
|
||||
async deliver(platformId, threadId, message) {
|
||||
const tid = threadId ?? platformId;
|
||||
if (message.kind === 'chat-sdk') {
|
||||
const content = message.content as Record<string, unknown>;
|
||||
if (content.operation === 'edit') {
|
||||
await adapter.editMessage(tid, content.messageId as string,
|
||||
{ markdown: content.text as string });
|
||||
} else if (content.operation === 'reaction') {
|
||||
await adapter.addReaction(tid, content.messageId as string,
|
||||
content.emoji as string);
|
||||
} else {
|
||||
await adapter.postMessage(tid, content as AdapterPostableMessage);
|
||||
}
|
||||
} else {
|
||||
const content = message.content as { text: string };
|
||||
await adapter.postMessage(tid, { markdown: content.text });
|
||||
}
|
||||
},
|
||||
|
||||
async setTyping(platformId, threadId) {
|
||||
await adapter.startTyping(threadId ?? platformId);
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
await chat.shutdown();
|
||||
},
|
||||
|
||||
isConnected() { return true; },
|
||||
|
||||
updateConversations(conversations) {
|
||||
// 订阅新对话,可取消订阅已移除的对话
|
||||
for (const conv of conversations) {
|
||||
if (conv.agentGroupId) {
|
||||
chat.state.subscribe(conv.platformId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 原生 NanoClaw Channel(不使用 Chat SDK)
|
||||
|
||||
原生 channel 直接实现 `ChannelAdapter` 接口。WhatsApp/Baileys adapter 是典型的例子 — 它通过 `/add-whatsapp` 技能提供,不在主干代码中:
|
||||
|
||||
```typescript
|
||||
function createWhatsAppChannel(): ChannelAdapter {
|
||||
let socket: WASocket;
|
||||
let config: ChannelSetup;
|
||||
|
||||
return {
|
||||
name: 'whatsapp',
|
||||
channelType: 'whatsapp',
|
||||
|
||||
async setup(setup) {
|
||||
config = setup;
|
||||
socket = await connectBaileys();
|
||||
|
||||
socket.on('messages.upsert', (event) => {
|
||||
for (const msg of event.messages) {
|
||||
const jid = msg.key.remoteJid;
|
||||
const conv = config.conversations.find(c => c.platformId === jid);
|
||||
|
||||
// 触发器检查(原生 channel — adapter 执行,非 host)
|
||||
if (conv?.requiresTrigger && conv.triggerPattern) {
|
||||
if (!new RegExp(conv.triggerPattern).test(msg.message?.conversation || '')) {
|
||||
return; // 不匹配触发器
|
||||
}
|
||||
}
|
||||
|
||||
config.onInbound(jid, null, {
|
||||
id: msg.key.id,
|
||||
kind: 'chat',
|
||||
content: {
|
||||
sender: msg.pushName || msg.key.participant,
|
||||
senderId: msg.key.participant || msg.key.remoteJid,
|
||||
text: msg.message?.conversation || '',
|
||||
attachments: [],
|
||||
isFromMe: msg.key.fromMe,
|
||||
},
|
||||
timestamp: new Date(msg.messageTimestamp * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async deliver(platformId, threadId, message) {
|
||||
const content = message.content as { text: string };
|
||||
await socket.sendMessage(platformId, { text: content.text });
|
||||
},
|
||||
|
||||
async setTyping(platformId) {
|
||||
await socket.sendPresenceUpdate('composing', platformId);
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
await socket.logout();
|
||||
},
|
||||
|
||||
isConnected() { return !!socket; },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Session DB(会话数据库)Schema 详解
|
||||
|
||||
### messages_in 内容示例
|
||||
|
||||
**`chat`** — 简洁的 NanoClaw 格式:
|
||||
```json
|
||||
{
|
||||
"sender": "John",
|
||||
"senderId": "user123",
|
||||
"text": "Check this PR",
|
||||
"attachments": [{ "type": "image", "url": "https://signed-url..." }],
|
||||
"isFromMe": false
|
||||
}
|
||||
```
|
||||
|
||||
**`chat-sdk`** — 完整的 Chat SDK `SerializedMessage`:
|
||||
```json
|
||||
{
|
||||
"_type": "chat:Message",
|
||||
"id": "msg-1",
|
||||
"threadId": "slack:C123:1234.5678",
|
||||
"text": "Check this PR",
|
||||
"formatted": { "type": "root", "children": [...] },
|
||||
"author": { "userId": "U123", "userName": "john", "fullName": "John", "isBot": false, "isMe": false },
|
||||
"metadata": { "dateSent": "2024-01-01T00:00:00Z", "edited": false },
|
||||
"attachments": [{ "type": "image", "url": "https://...", "name": "screenshot.png" }],
|
||||
"isMention": true,
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
**问题响应**(用户点击交互式卡片后):
|
||||
```json
|
||||
{
|
||||
"sender": "John",
|
||||
"senderId": "user123",
|
||||
"text": "Yes",
|
||||
"questionId": "q-123",
|
||||
"selectedOption": "Yes",
|
||||
"isFromMe": false
|
||||
}
|
||||
```
|
||||
|
||||
### messages_out 内容示例
|
||||
|
||||
**普通聊天消息:**
|
||||
```json
|
||||
{ "text": "LGTM, merging now" }
|
||||
```
|
||||
|
||||
**Chat SDK markdown:**
|
||||
```json
|
||||
{ "markdown": "## Review Summary\n**Status**: Approved\n\nNo issues found." }
|
||||
```
|
||||
|
||||
**卡片:**
|
||||
```json
|
||||
{
|
||||
"card": {
|
||||
"type": "card",
|
||||
"title": "Deployment Approval",
|
||||
"children": [
|
||||
{ "type": "text", "content": "Deploy 2.1.0 to production?" },
|
||||
{ "type": "actions", "children": [
|
||||
{ "type": "button", "id": "approve", "label": "Approve", "style": "primary" },
|
||||
{ "type": "button", "id": "reject", "label": "Reject", "style": "danger" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
"fallbackText": "Deployment Approval: Deploy 2.1.0 to production? [Approve] [Reject]"
|
||||
}
|
||||
```
|
||||
|
||||
**询问用户问题:**
|
||||
```json
|
||||
{
|
||||
"operation": "ask_question",
|
||||
"questionId": "q-123",
|
||||
"title": "Failing Test",
|
||||
"question": "How should we handle the failing test?",
|
||||
"options": [
|
||||
"Skip it",
|
||||
{ "label": "Fix and retry", "selectedLabel": "✅ Fixing", "value": "fix" },
|
||||
{ "label": "Abort deployment", "selectedLabel": "❌ Aborted", "value": "abort" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**编辑消息:**
|
||||
```json
|
||||
{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments on line 42" }
|
||||
```
|
||||
|
||||
**添加反应:**
|
||||
```json
|
||||
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
|
||||
```
|
||||
|
||||
**系统操作:**
|
||||
```json
|
||||
{ "action": "reset_session", "payload": { "session_id": "sess-123", "reason": "Skills updated" } }
|
||||
```
|
||||
|
||||
## Host Delivery(主机投递)逻辑
|
||||
|
||||
host 读取 `messages_out` 并根据 `kind` 和 `operation` 进行分发:
|
||||
|
||||
```typescript
|
||||
async function deliverMessage(row: MessagesOutRow, adapter: ChannelAdapter) {
|
||||
const content = JSON.parse(row.content);
|
||||
|
||||
// 系统操作 — host 内部处理
|
||||
if (row.kind === 'system') {
|
||||
await handleSystemAction(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent 间通信 — 写入目标 session DB
|
||||
if (isAgentDestination(row)) {
|
||||
await writeToAgentSession(row);
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel 投递 — 委托给 adapter
|
||||
await adapter.deliver(row.platform_id, row.thread_id, {
|
||||
kind: row.kind,
|
||||
content,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
adapter 的 `deliver()` 方法在内部处理操作分发(post vs edit vs reaction)。
|
||||
215
docs/zh/architecture-diagram.md
Normal file
215
docs/zh/architecture-diagram.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# NanoClaw 架构图
|
||||
|
||||
## 系统概览
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Platforms["消息平台"]
|
||||
P1[Discord]
|
||||
P2[Telegram]
|
||||
P3[Slack]
|
||||
P4[GitHub / Linear]
|
||||
P5[WhatsApp / iMessage / Teams / GChat / Matrix / Webex / Email]
|
||||
end
|
||||
|
||||
subgraph Host["宿主机进程 (Node)"]
|
||||
direction TB
|
||||
Bridge["Chat SDK 桥接<br/>(src/channels/chat-sdk-bridge.ts)"]
|
||||
Router["路由器<br/>(src/router.ts)<br/>platformId + threadId -> messaging_group -> agent_group -> session"]
|
||||
SessMgr["Session 管理器<br/>(src/session-manager.ts)<br/>创建 inbound.db + outbound.db"]
|
||||
Runner["容器运行器<br/>(src/container-runner.ts)<br/>OneCLI ensureAgent + 启动"]
|
||||
Delivery["投递轮询器<br/>(src/delivery.ts)<br/>活跃时 1s / sweep 时 60s"]
|
||||
Sweep["宿主机 Sweep<br/>(src/host-sweep.ts)<br/>心跳、重试、重复执行"]
|
||||
Central[("中央库<br/>data/v2.db<br/>agent_groups<br/>messaging_groups<br/>messaging_group_agents<br/>sessions<br/>pending_approvals")]
|
||||
end
|
||||
|
||||
subgraph OneCLI["OneCLI 网关 (0.3.1)"]
|
||||
Vault["Agent Vault<br/>秘密 + OAuth"]
|
||||
Approvals["configureManualApproval<br/>-> pending_approvals"]
|
||||
end
|
||||
|
||||
subgraph Session["每个 Session 的容器 (Docker / Apple Container)"]
|
||||
direction TB
|
||||
PollLoop["轮询循环<br/>(container/agent-runner)"]
|
||||
Provider["Agent 提供程序<br/>(claude、opencode、mock;待办: codex)"]
|
||||
MCP["MCP 工具<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
Skills["容器技能<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>宿主机写入<br/>偶数 seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
OutDB[("outbound.db<br/>容器写入<br/>奇数 seq<br/>messages_out<br/>心跳文件")]
|
||||
end
|
||||
|
||||
subgraph Groups["Agent Group 文件系统 (groups/*)"]
|
||||
Folder["CLAUDE.md<br/>记忆<br/>每个 group 的技能<br/>container_config"]
|
||||
end
|
||||
|
||||
P1 & P2 & P3 & P4 & P5 --> Bridge
|
||||
Bridge --> Router
|
||||
Router --> Central
|
||||
Router --> SessMgr
|
||||
SessMgr --> InDB
|
||||
SessMgr --> Runner
|
||||
Runner --> OneCLI
|
||||
Runner --> PollLoop
|
||||
PollLoop --> InDB
|
||||
PollLoop --> Provider
|
||||
Provider --> MCP
|
||||
Provider --> Skills
|
||||
MCP --> OutDB
|
||||
OutDB --> Delivery
|
||||
Delivery --> Central
|
||||
Delivery --> Bridge
|
||||
Bridge --> P1 & P2 & P3 & P4 & P5
|
||||
Sweep --> InDB
|
||||
Sweep --> OutDB
|
||||
Sweep --> Central
|
||||
Runner -.挂载.-> Folder
|
||||
MCP -.审批.-> Approvals
|
||||
Approvals --> Central
|
||||
Provider -.API 调用.-> Vault
|
||||
```
|
||||
|
||||
## 消息流程 (入站 -> agent -> 出站)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as 平台 (例如 Telegram)
|
||||
participant B as Chat SDK 桥接
|
||||
participant R as 路由器
|
||||
participant SM as Session 管理器
|
||||
participant IDB as inbound.db
|
||||
participant C as 容器 (agent-runner)
|
||||
participant ODB as outbound.db
|
||||
participant D as 投递轮询器
|
||||
|
||||
P->>B: 新消息
|
||||
B->>R: routeInbound(platformId, threadId, msg)
|
||||
R->>R: 解析 messaging_group -> agent_group -> session<br/>(agent-shared | shared | per-thread)
|
||||
R->>SM: 确保 session + DB 存在
|
||||
R->>IDB: INSERT messages_in (偶数 seq)
|
||||
R->>C: 唤醒容器 (docker run / 已在运行)
|
||||
C->>IDB: 轮询 messages_in
|
||||
C->>C: 格式化 xml, 流式传输到选定的 provider
|
||||
C->>ODB: INSERT messages_out (奇数 seq)<br/>解析 <message to="name"> 块
|
||||
D->>ODB: 1s 轮询(活跃)/ 60s(sweep)
|
||||
D->>D: hasDestination() 重新验证
|
||||
D->>B: 通过适配器投递
|
||||
B->>P: 发送消息 / 编辑 / 反应 / 文件 / 卡片
|
||||
```
|
||||
|
||||
## 命名目的地 + Agent 到 Agent
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph AgentA["Agent Group A (主agent)"]
|
||||
A_out["输出:<br/><message to='slack'>...</message><br/><message to='browser-agent'>...</message><br/><internal>scratchpad</internal>"]
|
||||
end
|
||||
|
||||
subgraph Dests["inbound.db.destinations (每个 agent)"]
|
||||
D1["slack -> messaging_group 42"]
|
||||
D2["browser-agent -> agent_group 7<br/>(双向行)"]
|
||||
D3["github -> messaging_group 13"]
|
||||
end
|
||||
|
||||
subgraph AgentB["Agent Group B (浏览器子agent)"]
|
||||
B_session["自己的 inbound.db / outbound.db<br/>继承了返回到 A 的目的地"]
|
||||
end
|
||||
|
||||
Slack[Slack 频道]
|
||||
GitHub[GitHub PR 线程]
|
||||
|
||||
A_out -->|解析 + 查找| Dests
|
||||
D1 -->|投递| Slack
|
||||
D2 -->|写入 B 的 inbound.db| B_session
|
||||
D3 -->|投递| GitHub
|
||||
B_session -.通过 'parent' 回复.-> Dests
|
||||
```
|
||||
|
||||
## 实体模型 + 隔离级别
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
agent_groups ||--o{ messaging_group_agents : 已连接
|
||||
messaging_groups ||--o{ messaging_group_agents : 已连接
|
||||
agent_groups ||--o{ sessions : 运行
|
||||
messaging_groups ||--o{ sessions : 上下文
|
||||
agent_groups ||--o{ agent_destinations : 拥有
|
||||
agent_groups ||--o{ pending_approvals : 请求
|
||||
|
||||
agent_groups {
|
||||
int id
|
||||
string name
|
||||
string folder
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
messaging_groups {
|
||||
int id
|
||||
string channel_type
|
||||
string platform_id
|
||||
string name
|
||||
bool is_group
|
||||
string unknown_sender_policy "strict | request_approval | public"
|
||||
}
|
||||
users {
|
||||
string id PK "命名空间 <channel>:<handle>"
|
||||
string kind
|
||||
string display_name
|
||||
}
|
||||
user_roles {
|
||||
string user_id FK
|
||||
string role "owner | admin"
|
||||
string agent_group_id FK "null = 全局"
|
||||
}
|
||||
agent_group_members {
|
||||
string user_id FK
|
||||
string agent_group_id FK
|
||||
}
|
||||
user_dms {
|
||||
string user_id FK
|
||||
string channel_type
|
||||
string messaging_group_id FK
|
||||
}
|
||||
messaging_group_agents {
|
||||
int messaging_group_id
|
||||
int agent_group_id
|
||||
string session_mode "agent-shared | shared | per-thread"
|
||||
json trigger_rules
|
||||
int priority
|
||||
}
|
||||
sessions {
|
||||
int id
|
||||
int agent_group_id
|
||||
int messaging_group_id
|
||||
string sdk_session_id
|
||||
string status
|
||||
}
|
||||
```
|
||||
|
||||
### 隔离级别速查表
|
||||
|
||||
| 级别 | `session_mode` | 共享内容 | 示例 |
|
||||
|---|---|---|---|
|
||||
| 1. 共享 session | `agent-shared` | 工作区 + 记忆 + 对话 | Slack + GitHub webhooks 在同一线程 |
|
||||
| 2. 相同 agent,独立 session | `shared` / `per-thread` | 仅工作区 + 记忆 | 一个 agent 跨 3 个 Telegram 聊天 |
|
||||
| 3. 独立的 agent group | (不同的 `agent_group_id`) | 无 | 个人 vs 工作频道 |
|
||||
|
||||
## 双库拆分(为什么)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Mount["/workspace (挂载到容器中的卷)"]
|
||||
In[("inbound.db")]
|
||||
Out[("outbound.db")]
|
||||
HB["/.heartbeat (文件 touch)"]
|
||||
end
|
||||
|
||||
Host[宿主机进程] -->|"仅写入<br/>(偶数 seq)"| In
|
||||
Host -->|读取| Out
|
||||
Container[agent-runner] -->|读取| In
|
||||
Container -->|"仅写入<br/>(奇数 seq)"| Out
|
||||
Container -->|每次轮询 touch| HB
|
||||
HostSweep[宿主机 sweep] -->|stat mtime| HB
|
||||
HostSweep -->|读取 processing_ack| In
|
||||
|
||||
note1["每个文件有且仅有一个写入者。<br/>消除了 SQLite 跨进程写入竞争。<br/>无冲突的 seq 编号。"]
|
||||
```
|
||||
911
docs/zh/architecture.md
Normal file
911
docs/zh/architecture.md
Normal file
@@ -0,0 +1,911 @@
|
||||
# NanoClaw 架构(草案)
|
||||
|
||||
## 核心理念
|
||||
|
||||
每个智能体会话(agent session)都有一个挂载的 SQLite 数据库(DB)。该数据库是宿主机(host)与容器(container)之间的唯一 IO 机制。没有 IPC 文件,没有 stdin 管道。两张表:`messages_in`(宿主机 → agent-runner)和 `messages_out`(agent-runner → 宿主机)。一切皆消息。
|
||||
|
||||
## 两级数据库
|
||||
|
||||
**中央数据库(Central DB,宿主机进程内):**
|
||||
- 智能体组(agent group)、对话、路由(routing)表
|
||||
- 将平台 ID 映射到 agent group → 会话(session)
|
||||
- 通道适配器(channel adapter)不直接访问此库——宿主机负责查找
|
||||
|
||||
**逐会话数据库(Per-session DB,挂载到容器内):**
|
||||
- `messages_in`(宿主机写入,agent-runner 读取)
|
||||
- `messages_out`(agent-runner 写入,宿主机读取)
|
||||
- 一切皆消息:聊天、任务、Webhook、系统操作、智能体间通信——均使用这两张表
|
||||
- 每个 session 一个 DB,而非每个 agent group 一个
|
||||
|
||||
## Agent Group 与 Session
|
||||
|
||||
一个 agent group 拥有自己的文件系统——文件夹、CLAUDE.md、技能(skills)、容器配置。多个 session 可以共享同一个 agent group(相同的文件系统、相同的 skills),但每个 session 都有自己独立的 DB,挂载在已知路径上。每个 session = 一个独立的容器,使用相同 agent group 的文件系统但拥有不同的 session DB。
|
||||
|
||||
## 消息流程
|
||||
|
||||
```
|
||||
平台事件
|
||||
→ 通道适配器(Channel Adapter,触发器检查、ID 提取)
|
||||
→ 返回:{ platformChannelId, platformThreadId, triggered }
|
||||
→ 宿主机将 platformChannelId + platformThreadId 映射到 agent group + session
|
||||
→ 宿主机将消息写入 session 的 DB
|
||||
→ 宿主机调用 wakeUpAgent(session)
|
||||
→ 容器启动(或已在运行)
|
||||
→ Agent-runner 轮询其 session DB,发现新消息
|
||||
→ Agent-runner 调用 Claude 进行处理
|
||||
→ Agent-runner 将响应写入 session DB
|
||||
→ 宿主机轮询活跃 session DB 以获取响应
|
||||
→ 宿主机读取响应,查找对话,通过 channel adapter 进行投递(delivery)
|
||||
```
|
||||
|
||||
## 通道适配器(Channel Adapter)
|
||||
|
||||
Channel adapter 负责:
|
||||
1. 接收平台事件(Webhook、轮询(polling)、WebSocket——平台特定)
|
||||
2. **过滤**:决定将哪些消息转发给宿主机处理。可以是无状态的(正则触发器匹配)或有状态的(例如,"该机器人曾在此线程中被提及过吗?如果是,则转发所有后续消息")。Adapter 接收未经过滤的平台消息流,并决定传递哪些消息。如何决定是实现细节——NanoClaw 不关心也不需知道。
|
||||
3. 提取并标准化两个 ID:
|
||||
- **Platform channel ID**——标识对话(WhatsApp 群组、Slack 频道、邮件线程)
|
||||
- **Platform thread ID**——可选的子上下文(Slack 消息分支、GitHub PR 评论分支)
|
||||
4. 出站投递——将响应发送回平台
|
||||
|
||||
Channel adapter 不知道 agent group ID 或 session ID。它返回平台级别的标识符。宿主机将这些映射到实体模型。
|
||||
|
||||
两级 ID 方案(channel ID + thread ID)提供了灵活性:
|
||||
- 希望每个 Slack 消息分支都是独立的 session?返回唯一的 thread ID。
|
||||
- 希望 Slack 频道中的所有消息共享一个 session?返回相同的 thread ID(或 null)。
|
||||
- 这是按通道配置的,而非全局配置。
|
||||
|
||||
### Channel Adapter 配置
|
||||
|
||||
Adapter 是无状态的——它们在设置时从宿主机接收配置,而非直接从 DB 读取。
|
||||
|
||||
**存在于代码中的(按通道类型,运行时不变):**
|
||||
- 自动注册(auto-registration)行为(启用/禁用,如何工作)
|
||||
- 发送者允许列表规则
|
||||
- 允许列表中的发送者是否可以自动注册群组
|
||||
- 平台特定的连接和消息处理
|
||||
|
||||
这些是在设置 channel adapter 时做出的决策。更改它们 = 修改代码。
|
||||
|
||||
**存在于 DB 中的(按群组,因组而异):**
|
||||
- 由哪个 agent group 处理
|
||||
- 触发/过滤规则(正则、仅 @提及、排除某些发送者等)
|
||||
- 响应范围(响应所有消息 vs 仅已触发/允许列表中的消息)
|
||||
- Session 模式(共享 vs 每线程独立)
|
||||
|
||||
宿主机从 DB 读取每个群组的配置,并在设置时将其传递给 adapter。如果配置在运行时发生更改(管理员智能体注册新群组、更改触发器),宿主机调用 adapter 的更新方法。
|
||||
|
||||
### 自动注册(Auto-Registration)
|
||||
|
||||
当 adapter 转发来自未知群组的消息时,宿主机需要决定是否创建该群组及其 session。
|
||||
|
||||
**Adapter 控制是否转发未知消息**——基于其代码级别的 auto-registration 规则(发送者允许列表、群组添加检测等)。如果 adapter 转发它,宿主机则创建群组 + session。
|
||||
|
||||
**已知群组的 session 创建:**
|
||||
- 共享 session 模式:宿主机查找现有 session,如果是第一条消息则创建一个
|
||||
- 每线程独立 session 模式:宿主机通过 threadId 查找。如果此线程不存在 session,则使用相同的 agent group 自动创建一个
|
||||
|
||||
**代码级别的规则是通道特定的:**
|
||||
- WhatsApp:如果允许列表中的号码将机器人添加到群组 → 自动注册。如果未知号码私信 → 取决于 adapter 的配置。
|
||||
- 邮件:如果发送者已知 → 自动注册线程。如果未知 → 丢弃。
|
||||
- Slack:如果某人在新频道中 @提及机器人 → adapter 根据其规则决定是否转发。
|
||||
|
||||
没有 `channel_configs` 表——通道类型级别的行为内置于 adapter 代码中。
|
||||
|
||||
### Chat SDK 集成
|
||||
|
||||
Chat SDK Adapter 按通道封装:
|
||||
- 每个 Chat SDK adapter 拥有自己的 Chat 实例
|
||||
- 并发模式按通道配置(聊天使用并发,任务使用队列,Webhook 使用防抖)
|
||||
- 一个桥接(bridge)封装 Chat 实例 + adapter,以符合 NanoClaw 的标准通道接口
|
||||
- Chat SDK 处理:Webhook 解析、去重、消息历史、平台 API 调用、富内容投递
|
||||
- NanoClaw 处理:路由、智能体生命周期、session 管理
|
||||
|
||||
**Chat SDK 的订阅模型:**
|
||||
|
||||
Chat SDK 拥有自己的线程级订阅概念(与 NanoClaw 的通道级注册不同):
|
||||
- `onNewMention` / `onNewMessage(regex)`——首次联系时触发(例如,在 Slack 消息分支中的 @提及)
|
||||
- `thread.subscribe()`——选择接收该线程中的所有未来消息
|
||||
- `onSubscribedMessage`——在已订阅线程中的所有消息触发
|
||||
|
||||
这是子通道粒度。NanoClaw 在通道级别注册("监听此 Discord 频道")。Chat SDK 在线程级别订阅("追踪此特定 Slack 消息分支")。桥接允许 Chat SDK 内部管理自己的订阅——NanoClaw 不干预或复制此行为。
|
||||
|
||||
**平台能力差异:**
|
||||
|
||||
各 adapter 的能力差异显著(参见 [Chat SDK adapter 文档](https://chat-sdk.dev/docs/adapters)):
|
||||
- **Slack**:完整富内容(Block Kit 卡片、模态框、流式传输、表情反应、临时消息)
|
||||
- **Discord**:Embeds、按钮、通过 post+edit 实现的流式传输
|
||||
- **WhatsApp(Cloud API)**:仅私信、交互式回复按钮、不支持流式传输、不支持表情反应
|
||||
- **GitHub/Linear**:Markdown 评论、无交互元素
|
||||
- **Telegram**:内联键盘按钮、通过 post+edit 实现的流式传输
|
||||
|
||||
宿主机/桥接处理优雅降级——如果 agent 在不支持卡片的平台上发布卡片,则回退为文本。
|
||||
|
||||
非 Chat SDK 通道(WhatsApp via Baileys、Gmail、自定义集成)直接实现 NanoClaw 通道接口——无需桥接,无需 Chat SDK 类型。
|
||||
|
||||
## 容器生命周期
|
||||
|
||||
宿主机是一个编排器(orchestrator):
|
||||
1. **启动(Spawn)**——当调用 wakeUpAgent 且该 session 不存在容器时
|
||||
2. **空闲终止(Idle kill)**——当容器在某个超时时段内没有未处理消息时
|
||||
3. **限制(Limits)**——MAX_CONCURRENT_CONTAINERS 限制活跃容器数量
|
||||
|
||||
当容器启动时,agent-runner 立即开始轮询其 session DB。消息已在那里等待。
|
||||
|
||||
## 媒体处理
|
||||
|
||||
### 入站
|
||||
|
||||
宿主机不下载媒体。取而代之:
|
||||
- 消息包含下载 URL(尽可能使用签名 URL)
|
||||
- Agent-runner 在容器内下载并处理媒体
|
||||
- 对于签名 URL 不适用的通道(例如,使用缓冲流的 WhatsApp),channel adapter 下载媒体并通过容器可访问的本地 URL/服务器提供服务
|
||||
|
||||
**原生内容块(取决于提供商):**
|
||||
|
||||
Agent-runner 检测文件类型,并在提供商支持的情况下将支持的类型作为原生内容块传递:
|
||||
|
||||
| 类型 | Claude | Codex | OpenCode |
|
||||
|------|--------|-------|----------|
|
||||
| 图片(JPEG、PNG、GIF、WebP) | 原生图片内容块 | 保存到磁盘,在提示中引用 | 保存到磁盘,在提示中引用 |
|
||||
| PDF | 原生文档内容块 | 保存到磁盘 | 保存到磁盘 |
|
||||
| 音频 | 原生音频内容块 | 保存到磁盘 | 保存到磁盘 |
|
||||
| 其他文件(代码、数据、视频、归档) | 保存到磁盘 | 保存到磁盘 | 保存到磁盘 |
|
||||
|
||||
"保存到磁盘"指下载到 `/workspace/downloads/{messageId}/`,并在提示文本中作为可用文件路径引用。Agent 可以使用工具(Read、Bash)来访问它。
|
||||
|
||||
Agent-runner 根据提供商不同构建提示。对于 Claude,它构造包含图片/文档块的多部分 `MessageParam` 内容。对于 Codex/OpenCode,所有内容都是带有文件路径引用的文本。
|
||||
|
||||
### 出站
|
||||
|
||||
出站文件投递是基于工具的。Agent 使用文件路径调用工具(例如 `send_file`)。Agent-runner 将文件移动到发件箱并写入 `messages_out` 行。
|
||||
|
||||
```
|
||||
/workspace/
|
||||
outbox/
|
||||
{message_id}/ ← 每个 messages_out 行一个目录
|
||||
chart.png
|
||||
report.pdf
|
||||
```
|
||||
|
||||
`messages_out` 内容仅引用文件名:
|
||||
|
||||
```json
|
||||
{ "text": "这是图表", "files": ["chart.png", "report.pdf"] }
|
||||
```
|
||||
|
||||
DB 中没有路径——约定即是契约。宿主机从挂载的 session 文件夹中的 `outbox/{message_id}/` 读取文件,并通过 adapter 进行投递(Chat SDK 使用 `FileUpload` 附带缓冲区数据,或原生通道使用平台特定上传)。宿主机在成功投递后清理 outbox 目录。
|
||||
|
||||
出站文件使用专用的 `send_file` MCP 工具(与 `send_message` 分离)。参见 [agent-runner-details.md](agent-runner-details.md) 了解工具接口。
|
||||
|
||||
### 消息去重
|
||||
|
||||
去重是 channel adapter 的职责。Chat SDK 内部处理此问题。原生 adapter 根据需要追踪平台消息 ID。宿主机不进行去重——如果 adapter 转发消息,宿主机就写入。
|
||||
|
||||
## Session DB 模式
|
||||
|
||||
两张表。内容使用 JSON blob——无模式,格式因 `kind` 而异。
|
||||
|
||||
```sql
|
||||
-- 宿主机写入,agent-runner 读取
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed'
|
||||
status_changed TEXT, -- 最后状态变更的 ISO 时间戳
|
||||
process_after TEXT, -- ISO 时间戳。NULL = 立即处理。
|
||||
recurrence TEXT, -- cron 表达式。NULL = 一次性。
|
||||
tries INTEGER DEFAULT 0, -- 处理尝试次数
|
||||
|
||||
-- 路由(agent-runner 复制到 messages_out;agent 永远不会看到这些字段)
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
-- 负载(结构取决于 kind)
|
||||
content TEXT NOT NULL -- JSON blob
|
||||
);
|
||||
|
||||
-- Agent-runner 写入,宿主机读取
|
||||
CREATE TABLE messages_out (
|
||||
id TEXT PRIMARY KEY,
|
||||
in_reply_to TEXT, -- 引用 messages_in.id(可选)
|
||||
timestamp TEXT NOT NULL,
|
||||
delivered INTEGER DEFAULT 0,
|
||||
deliver_after TEXT, -- ISO 时间戳。NULL = 立即投递。
|
||||
recurrence TEXT, -- cron 表达式。NULL = 一次性。
|
||||
|
||||
-- 路由(默认:由 agent-runner 从 messages_in 复制)
|
||||
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
-- 负载(格式匹配 kind)
|
||||
content TEXT NOT NULL -- JSON blob
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
### 调度(Scheduling)
|
||||
|
||||
一次性任务和循环(recurrence)任务使用相同的表——没有独立的调度器。
|
||||
|
||||
**一次性:** `process_after`(入站)或 `deliver_after`(出站),且 `recurrence = NULL`。
|
||||
|
||||
**循环:** 同上,外加 `recurrence` cron 表达式。宿主机将行标记为已处理/已投递后,如果设置了 `recurrence`,则插入新行,其中 `process_after`/`deliver_after` 推进到下一个 cron 时间点。下次时间从计划时间(而非墙上时间)计算,以防止漂移。
|
||||
|
||||
**宿主机巡检(Host sweep,所有 session DB 约每 60 秒一次):**
|
||||
- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → 唤醒 agent
|
||||
- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → 过期检测,增加 `tries`,带退避重置为 `pending`
|
||||
- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → 投递
|
||||
- 完成/投递带有 `recurrence` 的行后,插入下一次发生
|
||||
|
||||
**活跃容器轮询**(约 1 秒)检查相同的条件,但仅针对正在运行容器的 session。
|
||||
|
||||
**Agent-runner 创建调度**的方式是写入带有 `process_after` 和可选的 `recurrence` 的 `messages_in`(发给自身)或 `messages_out`(提醒/通知)。
|
||||
|
||||
### messages_in 各 kind 的内容格式
|
||||
|
||||
**`chat`** — 简洁的 NanoClaw 格式。任何通道都可以生成此格式。
|
||||
```json
|
||||
{
|
||||
"sender": "John",
|
||||
"senderId": "user123",
|
||||
"text": "请检查这个 PR",
|
||||
"attachments": [{ "type": "image", "url": "https://signed-url..." }],
|
||||
"isFromMe": false
|
||||
}
|
||||
```
|
||||
|
||||
**`chat-sdk`** — 完整的 Chat SDK `SerializedMessage`,从桥接 adapter 透传。包含 `author`、`text`、`formatted`(mdast AST)、`attachments`、`isMention`、`links`、`metadata`。
|
||||
|
||||
**`task`** — 计划任务触发。
|
||||
```json
|
||||
{ "prompt": "审查开放 PR", "script": "scripts/review.sh" }
|
||||
```
|
||||
|
||||
**`webhook`** — 原始 Webhook 负载。
|
||||
```json
|
||||
{ "source": "github", "event": "pull_request", "payload": { ... } }
|
||||
```
|
||||
|
||||
**`system`** — 宿主机操作结果(对 agent 请求的系统操作的响应)。
|
||||
```json
|
||||
{ "action": "register_group", "status": "success", "result": { "agent_group_id": "ag-456" } }
|
||||
```
|
||||
|
||||
### messages_out 各 kind 的内容格式
|
||||
|
||||
输出 `kind` 决定格式和投递 adapter。默认:agent-runner 从正在响应的 `messages_in` 行复制 `kind` 和路由字段。
|
||||
|
||||
**`chat`** — 简洁的 NanoClaw 格式。NanoClaw 通道通过 `sendMessage(text)` 投递。
|
||||
```json
|
||||
{ "text": "LGTM,正在合并" }
|
||||
```
|
||||
|
||||
**`chat-sdk`** — Chat SDK `AdapterPostableMessage`。桥接 adapter 通过 `thread.post()` 投递。可以是 Markdown、卡片或原始格式——adapter 处理平台转换。
|
||||
```json
|
||||
{ "markdown": "## 审查\n**LGTM**", "attachments": [...] }
|
||||
```
|
||||
```json
|
||||
{ "card": { "type": "card", "title": "审查", "children": [...] }, "fallbackText": "..." }
|
||||
```
|
||||
|
||||
**`task`** — 任务结果。宿主机记录日志并可选择通知。
|
||||
```json
|
||||
{ "result": "已审查 3 个 PR", "status": "success" }
|
||||
```
|
||||
|
||||
**`webhook`** — Webhook 响应。宿主机发送 HTTP 响应或通知。
|
||||
```json
|
||||
{ "response": { "status": 200, "body": { ... } } }
|
||||
```
|
||||
|
||||
**`system`** — 宿主机操作请求(注册群组、重置 session 等)。宿主机读取、验证权限、执行、将结果作为 `system` 类型的 `messages_in` 行写回。
|
||||
```json
|
||||
{ "action": "reset_session", "payload": { "session_id": "sess-123" } }
|
||||
```
|
||||
|
||||
### 交互式操作(卡片、表情反应、编辑)
|
||||
|
||||
所有交互式操作通过 `messages_in`/`messages_out` 流转——DB 是容器唯一的 IO 边界。Agent 使用 MCP 工具;agent-runner 将工具调用转换为结构化的 `messages_out` 行;宿主机通过适当的 adapter 方法投递。
|
||||
|
||||
**带有用户交互的卡片(例如,"向用户提问"):**
|
||||
|
||||
1. Agent 调用 `ask_user_question` 工具,附带问题 + 选项
|
||||
2. Agent-runner 将问询卡片写入 `messages_out`
|
||||
3. 宿主机通过 adapter 以交互式卡片形式投递(例如,Slack Block Kit 按钮)
|
||||
4. 用户点击选项
|
||||
5. 平台将事件发送回 adapter → 宿主机将响应写入 `messages_in`
|
||||
6. Agent-runner 读取 `messages_in`,匹配到挂起的工具调用,将选择结果作为工具结果返回给 agent
|
||||
|
||||
Agent-runner 在等待 `messages_in` 中的用户响应期间保持工具调用打开。往返路径:agent → `messages_out` → 宿主机 → 平台 → 用户点击 → 平台 → 宿主机 → `messages_in` → agent-runner → agent。
|
||||
|
||||
**审批(Approvals):**
|
||||
|
||||
两种模式,均在宿主机级别处理:
|
||||
- **隐式**:Agent 调用需要审批的工具。宿主机拦截,向管理员发送审批卡片,等待响应,然后执行或拒绝。Agent 不知道审批步骤。
|
||||
- **显式**:Agent 通过工具显式请求审批。Agent-runner 将审批请求写入 `messages_out`。与"向用户提问"流程相同——响应通过 `messages_in` 返回。
|
||||
|
||||
两种情况下,审批和操作执行均在宿主机侧进行,而非 agent 侧。
|
||||
|
||||
**审批路由:** 权限是用户级别的概念。`user_roles` 记录 `owner`(仅全局——第一个配对的用户成为所有者)和 `admin`(全局或限定于特定 `agent_group_id`)。当操作需要审批时,`pickApprover(agentGroupId)` 按顺序返回候选者:该 agent group 的限定管理员 → 全局管理员 → 所有者(去重)。然后 `pickApprovalDelivery` 取第一个可通过 `ensureUserDm` 联系到的候选者(采用同通道类型优先策略,因此 Discord 审批请求优先选择使用 Discord 的审批者)。审批卡片发送到审批者的 DM 消息组,而非发起对话。对于需要 DM 解析的通道(Discord/Slack/…),投递通过 Chat SDK 的 `openDM` 解析;对于可直接寻址的通道(Telegram/WhatsApp/…),直接使用用户句柄。映射缓存在 `user_dms` 中以供后续请求使用。参见 `src/access.ts`、`src/user-dm.ts`。
|
||||
|
||||
**编辑已发送消息:**
|
||||
|
||||
Agent 调用 `edit_message` 工具,附带消息 ID 和新内容。Agent-runner 将编辑操作写入 `messages_out`。宿主机调用 `adapter.editMessage()`。Agent 上下文中的消息包含整数 ID,因此 agent 可以引用它们。
|
||||
|
||||
**表情反应(Reactions):**
|
||||
|
||||
Agent 调用 `add_reaction` 工具,附带消息 ID 和 emoji。Agent-runner 将反应操作写入 `messages_out`。宿主机调用 `adapter.addReaction()`。
|
||||
|
||||
**`messages_out` 内容中的操作:**
|
||||
|
||||
```json
|
||||
// 普通消息(默认)
|
||||
{ "text": "LGTM" }
|
||||
|
||||
// 交互式卡片
|
||||
{ "operation": "ask_question", "title": "部署", "question": "批准部署?", "options": ["是", "否", "推迟"] }
|
||||
|
||||
// 编辑现有消息
|
||||
{ "operation": "edit", "messageId": "3", "text": "更新:LGTM,有少量意见" }
|
||||
|
||||
// 表情反应
|
||||
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
|
||||
```
|
||||
|
||||
宿主机读取 `operation` 字段(如果存在)并调用相应的 adapter 方法。没有 `operation` 字段 = 普通消息投递。平台能力各异——宿主机/桥接处理优雅降级(例如,在不支持表情反应的平台上添加表情 → 跳过或作为文本发送)。
|
||||
|
||||
### 智能体间通信(Agent-to-Agent Communication)
|
||||
|
||||
向另一个 agent 发送消息使用与通道投递相同的路由字段。Agent-runner 设置 `channel_type: 'agent'`,`platform_id` 设为目标 agent group ID。可选地,`thread_id` 可以指定特定 session(null = 查找或创建默认 session)。
|
||||
|
||||
从发送 agent 的角度看,这与发送到 Slack 或 WhatsApp 是相同的机制——只是带有不同路由信息的 `messages_out` 行。宿主机读取后,检查此 agent group 是否有权向目标发送消息,解析目标 session,并将 `messages_in` 行写入该 session 的 DB。
|
||||
|
||||
```json
|
||||
// messages_out 路由字段
|
||||
{ "kind": "chat", "channel_type": "agent", "platform_id": "pr-worker", "thread_id": null }
|
||||
// messages_out 内容
|
||||
{ "text": "重置你的 session 并重新审查", "sender": "监督者", "senderId": "agent:pr-admin" }
|
||||
```
|
||||
|
||||
接收 agent 得到的是普通的聊天消息。除非相关内容需要,否则它不需要知道来源是另一个 agent。
|
||||
|
||||
### 路由
|
||||
|
||||
**默认行为:** Agent-runner 将路由字段(`kind`、`platform_id`、`channel_type`、`thread_id`)从 `messages_in` 行复制到 `messages_out`。响应发送回原始来源。
|
||||
|
||||
**宿主机验证:** 投递前,宿主机检查此 agent group 是否被允许向目标发送消息。Agent-runner 复制路由;宿主机验证。
|
||||
|
||||
**多目标模式(定制):** Agent 可能需要发送到与来源不同的通道(例如,Webhook 触发 Slack 通知)。这通过自定义代码支持,而非内置于核心:
|
||||
|
||||
1. 向 session DB 添加 `destinations` 表,将逻辑名称映射到路由字段
|
||||
2. 在设置 session 时由宿主机填充
|
||||
3. 修改 agent 提示以列出可用的目标
|
||||
4. Agent 通过名称选择目标;agent-runner 解析为路由字段
|
||||
5. 宿主机照常验证
|
||||
|
||||
这被记录为一种模式,而非内置功能。
|
||||
|
||||
## 核心属性
|
||||
- 通过文件系统挂载实现的容器隔离(isolation)
|
||||
- 凭证代理(OneCLI)
|
||||
- 每个 agent group 的工作空间(文件夹、CLAUDE.md、skills)
|
||||
- 基于轮询(非事件驱动)
|
||||
- 容器启动时对每个 agent group 的 agent-runner 进行重新编译(agent 可以修改其自身源代码,请求重建/重启,更改在销毁后仍然保留)
|
||||
- 宿主机 ↔ 容器 IO 通过挂载的 session DB(`messages_in` / `messages_out`)——无 stdin 管道,无 IPC 文件
|
||||
- Agent 命令是 `kind: 'system'` 的 `messages_out` 行
|
||||
- 通过 `messages_out` 上的目标 agent 路由支持 agent-to-agent 通信
|
||||
- 调度使用相同消息表上的 `process_after` / `deliver_after` + `recurrence`
|
||||
- 媒体通过签名 URL,在容器内下载
|
||||
- Channel adapter 使用 Chat SDK 桥接 + 标准接口(主干代码仅包含桥接/注册表;平台 adapter 通过 `/add-<channel>` 技能安装)
|
||||
- 路由:channel adapter 提取 ID,宿主机映射到实体
|
||||
- 并发:Chat SDK 按通道 + 容器限制
|
||||
- Session 范围:逐 session DB,每个 agent group 多个 session
|
||||
|
||||
## 设计决策
|
||||
|
||||
**Session DB 位置:** 不在 agent group 文件夹中。独立目录(例如,`sessions/{session_id}/`)。每个 session 拥有自己的文件夹,包含 `session.db` 和 Claude SDK 的 `.claude/` 目录。Session 身份即是文件夹——无需追踪 Claude SDK session ID。
|
||||
|
||||
**容器挂载结构:**
|
||||
|
||||
```
|
||||
/workspace/ ← 挂载:session 文件夹(读写)
|
||||
.claude/ ← Claude SDK session 数据(自动创建)
|
||||
session.db ← session SQLite DB
|
||||
outbox/ ← agent-runner 在此写入出站文件
|
||||
agent/ ← 挂载:agent group 文件夹(嵌套,读写)
|
||||
CLAUDE.md ← agent 指令
|
||||
skills/ ← agent 技能
|
||||
... 工作文件
|
||||
```
|
||||
|
||||
两个目录挂载:session 文件夹挂载到 `/workspace`,agent group 文件夹挂载到 `/workspace/agent/`。Agent-runner 进入 `/workspace/agent/` 来运行 agent。Claude SDK 将 `.claude/` 写入 `/workspace/.claude/`(工作空间的根目录)。Session DB 位于 `/workspace/session.db`。
|
||||
|
||||
这在 Docker(嵌套 bind mount)和 Apple Container(仅目录挂载——不支持文件级挂载,但支持嵌套目录挂载)上均可工作。
|
||||
|
||||
**Session DB 并发访问:** 宿主机写入 `messages_in`,agent-runner 写入 `messages_out`。两者同时访问同一 SQLite 文件。WAL 模式处理此问题——SQLite 允许多个并发读取者,且双方写入不同的表,因此写入争用极小。宿主机在创建 session DB 时启用 WAL 模式。
|
||||
|
||||
**Session 管理:** 宿主机管理。宿主机创建 session 文件夹并挂载。容器只能看到自己的 session 文件夹。
|
||||
|
||||
**Session 创建(无竞态条件):**
|
||||
|
||||
1. 消息到达,宿主机在中央数据库中检查是否有匹配此群组 + 线程的 session
|
||||
2. Session 不存在 → 宿主机原子性地在中央数据库中创建 session 行,创建 session 文件夹,创建 session DB,写入消息
|
||||
3. 在容器启动前有更多消息到达 → 宿主机找到现有 session,写入同一 session DB
|
||||
4. 容器启动,挂载文件夹,agent-runner 发现等待中的消息
|
||||
|
||||
中央数据库中的 session 行创建是序列化点。无需协调 Claude SDK session ID——当 agent 运行时,SDK 自行发现其在 `.claude/` 中的 session 数据。
|
||||
|
||||
**系统操作:** Agent 使用 MCP 工具(注册群组、重置 session、计划任务等)。Agent-runner 处理这些工具调用,并写入结构化的、确定性的 `kind: 'system'` 的 `messages_out` 行。这不是自然语言——它是宿主机确定性处理的编程式、结构化负载。宿主机验证权限、执行,并将结果作为 `system` 类型的 `messages_in` 行写回。
|
||||
|
||||
**容器生命周期:** 无预热池。容器按需启动(wakeUpAgent),并在空闲时由宿主机从外部终止。现有空闲检测 + 终止机制得以沿用。
|
||||
|
||||
## 运行行为
|
||||
|
||||
### 输出投递
|
||||
|
||||
NanoClaw 不向用户流式传输 token。Claude Agent SDK 的 `query()` 生成完整结果。Agent-runner 将每个结果的一条完整消息写入 `messages_out`。宿主机将完整消息投递到通道。
|
||||
|
||||
消息编辑是作为显式操作支持的(agent 调用 `edit_message` 工具),而非作为流式传输机制。
|
||||
|
||||
输入指示器:当容器对某个 session 处于活跃状态时,宿主机设置输入状态,当容器退出或 `messages_out` 中出现响应时清除。
|
||||
|
||||
### 消息批处理
|
||||
|
||||
当多条消息在容器停机期间到达时,它们以 `handled = 0` 行的形式累积在 `messages_in` 中。当容器唤醒时,agent-runner 查询所有未处理的消息,并将它们作为批次处理——多条消息被格式化到单个 `<messages>` XML 块中。
|
||||
|
||||
### 消息生命周期
|
||||
|
||||
```
|
||||
pending → processing → completed
|
||||
→ failed(达到最大重试次数后)
|
||||
```
|
||||
|
||||
- **pending**:宿主机写入。待拾取(如果 `process_after` 为 null 或已过时)。
|
||||
- **processing**:Agent-runner 在拾取消息时设置此状态。`status_changed` 设为当前时间。防止其他轮询重复拾取同一消息。
|
||||
- **completed**:Agent-runner 在成功处理后设置此状态。
|
||||
- **failed**:耗尽最大重试次数后设置。
|
||||
|
||||
**过期检测(Stale detection)**:如果消息处于 `processing` 状态但 `status_changed` 太久远(例如 > 10 分钟),宿主机假定容器崩溃。它将消息重置为 `pending`,递增 `tries`,并设置带指数退避的 `process_after`。
|
||||
|
||||
### 错误处理与重试
|
||||
|
||||
重试使用带有指数退避的 `process_after`。每次重试递增 `tries` 并推迟 `process_after`:
|
||||
|
||||
- 尝试 1:立即
|
||||
- 尝试 2:+5 秒
|
||||
- 尝试 3:+10 秒
|
||||
- 尝试 4:+20 秒
|
||||
- 尝试 5:+40 秒
|
||||
- 达到最大重试次数后:状态设为 `failed`
|
||||
|
||||
由宿主机计算此信息——而非 agent-runner。当宿主机检测到过期的 `processing` 消息或容器异常退出时,递增 `tries`,计算下一个 `process_after`,并将状态重置为 `pending`。
|
||||
|
||||
**输出已发送保护**:如果某批次已有 `delivered` 的 `messages_out` 行,则不重试(防止向用户发送重复消息)。
|
||||
|
||||
### 宿主机轮询
|
||||
|
||||
两个层级:
|
||||
- **活跃容器(约 1 秒)**:轮询 session DB 以获取待投递的新 `messages_out` 行
|
||||
- **所有 session(约 60 秒)**:巡检所有 session DB,查找到期的 `process_after` / `deliver_after` 时间戳,处理循环
|
||||
|
||||
## 灵活性模型
|
||||
|
||||
该架构**对代码修改灵活,但并非所有场景都可配置**。高级设置(如以下 PR Factory)使用自定义路由逻辑和宿主机侧钩子——而非数据库配置列。
|
||||
|
||||
### 用于技能定制的代码结构
|
||||
|
||||
NanoClaw 通过技能(skills)进行定制——合并到用户安装中的分支。不同的 skills 添加不同的能力(通道、集成、行为)。代码必须结构化为:
|
||||
|
||||
1. **不同定制互不冲突。** 添加 Slack 和添加 Telegram 不应产生合并冲突。添加新的 MCP 工具不应与添加通道冲突。每种定制类型应有自己的文件。
|
||||
|
||||
2. **核心功能块在单独的文件中。** 通道注册、消息格式化、MCP 工具、路由逻辑、容器管理——各自独立的文件。更改消息格式化方式的 skill 不会触及处理容器启动的文件。
|
||||
|
||||
3. **入口文件(index)保持精简。** 它将各部分连接起来(初始化 DB、启动 adapter、启动轮询循环),但不包含业务逻辑。所有逻辑驻留在目的特定的模块中,skills 可以独立修改。
|
||||
|
||||
4. **不要过度拆分。** 简单的更改(例如,添加新的消息类型)不应需要在 5 个文件中编辑。将相关逻辑分组在一起。目标是每个 skill 的核心更改只触及 1-2 个文件。
|
||||
|
||||
5. **注册模式优先于 switch 语句。** 通道、MCP 工具和提供商应使用注册/插件模式。Skill 通过添加文件和注册调用来添加通道——而不是在中央 switch 语句中与其他每个通道一起编辑。
|
||||
|
||||
**实际示例:** 通过 skill 添加新通道应需要:
|
||||
- 一个新文件(channel adapter 或 Chat SDK 配置)
|
||||
- 在 barrel 文件(`channels/index.ts`)中添加一行以导入自注册模块
|
||||
- 对路由、格式化、投递或容器代码零更改
|
||||
|
||||
### 冲突热点与解决方案
|
||||
|
||||
对 33 个 skill 分支的分析显示以下文件导致最多的合并冲突:
|
||||
|
||||
| 热点 | 冲突原因 | 解决方案 |
|
||||
|-----------|-----------------|-------------|
|
||||
| `src/index.ts`(2000 LOC) | 每个 skill 都修补主循环、导入、初始化逻辑 | 精简的入口文件,连接各模块。逻辑驻留在目的特定文件中(router、delivery、session-manager、host-sweep)。 |
|
||||
| `src/config.ts` | 每个 skill 都向中央文件添加环境变量 | 配置在使用的模块内声明。每个模块读取自己的 env var。没有每个 skill 都编辑的中央配置注册表。 |
|
||||
| `src/container-runner.ts` | 通道 skills 添加挂载、env var、凭证设置 | 声明式挂载注册。通道在自己的文件中声明挂载。Container runner 从注册表中读取,而非硬编码列表。 |
|
||||
| `src/db.ts`(750 LOC) | 模式、迁移和所有 CRUD 在一个文件中 | 按实体拆分。编号迁移。Skills 添加迁移文件 + 编辑一个实体文件。 |
|
||||
| `container/agent-runner/src/index.ts` | Agent 协议、IPC 处理、格式化全在一个文件中 | 拆分为 poll-loop、formatter、providers/、mcp-tools/。Session DB 替代 IPC。 |
|
||||
| `src/ipc.ts` | 每个 MCP 工具添加都修补一个文件 | `mcp-tools/` 目录配合 barrel。Skills 添加工具文件 + barrel 行。 |
|
||||
| `src/channels/index.ts` | 每个通道在同一位置添加 import 行 | 带每个通道注释槽的 barrel 文件(当前模式有效,保留)。 |
|
||||
|
||||
**挂载注册模式:** 与其让每个通道 skill 编辑 `buildVolumeMounts()`,通道声明挂载,container runner 收集它们:
|
||||
|
||||
```typescript
|
||||
// channels/gmail.ts
|
||||
registerChannel('gmail', {
|
||||
factory: createGmailAdapter,
|
||||
mounts: [
|
||||
{ hostPath: '~/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: false }
|
||||
],
|
||||
env: ['GMAIL_OAUTH_TOKEN'],
|
||||
});
|
||||
```
|
||||
|
||||
Container runner 从通道注册表读取注册的挂载——无需编辑 `container-runner.ts`。
|
||||
|
||||
**配置模式:** Skills 不修补 `config.ts` 或 `.env.example`。Skill 特定的 env var 在 skill 的 SKILL.md 中记录——设置过程读取这些指令。每个模块直接读取自己的 env var:
|
||||
|
||||
```typescript
|
||||
// channels/discord.ts
|
||||
const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
// channels/gmail.ts
|
||||
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
|
||||
```
|
||||
|
||||
共享配置(DATA_DIR、TIMEZONE、MAX_CONCURRENT_CONTAINERS)保留在 `config.ts` 中。通道/skill 特定配置保留在使用的模块中。
|
||||
|
||||
### 代码风格
|
||||
|
||||
**行宽:120 字符。** 大多数语句能在一行内写完而不牺牲可读性。
|
||||
|
||||
**简洁日志。** 一个薄封装使每个日志调用保持在一行:
|
||||
|
||||
```typescript
|
||||
log.info('IPC 消息已发送', { chatJid, sourceGroup });
|
||||
log.warn('未授权的 IPC 尝试', { chatJid });
|
||||
log.error('处理错误', { file, err });
|
||||
```
|
||||
|
||||
### DB 文件结构
|
||||
|
||||
DB 层按实体拆分,而非保持在一个整体文件中:
|
||||
|
||||
```
|
||||
src/db/
|
||||
connection.ts ← 单例、初始化、WAL 模式
|
||||
schema.ts ← CREATE TABLE 语句(当前状态,供参考)
|
||||
migrations/
|
||||
index.ts ← 运行器:检查版本,应用待执行的迁移
|
||||
001-initial.ts ← 初始模式
|
||||
002-pending-questions.ts ← 示例:添加 pending_questions 表
|
||||
... ← skills 追加新的编号文件
|
||||
agent-groups.ts ← agent_groups 的 CRUD
|
||||
messaging-groups.ts ← messaging_groups + messaging_group_agents 的 CRUD
|
||||
sessions.ts ← sessions + pending_questions 的 CRUD
|
||||
index.ts ← barrel:重新导出所有内容
|
||||
```
|
||||
|
||||
**原则:**
|
||||
- **按实体拆分,而非按层。** 每个实体文件有自己的 CRUD 函数(约 50-100 行)。向 messaging_groups 添加列的 skill 只编辑 `messaging-groups.ts`——不触及 sessions 或 agent groups。
|
||||
- **Schema 作为当前状态 + 迁移作为历史。** `schema.ts` 记录 DB 现在的样子(阅读它以理解模式)。迁移是只追加的编号文件,描述我们如何达到当前状态。
|
||||
- **无内联 ALTER TABLE。** 带有 `schema_version` 表的迁移运行器替代了 `try { ALTER TABLE } catch { /* 已存在 */ }` 代码块。启动时,检查当前版本并按顺序应用待执行的迁移。每个迁移是一个函数:`(db: Database) => void`。
|
||||
- **Skills 添加迁移。** 需要新增列的 skill 添加一个新的编号迁移文件。只要编号不冲突(为 skill 分支使用时间戳或足够大的编号),就不会与其他 skills 的迁移冲突。
|
||||
|
||||
**Agent-runner session DB** 使用相同的模式但更轻量——无需迁移,因为 session DB 由宿主机全新创建:
|
||||
|
||||
```
|
||||
container/agent-runner/src/db/
|
||||
connection.ts ← 在固定路径打开 session.db,WAL 模式
|
||||
messages-in.ts ← 读取待处理、更新状态
|
||||
messages-out.ts ← 写入结果、outbox 查询
|
||||
index.ts ← barrel
|
||||
```
|
||||
|
||||
### 基础架构必须原生支持的功能
|
||||
|
||||
以下是构建块。它们都不需要特殊抽象——它们自然产生于逐 session DB、宿主机管理的路由和 `kind: 'system'` 的 `messages_out`:
|
||||
|
||||
1. **同一通道上基于内容路由的多个 agent group。** 同一线程中的不同消息可以基于内容路由到不同的 agent group(例如,@提及路由到监督者,普通消息路由到工作者)。Channel adapter 的路由逻辑——自定义代码——决定。
|
||||
|
||||
2. **来自共享 agent group 的每线程 session。** 多个 session 共享同一个 agent group(文件系统、skills、CLAUDE.md),但每个获得自己的 session DB。工作者池的标准用法。
|
||||
|
||||
3. **Session 重置和重放。** 为同一线程创建新 session。将旧消息标记为未处理,以便轮询重新拾取。旧输出在平台(例如 Discord 线程)中仍然可见以供比较。这是 agent 可以请求的操作——而非自动。
|
||||
|
||||
4. **跨 session 读取访问。** 某些 agent 可以查询其他 session 的数据。不同的访问级别:管理者查看 `messages_in`/`messages_out`(审查内容)。监督者查看完整内部信息(agent 日志、工具调用、调试追踪)。这只是文件系统/DB 访问——挂载或查询正确的路径。
|
||||
|
||||
5. **上下文复制到新 session。** 当监督者在工作者线程中被调用时,创建包含相关消息副本的新 session。自定义宿主机侧代码处理此问题。
|
||||
|
||||
6. **Agent 发起的宿主机操作。** Agent 使用 MCP 工具(重置 session、更新 skills 等)。Agent-runner 处理工具调用并写入结构化的 `system` 类型的 `messages_out` 行。宿主机读取并在权限检查后执行。Agent 可以请求,但宿主机决定。
|
||||
|
||||
### 示例:PR Factory
|
||||
|
||||
三个 agent group,一个 Discord 频道(PR Factory),外加一个管理员通道:
|
||||
|
||||
| 角色 | Agent Group | 所在位置 | Session 模型 |
|
||||
|------|-------------|-------|---------------|
|
||||
| **工作者** | pr-worker | PR Factory 线程 | 每个线程一个 session(每个 PR) |
|
||||
| **管理者** | pr-manager | PR Factory 频道 | 单个 session,跨工作者 session 查询 |
|
||||
| **监督者** | pr-admin | 管理员通道 + PR Factory(被 @标记时) | 管理员通道中的主 session;在工作者线程中被调用时创建每线程 session |
|
||||
|
||||
**工作者流程:** GitHub PR → Discord 线程 → 工作者 agent 审查(分类、审查、测试计划)。每个线程从共享的 pr-worker group 获得一个 session。
|
||||
|
||||
**反馈流程:** 用户在工作线程中 @标记监督者 → 自定义路由将其发送到监督者,附带包含该线程消息(已复制)的新 session。监督者将反馈收集到文件系统。工作者看不到监督者消息。
|
||||
|
||||
**迭代流程:** 用户在管理员通道中与监督者讨论反馈 → 监督者建议 skill 更改(以带 diff 的富卡片显示)→ 用户批准 → 监督者通过宿主机操作应用更改 → 监督者请求 session 重置 + 重放 → 工作者使用更新后的 skills 在相同线程但全新的 session 中重新审查相同 PR → 用户并排比较审查结果。
|
||||
|
||||
**管理者流程:** 用户在 PR Factory 主频道(而非线程内)与管理者交谈。管理者可以搜索所有工作者 session DB(`messages_in`/`messages_out`),以回答如"今天有多少 PR?"或"哪些话题在趋势中?"等问题。可以请求操作(关闭 PR、重新打开)。
|
||||
|
||||
**自定义代码 vs 基础架构:**
|
||||
|
||||
| 能力 | 基础架构 | 自定义代码(PR Factory) |
|
||||
|-----------|-------------------|-------------------------|
|
||||
| 每线程 session | ✓ platformThreadId → session | |
|
||||
| 跨 session 共享 agent group | ✓ 多个 session,一个 group | |
|
||||
| 写入消息到 session DB | ✓ 标准流程 | |
|
||||
| @提及路由到不同 agent | | ✓ Channel adapter 路由逻辑 |
|
||||
| 上下文复制到监督者 session | | ✓ 监督者调用时的宿主机侧钩子 |
|
||||
| Session 重置 + 重放 | ✓ 原语(新 session、标记未处理) | ✓ 监督者操作触发 |
|
||||
| Skill 更新 | ✓ 文件系统写入 | ✓ 监督者操作应用更改 |
|
||||
| 跨 session 查询 | ✓ DB/文件系统访问 | ✓ 管理者的工具知道在哪里查找 |
|
||||
| 富卡片输出 | ✓ messages_out 中的结构化输出 | |
|
||||
|
||||
## 中央数据库模式
|
||||
|
||||
中央数据库处理路由和实体管理。所有内容和执行状态驻留在逐 session DB 中。
|
||||
|
||||
```sql
|
||||
-- 智能体工作区:文件夹、skills、CLAUDE.md、容器配置
|
||||
CREATE TABLE agent_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
agent_provider TEXT, -- session 的默认值(null = 系统默认值)
|
||||
container_config TEXT, -- JSON: { additionalMounts, timeout }
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 平台群组/频道(WhatsApp 群组、Slack 频道、Discord 频道、邮件线程等)
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL, -- 'whatsapp'、'slack'、'discord'、'telegram'、'email'
|
||||
platform_id TEXT NOT NULL, -- 平台特定 ID(JID、频道 ID 等)
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
|
||||
-- 用户(消息平台身份,命名空间格式 "<channel_type>:<handle>")
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, -- 例如 'telegram:123456', 'discord:1470...'
|
||||
kind TEXT NOT NULL, -- 镜像 channel_type 前缀
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 角色(owner 仅全局;admin 可以是全局或限定于某个 agent_group)
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL, -- 'owner' | 'admin'
|
||||
agent_group_id TEXT REFERENCES agent_groups(id), -- NULL 表示全局
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role, agent_group_id)
|
||||
);
|
||||
-- owner 行必须使 agent_group_id = NULL(在 db/user-roles.ts 中强制)
|
||||
|
||||
-- 成员关系(显式非特权访问;admin/owner 隐含成员关系)
|
||||
CREATE TABLE agent_group_members (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
added_by TEXT,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- DM 解析缓存(避免每次重新解析冷 DM)
|
||||
CREATE TABLE user_dms (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
channel_type TEXT NOT NULL,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
resolved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_type)
|
||||
);
|
||||
|
||||
-- 哪些 agent group 处理哪些 messaging group,使用什么规则
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
|
||||
response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted'
|
||||
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
|
||||
priority INTEGER DEFAULT 0, -- 更高 = 当多个 agent 匹配时优先检查
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- Session:一个文件夹 = 一个 session = 运行时的一个容器
|
||||
-- 文件夹路径推导:sessions/{agent_group_id}/{session_id}/
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
messaging_group_id TEXT REFERENCES messaging_groups(id), -- 内部/派生 session 为 null
|
||||
thread_id TEXT, -- 平台线程 ID(共享 session 模式为 null)
|
||||
agent_provider TEXT, -- 逐 session 覆盖(null = 继承自 agent_group)
|
||||
status TEXT DEFAULT 'active', -- 'active' | 'closed'
|
||||
container_status TEXT DEFAULT 'stopped', -- 'running' | 'idle' | 'stopped'
|
||||
last_active TEXT, -- 最后消息活动时间戳
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id);
|
||||
CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id);
|
||||
|
||||
-- 挂起的交互式问题(等待用户响应的卡片)
|
||||
-- 宿主机在投递问题卡片时写入,收到响应时删除
|
||||
CREATE TABLE pending_questions (
|
||||
question_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
message_out_id TEXT NOT NULL, -- 发送卡片的 messages_out 行
|
||||
platform_id TEXT, -- 卡片投递到的位置
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 挂起问题流程
|
||||
|
||||
当宿主机投递带有 `operation: 'ask_question'` 的 `messages_out` 行时:
|
||||
1. 宿主机通过 channel adapter 投递卡片
|
||||
2. 宿主机写入 `pending_questions` 行,映射 `question_id` → `session_id`
|
||||
|
||||
当 Chat SDK `ActionEvent`(按钮点击)到达时:
|
||||
1. 桥接从事件中提取 `actionId`
|
||||
2. 宿主机通过 `question_id`(从 actionId 推导——桥接维护映射关系)查找 `pending_questions`
|
||||
3. 宿主机找到目标 session,写入包含 `questionId` + `selectedOption` 的 `messages_in` 行
|
||||
4. 宿主机删除 `pending_questions` 行
|
||||
5. Agent-runner 拾取 `messages_in` 行,匹配到挂起的工具调用,返回选择结果
|
||||
|
||||
这避免了扫描 session DB。中央数据库是路由查找——与消息路由相同的模式。
|
||||
|
||||
同样用于宿主机生成的审批卡片:当宿主机向管理员 DM 发送审批请求时,写入 `pending_questions` 行。管理员的响应被路由回发起 session。
|
||||
|
||||
### 容器生命周期状态
|
||||
|
||||
```
|
||||
stopped → running → idle → stopped
|
||||
↗
|
||||
idle → running(预热期间收到新消息)
|
||||
```
|
||||
|
||||
- **stopped**:无容器。每 60 秒巡检到期定时消息。
|
||||
- **running**:活跃处理中。每 1 秒轮询 `messages_out`。
|
||||
- **idle**:处理完毕,容器仍预热中(最多 30 分钟超时)。每 1 秒轮询以便快速拾取新消息。
|
||||
- 达到空闲超时 → 宿主机终止容器 → stopped。
|
||||
|
||||
## Agent-Runner 架构
|
||||
|
||||
Agent-runner 是容器内的进程。它在 session DB 和 Claude SDK 之间进行中介——轮询工作、为 agent 格式化消息、将工具调用转换为 DB 行,以及管理 agent 生命周期。
|
||||
|
||||
### IO 模型
|
||||
|
||||
所有 IO 通过 session DB 进行。无 stdin、无 stdout 标记、无 IPC 文件。
|
||||
|
||||
- 初始输入和后续:轮询 `messages_in`
|
||||
- 输出:写入 `messages_out` 行
|
||||
- MCP 工具:写入 DB 行(无 IPC 文件)
|
||||
- 关闭:宿主机在空闲超时时终止容器,或 agent-runner 在没有待处理工作时退出
|
||||
|
||||
### 轮询循环
|
||||
|
||||
1. 查询 `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`
|
||||
2. 如果找到行:设置每行 `status = 'processing'`、`status_changed = now()`
|
||||
3. 将消息批处理为单个提示(剥离路由字段,按 kind 格式化)
|
||||
4. 推送到 Claude SDK 的 MessageStream
|
||||
5. 处理 agent 输出 → 写入 `messages_out` 行
|
||||
6. 将已处理消息设置为 `status = 'completed'`
|
||||
7. 回到步骤 1。如果未找到消息,短暂休眠并重新轮询(容器在空闲超时前保持预热)
|
||||
|
||||
### 按 Kind 的消息格式化
|
||||
|
||||
Agent-runner 在格式化前剥离路由字段(`platform_id`、`channel_type`、`thread_id`)。Agent 永远不会看到路由信息——它只看到内容。
|
||||
|
||||
- **`chat`** — 格式化为 `<messages>` XML 块
|
||||
- **`chat-sdk`** — 从序列化消息中提取 text、author、attachments;格式化为 `<messages>` XML
|
||||
- **`task`** — 格式化为 `[SCHEDULED TASK]` 前缀 + 提示。如果存在,先运行预脚本。
|
||||
- **`webhook`** — 格式化为 `[WEBHOOK: source/event]` + JSON 负载
|
||||
- **`system`** — 宿主机操作结果(例如,"register_group 成功")。格式化为系统上下文,而非聊天消息。
|
||||
|
||||
混合批次(例如,一条聊天消息 + 一个系统结果均待处理)使用明确的分隔符合并为单个提示。
|
||||
|
||||
### MCP 工具
|
||||
|
||||
MCP 工具直接写入 session DB。
|
||||
|
||||
**核心工具:**
|
||||
|
||||
| 工具 | 功能 |
|
||||
|------|-------------|
|
||||
| `send_message` | 写入 `messages_out` 行,`kind: 'chat'` |
|
||||
| `send_file` | 将文件移动到 `outbox/{msg_id}/`,写入带文件名的 `messages_out` |
|
||||
| `schedule_task` | 写入带有 `process_after` + `recurrence` 的 `messages_in` 行(发给自身)。或带有 `deliver_after` 的 `messages_out` 用于出站提醒。 |
|
||||
| `list_tasks` | 查询 `messages_in WHERE recurrence IS NOT NULL` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | 修改 `messages_in` 行(更新状态、清除/设置 recurrence) |
|
||||
| `register_agent_group` | 写入 `messages_out`,`kind: 'system'`,`action: 'register_agent_group'` |
|
||||
|
||||
**新增工具:**
|
||||
|
||||
| 工具 | 功能 |
|
||||
|------|-------------|
|
||||
| `ask_user_question` | 写入带有问询卡片的 `messages_out`。保持工具调用打开,轮询 `messages_in` 查找匹配 `questionId` 的响应。将选择作为工具结果返回。 |
|
||||
| `edit_message` | 写入带有 `operation: 'edit'` 的 `messages_out` |
|
||||
| `add_reaction` | 写入带有 `operation: 'reaction'` 的 `messages_out` |
|
||||
| `send_to_agent` | 写入带有 `channel_type: 'agent'`、`platform_id: '{target}'` 的 `messages_out` |
|
||||
| `send_card` | 写入带有卡片结构的 `messages_out` |
|
||||
|
||||
参见 [agent-runner-details.md](agent-runner-details.md) 了解完整的 MCP 工具参数定义。
|
||||
|
||||
### 卡片
|
||||
|
||||
**Agent 发起(出站):** 基于工具。Agent 调用 `ask_user_question`(带有选项的交互式卡片)或 `send_card`(结构化卡片)。Agent-runner 将卡片结构写入 `messages_out`。宿主机/adapter 处理平台特定渲染(Slack Block Kit、Discord embeds、Telegram 内联键盘、文本回退)。
|
||||
|
||||
**宿主机发起(审批卡片):** 当操作需要审批时,宿主机生成标准化审批卡片并发送到管理员 DM。这些不是 agent 发起的——agent 不知道审批步骤。卡片格式是固定的(操作描述 + 批准/拒绝按钮)。
|
||||
|
||||
**入站(卡片响应):** 不是卡片——它是 `messages_in` 行,内容中包含 `questionId` + `selectedOption`。Agent-runner 匹配到挂起的 `ask_user_question` 工具调用,并将选择作为工具结果返回。
|
||||
|
||||
### 命令
|
||||
|
||||
以 `/` 开头的消息会与三个列表进行匹配检查:
|
||||
|
||||
**白名单命令(透传给 agent):**
|
||||
- Agent 提供商原生处理的标准斜杠命令(例如,Claude 的内置命令)
|
||||
- 原样传递,不做 `<messages>` XML 包装
|
||||
|
||||
**管理员专用命令(需要管理员发送者):**
|
||||
- `/remote-control` — 远程控制 session
|
||||
- `/clear` — 清除 session 上下文
|
||||
- `/compact` — 强制上下文压缩
|
||||
- 如果由非管理员用户发送,命令被拒绝并返回错误消息。不转发给 agent。
|
||||
|
||||
**过滤命令(完全丢弃):**
|
||||
- 在 NanoClaw 上下文中无意义或可能导致问题的命令
|
||||
- 静默丢弃——无错误,不转发
|
||||
|
||||
命令列表硬编码在 agent-runner 中。管理员验证在消息到达容器之前由宿主机侧完成:`src/command-gate.ts` 查询 `user_roles`(owner / 全局 admin / 此 agent group 的限定 admin),并据此放行消息、丢弃或路由到其他地方。容器没有管理员身份的概念——无 env var,无 DB 查询,无逐消息检查。
|
||||
|
||||
### 循环任务(Recurring Tasks)
|
||||
|
||||
Agent-runner 像处理其他 `messages_in` 行一样处理循环任务消息。在 agent-runner 将循环消息标记为 `completed` 后,**宿主机**处理插入下一次发生(新的 `messages_in` 行,`process_after` 推进到下一个 cron 时间点)。Agent-runner 不管理循环——它只处理找到的消息。
|
||||
|
||||
预脚本:如果 task 消息有 `script` 字段,先运行它。如果 `wakeAgent = false`,标记为 completed 而不调用 Claude。
|
||||
|
||||
### 智能体间消息(Agent-to-Agent Messaging)
|
||||
|
||||
**出站:** Agent 调用 `send_to_agent` 工具 → agent-runner 写入 `messages_out`,其中 `channel_type: 'agent'`,`platform_id` = 目标 agent group ID。宿主机验证权限并写入目标 session 的 `messages_in`。
|
||||
|
||||
**入站:** 来自其他 agent 的消息以普通 `chat` 类型的 `messages_in` 行到达。内容包含 `sender` 和 `senderId`(例如,`"senderId": "agent:pr-admin"`)。无特殊格式化——agent 将其视为聊天消息。
|
||||
|
||||
### Agent-Runner 属性
|
||||
|
||||
- AgentProvider 接口封装 SDK 特定查询逻辑(主干代码包含 `claude` 提供商;其他提供商如 OpenCode 通过 `/add-<provider>` 技能安装)
|
||||
- 通过提供商特定机制恢复 session
|
||||
- 从 CLAUDE.md 文件加载系统提示
|
||||
- PreCompact 钩子用于对话归档(Claude 提供商)
|
||||
- task 类型消息的脚本执行
|
||||
|
||||
## 待解决问题
|
||||
|
||||
- **审批路由**——宿主机如何找到管理员的 DM 对话?如果没有 DM 通道怎么办?审批列表是否可按 agent group 或全局配置?
|
||||
- **MCP 服务器生命周期**——MCP 服务器进程是在同一容器中的多次查询之间持续存在,还是每次都重新启动?
|
||||
- **容器启动配置**——在启动时除了 env var 之外,还会向容器传递什么配置(如果有)?Session DB 在固定的挂载路径上。系统提示来自 CLAUDE.md。提供商名称来自 env。还有什么?
|
||||
- **挂起问题时的空闲检测**——当 `ask_user_question` 等待响应时,容器不应被视为空闲。还需要检测 agent 是否仍在工作(活跃的工具调用、子 agent),并在即使最近没有写入 `messages_out` 的情况下避免终止容器。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- **[api-details.md](api-details.md)** — Channel adapter 接口(NanoClaw + Chat SDK 桥接)、消息内容示例、宿主机投递逻辑
|
||||
- **[agent-runner-details.md](agent-runner-details.md)** — AgentProvider 接口、MCP 工具、消息格式化、媒体处理、提供商实现
|
||||
80
docs/zh/build-and-runtime.md
Normal file
80
docs/zh/build-and-runtime.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 构建与运行时
|
||||
|
||||
NanoClaw 运行的是分拆式技术栈:宿主机使用 Node + pnpm,agent 容器使用 Bun。它们之间仅通过每个 session(会话)的两个 SQLite 文件来通信——它们之间没有共享模块,这也是它们能干净地使用不同运行时的原因。
|
||||
|
||||
## 为什么分拆
|
||||
|
||||
- **宿主机保持使用 Node**,因为 Baileys(WhatsApp)依赖 `libsignal-node` 原生绑定和久经考验的 WebSocket/HTTP 栈。Bun 的 Node-API 兼容性已经有所改善,但这不是我们想冒险的地方。
|
||||
- **容器使用 Bun**,因为 `bun:sqlite` 是内置的(无需每次镜像重建都编译 `better-sqlite3` 原生模块),源码直接运行(无需在镜像构建或 session 唤醒时做 tsc 构建步骤),而且 `bun install` 比 `npm install` 快大约 5-10 倍。
|
||||
|
||||
宿主机和容器各有自己的包树:
|
||||
|
||||
```
|
||||
/ pnpm + Node 22
|
||||
pnpm-lock.yaml 宿主机依赖(频道、Chat SDK、Baileys、better-sqlite3 等)
|
||||
pnpm-workspace.yaml minimumReleaseAge + onlyBuiltDependencies 策略
|
||||
|
||||
/container/agent-runner/ Bun 1.3+
|
||||
bun.lock agent-runner 运行时依赖(Claude Agent SDK、MCP SDK、zod 等)
|
||||
package.json @types/bun、typescript 开发依赖用于类型检查
|
||||
```
|
||||
|
||||
容器镜像内部也有 pnpm + Node,用于全局 CLI(`@anthropic-ai/claude-code`、`agent-browser`、`vercel`)。这些是 agent 在运行时调用的 Node 二进制文件,不是库依赖。将它们保持在 pnpm 上可以保留 CLI 版本的供应链策略。
|
||||
|
||||
## 锁文件
|
||||
|
||||
| 树 | 锁文件 | 管理器 | 依赖变更后重新生成 |
|
||||
|------|----------|---------|----------------------------|
|
||||
| 宿主机 | `pnpm-lock.yaml` | pnpm 10 | `pnpm install` |
|
||||
| Agent-runner | `container/agent-runner/bun.lock` | Bun 1.3+ | `cd container/agent-runner && bun install` |
|
||||
|
||||
两者都已提交。CI 和 Dockerfile 运行 `--frozen-lockfile` 变体——`package.json` 与锁文件之间的任何偏差都会导致构建失败。
|
||||
|
||||
## 供应链
|
||||
|
||||
- **宿主机 + 全局 CLI**(pnpm):`minimumReleaseAge: 4320`(新版本需要等待 3 天),`onlyBuiltDependencies` 白名单用于 postinstall 脚本。见 `pnpm-workspace.yaml` 和 `docs/SECURITY.md`。
|
||||
- **Agent-runner**(Bun):无发布年龄策略——Bun 目前没有等效机制。防御措施是 `bun.lock` 锁定版本加上通过 Dockerfile ARG 固定版本的 CLI/Bun 本身。当升级 `@anthropic-ai/claude-agent-sdk` 或任何运行时依赖时,请查看 npm 上的发布日期并有意识地升级,而不是通过 `bun update`。
|
||||
|
||||
## 镜像构建范围
|
||||
|
||||
`container/Dockerfile` 是基于 `node:22-slim` 的单阶段构建:
|
||||
|
||||
- **固定的 ARG**——`BUN_VERSION`、`CLAUDE_CODE_VERSION`、`AGENT_BROWSER_VERSION`、`VERCEL_VERSION`。在 PR 中有意识地升级。
|
||||
- **CJK 字体**——`ARG INSTALL_CJK_FONTS=false`。`container/build.sh` 从 `.env` 读取 `INSTALL_CJK_FONTS` 并传递。默认构建节省约 200MB;当用户处理中文/日文/韩文内容时选择加入。
|
||||
- **BuildKit 缓存挂载**——`/var/cache/apt`、`/var/lib/apt`、`/root/.bun/install/cache`、`/root/.cache/pnpm`。当 `package.json`/`bun.lock` 未变更时的重建速度很快。需要 BuildKit(Docker 23+、Apple Container 兼容下默认启用)。
|
||||
- **`tini` 作为 init**——回收 Chromium 僵尸进程,转发信号以便在 SIGTERM 时完成正在进行的 `outbound.db` 写入。
|
||||
- **`entrypoint.sh`**(已提取)——`exec bun run /app/src/index.ts` 在 tini 下运行。可读且可 diff。
|
||||
- **无编译后的 `/app/dist`**——Bun 直接运行 TS。宿主机在 session 启动时还会将最新源码挂载到 `/app/src` 上,因此宿主机的编辑无需重建镜像即可生效。
|
||||
|
||||
## Session 唤醒(两条路径)
|
||||
|
||||
1. **基础镜像 ENTRYPOINT**——用于 stdin 管道测试调用,如 `container/build.sh` 中的示例:`tini --> entrypoint.sh` 捕获 stdin 到 `/tmp/input.json`,然后 `exec bun run src/index.ts`。
|
||||
2. **宿主机生成的 session**——`src/container-runner.ts` 第 301 行附近使用 `--entrypoint bash` 加 `-c 'exec bun run /app/src/index.ts'`。绕过 tini(Docker 默认的 PID 1 处理生效)。stdin 未使用;所有 IO 通过挂载的 session DB 流动。
|
||||
|
||||
两条路径最终都由 Bun 运行同一个源码文件 `/app/src/index.ts`。
|
||||
|
||||
## CI 流程
|
||||
|
||||
`.github/workflows/ci.yml` 安装 Node(带 pnpm 缓存)和 Bun,然后按顺序运行:
|
||||
|
||||
1. `pnpm install --frozen-lockfile`(宿主机)
|
||||
2. `bun install --frozen-lockfile` 在 `container/agent-runner/` 中(容器)
|
||||
3. `pnpm run format:check`
|
||||
4. `pnpm exec tsc --noEmit`(宿主机类型检查)
|
||||
5. `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`(容器类型检查)
|
||||
6. `pnpm exec vitest run`(宿主机测试)
|
||||
7. `bun test` 在 `container/agent-runner/` 中(容器测试)
|
||||
|
||||
任何失败都会导致 PR 失败。
|
||||
|
||||
## 关键不变条件
|
||||
|
||||
- **Session DB 必须使用 `journal_mode=DELETE`。** WAL 的 `-shm` 内存映射不会跨宿主机和客户机之间的 VirtioFS 传递。参见 `container/agent-runner/src/db/connection.ts` 顶部的文档注释和 `src/session-manager.ts`。
|
||||
- **容器中的命名 SQL 参数要求在 JS 对象键中使用前缀。** `bun:sqlite` 不会像宿主机上的 `better-sqlite3` 那样自动去除 `@`/`$`/`:`。在 SQL 和键中都使用 `$name`:`.run({ $id: msg.id })`。位置 `?` 参数正常工作。
|
||||
- **Agent-runner 测试在 `bun:test` 下运行,而非 vitest。** `vitest.config.ts` 排除了 `container/agent-runner/` 树,因为 vitest 在 Node 上运行,无法加载 `bun:sqlite`。
|
||||
- **容器镜像中没有 tsc 构建步骤。** 重新添加一个会重新引入我们已移除的每个 session 唤醒约 200-500ms 的成本。
|
||||
- **全局容器 CLI 保持使用 pnpm,而非 Bun。** `agent-browser`、`@anthropic-ai/claude-code`、`vercel` 以及 agent 调用的任何未来 Node CLI 都应在 Dockerfile 的 pnpm 全局安装块下固定版本。`bun install -g` 会绕过 pnpm 供应链策略。
|
||||
|
||||
## 迁移历史
|
||||
|
||||
这一结构替换了统一的 npm-on-Node 技术栈(宿主机和容器均为 Node)。pnpm 迁移首先落地(PR #1771),使宿主机纳入供应链策略,然后容器迁移到 Bun 以消除原生模块编译和每次唤醒的 tsc 步骤。选择分拆而非完全转用 Bun,是因为 Baileys 的原生依赖是宿主机上的主要风险面——容器没有这样的依赖,因此可以在不承担风险的情况下受益于 Bun。
|
||||
347
docs/zh/db-central.md
Normal file
347
docs/zh/db-central.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# NanoClaw — 中央数据库模式(Central DB Schema)
|
||||
|
||||
`data/v2.db` 的完整参考,这是主机拥有的管理平面数据库。先阅读 [db.md](db.md) 了解三数据库概览、结构和跨挂载规则。
|
||||
|
||||
访问层:`src/db/`。权威模式参考:`src/db/schema.ts`(仅注释——实际创建通过 `src/db/migrations/` 中的迁移运行)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 表
|
||||
|
||||
### 1.1 `agent_groups`
|
||||
|
||||
代理工作区(workspace)。每个 1:1 映射到一个 `groups/<folder>/` 目录,该目录包含 `CLAUDE.md` 和技能。容器配置位于 `container_configs` 中(见下方 §1.x);在生成容器时会物化(materialize)一个 `container.json` 文件供容器运行器读取。
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
agent_provider TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **读取者:**`src/session-manager.ts`、`src/delivery.ts`、`src/router.ts`
|
||||
- **写入者:**`src/db/agent-groups.ts`
|
||||
|
||||
### 1.2 `messaging_groups`
|
||||
|
||||
每个平台聊天一行(一个 WhatsApp 群组、一个 Slack 频道、一个 1:1 私信等)。
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
```
|
||||
|
||||
- `unknown_sender_policy`:`strict`(丢弃)、`request_approval`(请求管理员)、`public`(允许)。
|
||||
- **读取者:**`src/router.ts`、`src/delivery.ts`、`src/session-manager.ts`
|
||||
- **写入者:**`src/db/messaging-groups.ts`、频道设置流程
|
||||
|
||||
### 1.3 `messaging_group_agents`
|
||||
|
||||
接线表(wiring):哪个代理组处理哪个消息组。多对多——同一频道可以路由到多个代理(参见 [isolation-model.md](isolation-model.md))。
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT,
|
||||
response_scope TEXT DEFAULT 'all',
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
- `session_mode`:`shared`(每频道一个会话)、`per-thread`(每线程一个)、`agent-shared`(每个代理组跨所有频道一个)。
|
||||
- `trigger_rules`:JSON;例如原生频道的正则表达式。
|
||||
- **副作用:**创建接线时也必须填充 `agent_destinations`——修改其中一个时不要忘记另一个(参见 §1.10)。
|
||||
|
||||
### 1.4 `users`
|
||||
|
||||
平台用户身份。ID 命名空间化:`tg:123456`、`discord:abc`、`phone:+1555...`、`email:a@x.com`。一个人可能拥有多行——尚无跨频道关联。
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **写入者/读取者:**`src/db/users.ts`;频道认证流程
|
||||
|
||||
### 1.5 `user_roles`
|
||||
|
||||
权限表。**权限是用户级别的,永远不是代理组级别的。**
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL,
|
||||
agent_group_id TEXT REFERENCES agent_groups(id),
|
||||
granted_by TEXT REFERENCES users(id),
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role, agent_group_id)
|
||||
);
|
||||
CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
|
||||
```
|
||||
|
||||
不变量(Invariants):
|
||||
- `role = 'owner'` → 必须是全局的 (`agent_group_id IS NULL`)。在 `grantRole()` 中强制执行。
|
||||
- `role = 'admin'` → 全局(NULL)或限定到一个代理组。
|
||||
- 代理组 A 的 admin 隐含了 A 的成员身份——不需要 `agent_group_members` 行。
|
||||
|
||||
访问层:`src/db/user-roles.ts`、`src/access.ts`。
|
||||
|
||||
### 1.6 `agent_group_members`
|
||||
|
||||
非特权用户的显式成员身份。Owner 和 admin 不需要此处的行——他们是隐式成员。
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_group_members (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
added_by TEXT REFERENCES users(id),
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, agent_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 1.7 `user_dms`
|
||||
|
||||
私信(DM)频道发现的缓存。让主机无需每次都调用平台的 `openConversation` API 即可发送冷私信(批准卡片、配对码等)。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_dms (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
channel_type TEXT NOT NULL,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
resolved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_type)
|
||||
);
|
||||
```
|
||||
|
||||
由 `src/user-dm.ts` 中的 `ensureUserDm()` 延迟填充。
|
||||
|
||||
### 1.8 `sessions`
|
||||
|
||||
会话注册表(session registry)。每个受 `session_mode` 约束的(代理组、消息组、线程)元组一行。仅存储生命周期元数据——不含消息。
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
messaging_group_id TEXT REFERENCES messaging_groups(id),
|
||||
thread_id TEXT,
|
||||
agent_provider TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
container_status TEXT DEFAULT 'stopped',
|
||||
last_active TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id);
|
||||
CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id);
|
||||
```
|
||||
|
||||
- **由 `resolveSession()` 解析:**`src/session-manager.ts`。
|
||||
- 创建会话还会通过 `initSessionFolder()` 配置会话文件夹和两个会话数据库——参见 [db-session.md](db-session.md)。
|
||||
|
||||
### 1.9 `pending_questions`
|
||||
|
||||
`ask_user_question` MCP 工具将一个交互式问题停放在此处,容器通过 `questionId` 将传入的 `system` 消息匹配回来。
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_questions (
|
||||
question_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
message_out_id TEXT NOT NULL,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 1.10 `agent_destinations`
|
||||
|
||||
用于出站发送的权限 ACL 和名称解析映射。代理请求 `send_message(to="dev-channel")` 必须在此有一个 `local_name = 'dev-channel'` 的行,否则发送将被拒绝为 `unknown destination`。
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_destinations (
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
local_name TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL, -- 'channel' | 'agent'
|
||||
target_id TEXT NOT NULL, -- messaging_group_id | agent_group_id
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (agent_group_id, local_name)
|
||||
);
|
||||
CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id);
|
||||
```
|
||||
|
||||
**投影不变量(负载关键)。**中央表是真实数据源,但每个运行中的容器从其自己的 `inbound.db` 读取投影(参见 [db-session.md §2.3](db-session.md#23-destinations))。任何在容器运行时修改 `agent_destinations` 的代码还必须调用 `writeDestinations()`(`src/session-manager.ts`),否则容器将因过时数据拒绝发送。已知调用点:`createMessagingGroupAgent()` 在 `src/db/messaging-groups.ts` 中,`create_agent` 系统动作在 `src/delivery.ts` 中。
|
||||
|
||||
访问层:`src/db/agent-destinations.ts`。
|
||||
|
||||
### 1.11 `pending_approvals`
|
||||
|
||||
两个工作流共享此表:
|
||||
|
||||
- **会话绑定的 MCP 批准**——`install_packages`、`add_mcp_server`。`session_id` 有值。
|
||||
- **OneCLI 凭证批准**——`session_id` 可为 NULL;`agent_group_id` + `channel_type` + `platform_id` 路由管理卡片。
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_approvals (
|
||||
approval_id TEXT PRIMARY KEY,
|
||||
session_id TEXT REFERENCES sessions(id),
|
||||
request_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
agent_group_id TEXT REFERENCES agent_groups(id),
|
||||
channel_type TEXT,
|
||||
platform_id TEXT,
|
||||
platform_message_id TEXT,
|
||||
expires_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
options_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX idx_pending_approvals_action_status ON pending_approvals(action, status);
|
||||
```
|
||||
|
||||
- `status`:`pending` | `approved` | `rejected` | `expired`。
|
||||
- `platform_message_id` 让主机在决策后原地编辑管理卡片。
|
||||
- 访问层:`src/db/sessions.ts`;清理和递送:`src/onecli-approvals.ts`。
|
||||
|
||||
### 1.12 `unregistered_senders`
|
||||
|
||||
审计追踪:每次消息被丢弃(未知发送者、严格策略),我们在此递增计数器,以便管理员可以看到谁一直在尝试联系。
|
||||
|
||||
```sql
|
||||
CREATE TABLE unregistered_senders (
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
sender_name TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
messaging_group_id TEXT,
|
||||
agent_group_id TEXT,
|
||||
message_count INTEGER NOT NULL DEFAULT 1,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (channel_type, platform_id)
|
||||
);
|
||||
CREATE INDEX idx_unregistered_senders_last_seen ON unregistered_senders(last_seen);
|
||||
```
|
||||
|
||||
写入者:`recordDroppedMessage()` 在 `src/db/dropped-messages.ts` 中。冲突时递增 `message_count` + `last_seen`。
|
||||
|
||||
### 1.13 Chat SDK 桥接表
|
||||
|
||||
支持 Chat SDK 桥接使用的 `SqliteStateAdapter` 的状态(参见 [api-details.md](api-details.md))。NanoClaw 代码很少直接接触这些表——它们由 `src/state-sqlite.ts` 拥有。
|
||||
|
||||
```sql
|
||||
CREATE TABLE chat_sdk_kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER -- unix 时间戳,可为空
|
||||
);
|
||||
|
||||
CREATE TABLE chat_sdk_subscriptions (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
subscribed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE chat_sdk_locks (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE chat_sdk_lists (
|
||||
key TEXT NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER,
|
||||
PRIMARY KEY (key, idx)
|
||||
);
|
||||
```
|
||||
|
||||
### 1.14 `schema_version`
|
||||
|
||||
迁移账本(migration ledger),由迁移运行器(§2)写入。
|
||||
|
||||
```sql
|
||||
CREATE TABLE schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 1.15 `container_configs`
|
||||
|
||||
每个代理组的容器运行时配置。`provider`、`model`、`packages`、MCP 服务器、挂载、CLI 范围等的真实数据源。在生成容器时物化到 `groups/<folder>/container.json`。
|
||||
|
||||
```sql
|
||||
CREATE TABLE container_configs (
|
||||
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
effort TEXT,
|
||||
image_tag TEXT,
|
||||
assistant_name TEXT,
|
||||
max_messages_per_prompt INTEGER,
|
||||
skills TEXT NOT NULL DEFAULT '"all"',
|
||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **读取者:**`src/container-config.ts`、`src/container-runner.ts`、`src/cli/dispatch.ts`(范围强制执行)、`src/claude-md-compose.ts`
|
||||
- **写入者:**`src/db/container-configs.ts`、`src/modules/self-mod/apply.ts`、`src/backfill-container-configs.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. 迁移系统
|
||||
|
||||
迁移(migrations)位于 `src/db/migrations/`,每个迁移一个文件。运行器:`runMigrations()` 在 `src/db/migrations/index.ts` 中。它:
|
||||
|
||||
1. 如果不存在则创建 `schema_version`。
|
||||
2. 读取 `MAX(version)`——称为 `current`。
|
||||
3. 对每个 `version > current` 的迁移,在事务内执行 `up(db)` 并追加 `schema_version` 行。
|
||||
|
||||
| # | 文件 | 引入内容 |
|
||||
|---|------|------------|
|
||||
| 001 | `001-initial.ts` | 核心表:`agent_groups`、`messaging_groups`、`messaging_group_agents`、`users`、`user_roles`、`agent_group_members`、`user_dms`、`sessions`、`pending_questions` |
|
||||
| 002 | `002-chat-sdk-state.ts` | `chat_sdk_kv`、`chat_sdk_subscriptions`、`chat_sdk_locks`、`chat_sdk_lists` |
|
||||
| 003 | `003-pending-approvals.ts` | `pending_approvals`(会话绑定 + OneCLI 字段) |
|
||||
| 004 | `004-agent-destinations.ts` | `agent_destinations` + 从现有 `messaging_group_agents` 接线回填 |
|
||||
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` 添加 `title`、`options_json`(改造在 003 和 007 之间创建的数据库) |
|
||||
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 009 | `009-drop-pending-credentials.ts` | 删除已废弃的 `pending_credentials` 表 |
|
||||
| 014 | `014-container-configs.ts` | `container_configs`——每个代理组的容器运行时配置 |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
|
||||
编号 005 和 006 有意空缺——迁移在早期开发期间重新编号。
|
||||
|
||||
会话数据库模式(`INBOUND_SCHEMA`、`OUTBOUND_SCHEMA`)**不**在此处进行版本管理。它们使用 `CREATE TABLE IF NOT EXISTS`,因此当重新打开旧版本构建的会话文件时,新列通过会话数据库的延迟迁移辅助函数(`migrateDeliveredTable()` 等)添加。参见 [db-session.md](db-session.md)。
|
||||
186
docs/zh/db-session.md
Normal file
186
docs/zh/db-session.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# NanoClaw — 每个 Session 的 DB Schema
|
||||
|
||||
每个 session(会话)拥有的两个 SQLite 文件的参考:`inbound.db`(宿主机写入,容器读取)和 `outbound.db`(容器写入,宿主机读取)。请先阅读 [db.md](db.md) 了解三库概览、单一写入者规则以及跨挂载可见性约束。
|
||||
|
||||
Schema 位于 `src/db/schema.ts` 中,作为 `INBOUND_SCHEMA` 和 `OUTBOUND_SCHEMA` 常量。当新的 session 目录被配置时,两个文件都由 `src/session-manager.ts` 中的 `ensureSchema()` 创建。
|
||||
|
||||
---
|
||||
|
||||
## 1. Session 目录布局
|
||||
|
||||
```
|
||||
data/v2-sessions/<agent_group_id>/<session_id>/
|
||||
inbound.db ← 宿主机写入,容器读取(只读挂载)
|
||||
outbound.db ← 容器写入,宿主机读取(只读打开)
|
||||
.heartbeat ← 容器触碰的 mtime(非 DB 写入)
|
||||
inbox/<message_id>/ ← 用户附件,从入站消息内容解码
|
||||
outbox/<message_id>/ ← agent 生成的附件
|
||||
```
|
||||
|
||||
一个 session = 一个目录 = 一对 DB。`agent_group_id` 父目录还存放每个 agent group 共享的状态(`.claude-shared/`、`agent-runner-src/`),这些状态在该 agent group 的所有 session 间共享。
|
||||
|
||||
`src/session-manager.ts` 中的路径辅助函数:`sessionDir()`、`inboundDbPath()`、`outboundDbPath()`、`heartbeatPath()`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 入站库 (`inbound.db`)
|
||||
|
||||
宿主机拥有,容器只读。Schema 常量:`src/db/schema.ts` 中的 `INBOUND_SCHEMA`。
|
||||
|
||||
### 2.1 `messages_in`
|
||||
|
||||
抵达 session 的每条消息:用户聊天、计划任务、重复任务、问题回复、内部系统消息。
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- 仅偶数(宿主机分配)——见 §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- 重复任务的 cron 表达式
|
||||
series_id TEXT, -- 将重复任务的发生次数分组
|
||||
tries INTEGER DEFAULT 0,
|
||||
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = 仅上下文(不唤醒),1 = 唤醒 agent
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL, -- JSON;格式取决于 kind
|
||||
source_session_id TEXT, -- agent 到 agent 的返回路径
|
||||
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = 仅在容器的首次轮询时投递
|
||||
);
|
||||
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
```
|
||||
|
||||
内容格式:见 [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details)。
|
||||
|
||||
**写入者(宿主机):** `insertMessage()`、`insertTask()`、`insertRecurrence()`——均在 `src/db/session-db.ts` 中。每个都调用 `nextEvenSeq()`。
|
||||
**读取者(容器):** `container/agent-runner/src/db/messages-in.ts`——轮询 `status='pending' AND (process_after IS NULL OR process_after <= now)`。
|
||||
|
||||
### 2.2 `delivered`
|
||||
|
||||
宿主机在将 `messages_out` 行交给频道适配器后写入此处。容器读取 `platform_message_id` 以定位编辑和反应。
|
||||
|
||||
```sql
|
||||
CREATE TABLE delivered (
|
||||
message_out_id TEXT PRIMARY KEY,
|
||||
platform_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'delivered', -- delivered|failed
|
||||
delivered_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
写入者:`src/db/session-db.ts` 中的 `markDelivered()` / `markDeliveryFailed()`。较旧的 session DB 由 `migrateDeliveredTable()` 惰性地升级 schema。
|
||||
|
||||
### 2.3 `destinations`
|
||||
|
||||
本 session 的 agent 对应的中央 `agent_destinations` 表(见 [db-central.md §1.10](db-central.md#110-agent_destinations))的投影。容器根据此表解析 `to="name"`;如果行不存在,则拒绝发送并报 `unknown destination`。
|
||||
|
||||
```sql
|
||||
CREATE TABLE destinations (
|
||||
name TEXT PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
type TEXT NOT NULL, -- 'channel' | 'agent'
|
||||
channel_type TEXT, -- 用于 type='channel'
|
||||
platform_id TEXT, -- 用于 type='channel'
|
||||
agent_group_id TEXT -- 用于 type='agent'
|
||||
);
|
||||
```
|
||||
|
||||
在每次容器唤醒时以及连接配置在 session 中途变更时,由 `writeDestinations()` 整体重写(事务中的 DELETE + INSERT)。`src/db/schema.ts` 中该表的注释是刷新语义的规范陈述。
|
||||
|
||||
### 2.4 `session_routing`
|
||||
|
||||
单行(`id=1`)默认路由:当 agent 未指定目的地时,出站消息的走向。
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_routing (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
channel_type TEXT,
|
||||
platform_id TEXT,
|
||||
thread_id TEXT
|
||||
);
|
||||
```
|
||||
|
||||
由 `writeSessionRouting()` 在每次容器唤醒时写入,数据来源于 `sessions.messaging_group_id` + `sessions.thread_id`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 序列号奇偶规则
|
||||
|
||||
每条消息(入站或出站)获得一个单调递增的整数 `seq`,在 session 内跨两张表唯一。
|
||||
|
||||
- **宿主机写入偶数 seq**(2、4、6、…)到 `messages_in`——`src/db/session-db.ts:75` 中的 `nextEvenSeq()`。
|
||||
- **容器写入奇数 seq**(1、3、5、…)到 `messages_out`——逻辑在 `container/agent-runner/src/db/messages-out.ts:54`(`max % 2 === 0 ? max + 1 : max + 2`),跨*两张*表读取 `MAX(seq)` 以保持全局顺序。
|
||||
|
||||
为什么不相交?`seq` 是 agent 视角的消息 ID。当 agent 调用 `edit_message(seq=5)` 或 `add_reaction(seq=6)` 时,`getMessageIdBySeq()` 利用奇偶来路由查找:奇数 → `messages_out`,偶数 → `messages_in`。单凭奇偶就能消除歧义,无需连接。冲突会破坏编辑功能。
|
||||
|
||||
如果你添加了一条写入任一张表的代码路径,请保持奇偶规则——该规则不是通过约束强制执行的,只有两个辅助函数在维护。
|
||||
|
||||
---
|
||||
|
||||
## 4. 出站库 (`outbound.db`)
|
||||
|
||||
容器拥有,宿主机只读。Schema 常量:`src/db/schema.ts` 中的 `OUTBOUND_SCHEMA`。
|
||||
|
||||
### 4.1 `messages_out`
|
||||
|
||||
agent 生成的所有内容:聊天回复、编辑、反应、卡片、问题发送、agent 到 agent 消息、系统动作。
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages_out (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- 仅奇数(容器分配)——见 §3
|
||||
in_reply_to TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
deliver_after TEXT,
|
||||
recurrence TEXT,
|
||||
kind TEXT NOT NULL, -- chat|chat-sdk|system|…
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL -- JSON;操作类型包含在其中(edit/reaction/card/…)
|
||||
);
|
||||
```
|
||||
|
||||
内容格式:见 [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details)。
|
||||
|
||||
**写入者(容器):** `container/agent-runner/src/db/messages-out.ts` 中的 `writeMessageOut()`。
|
||||
**读取者(宿主机):** `src/delivery.ts`(轮询投递),`getMessageIdBySeq()` / `getRoutingBySeq()` 用于编辑/反应定位。
|
||||
|
||||
### 4.2 `processing_ack`
|
||||
|
||||
容器侧对它所接触的每个 `messages_in.id` 的状态记录。宿主机轮询此表并将状态同步回 `messages_in`——这避免了容器向 `inbound.db` 写入。
|
||||
|
||||
```sql
|
||||
CREATE TABLE processing_ack (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL, -- processing|completed|failed
|
||||
status_changed TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
崩溃恢复:容器启动时,陈旧的 `processing` 条目被清除。宿主机侧同步:`src/host-sweep.ts` 中的 `syncProcessingAcks()`。
|
||||
|
||||
### 4.3 `session_state`
|
||||
|
||||
持久化的容器拥有的 KV 存储。主要消费者是 Chat SDK session ID——将其存储在这里可以让 agent 的对话在容器重启后恢复。可通过 `/clear` 清除。
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
访问方式:`container/agent-runner/src/db/session-state.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 5. Schema 演化
|
||||
|
||||
与中央库不同,session DB **不**经过编号迁移。`INBOUND_SCHEMA` 和 `OUTBOUND_SCHEMA` 都使用 `CREATE TABLE IF NOT EXISTS`,因此新的 session 总是获得最新的 schema。对于在较旧版本下创建的 session 目录,列级别的差异在打开时惰性修补——例如 `src/db/session-db.ts` 中的 `migrateDeliveredTable()` 如果缺少 `platform_message_id` 和 `status` 列,则会将其添加到 `delivered` 表。
|
||||
|
||||
如果你向任一 schema 添加了一个列,请为现有的 session 目录添加匹配的惰性迁移,并优先使用可空的列或带默认值的列,这样就不需要回填数据。
|
||||
119
docs/zh/db.md
Normal file
119
docs/zh/db.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# NanoClaw 数据库架构 — 概述
|
||||
|
||||
数据模型的概览:三个数据库(database),它们如何协同工作,以及跨数据库保持不变的那些约束。表级 schema 请参阅下面的链接。
|
||||
|
||||
- **[db-central.md](db-central.md)** — `data/v2.db` 中的每一张表(身份标识、连接关系、审批、Chat SDK 状态)以及迁移系统。
|
||||
- **[db-session.md](db-session.md)** — 每个 session(会话)的 `inbound.db` + `outbound.db` 对、seq 奇偶规则以及 session 目录布局。
|
||||
|
||||
相关文档:[architecture.md](architecture.md)(高层设计);[api-details.md](api-details.md)(出入站消息内容格式);[isolation-model.md](isolation-model.md)(频道到 agent 的连接模式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 三个数据库
|
||||
|
||||
NanoClaw 使用**三种 SQLite 数据库**,全部位于宿主机文件系统上:
|
||||
|
||||
| DB | 位置 | 写入者 | 读取者 | 用途 |
|
||||
|----|----------|--------|---------|---------|
|
||||
| **中央库(Central)** | `data/v2.db` | 宿主机 | 宿主机 | 身份、权限、路由、连接——管理平面 |
|
||||
| **Session 入站库** | `data/v2-sessions/<agent_group_id>/<session_id>/inbound.db` | 宿主机 | 宿主机(同步)、容器(只读) | 宿主机 → 容器消息 + 路由投影 |
|
||||
| **Session 出站库** | `data/v2-sessions/<agent_group_id>/<session_id>/outbound.db` | 容器 | 宿主机(轮询)、容器 | 容器 → 宿主机消息 + 处理状态 |
|
||||
|
||||
**单一写入者规则。** 每个 SQLite 文件有且仅有一个写入者。宿主机写入中央库和每个 `inbound.db`;容器只写入自己的 `outbound.db`。这消除了跨越 Docker/Apple Container 挂载边界的写入竞争——SQLite 锁在跨挂载场景下不可靠。
|
||||
|
||||
**一切皆为消息。** 宿主机和容器之间没有 IPC、stdin 管道或文件监视器。两个 session DB 是唯一的 IO 接口。心跳(heartbeat)是对 `.heartbeat` 的文件 `touch(2)`,而非数据库写入。
|
||||
|
||||
**日志模式。** Session DB 使用 `journal_mode = DELETE`(而非 WAL)。跨挂载的 WAL 可见性是个 bug 温床;DELETE 模式加上 open-write-close 会强制刷新页面缓存,使对端能看到变更。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库映射
|
||||
|
||||
```
|
||||
data/
|
||||
v2.db ← 中央库(宿主机 ↔ 宿主机)
|
||||
v2-sessions/
|
||||
<agent_group_id>/
|
||||
.claude-shared/ ← agent group 共享的 Claude 状态
|
||||
agent-runner-src/ ← 每个 agent group 的 agent-runner 覆盖层
|
||||
<session_id>/
|
||||
inbound.db ← 宿主机写入,容器读取
|
||||
outbound.db ← 容器写入,宿主机读取
|
||||
.heartbeat ← 容器触碰的 mtime
|
||||
inbox/<message_id>/ ← 已解码的用户附件
|
||||
outbox/<message_id>/ ← agent 生成的附件
|
||||
```
|
||||
|
||||
路径辅助函数:`sessionDir()`、`inboundDbPath()`、`outboundDbPath()`、`heartbeatPath()`——均在 `src/session-manager.ts` 中。
|
||||
|
||||
---
|
||||
|
||||
## 3. 中央库 vs. session:数据存放规则
|
||||
|
||||
| 数据类型 | 存放位置 | 原因 |
|
||||
|--------------|-------|-----|
|
||||
| 身份、角色、成员关系 | 中央库 | 稳定、跨 session、极少写入 |
|
||||
| 频道连接、路由规则 | 中央库 | 管理平面 |
|
||||
| 目的地 ACL | 中央库(+ 每个 session 的投影) | 中央为真源;session 本地快速查找 |
|
||||
| Session 注册表(id、状态) | 中央库 | 宿主机编排生命周期 |
|
||||
| 审批与待处理问题 | 中央库 | 能经受容器重启、管理员可见 |
|
||||
| 丢弃消息审计 | 中央库 | 全局运维视图 |
|
||||
| 入站消息、重试状态 | session `inbound.db` | 每个 session 的工作负载;宿主机为唯一写入者 |
|
||||
| 出站消息、agent 状态 | session `outbound.db` | 容器为唯一写入者;宿主机轮询 |
|
||||
| 投递结果 | session `inbound.db`(`delivered`) | 宿主机在成功时写入;容器读取以定位编辑目标 |
|
||||
| 处理状态 | session `outbound.db`(`processing_ack`) | 容器不能写入 `inbound.db` |
|
||||
|
||||
启发式原则:如果一个值是消息、路由投影或运行时确认,则放入 per-session 的 DB。其他一切都在中央库。
|
||||
|
||||
---
|
||||
|
||||
## 4. 跨挂载可见性
|
||||
|
||||
Session DB 被 bind mount 到容器中。在修改 DB 代码之前,需要了解几条规则:
|
||||
|
||||
- **`journal_mode = DELETE`,而非 WAL。** WAL 文件不能可靠地跨越挂载边界,容器可能读到过时的页面。DELETE 模式强制每个写入者刷新主文件。
|
||||
- **宿主机侧 open-write-close。** 宿主机对 `inbound.db` 的写入是打开连接、写入、然后关闭。保持句柄打开会使缓存的页面对容器不可见。
|
||||
- **容器读取为只读。** 容器以 `readonly: true` 打开 `inbound.db`,且从不写入——所有容器→宿主机的状态通过 `outbound.db` 传递(见 [db-session.md](db-session.md#52-processing_ack))。
|
||||
- **心跳是文件 touch。** `.heartbeat` 的 mtime 是存活信号,而非 DB 列。每次心跳做 DB 写入会串行化在其他写入者后面。
|
||||
|
||||
这些规则在 `src/session-manager.ts` 和 `container/agent-runner/src/db/` 中通过约定强制执行。如果你要修改 DB 的打开方式,请先重新阅读这些代码。
|
||||
|
||||
---
|
||||
|
||||
## 5. 设计模式一览
|
||||
|
||||
1. **两库 session 拆分。** `inbound.db` 和 `outbound.db` 各有一个写入者,一个数据流向——无跨挂载锁竞争。
|
||||
2. **Seq 奇偶规则。** 偶数 = 宿主机,奇数 = 容器。两张表之间的不相交命名空间让 agent 可以仅凭 `seq` 引用任何消息。详情见 [db-session.md §3](db-session.md#3-sequence-numbering-invariant)。
|
||||
3. **投影模式。** `agent_destinations` 和 `session_routing` 在容器唤醒时从中央库投影到每个 session 的 `inbound.db` 中——容器获得快速、本地的读取路径,无需跨挂载查询。
|
||||
4. **通过反向通道确认。** 容器从不写入 `inbound.db`。状态同步通过 `outbound.db` 中的 `processing_ack` 实现,由宿主机轮询并协调。
|
||||
5. **带外心跳。** 对 `.heartbeat` 的文件 `touch`,而非 DB 写入,因此存活检查不串行在其他写入者后面。
|
||||
6. **惰性 session-DB 迁移。** 中央库使用编号迁移;per-session 的 DB 使用 `IF NOT EXISTS` + 针对旧 session 目录的临时 `ALTER TABLE` 辅助函数。
|
||||
7. **ACL = 行的存在。** `agent_destinations` 的成员关系本身就是权限——没有单独的 `permissions` 表。
|
||||
|
||||
---
|
||||
|
||||
## 6. 读取者与写入者 — 一览
|
||||
|
||||
| 表 | DB | 写入者 | 读取者 |
|
||||
|-------|----|-----------|-----------|
|
||||
| `agent_groups` | central | `src/db/agent-groups.ts` | session 解析器、投递、路由 |
|
||||
| `messaging_groups` | central | `src/db/messaging-groups.ts`、频道设置 | 路由、投递、session 解析器 |
|
||||
| `messaging_group_agents` | central | `src/db/messaging-groups.ts` | 路由 |
|
||||
| `users` | central | `src/db/users.ts`、认证流程 | 权限检查 |
|
||||
| `user_roles` | central | `src/db/user-roles.ts` | `src/access.ts`、所有权限关卡 |
|
||||
| `agent_group_members` | central | `src/db/agent-group-members.ts` | 成员资格检查 |
|
||||
| `user_dms` | central | `src/user-dm.ts`(`ensureUserDm`) | 审批 + 配对投递 |
|
||||
| `sessions` | central | `src/db/sessions.ts`、`src/session-manager.ts` | 投递、sweep、容器运行器 |
|
||||
| `pending_questions` | central | `src/db/sessions.ts`(通过 `ask_user_question`) | 容器响应匹配器 |
|
||||
| `agent_destinations` | central | `src/db/agent-destinations.ts`、迁移 004 回填 | `writeDestinations()`、投递 ACL |
|
||||
| `pending_approvals` | central | `src/db/sessions.ts`、`src/onecli-approvals.ts` | 管理员卡片投递、sweep |
|
||||
| `unregistered_senders` | central | `src/db/dropped-messages.ts` | 运维工具 |
|
||||
| `chat_sdk_*` | central | `src/state-sqlite.ts` | Chat SDK 桥接 |
|
||||
| `schema_version` | central | `src/db/migrations/index.ts` | 迁移运行器 |
|
||||
| `messages_in` | inbound | `src/db/session-db.ts` | `container/agent-runner/src/db/messages-in.ts` |
|
||||
| `delivered` | inbound | `src/db/session-db.ts`(`markDelivered`) | 容器编辑/反应定位 |
|
||||
| `destinations` | inbound | `writeDestinations()` 在 `src/session-manager.ts` | 容器路由 / ACL |
|
||||
| `session_routing` | inbound | `writeSessionRouting()` 在 `src/session-manager.ts` | 容器 `send_message` 默认值 |
|
||||
| `messages_out` | outbound | `container/agent-runner/src/db/messages-out.ts` | `src/delivery.ts` 轮询循环 |
|
||||
| `processing_ack` | outbound | `container/agent-runner/src/db/messages-in.ts` | `src/host-sweep.ts`(`syncProcessingAcks`) |
|
||||
| `session_state` | outbound | `container/agent-runner/src/db/session-state.ts` | 容器启动时 |
|
||||
359
docs/zh/docker-sandboxes.md
Normal file
359
docs/zh/docker-sandboxes.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# 在 Docker 沙盒中运行 NanoClaw(手动设置)
|
||||
|
||||
本指南介绍如何从头开始在 [Docker 沙盒](https://docs.docker.com/ai/sandboxes/)中设置 NanoClaw——无需安装脚本,无需预构建的 fork。你将克隆上游仓库,应用必要的补丁,并在完全的 hypervisor 级别隔离下运行 agent(智能体)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
宿主机 (macOS / Windows WSL)
|
||||
└── Docker 沙盒 (带隔离内核的微 VM)
|
||||
├── NanoClaw 进程 (Node.js)
|
||||
│ ├── 频道适配器 (WhatsApp, Telegram 等)
|
||||
│ └── 容器启动器 → 嵌套 Docker 守护进程
|
||||
└── Docker-in-Docker
|
||||
└── nanoclaw-agent 容器
|
||||
└── Claude Agent SDK
|
||||
```
|
||||
|
||||
每个 agent 在自己的容器中运行,位于一个与你的宿主机完全隔离的微 VM 内。两层隔离:每个 agent 的容器 + VM 边界。
|
||||
|
||||
沙盒在 `host.docker.internal:3128` 提供一个 MITM 代理,处理网络访问并自动注入你的 Anthropic API 密钥。
|
||||
|
||||
> **注意:** 本指南基于在 macOS(Apple Silicon)上使用 WhatsApp 验证过的设置。其他频道(Telegram、Slack 等)和环境(Windows WSL)可能需要针对其特定 HTTP/WebSocket 客户端添加额外的代理补丁。核心补丁(容器运行器、凭据代理、Dockerfile)普遍适用——频道特定的代理配置各有不同。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- **Docker Desktop v4.40+** 支持沙盒
|
||||
- **Anthropic API 密钥**(沙盒代理管理注入)
|
||||
- 对于 **Telegram**:来自 [@BotFather](https://t.me/BotFather) 的 bot token 和你的 chat ID
|
||||
- 对于 **WhatsApp**:一部安装了 WhatsApp 的手机
|
||||
|
||||
验证沙盒支持:
|
||||
```bash
|
||||
docker sandbox version
|
||||
```
|
||||
|
||||
## 步骤 1:创建沙盒
|
||||
|
||||
在你的宿主机上:
|
||||
|
||||
```bash
|
||||
# 创建工作区目录
|
||||
mkdir -p ~/nanoclaw-workspace
|
||||
|
||||
# 创建带工作区挂载的 shell 沙盒
|
||||
docker sandbox create shell ~/nanoclaw-workspace
|
||||
```
|
||||
|
||||
如果你使用 WhatsApp,配置代理绕过,使 WhatsApp 的 Noise 协议不被 MITM 检查:
|
||||
|
||||
```bash
|
||||
docker sandbox network proxy shell-nanoclaw-workspace \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
Telegram 不需要代理绕过。
|
||||
|
||||
进入沙盒:
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
```
|
||||
|
||||
## 步骤 2:安装前提依赖
|
||||
|
||||
在沙盒内:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
## 步骤 3:克隆并安装 NanoClaw
|
||||
|
||||
NanoClaw 必须位于工作区目录内——Docker-in-Docker 只能从共享的工作区路径进行 bind-mount。
|
||||
|
||||
```bash
|
||||
# 先克隆到家目录(virtiofs 可能在克隆期间损坏 git pack 文件)
|
||||
cd ~
|
||||
git clone https://github.com/nanocoai/nanoclaw.git
|
||||
|
||||
# 替换为你的工作区路径(你传递给 `docker sandbox create` 的宿主机路径)
|
||||
WORKSPACE=/Users/you/nanoclaw-workspace
|
||||
|
||||
# 移入工作区,以便 DinD 挂载生效
|
||||
mv nanoclaw "$WORKSPACE/nanoclaw"
|
||||
cd "$WORKSPACE/nanoclaw"
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
pnpm install https-proxy-agent
|
||||
```
|
||||
|
||||
## 步骤 4:应用代理和沙盒补丁
|
||||
|
||||
NanoClaw 需要多个补丁才能在 Docker 沙盒内工作。这些补丁处理代理路由、CA 证书和 Docker-in-Docker 挂载限制。
|
||||
|
||||
### 4a. Dockerfile——用于容器镜像构建的代理参数
|
||||
|
||||
`sandbox 内的 docker build` 会因为沙盒的 MITM 代理提供自己的证书而出现 `SELF_SIGNED_CERT_IN_CHAIN` 失败。向 `container/Dockerfile` 添加代理构建参数:
|
||||
|
||||
在 `FROM` 行之后添加这些行:
|
||||
|
||||
```dockerfile
|
||||
# 接受代理构建参数
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
ARG no_proxy
|
||||
ARG NODE_EXTRA_CA_CERTS
|
||||
ARG npm_config_strict_ssl=true
|
||||
RUN npm config set strict-ssl ${npm_config_strict_ssl}
|
||||
```
|
||||
|
||||
并在 `RUN pnpm install` 行之后:
|
||||
|
||||
```dockerfile
|
||||
RUN npm config set strict-ssl true
|
||||
```
|
||||
|
||||
### 4b. 构建脚本——转发代理参数
|
||||
|
||||
补丁 `container/build.sh`,将代理环境变量传递给 `docker build`:
|
||||
|
||||
在 `docker build` 命令中添加这些 `--build-arg` 标志:
|
||||
|
||||
```bash
|
||||
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
|
||||
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
|
||||
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
|
||||
--build-arg npm_config_strict_ssl=false \
|
||||
```
|
||||
|
||||
### 4c. 容器运行器——代理转发、CA 证书挂载、/dev/null 修复
|
||||
|
||||
对 `src/container-runner.ts` 的三处更改:
|
||||
|
||||
**替换 `/dev/null` 影子挂载。** 沙盒拒绝 `/dev/null` bind mount。找到 `.env` 被影子挂载到 `/dev/null` 的位置,将其替换为一个空文件:
|
||||
|
||||
```typescript
|
||||
// 创建一个空文件来影子 .env(Docker 沙盒拒绝 /dev/null 挂载)
|
||||
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
|
||||
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
|
||||
// 在挂载中使用 emptyEnvPath 代替 '/dev/null'
|
||||
```
|
||||
|
||||
**将代理环境变量转发**到生成的 agent 容器。为 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及其小写变体添加 `-e` 标志。
|
||||
|
||||
**挂载 CA 证书。** 如果设置了 `NODE_EXTRA_CA_CERTS` 或 `SSL_CERT_FILE`,将证书复制到项目目录并挂载到 agent 容器中:
|
||||
|
||||
```typescript
|
||||
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
|
||||
if (caCertSrc) {
|
||||
const certDir = path.join(DATA_DIR, 'ca-cert');
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
|
||||
// 挂载:certDir -> /workspace/ca-cert(只读)
|
||||
// 在容器中设置 NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt
|
||||
}
|
||||
```
|
||||
|
||||
### 4d. 容器运行时——防止自我终止
|
||||
|
||||
在 `src/container-runtime.ts` 中,`cleanupOrphans()` 函数通过 `nanoclaw-` 前缀匹配容器。在沙盒内,沙盒容器本身可能匹配(例如 `nanoclaw-docker-sandbox`)。过滤掉当前主机名:
|
||||
|
||||
```typescript
|
||||
// 在 cleanupOrphans() 中,从要停止的容器列表中过滤掉 os.hostname()
|
||||
```
|
||||
|
||||
### 4e. 凭据代理——通过 MITM 代理路由
|
||||
|
||||
在 `src/credential-proxy.ts` 中,上游 API 请求需要通过沙盒代理。向出站请求添加 `HttpsProxyAgent`:
|
||||
|
||||
```typescript
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
// 将 upstreamAgent 传递给 https.request() 选项
|
||||
```
|
||||
|
||||
### 4f. 安装脚本——代理构建参数
|
||||
|
||||
补丁 `setup/container.ts`,传递与 `build.sh`(步骤 4b)相同的代理 `--build-arg` 标志。
|
||||
|
||||
## 步骤 5:构建
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
bash container/build.sh
|
||||
```
|
||||
|
||||
## 步骤 6:添加频道
|
||||
|
||||
### Telegram
|
||||
|
||||
```bash
|
||||
# 应用 Telegram 技能
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
||||
|
||||
# 应用技能后重建
|
||||
pnpm run build
|
||||
|
||||
# 配置 .env
|
||||
cat > .env << EOF
|
||||
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# 注册你的聊天
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "tg:<your-chat-id>" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "telegram_main" \
|
||||
--channel telegram \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**查找你的 chat ID:** 向你的 bot 发送任意消息,然后:
|
||||
```bash
|
||||
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**群组中的 Telegram:** 在 @BotFather 中禁用群组隐私(`/mybots` > Bot Settings > Group Privacy > Turn off),然后移除并重新添加 bot。
|
||||
|
||||
**重要提示:** 如果 Telegram 技能创建了 `src/channels/telegram.ts`,你需要为代理支持打补丁。添加一个 `HttpsProxyAgent` 并通过 `baseFetchConfig.agent` 将其传递给 grammy 的 `Bot` 构造函数。然后重建。
|
||||
|
||||
### WhatsApp
|
||||
|
||||
确保你已先在[步骤 1](#步骤-1创建沙盒)中配置了代理绕过。
|
||||
|
||||
```bash
|
||||
# 应用 WhatsApp 技能
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
|
||||
|
||||
# 重建
|
||||
pnpm run build
|
||||
|
||||
# 配置 .env
|
||||
cat > .env << EOF
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# 认证(选择一种):
|
||||
|
||||
# QR 码——用 WhatsApp 相机扫描:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
|
||||
# 或配对码——在 WhatsApp > 已关联设备 > 通过电话号码关联中输入代码:
|
||||
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
|
||||
|
||||
# 注册你的聊天(JID = 你的电话号码 + @s.whatsapp.net)
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "<phone>@s.whatsapp.net" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "whatsapp_main" \
|
||||
--channel whatsapp \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**重要提示:** WhatsApp 技能文件(`src/channels/whatsapp.ts` 和 `src/whatsapp-auth.ts`)也需要代理补丁——为 WebSocket 连接添加 `HttpsProxyAgent` 以及一个支持代理的版本获取。然后重建。
|
||||
|
||||
### 两个频道
|
||||
|
||||
应用两个技能,为两者打代理补丁,合并 `.env` 变量,并分别注册每个聊天。
|
||||
|
||||
## 步骤 7:运行
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
你不需要手动设置 `ANTHROPIC_API_KEY`。沙盒代理拦截请求,并自动将 `proxy-managed` 替换为你的真实密钥。
|
||||
|
||||
## 网络细节
|
||||
|
||||
### 代理如何工作
|
||||
|
||||
沙盒的所有流量通过宿主机代理路由,地址为 `host.docker.internal:3128`:
|
||||
|
||||
```
|
||||
Agent 容器 → DinD 网桥 → 沙盒 VM → host.docker.internal:3128 → 宿主机代理 → api.anthropic.com
|
||||
```
|
||||
|
||||
**"绕过"并不意味着流量跳过代理。** 它意味着代理在不做 MITM 检查的情况下传递流量。Node.js 不会自动使用 `HTTP_PROXY` 环境变量——你需要在每个 HTTP/WebSocket 客户端中显式配置 `HttpsProxyAgent`。
|
||||
|
||||
### DinD 挂载的共享路径
|
||||
|
||||
只有工作区目录可用于 Docker-in-Docker bind mount。工作区之外的路径会失败并显示"path not shared":
|
||||
- `/dev/null` → 改为项目目录中的空文件
|
||||
- `/usr/local/share/ca-certificates/` → 将证书复制到项目目录
|
||||
- `/home/agent/` → 改为克隆到工作区
|
||||
|
||||
### Git clone 和 virtiofs
|
||||
|
||||
工作区通过 virtiofs 挂载。Git 的 pack 文件处理在 virtiofs 上可能在 clone 期间损坏。解决方法:先 clone 到 `/home/agent`,然后 `mv` 到工作区。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### pnpm install 失败并出现 SELF_SIGNED_CERT_IN_CHAIN
|
||||
```bash
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
### 容器构建失败并出现代理错误
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg http_proxy=$http_proxy \
|
||||
--build-arg https_proxy=$https_proxy \
|
||||
-t nanoclaw-agent:latest container/
|
||||
```
|
||||
|
||||
### Agent 容器失败并出现 "path not shared"
|
||||
所有 bind-mounted 路径必须在工作区目录下。检查:
|
||||
- NanoClaw 是否克隆到了工作区?(不是 `/home/agent/`)
|
||||
- CA 证书是否复制到了项目根目录?
|
||||
- 空的 `.env` 影子文件是否已创建?
|
||||
|
||||
### Agent 容器无法访问 Anthropic API
|
||||
验证代理环境变量是否转发到了 agent 容器。检查容器日志中的 `HTTP_PROXY=http://host.docker.internal:3128`。
|
||||
|
||||
### WhatsApp 错误 405
|
||||
版本获取返回了过时的版本。确保已应用支持代理的 `fetchWaVersionViaProxy` 补丁——它通过 `HttpsProxyAgent` 获取 `sw.js` 并解析 `client_revision`。
|
||||
|
||||
### WhatsApp 立即显示 "Connection failed"
|
||||
代理绕过未配置。从**宿主机**运行:
|
||||
```bash
|
||||
docker sandbox network proxy <sandbox-name> \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
### Telegram bot 不接收消息
|
||||
1. 检查 grammy 代理补丁已应用(在 `src/channels/telegram.ts` 中查找 `HttpsProxyAgent`)
|
||||
2. 如果在群组中使用,检查 @BotFather 中群组隐私已禁用
|
||||
|
||||
### Git clone 失败并出现 "inflate: data stream error"
|
||||
先 clone 到非工作区路径,然后移动:
|
||||
```bash
|
||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
```
|
||||
|
||||
### WhatsApp QR 码不显示
|
||||
在沙盒内以交互方式运行认证命令(不通过 `docker sandbox exec` 管道):
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
# 然后在沙盒内:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
```
|
||||
88
docs/zh/isolation-model.md
Normal file
88
docs/zh/isolation-model.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 频道隔离模型
|
||||
|
||||
NanoClaw 将消息频道与 agent group(智能体组)解耦。当你连接一个频道(Discord、Telegram、Slack、GitHub 等)时,需要决定它与你现有 agent 的关系。共有三个隔离级别。
|
||||
|
||||
## 三个级别
|
||||
|
||||
### 1. 共享 Session
|
||||
|
||||
多个频道输入到同一个对话中。agent 在一个线程中看到来自所有频道的所有消息。
|
||||
|
||||
**共享的内容:** 所有——工作区、记忆、CLAUDE.md,以及对话本身。一条 GitHub PR 评论和一条 Slack 消息在 agent 的上下文中并排出现。
|
||||
|
||||
**示例:** 一个 Slack 频道与 GitHub webhooks 配对。agent 通过 GitHub 接收 PR 审查请求,并在 Slack 中讨论它们——全部在一个 session(会话)中。当有人评论 PR 时,agent 可以引用之前 Slack 中关于该功能的讨论。
|
||||
|
||||
**何时使用:** 当一个频道向另一个频道提供上下文时。Webhook/通知频道(GitHub、Linear)与聊天频道(Slack、Discord)配对是典型场景。
|
||||
|
||||
**技术实现:** 两个 messaging group(消息组)以 `session_mode: 'agent-shared'` 连接到同一个 agent group。Session 解析仅按 agent group ID 查找,忽略 messaging group——因此所有频道汇聚到同一个 session。
|
||||
|
||||
---
|
||||
|
||||
### 2. 相同 Agent,独立 Session
|
||||
|
||||
多个频道共享同一个 agent(相同工作区、记忆、个性),但拥有独立的对话。
|
||||
|
||||
**共享的内容:** 工作区、记忆、CLAUDE.md 以及所有持久化状态。如果你在一个 session 中告诉 agent 某事,它可以将其保存到记忆中并在另一个 session 中调取。agent 的个性、知识和工具在各个 session 中完全相同。
|
||||
|
||||
**独立的内容:** 对话线程。来自一个频道的消息不会出现在另一个频道的 session 中。每个频道有自己的上下文窗口和对话历史。
|
||||
|
||||
**示例:** 你有三个与 agent 的 Telegram 聊天——一个用于副项目,一个用于个人任务,一个用于工作。三个聊天共享同一个 agent 工作区。如果你在项目聊天中要求它记住你的 API 密钥命名规范,它可能在工作聊天中也调用该规范。但对话本身是独立的。
|
||||
|
||||
**何时使用:** 当你是跨频道的主要(或唯一)参与者,并且希望统一的 agent 身份时。这是跨多个平台或多个群组进行个人使用的最常见设置。
|
||||
|
||||
**技术实现:** 多个 messaging group 以 `session_mode: 'shared'`(或 `'per-thread'`)连接到同一个 agent group。每个 messaging group 获得自己的 session,但它们都在同一个 agent group 目录下运行。
|
||||
|
||||
---
|
||||
|
||||
### 3. 独立的 Agent Group
|
||||
|
||||
每个频道拥有自己的 agent,拥有自己的工作区、记忆和个性。没有任何共享。
|
||||
|
||||
**共享的内容:** 无。这些 agent 彼此不知道对方的存在。不同的 CLAUDE.md,不同的记忆,不同的工作区,不同的对话历史。
|
||||
|
||||
**示例:** 你有一个与朋友的 Telegram 群组,以及一个用于团队项目的 Discord 服务器。朋友不应该知道你和团队讨论了什么,反之亦然。每个都获得自己的 agent,拥有自己的记忆和个性。
|
||||
|
||||
**何时使用:** 当涉及不同的人时,或者一个频道的信息永远不应泄露到另一个频道时。只要有隐私或保密边界,这就是正确的选择。
|
||||
|
||||
**技术实现:** 每个频道连接到不同的 agent group,每个在 `groups/` 下有自己的目录。独立的容器,独立的 session 数据库,独立的一切。
|
||||
|
||||
---
|
||||
|
||||
## 如何决定
|
||||
|
||||
关键问题:**你是否能接受一个频道中的任何和所有信息在另一个频道中可用?**
|
||||
|
||||
- **不能** → 独立的 agent group(级别 3)
|
||||
- **能,而且频道之间应该看到彼此的消息** → 共享 session(级别 1)
|
||||
- **能,但对话应该独立** → 相同 agent,独立 session(级别 2)
|
||||
|
||||
### 经验法则
|
||||
|
||||
| 场景 | 推荐级别 |
|
||||
|----------|------------------|
|
||||
| 仅你一人,多个平台(Telegram + Discord + Slack) | 相同 agent,独立 session |
|
||||
| 仅你一人,一个平台上的多个群组(3 个 Telegram 聊天) | 相同 agent,独立 session |
|
||||
| Webhook 频道 + 聊天频道(GitHub + Slack) | 共享 session |
|
||||
| 与朋友 A 的频道和与朋友 B 的频道 | 独立的 agent group |
|
||||
| 个人频道和工作频道 | 独立的 agent group |
|
||||
| 不同访问级别的团队频道 | 独立的 agent group |
|
||||
|
||||
### 不确定时
|
||||
|
||||
如果参与者在各频道相同 → 同一个 agent group 通常没问题。
|
||||
|
||||
如果涉及不同的人 → 独立的 agent group。否则信息会通过 agent 记忆交叉传播。
|
||||
|
||||
## 实体模型
|
||||
|
||||
```
|
||||
agent_groups (工作区、记忆、CLAUDE.md、个性)
|
||||
↕ 多对多
|
||||
messaging_groups (平台上特定的频道/聊天/群组)
|
||||
通过
|
||||
messaging_group_agents (session_mode、trigger_rules、priority)
|
||||
```
|
||||
|
||||
- **共享 session:** 多个 messaging_groups → 同一个 agent_group,`session_mode = 'agent-shared'`
|
||||
- **相同 agent,独立 session:** 多个 messaging_groups → 同一个 agent_group,`session_mode = 'shared'`
|
||||
- **独立 agent:** 每个 messaging_group → 不同的 agent_group
|
||||
139
docs/zh/migration-dev.md
Normal file
139
docs/zh/migration-dev.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# v1 → v2 迁移 — 开发指南
|
||||
|
||||
如何测试、开发和调试迁移流程。
|
||||
|
||||
## 快速入门
|
||||
|
||||
```bash
|
||||
# 完整周期:重置 → 迁移 → Claude 完成
|
||||
bash migrate-v2-reset.sh && bash migrate-v2.sh
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
两部分迁移:
|
||||
|
||||
1. **`migrate-v2.sh`**——确定性 bash 脚本。处理前提条件、DB 种子、文件拷贝、频道安装、容器构建、服务切换。写入 `logs/setup-migration/handoff.json`,然后 `exec` 到 Claude 中。
|
||||
|
||||
2. **`/migrate-from-v1` 技能**——由 Claude 驱动。读取交接文件,种子 owner/roles,清理 CLAUDE.local.md,验证容器配置,移植 fork 定制。
|
||||
|
||||
## 文件布局
|
||||
|
||||
```
|
||||
migrate-v2.sh # 入口点
|
||||
migrate-v2-reset.sh # 清除 v2 状态以重新测试
|
||||
setup/migrate-v2/
|
||||
env.ts # 阶段 1a:合并 .env
|
||||
db.ts # 阶段 1b:种子 v2 DB
|
||||
groups.ts # 阶段 1c:拷贝 group 目录 + container.json
|
||||
sessions.ts # 阶段 1d:拷贝 session 并设置续接
|
||||
tasks.ts # 阶段 1e:移植计划任务
|
||||
channel-auth.ts # 阶段 2b:拷贝频道认证状态
|
||||
select-channels.ts # 阶段 2a:clack 多选
|
||||
switchover-prompt.ts # 服务切换提示
|
||||
setup/migrate-v2/shared.ts # 共享辅助函数(JID 解析、触发器映射等)
|
||||
.claude/skills/migrate-from-v1/ # Claude 技能
|
||||
logs/setup-migration/handoff.json # 由 migrate-v2.sh 写入,由技能读取
|
||||
logs/migrate-steps/*.log # 每个步骤的原始输出
|
||||
```
|
||||
|
||||
## 开发循环
|
||||
|
||||
```bash
|
||||
# 重置 v2 到干净状态(保留 node_modules)
|
||||
bash migrate-v2-reset.sh
|
||||
|
||||
# 以非交互式频道选择运行迁移
|
||||
NANOCLAW_CHANNELS="telegram" bash migrate-v2.sh
|
||||
|
||||
# 或以交互方式运行(clack 多选)
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
`migrate-v2-reset.sh` 清除:`data/`、`logs/`、`.env`、`groups/`(恢复 git 跟踪的)、`container/skills/`(恢复 git 跟踪的)、`src/channels/`(恢复 git 跟踪的)。
|
||||
|
||||
它**不**清除 `node_modules/`(重新安装成本高昂)。
|
||||
|
||||
## 测试单个步骤
|
||||
|
||||
每个步骤是一个独立的 TypeScript 文件:
|
||||
|
||||
```bash
|
||||
# 运行单个步骤(在 pnpm install 之后)
|
||||
pnpm exec tsx setup/migrate-v2/env.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/db.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/groups.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/sessions.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/tasks.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/channel-auth.ts /path/to/v1 telegram discord
|
||||
```
|
||||
|
||||
每个步骤向 stdout 打印 `OK:<details>`、`SKIPPED:<reason>` 或错误。成功/跳过时退出 0,失败时退出非零值。
|
||||
|
||||
## 调试
|
||||
|
||||
### 检查已迁移的内容
|
||||
|
||||
```bash
|
||||
# Agent groups
|
||||
sqlite3 data/v2.db "SELECT * FROM agent_groups"
|
||||
|
||||
# Messaging groups + 连线
|
||||
sqlite3 data/v2.db "SELECT mg.id, mg.channel_type, mg.platform_id, mg.unknown_sender_policy, mga.engage_mode, mga.engage_pattern FROM messaging_groups mg JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id"
|
||||
|
||||
# Sessions
|
||||
sqlite3 data/v2.db "SELECT * FROM sessions"
|
||||
|
||||
# 用户和角色
|
||||
sqlite3 data/v2.db "SELECT * FROM users"
|
||||
sqlite3 data/v2.db "SELECT * FROM user_roles"
|
||||
|
||||
# Session 续接(哪个 Claude Code session 将被恢复)
|
||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups LIMIT 1")
|
||||
SESS_ID=$(sqlite3 data/v2.db "SELECT id FROM sessions LIMIT 1")
|
||||
sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/outbound.db "SELECT * FROM session_state"
|
||||
|
||||
# 计划任务
|
||||
sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/inbound.db "SELECT id, kind, recurrence, status FROM messages_in WHERE kind='task'"
|
||||
```
|
||||
|
||||
### 检查交接文件
|
||||
|
||||
```bash
|
||||
python3 -m json.tool logs/setup-migration/handoff.json
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
**切换后 bot 不响应:**
|
||||
1. 检查两个服务都没有运行:`systemctl --user list-units 'nanoclaw*'`
|
||||
2. 检查错误日志:`tail logs/nanoclaw.error.log`
|
||||
3. 检查发送者策略:`sqlite3 data/v2.db "SELECT unknown_sender_policy FROM messaging_groups"`——在 owner 被种子之前必须是 `public`
|
||||
4. 检查触发模式:`sqlite3 data/v2.db "SELECT engage_mode, engage_pattern FROM messaging_group_agents"`——应为 `pattern` / `.` 用于响应所有内容
|
||||
|
||||
**Session 未从 v1 续接:**
|
||||
1. 检查是否设置了续接:见上方的"Session 续接"查询
|
||||
2. 检查 JSONL 是否在正确路径下:`ls data/v2-sessions/<ag_id>/.claude-shared/projects/-workspace-agent/`
|
||||
3. v1 session JSONL 应从 `-workspace-group/` 拷贝到 `-workspace-agent/`(v2 容器 CWD 为 `/workspace/agent`)
|
||||
|
||||
**服务切换回退不起作用:**
|
||||
1. v2 服务名称为 `nanoclaw-v2-<hash>`——找到它:`systemctl --user list-units 'nanoclaw*'`
|
||||
2. 手动停止:`systemctl --user stop <unit> && systemctl --user disable <unit>`
|
||||
3. 重启 v1:`systemctl --user start nanoclaw`
|
||||
|
||||
### 步骤日志
|
||||
|
||||
每个步骤将原始输出写入 `logs/migrate-steps/<step>.log`。当某个步骤失败时读取这些日志:
|
||||
|
||||
```bash
|
||||
cat logs/migrate-steps/1b-db.log
|
||||
cat logs/migrate-steps/1d-sessions.log
|
||||
```
|
||||
|
||||
## 关键决策
|
||||
|
||||
- `unknown_sender_policy` 在迁移期间设置为 `public`,以便 bot 立即响应。`/migrate-from-v1` 技能在种子 owner 后收紧此设置。
|
||||
- v1 中 `requires_trigger=0` 优先于非空的 `trigger_pattern`——它意味着"响应所有内容"。
|
||||
- v1 `container_config.additionalMounts` 直接写入 v2 `container.json`(相同格式)。
|
||||
- v1 Claude Code session 从 `-workspace-group/` 拷贝到 `-workspace-agent/`,session ID 以 `continuation:claude` 写入 `outbound.db`,以便 agent-runner 恢复同一对话。
|
||||
- 结尾的 `exec claude "/migrate-from-v1"` 替换了 bash 进程——`write_handoff` 在 `exec` 之前显式调用,因为 EXIT 陷阱不会在 `exec` 时触发。
|
||||
88
docs/zh/ollama.md
Normal file
88
docs/zh/ollama.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 在本地 Ollama 上运行 Agent
|
||||
|
||||
NanoClaw agent(智能体)可以被路由到本地 [Ollama](https://ollama.com) 实例,而非 Anthropic API。这可以将 API 成本降至零,并将所有推理保留在你的硬件上。
|
||||
|
||||
## 工作原理
|
||||
|
||||
Ollama 暴露了一个 Anthropic 兼容的 `/v1/messages` 端点。Claude Code CLI(运行在 agent 容器内)使用 Anthropic SDK,该 SDK 读取 `ANTHROPIC_BASE_URL` 来找到 API 主机。将该变量指向 Ollama 就是所需的全部——无需新的 provider(提供程序)代码,无需更改 agent 运行时。
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Agent 容器 │
|
||||
│ │
|
||||
│ Claude Code CLI │
|
||||
│ ↓ ANTHROPIC_BASE_URL │
|
||||
│ http://host.docker. │ ┌──────────────────┐
|
||||
│ internal:11434 ───────┼─────▶│ Ollama :11434 │
|
||||
│ │ │ gemma4:latest │
|
||||
└─────────────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
`host.docker.internal` 是 Docker 的魔法主机名,从容器内部解析为宿主机——因此运行在你的 Mac 或 Linux 机器上的 Ollama 可以通过该地址访问。
|
||||
|
||||
## OneCLI 的复杂之处
|
||||
|
||||
NanoClaw 通常通过 OneCLI HTTPS 代理来运行 API 调用,该代理将真实凭据注入替换占位密钥。当重定向到 Ollama 时,你需要绕过该代理,使请求直接发出。两个环境变量处理此问题:
|
||||
|
||||
- `NO_PROXY=host.docker.internal`——告诉 Anthropic SDK 的 HTTP 客户端对该主机名跳过代理
|
||||
- `no_proxy=host.docker.internal`——小写变体,用于检查小写形式的工具
|
||||
|
||||
两者都在 agent group 的 `container.json` 中与 `ANTHROPIC_BASE_URL` 一起设置。
|
||||
|
||||
## 网络隔离
|
||||
|
||||
设置 `ANTHROPIC_BASE_URL` 会重定向请求,但不能阻止配置错误的 agent 意外地直接访问 `api.anthropic.com`。`container.json` 中的 `blockedHosts` 字段添加一个 Docker `--add-host` 标志,将该域名解析为 `0.0.0.0`,使其在容器内无法物理访问:
|
||||
|
||||
```json
|
||||
"blockedHosts": ["api.anthropic.com"]
|
||||
```
|
||||
|
||||
有了这个设置,即使模型设置漂移回 Claude 模型名称,API 调用也会立即失败,而不是悄悄地从你的账户扣费。
|
||||
|
||||
## 模型选择
|
||||
|
||||
Claude Code CLI 从容器的 `~/.claude/settings.json` 读取其模型,NanoClaw 从 `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json` bind mount 该文件。设置 `"model": "gemma4:latest"`(或你已 pull 的任何 Ollama 模型)。使用来自 `ollama list` 的精确名称。
|
||||
|
||||
Apple Silicon 的模型选择考量:
|
||||
|
||||
| 模型 | 规模 | 质量 | 速度(M4 Pro) |
|
||||
|-------|------|---------|----------------|
|
||||
| `gemma4:latest` | 12B | 通用良好 | 快 |
|
||||
| `qwen3-coder:latest` | 32B | 编码任务出色 | 中等 |
|
||||
| `llama3.2:latest` | 3B | 基础 | 非常快 |
|
||||
|
||||
agent 大量使用工具调用(读取/写入文件、shell 命令)。支持可靠工具调用的模型效果最好。Gemma 4 和 Qwen 3 Coder 都能很好地处理结构化工具调用。
|
||||
|
||||
## 代码层面的变更
|
||||
|
||||
三个文件需要支持此功能。确切变更见 `/add-ollama-provider`。
|
||||
|
||||
**`src/container-config.ts`**——`ContainerConfig` 接口需要 `env` 和 `blockedHosts` 字段,以便每个 group 的 JSON 可以携带它们。
|
||||
|
||||
**`src/container-runner.ts`**——在容器启动时,`env` 条目变为 `-e KEY=VAL` Docker 标志(在 OneCLI 注入的变量之后应用,因此它们获胜),`blockedHosts` 条目变为 `--add-host HOST:0.0.0.0` 标志。
|
||||
|
||||
**`container/Dockerfile`**——容器以宿主机用户的 uid 运行(例如 macOS 上的 501),而非 `node` 用户(uid 1000)。家目录必须是 `chmod 777`,以便任何 uid 都可以写入 `~/.claude.json` 和 `~/.claude/settings.json`。
|
||||
|
||||
## 权衡
|
||||
|
||||
| | Ollama(本地) | Anthropic API |
|
||||
|---|---|---|
|
||||
| 成本 | 免费 | 按 token 付费 |
|
||||
| 隐私 | 完全本地 | 数据发送到 Anthropic |
|
||||
| 模型质量 | 良好(开放权重) | 出色(Claude) |
|
||||
| 冷启动 | 5–30 秒(模型加载) | ~1 秒 |
|
||||
| 上下文窗口 | 因模型而异 | 200k tokens(Sonnet) |
|
||||
| 工具调用可靠性 | 良好(大型模型) | 出色 |
|
||||
| 硬件要求 | 16GB+ RAM | 无 |
|
||||
|
||||
对于有能力的硬件上的个人自动化,权衡倾向于本地。对于需要大上下文或高可靠性的复杂多步骤任务,Claude 仍然领先。
|
||||
|
||||
## 恢复使用 Claude
|
||||
|
||||
从 `groups/<folder>/container.json` 中移除 `env` 和 `blockedHosts` 键,从共享设置文件中移除 `"model"`,并重启服务。无需重建。
|
||||
|
||||
## 另见
|
||||
|
||||
- `/add-ollama-provider`——为任何 agent group 配置 Ollama 的分步技能
|
||||
- [Ollama Anthropic 兼容文档](https://ollama.com/blog/openai-compatibility)——API 桥接的上游文档
|
||||
- `docs/architecture.md`——容器启动和环境变量注入管道的工作原理
|
||||
174
docs/zh/setup-flow.md
Normal file
174
docs/zh/setup-flow.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 安装流程
|
||||
|
||||
本文档是 NanoClaw 端到端脚本化安装的契约
|
||||
(`bash nanoclaw.sh` → `pnpm run setup:auto`)。在添加新步骤、修复回退或更改输出渲染方式之前,请阅读本文档。
|
||||
|
||||
## 三个输出级别
|
||||
|
||||
每个安装步骤在**三个不同级别**产生输出。它们面向
|
||||
不同的受众,输出到不同的位置,并以不同的格式呈现。
|
||||
不要混淆它们。
|
||||
|
||||
| 级别 | 受众 | 目的地 | 格式 |
|
||||
|---|---|---|---|
|
||||
| 1. 面向用户 | 运行安装的操作者 | 终端(通过 clack) | 品牌化的、简洁的、信息性的——"产品内容" |
|
||||
| 2. 进度 | 未来调试者、审查失败运行的 AI agent、发布支持 | `logs/setup.log`(一个文件,仅追加) | 结构化的每个步骤块,线性时间顺序,人类和机器可读 |
|
||||
| 3. 原始 | 正在深度调试特定步骤的人 | `logs/setup-steps/NN-step-name.log`(每个步骤一个文件) | 完整的原始子进程 stdout + stderr,逐字记录 |
|
||||
|
||||
可以这样理解:用户看到的是**摘要**,进度日志是
|
||||
**带关键事实的索引**,原始日志是**证据**。
|
||||
|
||||
### 级别 1:面向用户(clack)
|
||||
|
||||
由 `setup/auto.ts` 通过 `@clack/prompts` 渲染。这是我们的*产品
|
||||
界面*——每行都应读起来像是我们为第一天遇到的陌生人设计的一样。
|
||||
|
||||
- 使用 clack spinner 表示进行中的工作。显示经过的时间。
|
||||
- `p.log.success` / `p.log.step` / `p.log.warn` 用于永久状态标记。
|
||||
- `p.note` 用于多行信息(配对码、下一步)。
|
||||
- `p.text` / `p.select` / `p.password` 用于提示。
|
||||
- 品牌调色板:`setup/auto.ts` 中的 `brand()` / `brandBold()` / `brandChip()` 辅助函数。当终端支持时使用真彩色,否则回退到 16 色青色,管道输出 / `NO_COLOR` 时使用纯文本。
|
||||
|
||||
规则:
|
||||
- **无中断。** 每个子步骤属于同一个视觉流程。唯一的例外是 Anthropic 凭据注册(见下文)。
|
||||
- **无原始子进程输出。** 永远不要对输出内容非我们所编写的子进程使用 `stdio: 'inherit'`。捕获它,仅在失败时显示。
|
||||
- **无调试样式前缀**(`[add-telegram] …`、`INFO …`、时间戳)。这些属于级别 2 和 3。
|
||||
- **无 emoji**,除非 clack 图形需要。
|
||||
|
||||
### 级别 2:进度日志
|
||||
|
||||
`logs/setup.log`——每次安装运行一个文件,仅追加,跨多次运行安装累积
|
||||
(如果某次运行中途失败并被重新尝试,新条目会追加)。这是你在操作者报告安装 bug 时
|
||||
会要求他们粘贴的东西,也是 AI agent 会阅读以了解发生了什么的东西。
|
||||
|
||||
条目格式:
|
||||
|
||||
```
|
||||
=== [2026-04-22T22:14:12Z] bootstrap [45.1s] → success ===
|
||||
platform: linux
|
||||
is_wsl: false
|
||||
node_version: 22.22.2
|
||||
deps_ok: true
|
||||
native_ok: true
|
||||
raw: logs/setup-steps/01-bootstrap.log
|
||||
|
||||
=== [2026-04-22T22:14:57Z] environment [2.3s] → success ===
|
||||
docker: running
|
||||
apple_container: not_found
|
||||
raw: logs/setup-steps/02-environment.log
|
||||
|
||||
=== [2026-04-22T22:15:00Z] container [92.4s] → success ===
|
||||
runtime: docker
|
||||
image: nanoclaw-agent:latest
|
||||
build_ok: true
|
||||
raw: logs/setup-steps/03-container.log
|
||||
```
|
||||
|
||||
设计约束:
|
||||
- 开始行带有起始时间戳(UTC,ISO-8601),使 `grep` 能给出顺序。
|
||||
- 持续时间以秒为单位,保留一位小数——快速步骤读作 "0.5s",而非 "0ms"。
|
||||
- 状态为以下之一:`success`、`skipped`、`failed`、`aborted`。
|
||||
- 字段是步骤特定的,但**必须**是短标量值。无 JSON,无多行。如果值很长,放在原始日志中并引用它。
|
||||
- 始终输出一个 `raw:` 指针,即使在成功时——使调试第二次失败更容易。
|
||||
- **用户选择**是其自己的条目,不嵌套在步骤中:
|
||||
|
||||
```
|
||||
=== [2026-04-22T22:17:44Z] user-input → display_name ===
|
||||
value: gav
|
||||
|
||||
=== [2026-04-22T22:17:51Z] user-input → channel_choice ===
|
||||
value: telegram
|
||||
```
|
||||
|
||||
这些很重要,因为通过安装流程的路径取决于它们。
|
||||
|
||||
日志以标识运行的头部块开始,以完成块结束:
|
||||
|
||||
```
|
||||
## 2026-04-22T22:14:12Z · setup:auto 已启动
|
||||
user: exedev
|
||||
cwd: /home/exedev/nanoclaw
|
||||
branch: branded-setup
|
||||
commit: 6e0d742
|
||||
|
||||
… (步骤条目) …
|
||||
|
||||
## 2026-04-22T22:18:54Z · 已完成 (总计 4m42s)
|
||||
```
|
||||
|
||||
失败时完成块会指出失败的步骤及其错误:
|
||||
|
||||
```
|
||||
## 2026-04-22T22:16:40Z · 在 container 处中止 (err=cache_miss)
|
||||
```
|
||||
|
||||
### 级别 3:原始按步骤日志
|
||||
|
||||
`logs/setup-steps/NN-step-name.log`——每个步骤一个文件,按执行顺序编号(两位零填充前缀以支持自然排序)。来自子进程的完整逐字 stdout + stderr。每次运行时截断并重写(非追加)。
|
||||
|
||||
内容为步骤输出的任何内容:apt 输出、docker 构建层、pnpm install 内容、`curl` 响应体等。这是证据层面——"shell 实际看到了什么?"不过滤任何内容。
|
||||
|
||||
## 新步骤的契约
|
||||
|
||||
当你添加一个步骤(无论是 `setup/<name>.ts` 中的 TS 步骤还是从 `auto.ts` 调用的 bash 安装程序),它必须:
|
||||
|
||||
1. **从调用方接收一个原始日志路径。** 将所有 stdout + stderr 写入那里。不要直接写入终端。
|
||||
2. **在结束时输出单个终端状态块**,包含 `STATUS: success|skipped|failed` 和任何步骤特定字段:
|
||||
|
||||
```
|
||||
=== NANOCLAW SETUP: STEP_NAME ===
|
||||
STATUS: success
|
||||
KEY: value
|
||||
KEY: value
|
||||
=== END ===
|
||||
```
|
||||
|
||||
字段名使用 `UPPER_SNAKE_CASE`。值是短标量值。
|
||||
|
||||
3. 如果是一个长时间运行的步骤,可选择在中途发出**子状态块**。`auto.ts` 实时解析它们,并可以渲染中间 UI(正如 `pair-telegram` 使用 `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` 所做的那样)。
|
||||
|
||||
4. **在硬失败时以非零退出**,以便 `auto.ts` 可以区分"步骤运行完成并报告失败"与"步骤崩溃"。
|
||||
|
||||
驱动程序处理其余部分:级别 1 中的 spinner,级别 2 中的结构化追加,级别 3 中的原始捕获。
|
||||
|
||||
## Anthropic 例外
|
||||
|
||||
Anthropic 凭据注册(`setup/register-claude-token.sh`)是视觉流程中**唯一**允许的中断。原因:
|
||||
|
||||
- `claude setup-token` 打开浏览器,运行自己的 OAuth 提示,并打印 token。它通过 `script(1)` 拥有 TTY。
|
||||
- 我们不想自己重新实现 OAuth 设备流程。
|
||||
- 我们不想拦截/镜像 token(它已经出现在用户终端——镜像它会增加攻击面)。
|
||||
|
||||
因此在此步骤期间:
|
||||
- clack 流程显式暂停(一个 `p.log.step` 标记说"这部分是交互式的,你正在移交给 Anthropic")。
|
||||
- 子进程完全继承 stdio。
|
||||
- 当控制返回时,clack 在下一行恢复,带有一个成功标记。
|
||||
|
||||
级别 2 日志仍然获得一个条目(`auth [interactive] → success` 带有方法——subscription / oauth-token / api-key)。级别 3 的捕获在这里是可选的;镜像 `script -q` 输出很棘手,将 token 泄露到磁盘的风险超过了调试价值。
|
||||
|
||||
## 文件参考
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
| `nanoclaw.sh` | 顶层封装。阶段 1(bootstrap)和阶段 2(setup:auto)编排。写入 bootstrap 的原始日志 + 进度条目。 |
|
||||
| `setup.sh` | 阶段 1 bootstrap:Node、pnpm、原生模块验证。发出自己的 `BOOTSTRAP` 状态块(历史上打印到 stdout;现在输出到 bootstrap 原始日志)。 |
|
||||
| `setup/auto.ts` | 阶段 2 驱动程序。编排 clack UI、步骤执行、用户提示,并为其启动的每个步骤写入所有三个日志级别。 |
|
||||
| `setup/logs.ts` | 日志记录原语(`logStep`、`logUserInput`、`logComplete`、`stepRawLog`、`initSetupLog`)。级别 2/3 格式和文件路径的唯一真源。 |
|
||||
| `setup/<step>.ts` | 各个步骤的实现。必须输出一个终端状态块;不能直接写入终端。 |
|
||||
| `setup/register-claude-token.sh` | Anthropic 例外。继承 stdio,打印自己的 UI,向驱动程序返回状态。 |
|
||||
| `setup/add-telegram.sh` | 非交互式适配器安装程序。从 env 读取 `TELEGRAM_BOT_TOKEN`;从不提示。面向用户的部分驻留在 `auto.ts` 中。 |
|
||||
| `setup/pair-telegram.ts` | 发出 `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` 状态块。从不打印 UI。驱动程序通过 clack notes 渲染。 |
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
- **在步骤内部打印调试输出。** 开发时诱人;检入代码时禁止。所有运行时消息都通过状态块(级别 2)或原始日志写入(级别 3)。
|
||||
- **添加一个"仅此一次"输出到终端的 `console.log`。** 它会破坏 clack 流程——spinner 行会被撕裂。改用 `src/log.ts` 中的 `log.info` / `log.error`(写入原始日志)。
|
||||
- **对非例外子进程使用 `stdio: 'inherit'`。** 见上文 Anthropic。其他任何情况都需要 `pipe` + 显式捕获。
|
||||
- **Tee 到 stderr。** Clack 的 spinner 在一个步骤期间拥有终端。即使是 stderr 写入也会撕裂框架。管道传输一切,然后选择要暴露的内容。
|
||||
- **bash `$VAR…` 位置中的 UTF-8。** Bash 的词法分析器可能将多字节字符的第一个字节拉入变量名,触发 `set -u`。始终加花括号:`${VAR}…`。
|
||||
|
||||
## 未来工作(尚未实现)
|
||||
|
||||
- **进度日志轮转。** 今天的实现在每次运行时截断。未来:将之前的运行滚动到 `logs/setup.log.1`、`.2` 等。
|
||||
- **多次运行安装的原始日志轮转。** 目前每次运行会覆盖。目前可以接受;如果支持需要比较连续尝试,则重新审视。
|
||||
- **`register-claude-token.sh` 的结构化输出。** 交互式步骤目前不发出机器可读状态。未来可以在交互后添加一个状态块,注明使用的方法。
|
||||
101
docs/zh/setup-wiring.md
Normal file
101
docs/zh/setup-wiring.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 安装连线——状态与剩余工作
|
||||
|
||||
最后更新:2026-04-09
|
||||
|
||||
## 已完成
|
||||
|
||||
### 双库拆分(Session DB 写入隔离)
|
||||
- Session DB 拆分为 `inbound.db`(宿主机拥有)和 `outbound.db`(容器拥有)
|
||||
- 每个文件有且仅有一个写入者——消除了跨越宿主机-容器挂载的 SQLite 写入竞争
|
||||
- 宿主机使用偶数 seq 号,容器使用奇数(无冲突)
|
||||
- 容器心跳通过文件 touch(`/workspace/.heartbeat`)而非 DB UPDATE
|
||||
- 调度 MCP 工具通过 messages_out 发出系统操作;宿主机在 `delivery.ts:handleSystemAction()` 中将其应用到 inbound.db
|
||||
- 宿主机 sweep 读取 `processing_ack` 表 + 心跳文件 mtime 以检测过期
|
||||
- 容器在启动时清除过期的 `processing_ack` 条目(崩溃恢复)
|
||||
- 文件:`src/db/schema.ts`(INBOUND_SCHEMA + OUTBOUND_SCHEMA)、`src/session-manager.ts`、`src/delivery.ts`、`src/host-sweep.ts`、`container/agent-runner/src/db/connection.ts`、`messages-in.ts`、`messages-out.ts`、`poll-loop.ts`、`mcp-tools/scheduling.ts`、`mcp-tools/interactive.ts`
|
||||
- 容器镜像已使用 tsconfig(`container/agent-runner/tsconfig.json`)重建
|
||||
- E2E 已验证:宿主机 → Docker 容器 → Claude 响应 → "E2E works!" ✓
|
||||
|
||||
### OneCLI 集成
|
||||
- 在 `src/container-runner.ts` 中,`ensureAgent()` 调用已添加在 `applyContainerConfig()` 之前
|
||||
- 如果没有 `ensureAgent`,OneCLI 会拒绝未知的 agent 标识符并返回 false,使容器没有凭据
|
||||
- E2E 已验证 OneCLI 凭据注入 ✓
|
||||
|
||||
### 频道 Barrel
|
||||
- `src/index.ts` 导入 `./channels/index.js`(barrel)
|
||||
- 主干仅提供 barrel + Chat SDK 桥接;`/add-<channel>` 技能将适配器文件放入并通过 barrel 槽注册
|
||||
- 主干不提供任何频道适配器
|
||||
|
||||
### 安装注册(部分完成)
|
||||
- `setup/register.ts` 在 `data/v2.db` 中创建实体(`agent_groups`、`messaging_groups`、`messaging_group_agents`)
|
||||
- 接受 `--platform-id` 标志
|
||||
- `getMessagingGroupAgentByPair()` 防止重复连线
|
||||
- `setup/verify.ts` 检查中央库(统计有连线的 agent group 数量)
|
||||
|
||||
### 路由日志
|
||||
- `src/router.ts` 在没有 agent 连线时以 WARN 级别记录 `MESSAGE DROPPED`,并提供可操作的指导
|
||||
|
||||
---
|
||||
|
||||
## 之前未完成——现已解决
|
||||
|
||||
### 1. ~~频道技能不注册 Group~~ ✅
|
||||
|
||||
频道技能现在在其"下一步"部分指向 `/manage-channels`。注册由 `/manage-channels` 技能处理,该技能读取每个频道的 `## Channel Info` 部分以获取平台特定指导。频道技能保持精简(仅凭据)。
|
||||
|
||||
### 2. ~~安装 SKILL.md 缺少 Group 注册步骤~~ ✅
|
||||
|
||||
在频道安装(第 5 步)和挂载白名单(第 6 步)之间添加了第 5a 步"将频道连接到 Agent Group"。此步骤调用 `/manage-channels`,它处理 agent group 创建、隔离级别决策和连线。
|
||||
|
||||
### 3. ~~频道技能应知道频道类型~~ ✅
|
||||
|
||||
每个频道技能都有一个结构化的 `## Channel Info` 部分,包含:type、terminology、how-to-find-id、supports-threads、typical-use、default-isolation。`/manage-channels` 技能读取这些信息以提供上下文建议。
|
||||
|
||||
### 4. ~~验证步骤频道认证检查~~ ✅
|
||||
|
||||
`setup/verify.ts` 检查所有频道 token:DISCORD_BOT_TOKEN、TELEGRAM_BOT_TOKEN、SLACK_BOT_TOKEN+SLACK_APP_TOKEN、GITHUB_TOKEN、LINEAR_API_KEY、GCHAT_CREDENTIALS、TEAMS_APP_ID+TEAMS_APP_PASSWORD、WEBEX_BOT_TOKEN、MATRIX_ACCESS_TOKEN、RESEND_API_KEY、WHATSAPP_ACCESS_TOKEN、IMESSAGE_ENABLED,以及 WhatsApp Baileys 认证目录。
|
||||
|
||||
### 5. Agent-Shared Session 模式 ✅
|
||||
|
||||
添加了 `session_mode: 'agent-shared'` 用于跨频道共享 session(例如 GitHub + Slack 在同一对话中)。当设置此模式时,Session 解析按 agent_group_id 而非 messaging_group_id 查找。
|
||||
|
||||
---
|
||||
|
||||
## 架构参考
|
||||
|
||||
### 实体模型
|
||||
```
|
||||
agent_groups (id, name, folder, agent_provider, container_config)
|
||||
↕ 多对多
|
||||
messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy)
|
||||
通过
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority)
|
||||
|
||||
users (id, kind, display_name) -- 命名空间为 "<channel>:<handle>"
|
||||
user_roles (user_id, role, agent_group_id) -- owner / admin(全局或限定范围)
|
||||
agent_group_members (user_id, agent_group_id) -- 非特权访问关卡
|
||||
user_dms (user_id, channel_type, messaging_group_id) -- 冷启动 DM 缓存
|
||||
```
|
||||
|
||||
权限是用户级别的概念——没有"主"agent group 或"管理员"messaging group。`user_roles` 携带 `owner`(仅全局,首次配对设置)和 `admin`(全局或限定到一个 `agent_group_id`)。未知发送者门控是按 messaging group 的,通过 `messaging_groups.unknown_sender_policy`(`strict | request_approval | public`)。
|
||||
|
||||
### 消息流程
|
||||
```
|
||||
频道适配器 → routeInbound() → 解析 messaging_group → 通过 messaging_group_agents 解析 agent
|
||||
→ 解析/创建 session → 写入 inbound.db → 唤醒容器 → agent-runner 轮询 inbound.db
|
||||
→ agent 响应 → 写入 outbound.db → 宿主机投递轮询读取 outbound.db → 通过适配器投递
|
||||
```
|
||||
|
||||
### 关键文件
|
||||
| 文件 | 用途 |
|
||||
|------|---------|
|
||||
| `src/index.ts` | 入口点,导入频道 barrel |
|
||||
| `src/channels/index.ts` | 频道 barrel——主干仅包含注册表/Chat SDK 桥接;技能将适配器放入 |
|
||||
| `src/router.ts` | 入站路由,自动创建 messaging group |
|
||||
| `src/session-manager.ts` | 为每个 session 创建 inbound.db + outbound.db |
|
||||
| `src/delivery.ts` | 轮询 outbound.db,投递,处理系统操作 |
|
||||
| `src/host-sweep.ts` | 同步 processing_ack,过期检测,重复执行 |
|
||||
| `src/container-runner.ts` | 启动容器,OneCLI ensureAgent + applyContainerConfig |
|
||||
| `setup/register.ts` | 创建实体(agent_group、messaging_group、连线) |
|
||||
| `setup/verify.ts` | 检查中央库中已注册的 group |
|
||||
| `container/agent-runner/src/db/connection.ts` | 双库连接层(inbound 只读,outbound 读写) |
|
||||
677
docs/zh/skills-as-branches.md
Normal file
677
docs/zh/skills-as-branches.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# 技能作为分支(Skills as Branches)
|
||||
|
||||
## 概述
|
||||
|
||||
本文档涵盖**功能技能(feature skills)**——通过 git 分支合并(branch merge)添加能力的技能。这是最复杂的技能类型,也是扩展 NanoClaw 的主要方式。
|
||||
|
||||
NanoClaw 共有四种技能类型。完整分类请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md):
|
||||
|
||||
| 类型 | 位置 | 工作原理 |
|
||||
|------|----------|-------------|
|
||||
| **功能型(Feature)**(本文档) | `.claude/skills/` + `skill/*` 分支 | SKILL.md 包含指令;代码位于分支上,通过 `git merge` 应用 |
|
||||
| **工具型(Utility)** | `.claude/skills/<name>/` 包含代码文件 | 自包含工具;代码在技能目录中,安装时复制到位 |
|
||||
| **操作型(Operational)** | `main` 上的 `.claude/skills/` | 纯指令工作流(setup、debug、update) |
|
||||
| **容器型(Container)** | `container/skills/` | 运行时加载到代理容器内部 |
|
||||
|
||||
---
|
||||
|
||||
功能技能以 git 分支的形式分发在上游仓库中。应用一个技能就是一次 `git merge`。更新核心也是一次 `git merge`。一切都是标准 git 操作。
|
||||
|
||||
这取代了之前的 `skills-engine/` 系统(三路文件合并、`.nanoclaw/` 状态、清单文件、重放、备份/恢复),改用纯 git 操作和 Claude 进行冲突解决。
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 仓库结构
|
||||
|
||||
上游仓库 (`nanocoai/nanoclaw`) 维护以下内容:
|
||||
|
||||
- `main` — 核心 NanoClaw(不含技能代码)
|
||||
- `skill/discord` — main + Discord 集成
|
||||
- `skill/telegram` — main + Telegram 集成
|
||||
- `skill/slack` — main + Slack 集成
|
||||
- `skill/gmail` — main + Gmail 集成
|
||||
- 等等。
|
||||
|
||||
每个技能分支包含该技能的所有代码改动:新增文件、修改的源文件、更新的 `package.json` 依赖项、`.env.example` 新增内容——一切。没有清单文件,没有结构化操作,没有单独的 `add/` 和 `modify/` 目录。
|
||||
|
||||
### 技能发现与安装
|
||||
|
||||
技能分为两类:
|
||||
|
||||
**操作型技能**(位于 `main`,始终可用):
|
||||
- `/setup`、`/debug`、`/update-nanoclaw`、`/customize`、`/update-skills`
|
||||
- 这些是纯指令的 SKILL.md 文件——没有代码改动,只有工作流
|
||||
- 位于 `main` 上的 `.claude/skills/`,对每个用户立即可用
|
||||
|
||||
**功能型技能**(在市场(marketplace)中,按需安装):
|
||||
- `/add-discord`、`/add-telegram`、`/add-slack`、`/add-gmail` 等。
|
||||
- 每个都有一套 SKILL.md 安装指令和对应的 `skill/*` 分支代码
|
||||
- 位于市场仓库 (`nanocoai/nanoclaw-skills`) 中
|
||||
|
||||
用户永远不会直接与市场交互。操作型技能 `/setup` 和 `/customize` 透明地处理插件安装:
|
||||
|
||||
```bash
|
||||
# Claude 在后台运行此命令——用户不可见
|
||||
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
|
||||
```
|
||||
|
||||
技能在 `claude plugin install` 之后热加载(hot-loaded)——无需重启。这意味着 `/setup` 可以安装市场插件,然后立即运行任何功能技能,全部在一个会话(session)中完成。
|
||||
|
||||
### 选择性技能安装
|
||||
|
||||
`/setup` 询问用户想要哪些频道,然后只提供相关技能:
|
||||
|
||||
1. "你想用哪些消息频道?" → Discord、Telegram、Slack、WhatsApp
|
||||
2. 用户选择 Telegram → Claude 安装插件并运行 `/add-telegram`
|
||||
3. Telegram 设置完成后:"想为 Telegram 添加 Agent Swarm 支持?" → 提供 `/add-telegram-swarm`
|
||||
4. "想启用社区技能?" → 安装社区市场插件
|
||||
|
||||
依赖技能(例如 `telegram-swarm` 依赖 `telegram`)仅在其父技能安装后才提供。`/customize` 在安装后的添加操作中遵循相同模式。
|
||||
|
||||
### 市场配置
|
||||
|
||||
NanoClaw 的 `.claude/settings.json` 注册了官方市场:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
市场仓库使用 Claude Code 的插件结构:
|
||||
|
||||
```
|
||||
nanocoai/nanoclaw-skills/
|
||||
.claude-plugin/
|
||||
marketplace.json # 插件目录
|
||||
plugins/
|
||||
nanoclaw-skills/ # 捆绑所有官方技能的单一插件
|
||||
.claude-plugin/
|
||||
plugin.json # 插件清单
|
||||
skills/
|
||||
add-discord/
|
||||
SKILL.md # 安装指令;步骤 1 是 "merge the branch"
|
||||
add-telegram/
|
||||
SKILL.md
|
||||
add-slack/
|
||||
SKILL.md
|
||||
...
|
||||
```
|
||||
|
||||
多个技能捆绑在一个插件中——安装 `nanoclaw-skills` 使所有功能技能立即可用。单独技能不需要单独安装。
|
||||
|
||||
每个 SKILL.md 告诉 Claude 在第 1 步合并相应的技能分支,然后引导交互式设置(环境变量、机器人创建等)。
|
||||
|
||||
### 应用技能
|
||||
|
||||
用户运行 `/add-discord`(通过市场发现)。Claude 按照 SKILL.md 操作:
|
||||
|
||||
1. `git fetch upstream skill/discord`
|
||||
2. `git merge upstream/skill/discord`
|
||||
3. 交互式设置(创建机器人、获取 token、配置环境变量等)
|
||||
|
||||
或手动操作:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/discord
|
||||
git merge upstream/skill/discord
|
||||
```
|
||||
|
||||
### 应用多个技能
|
||||
|
||||
```bash
|
||||
git merge upstream/skill/discord
|
||||
git merge upstream/skill/telegram
|
||||
```
|
||||
|
||||
Git 处理组合(composition)。如果两个技能修改了相同的行,那就是真实的冲突,由 Claude 解决。
|
||||
|
||||
### 更新核心
|
||||
|
||||
```bash
|
||||
git fetch upstream main
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
由于技能分支保持与 main 向前合并(参见 CI 部分),用户已合并的技能改动和上游改动之间有正确的公共祖先。
|
||||
|
||||
### 检查技能更新
|
||||
|
||||
之前合并过技能分支的用户可以检查更新。对于每个 `upstream/skill/*` 分支,检查该分支是否有不在用户 HEAD 中的提交:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
for branch in $(git branch -r | grep 'upstream/skill/'); do
|
||||
# 检查用户是否在某个时间点合并过此技能
|
||||
merge_base=$(git merge-base HEAD "$branch" 2>/dev/null) || continue
|
||||
# 检查技能分支是否有超出用户已有的新提交
|
||||
if ! git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then
|
||||
echo "$branch 有可用更新"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
这不需要任何状态——它使用 git 历史来确定之前合并了哪些技能以及它们是否有新提交。
|
||||
|
||||
此逻辑可通过两种方式使用:
|
||||
- 内置于 `/update-nanoclaw` 中——合并 main 后可选择性检查技能更新
|
||||
- 独立的 `/update-skills` ——独立检查和合并技能更新
|
||||
|
||||
### 冲突解决
|
||||
|
||||
在任何合并步骤中,可能出现冲突。Claude 解决它们——读取冲突文件,理解双方的意图,并生成正确的结果。这就是分支方案能够大规模可行的原因:以前需要人工判断的冲突解决现已自动化。
|
||||
|
||||
### 技能依赖
|
||||
|
||||
一些技能依赖其他技能。例如 `skill/telegram-swarm` 需要 `skill/telegram`。依赖技能分支从其父技能分支派生,而不是从 `main` 派生。
|
||||
|
||||
这意味着 `skill/telegram-swarm` 包含 telegram 的所有改动加上自己的新增内容。当用户合并 `skill/telegram-swarm` 时,他们同时获得两者——无需单独合并 telegram。
|
||||
|
||||
依赖关系隐含在 git 历史中——`git merge-base --is-ancestor` 判断一个技能分支是否为另一个的祖先。不需要单独的依赖文件。
|
||||
|
||||
### 卸载技能
|
||||
|
||||
```bash
|
||||
# 找到合并提交
|
||||
git log --merges --oneline | grep discord
|
||||
|
||||
# 回退它
|
||||
git revert -m 1 <merge-commit>
|
||||
```
|
||||
|
||||
这会创建一个新的提交来撤销该技能的改动。Claude 可以处理整个流程。
|
||||
|
||||
如果用户在合并后修改了技能的代码(在之上做了自定义改动),回退可能会冲突——Claude 会解决它。
|
||||
|
||||
如果用户之后想重新应用技能,他们需要先回退之前回退的提交(git 将已回退的改动视为"已应用并已撤销")。Claude 也会处理这个问题。
|
||||
|
||||
## CI:保持技能分支最新
|
||||
|
||||
每次推送到 `main` 时运行一个 GitHub Action:
|
||||
|
||||
1. 列出所有 `skill/*` 分支
|
||||
2. 对每个技能分支,将 `main` 合并进去(向前合并,而非变基)
|
||||
3. 对合并结果运行构建和测试
|
||||
4. 如果测试通过,推送更新后的技能分支
|
||||
5. 如果技能失败(冲突、构建错误、测试失败),打开 GitHub issue 进行手动解决
|
||||
|
||||
**为什么用向前合并而非变基(rebase):**
|
||||
- 无需 force-push——保留了已合并该技能的用户的历史
|
||||
- 用户可以重新合并技能分支以获取技能更新(bug 修复、改进)
|
||||
- Git 在整个合并图中有正确的公共祖先
|
||||
|
||||
**为什么这具有可扩展性:**即使有几百个技能和每天几次 main 提交,CI 成本也微不足道。Haiku 模型速度快且便宜。一两年以前不可行的方法现在因为 Claude 可以大规模解决冲突而变得实用。
|
||||
|
||||
## 安装流程
|
||||
|
||||
### 新用户(推荐)
|
||||
|
||||
1. 在 GitHub 上 Fork `nanocoai/nanoclaw`(点击 Fork 按钮)
|
||||
2. 克隆你的 fork:
|
||||
```bash
|
||||
git clone https://github.com/<you>/nanoclaw.git
|
||||
cd nanoclaw
|
||||
```
|
||||
3. 运行 Claude Code:
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
4. 运行 `/setup`——Claude 处理依赖项、认证、容器设置、服务配置,并在不存在时添加 `upstream` 远程
|
||||
|
||||
推荐 fork 是因为它为用户提供了一个远程仓库来推送自定义内容。仅克隆可用于试用,但没有远程备份。
|
||||
|
||||
### 从克隆迁移的现有用户
|
||||
|
||||
之前运行了 `git clone https://github.com/nanocoai/nanoclaw.git` 且有本地自定义内容的用户:
|
||||
|
||||
1. 在 GitHub 上 Fork `nanocoai/nanoclaw`
|
||||
2. 重新路由远程仓库:
|
||||
```bash
|
||||
git remote rename origin upstream
|
||||
git remote add origin https://github.com/<you>/nanoclaw.git
|
||||
git push --force origin main
|
||||
```
|
||||
需要 `--force` 是因为新 fork 的 main 是上游最新版,但用户想要他们可能落后的版本。fork 刚刚创建,所以没有可丢失的内容。
|
||||
3. 此后,`origin` = 用户的 fork,`upstream` = nanocoai/nanoclaw
|
||||
|
||||
### 从旧技能引擎迁移的现有用户
|
||||
|
||||
之前通过 `skills-engine/` 系统应用技能的用户在文件树中有技能代码,但没有链接到技能分支的合并提交。Git 不知道这些改动来自技能,因此在其之上合并技能分支会产生冲突或重复。
|
||||
|
||||
**对于今后的新技能:**正常合并技能分支即可。没有问题。
|
||||
|
||||
**对于现有的旧引擎技能**,有两种迁移路径:
|
||||
|
||||
**方案 A:逐技能重新应用(保留你的 fork)**
|
||||
1. 对于每个旧引擎技能:识别并回退旧改动,然后重新合并技能分支
|
||||
2. Claude 协助识别需要回退的内容并解决所有冲突
|
||||
3. 自定义修改(非技能改动)被保留
|
||||
|
||||
**方案 B:全新开始(最干净)**
|
||||
1. 从上游创建新的 fork
|
||||
2. 合并你想要的技能分支
|
||||
3. 手动重新应用你的自定义(非技能)改动
|
||||
4. Claude 通过比较旧 fork 和新 fork 来帮助识别自定义改动
|
||||
|
||||
两种情况都需要:
|
||||
- 删除 `.nanoclaw/` 目录(不再需要)
|
||||
- `skills-engine/` 代码将在所有技能迁移后从上游移除
|
||||
- `/update-skills` 仅跟踪通过分支合并应用的技能——旧引擎技能不会出现在更新检查中
|
||||
|
||||
## 用户工作流
|
||||
|
||||
### 自定义改动
|
||||
|
||||
用户直接在其 main 分支上进行自定义改动。这是标准的 fork 工作流——他们的 `main` 就是他们的定制版本。
|
||||
|
||||
```bash
|
||||
# 进行改动
|
||||
vim src/config.ts
|
||||
git commit -am "将触发词改为 @Bob"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
自定义改动、技能和核心更新共存于他们的 main 分支上。Git 在每一步合并时处理三路合并,因为它可以通过合并历史追溯公共祖先。
|
||||
|
||||
### 应用技能
|
||||
|
||||
在 Claude Code 中运行 `/add-discord`(通过市场插件发现),或手动操作:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/discord
|
||||
git merge upstream/skill/discord
|
||||
# 按照设置指令进行配置
|
||||
git push origin main
|
||||
```
|
||||
|
||||
如果用户在合并技能分支时落后于上游 main,合并可能会同时引入一些核心改动(因为技能分支是向前合并了 main 的)。这通常没问题——他们得到一个兼容的版本。
|
||||
|
||||
### 更新核心
|
||||
|
||||
```bash
|
||||
git fetch upstream main
|
||||
git merge upstream/main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
这与现有 `/update-nanoclaw` 技能的合并路径相同。
|
||||
|
||||
### 更新技能
|
||||
|
||||
运行 `/update-skills` 或让 `/update-nanoclaw` 在核心更新后检查。对于每个有新提交的先前合并的技能分支,Claude 会提出合并更新的建议。
|
||||
|
||||
### 向上游贡献
|
||||
|
||||
想要向上游提交 PR 的用户:
|
||||
|
||||
```bash
|
||||
git fetch upstream main
|
||||
git checkout -b my-fix upstream/main
|
||||
# 进行改动
|
||||
git push origin my-fix
|
||||
# 从 my-fix 创建 PR 到 nanocoai/nanoclaw:main
|
||||
```
|
||||
|
||||
标准的 fork 贡献工作流。他们的自定义改动保留在 main 上,不会泄露到 PR 中。
|
||||
|
||||
## 贡献技能
|
||||
|
||||
以下流程适用于**功能技能**(基于分支)。对于工具型技能(自包含工具)和容器型技能,贡献者直接向 `.claude/skills/<name>/` 或 `container/skills/<name>/` 添加文件的 PR——无需分支提取。所有技能类型请参见 [CONTRIBUTING.md](../CONTRIBUTING.md)。
|
||||
|
||||
### 贡献者流程(功能技能)
|
||||
|
||||
1. Fork `nanocoai/nanoclaw`
|
||||
2. 从 `main` 创建分支
|
||||
3. 进行代码改动(新的频道文件、修改的集成点、更新的 package.json、.env.example 新增内容等)
|
||||
4. 向 `main` 提交 PR
|
||||
|
||||
贡献者提交一个普通的 PR——他们不需要了解技能分支或市场仓库。他们只需进行代码改动并提交。
|
||||
|
||||
### 维护者流程
|
||||
|
||||
当技能 PR 被审查和批准后:
|
||||
|
||||
1. 从 PR 的提交创建一个 `skill/<name>` 分支:
|
||||
```bash
|
||||
git fetch origin pull/<PR_NUMBER>/head:skill/<name>
|
||||
git push origin skill/<name>
|
||||
```
|
||||
2. Force-push 到贡献者的 PR 分支,将其替换为一个单独的提交,将贡献者添加到 `CONTRIBUTORS.md`(移除所有代码改动)
|
||||
3. 将精简后的 PR 合并到 `main`(仅包含贡献者添加)
|
||||
4. 将技能的 SKILL.md 添加到市场仓库 (`nanocoai/nanoclaw-skills`)
|
||||
|
||||
这样:
|
||||
- 贡献者获得合并荣誉(其 PR 被合并)
|
||||
- 他们由维护者自动添加到 CONTRIBUTORS.md 中
|
||||
- 技能分支从其工作中创建
|
||||
- `main` 保持干净(无技能代码)
|
||||
- 贡献者只需要做一件事:提交含有代码改动的 PR
|
||||
|
||||
**注意:**来自 fork 的 GitHub PR 默认选中"允许维护者编辑",因此维护者可以推送到贡献者的 PR 分支。
|
||||
|
||||
### 技能 SKILL.md
|
||||
|
||||
贡献者可以选择提供 SKILL.md(在 PR 中或单独提供)。它放入市场仓库并包含:
|
||||
|
||||
1. 前置信息(名称、描述、触发器)
|
||||
2. 步骤 1:合并技能分支
|
||||
3. 步骤 2-N:交互式设置(创建机器人、获取 token、配置环境变量、验证)
|
||||
|
||||
如果贡献者未提供 SKILL.md,维护者将根据 PR 编写一个。
|
||||
|
||||
## 社区市场
|
||||
|
||||
任何人都可以使用技能分支维护自己的 fork 和自己的市场仓库。这实现了社区驱动的技能生态系统,无需对上游仓库的写入权限。
|
||||
|
||||
### 工作原理
|
||||
|
||||
社区贡献者:
|
||||
|
||||
1. 维护 NanoClaw 的一个 fork(例如 `alice/nanoclaw`)
|
||||
2. 在其 fork 上使用自定义技能创建 `skill/*` 分支
|
||||
3. 创建一个市场仓库(例如 `alice/nanoclaw-skills`),带有 `.claude-plugin/marketplace.json` 和插件结构
|
||||
|
||||
### 添加社区市场
|
||||
|
||||
如果社区贡献者受信任,他们可以提交 PR 将其市场添加到 NanoClaw 的 `.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
},
|
||||
"alice-nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "alice/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
合并后,所有 NanoClaw 用户自动发现社区市场以及官方市场。
|
||||
|
||||
### 安装社区技能
|
||||
|
||||
`/setup` 和 `/customize` 询问用户是否想启用社区技能。如果是,Claude 通过 `claude plugin install` 安装社区市场插件:
|
||||
|
||||
```bash
|
||||
claude plugin install alice-skills@alice-nanoclaw-skills --scope project
|
||||
```
|
||||
|
||||
社区技能热加载并立即可用——无需重启。依赖技能仅在其先决条件满足后才提供(例如,社区 Telegram 附加组件仅在 Telegram 安装后提供)。
|
||||
|
||||
用户也可以通过 `/plugin` 手动浏览和安装社区插件。
|
||||
|
||||
### 该系统的特性
|
||||
|
||||
- **无需审批把关。**任何人都可以在其 fork 上创建技能,无需许可。要列入自动发现的市场才需要批准。
|
||||
- **多个市场共存。**用户在 `/plugin` 中看到来自所有受信任市场的技能。
|
||||
- **社区技能使用相同的合并模式。**SKILL.md 只是指向不同的远程仓库:
|
||||
```bash
|
||||
git remote add alice https://github.com/alice/nanoclaw.git
|
||||
git fetch alice skill/my-cool-feature
|
||||
git merge alice/skill/my-cool-feature
|
||||
```
|
||||
- **用户也可以手动添加市场。**即使未列入 settings.json,用户也可以运行 `/plugin marketplace add alice/nanoclaw-skills` 来发现任何来源的技能。
|
||||
- **CI 是每个 fork 独立的。**每个社区维护者运行自己的 CI 以保持其技能分支向前合并。他们可以使用与上游仓库相同的 GitHub Action。
|
||||
|
||||
## 风味(Flavors)
|
||||
|
||||
风味是 NanoClaw 的精选 fork——为特定用例量身定制的技能、自定义改动和配置的组合(例如,"NanoClaw for Sales"、"NanoClaw Minimal"、"NanoClaw for Developers")。
|
||||
|
||||
### 创建风味
|
||||
|
||||
1. Fork `nanocoai/nanoclaw`
|
||||
2. 合并你想要的技能
|
||||
3. 进行自定义改动(触发词、提示词、集成等)
|
||||
4. 你的 fork 的 `main` 就是风味
|
||||
|
||||
### 安装风味
|
||||
|
||||
在 `/setup` 期间,在任何配置发生之前向用户提供风味选择。设置技能从仓库读取 `flavors.yaml`(随上游发布,始终最新)并呈现选项:
|
||||
|
||||
AskUserQuestion: "从风味开始还是默认 NanoClaw?"
|
||||
- 默认 NanoClaw
|
||||
- NanoClaw for Sales — Gmail + Slack + CRM(由 alice 维护)
|
||||
- NanoClaw Minimal — 仅 Telegram,轻量级(由 bob 维护)
|
||||
|
||||
如果选择了风味:
|
||||
|
||||
```bash
|
||||
git remote add <风味名称> https://github.com/alice/nanoclaw.git
|
||||
git fetch <风味名称> main
|
||||
git merge <风味名称>/main
|
||||
```
|
||||
|
||||
然后设置过程正常继续(依赖项、认证、容器、服务)。
|
||||
|
||||
**此选择仅在全新 fork 时提供**——当用户的 main 与上游 main 匹配或接近且没有本地提交时。如果 `/setup` 检测到显著的本地改动(在现有安装上重新运行 setup),它会跳过风味选择,直接进入配置。
|
||||
|
||||
安装后,用户的 fork 有三个远程仓库:
|
||||
- `origin` — 其 fork(推送自定义内容到此)
|
||||
- `upstream` — `nanocoai/nanoclaw`(核心更新)
|
||||
- `<风味名称>` — 风味 fork(风味更新)
|
||||
|
||||
### 更新风味
|
||||
|
||||
```bash
|
||||
git fetch <风味名称> main
|
||||
git merge <风味名称>/main
|
||||
```
|
||||
|
||||
风味维护者保持其 fork 更新(合并上游、更新技能)。用户以与获取核心更新相同的方式获取风味更新。
|
||||
|
||||
### 风味注册表
|
||||
|
||||
`flavors.yaml` 位于上游仓库中:
|
||||
|
||||
```yaml
|
||||
flavors:
|
||||
- name: NanoClaw for Sales
|
||||
repo: alice/nanoclaw
|
||||
description: Gmail + Slack + CRM 集成,每日流水线摘要
|
||||
maintainer: alice
|
||||
|
||||
- name: NanoClaw Minimal
|
||||
repo: bob/nanoclaw
|
||||
description: 仅 Telegram,无容器开销
|
||||
maintainer: bob
|
||||
```
|
||||
|
||||
任何人都可以提交 PR 来添加自己的风味。该文件在 `/setup` 运行时可本地使用,因为它是克隆仓库的一部分。
|
||||
|
||||
### 可发现性
|
||||
|
||||
- **设置期间**——风味选择作为初始设置流程的一部分提供
|
||||
- **`/browse-flavors` 技能**——随时读取 `flavors.yaml` 并呈现选项
|
||||
- **GitHub 主题**——风味 fork 可以标记 `nanoclaw-flavor` 标签以便搜索
|
||||
- **Discord / 网站**——社区精选列表
|
||||
|
||||
## 迁移
|
||||
|
||||
从旧技能引擎到分支的迁移已完成。所有功能技能现在位于 `skill/*` 分支上,技能引擎已移除。
|
||||
|
||||
### 技能分支
|
||||
|
||||
| 分支 | 基准 | 描述 |
|
||||
|--------|------|-------------|
|
||||
| `skill/whatsapp` | `main` | WhatsApp 频道 |
|
||||
| `skill/telegram` | `main` | Telegram 频道 |
|
||||
| `skill/slack` | `main` | Slack 频道 |
|
||||
| `skill/discord` | `main` | Discord 频道 |
|
||||
| `skill/gmail` | `main` | Gmail 频道 |
|
||||
| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper 语音转录 |
|
||||
| `skill/image-vision` | `skill/whatsapp` | 图片附件处理 |
|
||||
| `skill/pdf-reader` | `skill/whatsapp` | PDF 附件阅读 |
|
||||
| `skill/local-whisper` | `skill/voice-transcription` | 本地 whisper.cpp 转录 |
|
||||
| `skill/ollama-tool` | `main` | Ollama MCP 服务器用于本地模型 |
|
||||
| `skill/apple-container` | `main` | Apple Container 运行时 |
|
||||
| `skill/reactions` | `main` | WhatsApp 表情反应 |
|
||||
|
||||
### 已移除的内容
|
||||
|
||||
- `skills-engine/` 目录(整个引擎)
|
||||
- `scripts/apply-skill.ts`、`scripts/uninstall-skill.ts`、`scripts/rebase.ts`
|
||||
- `scripts/fix-skill-drift.ts`、`scripts/validate-all-skills.ts`
|
||||
- `.github/workflows/skill-drift.yml`、`.github/workflows/skill-pr.yml`
|
||||
- 技能目录中的所有 `add/`、`modify/`、`tests/` 和 `manifest.yaml`
|
||||
- `.nanoclaw/` 状态目录
|
||||
|
||||
操作型技能(`setup`、`debug`、`update-nanoclaw`、`customize`、`update-skills`)保留在 main 的 `.claude/skills/` 中。
|
||||
|
||||
## 变更内容
|
||||
|
||||
### README 快速入门
|
||||
|
||||
之前:
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/NanoClaw.git
|
||||
cd NanoClaw
|
||||
claude
|
||||
```
|
||||
|
||||
之后:
|
||||
```
|
||||
1. 在 GitHub 上 Fork nanocoai/nanoclaw
|
||||
2. git clone https://github.com/<you>/nanoclaw.git
|
||||
3. cd nanoclaw
|
||||
4. claude
|
||||
5. /setup
|
||||
```
|
||||
|
||||
### 设置技能 (`/setup`)
|
||||
|
||||
设置流程的更新:
|
||||
|
||||
- 检查 `upstream` 远程是否存在;如果不存在则添加:`git remote add upstream https://github.com/nanocoai/nanoclaw.git`
|
||||
- 检查 `origin` 是否指向用户的 fork(而非 nanocoai)。如果指向 nanocoai,引导他们完成 fork 迁移。
|
||||
- **安装市场插件:**`claude plugin install nanoclaw-skills@nanoclaw-skills --scope project`——使所有功能技能可用(热加载,无需重启)
|
||||
- **询问要添加哪些频道:**呈现频道选项(Discord、Telegram、Slack、WhatsApp、Gmail),为选中的频道运行相应的 `/add-*` 技能
|
||||
- **提供依赖技能:**频道设置后,提供相关附加组件(例如 Telegram 后的 Agent Swarm、WhatsApp 后的语音转录)
|
||||
- **可选启用社区市场:**询问用户是否想要社区技能,同时安装这些市场插件
|
||||
|
||||
### `.claude/settings.json`
|
||||
|
||||
市场配置,使官方市场自动注册:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### main 上的技能目录
|
||||
|
||||
`main` 上的 `.claude/skills/` 目录仅保留操作型技能(setup、debug、update-nanoclaw、customize、update-skills)。功能技能(add-discord、add-telegram 等)位于市场仓库中,通过 `/setup` 或 `/customize` 期间的 `claude plugin install` 安装。
|
||||
|
||||
### 技能引擎移除
|
||||
|
||||
以下内容可以移除:
|
||||
|
||||
- `skills-engine/`——整个目录(应用、合并、重放、状态、备份等)
|
||||
- `scripts/apply-skill.ts`
|
||||
- `scripts/uninstall-skill.ts`
|
||||
- `scripts/fix-skill-drift.ts`
|
||||
- `scripts/validate-all-skills.ts`
|
||||
- `.nanoclaw/`——状态目录
|
||||
- 所有技能目录中的 `add/` 和 `modify/` 子目录
|
||||
- main 上 `.claude/skills/` 中的功能技能 SKILL.md 文件(它们现在位于市场中)
|
||||
|
||||
操作型技能(`setup`、`debug`、`update-nanoclaw`、`customize`、`update-skills`)保留在 main 的 `.claude/skills/` 中。
|
||||
|
||||
### 新的基础设施
|
||||
|
||||
- **市场仓库** (`nanocoai/nanoclaw-skills`)——绑定所有功能技能的 SKILL.md 文件的单一 Claude Code 插件
|
||||
- **CI GitHub Action**——每次推送到 `main` 时将 `main` 向前合并到所有 `skill/*` 分支,使用 Claude (Haiku) 解决冲突
|
||||
- **`/update-skills` 技能**——使用 git 历史检查并应用技能分支更新
|
||||
- **`CONTRIBUTORS.md`**——追踪技能贡献者
|
||||
|
||||
### 更新技能 (`/update-nanoclaw`)
|
||||
|
||||
采用基于分支的方法后更新技能变得更简单。旧的技能引擎需要在合并核心更新后重放所有已应用的技能——这整步消失了。技能改动已经在用户的 git 历史中,所以 `git merge upstream/main` 直接就能使用。
|
||||
|
||||
**保持不变的内容:**
|
||||
- 预检(干净的工作树、upstream 远程)
|
||||
- 备份分支 + 标签
|
||||
- 预览(git log、git diff、文件分桶)
|
||||
- 合并/拣选/变基选项
|
||||
- 冲突预览(dry-run 合并)
|
||||
- 冲突解决
|
||||
- 构建 + 测试验证
|
||||
- 回滚指令
|
||||
|
||||
**移除的内容:**
|
||||
- 技能重放步骤(旧技能引擎需要在核心更新后重新应用技能)
|
||||
- 重新运行结构化操作(npm 依赖项、env 变量——这些现在是 git 历史的一部分)
|
||||
|
||||
**新增的内容:**
|
||||
- 末尾的可选步骤:"检查技能更新?",运行 `/update-skills` 逻辑
|
||||
- 它检查任何先前合并的技能分支是否有新提交(bug 修复、技能本身的改进——不仅仅是来自 main 的向前合并)
|
||||
|
||||
**为什么核心更新后用户不需要重新合并技能:**
|
||||
当用户合并了一个技能分支时,这些改动成为其 git 历史的一部分。当他们之后合并 `upstream/main` 时,git 执行普通的三路合并——其文件树中的技能改动不受影响,只引入核心改动。向前合并 CI 确保技能分支保持与最新 main 兼容,但这是为新用户全新应用技能准备的。已经合并了该技能的现有用户不需要做任何事。
|
||||
|
||||
用户只有在技能本身被更新时(不仅仅是向前合并了 main)才需要重新合并技能分支。`/update-skills` 检查会检测到这一点。
|
||||
|
||||
## Discord 公告
|
||||
|
||||
### 致现有用户
|
||||
|
||||
> **技能现在是 git 分支**
|
||||
>
|
||||
> 我们简化了 NanoClaw 中技能的工作方式。技能现在是你可以合并进来的 git 分支,而非自定义技能引擎。
|
||||
>
|
||||
> **这对你意味着什么:**
|
||||
> - 应用技能:`git fetch upstream skill/discord && git merge upstream/skill/discord`
|
||||
> - 更新核心:`git fetch upstream main && git merge upstream/main`
|
||||
> - 检查技能更新:`/update-skills`
|
||||
> - 不再有 `.nanoclaw/` 状态目录或技能引擎
|
||||
>
|
||||
> **我们现在推荐 fork 而非克隆。**这给你一个远程仓库来推送自定义内容。
|
||||
>
|
||||
> **如果你目前有带本地改动的克隆**,迁移到 fork:
|
||||
> 1. 在 GitHub 上 Fork `nanocoai/nanoclaw`
|
||||
> 2. 运行:
|
||||
> ```
|
||||
> git remote rename origin upstream
|
||||
> git remote add origin https://github.com/<you>/nanoclaw.git
|
||||
> git push --force origin main
|
||||
> ```
|
||||
> 即使你非常落后也可以——只需推送当前状态。
|
||||
>
|
||||
> **如果你之前通过旧系统应用了技能**,代码改动已经在你的工作树中——无需重做。你可以删除 `.nanoclaw/` 目录。未来的技能和更新使用基于分支的方法。
|
||||
>
|
||||
> **发现技能:**技能现在通过 Claude Code 的插件市场可用。在 Claude Code 中运行 `/plugin` 浏览和安装可用技能。
|
||||
|
||||
### 致技能贡献者
|
||||
|
||||
> **贡献技能**
|
||||
>
|
||||
> 贡献一个技能:
|
||||
> 1. Fork `nanocoai/nanoclaw`
|
||||
> 2. 从 `main` 创建分支并进行代码改动
|
||||
> 3. 提交一个普通的 PR
|
||||
>
|
||||
> 就这样。我们将从你的 PR 创建 `skill/<name>` 分支,将你添加到 CONTRIBUTORS.md,并将 SKILL.md 添加到市场。CI 自动保持技能分支与 `main` 向前合并,使用 Claude 解决任何冲突。
|
||||
>
|
||||
> **想运行你自己的技能市场?**在你的 fork 上维护技能分支并创建一个市场仓库。提交 PR 将其添加到 NanoClaw 的自动发现市场中——或者用户可以通过 `/plugin marketplace add` 手动添加。
|
||||
172
docs/zh/v1-to-v2-changes.md
Normal file
172
docs/zh/v1-to-v2-changes.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# NanoClaw v1 → v2 — 变更内容
|
||||
|
||||
NanoClaw v1(你一直在运行的 `~/nanoclaw` 检出)与 v2(本次重写)之间的宏观差异。这不是迁移指南——那是 `bash migrate-v2.sh` 和 `/migrate-from-v1` 技能的角色。本文档是**词汇表**:当某些东西被移动或重命名时,在这里找到它。
|
||||
|
||||
在接触迁移代码或将定制向前移植之前,请阅读本文档。
|
||||
|
||||
---
|
||||
|
||||
## 一句话总结
|
||||
|
||||
v1 是一个 Node 进程,带有一个 SQLite 文件和原生频道适配器。v2 是一个生成每个 session(会话)Docker 容器的宿主机,将状态拆分到中央库 + 每个 session 的 DB 对中,通过显式实体模型路由,并将频道作为技能从兄弟分支安装。
|
||||
|
||||
---
|
||||
|
||||
## 实体模型——最大的变化
|
||||
|
||||
**v1:** 一张扁平的 `registered_groups(jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name)` 表。一个 group 目录是 agent 身份的单位。一个聊天(JID)连接到恰好一个目录,`trigger_pattern` 是路由器应用于每条入站消息的不透明正则表达式。
|
||||
|
||||
**v2:** 三张表,中间有一个有意的多对多关系:
|
||||
|
||||
```
|
||||
agent_groups ─┐
|
||||
├─ messaging_group_agents ─┬─ messaging_groups
|
||||
│ (engage_mode, │ (channel_type,
|
||||
│ engage_pattern, │ platform_id,
|
||||
│ sender_scope, │ unknown_sender_policy)
|
||||
│ ignored_message_policy,
|
||||
│ session_mode, priority)
|
||||
```
|
||||
|
||||
后果:
|
||||
|
||||
- **一个 agent 可以在多个聊天上应答,一个聊天可以扇出到多个 agent。** v1 两者都做不到。
|
||||
- **没有 `is_main` 标志。** 权限现在通过 `user_roles`(owner/admin,全局或限定范围)显式化。见下文。
|
||||
- **没有 `trigger_pattern` 正则表达式。** 替换为四个正交列。自动迁移和 `/migrate-from-v1` 技能使用的映射规则:
|
||||
- v1 `trigger_pattern` 非空 → v2 `engage_mode='pattern'`、`engage_pattern = <the regex>`
|
||||
- v1 `requires_trigger=0` 或 pattern 为 `.`/`.*` → v2 `engage_mode='pattern'`、`engage_pattern='.'`("始终"变体)
|
||||
- 无 pattern 且需要触发器 → v2 `engage_mode='mention'`
|
||||
- `sender_scope` 和 `ignored_message_policy` 是新的;默认值 `all` / `drop`
|
||||
- **JID 分解。** v1 的 `jid` 列存储 `dc:12345` / `tg:67890`。v2 将其拆分为 `channel_type` + `platform_id`。具体来说:`dc:12345` 变为 `channel_type='discord'`、`platform_id='discord:12345'`。前缀别名(`dc` → `discord`、`tg` → `telegram`、`wa` → `whatsapp`)在 `setup/migrate-v2/shared.ts` 中。
|
||||
- **v1 中 `channel_name` 不可靠。** 许多行的该列为空;实际频道只能从 JID 前缀猜测。v2 的 `channel_type` 始终是显式的。
|
||||
|
||||
---
|
||||
|
||||
## 中央库 vs. Session DB
|
||||
|
||||
**v1:** 一个 SQLite 文件位于 `store/messages.db`。每个聊天、消息、注册 group、计划任务和 session 都存在那里。宿主机和任何 agent 进程都打开同一个文件。
|
||||
|
||||
**v2:** 三种 DB 形态。
|
||||
|
||||
1. `data/v2.db`——**中央库**。所有非 per-session 的东西:users、roles、agent groups、messaging groups、连线、pending approvals、user DMs、schema 迁移。
|
||||
2. `data/v2-sessions/<session_id>/inbound.db`——**宿主机写入,容器读取**。`messages_in`、路由、destinations、pending questions、processing_ack。计划任务存于此处(见"调度")。
|
||||
3. `data/v2-sessions/<session_id>/outbound.db`——**容器写入,宿主机读取**。`messages_out`、session_state。
|
||||
|
||||
每个文件有且仅有一个写入者。无跨挂载锁竞争。心跳是对 `/workspace/.heartbeat` 的文件 touch,而非 DB 更新。宿主机使用偶数 `seq` 号,容器使用奇数。
|
||||
|
||||
消息历史(v1 `messages` 表、v1 `chats` 表)**不被迁移**。迁移将运行上重要的状态向前复制(agent、频道、连线、计划任务、group 目录),留下聊天日志。
|
||||
|
||||
---
|
||||
|
||||
## 调度
|
||||
|
||||
**v1:** 在 `store/messages.db` 中有专门的 `scheduled_tasks` 表,拥有自己的列(`schedule_type`、`schedule_value`、`next_run`、`last_run`、`context_mode`、`script`、`status`)。一个独立的类似 cron 的调度进程从中读取。
|
||||
|
||||
**v2:** 计划任务是 session `inbound.db` 中带有 `kind='task'` 的 **`messages_in` 行**。相关列:
|
||||
- `process_after`(ISO8601)——宿主机 sweep 在 `datetime(process_after) <= datetime('now')` 时唤醒容器
|
||||
- `recurrence`——cron 字符串;`NULL` = 单次执行
|
||||
- `series_id`——将重复事件分组;首次插入时设置为 task id
|
||||
- `status`——`pending` | `processing` | `completed` | `failed` | `paused`
|
||||
|
||||
公共 API 是 `src/modules/scheduling/db.ts` 中的 `insertTask()`。重复执行通过 `cron-parser` 在用户时区计算(见 `src/modules/scheduling/recurrence.ts`)。迁移将 v1 的 `schedule_type`+`schedule_value` 对映射为单个 cron 字符串,然后调用 `insertTask()`。
|
||||
|
||||
任务可以在 session 唤醒之前存在——宿主机 sweep 在首次到期时创建/唤醒容器。
|
||||
|
||||
---
|
||||
|
||||
## 凭据
|
||||
|
||||
**v1:** `.env`——明文环境变量。`DISCORD_BOT_TOKEN`、`ANTHROPIC_API_KEY` 等。宿主机直接读取它们并传递给任何需要它们的代码。
|
||||
|
||||
**v2:** OneCLI Agent Vault。一个位于 `http://127.0.0.1:10254` 的独立本地服务持有秘密。Agent 被*限定*到特定秘密,vault 在批准后将它们注入到离开容器的 API 请求中。容器永远看不到原始秘密值。
|
||||
|
||||
注意:自动创建的 agent 默认为 `selective` 秘密模式——没有附加秘密,即使匹配的秘密存在于 vault 中。关于修复方案(`onecli agents set-secret-mode --mode all`),见根 CLAUDE.md 中"auto-created agents start in selective secret mode"部分。
|
||||
|
||||
**自动迁移做了什么:** 将每个 v1 `.env` 键逐字复制到 v2 `.env`,永不覆盖现有的 v2 键。OneCLI vault 迁移是一个单独的步骤,由 `/init-onecli` 技能处理,该技能知道如何从 `.env` 中拉取。
|
||||
|
||||
---
|
||||
|
||||
## 频道适配器
|
||||
|
||||
**v1:** 在 `src/channels/` 中导入的原生适配器(例如直接使用 `discord.js`)。安装一个频道意味着编辑代码、添加依赖并设置环境变量。
|
||||
|
||||
**v2:** 频道适配器存在于兄弟 `channels` 分支上。每个 `/add-<channel>` 技能:
|
||||
1. `git fetch origin channels`
|
||||
2. `git show channels:src/channels/<name>.ts > src/channels/<name>.ts`
|
||||
3. 将 `import './<name>.js';` 追加到 `src/channels/index.ts`
|
||||
4. `pnpm install @chat-adapter/<name>@<pinned>`
|
||||
5. `pnpm run build`
|
||||
|
||||
幂等——重新运行为无操作。固定版本保持供应链诚信。自动迁移检测 v1 中哪些频道已被连接(通过不同的 `channel_name` / JID 前缀),并为每个频道运行匹配的 `setup/install-<channel>.sh`。v1 中没有 v2 技能的频道(目前少见,随着 v2 的完善将更常见)被记录在交接文件中,由 `/migrate-from-v1` 技能向用户提出。
|
||||
|
||||
**`.env` 之外的频道认证。** 某些频道在磁盘上存储 session 状态(Baileys WhatsApp 密钥库、Matrix 同步状态、iMessage tokens)。`channel-auth` 步骤有一个按频道的注册表(`setup/migrate-v2/shared.ts: CHANNEL_AUTH_REGISTRY`),知道除了 env 键之外还要复制哪些文件 glob。
|
||||
|
||||
---
|
||||
|
||||
## 权限——从隐式到显式
|
||||
|
||||
**v1:** `registered_groups.is_main = 1` 标记一个 group 为特权级别。没有 `users` 表。权限是约定而非强制执行。
|
||||
|
||||
**v2:** 显式表。
|
||||
- `users(id = "<channel_type>:<handle>", kind, display_name)`——每个消息平台标识符一行
|
||||
- `user_roles(user_id, role ∈ {owner, admin}, agent_group_id nullable, granted_by, granted_at)`——owner 始终全局;admin 可以是全局或限定范围
|
||||
- `agent_group_members(user_id, agent_group_id, ...)`——用于 `sender_scope='known'` 关卡的"已知"成员资格
|
||||
|
||||
Owner 在 `/migrate-from-v1` 技能的访谈阶段("哪个 handle 是你?")被种子。自动迁移不会猜测——v1 没有这方面的真源。
|
||||
|
||||
**默认访问——"任何人都可以与 bot 通话" vs "仅已知用户"。** v1 隐式存储(通过触发器 regex + `is_main`)。v2 通过 `messaging_groups.unknown_sender_policy ∈ {'strict', 'request_approval', 'public'}` 暴露它。技能询问用户 v1 以哪种模式运行,并相应地翻转已迁移的 messaging group。
|
||||
|
||||
---
|
||||
|
||||
## 磁盘上的 Group 目录
|
||||
|
||||
**v1:** `groups/<folder>/CLAUDE.md` 和可选的 `logs/`。`CLAUDE.md` 是一个纯指令文件,group 特定。
|
||||
|
||||
**v2:** 每个 group 仍然位于 `groups/<folder>/`,但形态更丰富:
|
||||
- `CLAUDE.md`——**在容器启动时组合**,来自 `.claude-shared.md`(到全局的 symlink)+ `.claude-fragments/*.md`(模块片段)+ `CLAUDE.local.md`。**不要直接编辑 `CLAUDE.md`。**
|
||||
- `CLAUDE.local.md`——每个 group 的内容。迁移将 v1 的旧 `CLAUDE.md` 写到这里。
|
||||
- `container.json`——可选的每个 group 的容器配置(apt 依赖、env、挂载)。v1 的 `registered_groups.container_config` JSON 接近但不完全相同——迁移将 v1 的 payload 存储在 `groups/<folder>/.v1-container-config.json` 中供技能协调,而不是静默映射。
|
||||
- `.claude-fragments/` 和 `.claude-shared.md` 在宿主机首次接触到该 group 时由 `initGroupFilesystem()` 安装,因此迁移只需写入 `CLAUDE.local.md`,将脚手架工作留给宿主机。
|
||||
|
||||
---
|
||||
|
||||
## 宿主机进程 vs. 容器
|
||||
|
||||
**v1:** 单个 Node 进程。"Agent"与路由器是同一个进程。
|
||||
|
||||
**v2:** 顶层的 Node 宿主机,每个 session 一个 Bun 运行时的 Docker 容器。它们仅通过两个 session DB 通信。无共享模块,无 IPC,无 stdin 管道。如果你编写了从 agent 进入宿主机内部(或反之)的自定义代码,那个接口已不复存在——移植它是 `/migrate-from-v1` 技能的话题,而非机械复制。
|
||||
|
||||
锁文件:宿主机使用 `pnpm-lock.yaml`,agent-runner 使用 `bun.lock`。宿主机侧 `minimumReleaseAge: 4320`(3 天供应链等待);agent-runner 没有发布年龄限制。
|
||||
|
||||
---
|
||||
|
||||
## 自我修改和 MCP 工具
|
||||
|
||||
**v1:** 如果你添加了 MCP 服务器或自我修改管道,通常是直接编辑长期运行的进程。
|
||||
|
||||
**v2:**
|
||||
- MCP 服务器通过 `container/agent-runner/src/mcp-tools/*.ts` 注册并按 session 加载。还有 `install_packages` 和 `add_mcp_server` 自我修改工具,在重建容器镜像之前经过管理员审批流程(`src/modules/self-mod/apply.ts`)。
|
||||
- 你在 v1 中编写的自定义 MCP 工具可以清晰地映射到 v2 的工具注册表,但导入路径、运行时(Bun vs Node)和 SQL 辅助函数差异(`bun:sqlite` 使用 `$name` 前缀的参数)可能需要调整。技能将引导你完成这些。
|
||||
|
||||
---
|
||||
|
||||
## 已消失或无法映射的内容
|
||||
|
||||
- **`scheduled_tasks` 作为一个单独的表的模式**——已移入 session `inbound.db` 的 `kind='task'` 下。迁移移植活跃行;非活跃/已完成的行导出到 `logs/setup-migration/inactive-tasks.json` 供参考。
|
||||
- **`messages` / `chats` 表(聊天历史)**——不迁移。如需要,保留在 v1 检出中。
|
||||
- **`router_state`(键/值)**——不迁移。v2 状态存于上述显式表中。
|
||||
- **`sessions`(v1 group→session_id)**——v1 session 不映射;v2 session 以 `(agent_group_id, messaging_group_id, thread_id)` 为键并按需创建。
|
||||
- **对旧 `store/messages.db` 的原始访问**——v1 DB 被保留在原位且不被触碰。如果迁移出错可以重新运行(对于 agent/频道/连线,迁移子步是幂等的;目录使用 rsync 语义)。
|
||||
|
||||
---
|
||||
|
||||
## 迁移范围——代码所在位置
|
||||
|
||||
- `migrate-v2.sh`——入口点:从 v2 检出运行 `bash migrate-v2.sh`。
|
||||
- `setup/migrate-v2/*.ts`——各个迁移步骤(env、db、groups、sessions、tasks、channel-auth、select-channels、switchover-prompt)。
|
||||
- `setup/migrate-v2/shared.ts`——JID 解析、触发器映射、频道认证注册表。
|
||||
- `logs/setup-migration/handoff.json`——由 `migrate-v2.sh` 写入,由 `/migrate-from-v1` 技能读取。
|
||||
- `logs/migrate-steps/*.log`——每个步骤的原始 stdout。
|
||||
- `.claude/skills/migrate-from-v1/SKILL.md`——用于 owner 种子、CLAUDE.md 清理、容器配置验证、fork 移植的 Claude 技能。
|
||||
- `migrate-v2-reset.sh`——开发辅助工具,清除 v2 状态以重新测试。
|
||||
- 完整开发指南见 [docs/migration-dev.md](migration-dev.md)。
|
||||
Reference in New Issue
Block a user