# 架构说明 ## 两半,一个包 ``` ┌─ 宿主进程 ────────────────────────────────┐ ┌─ 浏览器 ────────────┐ │ impl.js │ │ client.js │ │ ├─ store 持久状态 + 串行写队列 │ │ ├─ 侧边栏条目 │ │ ├─ 记忆文件层 $DSH_HOME$DSH_HOME/bot/memory/ │ │ ├─ bot 页面 │ │ ├─ 工作区判定 纯函数,无模型 │ │ └─ 设置页 │ │ ├─ 执行器 可选,默认关 │ │ │ │ ├─ 审批闸门 │ │ 同一组 HTTP 路由 │ │ ├─ 连接(入站/出站) │ │ /api/bot.state │ │ └─ MCP 客户端 │ │ /api/bot.manage │ │ │ │ /api/bot.inbound │ │ 7 个 bot_* 工具 │ │ /api/bot.rewind │ └───────────────────────────────────────────┘ └─────────────────────┘ ``` 两半在同一个包里:宿主那半由 `cordis.patch.yml` 那一行挂载,Web 那半由 `package.json` 的 `dsh.client` 声明。**客户端那半通过 `window.__ModuleLoader__.load()` 注册,不 import 任何 `@deepseek-ai/dsh-client-*` 包** —— 于是它不会因为上游改了内部类名而碎掉。 ## store:为什么是串行的 状态在一个 JSON 文件里(`$DSH_HOME$DSH_HOME/bot/bot.json`)。多个工具同一刻调用是常态 —— 两个 `bot_remember` 并发、一个 `bot_task` 和一次入站消息撞上。 做法是**串行写队列 + 随机临时文件名 + 原子改名**: - 写操作排队,不并行落盘; - 每次写先落到 `bot.json..tmp`,再 `rename()` 覆盖 —— 改名在同一文件系统内是原子的,所以**读到的永远是完整的旧版本或完整的新版本**,不会是半个文件; - 随机后缀让两个进程同时写也不会互相覆盖临时文件。 有一条测试专门验证这件事:两个写入同刻提交,两条都完整落盘且计数各自准确。 ## 记忆文件层:分层就是策略 ``` $DSH_HOME$DSH_HOME/bot/memory/ ├── MEMORY.md ← 根文件,全文进上下文 ├── SOUL.md ← 它是谁 ├── USER.md ← 用户是谁、在意什么 ├── MEMORY-CORE.md ← 长期的决定与事实 └── notes/ └── 2026-09.md ← 子目录,只贡献 name + description ``` **规则只有一条:文件的第一段 frontmatter 里的 `name` 和 `description` 是它的名片;`MEMORY.md` 的存在本身就是「这一层是记忆」的声明。** 于是: - 根文件全文进系统提示 —— 它们承担「改变行为」的职责,所以必须一直在; - 子目录里的每个文件只贡献两行 frontmatter —— 模型知道有这么一份笔记存在,需要时才去读; - **把一个文件从根挪进子目录,就是降级;挪出来就是升级。** 移动是文件操作,不是配置修改,所以你用文件管理器就能改。 推论:**给一份笔记写一句好的 description,比给它写长正文更有用** —— 前者决定它会不会被想起来。 写入会被拒绝的情况(身份证号、凭据、令牌)在 `addMemory` 里判定,是硬门,不受设置影响。 ## 工作区判定:一个纯函数 ```js workspaceVerdict(toolName, args, workspace) → "inside" | "guarded" | "refused" | "unchecked" ``` 四个答案,`unchecked` 是其中最重要的一个。 前两个好懂:在工作区里、在工作区外面。`refused` 是绝对不允许的(比如写系统目录)。**`unchecked` 指的是「这个工具没有路径参数可查」** —— 一次搜索、一次桌面点击、一次日历写入都没有路径,所以路径规则对它们无从下手。 它的存在不是为了在代码里表现得不一样,而是为了**让「查过了,没问题」和「这里没东西可查」在代码里分得开**。否则前者会冒充后者 —— 一个没被检查的动作,看起来和一个通过检查的动作一模一样。那是安静失败最经典的形状。 `commandPathTokens` 把 shell 命令切成路径形状的 token,所以埋在一句 `# 注释` 里的路径骗不过 `startsWith`。重定向目标单独提取。 **整个判定不含模型调用。** 这是刻意的:能确定性判断的事就不该问模型,尤其是安全相关的。 ## 审批闸门 任务执行时,每一次工具调用都过一遍 `workspaceVerdict`。返回 `guarded` 且实例权限不是 `full` 时: 1. 任务进入 `awaiting`,**不是失败**; 2. 停下的理由里带上**工具名和完整参数**(截断到 1200 字); 3. 用户在面板里看到它,选择批准 / 授一张通行证 / 拒绝。 **批准之后重跑不会再拦你** —— `guarded` 分支会看 `task.approved`。没有这一条,批准会变成死循环:任务回到队列、重跑、撞上同一个边界、再停下,而用户看到的是**一模一样的问题**,会以为自己那次点击没生效。 通行证(waiver)**只匹配完全相同的工具名**,而且**永远带过期时间**。没有「永久允许」这个选项,因为没人能替未来的自己签字。 改任务的标题或备注会撤销批准:**你当初批准的是当时读到的那个任务。** ## 执行器:事件与状态,不是「该做点什么了」 执行器是可选的后台循环,默认关闭。打开后,`satisfiedConditions` 决定这一轮做不做: | 条件 | 检查的是 | | --- | --- | | `openTasks` | 队列里有它自己的、非自主产生的活(默认唯一开启的) | | `unreadInbound` | 有从连接进来、之后没有回应过的消息 | | `agendaDue` | 有到点未完成的日程 | | `workspaceChanged` | 工作区里有比自己上次看过更新的文件 | **空闲只是前置条件,不是理由本身。** 找不到理由就明确报告「空闲够了,但没有任何符合条件的理由」,而不是被悄悄填满活动。 这直接来自调研里那句话:**用事件触发、用状态设条件,而不是由「该做点什么了」驱动。** 因为后者答不出「为什么是这件事,为什么是现在」。 单写者锁按**解析后的工作区**而不是实例 id 分配 —— 两个实例指向同一个目录时,它们不该同时写。 ## entry.js:为什么是个薄壳 `entry.js` 只做一件事:`stat()` 实现文件,把它的 mtime 编进 import URL,然后 import。于是**改 `impl.js` 不必重启宿主** —— 新请求会拿到新的 URL,Node 就重新加载。 **但它导出的 `inject` 才是宿主读的那一份。** Cordis 读的是 entry 模块的导出,不是实现文件里的。两者不一致时,插件会在缺服务的情况下启动,然后在第一次用到时报: ``` cannot get property "agentDefaultModel" without inject ``` 这句话看起来像「部署没配」,实际是「插件没拿到那个服务」—— 修法完全不同。所以: - `entry.js` 与 `impl.js` 的 `inject` 必须逐字一致; - 验证套件里有一条断言守着这件事; - `bot_status` 会打印当前生效的模型及其来源,让这句话在状态里就能被区分。 ## 客户端:一次轮询喂所有界面 侧边栏的每个实例条目、bot 页面、设置页,**订阅同一次 `/api/bot.state` 轮询**(3 秒)。所以它们不会各说各话。 **样式归属也是刻意的**:侧边栏条目在侧边栏渲染时就要出现,而页面只在面板被选中时才渲染。所以条目**自带全部样式,内联**,不依赖任何可能还没挂载的样式表。 `inject` 里声明了 `@deepseek-ai/dsh-client-ui-conversation`,因为要复用它的会话行外观(宽度轴、输入框形状)—— **复用外观,但不 import 它的内部类名**。 ## 验证策略 三层,各自覆盖不同的失败: | 层 | 覆盖什么 | 跑法 | | --- | --- | --- | | `tests/verify-bot-plugin.mjs`(224 项) | 加载**真实** schema 验证器,端到端跑每个工具与路由 | 本地 | | `scripts/check-client-wiring.mjs`(25 项) | 「改了但没接上」——语法通过只说明文件能解析,不说明元素真的在渲染树里 | 任何地方 | | `scripts/audit-against-research.mjs` | 逐条到代码里查符号,回归「已实现 / 未实现」的判定 | 任何地方 | 第一条刻意**不用桩**:一个桩会乐于同意插件对一个宿主会拒绝的 schema 的看法,而那正是这套测试要抓的那类失败。