添加中文文档

This commit is contained in:
2026-05-12 13:14:17 +00:00
parent 61d7ca6bba
commit 38bb076ac6
24 changed files with 6876 additions and 0 deletions

148
CONTRIBUTING.zh-CN.md Normal file
View File

@@ -0,0 +1,148 @@
# 贡献指南
## 开始之前
1. **检查是否已有相关工作。** 开始前先搜索已有的 PR 和 Issue
```bash
gh pr list --repo nanocoai/nanoclaw --search "<你的功能>"
gh issue list --repo nanocoai/nanoclaw --search "<你的功能>"
```
如果已有相关的 PR 或 Issue请在其基础上推进而不是重复劳动。
2. **检查理念一致性。** 阅读 [README.md 中的理念章节](README.md#philosophy)。源代码改动应仅限于 90% 以上用户都需要的功能。Skills 可以小众一些,但仍应具备超出单用户场景的通用性。
3. **一个 PR 只做一件事。** 每个 PR 只做一件事——修复一个 bug、添加一个 skill、做一项简化。不要在同一个 PR 中混杂无关的改动。
## 源代码改动
**接受:** Bug 修复、安全修复、简化、减少代码。
**不接受:** 新功能、新能力、兼容性适配、功能增强。这些应通过 skill 实现。
## Skills
NanoClaw 使用 [Claude Code skills](https://code.claude.com/docs/en/skills)——带可选附属文件的 Markdown 文件,用于教会 Claude 如何完成某项任务。NanoClaw 中有四种类型的 skill各有不同用途。
### 为什么用 skill
每个用户都应该拥有简洁、最小化的代码只包含他们真正需要的功能。Skill 让用户可以有选择地为自己的 fork 添加功能,而不必继承他们不需要的功能代码。
### Skill 类型
#### 1. 功能 Skill基于分支
通过合并 Git 分支来为 NanoClaw 添加能力。SKILL.md 包含设置说明;实际代码位于 `skill/*` 分支上。
**位置:** `.claude/skills/`(仅在 `main` 分支上有说明文档),代码在 `skill/*` 分支
**示例:** `/add-telegram`、`/add-slack`、`/add-discord`、`/add-gmail`
**工作方式:**
1. 用户执行 `/add-telegram`
2. Claude 按照 SKILL.md 操作fetch 并合并 `skill/telegram` 分支
3. Claude 引导完成交互式设置环境变量、Bot 创建等)
**贡献功能 skill**
1. Fork `nanocoai/nanoclaw`,基于 `main` 创建分支
2. 进行代码改动(新增文件、修改源码、更新 `package.json` 等)
3. 在 `.claude/skills/<name>/` 中添加 SKILL.md包含设置说明——第一步应该是合并该分支
4. 提交 PR。我们会基于你的工作创建 `skill/<name>` 分支
参考 `/add-telegram` 作为范例。完整系统设计见 [docs/skills-as-branches.md](docs/skills-as-branches.md)。
#### 2. 工具 Skill含代码文件
独立的工具,在 SKILL.md 之外还附带代码文件。SKILL.md 告诉 Claude 如何安装该工具;代码位于 skill 目录本身(如 `scripts/` 子目录中)。
**位置:** `.claude/skills/<name>/`,包含附属文件
**示例:** `/claw`Python CLI位于 `scripts/claw`
**与功能 skill 的关键区别:** 无需合并分支。代码是自包含在 skill 目录中的,安装时复制到目标位置。
**规范:**
- 将代码放在单独的文件中,不要内联在 SKILL.md 里
- 使用 `${CLAUDE_SKILL_DIR}` 引用 skill 目录中的文件
- SKILL.md 包含安装说明、使用文档和故障排除
#### 3. 操作 Skill纯指令
不涉及代码改动的流程和指南。SKILL.md 就是整个 skill——Claude 按照指令完成某项任务。
**位置:** `.claude/skills/`(在 `main` 分支上)
**示例:** `/setup`、`/debug`、`/customize`、`/update-nanoclaw`、`/update-skills`
**规范:**
- 纯指令——没有代码文件,没有分支合并
- 使用 `AskUserQuestion` 进行交互式提示
- 这些 skill 保留在 `main` 分支上,所有用户始终可用
#### 4. 容器 SkillAgent 运行时)
在 agent 容器内部运行的 skill而非在宿主机上运行。这些 skill 教会容器内的 agent 如何使用工具、格式化输出或执行任务。容器启动时,它们会被同步到每个 agent group 的 `.claude/skills/` 目录中。
**位置:** `container/skills/<name>/`
**示例:** `agent-browser`(网页浏览)、`capabilities`/capabilities 命令)、`status`/status 命令)、`slack-formatting`Slack mrkdwn 语法)
**关键区别:** 这些 skill 不由用户在宿主机上调用。它们由容器内的 Claude Code 加载,影响 agent 的行为方式。
**规范:**
- 遵循相同的 SKILL.md + frontmatter 格式
- 使用 `allowed-tools` frontmatter 来限定工具权限范围
- 保持专注——agent 的上下文窗口由所有容器 skill 共享
### SKILL.md 格式
所有 skill 都遵循 [Claude Code skills 标准](https://code.claude.com/docs/en/skills)
```markdown
---
name: my-skill
description: 这个 skill 做什么以及何时使用。
---
这里是说明...
```
**规则:**
- SKILL.md 保持在 **500 行以内**——将细节移到单独的参考文件中
- `name`:小写字母、数字和连字符,最多 64 个字符
- `description`必填——Claude 据此判断何时调用该 skill
- 将代码放在单独的文件中,不要内联在 Markdown 中
- 所有可用的 frontmatter 字段见 [skills 标准](https://code.claude.com/docs/en/skills)
## 测试
在提交前,在一个全新 clone 的环境中测试你的贡献。对于 skill要完整地跑一遍端到端流程并验证其正常工作。
## Pull Request
### 提交前
1. **关联相关 Issue。** 如果你的 PR 解决了某个未关闭的 Issue在描述中加入 `Closes #123`,这样合并后 Issue 会自动关闭。
2. **充分测试。** 亲自运行该功能。对于 skill在全新 clone 中测试。
3. **检查安装专属文件。** 创建 PR 前,确认 diff 中没有安装专属文件(参见 CLAUDE.md 中的 PR Hygiene 章节)。
4. **勾选正确的选项框。** 在 PR 模板中进行选择,标签会根据你的选择自动应用:
| 选项框 | 标签 |
|--------|------|
| 功能 Skill | `PR: Skill` + `PR: Feature` |
| 工具 Skill | `PR: Skill` |
| 操作/容器 Skill | `PR: Skill` |
| 修复 | `PR: Fix` |
| 简化 | `PR: Refactor` |
| 文档 | `PR: Docs` |
### PR 描述
保持简洁。删除不适用的模板章节。描述应包含:
- **是什么**——PR 添加或改动了什么
- **为什么**——动机是什么
- **如何实现**——简要说明实现方式
- **如何测试**——你做了什么来验证它能正常工作
- **如何使用**——用户如何调用(针对 skill
不要堆砌文字。几句清晰的话比冗长的段落更好。

View 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
├── NATpfctl将 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

View 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
View 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
View 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 进行定制,最终得到做他们所需事情的干净代码——而不是一个试图同时支持每个人用例的臃肿系统。
---
## RFSRequest 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
View File

@@ -0,0 +1,643 @@
# Claude Agent SDK 深度剖析
`@anthropic-ai/claude-agent-sdk` v0.2.290.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.jsongitignored
```
省略时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`
### 情况 3Agent 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
View File

@@ -0,0 +1,161 @@
# NanoClaw 安全模型
## 信任模型
| 实体 | 信任级别 | 理由 |
|--------|-------------|-----------|
| 主群组Main group | 受信任 | 私人自我聊天,管理员控制 |
| 非主群组Non-main groups | 不受信任 | 其他用户可能是恶意的 |
| 容器 agentContainer 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`(只读) | 无 |
| StoreSQLite 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. 自动化 agentClaude、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
View 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 | ContainersLinux 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/ # 编译后的 JavaScriptgitignored
├── .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/ # 容器 IPCmessages/、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` 文件中配置认证。有两种选择:
**选项1Claude 订阅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 使用 BaileysTelegram 使用 Bot API
3. 消息存入 SQLitestore/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/nodesessions 必须位于 `/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
```

View File

@@ -0,0 +1,749 @@
# NanoClaw Agent-Runner 详解
容器内 agent-runner代理运行器的实现级细节。高层设计请参见 [architecture.md](architecture.md)。
## 关注点分离
agent-runner 分为两层:
1. **Agent-runner 核心** — 负责 poll loop轮询循环、消息格式化、数据库读写、MCP toolMCP 工具)实现、路由、状态管理、媒体处理。此部分是 NanoClaw 专有的,跨所有 provider提供器共享。
2. **Agent provider代理提供器** — 负责 SDK 交互。接收格式化后的提示词,将其推送到 SDK并回传事件。主干代码内置 `claude` provider其他 providerOpenCode、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 serverMCP 服务器)配置(标准化格式 — 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 在内部注册 hooksPreCompact、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 仍在产出事件。对于其他 provideragent-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 serverMCP 服务器),向 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 的 provideragent-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
View 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 BridgeChat SDK 桥接)
封装一个 Chat SDK adapter + Chat 实例以符合 NanoClaw `ChannelAdapter` 接口。主干代码仅内置桥接层和 channel registry通道注册表 — 平台特定的 Chat SDK adapterDiscord、Slack、Telegram 等)和原生 adapterWhatsApp/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

View 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 轮询(活跃)/ 60ssweep
D->>D: hasDestination() 重新验证
D->>B: 通过适配器投递
B->>P: 发送消息 / 编辑 / 反应 / 文件 / 卡片
```
## 命名目的地 + Agent 到 Agent
```mermaid
flowchart LR
subgraph AgentA["Agent Group A (主agent)"]
A_out["输出:<br/>&lt;message to='slack'&gt;...&lt;/message&gt;<br/>&lt;message to='browser-agent'&gt;...&lt;/message&gt;<br/>&lt;internal&gt;scratchpad&lt;/internal&gt;"]
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
View 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 实现的流式传输
- **WhatsAppCloud 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 不适用的通道(例如,使用缓冲流的 WhatsAppchannel 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_outagent 永远不会看到这些字段)
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` 可以指定特定 sessionnull = 查找或创建默认 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.dbWAL 模式
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, -- 平台特定 IDJID、频道 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 工具、消息格式化、媒体处理、提供商实现

View File

@@ -0,0 +1,80 @@
# 构建与运行时
NanoClaw 运行的是分拆式技术栈:宿主机使用 Node + pnpmagent 容器使用 Bun。它们之间仅通过每个 session会话的两个 SQLite 文件来通信——它们之间没有共享模块,这也是它们能干净地使用不同运行时的原因。
## 为什么分拆
- **宿主机保持使用 Node**,因为 BaileysWhatsApp依赖 `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` 未变更时的重建速度很快。需要 BuildKitDocker 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'`。绕过 tiniDocker 默认的 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
View 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
View 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
View 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
View 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 密钥。
> **注意:** 本指南基于在 macOSApple 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
// 创建一个空文件来影子 .envDocker 沙盒拒绝 /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
```

View 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
View 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 # 阶段 2aclack 多选
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
View 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 |
| 冷启动 | 530 秒(模型加载) | ~1 秒 |
| 上下文窗口 | 因模型而异 | 200k tokensSonnet |
| 工具调用可靠性 | 良好(大型模型) | 出色 |
| 硬件要求 | 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
View 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
```
设计约束:
- 开始行带有起始时间戳UTCISO-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` | 顶层封装。阶段 1bootstrap和阶段 2setup:auto编排。写入 bootstrap 的原始日志 + 进度条目。 |
| `setup.sh` | 阶段 1 bootstrapNode、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
View 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` 检查所有频道 tokenDISCORD_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 读写) |

View 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
View 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)。