diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md new file mode 100644 index 0000000..d70f2a9 --- /dev/null +++ b/CONTRIBUTING.zh-CN.md @@ -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//` 中添加 SKILL.md,包含设置说明——第一步应该是合并该分支 +4. 提交 PR。我们会基于你的工作创建 `skill/` 分支 + +参考 `/add-telegram` 作为范例。完整系统设计见 [docs/skills-as-branches.md](docs/skills-as-branches.md)。 + +#### 2. 工具 Skill(含代码文件) + +独立的工具,在 SKILL.md 之外还附带代码文件。SKILL.md 告诉 Claude 如何安装该工具;代码位于 skill 目录本身(如 `scripts/` 子目录中)。 + +**位置:** `.claude/skills//`,包含附属文件 + +**示例:** `/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. 容器 Skill(Agent 运行时) + +在 agent 容器内部运行的 skill,而非在宿主机上运行。这些 skill 教会容器内的 agent 如何使用工具、格式化输出或执行任务。容器启动时,它们会被同步到每个 agent group 的 `.claude/skills/` 目录中。 + +**位置:** `container/skills//` + +**示例:** `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) + +不要堆砌文字。几句清晰的话比冗长的段落更好。 diff --git a/docs/zh/APPLE-CONTAINER-NETWORKING.md b/docs/zh/APPLE-CONTAINER-NETWORKING.md new file mode 100644 index 0000000..f7cae8c --- /dev/null +++ b/docs/zh/APPLE-CONTAINER-NETWORKING.md @@ -0,0 +1,90 @@ +# Apple Container 网络设置 (macOS 26) + +Apple Container 的 vmnet 网络需要手动配置,容器才能访问互联网。否则容器可以与宿主机通信,但无法访问外部服务(DNS、HTTPS、API)。 + +## 快速设置 + +运行以下两条命令(需要 `sudo`): + +```bash +# 1. 启用 IP 转发,以便宿主机路由容器流量 +sudo sysctl -w net.inet.ip.forwarding=1 + +# 2. 启用 NAT,以便容器流量通过你的互联网接口进行伪装 +echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef - +``` + +> **注意:** 将 `en0` 替换为你的活跃互联网接口。使用 `route get 8.8.8.8 | grep interface` 检查。 + +## 持久化设置 + +这些设置在重启后会重置。要使其永久生效: + +**IP 转发**——添加到 `/etc/sysctl.conf`: +``` +net.inet.ip.forwarding=1 +``` + +**NAT 规则**——添加到 `/etc/pf.conf`(在任何现有规则之前): +``` +nat on en0 from 192.168.64.0/24 to any -> (en0) +``` + +然后重新加载:`sudo pfctl -f /etc/pf.conf` + +## IPv6 DNS 问题 + +默认情况下,DNS 解析器先返回 IPv6 (AAAA) 记录,然后才返回 IPv4 (A) 记录。由于我们的 NAT 仅处理 IPv4,容器内的 Node.js 应用将首先尝试 IPv6 然后失败。 + +容器镜像和运行器通过以下方式配置为优先使用 IPv4: +``` +NODE_OPTIONS=--dns-result-order=ipv4first +``` + +这在 `Dockerfile` 中设置,并通过 `-e` 标志在 `container-runner.ts` 中传递。 + +## 验证 + +```bash +# 检查 IP 转发是否已启用 +sysctl net.inet.ip.forwarding +# 预期输出:net.inet.ip.forwarding: 1 + +# 测试容器互联网访问 +container run --rm --entrypoint curl nanoclaw-agent:latest \ + -s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com +# 预期输出:404 + +# 检查桥接接口(仅当容器运行时存在) +ifconfig bridge100 +``` + +## 故障排查 + +| 症状 | 原因 | 修复方案 | +|---------|-------|-----| +| `curl: (28) Connection timed out` | IP 转发未启用 | `sudo sysctl -w net.inet.ip.forwarding=1` | +| HTTP 正常,HTTPS 超时 | IPv6 DNS 解析 | 添加 `NODE_OPTIONS=--dns-result-order=ipv4first` | +| `Could not resolve host` | DNS 未转发 | 检查 bridge100 是否存在,验证 pfctl NAT 规则 | +| 容器输出后挂起 | agent-runner 缺少 `process.exit(0)` | 重建容器镜像 | + +## 工作原理 + +``` +容器 VM (192.168.64.x) + │ + ├── eth0 → 网关 192.168.64.1 + │ +bridge100 (192.168.64.1) ← 宿主机网桥,由 vmnet 在容器运行时创建 + │ + ├── IP 转发(sysctl)将数据包从 bridge100 路由 → en0 + │ + ├── NAT(pfctl)将 192.168.64.0/24 伪装 → en0 的 IP + │ +en0(你的 WiFi/以太网)→ 互联网 +``` + +## 参考资料 + +- [apple/container#469](https://github.com/apple/container/issues/469) — macOS 26 上容器无网络 +- [apple/container#656](https://github.com/apple/container/issues/656) — 构建期间无法访问互联网 URL diff --git a/docs/zh/BRANCH-FORK-MAINTENANCE.md b/docs/zh/BRANCH-FORK-MAINTENANCE.md new file mode 100644 index 0000000..cd0bf33 --- /dev/null +++ b/docs/zh/BRANCH-FORK-MAINTENANCE.md @@ -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/分支仍能构建——传递依赖的变更可能破坏下游代码。 diff --git a/docs/zh/README.md b/docs/zh/README.md new file mode 100644 index 0000000..9d3b7cb --- /dev/null +++ b/docs/zh/README.md @@ -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) | diff --git a/docs/zh/REQUIREMENTS.md b/docs/zh/REQUIREMENTS.md new file mode 100644 index 0000000..916ea44 --- /dev/null +++ b/docs/zh/REQUIREMENTS.md @@ -0,0 +1,187 @@ +# NanoClaw 需求 + +项目创建者的原始需求和设计决策。 + +--- + +## 为什么存在这个项目 + +这是 OpenClaw(原名 ClawBot)的轻量、安全的替代方案。那个项目变得臃肿至极——4 到 5 个不同的进程运行不同的网关,无穷无尽的配置文件,无穷无尽的集成。它是一个安全噩梦,agent 不在隔离的进程中运行;有各种漏洞百出的变通方案试图阻止它们访问不应访问的系统部分。任何人都不可能真正理解整个代码库。运行它基本上就是在赌运气。 + +NanoClaw 为您提供核心功能,没有那些混乱。 + +--- + +## 理念 + +### 小到可以读懂 + +整个代码库应该是您可以阅读和理解的东西。一个 Node.js 进程。少量源文件。没有微服务,没有消息队列,没有抽象层。 + +### 通过真正的隔离实现安全 + +不是通过应用级权限系统试图阻止 agent 访问东西,agent 运行在实际的 Linux 容器中。隔离在操作系统层面。Agent 只能看到显式挂载的内容。Bash 访问是安全的,因为命令在容器内运行,而不是在您的 Mac 上。 + +### 为个人用户构建 + +这不是一个框架或平台。它是为每个用户量身定制的软件。您 fork 仓库,添加您想要的渠道(WhatsApp、Telegram、Discord、Slack、Gmail),最终得到做您所需事情的干净代码。 + +### 定制 = 代码修改 + +没有配置膨胀。如果您想要不同的行为,修改代码。代码库足够小,这样做既安全又实用。极小部分内容如触发词在配置中。其他一切——只需修改代码做您想做的事。 + +### AI 原生开发 + +我不需要安装向导——Claude Code 引导设置。我不需要监控仪表板——我问 Claude Code 发生了什么。我不需要精心设计的日志 UI——我让 Claude 读日志。我不需要调试工具——我描述问题,Claude 修复它。 + +代码库假设您有一个 AI 协作者。它不需要过度自文档化或自调试,因为 Claude 始终在那里。 + +### Skills 优先于功能 + +当人们贡献时,他们不应该在支持 WhatsApp 的同时再添加"Telegram 支持"。他们应该贡献一个 skill,如 `/add-telegram`,来改造代码库。用户 fork 仓库,运行 skills 进行定制,最终得到做他们所需事情的干净代码——而不是一个试图同时支持每个人用例的臃肿系统。 + +--- + +## RFS(Request for Skills) + +我们希望看到贡献的 skills: + +### 通信渠道 +- `/add-signal` - 添加 Signal 作为渠道 +- `/add-matrix` - 添加 Matrix 集成 + +> **注意:** Telegram、Slack、Discord、Gmail 和 Apple Container 的 skills 已经存在。完整列表请参见 [skills 文档](https://docs.nanoclaw.dev/integrations/skills-system)。 + +--- + +## 愿景 + +一个可通过消息访问的个人 Claude 助手,使用最少的自定义代码。 + +**核心组件:** +- **Claude Agent SDK** 作为核心 agent +- **容器(Containers)** 用于隔离的 agent 执行(Linux VM) +- **多渠道消息**(WhatsApp、Telegram、Discord、Slack、Gmail)——仅添加您需要的渠道 +- **持久化记忆**——按对话和全局 +- **定时任务**,运行 Claude 并能回复消息 +- **Web 访问**,用于搜索和浏览 +- **浏览器自动化**,通过 agent-browser + +**实现方法:** +- 使用现有工具(渠道库、Claude Agent SDK、MCP 服务器) +- 最少的胶水代码 +- 尽可能使用基于文件的系统(CLAUDE.md 用于记忆,目录用于群组) + +--- + +## 架构决策 + +### 消息路由 +- 路由器监听已连接的渠道,根据配置路由消息 +- 仅处理来自已注册群组的消息 +- 触发词:`@Andy` 前缀(不区分大小写),可通过 `ASSISTANT_NAME` 环境变量配置 +- 未注册的群组完全被忽略 + +### 记忆系统 +- **按群组记忆**:每个群组有一个带自己 `CLAUDE.md` 的目录 +- **全局记忆**:根 `CLAUDE.md` 供所有群组读取,但只能从"主群组"(自我聊天)写入 +- **文件**:群组可以在其目录中创建/读取文件并引用它们 +- Agent 运行在群组的目录中,自动继承两个 CLAUDE.md 文件 + +### Session 管理 +- 每个群组维护一个对话 session(通过 Claude Agent SDK) +- Sessions 在上下文过长时自动压缩,保留关键信息 + +### 容器隔离 +- 所有 agent 在容器(轻量级 Linux VM)内运行 +- 每次 agent 调用启动一个挂载了目录的容器 +- 容器提供文件系统隔离——agent 只能看到已挂载的路径 +- Bash 访问安全,因为命令在容器内运行,而非在宿主机上 +- 通过 agent-browser 和容器中的 Chromium 进行浏览器自动化 + +### 定时任务 +- 用户可以从任何群组要求 Claude 安排定期或一次性任务 +- 任务在创建它们的群组上下文中作为完整 agent 运行 +- 任务可以访问包括 Bash 在内的所有工具(在容器中安全) +- 任务可以选择性地通过 `send_message` 工具向群组发送消息,或静默完成 +- 任务运行记录到数据库,包含持续时间和结果 +- 调度类型:cron 表达式、间隔(毫秒)或一次性(ISO 时间戳) +- 从主群组:可以为任何群组安排任务,查看/管理所有任务 +- 从其他群组:只能管理该群组的任务 + +### 群组管理 +- 新群组通过主渠道显式添加 +- 群组在 SQLite 中注册(通过主渠道或 IPC `register_group` 命令) +- 每个群组在 `groups/` 下获得一个专用目录 +- 群组可以通过 `containerConfig` 挂载额外目录 + +### 主渠道权限 +- 主渠道是管理员/控制群组(通常是自我聊天) +- 可以向全局记忆写入(`groups/CLAUDE.md`) +- 可以为任何群组安排定时任务 +- 可以查看和管理所有群组的任务 +- 可以为任何群组配置额外的目录挂载 + +--- + +## 集成点 + +### 渠道 +- WhatsApp (baileys)、Telegram (grammy)、Discord (discord.js)、Slack (@slack/bolt)、Gmail (googleapis) +- 每个渠道存于单独的 fork 仓库中,通过 skills(例如 `/add-whatsapp`、`/add-telegram`)添加 +- 消息存储在 SQLite 中,由路由器轮询 +- 渠道在启动时自行注册——未配置的渠道被跳过并发出警告 + +### 调度器 +- 内置调度器在宿主机上运行,启动容器执行任务 +- 自定义 `nanoclaw` MCP 服务器(容器内)提供调度工具 +- 工具:`schedule_task`、`list_tasks`、`pause_task`、`resume_task`、`cancel_task`、`send_message` +- 任务存储在 SQLite 中,带运行历史 +- 调度器循环每分钟检查到期任务 +- 任务在容器化的群组上下文中执行 Claude Agent SDK + +### Web 访问 +- 内置 WebSearch 和 WebFetch 工具 +- 标准 Claude Agent SDK 能力 + +### 浏览器自动化 +- 容器中的 agent-browser CLI 和 Chromium +- 基于快照的交互,带元素引用(@e1、@e2 等) +- 截图、PDF、视频录制 +- 认证状态持久化 + +--- + +## 设置与定制 + +### 理念 +- 最小化配置文件 +- 通过 Claude Code 进行设置和定制 +- 用户克隆仓库并运行 Claude Code 进行配置 +- 每个用户得到精确匹配其需求的定制设置 + +### Skills +- `/setup` - 安装依赖,配置渠道,启动服务 +- `/customize` - 通用 skill,用于添加能力 +- `/update-nanoclaw` - 拉取上游更改,与定制合并 + +### 部署 +- 运行在 macOS (launchd)、Linux (systemd) 或 Windows (WSL2) 上 +- 单个 Node.js 进程处理一切 + +--- + +## 个人配置(参考) + +以下是创建者的设置,存档于此供参考: + +- **触发词**:`@Andy`(不区分大小写) +- **响应前缀**:`Andy:` +- **角色**:默认 Claude(无自定义个性) +- **主渠道**:自我聊天(在 WhatsApp 中给自己发消息) + +--- + +## 项目名称 + +**NanoClaw** - 引用 Clawdbot(现为 OpenClaw)。 diff --git a/docs/zh/SDK_DEEP_DIVE.md b/docs/zh/SDK_DEEP_DIVE.md new file mode 100644 index 0000000..6fdcb86 --- /dev/null +++ b/docs/zh/SDK_DEEP_DIVE.md @@ -0,0 +1,643 @@ +# Claude Agent SDK 深度剖析 + +对 `@anthropic-ai/claude-agent-sdk` v0.2.29–0.2.34 的反向工程发现,以理解 `query()` 如何工作、为什么 agent teams 子代理(subagents)被终止,以及如何修复。补充了官方 SDK 参考文档。 + +## 架构(Architecture) + +``` +Agent Runner (我们的代码) + └── query() → SDK (sdk.mjs) + └── 生成 CLI 子进程 (cli.js) + └── Claude API 调用、工具执行 + └── Task 工具 → 生成子代理子进程 +``` + +SDK 以子进程形式生成 `cli.js`,带有 `--output-format stream-json --input-format stream-json --print --verbose` 标志。通信通过 stdin/stdout 上的 JSON 行进行。 + +`query()` 返回一个扩展了 `AsyncGenerator` 的 `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` | `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` | `process.env` | 环境变量 | +| `executable` | `'bun' \| 'deno' \| 'node'` | 自动检测 | JavaScript 运行时 | +| `fallbackModel` | `string` | `undefined` | 主模型失败时使用的模型 | +| `forkSession` | `boolean` | `false` | 恢复时,分叉到新的 session ID 而非继续原始会话 | +| `hooks` | `Partial>` | `{}` | 事件钩子回调 | +| `includePartialMessages` | `boolean` | `false` | 包含部分消息事件(流式) | +| `maxBudgetUsd` | `number` | `undefined` | 查询的最大美元预算 | +| `maxThinkingTokens` | `number` | `undefined` | 思考过程的最大 token 数 | +| `maxTurns` | `number` | `undefined` | 最大对话轮次 | +| `mcpServers` | `Record` | `{}` | MCP 服务器配置 | +| `model` | `string` | CLI 默认值 | 使用的 Claude 模型 | +| `outputFormat` | `{ type: 'json_schema', schema: JSONSchema }` | `undefined` | 结构化输出格式 | +| `pathToClaudeCodeExecutable` | `string` | 使用内置版本 | Claude Code 可执行文件路径 | +| `permissionMode` | `PermissionMode` | `'default'` | 权限模式 | +| `plugins` | `SdkPluginConfig[]` | `[]` | 从本地路径加载自定义插件 | +| `resume` | `string` | `undefined` | 要恢复的 session ID | +| `resumeSessionAt` | `string` | `undefined` | 在特定消息 UUID 处恢复会话 | +| `sandbox` | `SandboxSettings` | `undefined` | 沙箱行为配置 | +| `settingSources` | `SettingSource[]` | `[]`(无) | 要加载的文件系统设置。必须包含 `'project'` 才能加载 CLAUDE.md | +| `stderr` | `(data: string) => void` | `undefined` | stderr 输出回调 | +| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | 系统提示词。使用预设可获取 Claude Code 的提示词,可选 `append` | +| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | 工具配置 | + +### PermissionMode + +```typescript +type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; +``` + +### SettingSource + +```typescript +type SettingSource = 'user' | 'project' | 'local'; +// 'user' → ~/.claude/settings.json +// 'project' → .claude/settings.json(版本控制的) +// 'local' → .claude/settings.local.json(gitignored) +``` + +省略时,SDK 不加载任何文件系统设置(默认隔离)。优先级:local > project > user。编程方式提供的选项始终覆盖文件系统设置。 + +### AgentDefinition + +以编程方式定义的子代理(不是 agent teams——这些更简单,无代理间协调): + +```typescript +type AgentDefinition = { + description: string; // 何时使用此代理 + tools?: string[]; // 允许的工具(省略则继承全部) + prompt: string; // 代理的系统提示词 + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; +} +``` + +### McpServerConfig + +```typescript +type McpServerConfig = + | { type?: 'stdio'; command: string; args?: string[]; env?: Record } + | { type: 'sse'; url: string; headers?: Record } + | { type: 'http'; url: string; headers?: Record } + | { 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; + +type PermissionResult = + | { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] } + | { behavior: 'deny'; message: string; interrupt?: boolean }; +``` + +## SDKMessage 类型 + +`query()` 可以产出 16 种消息类型。官方文档展示了一个简化的 7 种联合类型,但 `sdk.d.ts` 包含完整集合: + +| 类型 | 子类型 | 用途 | +|------|---------|---------| +| `system` | `init` | 会话已初始化,包含 session_id、tools、model | +| `system` | `task_notification` | 后台代理已完成/失败/停止 | +| `system` | `compact_boundary` | 对话已压缩 | +| `system` | `status` | 状态变更(例如正在压缩) | +| `system` | `hook_started` | 钩子执行已开始 | +| `system` | `hook_progress` | 钩子进度输出 | +| `system` | `hook_response` | 钩子已完成 | +| `system` | `files_persisted` | 文件已保存 | +| `assistant` | — | Claude 的响应(文本 + 工具调用) | +| `user` | — | 用户消息(内部) | +| `user` (replay) | — | 恢复时重放的用户消息 | +| `result` | `success` / `error_*` | 提示词处理轮的最终结果 | +| `stream_event` | — | 部分流式内容(当 includePartialMessages 时) | +| `tool_progress` | — | 长时间运行工具进度 | +| `auth_status` | — | 认证状态变化 | +| `tool_use_summary` | — | 前置工具使用的摘要 | + +### SDKTaskNotificationMessage (sdk.d.ts:1507) + +```typescript +type SDKTaskNotificationMessage = { + type: 'system'; + subtype: 'task_notification'; + task_id: string; + status: 'completed' | 'failed' | 'stopped'; + output_file: string; + summary: string; + uuid: UUID; + session_id: string; +}; +``` + +### SDKResultMessage (sdk.d.ts:1375) + +两种变体,共享字段: + +```typescript +// 两种变体共享的字段: +// uuid, session_id, duration_ms, duration_api_ms, is_error, num_turns, +// total_cost_usd, usage: NonNullableUsage, modelUsage, permission_denials + +// 成功: +type SDKResultSuccess = { + type: 'result'; + subtype: 'success'; + result: string; + structured_output?: unknown; + // ...共享字段 +}; + +// 错误: +type SDKResultError = { + type: 'result'; + subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries'; + errors: string[]; + // ...共享字段 +}; +``` + +result 上有用的字段:`total_cost_usd`、`duration_ms`、`num_turns`、`modelUsage`(按模型细分的 `costUSD`、`inputTokens`、`outputTokens`、`contextWindow`)。 + +### SDKAssistantMessage + +```typescript +type SDKAssistantMessage = { + type: 'assistant'; + uuid: UUID; + session_id: string; + message: APIAssistantMessage; // 来自 Anthropic SDK + parent_tool_use_id: string | null; // 非空时来自子代理 +}; +``` + +### SDKSystemMessage (init) + +```typescript +type SDKSystemMessage = { + type: 'system'; + subtype: 'init'; + uuid: UUID; + session_id: string; + apiKeySource: ApiKeySource; + cwd: string; + tools: string[]; + mcp_servers: { name: string; status: string }[]; + model: string; + permissionMode: PermissionMode; + slash_commands: string[]; + output_style: string; +}; +``` + +## 轮次行为:代理何时停止与继续 + +### 代理停止时(不再进行 API 调用) + +**1. 响应中没有 tool_use 块(主要情况)** + +Claude 仅以文本响应——它判定已完成任务。API 的 `stop_reason` 将为 `"end_turn"`。SDK 不做此决定——完全由 Claude 的模型输出驱动。 + +**2. 超过最大轮次**——导致 `SDKResultError`,`subtype: "error_max_turns"`。 + +**3. 中止信号**——通过 `abortController` 用户中断。 + +**4. 超出预算**——`totalCost >= maxBudgetUsd` → `"error_max_budget_usd"`。 + +**5. 停止钩子阻止继续**——钩子返回 `{preventContinuation: true}`。 + +### 代理继续时(进行另一次 API 调用) + +**1. 响应包含 tool_use 块(主要情况)**——执行工具,递增 turnCount,递归进入 EZ。 + +**2. max_output_tokens 恢复**——最多 3 次重试,附带"将工作拆分为更小片段"的上下文消息。 + +**3. 停止钩子阻塞错误**——错误作为上下文消息反馈,循环继续。 + +**4. 模型回退**——使用回退模型重试(一次性)。 + +### 决策表 + +| 条件 | 动作 | 结果类型 | +|-----------|--------|-------------| +| 响应有 `tool_use` 块 | 执行工具,递归进入 `EZ` | 继续 | +| 响应没有 `tool_use` 块 | 运行停止钩子,返回 | `success` | +| `turnCount > maxTurns` | 产出 max_turns_reached | `error_max_turns` | +| `totalCost >= maxBudgetUsd` | 产出预算错误 | `error_max_budget_usd` | +| `abortController.signal.aborted` | 产出中断消息 | 取决于上下文 | +| `stop_reason === "max_tokens"`(输出) | 使用恢复提示词最多重试 3 次 | 继续 | +| 停止钩子 `preventContinuation` | 立即返回 | `success` | +| 停止钩子阻塞错误 | 反馈错误,递归 | 继续 | +| 模型回退错误 | 使用回退模型重试(一次性) | 继续 | + +## 子代理执行模式 + +### 情况 1:同步子代理 (`run_in_background: false`)——阻塞 + +父代理调用 Task 工具 → `VR()` 为子代理运行 `EZ()` → 父代理等待完整结果 → 工具结果返回给父代理 → 父代理继续。 + +子代理运行完整的递归 EZ 循环。父代理的工具执行通过 `await` 挂起。存在一个执行中"提升"机制:同步子代理可以通过 `Promise.race()` 与 `backgroundSignal` promise 竞争被提升为后台。 + +### 情况 2:后台任务 (`run_in_background: true`)——不等待 + +- **Bash 工具:**命令启动,工具立即返回空结果 + `backgroundTaskId` +- **Task/Agent 工具:**子代理在即发即忘(fire-and-forget)包装器 (`g01()`) 中启动,工具立即返回 `status: "async_launched"` + `outputFile` 路径 + +在发出 `type: "result"` 消息之前,零"等待后台任务"逻辑。当后台任务完成时,单独发出 `SDKTaskNotificationMessage`。 + +### 情况 3:Agent Teams (TeammateTool / SendMessage)——先结果,后轮询 + +团队领导者运行其正常的 EZ 循环,其中包括生成队友。当领导者的 EZ 循环完成时,发出 `type: "result"`。然后领导者进入结果后轮询循环: + +```javascript +while (true) { + // 检查是否没有活跃队友且没有正在运行的任务 → 跳出 + // 检查来自队友的未读消息 → 作为新提示词重新注入,重启 EZ 循环 + // 如果 stdin 关闭且有活跃队友 → 注入关闭提示词 + // 每 500ms 轮询 +} +``` + +从 SDK 消费者角度看:你收到初始的 `type: "result"`,但 AsyncGenerator 可能继续产出更多消息,因为团队领导者处理队友响应并重新进入代理循环。生成器只有在所有队友都已关闭时才真正完成。 + +## isSingleUserTurn 问题 + +来自 sdk.mjs: + +```javascript +QK = typeof X === "string" // isSingleUserTurn = true,当 prompt 为字符串时 +``` + +当 `isSingleUserTurn` 为 true 且第一个 `result` 消息到达时: + +```javascript +if (this.isSingleUserTurn) { + this.transport.endInput(); // 向 CLI 关闭 stdin +} +``` + +这触发连锁反应: + +1. SDK 关闭 CLI stdin +2. CLI 检测到 stdin 关闭 +3. 轮询循环看到 `D = true`(stdin 关闭)且有活跃队友 +4. 注入关闭提示词 → 领导者向所有队友发送 `shutdown_request` +5. **队友在搜索中途被杀死** + +关闭提示词(通过在压缩的 cli.js 中的 `BGq` 变量找到): + +``` +You are running in non-interactive mode and cannot return a response +to the user until your team is shut down. + +You MUST shut down your team before preparing your final response: +1. Use requestShutdown to ask each team member to shut down gracefully +2. Wait for shutdown approvals +3. Use the cleanup operation to clean up the team +4. Only then provide your final response to the user +``` + +### 实际问题 + +使用 V1 `query()` + 字符串 prompt + agent teams: + +1. 领导者生成队友,他们开始搜索 +2. 领导者的 EZ 循环结束("我已经派出了团队,他们正在处理此事") +3. 发出 `type: "result"` +4. SDK 看到 `isSingleUserTurn = true` → 立即关闭 stdin +5. 轮询循环检测到 stdin 关闭 + 活跃队友 → 注入关闭提示词 +6. 领导者向所有队友发送 `shutdown_request` +7. **队友可能刚开始了 10 秒的 5 分钟搜索任务,就被要求停止** + +## 修复方案:流式输入模式 + +不传递字符串 prompt(这会设置 `isSingleUserTurn = true`),而是传递一个 `AsyncIterable`: + +```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` 并自行管理协调 + +### 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; +``` + +### 钩子返回值 + +```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 } + | { 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 { + interrupt(): Promise; // 停止当前执行(仅流式输入模式) + rewindFiles(userMessageUuid: string): Promise; // 将文件恢复到消息时的状态(需要 enableFileCheckpointing) + setPermissionMode(mode: PermissionMode): Promise; // 更改权限(仅流式输入模式) + setModel(model?: string): Promise; // 更改模型(仅流式输入模式) + setMaxThinkingTokens(max: number | null): Promise; // 更改思考 token 数(仅流式输入模式) + supportedCommands(): Promise; // 可用的斜杠命令 + supportedModels(): Promise; // 可用模型 + mcpServerStatus(): Promise; // MCP 服务器连接状态 + accountInfo(): Promise; // 已认证用户信息 +} +``` + +在 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( + name: string, + description: string, + inputSchema: Schema, + handler: (args: z.infer>, extra: unknown) => Promise +): SdkMcpToolDefinition +``` + +### createSdkMcpServer() + +创建进程内 MCP 服务器(我们改用 stdio 以支持子代理继承): + +```typescript +function createSdkMcpServer(options: { + name: string; + version?: string; + tools?: Array>; +}): 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 可执行文件(压缩的,作为子进程运行) diff --git a/docs/zh/SECURITY.md b/docs/zh/SECURITY.md new file mode 100644 index 0000000..75db55c --- /dev/null +++ b/docs/zh/SECURITY.md @@ -0,0 +1,161 @@ +# NanoClaw 安全模型 + +## 信任模型 + +| 实体 | 信任级别 | 理由 | +|--------|-------------|-----------| +| 主群组(Main group) | 受信任 | 私人自我聊天,管理员控制 | +| 非主群组(Non-main groups) | 不受信任 | 其他用户可能是恶意的 | +| 容器 agent(Container agents) | 沙箱化 | 隔离的执行环境 | +| 入站消息(Incoming messages) | 用户输入 | 潜在的 prompt 注入 | + +## 安全边界 + +### 1. 容器隔离(主要边界) + +Agent 在容器(轻量级 Linux VM)中执行,提供: +- **进程隔离** - 容器进程无法影响宿主机 +- **文件系统隔离** - 只有显式挂载的目录可见 +- **非 root 执行** - 以非特权 `node` 用户(uid 1000)运行 +- **临时容器** - 每次调用都是全新环境(`--rm`) + +这是主要的安全边界。不是依赖应用级权限检查,而是通过限制挂载的内容来缩小攻击面。 + +### 2. 挂载安全 + +**外部白名单** - 挂载权限存储在 `~/.config/nanoclaw/mount-allowlist.json`,该文件: +- 位于项目根目录之外 +- 永远不会挂载到容器中 +- 无法被 agent 修改 + +**默认阻止模式:** +``` +.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker, +credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519, +private_key, .secret +``` + +**保护措施:** +- 验证前解析符号链接(防止目录遍历攻击) +- 容器路径验证(拒绝 `..` 和绝对路径) +- `nonMainReadOnly` 选项强制非主群组使用只读 + +**只读项目根目录:** + +主群组的项目根目录以只读方式挂载。Agent 需要的可写路径(store、群组目录、IPC、`.claude/`)单独挂载。这防止 agent 修改宿主机应用代码(`src/`、`dist/`、`package.json` 等),否则下次重启时整个沙箱将被完全绕过。`store/` 目录以读写方式挂载,以便主 agent 可以直接访问 SQLite 数据库。 + +### 3. Session 隔离 + +每个群组在 `data/sessions/{group}/.claude/` 拥有隔离的 Claude session: +- 群组无法查看其他群组的对话历史 +- Session 数据包含完整的消息历史和已读取的文件内容 +- 防止跨群组信息泄露 + +### 4. IPC 授权 + +消息和任务操作根据群组身份进行验证: + +| 操作 | 主群组 | 非主群组 | +|-----------|------------|----------------| +| 向自己的聊天发送消息 | ✓ | ✓ | +| 向其他聊天发送消息 | ✓ | ✗ | +| 为自己安排定时任务 | ✓ | ✓ | +| 为其他群组安排定时任务 | ✓ | ✗ | +| 查看所有任务 | ✓ | 仅自己的 | +| 管理其他群组 | ✓ | ✗ | + +### 5. 凭证隔离(OneCLI Agent Vault) + +真实的 API 凭证**永远不会进入容器**。NanoClaw 使用 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 来代理出站请求并在网关层面注入凭证。 + +**工作原理:** +1. 凭证通过 `onecli secrets create` 一次性注册,由 OneCLI 存储和管理 +2. 当 NanoClaw 启动容器时,调用 `applyContainerConfig()` 将出站 HTTPS 路由通过 OneCLI 网关 +3. 网关按 host 和 path 匹配请求,注入真实凭证并转发 +4. Agent 无法发现真实凭证——不在环境变量、stdin、文件或 `/proc` 中 + +**按 agent 策略:** +每个 NanoClaw 群组拥有自己的 OneCLI agent 身份。这允许按群组设置不同的凭证策略(例如,销售 agent vs. 支持 agent)。OneCLI 支持速率限制,基于时间的访问和审批流程正在规划中。 + +**不挂载的内容:** +- 渠道认证 session(`store/auth/`)——仅宿主机 +- 挂载白名单——外部存储,永不挂载 +- 任何匹配阻止模式的凭证 +- `.env` 在项目根目录挂载中被 `/dev/null` 遮蔽 + +## 权限对比 + +| 能力 | 主群组 | 非主群组 | +|------------|------------|----------------| +| 项目根目录访问 | `/workspace/project`(只读) | 无 | +| Store(SQLite DB) | `/workspace/project/store`(读写) | 无 | +| 群组目录 | `/workspace/group`(读写) | `/workspace/group`(读写) | +| 全局记忆 | 通过项目隐式访问 | `/workspace/global`(只读) | +| 额外挂载 | 可配置 | 只读,除非允许 | +| 网络访问 | 无限制 | 无限制 | +| MCP 工具 | 全部 | 全部 | + +## 安全架构图 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 不受信任区域(UNTRUSTED ZONE) │ +│ 入站消息(潜在恶意) │ +└────────────────────────────────┬─────────────────────────────────┘ + │ + ▼ 触发检查、输入转义 +┌──────────────────────────────────────────────────────────────────┐ +│ 宿主机进程(HOST PROCESS,受信任) │ +│ • 消息路由 │ +│ • IPC 授权 │ +│ • 挂载验证(外部白名单) │ +│ • 容器生命周期 │ +│ • OneCLI Agent Vault(注入凭证,执行策略) │ +└────────────────────────────────┬─────────────────────────────────┘ + │ + ▼ 仅显式挂载,无 secrets +┌──────────────────────────────────────────────────────────────────┐ +│ 容器(CONTAINER,隔离/沙箱化) │ +│ • Agent 执行 │ +│ • Bash 命令(沙箱内) │ +│ • 文件操作(限于挂载范围) │ +│ • API 调用通过 OneCLI Agent Vault 路由 │ +│ • 环境变量或文件系统中无真实凭证 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## 供应链安全(pnpm) + +NanoClaw 使用 pnpm,在 `pnpm-workspace.yaml` 中配置了两道供应链防护: + +### 最低发布龄期 + +`minimumReleaseAge: 4320`(3 天)。pnpm 将拒绝解析发布不到 3 天的任何包版本。这可以防御仿冒包和被盗用的维护者账户——大多数恶意发布在 72 小时内会被检测并撤回。 + +**将包排除在发布龄期门禁之外**(`minimumReleaseAgeExclude`): + +这种情况应很少发生。当零日修复或关键依赖需要立即更新时: + +1. 排除条目必须经人类维护者审查和批准 +2. 条目必须锁定被排除的**确切版本**——绝不能使用范围或通配符 + ```yaml + minimumReleaseAgeExclude: + some-package: "1.2.3" # 由 @user 批准, 2026-04-14 — CVE-XXXX-YYYY 修复 + ``` +3. 一旦该版本超过阈值(即 3 天后),应移除排除项 +4. 自动化 agent(Claude、CI 机器人)绝不能在没有人类签署的情况下添加排除项 + +### 构建脚本白名单 + +`onlyBuiltDependencies` 限制哪些包可以执行 install/postinstall 脚本。只有此列表中的包才被允许在 `pnpm install` 期间运行构建脚本。当前允许的包: + +- `better-sqlite3` — 编译原生 SQLite 绑定 +- `esbuild` — 下载平台特定的二进制文件 +- `protobufjs` — 生成 protobuf 绑定(Baileys/libsignal 使用) +- `sharp` — 下载平台特定的图像处理二进制文件 + +向此列表添加包需要人类批准——构建脚本以安装用户的权限执行任意代码。 + +### `.npmrc` 安全网 + +`.npmrc` 文件包含 `minReleaseAge=3d` 作为后备。权威设置在 `pnpm-workspace.yaml` 中,但如果 npm 曾被直接调用(例如被不遵循 pnpm 的工具调用),`.npmrc` 提供纵深防御。 diff --git a/docs/zh/SPEC.md b/docs/zh/SPEC.md new file mode 100644 index 0000000..f8bec31 --- /dev/null +++ b/docs/zh/SPEC.md @@ -0,0 +1,782 @@ +# NanoClaw 规范 + +一款个人 Claude 助手,支持多渠道、按会话持久化记忆、定时任务以及容器隔离的 agent(智能体)执行。 + +--- + +## 目录 + +1. [架构](#架构) +2. [架构:渠道系统](#架构渠道系统) +3. [目录结构](#目录结构) +4. [配置](#配置) +5. [记忆系统](#记忆系统) +6. [会话管理](#session-管理) +7. [消息流转](#消息流转) +8. [命令](#命令) +9. [定时任务](#定时任务) +10. [MCP 服务器](#mcp-服务器) +11. [部署](#部署) +12. [安全考量](#安全考量) + +--- + +## 架构 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ HOST(宿主机,macOS / Linux) │ +│ (主 Node.js 进程) │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ Channels │─────────────────▶│ SQLite 数据库 │ │ +│ │ (渠道,启动时 │◀────────────────│ (messages.db) │ │ +│ │ 自行注册) │ 存储/发送 └─────────┬──────────┘ │ +│ └──────────────────┘ │ │ +│ │ │ +│ ┌─────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ +│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │ +│ │ (消息轮询循环) │ │ (调度器循环) │ │ (IPC 监听器) │ │ +│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │ +│ │ │ │ +│ └───────────┬───────────┘ │ +│ │ 启动容器 │ +│ ▼ │ +├──────────────────────────────────────────────────────────────────────┤ +│ CONTAINER(容器,Linux VM) │ +├──────────────────────────────────────────────────────────────────────┤ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ AGENT RUNNER(智能体运行器) │ │ +│ │ │ │ +│ │ 工作目录: /workspace/group(从宿主机挂载) │ │ +│ │ 卷挂载: │ │ +│ │ • groups/{name}/ → /workspace/group │ │ +│ │ • groups/global/ → /workspace/global/(仅非主群组) │ │ +│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │ +│ │ • 额外目录 → /workspace/extra/* │ │ +│ │ │ │ +│ │ 工具(所有群组): │ │ +│ │ • Bash(安全——在容器内沙箱化!) │ │ +│ │ • Read, Write, Edit, Glob, Grep(文件操作) │ │ +│ │ • WebSearch, WebFetch(互联网访问) │ │ +│ │ • agent-browser(浏览器自动化) │ │ +│ │ • mcp__nanoclaw__*(通过 IPC 的调度器工具) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +### 技术栈 + +| 组件 | 技术 | 用途 | +|-----------|------------|---------| +| Channel System | Channel registry (`src/channels/registry.ts`) | 渠道在启动时自行注册 | +| Message Storage | SQLite (better-sqlite3) | 存储消息供轮询 | +| Container Runtime | Containers(Linux VM) | 用于 agent 执行的隔离环境 | +| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | 带工具和 MCP 服务器的 Claude 运行环境 | +| Browser Automation | agent-browser + Chromium | Web 交互与截图 | +| Runtime | Node.js 20+ | 用于路由和调度的宿主机进程 | + +--- + +## 架构:渠道系统 + +核心不含任何内置渠道——每个渠道(WhatsApp、Telegram、Slack、Discord、Gmail)作为 [Claude Code skill](https://code.claude.com/docs/en/skills) 安装,将渠道代码添加到您的 fork 中。渠道在启动时自行注册;已安装但缺少凭证的渠道会发出 WARN 日志并被跳过。 + +### 系统图 + +```mermaid +graph LR + subgraph Channels["渠道"] + WA[WhatsApp] + TG[Telegram] + SL[Slack] + DC[Discord] + New["其他渠道 (Signal, Gmail...)"] + end + + subgraph Orchestrator["编排器 — index.ts"] + ML[Message Loop] + GQ[Group Queue] + RT[Router] + TS[Task Scheduler] + DB[(SQLite)] + end + + subgraph Execution["容器执行"] + CR[Container Runner] + LC["Linux Container"] + IPC[IPC Watcher] + end + + %% Flow + WA & TG & SL & DC & New -->|onMessage| ML + ML --> GQ + GQ -->|并发控制| CR + CR --> LC + LC -->|文件系统 IPC| IPC + IPC -->|任务与消息| RT + RT -->|Channel.sendMessage| Channels + TS -->|到期任务| CR + + %% DB Connections + DB <--> ML + DB <--> TS + + %% Styling for the dynamic channel + style New stroke-dasharray: 5 5,stroke-width:2px +``` + +### 渠道注册表 + +渠道系统建立在 `src/channels/registry.ts` 中的工厂注册表之上: + +```typescript +export type ChannelFactory = (opts: ChannelOpts) => Channel | null; + +const registry = new Map(); + +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; + sendMessage(jid: string, text: string): Promise; + isConnected(): boolean; + ownsJid(jid: string): boolean; + disconnect(): Promise; + setTyping?(jid: string, isTyping: boolean): Promise; + syncGroups?(force: boolean): Promise; +} +``` + +### 自行注册模式 + +渠道使用 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-/` 贡献一个 skill,该 skill 需要: + +1. 添加一个 `src/channels/.ts` 文件,实现 `Channel` 接口 +2. 在模块加载时调用 `registerChannel(name, factory)` +3. 如果凭证缺失,工厂返回 `null` +4. 向 `src/channels/index.ts` 添加一条导入行 + +请参考已有的 skills(`/add-whatsapp`、`/add-telegram`、`/add-slack`、`/add-discord`、`/add-gmail`)了解模式。 + +--- + +## 目录结构 + +``` +nanoclaw/ +├── CLAUDE.md # Claude Code 的项目上下文 +├── docs/ +│ ├── SPEC.md # 本规范文档 +│ ├── REQUIREMENTS.md # 架构决策 +│ └── SECURITY.md # 安全模型 +├── README.md # 用户文档 +├── package.json # Node.js 依赖 +├── tsconfig.json # TypeScript 配置 +├── .mcp.json # MCP 服务器配置(参考) +├── .gitignore +│ +├── src/ +│ ├── index.ts # 编排器:状态、消息循环、agent 调用 +│ ├── channels/ +│ │ ├── registry.ts # 渠道工厂注册表 +│ │ └── index.ts # 渠道自行注册的 barrel 导入 +│ ├── ipc.ts # IPC 监听器与任务处理 +│ ├── router.ts # 消息格式化和出站路由 +│ ├── config.ts # 配置常量 +│ ├── types.ts # TypeScript 接口(包含 Channel) +│ ├── logger.ts # Pino 日志器配置 +│ ├── db.ts # SQLite 数据库初始化与查询 +│ ├── group-queue.ts # 带全局并发限制的按群组队列 +│ ├── mount-security.ts # 容器挂载白名单验证 +│ ├── whatsapp-auth.ts # 独立的 WhatsApp 认证 +│ ├── task-scheduler.ts # 到期时运行定时任务 +│ └── container-runner.ts # 在容器中启动 agent +│ +├── container/ +│ ├── Dockerfile # 容器镜像(以 'node' 用户运行,包含 Claude Code CLI) +│ ├── build.sh # 容器镜像的构建脚本 +│ ├── agent-runner/ # 在容器内运行的代码 +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ ├── index.ts # 入口点(查询循环、IPC 轮询、会话恢复) +│ │ └── ipc-mcp-stdio.ts # 基于 stdio 的 MCP 服务器,用于宿主机通信 +│ └── skills/ +│ └── agent-browser.md # 浏览器自动化 skill +│ +├── dist/ # 编译后的 JavaScript(gitignored) +│ +├── .claude/ +│ └── skills/ +│ ├── setup/SKILL.md # /setup - 首次安装 +│ ├── customize/SKILL.md # /customize - 添加能力 +│ ├── debug/SKILL.md # /debug - 容器调试 +│ ├── add-telegram/SKILL.md # /add-telegram - Telegram 渠道 +│ ├── add-gmail/SKILL.md # /add-gmail - Gmail 集成 +│ ├── add-voice-transcription/ # /add-voice-transcription - Whisper +│ ├── x-integration/SKILL.md # /x-integration - X/Twitter +│ ├── convert-to-apple-container/ # /convert-to-apple-container - Apple Container 运行时 +│ └── add-parallel/SKILL.md # /add-parallel - 并行 agent +│ +├── groups/ +│ ├── CLAUDE.md # 全局记忆(所有群组都读取此文件) +│ ├── {channel}_main/ # 主控制渠道(例如 whatsapp_main/) +│ │ ├── CLAUDE.md # 主渠道记忆 +│ │ └── logs/ # 任务执行日志 +│ └── {channel}_{group-name}/ # 按群组目录(注册时创建) +│ ├── CLAUDE.md # 群组专属记忆 +│ ├── logs/ # 此群组的任务日志 +│ └── *.md # agent 创建的文件 +│ +├── store/ # 本地数据(gitignored) +│ ├── auth/ # WhatsApp 认证状态 +│ └── messages.db # SQLite 数据库(messages、chats、scheduled_tasks、task_run_logs、registered_groups、sessions、router_state) +│ +├── data/ # 应用状态(gitignored) +│ ├── sessions/ # 按群组会话数据(.claude/ 目录,含 JSONL 对话记录) +│ ├── env/env # .env 的副本,用于挂载到容器 +│ └── ipc/ # 容器 IPC(messages/、tasks/) +│ +├── logs/ # 运行时日志(gitignored) +│ ├── nanoclaw.log # 宿主机 stdout +│ └── nanoclaw.error.log # 宿主机 stderr +│ # 注意:每个容器的日志在 groups/{folder}/logs/container-*.log +│ +└── launchd/ + └── com.nanoclaw.plist # macOS 服务配置 +``` + +--- + +## 配置 + +配置常量在 `src/config.ts` 中定义: + +```typescript +import path from 'path'; + +export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; + +// 路径必须是绝对路径(容器挂载需要) +const PROJECT_ROOT = process.cwd(); +export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); +export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); +export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); + +// 容器配置 +export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 默认30分钟 +export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30分钟——最后一次结果后保持容器存活 +export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); + +export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); +``` + +**注意:** 路径必须是绝对路径,容器卷挂载才能正常工作。 + +### 容器配置 + +群组可以通过 SQLite `registered_groups` 表中的 `containerConfig`(以 JSON 形式存储在 `container_config` 列中)挂载额外目录。注册示例: + +```typescript +setRegisteredGroup("1234567890@g.us", { + name: "Dev Team", + folder: "whatsapp_dev-team", + trigger: "@Andy", + added_at: new Date().toISOString(), + containerConfig: { + additionalMounts: [ + { + hostPath: "~/projects/webapp", + containerPath: "webapp", + readonly: false, + }, + ], + timeout: 600000, + }, +}); +``` + +目录命名遵循 `{channel}_{group-name}` 约定(例如 `whatsapp_family-chat`、`telegram_dev-team`)。主群组在注册时会设置 `isMain: true`。 + +额外挂载在容器内显示为 `/workspace/extra/{containerPath}`。 + +**挂载语法说明:** 读写挂载使用 `-v host:container`,但只读挂载需要使用 `--mount "type=bind,source=...,target=...,readonly"`(`:ro` 后缀可能不是所有运行时都支持)。 + +### Claude 认证 + +在项目根目录的 `.env` 文件中配置认证。有两种选择: + +**选项1:Claude 订阅(OAuth token)** +```bash +CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... +``` +如果您已登录 Claude Code,可以从 `~/.claude/.credentials.json` 中提取 token。 + +**选项2:按用量付费的 API Key** +```bash +ANTHROPIC_API_KEY=sk-ant-api03-... +``` + +只有认证变量(`CLAUDE_CODE_OAUTH_TOKEN` 和 `ANTHROPIC_API_KEY`)从 `.env` 中提取并写入 `data/env/env`,然后挂载到容器中的 `/workspace/env-dir/env`,由入口点脚本加载。这确保 `.env` 中的其他环境变量不会暴露给 agent。此变通方案是必要的,因为某些容器运行时在使用 `-i`(带管道 stdin 的交互模式)时会丢失 `-e` 环境变量。 + +### 更改助手名称 + +设置 `ASSISTANT_NAME` 环境变量: + +```bash +ASSISTANT_NAME=Bot pnpm start +``` + +或编辑 `src/config.ts` 中的默认值。这会更改: +- 触发模式(消息必须以 `@YourName` 开头) +- 响应前缀(自动添加 `YourName:`) + +### launchd 中的占位符值 + +包含 `{{PLACEHOLDER}}` 值的文件需要进行配置: +- `{{PROJECT_ROOT}}` - nanoclaw 安装的绝对路径 +- `{{NODE_PATH}}` - node 二进制文件的路径(通过 `which node` 检测) +- `{{HOME}}` - 用户的主目录 + +--- + +## 记忆系统 + +NanoClaw 使用基于 CLAUDE.md 文件的层级记忆系统。 + +### 记忆层级 + +| 层级 | 位置 | 读取者 | 写入者 | 用途 | +|-------|----------|---------|------------|---------| +| **全局** | `groups/CLAUDE.md` | 所有群组 | 仅主群组 | 在所有对话之间共享的偏好、事实、上下文 | +| **群组** | `groups/{name}/CLAUDE.md` | 该群组 | 该群组 | 群组专属上下文、对话记忆 | +| **文件** | `groups/{name}/*.md` | 该群组 | 该群组 | 对话过程中创建的笔记、研究、文档 | + +### 记忆如何运作 + +1. **Agent 上下文加载** + - Agent 运行在 `groups/{group-name}/` 作为 `cwd` + - Claude Agent SDK 使用 `settingSources: ['project']` 自动加载: + - `../CLAUDE.md`(上级目录 = 全局记忆) + - `./CLAUDE.md`(当前目录 = 群组记忆) + +2. **写入记忆** + - 当用户说"记住这个",agent 写入 `./CLAUDE.md` + - 当用户说"全局记住这个"(仅主渠道),agent 写入 `../CLAUDE.md` + - Agent 可以在群组目录中创建 `notes.md`、`research.md` 等文件 + +3. **主渠道权限** + - 只有"主"群组(自我聊天)可以向全局记忆写入 + - 主群组可以管理已注册的群组并为任何群组安排定时任务 + - 主群组可以为任何群组配置额外的目录挂载 + - 所有群组都有 Bash 访问权限(安全,因为在容器内运行) + +--- + +## Session 管理 + +Session 实现了对话连续性——Claude 会记住你们之前谈过的内容。 + +### Session 如何运作 + +1. 每个群组在 SQLite 中有一个 session ID(`sessions` 表,按 `group_folder` 索引) +2. Session ID 传递给 Claude Agent SDK 的 `resume` 选项 +3. Claude 以完整上下文继续对话 +4. Session 对话记录以 JSONL 文件形式存储在 `data/sessions/{group}/.claude/` + +--- + +## 消息流转 + +### 入站消息流程 + +``` +1. 用户通过任何已连接的渠道发送消息 + │ + ▼ +2. 渠道接收消息(例如 WhatsApp 使用 Baileys,Telegram 使用 Bot API) + │ + ▼ +3. 消息存入 SQLite(store/messages.db) + │ + ▼ +4. 消息循环轮询 SQLite(每2秒) + │ + ▼ +5. 路由检查: + ├── chat_jid 是否在已注册群组中(SQLite)?→ 否:忽略 + └── 消息是否匹配触发模式?→ 否:存储但不处理 + │ + ▼ +6. 路由追上对话: + ├── 获取自上次 agent 交互以来的所有消息 + ├── 使用时间戳和发送者名称格式化 + └── 构建包含完整对话上下文的 prompt + │ + ▼ +7. 路由调用 Claude Agent SDK: + ├── cwd: groups/{group-name}/ + ├── prompt: 对话历史 + 当前消息 + ├── resume: session_id(用于连续性) + └── mcpServers: nanoclaw(调度器) + │ + ▼ +8. Claude 处理消息: + ├── 读取 CLAUDE.md 文件获取上下文 + └── 按需使用工具(搜索、邮件等) + │ + ▼ +9. 路由在响应前添加助手名称前缀,并通过所属渠道发送 + │ + ▼ +10. 路由更新最后 agent 时间戳并保存 session ID +``` + +### 触发词匹配 + +消息必须以触发模式开头(默认:`@Andy`): +- `@Andy what's the weather?` → ✅ 触发 Claude +- `@andy help me` → ✅ 触发(不区分大小写) +- `Hey @Andy` → ❌ 忽略(触发词不在开头) +- `What's up?` → ❌ 忽略(无触发词) + +### 对话追上 + +当触发消息到达时,agent 会收到自上次在该聊天中交互以来的所有消息。每条消息都带有时间戳和发送者名称: + +``` +[Jan 31 2:32 PM] John: hey everyone, should we do pizza tonight? +[Jan 31 2:33 PM] Sarah: sounds good to me +[Jan 31 2:35 PM] John: @Andy what toppings do you recommend? +``` + +这使得 agent 能够理解对话上下文,即使它没有在每条消息中被提及。 + +--- + +## 命令 + +### 任何群组中可用的命令 + +| 命令 | 示例 | 效果 | +|---------|---------|--------| +| `@Assistant [message]` | `@Andy what's the weather?` | 与 Claude 对话 | + +### 仅主渠道可用的命令 + +| 命令 | 示例 | 效果 | +|---------|---------|--------| +| `@Assistant add group "Name"` | `@Andy add group "Family Chat"` | 注册新群组 | +| `@Assistant remove group "Name"` | `@Andy remove group "Work Team"` | 取消注册群组 | +| `@Assistant list groups` | `@Andy list groups` | 显示已注册群组 | +| `@Assistant remember [fact]` | `@Andy remember I prefer dark mode` | 添加到全局记忆 | + +--- + +## 定时任务 + +NanoClaw 内置调度器,将任务作为完整 agent 在其群组上下文中运行。 + +### 调度如何运作 + +1. **群组上下文**:在群组中创建的任务使用该群组的工作目录和记忆运行 +2. **完整的 Agent 能力**:定时任务可以访问所有工具(WebSearch、文件操作等) +3. **可选的消息发送**:任务可以使用 `send_message` 工具向其群组发送消息,也可以静默完成 +4. **主渠道权限**:主渠道可以为任何群组安排任务并查看所有任务 + +### 调度类型 + +| 类型 | 值格式 | 示例 | +|------|--------------|---------| +| `cron` | Cron 表达式 | `0 9 * * 1`(每周一上午9点) | +| `interval` | 毫秒 | `3600000`(每小时) | +| `once` | ISO 时间戳 | `2024-12-25T09:00:00Z` | + +### 创建任务 + +``` +User: @Andy remind me every Monday at 9am to review the weekly metrics + +Claude: [调用 mcp__nanoclaw__schedule_task] + { + "prompt": "Send a reminder to review weekly metrics. Be encouraging!", + "schedule_type": "cron", + "schedule_value": "0 9 * * 1" + } + +Claude: Done! I'll remind you every Monday at 9am. +``` + +### 一次性任务 + +``` +User: @Andy at 5pm today, send me a summary of today's emails + +Claude: [调用 mcp__nanoclaw__schedule_task] + { + "prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.", + "schedule_type": "once", + "schedule_value": "2024-01-31T17:00:00Z" + } +``` + +### 管理任务 + +在任何群组中: +- `@Andy list my scheduled tasks` - 查看此群组的任务 +- `@Andy pause task [id]` - 暂停任务 +- `@Andy resume task [id]` - 恢复暂停的任务 +- `@Andy cancel task [id]` - 删除任务 + +在主渠道中: +- `@Andy list all tasks` - 查看所有群组的任务 +- `@Andy schedule task for "Family Chat": [prompt]` - 为其他群组安排任务 + +--- + +## MCP 服务器 + +### NanoClaw MCP(内置) + +`nanoclaw` MCP 服务器在每次 agent 调用时动态创建,带有当前群组的上下文。 + +**可用工具:** +| 工具 | 用途 | +|------|---------| +| `schedule_task` | 安排定期或一次性任务 | +| `list_tasks` | 显示任务(本群组的任务,主群组则显示全部) | +| `get_task` | 获取任务详情和运行历史 | +| `update_task` | 修改任务的 prompt 或调度 | +| `pause_task` | 暂停任务 | +| `resume_task` | 恢复暂停的任务 | +| `cancel_task` | 删除任务 | +| `send_message` | 通过群组的渠道发送消息 | + +--- + +## 部署 + +NanoClaw 作为单个 macOS launchd 服务运行。 + +### 启动序列 + +NanoClaw 启动时会: +1. **确保容器运行时正在运行**——如需要自动启动;终止前次运行遗留的 NanoClaw 孤儿容器 +2. 初始化 SQLite 数据库(如果存在 JSON 文件则从中迁移) +3. 从 SQLite 加载状态(已注册群组、sessions、路由状态) +4. **连接渠道**——遍历已注册渠道,实例化有凭证的渠道,对每个调用 `connect()` +5. 一旦至少有一个渠道连接: + - 启动调度器循环 + - 启动用于容器消息的 IPC 监听器 + - 设置按群组队列及 `processGroupMessages` + - 恢复关闭前未处理的消息 + - 启动消息轮询循环 + +### 服务:com.nanoclaw + +**launchd/com.nanoclaw.plist:** +```xml + + + + + Label + com.nanoclaw + ProgramArguments + + {{NODE_PATH}} + {{PROJECT_ROOT}}/dist/index.js + + WorkingDirectory + {{PROJECT_ROOT}} + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + {{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin + HOME + {{HOME}} + ASSISTANT_NAME + Andy + + StandardOutPath + {{PROJECT_ROOT}}/logs/nanoclaw.log + StandardErrorPath + {{PROJECT_ROOT}}/logs/nanoclaw.error.log + + +``` + +### 管理服务 + +```bash +# 安装服务 +cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/ + +# 启动服务 +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist + +# 停止服务 +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist + +# 检查状态 +launchctl list | grep nanoclaw + +# 查看日志 +tail -f logs/nanoclaw.log +``` + +--- + +## 安全考量 + +### 容器隔离 + +所有 agent 在容器(轻量级 Linux VM)内运行,提供: +- **文件系统隔离**:Agent 只能访问已挂载的目录 +- **安全的 Bash 访问**:命令在容器内运行,而不是在您的 Mac 上 +- **网络隔离**:可按容器配置(如需要) +- **进程隔离**:容器进程无法影响宿主机 +- **非 root 用户**:容器以非特权 `node` 用户(uid 1000)运行 + +### Prompt 注入风险 + +WhatsApp 消息可能包含试图操纵 Claude 行为的恶意指令。 + +**缓解措施:** +- 容器隔离限制爆炸半径 +- 仅处理已注册的群组 +- 需要触发词(减少误处理) +- Agent 只能访问其群组挂载的目录 +- 主群组可以按群组配置额外目录 +- Claude 内置的安全训练 + +**建议:** +- 仅注册受信任的群组 +- 仔细审查额外的目录挂载 +- 定期审查定时任务 +- 监控日志中的异常活动 + +### 凭证存储 + +| 凭证 | 存储位置 | 说明 | +|------------|------------------|-------| +| Claude CLI Auth | data/sessions/{group}/.claude/ | 按群组隔离,挂载到 /home/node/.claude/ | +| WhatsApp Session | store/auth/ | 自动创建,持续约20天 | + +### 文件权限 + +groups/ 目录包含个人记忆,应加以保护: +```bash +chmod 700 groups/ +``` + +--- + +## 故障排除 + +### 常见问题 + +| 问题 | 原因 | 解决方案 | +|-------|-------|----------| +| 消息无响应 | 服务未运行 | 检查 `launchctl list | grep nanoclaw` | +| "Claude Code process exited with code 1" | 容器运行时启动失败 | 检查日志;NanoClaw 会自动启动容器运行时,但可能失败 | +| "Claude Code process exited with code 1" | Session 挂载路径错误 | 确保挂载到 `/home/node/.claude/` 而非 `/root/.claude/` | +| Session 无法继续 | Session ID 未保存 | 检查 SQLite:`sqlite3 store/messages.db "SELECT * FROM sessions"` | +| Session 无法继续 | 挂载路径不匹配 | 容器用户是 `node`,HOME=/home/node;sessions 必须位于 `/home/node/.claude/` | +| "QR code expired" | WhatsApp session 过期 | 删除 store/auth/ 并重启 | +| "No groups registered" | 尚未添加群组 | 在主渠道中使用 `@Andy add group "Name"` | + +### 日志位置 + +- `logs/nanoclaw.log` - stdout +- `logs/nanoclaw.error.log` - stderr + +### 调试模式 + +手动运行以获取详细输出: +```bash +pnpm run dev +# 或 +node dist/index.js +``` diff --git a/docs/zh/agent-runner-details.md b/docs/zh/agent-runner-details.md new file mode 100644 index 0000000..b448952 --- /dev/null +++ b/docs/zh/agent-runner-details.md @@ -0,0 +1,749 @@ +# NanoClaw Agent-Runner 详解 + +容器内 agent-runner(代理运行器)的实现级细节。高层设计请参见 [architecture.md](architecture.md)。 + +## 关注点分离 + +agent-runner 分为两层: + +1. **Agent-runner 核心** — 负责 poll loop(轮询循环)、消息格式化、数据库读写、MCP tool(MCP 工具)实现、路由、状态管理、媒体处理。此部分是 NanoClaw 专有的,跨所有 provider(提供器)共享。 + +2. **Agent provider(代理提供器)** — 负责 SDK 交互。接收格式化后的提示词,将其推送到 SDK,并回传事件。主干代码内置 `claude` provider;其他 provider(OpenCode、Codex 等)通过 `/add-` 技能从 `providers` 分支安装。 + +边界划分:agent-runner 决定**发送什么**以及**如何处理结果**。provider 决定**如何与 SDK 通信**。 + +## AgentProvider 接口 + +```typescript +interface AgentProvider { + /** 启动一个新的查询。返回一个用于流式输入和输出的句柄。 */ + query(input: QueryInput): AgentQuery; +} + +interface QueryInput { + /** 初始提示词(已由 agent-runner 格式化)。 + * 纯文本时使用 String。多模态(图片、PDF、音频)时使用 ContentBlock[]。 */ + prompt: string | ContentBlock[]; + + /** 要恢复的会话 ID(如有) */ + sessionId?: string; + + /** 从会话的特定位置恢复(provider 特定字段,可能被忽略) */ + resumeAt?: string; + + /** 容器内的工作目录 */ + cwd: string; + + /** MCP server(MCP 服务器)配置(标准化格式 — provider 负责转换) */ + mcpServers: Record; + + /** 系统提示词 / 开发者指令 */ + systemPrompt?: string; + + /** SDK 进程的环境变量 */ + env: Record; + + /** agent 可访问的额外目录 */ + additionalDirectories?: string[]; +} + +interface McpServerConfig { + command: string; + args: string[]; + env: Record; +} + +interface AgentQuery { + /** 将一条后续消息推送到活动查询中 */ + push(message: string): void; + + /** 表示不再发送更多输入 */ + end(): void; + + /** 输出事件流 */ + events: AsyncIterable; + + /** 强制停止查询(例如容器正在关闭) */ + abort(): void; +} + +type ProviderEvent = + | { type: 'init'; sessionId: string } + | { type: 'result'; text: string | null } + | { type: 'error'; message: string; retryable: boolean; classification?: string } + | { type: 'progress'; message: string }; +``` + +### 接口不包含的内容 + +- **消息格式化** — agent-runner 在传递给 provider 之前格式化消息。provider 接收的是可直接发送的提示词字符串。 +- **Hooks(钩子)** — Claude 特有功能。Claude provider 在内部注册 hooks(PreCompact、PreToolUse 等)。其他 provider 不需要。 +- **工具许可列表** — Claude 使用 `allowedTools`。Codex 使用 `approvalPolicy`。OpenCode 使用 `permission`。各 provider 基于相同的意图("允许一切,无需提示")在内部自行配置。 +- **会话持久化** — Claude 自动将会话持久化到磁盘。Codex 和 OpenCode 管理各自的 session 状态。agent-runner 不控制这一点 — 它只传递 `sessionId` 和 `resumeAt`。 +- **沙箱配置** — provider 特定。各 provider 在内部配置自己的沙箱。 + +### Provider 事件语义 + +- **`init`** — 每次查询当 provider 建立或恢复 session 时发送一次。agent-runner 捕获 `sessionId` 用于后续恢复。 +- **`result`** — 当 agent 生成完整响应时发送。每次查询可发送多次(例如 Claude 的多轮子 agent 交互)。agent-runner 将每个 result 写入 `messages_out`。 +- **`error`** — 失败时发送。`retryable` 指示 agent-runner 是否应重试。`classification` 是可选的详细分类(如 `'quota'`、`'auth'`、`'transport'`)。 +- **`progress`** — 可选,用于日志记录。agent-runner 记录这些事件但不据此作出行动。 + +## Provider 实现 + +主干代码仅内置 `claude` provider。下文的 Codex 和 OpenCode 章节记录了 provider 接口以供参考,以及供安装额外 provider 的技能使用 — 它们并非核心镜像的内置部分。 + +### Claude Provider + +封装 `@anthropic-ai/claude-agent-sdk` 的 `query()`。 + +```typescript +class ClaudeProvider implements AgentProvider { + query(input: QueryInput): AgentQuery { + const stream = new MessageStream(); // AsyncIterable + 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 { + 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 { + 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`) +- 通过提示词文本中以 `` 前缀注入系统提示词 +- 不支持恢复(session 始终是新建的或按 ID 复用的) + +## Agent-Runner 核心 + +以下所有内容均由 agent-runner 处理,而非 provider。 + +### 轮询循环 + +``` +┌─────────────────────────────────────────┐ +│ │ +│ 1. 查询 messages_in 中待处理的行 │ +│ WHERE status = 'pending' │ +│ AND (process_after IS NULL │ +│ OR process_after <= now()) │ +│ │ +│ 2. 如果找到行: │ +│ a. 设置 status = 'processing' │ +│ b. 按 kind 格式化消息 │ +│ c. 剥离路由字段 │ +│ d. 调用 provider.query(prompt) │ +│ e. 处理 provider 事件 │ +│ f. 将结果写入 messages_out │ +│ g. 设置 status = 'completed' │ +│ │ +│ 3. 当查询处于活动状态时: │ +│ - 继续轮询 messages_in │ +│ - 新消息 → provider.push() │ +│ │ +│ 4. 查询结束时: │ +│ - 回到步骤 1 │ +│ - 若无消息,休眠并重新轮询 │ +│ │ +└─────────────────────────────────────────┘ +``` + +**活动查询期间的并发轮询:** 当 provider 正在运行查询时,agent-runner 以短间隔(约 500ms)持续轮询 `messages_in`。新的待处理消息被格式化并通过 `provider.push()` 推送到活动查询中。这使得在 agent 处理过程中后续消息可以到达 — Claude 原生支持此方式,Codex/OpenCode 通过内部的中止+重启来处理。 + +**空闲行为:** 当没有待处理消息且没有活动查询时,agent-runner 短暂休眠(1s)并重新轮询。容器保持运行状态直到 host(主机)将其终止(空闲超时)。 + +**空闲检测的例外情况:** 在以下情况下容器**不应**被视为空闲: +- 一个 `ask_user_question` 工具调用正在等待(等待用户在 `messages_in` 中的响应) +- agent 正在活跃工作中(工具调用进行中、子 agent 运行中) + +agent-runner 向 host 发送"忙碌"状态信号。具体机制因 provider 而异 — 对于 Claude,查询 AsyncGenerator 仍在产出事件。对于其他 provider,agent-runner 可以将心跳或状态指示器写入 session DB(会话数据库),host 在终止前会检查该状态。 + +### 消息格式化 + +agent-runner 将 `messages_in` 行转换为提示词字符串。provider 接收的是可直接发送的字符串 — 它不知道消息的种类或路由信息。 + +**路由字段剥离:** `platform_id`、`channel_type`、`thread_id` 永远不会包含在提示词中。它们作为上下文存储,用于写入 `messages_out`。 + +**按 kind 分类的单条消息格式化:** + +- **`chat`** — 格式化为消息 XML: + ```xml + + Check this PR + + ``` + +- **`chat-sdk`** — 从序列化的 Chat SDK 消息中提取字段: + ```xml + + Check this PR + [image: screenshot.png — https://signed-url...] + + ``` + 附件以内联方式列出。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 + + +Check this PR +Already on it + +``` + +混合 kind(例如一条 chat 消息 + 一条 system 响应)用清晰的分隔符组合。每个部分按 kind 标注。 + +**命令检测:** 以 `/` 开头的消息会与命令列表进行匹配。识别到的命令会绕过格式化,原样传递给 provider(用于 Claude 的斜杠命令处理),或被 agent-runner 拦截(用于会话重置等 NanoClaw 级别的命令)。 + +### 路由 + +当 agent-runner 拾取 `messages_in` 行时,它会从批次中捕获路由字段: + +```typescript +interface RoutingContext { + platformId: string | null; + channelType: string | null; + threadId: string | null; + inReplyTo: string | null; // 触发消息的 messages_in.id +} +``` + +写入 `messages_out`(无论是来自 provider 结果还是 MCP tool 调用)时,agent-runner 默认会复制此路由上下文。agent 永远不会看到路由字段 — 它只生成文本。路由是隐式的:"回复发送消息的人。" + +目标为其他目的地的 MCP tool(例如带显式 channel 参数的 `send_to_agent`、`send_message`)会覆盖该特定 `messages_out` 行的路由上下文。 + +### 状态管理 + +agent-runner 管理 `messages_in` 上的 `status` 和 `status_changed` 字段: + +``` +pending → processing → completed + → failed (若 provider 返回错误且已达到最大重试次数) +``` + +- **拾取:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)` +- **完成:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)` +- **错误:** agent-runner 不设置 `failed` — 它将消息保持为 `processing`。host 通过 `status_changed` 检测过时的 processing 状态并处理重试逻辑(重置为 pending,带退避策略)。这样将重试策略保留在 host 端。 + +### MCP 工具 + +agent-runner 运行一个 MCP server(MCP 服务器),向 agent 暴露 NanoClaw 工具。所有工具都写入 session DB。 + +**数据库路径:** MCP server 通过环境变量接收 session 数据库路径。它打开第二个连接到同一个 SQLite 文件(WAL 模式允许并发访问)。 + +#### send_message + +向当前对话(或指定 destination(目的地))发送聊天消息。 + +```typescript +{ + name: 'send_message', + params: { + text: string, // 消息内容 + channel?: string, // 可选:目标 channel 类型(默认:回复来源) + platformId?: string, // 可选:目标 platform ID + threadId?: string, // 可选:目标 thread ID + } +} +``` + +实现:写入一个 `messages_out` 行,`kind: 'chat'`。如果提供了 channel/platformId/threadId,则使用这些作为路由。否则,从当前路由上下文中复制。 + +#### send_file + +向当前对话发送文件。 + +```typescript +{ + name: 'send_file', + params: { + path: string, // 文件路径(相对于 /workspace/agent/ 或绝对路径) + text?: string, // 可选:附带消息 + filename?: string, // 显示名称(默认:path 的 basename) + } +} +``` + +实现: +1. 生成消息 ID +2. 创建 `outbox/{messageId}/` 目录 +3. 将文件复制到 outbox 目录中 +4. 写入一个 `messages_out` 行,内容中包含 `files: [filename]` + +#### send_card + +发送结构化卡片(交互式或仅展示)。 + +```typescript +{ + name: 'send_card', + params: { + card: CardElement, // 卡片结构(title、children、actions) + fallbackText?: string, // 不支持卡片的平台使用的文本回退 + } +} +``` + +实现:写入一个 `messages_out` 行,`kind: 'chat-sdk'`,内容中包含卡片结构。 + +#### ask_user_question + +发送一个交互式问题并等待用户响应。这是一个**阻塞式工具调用** — 直到用户响应后工具才会返回。 + +```typescript +{ + name: 'ask_user_question', + params: { + title: string, // 简短卡片标题,例如 "Confirm deletion" + question: string, + options: (string | { label: string; selectedLabel?: string; value?: string })[], + timeout?: number, // 秒(默认:300) + } +} +``` + +实现: +1. 生成 `questionId` +2. 写入一个 `messages_out` 行,包含 `operation: 'ask_question'`、问题、选项和 questionId +3. 轮询 `messages_in` 中是否有内容匹配的 `questionId` 的行 +4. 找到后,将 `selectedOption` 作为工具结果返回 +5. 如果超时到期,将超时错误作为工具结果返回 + +agent 的执行在此工具调用处暂停。provider 的查询继续运行(Claude 保持工具调用打开)。agent-runner 在单独循环中轮询响应。 + +#### edit_message + +编辑先前发送的消息。 + +```typescript +{ + name: 'edit_message', + params: { + messageId: string, // 显示给 agent 的整数 ID + text: string, // 新内容 + } +} +``` + +实现:写入一个 `messages_out` 行,包含 `operation: 'edit'`、消息 ID 和新文本。 + +#### add_reaction + +向消息添加表情反应。 + +```typescript +{ + name: 'add_reaction', + params: { + messageId: string, // 显示给 agent 的整数 ID + emoji: string, // 表情名称(如 'thumbs_up') + } +} +``` + +实现:写入一个 `messages_out` 行,包含 `operation: 'reaction'`。 + +#### send_to_agent + +向另一个 agent group(代理组)发送消息。 + +```typescript +{ + name: 'send_to_agent', + params: { + agentGroupId: string, // 目标 agent group + text: string, // 消息内容 + sessionId?: string, // 可选:目标特定 session + } +} +``` + +实现:写入一个 `messages_out` 行,包含 `channel_type: 'agent'`、`platform_id: agentGroupId`、`thread_id: sessionId`。 + +#### schedule_task + +安排一次性或 recurring task(周期性任务)。 + +```typescript +{ + name: 'schedule_task', + params: { + prompt: string, // 任务提示词 + processAfter: string, // 首次运行的 ISO 时间戳 + recurrence?: string, // cron 表达式(可选) + script?: string, // 前置脚本(可选) + } +} +``` + +实现:写入一个 `messages_in` 行(发给自己),包含 `kind: 'task'`、`process_after` 和可选的 `recurrence`。host sweep 在到期时拾取。 + +#### list_tasks + +列出活跃的 scheduled/recurring 任务。 + +```typescript +{ + name: 'list_tasks', + params: {} +} +``` + +实现:查询 `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`。 + +#### cancel_task / pause_task / resume_task / update_task + +修改已安排的任务。 + +```typescript +{ + name: 'cancel_task', + params: { taskId: string } +} +// pause_task: 设置 status = 'paused'(周期性任务的新状态值) +// resume_task: 设置 status = 'pending' +// update_task: 将 { prompt?, recurrence?, processAfter?, script? } 合并到活动行中 +``` + +实现:cancel/pause/resume 直接更新活动行。update_task 作为 system action 发送 — host 读取当前内容,合并提供的字段,并写回。所有四个操作均通过 `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')` 匹配,因此即使 agent 传入原始(现已完成的)id,也能触及周期性任务的下一个活动实例。 + +#### register_agent_group + +注册一个新的 agent group(仅限 admin)。 + +```typescript +{ + name: 'register_agent_group', + params: { + name: string, + folder: string, + platformId: string, // 要连接的 messaging group + channelType: string, + triggerRules?: object, + sessionMode?: 'shared' | 'per-thread', + } +} +``` + +实现:写入一个 `messages_out` 行,包含 `kind: 'system'`、`action: 'register_agent_group'`。host 读取、验证 admin 权限、在 central DB 中创建实体行,并写入一个 `system` 类型的 `messages_in` 响应。 + +### 媒体处理 + +#### 入站(messages_in → agent 提示词) + +agent-runner 检查 chat/chat-sdk 消息中的附件(attachment),并根据类型和 provider 能力进行处理: + +**Provider 原生的 content block:** + +| 类型 | Claude | Codex / OpenCode | +|------|--------|------------------| +| 图片(JPEG、PNG、GIF、WebP) | 原生图片 content block | 保存到磁盘 | +| PDF | 原生文档 content block | 保存到磁盘 | +| 音频 | 原生音频 content block | 保存到磁盘 | +| 其他文件(代码、数据、视频、存档) | 保存到磁盘 | 保存到磁盘 | + +**"保存到磁盘"** 的含义是:下载到 `/workspace/downloads/{messageId}/`,在提示词文本中引用: + +``` + + Check this spreadsheet + [file available at: /workspace/downloads/msg-123/data.xlsx] + +``` + +agent 可以使用工具(Read、Bash)访问保存的文件。 + +对于无法直接下载的 channel(例如 WhatsApp 缓冲流),channel adapter 通过本地 URL 提供媒体内容。agent-runner 从该 URL 下载。 + +**Content block 构建(Claude):** agent-runner 构建多部分 `MessageParam` 内容:`[{ type: 'image', source: { type: 'base64', media_type, data } }, { type: 'text', text: '...' }]`。此时传递给 provider 的提示词不是纯字符串 — `QueryInput.prompt` 字段需要支持 Claude 的结构化内容。provider 的 `query()` 方法处理特定格式的构建。 + +**Content block 构建(Codex/OpenCode):** 一切皆为文本。文件引用以内联方式出现在提示词字符串中。provider 接收的是纯字符串提示词。 + +#### 出站(agent → messages_out) + +通过 `send_file` MCP tool 处理(参见上文)。agent 显式决定发送文件 — agent-runner 不会扫描输出中的文件引用。 + +### 任务前置脚本 + +对于 `kind` 为 `task` 且内容中包含 `script` 字段的消息: + +1. agent-runner 将脚本写入临时文件 +2. 使用 `bash` 执行(30s 超时) +3. 将 stdout 最后一行解析为 JSON:`{ wakeAgent: boolean, data?: unknown }` +4. 如果 `wakeAgent === false`:将消息标记为已完成,不调用 provider +5. 如果 `wakeAgent === true`:用脚本输出丰富提示词,然后调用 provider + +### 对话转录归档 + +agent-runner 在上下文压缩前归档对话转录。对于 Claude,这通过 PreCompact hook 处理(provider 内部)。对于其他没有 hooks 的 provider,agent-runner 在每次查询完成后基于 provider 的输出进行归档。 + +归档位置:`/workspace/agent/conversations/{date}-{summary}.md` + +### 会话恢复 + +agent-runner 在查询之间跟踪 `sessionId` 和 `resumeAt`: + +- `sessionId` — 从 `ProviderEvent { type: 'init' }` 中捕获。在下一次查询时传回 `QueryInput.sessionId`。 +- `resumeAt` — Claude 特有(最后一条 assistant 消息的 UUID)。由 agent-runner 存储,传递给 `QueryInput.resumeAt`。不支持的 provider 会忽略它。 + +这些是容器生命周期的临时数据。当容器被终止并重启时,host 会传递从 central DB sessions 表中存储的 `sessionId`。`resumeAt` 在容器重启时丢失(provider 从 session 末尾恢复)。 + +### 容器启动 + +agent-runner 通过以下方式接收配置: + +- **环境变量:** `AGENT_PROVIDER`(claude/codex/opencode)、`NANOCLAW_ADMIN_USER_ID`、provider 特定变量(API 密钥、模型覆盖)、`TZ` +- **固定挂载路径:** Session DB 位于 `/workspace/session.db`。Agent group 文件夹位于 `/workspace/agent/`。系统提示词来自 `/workspace/agent/CLAUDE.md` 和 `/workspace/global/CLAUDE.md`。 +- **可选启动配置:** 部分配置可能以 JSON 文件形式传递到固定路径(例如 `/workspace/config.json`),用于诸如要恢复的 session ID、assistant 名称和 admin user ID 等内容。这避免了对环境变量的过度使用。 + +agent-runner 读取配置,创建 provider,并进入轮询循环。无标准输入,无初始提示词 — 消息已经存在于 session DB 中。 + +### Provider 工厂 + +```typescript +type ProviderName = 'claude' | string; + +function createProvider(name: ProviderName, config: ProviderConfig): AgentProvider { + // 主干代码注册 'claude';其他 provider 在通过技能安装时自行注册。 + const factory = providerRegistry.get(name); + if (!factory) throw new Error(`Unknown provider: ${name}`); + return factory(config); +} +``` + +provider 名称来自容器的环境变量(`AGENT_PROVIDER` env var),由 host 根据 `agent_groups.agent_provider` 或 `sessions.agent_provider` 设置。 + +`ProviderConfig` 包含 provider 特定设置(API 密钥、模型覆盖等),通过环境变量传递 — 而非通过接口。每个 provider 根据自身需要从 `env` 中读取。 + +## Agent-Runner 属性 + +- MCP server 是由 provider(通过 `mcpServers` 配置)启动的独立 Node 进程 +- MCP server 二进制文件跨 provider 共享 — 相同的工具、相同的数据库访问 +- CLAUDE.md 加载(全局 + 每组) — agent-runner 读取并作为 `systemPrompt` 传递 +- 额外目录发现(`/workspace/extra/*`) +- 通过 stderr 进行日志记录(`[agent-runner] ...`) + +## 相关文档 + +- **[architecture.md](architecture.md)** — 高层架构(session DB schema、central DB、channel adapter、消息流) +- **[api-details.md](api-details.md)** — Channel adapter 接口、消息内容示例、host delivery 逻辑 diff --git a/docs/zh/api-details.md b/docs/zh/api-details.md new file mode 100644 index 0000000..4f4a15d --- /dev/null +++ b/docs/zh/api-details.md @@ -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; + teardown(): Promise; + isConnected(): boolean; + + // 出站投递 + deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; + + // 可选 + setTyping?(platformId: string, threadId: string | null): Promise; + syncConversations?(): Promise; + updateConversations?(conversations: ConversationConfig[]): void; +} + +// 从 adapter 到 host 的入站消息 +interface InboundMessage { + id: string; + kind: 'chat' | 'chat-sdk'; + content: unknown; // JSON blob — NanoClaw chat 格式或 Chat SDK SerializedMessage + timestamp: string; +} + +// 从 host 到 adapter 的出站消息 +interface OutboundMessage { + kind: 'chat' | 'chat-sdk'; + content: unknown; // JSON blob — 与 kind 对应 +} +``` + +### Chat SDK Bridge(Chat SDK 桥接) + +封装一个 Chat SDK adapter + Chat 实例以符合 NanoClaw `ChannelAdapter` 接口。主干代码仅内置桥接层和 channel registry(通道注册表) — 平台特定的 Chat SDK adapter(Discord、Slack、Telegram 等)和原生 adapter(WhatsApp/Baileys)由 `/add-` 技能从 `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; + 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)。 diff --git a/docs/zh/architecture-diagram.md b/docs/zh/architecture-diagram.md new file mode 100644 index 0000000..e3e6760 --- /dev/null +++ b/docs/zh/architecture-diagram.md @@ -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 桥接
(src/channels/chat-sdk-bridge.ts)"] + Router["路由器
(src/router.ts)
platformId + threadId -> messaging_group -> agent_group -> session"] + SessMgr["Session 管理器
(src/session-manager.ts)
创建 inbound.db + outbound.db"] + Runner["容器运行器
(src/container-runner.ts)
OneCLI ensureAgent + 启动"] + Delivery["投递轮询器
(src/delivery.ts)
活跃时 1s / sweep 时 60s"] + Sweep["宿主机 Sweep
(src/host-sweep.ts)
心跳、重试、重复执行"] + Central[("中央库
data/v2.db
agent_groups
messaging_groups
messaging_group_agents
sessions
pending_approvals")] + end + + subgraph OneCLI["OneCLI 网关 (0.3.1)"] + Vault["Agent Vault
秘密 + OAuth"] + Approvals["configureManualApproval
-> pending_approvals"] + end + + subgraph Session["每个 Session 的容器 (Docker / Apple Container)"] + direction TB + PollLoop["轮询循环
(container/agent-runner)"] + Provider["Agent 提供程序
(claude、opencode、mock;待办: codex)"] + MCP["MCP 工具
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server"] + Skills["容器技能
(container/skills/)"] + InDB[("inbound.db
宿主机写入
偶数 seq
messages_in
destinations
processing_ack")] + OutDB[("outbound.db
容器写入
奇数 seq
messages_out
心跳文件")] + end + + subgraph Groups["Agent Group 文件系统 (groups/*)"] + Folder["CLAUDE.md
记忆
每个 group 的技能
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
(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)
解析 块 + D->>ODB: 1s 轮询(活跃)/ 60s(sweep) + D->>D: hasDestination() 重新验证 + D->>B: 通过适配器投递 + B->>P: 发送消息 / 编辑 / 反应 / 文件 / 卡片 +``` + +## 命名目的地 + Agent 到 Agent + +```mermaid +flowchart LR + subgraph AgentA["Agent Group A (主agent)"] + A_out["输出:
<message to='slack'>...</message>
<message to='browser-agent'>...</message>
<internal>scratchpad</internal>"] + end + + subgraph Dests["inbound.db.destinations (每个 agent)"] + D1["slack -> messaging_group 42"] + D2["browser-agent -> agent_group 7
(双向行)"] + D3["github -> messaging_group 13"] + end + + subgraph AgentB["Agent Group B (浏览器子agent)"] + B_session["自己的 inbound.db / outbound.db
继承了返回到 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 "命名空间 :" + 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[宿主机进程] -->|"仅写入
(偶数 seq)"| In + Host -->|读取| Out + Container[agent-runner] -->|读取| In + Container -->|"仅写入
(奇数 seq)"| Out + Container -->|每次轮询 touch| HB + HostSweep[宿主机 sweep] -->|stat mtime| HB + HostSweep -->|读取 processing_ack| In + + note1["每个文件有且仅有一个写入者。
消除了 SQLite 跨进程写入竞争。
无冲突的 seq 编号。"] +``` diff --git a/docs/zh/architecture.md b/docs/zh/architecture.md new file mode 100644 index 0000000..2b554dd --- /dev/null +++ b/docs/zh/architecture.md @@ -0,0 +1,911 @@ +# NanoClaw 架构(草案) + +## 核心理念 + +每个智能体会话(agent session)都有一个挂载的 SQLite 数据库(DB)。该数据库是宿主机(host)与容器(container)之间的唯一 IO 机制。没有 IPC 文件,没有 stdin 管道。两张表:`messages_in`(宿主机 → agent-runner)和 `messages_out`(agent-runner → 宿主机)。一切皆消息。 + +## 两级数据库 + +**中央数据库(Central DB,宿主机进程内):** +- 智能体组(agent group)、对话、路由(routing)表 +- 将平台 ID 映射到 agent group → 会话(session) +- 通道适配器(channel adapter)不直接访问此库——宿主机负责查找 + +**逐会话数据库(Per-session DB,挂载到容器内):** +- `messages_in`(宿主机写入,agent-runner 读取) +- `messages_out`(agent-runner 写入,宿主机读取) +- 一切皆消息:聊天、任务、Webhook、系统操作、智能体间通信——均使用这两张表 +- 每个 session 一个 DB,而非每个 agent group 一个 + +## Agent Group 与 Session + +一个 agent group 拥有自己的文件系统——文件夹、CLAUDE.md、技能(skills)、容器配置。多个 session 可以共享同一个 agent group(相同的文件系统、相同的 skills),但每个 session 都有自己独立的 DB,挂载在已知路径上。每个 session = 一个独立的容器,使用相同 agent group 的文件系统但拥有不同的 session DB。 + +## 消息流程 + +``` +平台事件 + → 通道适配器(Channel Adapter,触发器检查、ID 提取) + → 返回:{ platformChannelId, platformThreadId, triggered } + → 宿主机将 platformChannelId + platformThreadId 映射到 agent group + session + → 宿主机将消息写入 session 的 DB + → 宿主机调用 wakeUpAgent(session) + → 容器启动(或已在运行) + → Agent-runner 轮询其 session DB,发现新消息 + → Agent-runner 调用 Claude 进行处理 + → Agent-runner 将响应写入 session DB + → 宿主机轮询活跃 session DB 以获取响应 + → 宿主机读取响应,查找对话,通过 channel adapter 进行投递(delivery) +``` + +## 通道适配器(Channel Adapter) + +Channel adapter 负责: +1. 接收平台事件(Webhook、轮询(polling)、WebSocket——平台特定) +2. **过滤**:决定将哪些消息转发给宿主机处理。可以是无状态的(正则触发器匹配)或有状态的(例如,"该机器人曾在此线程中被提及过吗?如果是,则转发所有后续消息")。Adapter 接收未经过滤的平台消息流,并决定传递哪些消息。如何决定是实现细节——NanoClaw 不关心也不需知道。 +3. 提取并标准化两个 ID: + - **Platform channel ID**——标识对话(WhatsApp 群组、Slack 频道、邮件线程) + - **Platform thread ID**——可选的子上下文(Slack 消息分支、GitHub PR 评论分支) +4. 出站投递——将响应发送回平台 + +Channel adapter 不知道 agent group ID 或 session ID。它返回平台级别的标识符。宿主机将这些映射到实体模型。 + +两级 ID 方案(channel ID + thread ID)提供了灵活性: +- 希望每个 Slack 消息分支都是独立的 session?返回唯一的 thread ID。 +- 希望 Slack 频道中的所有消息共享一个 session?返回相同的 thread ID(或 null)。 +- 这是按通道配置的,而非全局配置。 + +### Channel Adapter 配置 + +Adapter 是无状态的——它们在设置时从宿主机接收配置,而非直接从 DB 读取。 + +**存在于代码中的(按通道类型,运行时不变):** +- 自动注册(auto-registration)行为(启用/禁用,如何工作) +- 发送者允许列表规则 +- 允许列表中的发送者是否可以自动注册群组 +- 平台特定的连接和消息处理 + +这些是在设置 channel adapter 时做出的决策。更改它们 = 修改代码。 + +**存在于 DB 中的(按群组,因组而异):** +- 由哪个 agent group 处理 +- 触发/过滤规则(正则、仅 @提及、排除某些发送者等) +- 响应范围(响应所有消息 vs 仅已触发/允许列表中的消息) +- Session 模式(共享 vs 每线程独立) + +宿主机从 DB 读取每个群组的配置,并在设置时将其传递给 adapter。如果配置在运行时发生更改(管理员智能体注册新群组、更改触发器),宿主机调用 adapter 的更新方法。 + +### 自动注册(Auto-Registration) + +当 adapter 转发来自未知群组的消息时,宿主机需要决定是否创建该群组及其 session。 + +**Adapter 控制是否转发未知消息**——基于其代码级别的 auto-registration 规则(发送者允许列表、群组添加检测等)。如果 adapter 转发它,宿主机则创建群组 + session。 + +**已知群组的 session 创建:** +- 共享 session 模式:宿主机查找现有 session,如果是第一条消息则创建一个 +- 每线程独立 session 模式:宿主机通过 threadId 查找。如果此线程不存在 session,则使用相同的 agent group 自动创建一个 + +**代码级别的规则是通道特定的:** +- WhatsApp:如果允许列表中的号码将机器人添加到群组 → 自动注册。如果未知号码私信 → 取决于 adapter 的配置。 +- 邮件:如果发送者已知 → 自动注册线程。如果未知 → 丢弃。 +- Slack:如果某人在新频道中 @提及机器人 → adapter 根据其规则决定是否转发。 + +没有 `channel_configs` 表——通道类型级别的行为内置于 adapter 代码中。 + +### Chat SDK 集成 + +Chat SDK Adapter 按通道封装: +- 每个 Chat SDK adapter 拥有自己的 Chat 实例 +- 并发模式按通道配置(聊天使用并发,任务使用队列,Webhook 使用防抖) +- 一个桥接(bridge)封装 Chat 实例 + adapter,以符合 NanoClaw 的标准通道接口 +- Chat SDK 处理:Webhook 解析、去重、消息历史、平台 API 调用、富内容投递 +- NanoClaw 处理:路由、智能体生命周期、session 管理 + +**Chat SDK 的订阅模型:** + +Chat SDK 拥有自己的线程级订阅概念(与 NanoClaw 的通道级注册不同): +- `onNewMention` / `onNewMessage(regex)`——首次联系时触发(例如,在 Slack 消息分支中的 @提及) +- `thread.subscribe()`——选择接收该线程中的所有未来消息 +- `onSubscribedMessage`——在已订阅线程中的所有消息触发 + +这是子通道粒度。NanoClaw 在通道级别注册("监听此 Discord 频道")。Chat SDK 在线程级别订阅("追踪此特定 Slack 消息分支")。桥接允许 Chat SDK 内部管理自己的订阅——NanoClaw 不干预或复制此行为。 + +**平台能力差异:** + +各 adapter 的能力差异显著(参见 [Chat SDK adapter 文档](https://chat-sdk.dev/docs/adapters)): +- **Slack**:完整富内容(Block Kit 卡片、模态框、流式传输、表情反应、临时消息) +- **Discord**:Embeds、按钮、通过 post+edit 实现的流式传输 +- **WhatsApp(Cloud API)**:仅私信、交互式回复按钮、不支持流式传输、不支持表情反应 +- **GitHub/Linear**:Markdown 评论、无交互元素 +- **Telegram**:内联键盘按钮、通过 post+edit 实现的流式传输 + +宿主机/桥接处理优雅降级——如果 agent 在不支持卡片的平台上发布卡片,则回退为文本。 + +非 Chat SDK 通道(WhatsApp via Baileys、Gmail、自定义集成)直接实现 NanoClaw 通道接口——无需桥接,无需 Chat SDK 类型。 + +## 容器生命周期 + +宿主机是一个编排器(orchestrator): +1. **启动(Spawn)**——当调用 wakeUpAgent 且该 session 不存在容器时 +2. **空闲终止(Idle kill)**——当容器在某个超时时段内没有未处理消息时 +3. **限制(Limits)**——MAX_CONCURRENT_CONTAINERS 限制活跃容器数量 + +当容器启动时,agent-runner 立即开始轮询其 session DB。消息已在那里等待。 + +## 媒体处理 + +### 入站 + +宿主机不下载媒体。取而代之: +- 消息包含下载 URL(尽可能使用签名 URL) +- Agent-runner 在容器内下载并处理媒体 +- 对于签名 URL 不适用的通道(例如,使用缓冲流的 WhatsApp),channel adapter 下载媒体并通过容器可访问的本地 URL/服务器提供服务 + +**原生内容块(取决于提供商):** + +Agent-runner 检测文件类型,并在提供商支持的情况下将支持的类型作为原生内容块传递: + +| 类型 | Claude | Codex | OpenCode | +|------|--------|-------|----------| +| 图片(JPEG、PNG、GIF、WebP) | 原生图片内容块 | 保存到磁盘,在提示中引用 | 保存到磁盘,在提示中引用 | +| PDF | 原生文档内容块 | 保存到磁盘 | 保存到磁盘 | +| 音频 | 原生音频内容块 | 保存到磁盘 | 保存到磁盘 | +| 其他文件(代码、数据、视频、归档) | 保存到磁盘 | 保存到磁盘 | 保存到磁盘 | + +"保存到磁盘"指下载到 `/workspace/downloads/{messageId}/`,并在提示文本中作为可用文件路径引用。Agent 可以使用工具(Read、Bash)来访问它。 + +Agent-runner 根据提供商不同构建提示。对于 Claude,它构造包含图片/文档块的多部分 `MessageParam` 内容。对于 Codex/OpenCode,所有内容都是带有文件路径引用的文本。 + +### 出站 + +出站文件投递是基于工具的。Agent 使用文件路径调用工具(例如 `send_file`)。Agent-runner 将文件移动到发件箱并写入 `messages_out` 行。 + +``` +/workspace/ + outbox/ + {message_id}/ ← 每个 messages_out 行一个目录 + chart.png + report.pdf +``` + +`messages_out` 内容仅引用文件名: + +```json +{ "text": "这是图表", "files": ["chart.png", "report.pdf"] } +``` + +DB 中没有路径——约定即是契约。宿主机从挂载的 session 文件夹中的 `outbox/{message_id}/` 读取文件,并通过 adapter 进行投递(Chat SDK 使用 `FileUpload` 附带缓冲区数据,或原生通道使用平台特定上传)。宿主机在成功投递后清理 outbox 目录。 + +出站文件使用专用的 `send_file` MCP 工具(与 `send_message` 分离)。参见 [agent-runner-details.md](agent-runner-details.md) 了解工具接口。 + +### 消息去重 + +去重是 channel adapter 的职责。Chat SDK 内部处理此问题。原生 adapter 根据需要追踪平台消息 ID。宿主机不进行去重——如果 adapter 转发消息,宿主机就写入。 + +## Session DB 模式 + +两张表。内容使用 JSON blob——无模式,格式因 `kind` 而异。 + +```sql +-- 宿主机写入,agent-runner 读取 +CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system' + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed' + status_changed TEXT, -- 最后状态变更的 ISO 时间戳 + process_after TEXT, -- ISO 时间戳。NULL = 立即处理。 + recurrence TEXT, -- cron 表达式。NULL = 一次性。 + tries INTEGER DEFAULT 0, -- 处理尝试次数 + + -- 路由(agent-runner 复制到 messages_out;agent 永远不会看到这些字段) + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + + -- 负载(结构取决于 kind) + content TEXT NOT NULL -- JSON blob +); + +-- Agent-runner 写入,宿主机读取 +CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, + in_reply_to TEXT, -- 引用 messages_in.id(可选) + timestamp TEXT NOT NULL, + delivered INTEGER DEFAULT 0, + deliver_after TEXT, -- ISO 时间戳。NULL = 立即投递。 + recurrence TEXT, -- cron 表达式。NULL = 一次性。 + + -- 路由(默认:由 agent-runner 从 messages_in 复制) + kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system' + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + + -- 负载(格式匹配 kind) + content TEXT NOT NULL -- JSON blob +); + +``` + +### 调度(Scheduling) + +一次性任务和循环(recurrence)任务使用相同的表——没有独立的调度器。 + +**一次性:** `process_after`(入站)或 `deliver_after`(出站),且 `recurrence = NULL`。 + +**循环:** 同上,外加 `recurrence` cron 表达式。宿主机将行标记为已处理/已投递后,如果设置了 `recurrence`,则插入新行,其中 `process_after`/`deliver_after` 推进到下一个 cron 时间点。下次时间从计划时间(而非墙上时间)计算,以防止漂移。 + +**宿主机巡检(Host sweep,所有 session DB 约每 60 秒一次):** +- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → 唤醒 agent +- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → 过期检测,增加 `tries`,带退避重置为 `pending` +- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → 投递 +- 完成/投递带有 `recurrence` 的行后,插入下一次发生 + +**活跃容器轮询**(约 1 秒)检查相同的条件,但仅针对正在运行容器的 session。 + +**Agent-runner 创建调度**的方式是写入带有 `process_after` 和可选的 `recurrence` 的 `messages_in`(发给自身)或 `messages_out`(提醒/通知)。 + +### messages_in 各 kind 的内容格式 + +**`chat`** — 简洁的 NanoClaw 格式。任何通道都可以生成此格式。 +```json +{ + "sender": "John", + "senderId": "user123", + "text": "请检查这个 PR", + "attachments": [{ "type": "image", "url": "https://signed-url..." }], + "isFromMe": false +} +``` + +**`chat-sdk`** — 完整的 Chat SDK `SerializedMessage`,从桥接 adapter 透传。包含 `author`、`text`、`formatted`(mdast AST)、`attachments`、`isMention`、`links`、`metadata`。 + +**`task`** — 计划任务触发。 +```json +{ "prompt": "审查开放 PR", "script": "scripts/review.sh" } +``` + +**`webhook`** — 原始 Webhook 负载。 +```json +{ "source": "github", "event": "pull_request", "payload": { ... } } +``` + +**`system`** — 宿主机操作结果(对 agent 请求的系统操作的响应)。 +```json +{ "action": "register_group", "status": "success", "result": { "agent_group_id": "ag-456" } } +``` + +### messages_out 各 kind 的内容格式 + +输出 `kind` 决定格式和投递 adapter。默认:agent-runner 从正在响应的 `messages_in` 行复制 `kind` 和路由字段。 + +**`chat`** — 简洁的 NanoClaw 格式。NanoClaw 通道通过 `sendMessage(text)` 投递。 +```json +{ "text": "LGTM,正在合并" } +``` + +**`chat-sdk`** — Chat SDK `AdapterPostableMessage`。桥接 adapter 通过 `thread.post()` 投递。可以是 Markdown、卡片或原始格式——adapter 处理平台转换。 +```json +{ "markdown": "## 审查\n**LGTM**", "attachments": [...] } +``` +```json +{ "card": { "type": "card", "title": "审查", "children": [...] }, "fallbackText": "..." } +``` + +**`task`** — 任务结果。宿主机记录日志并可选择通知。 +```json +{ "result": "已审查 3 个 PR", "status": "success" } +``` + +**`webhook`** — Webhook 响应。宿主机发送 HTTP 响应或通知。 +```json +{ "response": { "status": 200, "body": { ... } } } +``` + +**`system`** — 宿主机操作请求(注册群组、重置 session 等)。宿主机读取、验证权限、执行、将结果作为 `system` 类型的 `messages_in` 行写回。 +```json +{ "action": "reset_session", "payload": { "session_id": "sess-123" } } +``` + +### 交互式操作(卡片、表情反应、编辑) + +所有交互式操作通过 `messages_in`/`messages_out` 流转——DB 是容器唯一的 IO 边界。Agent 使用 MCP 工具;agent-runner 将工具调用转换为结构化的 `messages_out` 行;宿主机通过适当的 adapter 方法投递。 + +**带有用户交互的卡片(例如,"向用户提问"):** + +1. Agent 调用 `ask_user_question` 工具,附带问题 + 选项 +2. Agent-runner 将问询卡片写入 `messages_out` +3. 宿主机通过 adapter 以交互式卡片形式投递(例如,Slack Block Kit 按钮) +4. 用户点击选项 +5. 平台将事件发送回 adapter → 宿主机将响应写入 `messages_in` +6. Agent-runner 读取 `messages_in`,匹配到挂起的工具调用,将选择结果作为工具结果返回给 agent + +Agent-runner 在等待 `messages_in` 中的用户响应期间保持工具调用打开。往返路径:agent → `messages_out` → 宿主机 → 平台 → 用户点击 → 平台 → 宿主机 → `messages_in` → agent-runner → agent。 + +**审批(Approvals):** + +两种模式,均在宿主机级别处理: +- **隐式**:Agent 调用需要审批的工具。宿主机拦截,向管理员发送审批卡片,等待响应,然后执行或拒绝。Agent 不知道审批步骤。 +- **显式**:Agent 通过工具显式请求审批。Agent-runner 将审批请求写入 `messages_out`。与"向用户提问"流程相同——响应通过 `messages_in` 返回。 + +两种情况下,审批和操作执行均在宿主机侧进行,而非 agent 侧。 + +**审批路由:** 权限是用户级别的概念。`user_roles` 记录 `owner`(仅全局——第一个配对的用户成为所有者)和 `admin`(全局或限定于特定 `agent_group_id`)。当操作需要审批时,`pickApprover(agentGroupId)` 按顺序返回候选者:该 agent group 的限定管理员 → 全局管理员 → 所有者(去重)。然后 `pickApprovalDelivery` 取第一个可通过 `ensureUserDm` 联系到的候选者(采用同通道类型优先策略,因此 Discord 审批请求优先选择使用 Discord 的审批者)。审批卡片发送到审批者的 DM 消息组,而非发起对话。对于需要 DM 解析的通道(Discord/Slack/…),投递通过 Chat SDK 的 `openDM` 解析;对于可直接寻址的通道(Telegram/WhatsApp/…),直接使用用户句柄。映射缓存在 `user_dms` 中以供后续请求使用。参见 `src/access.ts`、`src/user-dm.ts`。 + +**编辑已发送消息:** + +Agent 调用 `edit_message` 工具,附带消息 ID 和新内容。Agent-runner 将编辑操作写入 `messages_out`。宿主机调用 `adapter.editMessage()`。Agent 上下文中的消息包含整数 ID,因此 agent 可以引用它们。 + +**表情反应(Reactions):** + +Agent 调用 `add_reaction` 工具,附带消息 ID 和 emoji。Agent-runner 将反应操作写入 `messages_out`。宿主机调用 `adapter.addReaction()`。 + +**`messages_out` 内容中的操作:** + +```json +// 普通消息(默认) +{ "text": "LGTM" } + +// 交互式卡片 +{ "operation": "ask_question", "title": "部署", "question": "批准部署?", "options": ["是", "否", "推迟"] } + +// 编辑现有消息 +{ "operation": "edit", "messageId": "3", "text": "更新:LGTM,有少量意见" } + +// 表情反应 +{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" } +``` + +宿主机读取 `operation` 字段(如果存在)并调用相应的 adapter 方法。没有 `operation` 字段 = 普通消息投递。平台能力各异——宿主机/桥接处理优雅降级(例如,在不支持表情反应的平台上添加表情 → 跳过或作为文本发送)。 + +### 智能体间通信(Agent-to-Agent Communication) + +向另一个 agent 发送消息使用与通道投递相同的路由字段。Agent-runner 设置 `channel_type: 'agent'`,`platform_id` 设为目标 agent group ID。可选地,`thread_id` 可以指定特定 session(null = 查找或创建默认 session)。 + +从发送 agent 的角度看,这与发送到 Slack 或 WhatsApp 是相同的机制——只是带有不同路由信息的 `messages_out` 行。宿主机读取后,检查此 agent group 是否有权向目标发送消息,解析目标 session,并将 `messages_in` 行写入该 session 的 DB。 + +```json +// messages_out 路由字段 +{ "kind": "chat", "channel_type": "agent", "platform_id": "pr-worker", "thread_id": null } +// messages_out 内容 +{ "text": "重置你的 session 并重新审查", "sender": "监督者", "senderId": "agent:pr-admin" } +``` + +接收 agent 得到的是普通的聊天消息。除非相关内容需要,否则它不需要知道来源是另一个 agent。 + +### 路由 + +**默认行为:** Agent-runner 将路由字段(`kind`、`platform_id`、`channel_type`、`thread_id`)从 `messages_in` 行复制到 `messages_out`。响应发送回原始来源。 + +**宿主机验证:** 投递前,宿主机检查此 agent group 是否被允许向目标发送消息。Agent-runner 复制路由;宿主机验证。 + +**多目标模式(定制):** Agent 可能需要发送到与来源不同的通道(例如,Webhook 触发 Slack 通知)。这通过自定义代码支持,而非内置于核心: + +1. 向 session DB 添加 `destinations` 表,将逻辑名称映射到路由字段 +2. 在设置 session 时由宿主机填充 +3. 修改 agent 提示以列出可用的目标 +4. Agent 通过名称选择目标;agent-runner 解析为路由字段 +5. 宿主机照常验证 + +这被记录为一种模式,而非内置功能。 + +## 核心属性 +- 通过文件系统挂载实现的容器隔离(isolation) +- 凭证代理(OneCLI) +- 每个 agent group 的工作空间(文件夹、CLAUDE.md、skills) +- 基于轮询(非事件驱动) +- 容器启动时对每个 agent group 的 agent-runner 进行重新编译(agent 可以修改其自身源代码,请求重建/重启,更改在销毁后仍然保留) +- 宿主机 ↔ 容器 IO 通过挂载的 session DB(`messages_in` / `messages_out`)——无 stdin 管道,无 IPC 文件 +- Agent 命令是 `kind: 'system'` 的 `messages_out` 行 +- 通过 `messages_out` 上的目标 agent 路由支持 agent-to-agent 通信 +- 调度使用相同消息表上的 `process_after` / `deliver_after` + `recurrence` +- 媒体通过签名 URL,在容器内下载 +- Channel adapter 使用 Chat SDK 桥接 + 标准接口(主干代码仅包含桥接/注册表;平台 adapter 通过 `/add-` 技能安装) +- 路由:channel 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 查询所有未处理的消息,并将它们作为批次处理——多条消息被格式化到单个 `` XML 块中。 + +### 消息生命周期 + +``` +pending → processing → completed + → failed(达到最大重试次数后) +``` + +- **pending**:宿主机写入。待拾取(如果 `process_after` 为 null 或已过时)。 +- **processing**:Agent-runner 在拾取消息时设置此状态。`status_changed` 设为当前时间。防止其他轮询重复拾取同一消息。 +- **completed**:Agent-runner 在成功处理后设置此状态。 +- **failed**:耗尽最大重试次数后设置。 + +**过期检测(Stale detection)**:如果消息处于 `processing` 状态但 `status_changed` 太久远(例如 > 10 分钟),宿主机假定容器崩溃。它将消息重置为 `pending`,递增 `tries`,并设置带指数退避的 `process_after`。 + +### 错误处理与重试 + +重试使用带有指数退避的 `process_after`。每次重试递增 `tries` 并推迟 `process_after`: + +- 尝试 1:立即 +- 尝试 2:+5 秒 +- 尝试 3:+10 秒 +- 尝试 4:+20 秒 +- 尝试 5:+40 秒 +- 达到最大重试次数后:状态设为 `failed` + +由宿主机计算此信息——而非 agent-runner。当宿主机检测到过期的 `processing` 消息或容器异常退出时,递增 `tries`,计算下一个 `process_after`,并将状态重置为 `pending`。 + +**输出已发送保护**:如果某批次已有 `delivered` 的 `messages_out` 行,则不重试(防止向用户发送重复消息)。 + +### 宿主机轮询 + +两个层级: +- **活跃容器(约 1 秒)**:轮询 session DB 以获取待投递的新 `messages_out` 行 +- **所有 session(约 60 秒)**:巡检所有 session DB,查找到期的 `process_after` / `deliver_after` 时间戳,处理循环 + +## 灵活性模型 + +该架构**对代码修改灵活,但并非所有场景都可配置**。高级设置(如以下 PR Factory)使用自定义路由逻辑和宿主机侧钩子——而非数据库配置列。 + +### 用于技能定制的代码结构 + +NanoClaw 通过技能(skills)进行定制——合并到用户安装中的分支。不同的 skills 添加不同的能力(通道、集成、行为)。代码必须结构化为: + +1. **不同定制互不冲突。** 添加 Slack 和添加 Telegram 不应产生合并冲突。添加新的 MCP 工具不应与添加通道冲突。每种定制类型应有自己的文件。 + +2. **核心功能块在单独的文件中。** 通道注册、消息格式化、MCP 工具、路由逻辑、容器管理——各自独立的文件。更改消息格式化方式的 skill 不会触及处理容器启动的文件。 + +3. **入口文件(index)保持精简。** 它将各部分连接起来(初始化 DB、启动 adapter、启动轮询循环),但不包含业务逻辑。所有逻辑驻留在目的特定的模块中,skills 可以独立修改。 + +4. **不要过度拆分。** 简单的更改(例如,添加新的消息类型)不应需要在 5 个文件中编辑。将相关逻辑分组在一起。目标是每个 skill 的核心更改只触及 1-2 个文件。 + +5. **注册模式优先于 switch 语句。** 通道、MCP 工具和提供商应使用注册/插件模式。Skill 通过添加文件和注册调用来添加通道——而不是在中央 switch 语句中与其他每个通道一起编辑。 + +**实际示例:** 通过 skill 添加新通道应需要: +- 一个新文件(channel adapter 或 Chat SDK 配置) +- 在 barrel 文件(`channels/index.ts`)中添加一行以导入自注册模块 +- 对路由、格式化、投递或容器代码零更改 + +### 冲突热点与解决方案 + +对 33 个 skill 分支的分析显示以下文件导致最多的合并冲突: + +| 热点 | 冲突原因 | 解决方案 | +|-----------|-----------------|-------------| +| `src/index.ts`(2000 LOC) | 每个 skill 都修补主循环、导入、初始化逻辑 | 精简的入口文件,连接各模块。逻辑驻留在目的特定文件中(router、delivery、session-manager、host-sweep)。 | +| `src/config.ts` | 每个 skill 都向中央文件添加环境变量 | 配置在使用的模块内声明。每个模块读取自己的 env var。没有每个 skill 都编辑的中央配置注册表。 | +| `src/container-runner.ts` | 通道 skills 添加挂载、env var、凭证设置 | 声明式挂载注册。通道在自己的文件中声明挂载。Container runner 从注册表中读取,而非硬编码列表。 | +| `src/db.ts`(750 LOC) | 模式、迁移和所有 CRUD 在一个文件中 | 按实体拆分。编号迁移。Skills 添加迁移文件 + 编辑一个实体文件。 | +| `container/agent-runner/src/index.ts` | Agent 协议、IPC 处理、格式化全在一个文件中 | 拆分为 poll-loop、formatter、providers/、mcp-tools/。Session DB 替代 IPC。 | +| `src/ipc.ts` | 每个 MCP 工具添加都修补一个文件 | `mcp-tools/` 目录配合 barrel。Skills 添加工具文件 + barrel 行。 | +| `src/channels/index.ts` | 每个通道在同一位置添加 import 行 | 带每个通道注释槽的 barrel 文件(当前模式有效,保留)。 | + +**挂载注册模式:** 与其让每个通道 skill 编辑 `buildVolumeMounts()`,通道声明挂载,container runner 收集它们: + +```typescript +// channels/gmail.ts +registerChannel('gmail', { + factory: createGmailAdapter, + mounts: [ + { hostPath: '~/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: false } + ], + env: ['GMAIL_OAUTH_TOKEN'], +}); +``` + +Container runner 从通道注册表读取注册的挂载——无需编辑 `container-runner.ts`。 + +**配置模式:** Skills 不修补 `config.ts` 或 `.env.example`。Skill 特定的 env var 在 skill 的 SKILL.md 中记录——设置过程读取这些指令。每个模块直接读取自己的 env var: + +```typescript +// channels/discord.ts +const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN; + +// channels/gmail.ts +const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH; +``` + +共享配置(DATA_DIR、TIMEZONE、MAX_CONCURRENT_CONTAINERS)保留在 `config.ts` 中。通道/skill 特定配置保留在使用的模块中。 + +### 代码风格 + +**行宽:120 字符。** 大多数语句能在一行内写完而不牺牲可读性。 + +**简洁日志。** 一个薄封装使每个日志调用保持在一行: + +```typescript +log.info('IPC 消息已发送', { chatJid, sourceGroup }); +log.warn('未授权的 IPC 尝试', { chatJid }); +log.error('处理错误', { file, err }); +``` + +### DB 文件结构 + +DB 层按实体拆分,而非保持在一个整体文件中: + +``` +src/db/ + connection.ts ← 单例、初始化、WAL 模式 + schema.ts ← CREATE TABLE 语句(当前状态,供参考) + migrations/ + index.ts ← 运行器:检查版本,应用待执行的迁移 + 001-initial.ts ← 初始模式 + 002-pending-questions.ts ← 示例:添加 pending_questions 表 + ... ← skills 追加新的编号文件 + agent-groups.ts ← agent_groups 的 CRUD + messaging-groups.ts ← messaging_groups + messaging_group_agents 的 CRUD + sessions.ts ← sessions + pending_questions 的 CRUD + index.ts ← barrel:重新导出所有内容 +``` + +**原则:** +- **按实体拆分,而非按层。** 每个实体文件有自己的 CRUD 函数(约 50-100 行)。向 messaging_groups 添加列的 skill 只编辑 `messaging-groups.ts`——不触及 sessions 或 agent groups。 +- **Schema 作为当前状态 + 迁移作为历史。** `schema.ts` 记录 DB 现在的样子(阅读它以理解模式)。迁移是只追加的编号文件,描述我们如何达到当前状态。 +- **无内联 ALTER TABLE。** 带有 `schema_version` 表的迁移运行器替代了 `try { ALTER TABLE } catch { /* 已存在 */ }` 代码块。启动时,检查当前版本并按顺序应用待执行的迁移。每个迁移是一个函数:`(db: Database) => void`。 +- **Skills 添加迁移。** 需要新增列的 skill 添加一个新的编号迁移文件。只要编号不冲突(为 skill 分支使用时间戳或足够大的编号),就不会与其他 skills 的迁移冲突。 + +**Agent-runner session DB** 使用相同的模式但更轻量——无需迁移,因为 session DB 由宿主机全新创建: + +``` +container/agent-runner/src/db/ + connection.ts ← 在固定路径打开 session.db,WAL 模式 + messages-in.ts ← 读取待处理、更新状态 + messages-out.ts ← 写入结果、outbox 查询 + index.ts ← barrel +``` + +### 基础架构必须原生支持的功能 + +以下是构建块。它们都不需要特殊抽象——它们自然产生于逐 session DB、宿主机管理的路由和 `kind: 'system'` 的 `messages_out`: + +1. **同一通道上基于内容路由的多个 agent group。** 同一线程中的不同消息可以基于内容路由到不同的 agent group(例如,@提及路由到监督者,普通消息路由到工作者)。Channel adapter 的路由逻辑——自定义代码——决定。 + +2. **来自共享 agent group 的每线程 session。** 多个 session 共享同一个 agent group(文件系统、skills、CLAUDE.md),但每个获得自己的 session DB。工作者池的标准用法。 + +3. **Session 重置和重放。** 为同一线程创建新 session。将旧消息标记为未处理,以便轮询重新拾取。旧输出在平台(例如 Discord 线程)中仍然可见以供比较。这是 agent 可以请求的操作——而非自动。 + +4. **跨 session 读取访问。** 某些 agent 可以查询其他 session 的数据。不同的访问级别:管理者查看 `messages_in`/`messages_out`(审查内容)。监督者查看完整内部信息(agent 日志、工具调用、调试追踪)。这只是文件系统/DB 访问——挂载或查询正确的路径。 + +5. **上下文复制到新 session。** 当监督者在工作者线程中被调用时,创建包含相关消息副本的新 session。自定义宿主机侧代码处理此问题。 + +6. **Agent 发起的宿主机操作。** Agent 使用 MCP 工具(重置 session、更新 skills 等)。Agent-runner 处理工具调用并写入结构化的 `system` 类型的 `messages_out` 行。宿主机读取并在权限检查后执行。Agent 可以请求,但宿主机决定。 + +### 示例:PR Factory + +三个 agent group,一个 Discord 频道(PR Factory),外加一个管理员通道: + +| 角色 | Agent Group | 所在位置 | Session 模型 | +|------|-------------|-------|---------------| +| **工作者** | pr-worker | PR Factory 线程 | 每个线程一个 session(每个 PR) | +| **管理者** | pr-manager | PR Factory 频道 | 单个 session,跨工作者 session 查询 | +| **监督者** | pr-admin | 管理员通道 + PR Factory(被 @标记时) | 管理员通道中的主 session;在工作者线程中被调用时创建每线程 session | + +**工作者流程:** GitHub PR → Discord 线程 → 工作者 agent 审查(分类、审查、测试计划)。每个线程从共享的 pr-worker group 获得一个 session。 + +**反馈流程:** 用户在工作线程中 @标记监督者 → 自定义路由将其发送到监督者,附带包含该线程消息(已复制)的新 session。监督者将反馈收集到文件系统。工作者看不到监督者消息。 + +**迭代流程:** 用户在管理员通道中与监督者讨论反馈 → 监督者建议 skill 更改(以带 diff 的富卡片显示)→ 用户批准 → 监督者通过宿主机操作应用更改 → 监督者请求 session 重置 + 重放 → 工作者使用更新后的 skills 在相同线程但全新的 session 中重新审查相同 PR → 用户并排比较审查结果。 + +**管理者流程:** 用户在 PR Factory 主频道(而非线程内)与管理者交谈。管理者可以搜索所有工作者 session DB(`messages_in`/`messages_out`),以回答如"今天有多少 PR?"或"哪些话题在趋势中?"等问题。可以请求操作(关闭 PR、重新打开)。 + +**自定义代码 vs 基础架构:** + +| 能力 | 基础架构 | 自定义代码(PR Factory) | +|-----------|-------------------|-------------------------| +| 每线程 session | ✓ platformThreadId → session | | +| 跨 session 共享 agent group | ✓ 多个 session,一个 group | | +| 写入消息到 session DB | ✓ 标准流程 | | +| @提及路由到不同 agent | | ✓ Channel adapter 路由逻辑 | +| 上下文复制到监督者 session | | ✓ 监督者调用时的宿主机侧钩子 | +| Session 重置 + 重放 | ✓ 原语(新 session、标记未处理) | ✓ 监督者操作触发 | +| Skill 更新 | ✓ 文件系统写入 | ✓ 监督者操作应用更改 | +| 跨 session 查询 | ✓ DB/文件系统访问 | ✓ 管理者的工具知道在哪里查找 | +| 富卡片输出 | ✓ messages_out 中的结构化输出 | | + +## 中央数据库模式 + +中央数据库处理路由和实体管理。所有内容和执行状态驻留在逐 session DB 中。 + +```sql +-- 智能体工作区:文件夹、skills、CLAUDE.md、容器配置 +CREATE TABLE agent_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + folder TEXT NOT NULL UNIQUE, + agent_provider TEXT, -- session 的默认值(null = 系统默认值) + container_config TEXT, -- JSON: { additionalMounts, timeout } + created_at TEXT NOT NULL +); + +-- 平台群组/频道(WhatsApp 群组、Slack 频道、Discord 频道、邮件线程等) +CREATE TABLE messaging_groups ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, -- 'whatsapp'、'slack'、'discord'、'telegram'、'email' + platform_id TEXT NOT NULL, -- 平台特定 ID(JID、频道 ID 等) + name TEXT, + is_group INTEGER DEFAULT 0, + unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public' + created_at TEXT NOT NULL, + UNIQUE(channel_type, platform_id) +); + +-- 用户(消息平台身份,命名空间格式 ":") +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`** — 格式化为 `` XML 块 +- **`chat-sdk`** — 从序列化消息中提取 text、author、attachments;格式化为 `` 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 的内置命令) +- 原样传递,不做 `` 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-` 技能安装) +- 通过提供商特定机制恢复 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 工具、消息格式化、媒体处理、提供商实现 diff --git a/docs/zh/build-and-runtime.md b/docs/zh/build-and-runtime.md new file mode 100644 index 0000000..7dbbb2c --- /dev/null +++ b/docs/zh/build-and-runtime.md @@ -0,0 +1,80 @@ +# 构建与运行时 + +NanoClaw 运行的是分拆式技术栈:宿主机使用 Node + pnpm,agent 容器使用 Bun。它们之间仅通过每个 session(会话)的两个 SQLite 文件来通信——它们之间没有共享模块,这也是它们能干净地使用不同运行时的原因。 + +## 为什么分拆 + +- **宿主机保持使用 Node**,因为 Baileys(WhatsApp)依赖 `libsignal-node` 原生绑定和久经考验的 WebSocket/HTTP 栈。Bun 的 Node-API 兼容性已经有所改善,但这不是我们想冒险的地方。 +- **容器使用 Bun**,因为 `bun:sqlite` 是内置的(无需每次镜像重建都编译 `better-sqlite3` 原生模块),源码直接运行(无需在镜像构建或 session 唤醒时做 tsc 构建步骤),而且 `bun install` 比 `npm install` 快大约 5-10 倍。 + +宿主机和容器各有自己的包树: + +``` +/ pnpm + Node 22 + pnpm-lock.yaml 宿主机依赖(频道、Chat SDK、Baileys、better-sqlite3 等) + pnpm-workspace.yaml minimumReleaseAge + onlyBuiltDependencies 策略 + +/container/agent-runner/ Bun 1.3+ + bun.lock agent-runner 运行时依赖(Claude Agent SDK、MCP SDK、zod 等) + package.json @types/bun、typescript 开发依赖用于类型检查 +``` + +容器镜像内部也有 pnpm + Node,用于全局 CLI(`@anthropic-ai/claude-code`、`agent-browser`、`vercel`)。这些是 agent 在运行时调用的 Node 二进制文件,不是库依赖。将它们保持在 pnpm 上可以保留 CLI 版本的供应链策略。 + +## 锁文件 + +| 树 | 锁文件 | 管理器 | 依赖变更后重新生成 | +|------|----------|---------|----------------------------| +| 宿主机 | `pnpm-lock.yaml` | pnpm 10 | `pnpm install` | +| Agent-runner | `container/agent-runner/bun.lock` | Bun 1.3+ | `cd container/agent-runner && bun install` | + +两者都已提交。CI 和 Dockerfile 运行 `--frozen-lockfile` 变体——`package.json` 与锁文件之间的任何偏差都会导致构建失败。 + +## 供应链 + +- **宿主机 + 全局 CLI**(pnpm):`minimumReleaseAge: 4320`(新版本需要等待 3 天),`onlyBuiltDependencies` 白名单用于 postinstall 脚本。见 `pnpm-workspace.yaml` 和 `docs/SECURITY.md`。 +- **Agent-runner**(Bun):无发布年龄策略——Bun 目前没有等效机制。防御措施是 `bun.lock` 锁定版本加上通过 Dockerfile ARG 固定版本的 CLI/Bun 本身。当升级 `@anthropic-ai/claude-agent-sdk` 或任何运行时依赖时,请查看 npm 上的发布日期并有意识地升级,而不是通过 `bun update`。 + +## 镜像构建范围 + +`container/Dockerfile` 是基于 `node:22-slim` 的单阶段构建: + +- **固定的 ARG**——`BUN_VERSION`、`CLAUDE_CODE_VERSION`、`AGENT_BROWSER_VERSION`、`VERCEL_VERSION`。在 PR 中有意识地升级。 +- **CJK 字体**——`ARG INSTALL_CJK_FONTS=false`。`container/build.sh` 从 `.env` 读取 `INSTALL_CJK_FONTS` 并传递。默认构建节省约 200MB;当用户处理中文/日文/韩文内容时选择加入。 +- **BuildKit 缓存挂载**——`/var/cache/apt`、`/var/lib/apt`、`/root/.bun/install/cache`、`/root/.cache/pnpm`。当 `package.json`/`bun.lock` 未变更时的重建速度很快。需要 BuildKit(Docker 23+、Apple Container 兼容下默认启用)。 +- **`tini` 作为 init**——回收 Chromium 僵尸进程,转发信号以便在 SIGTERM 时完成正在进行的 `outbound.db` 写入。 +- **`entrypoint.sh`**(已提取)——`exec bun run /app/src/index.ts` 在 tini 下运行。可读且可 diff。 +- **无编译后的 `/app/dist`**——Bun 直接运行 TS。宿主机在 session 启动时还会将最新源码挂载到 `/app/src` 上,因此宿主机的编辑无需重建镜像即可生效。 + +## Session 唤醒(两条路径) + +1. **基础镜像 ENTRYPOINT**——用于 stdin 管道测试调用,如 `container/build.sh` 中的示例:`tini --> entrypoint.sh` 捕获 stdin 到 `/tmp/input.json`,然后 `exec bun run src/index.ts`。 +2. **宿主机生成的 session**——`src/container-runner.ts` 第 301 行附近使用 `--entrypoint bash` 加 `-c 'exec bun run /app/src/index.ts'`。绕过 tini(Docker 默认的 PID 1 处理生效)。stdin 未使用;所有 IO 通过挂载的 session DB 流动。 + +两条路径最终都由 Bun 运行同一个源码文件 `/app/src/index.ts`。 + +## CI 流程 + +`.github/workflows/ci.yml` 安装 Node(带 pnpm 缓存)和 Bun,然后按顺序运行: + +1. `pnpm install --frozen-lockfile`(宿主机) +2. `bun install --frozen-lockfile` 在 `container/agent-runner/` 中(容器) +3. `pnpm run format:check` +4. `pnpm exec tsc --noEmit`(宿主机类型检查) +5. `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`(容器类型检查) +6. `pnpm exec vitest run`(宿主机测试) +7. `bun test` 在 `container/agent-runner/` 中(容器测试) + +任何失败都会导致 PR 失败。 + +## 关键不变条件 + +- **Session DB 必须使用 `journal_mode=DELETE`。** WAL 的 `-shm` 内存映射不会跨宿主机和客户机之间的 VirtioFS 传递。参见 `container/agent-runner/src/db/connection.ts` 顶部的文档注释和 `src/session-manager.ts`。 +- **容器中的命名 SQL 参数要求在 JS 对象键中使用前缀。** `bun:sqlite` 不会像宿主机上的 `better-sqlite3` 那样自动去除 `@`/`$`/`:`。在 SQL 和键中都使用 `$name`:`.run({ $id: msg.id })`。位置 `?` 参数正常工作。 +- **Agent-runner 测试在 `bun:test` 下运行,而非 vitest。** `vitest.config.ts` 排除了 `container/agent-runner/` 树,因为 vitest 在 Node 上运行,无法加载 `bun:sqlite`。 +- **容器镜像中没有 tsc 构建步骤。** 重新添加一个会重新引入我们已移除的每个 session 唤醒约 200-500ms 的成本。 +- **全局容器 CLI 保持使用 pnpm,而非 Bun。** `agent-browser`、`@anthropic-ai/claude-code`、`vercel` 以及 agent 调用的任何未来 Node CLI 都应在 Dockerfile 的 pnpm 全局安装块下固定版本。`bun install -g` 会绕过 pnpm 供应链策略。 + +## 迁移历史 + +这一结构替换了统一的 npm-on-Node 技术栈(宿主机和容器均为 Node)。pnpm 迁移首先落地(PR #1771),使宿主机纳入供应链策略,然后容器迁移到 Bun 以消除原生模块编译和每次唤醒的 tsc 步骤。选择分拆而非完全转用 Bun,是因为 Baileys 的原生依赖是宿主机上的主要风险面——容器没有这样的依赖,因此可以在不承担风险的情况下受益于 Bun。 diff --git a/docs/zh/db-central.md b/docs/zh/db-central.md new file mode 100644 index 0000000..4541b94 --- /dev/null +++ b/docs/zh/db-central.md @@ -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//` 目录,该目录包含 `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//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)。 diff --git a/docs/zh/db-session.md b/docs/zh/db-session.md new file mode 100644 index 0000000..e61a3c6 --- /dev/null +++ b/docs/zh/db-session.md @@ -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/// + inbound.db ← 宿主机写入,容器读取(只读挂载) + outbound.db ← 容器写入,宿主机读取(只读打开) + .heartbeat ← 容器触碰的 mtime(非 DB 写入) + inbox// ← 用户附件,从入站消息内容解码 + outbox// ← 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 目录添加匹配的惰性迁移,并优先使用可空的列或带默认值的列,这样就不需要回填数据。 diff --git a/docs/zh/db.md b/docs/zh/db.md new file mode 100644 index 0000000..a3bdc43 --- /dev/null +++ b/docs/zh/db.md @@ -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///inbound.db` | 宿主机 | 宿主机(同步)、容器(只读) | 宿主机 → 容器消息 + 路由投影 | +| **Session 出站库** | `data/v2-sessions///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/ + / + .claude-shared/ ← agent group 共享的 Claude 状态 + agent-runner-src/ ← 每个 agent group 的 agent-runner 覆盖层 + / + inbound.db ← 宿主机写入,容器读取 + outbound.db ← 容器写入,宿主机读取 + .heartbeat ← 容器触碰的 mtime + inbox// ← 已解码的用户附件 + outbox// ← 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` | 容器启动时 | diff --git a/docs/zh/docker-sandboxes.md b/docs/zh/docker-sandboxes.md new file mode 100644 index 0000000..6bbc67c --- /dev/null +++ b/docs/zh/docker-sandboxes.md @@ -0,0 +1,359 @@ +# 在 Docker 沙盒中运行 NanoClaw(手动设置) + +本指南介绍如何从头开始在 [Docker 沙盒](https://docs.docker.com/ai/sandboxes/)中设置 NanoClaw——无需安装脚本,无需预构建的 fork。你将克隆上游仓库,应用必要的补丁,并在完全的 hypervisor 级别隔离下运行 agent(智能体)。 + +## 架构 + +``` +宿主机 (macOS / Windows WSL) +└── Docker 沙盒 (带隔离内核的微 VM) + ├── NanoClaw 进程 (Node.js) + │ ├── 频道适配器 (WhatsApp, Telegram 等) + │ └── 容器启动器 → 嵌套 Docker 守护进程 + └── Docker-in-Docker + └── nanoclaw-agent 容器 + └── Claude Agent SDK +``` + +每个 agent 在自己的容器中运行,位于一个与你的宿主机完全隔离的微 VM 内。两层隔离:每个 agent 的容器 + VM 边界。 + +沙盒在 `host.docker.internal:3128` 提供一个 MITM 代理,处理网络访问并自动注入你的 Anthropic API 密钥。 + +> **注意:** 本指南基于在 macOS(Apple Silicon)上使用 WhatsApp 验证过的设置。其他频道(Telegram、Slack 等)和环境(Windows WSL)可能需要针对其特定 HTTP/WebSocket 客户端添加额外的代理补丁。核心补丁(容器运行器、凭据代理、Dockerfile)普遍适用——频道特定的代理配置各有不同。 + +## 前提条件 + +- **Docker Desktop v4.40+** 支持沙盒 +- **Anthropic API 密钥**(沙盒代理管理注入) +- 对于 **Telegram**:来自 [@BotFather](https://t.me/BotFather) 的 bot token 和你的 chat ID +- 对于 **WhatsApp**:一部安装了 WhatsApp 的手机 + +验证沙盒支持: +```bash +docker sandbox version +``` + +## 步骤 1:创建沙盒 + +在你的宿主机上: + +```bash +# 创建工作区目录 +mkdir -p ~/nanoclaw-workspace + +# 创建带工作区挂载的 shell 沙盒 +docker sandbox create shell ~/nanoclaw-workspace +``` + +如果你使用 WhatsApp,配置代理绕过,使 WhatsApp 的 Noise 协议不被 MITM 检查: + +```bash +docker sandbox network proxy shell-nanoclaw-workspace \ + --bypass-host web.whatsapp.com \ + --bypass-host "*.whatsapp.com" \ + --bypass-host "*.whatsapp.net" +``` + +Telegram 不需要代理绕过。 + +进入沙盒: +```bash +docker sandbox run shell-nanoclaw-workspace +``` + +## 步骤 2:安装前提依赖 + +在沙盒内: + +```bash +sudo apt-get update && sudo apt-get install -y build-essential python3 +npm config set strict-ssl false +``` + +## 步骤 3:克隆并安装 NanoClaw + +NanoClaw 必须位于工作区目录内——Docker-in-Docker 只能从共享的工作区路径进行 bind-mount。 + +```bash +# 先克隆到家目录(virtiofs 可能在克隆期间损坏 git pack 文件) +cd ~ +git clone https://github.com/nanocoai/nanoclaw.git + +# 替换为你的工作区路径(你传递给 `docker sandbox create` 的宿主机路径) +WORKSPACE=/Users/you/nanoclaw-workspace + +# 移入工作区,以便 DinD 挂载生效 +mv nanoclaw "$WORKSPACE/nanoclaw" +cd "$WORKSPACE/nanoclaw" + +# 安装依赖 +pnpm install +pnpm install https-proxy-agent +``` + +## 步骤 4:应用代理和沙盒补丁 + +NanoClaw 需要多个补丁才能在 Docker 沙盒内工作。这些补丁处理代理路由、CA 证书和 Docker-in-Docker 挂载限制。 + +### 4a. Dockerfile——用于容器镜像构建的代理参数 + +`sandbox 内的 docker build` 会因为沙盒的 MITM 代理提供自己的证书而出现 `SELF_SIGNED_CERT_IN_CHAIN` 失败。向 `container/Dockerfile` 添加代理构建参数: + +在 `FROM` 行之后添加这些行: + +```dockerfile +# 接受代理构建参数 +ARG http_proxy +ARG https_proxy +ARG no_proxy +ARG NODE_EXTRA_CA_CERTS +ARG npm_config_strict_ssl=true +RUN npm config set strict-ssl ${npm_config_strict_ssl} +``` + +并在 `RUN pnpm install` 行之后: + +```dockerfile +RUN npm config set strict-ssl true +``` + +### 4b. 构建脚本——转发代理参数 + +补丁 `container/build.sh`,将代理环境变量传递给 `docker build`: + +在 `docker build` 命令中添加这些 `--build-arg` 标志: + +```bash +--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \ +--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \ +--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \ +--build-arg npm_config_strict_ssl=false \ +``` + +### 4c. 容器运行器——代理转发、CA 证书挂载、/dev/null 修复 + +对 `src/container-runner.ts` 的三处更改: + +**替换 `/dev/null` 影子挂载。** 沙盒拒绝 `/dev/null` bind mount。找到 `.env` 被影子挂载到 `/dev/null` 的位置,将其替换为一个空文件: + +```typescript +// 创建一个空文件来影子 .env(Docker 沙盒拒绝 /dev/null 挂载) +const emptyEnvPath = path.join(DATA_DIR, 'empty-env'); +if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, ''); +// 在挂载中使用 emptyEnvPath 代替 '/dev/null' +``` + +**将代理环境变量转发**到生成的 agent 容器。为 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及其小写变体添加 `-e` 标志。 + +**挂载 CA 证书。** 如果设置了 `NODE_EXTRA_CA_CERTS` 或 `SSL_CERT_FILE`,将证书复制到项目目录并挂载到 agent 容器中: + +```typescript +const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE; +if (caCertSrc) { + const certDir = path.join(DATA_DIR, 'ca-cert'); + fs.mkdirSync(certDir, { recursive: true }); + fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt')); + // 挂载:certDir -> /workspace/ca-cert(只读) + // 在容器中设置 NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt +} +``` + +### 4d. 容器运行时——防止自我终止 + +在 `src/container-runtime.ts` 中,`cleanupOrphans()` 函数通过 `nanoclaw-` 前缀匹配容器。在沙盒内,沙盒容器本身可能匹配(例如 `nanoclaw-docker-sandbox`)。过滤掉当前主机名: + +```typescript +// 在 cleanupOrphans() 中,从要停止的容器列表中过滤掉 os.hostname() +``` + +### 4e. 凭据代理——通过 MITM 代理路由 + +在 `src/credential-proxy.ts` 中,上游 API 请求需要通过沙盒代理。向出站请求添加 `HttpsProxyAgent`: + +```typescript +import { HttpsProxyAgent } from 'https-proxy-agent'; + +const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; +const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; +// 将 upstreamAgent 传递给 https.request() 选项 +``` + +### 4f. 安装脚本——代理构建参数 + +补丁 `setup/container.ts`,传递与 `build.sh`(步骤 4b)相同的代理 `--build-arg` 标志。 + +## 步骤 5:构建 + +```bash +pnpm run build +bash container/build.sh +``` + +## 步骤 6:添加频道 + +### Telegram + +```bash +# 应用 Telegram 技能 +pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram + +# 应用技能后重建 +pnpm run build + +# 配置 .env +cat > .env << EOF +TELEGRAM_BOT_TOKEN= +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:" \ + --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/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 + +# 注册你的聊天(JID = 你的电话号码 + @s.whatsapp.net) +pnpm exec tsx setup/index.ts --step register \ + --jid "@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 \ + --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 +``` diff --git a/docs/zh/isolation-model.md b/docs/zh/isolation-model.md new file mode 100644 index 0000000..c29a4f3 --- /dev/null +++ b/docs/zh/isolation-model.md @@ -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 diff --git a/docs/zh/migration-dev.md b/docs/zh/migration-dev.md new file mode 100644 index 0000000..34018e5 --- /dev/null +++ b/docs/zh/migration-dev.md @@ -0,0 +1,139 @@ +# v1 → v2 迁移 — 开发指南 + +如何测试、开发和调试迁移流程。 + +## 快速入门 + +```bash +# 完整周期:重置 → 迁移 → Claude 完成 +bash migrate-v2-reset.sh && bash migrate-v2.sh +``` + +## 架构 + +两部分迁移: + +1. **`migrate-v2.sh`**——确定性 bash 脚本。处理前提条件、DB 种子、文件拷贝、频道安装、容器构建、服务切换。写入 `logs/setup-migration/handoff.json`,然后 `exec` 到 Claude 中。 + +2. **`/migrate-from-v1` 技能**——由 Claude 驱动。读取交接文件,种子 owner/roles,清理 CLAUDE.local.md,验证容器配置,移植 fork 定制。 + +## 文件布局 + +``` +migrate-v2.sh # 入口点 +migrate-v2-reset.sh # 清除 v2 状态以重新测试 +setup/migrate-v2/ + env.ts # 阶段 1a:合并 .env + db.ts # 阶段 1b:种子 v2 DB + groups.ts # 阶段 1c:拷贝 group 目录 + container.json + sessions.ts # 阶段 1d:拷贝 session 并设置续接 + tasks.ts # 阶段 1e:移植计划任务 + channel-auth.ts # 阶段 2b:拷贝频道认证状态 + select-channels.ts # 阶段 2a:clack 多选 + switchover-prompt.ts # 服务切换提示 +setup/migrate-v2/shared.ts # 共享辅助函数(JID 解析、触发器映射等) +.claude/skills/migrate-from-v1/ # Claude 技能 +logs/setup-migration/handoff.json # 由 migrate-v2.sh 写入,由技能读取 +logs/migrate-steps/*.log # 每个步骤的原始输出 +``` + +## 开发循环 + +```bash +# 重置 v2 到干净状态(保留 node_modules) +bash migrate-v2-reset.sh + +# 以非交互式频道选择运行迁移 +NANOCLAW_CHANNELS="telegram" bash migrate-v2.sh + +# 或以交互方式运行(clack 多选) +bash migrate-v2.sh +``` + +`migrate-v2-reset.sh` 清除:`data/`、`logs/`、`.env`、`groups/`(恢复 git 跟踪的)、`container/skills/`(恢复 git 跟踪的)、`src/channels/`(恢复 git 跟踪的)。 + +它**不**清除 `node_modules/`(重新安装成本高昂)。 + +## 测试单个步骤 + +每个步骤是一个独立的 TypeScript 文件: + +```bash +# 运行单个步骤(在 pnpm install 之后) +pnpm exec tsx setup/migrate-v2/env.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/db.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/groups.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/sessions.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/tasks.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/channel-auth.ts /path/to/v1 telegram discord +``` + +每个步骤向 stdout 打印 `OK:
`、`SKIPPED:` 或错误。成功/跳过时退出 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//.claude-shared/projects/-workspace-agent/` +3. v1 session JSONL 应从 `-workspace-group/` 拷贝到 `-workspace-agent/`(v2 容器 CWD 为 `/workspace/agent`) + +**服务切换回退不起作用:** +1. v2 服务名称为 `nanoclaw-v2-`——找到它:`systemctl --user list-units 'nanoclaw*'` +2. 手动停止:`systemctl --user stop && systemctl --user disable ` +3. 重启 v1:`systemctl --user start nanoclaw` + +### 步骤日志 + +每个步骤将原始输出写入 `logs/migrate-steps/.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` 时触发。 diff --git a/docs/zh/ollama.md b/docs/zh/ollama.md new file mode 100644 index 0000000..7655cdd --- /dev/null +++ b/docs/zh/ollama.md @@ -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//.claude-shared/settings.json` bind mount 该文件。设置 `"model": "gemma4:latest"`(或你已 pull 的任何 Ollama 模型)。使用来自 `ollama list` 的精确名称。 + +Apple Silicon 的模型选择考量: + +| 模型 | 规模 | 质量 | 速度(M4 Pro) | +|-------|------|---------|----------------| +| `gemma4:latest` | 12B | 通用良好 | 快 | +| `qwen3-coder:latest` | 32B | 编码任务出色 | 中等 | +| `llama3.2:latest` | 3B | 基础 | 非常快 | + +agent 大量使用工具调用(读取/写入文件、shell 命令)。支持可靠工具调用的模型效果最好。Gemma 4 和 Qwen 3 Coder 都能很好地处理结构化工具调用。 + +## 代码层面的变更 + +三个文件需要支持此功能。确切变更见 `/add-ollama-provider`。 + +**`src/container-config.ts`**——`ContainerConfig` 接口需要 `env` 和 `blockedHosts` 字段,以便每个 group 的 JSON 可以携带它们。 + +**`src/container-runner.ts`**——在容器启动时,`env` 条目变为 `-e KEY=VAL` Docker 标志(在 OneCLI 注入的变量之后应用,因此它们获胜),`blockedHosts` 条目变为 `--add-host HOST:0.0.0.0` 标志。 + +**`container/Dockerfile`**——容器以宿主机用户的 uid 运行(例如 macOS 上的 501),而非 `node` 用户(uid 1000)。家目录必须是 `chmod 777`,以便任何 uid 都可以写入 `~/.claude.json` 和 `~/.claude/settings.json`。 + +## 权衡 + +| | Ollama(本地) | Anthropic API | +|---|---|---| +| 成本 | 免费 | 按 token 付费 | +| 隐私 | 完全本地 | 数据发送到 Anthropic | +| 模型质量 | 良好(开放权重) | 出色(Claude) | +| 冷启动 | 5–30 秒(模型加载) | ~1 秒 | +| 上下文窗口 | 因模型而异 | 200k tokens(Sonnet) | +| 工具调用可靠性 | 良好(大型模型) | 出色 | +| 硬件要求 | 16GB+ RAM | 无 | + +对于有能力的硬件上的个人自动化,权衡倾向于本地。对于需要大上下文或高可靠性的复杂多步骤任务,Claude 仍然领先。 + +## 恢复使用 Claude + +从 `groups//container.json` 中移除 `env` 和 `blockedHosts` 键,从共享设置文件中移除 `"model"`,并重启服务。无需重建。 + +## 另见 + +- `/add-ollama-provider`——为任何 agent group 配置 Ollama 的分步技能 +- [Ollama Anthropic 兼容文档](https://ollama.com/blog/openai-compatibility)——API 桥接的上游文档 +- `docs/architecture.md`——容器启动和环境变量注入管道的工作原理 diff --git a/docs/zh/setup-flow.md b/docs/zh/setup-flow.md new file mode 100644 index 0000000..b83ce7a --- /dev/null +++ b/docs/zh/setup-flow.md @@ -0,0 +1,174 @@ +# 安装流程 + +本文档是 NanoClaw 端到端脚本化安装的契约 +(`bash nanoclaw.sh` → `pnpm run setup:auto`)。在添加新步骤、修复回退或更改输出渲染方式之前,请阅读本文档。 + +## 三个输出级别 + +每个安装步骤在**三个不同级别**产生输出。它们面向 +不同的受众,输出到不同的位置,并以不同的格式呈现。 +不要混淆它们。 + +| 级别 | 受众 | 目的地 | 格式 | +|---|---|---|---| +| 1. 面向用户 | 运行安装的操作者 | 终端(通过 clack) | 品牌化的、简洁的、信息性的——"产品内容" | +| 2. 进度 | 未来调试者、审查失败运行的 AI agent、发布支持 | `logs/setup.log`(一个文件,仅追加) | 结构化的每个步骤块,线性时间顺序,人类和机器可读 | +| 3. 原始 | 正在深度调试特定步骤的人 | `logs/setup-steps/NN-step-name.log`(每个步骤一个文件) | 完整的原始子进程 stdout + stderr,逐字记录 | + +可以这样理解:用户看到的是**摘要**,进度日志是 +**带关键事实的索引**,原始日志是**证据**。 + +### 级别 1:面向用户(clack) + +由 `setup/auto.ts` 通过 `@clack/prompts` 渲染。这是我们的*产品 +界面*——每行都应读起来像是我们为第一天遇到的陌生人设计的一样。 + +- 使用 clack spinner 表示进行中的工作。显示经过的时间。 +- `p.log.success` / `p.log.step` / `p.log.warn` 用于永久状态标记。 +- `p.note` 用于多行信息(配对码、下一步)。 +- `p.text` / `p.select` / `p.password` 用于提示。 +- 品牌调色板:`setup/auto.ts` 中的 `brand()` / `brandBold()` / `brandChip()` 辅助函数。当终端支持时使用真彩色,否则回退到 16 色青色,管道输出 / `NO_COLOR` 时使用纯文本。 + +规则: +- **无中断。** 每个子步骤属于同一个视觉流程。唯一的例外是 Anthropic 凭据注册(见下文)。 +- **无原始子进程输出。** 永远不要对输出内容非我们所编写的子进程使用 `stdio: 'inherit'`。捕获它,仅在失败时显示。 +- **无调试样式前缀**(`[add-telegram] …`、`INFO …`、时间戳)。这些属于级别 2 和 3。 +- **无 emoji**,除非 clack 图形需要。 + +### 级别 2:进度日志 + +`logs/setup.log`——每次安装运行一个文件,仅追加,跨多次运行安装累积 +(如果某次运行中途失败并被重新尝试,新条目会追加)。这是你在操作者报告安装 bug 时 +会要求他们粘贴的东西,也是 AI agent 会阅读以了解发生了什么的东西。 + +条目格式: + +``` +=== [2026-04-22T22:14:12Z] bootstrap [45.1s] → success === + platform: linux + is_wsl: false + node_version: 22.22.2 + deps_ok: true + native_ok: true + raw: logs/setup-steps/01-bootstrap.log + +=== [2026-04-22T22:14:57Z] environment [2.3s] → success === + docker: running + apple_container: not_found + raw: logs/setup-steps/02-environment.log + +=== [2026-04-22T22:15:00Z] container [92.4s] → success === + runtime: docker + image: nanoclaw-agent:latest + build_ok: true + raw: logs/setup-steps/03-container.log +``` + +设计约束: +- 开始行带有起始时间戳(UTC,ISO-8601),使 `grep` 能给出顺序。 +- 持续时间以秒为单位,保留一位小数——快速步骤读作 "0.5s",而非 "0ms"。 +- 状态为以下之一:`success`、`skipped`、`failed`、`aborted`。 +- 字段是步骤特定的,但**必须**是短标量值。无 JSON,无多行。如果值很长,放在原始日志中并引用它。 +- 始终输出一个 `raw:` 指针,即使在成功时——使调试第二次失败更容易。 +- **用户选择**是其自己的条目,不嵌套在步骤中: + + ``` + === [2026-04-22T22:17:44Z] user-input → display_name === + value: gav + + === [2026-04-22T22:17:51Z] user-input → channel_choice === + value: telegram + ``` + + 这些很重要,因为通过安装流程的路径取决于它们。 + +日志以标识运行的头部块开始,以完成块结束: + +``` +## 2026-04-22T22:14:12Z · setup:auto 已启动 + user: exedev + cwd: /home/exedev/nanoclaw + branch: branded-setup + commit: 6e0d742 + +… (步骤条目) … + +## 2026-04-22T22:18:54Z · 已完成 (总计 4m42s) +``` + +失败时完成块会指出失败的步骤及其错误: + +``` +## 2026-04-22T22:16:40Z · 在 container 处中止 (err=cache_miss) +``` + +### 级别 3:原始按步骤日志 + +`logs/setup-steps/NN-step-name.log`——每个步骤一个文件,按执行顺序编号(两位零填充前缀以支持自然排序)。来自子进程的完整逐字 stdout + stderr。每次运行时截断并重写(非追加)。 + +内容为步骤输出的任何内容:apt 输出、docker 构建层、pnpm install 内容、`curl` 响应体等。这是证据层面——"shell 实际看到了什么?"不过滤任何内容。 + +## 新步骤的契约 + +当你添加一个步骤(无论是 `setup/.ts` 中的 TS 步骤还是从 `auto.ts` 调用的 bash 安装程序),它必须: + +1. **从调用方接收一个原始日志路径。** 将所有 stdout + stderr 写入那里。不要直接写入终端。 +2. **在结束时输出单个终端状态块**,包含 `STATUS: success|skipped|failed` 和任何步骤特定字段: + + ``` + === NANOCLAW SETUP: STEP_NAME === + STATUS: success + KEY: value + KEY: value + === END === + ``` + + 字段名使用 `UPPER_SNAKE_CASE`。值是短标量值。 + +3. 如果是一个长时间运行的步骤,可选择在中途发出**子状态块**。`auto.ts` 实时解析它们,并可以渲染中间 UI(正如 `pair-telegram` 使用 `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` 所做的那样)。 + +4. **在硬失败时以非零退出**,以便 `auto.ts` 可以区分"步骤运行完成并报告失败"与"步骤崩溃"。 + +驱动程序处理其余部分:级别 1 中的 spinner,级别 2 中的结构化追加,级别 3 中的原始捕获。 + +## Anthropic 例外 + +Anthropic 凭据注册(`setup/register-claude-token.sh`)是视觉流程中**唯一**允许的中断。原因: + +- `claude setup-token` 打开浏览器,运行自己的 OAuth 提示,并打印 token。它通过 `script(1)` 拥有 TTY。 +- 我们不想自己重新实现 OAuth 设备流程。 +- 我们不想拦截/镜像 token(它已经出现在用户终端——镜像它会增加攻击面)。 + +因此在此步骤期间: +- clack 流程显式暂停(一个 `p.log.step` 标记说"这部分是交互式的,你正在移交给 Anthropic")。 +- 子进程完全继承 stdio。 +- 当控制返回时,clack 在下一行恢复,带有一个成功标记。 + +级别 2 日志仍然获得一个条目(`auth [interactive] → success` 带有方法——subscription / oauth-token / api-key)。级别 3 的捕获在这里是可选的;镜像 `script -q` 输出很棘手,将 token 泄露到磁盘的风险超过了调试价值。 + +## 文件参考 + +| 文件 | 角色 | +|---|---| +| `nanoclaw.sh` | 顶层封装。阶段 1(bootstrap)和阶段 2(setup:auto)编排。写入 bootstrap 的原始日志 + 进度条目。 | +| `setup.sh` | 阶段 1 bootstrap:Node、pnpm、原生模块验证。发出自己的 `BOOTSTRAP` 状态块(历史上打印到 stdout;现在输出到 bootstrap 原始日志)。 | +| `setup/auto.ts` | 阶段 2 驱动程序。编排 clack UI、步骤执行、用户提示,并为其启动的每个步骤写入所有三个日志级别。 | +| `setup/logs.ts` | 日志记录原语(`logStep`、`logUserInput`、`logComplete`、`stepRawLog`、`initSetupLog`)。级别 2/3 格式和文件路径的唯一真源。 | +| `setup/.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` 的结构化输出。** 交互式步骤目前不发出机器可读状态。未来可以在交互后添加一个状态块,注明使用的方法。 diff --git a/docs/zh/setup-wiring.md b/docs/zh/setup-wiring.md new file mode 100644 index 0000000..bc9fce6 --- /dev/null +++ b/docs/zh/setup-wiring.md @@ -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-` 技能将适配器文件放入并通过 barrel 槽注册 +- 主干不提供任何频道适配器 + +### 安装注册(部分完成) +- `setup/register.ts` 在 `data/v2.db` 中创建实体(`agent_groups`、`messaging_groups`、`messaging_group_agents`) +- 接受 `--platform-id` 标志 +- `getMessagingGroupAgentByPair()` 防止重复连线 +- `setup/verify.ts` 检查中央库(统计有连线的 agent group 数量) + +### 路由日志 +- `src/router.ts` 在没有 agent 连线时以 WARN 级别记录 `MESSAGE DROPPED`,并提供可操作的指导 + +--- + +## 之前未完成——现已解决 + +### 1. ~~频道技能不注册 Group~~ ✅ + +频道技能现在在其"下一步"部分指向 `/manage-channels`。注册由 `/manage-channels` 技能处理,该技能读取每个频道的 `## Channel Info` 部分以获取平台特定指导。频道技能保持精简(仅凭据)。 + +### 2. ~~安装 SKILL.md 缺少 Group 注册步骤~~ ✅ + +在频道安装(第 5 步)和挂载白名单(第 6 步)之间添加了第 5a 步"将频道连接到 Agent Group"。此步骤调用 `/manage-channels`,它处理 agent group 创建、隔离级别决策和连线。 + +### 3. ~~频道技能应知道频道类型~~ ✅ + +每个频道技能都有一个结构化的 `## Channel Info` 部分,包含:type、terminology、how-to-find-id、supports-threads、typical-use、default-isolation。`/manage-channels` 技能读取这些信息以提供上下文建议。 + +### 4. ~~验证步骤频道认证检查~~ ✅ + +`setup/verify.ts` 检查所有频道 token:DISCORD_BOT_TOKEN、TELEGRAM_BOT_TOKEN、SLACK_BOT_TOKEN+SLACK_APP_TOKEN、GITHUB_TOKEN、LINEAR_API_KEY、GCHAT_CREDENTIALS、TEAMS_APP_ID+TEAMS_APP_PASSWORD、WEBEX_BOT_TOKEN、MATRIX_ACCESS_TOKEN、RESEND_API_KEY、WHATSAPP_ACCESS_TOKEN、IMESSAGE_ENABLED,以及 WhatsApp Baileys 认证目录。 + +### 5. Agent-Shared Session 模式 ✅ + +添加了 `session_mode: 'agent-shared'` 用于跨频道共享 session(例如 GitHub + Slack 在同一对话中)。当设置此模式时,Session 解析按 agent_group_id 而非 messaging_group_id 查找。 + +--- + +## 架构参考 + +### 实体模型 +``` +agent_groups (id, name, folder, agent_provider, container_config) + ↕ 多对多 +messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy) + 通过 +messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority) + +users (id, kind, display_name) -- 命名空间为 ":" +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 读写) | diff --git a/docs/zh/skills-as-branches.md b/docs/zh/skills-as-branches.md new file mode 100644 index 0000000..01006d6 --- /dev/null +++ b/docs/zh/skills-as-branches.md @@ -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//` 包含代码文件 | 自包含工具;代码在技能目录中,安装时复制到位 | +| **操作型(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 +``` + +这会创建一个新的提交来撤销该技能的改动。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//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//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//` 或 `container/skills//` 添加文件的 PR——无需分支提取。所有技能类型请参见 [CONTRIBUTING.md](../CONTRIBUTING.md)。 + +### 贡献者流程(功能技能) + +1. Fork `nanocoai/nanoclaw` +2. 从 `main` 创建分支 +3. 进行代码改动(新的频道文件、修改的集成点、更新的 package.json、.env.example 新增内容等) +4. 向 `main` 提交 PR + +贡献者提交一个普通的 PR——他们不需要了解技能分支或市场仓库。他们只需进行代码改动并提交。 + +### 维护者流程 + +当技能 PR 被审查和批准后: + +1. 从 PR 的提交创建一个 `skill/` 分支: + ```bash + git fetch origin pull//head:skill/ + git push origin skill/ + ``` +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//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//nanoclaw.git +> git push --force origin main +> ``` +> 即使你非常落后也可以——只需推送当前状态。 +> +> **如果你之前通过旧系统应用了技能**,代码改动已经在你的工作树中——无需重做。你可以删除 `.nanoclaw/` 目录。未来的技能和更新使用基于分支的方法。 +> +> **发现技能:**技能现在通过 Claude Code 的插件市场可用。在 Claude Code 中运行 `/plugin` 浏览和安装可用技能。 + +### 致技能贡献者 + +> **贡献技能** +> +> 贡献一个技能: +> 1. Fork `nanocoai/nanoclaw` +> 2. 从 `main` 创建分支并进行代码改动 +> 3. 提交一个普通的 PR +> +> 就这样。我们将从你的 PR 创建 `skill/` 分支,将你添加到 CONTRIBUTORS.md,并将 SKILL.md 添加到市场。CI 自动保持技能分支与 `main` 向前合并,使用 Claude 解决任何冲突。 +> +> **想运行你自己的技能市场?**在你的 fork 上维护技能分支并创建一个市场仓库。提交 PR 将其添加到 NanoClaw 的自动发现市场中——或者用户可以通过 `/plugin marketplace add` 手动添加。 diff --git a/docs/zh/v1-to-v2-changes.md b/docs/zh/v1-to-v2-changes.md new file mode 100644 index 0000000..c0456ee --- /dev/null +++ b/docs/zh/v1-to-v2-changes.md @@ -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 = ` + - 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//inbound.db`——**宿主机写入,容器读取**。`messages_in`、路由、destinations、pending questions、processing_ack。计划任务存于此处(见"调度")。 +3. `data/v2-sessions//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-` 技能: +1. `git fetch origin channels` +2. `git show channels:src/channels/.ts > src/channels/.ts` +3. 将 `import './.js';` 追加到 `src/channels/index.ts` +4. `pnpm install @chat-adapter/@` +5. `pnpm run build` + +幂等——重新运行为无操作。固定版本保持供应链诚信。自动迁移检测 v1 中哪些频道已被连接(通过不同的 `channel_name` / JID 前缀),并为每个频道运行匹配的 `setup/install-.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 = ":", 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//CLAUDE.md` 和可选的 `logs/`。`CLAUDE.md` 是一个纯指令文件,group 特定。 + +**v2:** 每个 group 仍然位于 `groups//`,但形态更丰富: +- `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//.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)。