# dsh-onebot-hub · 设计与验证笔记 > **使用者只需要看根目录的 [README.md](../README.md)**——安装、配置、要知道的行为约定、工具清单都在那里。 > 本文是完整的设计与验证记录:拓扑推导、协议取证、每个测试断言覆盖什么、历次真机事故的复盘。 > 章节号沿用旧文档,方便回溯"当初为什么这么写"。 **[English](#english-summary) | 中文** 把 **DeepSeek Harness 挂到 OneBot v11 总线上的双向枢纽**。它同时扮演两个角色: - **上游的客户端**:以正向 WebSocket 拨号连上真正的 OneBot v11 实现端(NapCat / LLOneBot / go-cqhttp 等),拿到**全部**事件与全部 API 能力,DSH 自己也是一个 bot。 - **下游的实现端**:用同一套协议对下游 bot 提供反向 WebSocket 端点(并可主动拨号下游实现端),把用户指令**零改写**透传给下游,同时把下游的每一次 API 调用(`send_msg`、`get_msg`…)看得一清二楚。 一句话:**DSH 站在协议层,既能让指令畅通无阻地穿过它进入下游,又能知道所有消息和下游到底做了什么。** ## English summary A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that puts DSH on an **OneBot v11** bus (QQ / Lagrange / NapCat / LLOneBot up top, NoneBot or any OneBot implementation down below): - **Upstream client** — dials the real implementation over a forward WebSocket, so the agent sees every event and has every action available. - **Downstream implementation** — serves (or dials) downstream links with the same protocol and **relays events verbatim**, so existing bot plugins keep working with no adapter shims. - **A node that observes** — a full timeline of every message plus an action ledger of what each downstream bot did, which is what makes "how do I use this bot?" learnable. - **Agent-facing** — 28 `onebot_*` tools (observe people/groups/messages, control downstream bots, reply as the bot, admin actions, media, memetic-image library, cross-session memory) plus a persona system that decides when the agent should speak at all. Everything else in this README is in Chinese. ## 1. 为什么需要它(与 `dsh-onebot` 的区别) 已有的 `dsh-onebot` 适配器只做一件事:把上游的 `post_type: message` 变成一次 Agent 交互。它**不暴露任意 OneBot action**,也无法观察下游。 而 `nonebot-plugin-aigf-master` 这类"AI 群友"方案在**插件层**合成事件去调用下游插件,因而有 7 类系统性失效(见方案文档 `../docs/dsh-onebot-hub-方案.md` §18.2):`to_me` 恒为假、`sender.role` 恒为 `member`、只支持群聊、`reply` 恒为空、拿不到群名片/头衔/等级、`message_id` 恒为 `0`、`on_keyword`/`on_regex`/`on_message` 型插件根本扫不到。 **本插件把这件事下沉到协议层**:事件不再被合成,而是从上游原样搬来、原样搬给下游。下游适配器自己会 `event.message.reduce()` → `_check_reply()` → `_check_at_me()` → `_check_nickname()`(`nonebot/adapters/onebot/v11/bot.py:202-210`),所以只要事件是真的,上述 7 类失效就**自然消失**。 ## 2. 拓扑 ``` QQ 实现端(上游,真 OneBot v11) ▲ hub 正向 WS 拨号(X-Self-ID: hub 自己的号) │ 事件下发 / action 上行 ┌────┴─────────────────────────────────┐ │ dsh-onebot-hub(DSH 插件) │ │ protocol.js 零改写编解码 + 重签 │ │ trace.js 四道防环闸 │ │ virtual-world.js 实现端语义(get_msg…)│ │ router.js 策略:relay / bridge / … │ │ capture.js 时间线 + action 台账 │ └────┬─────────────────────────────────┘ │ ① hub 在 /onebot-hub/ws 上做反向 WS 端点(下游拨入) │ ② hub 也可主动拨号下游(下游是反向 WS 服务端时) ▼ X-Self-ID: 下游自己的号 下游 bot(NoneBot / 其它 OneBot v11 实现端) └─ 下游插件被真实事件触发,回复走 send_msg 回到 hub ``` ## 3. 安装 插件就是一个普通 npm 包(无构建步骤,纯 ESM;唯一的运行时依赖是 `ws`)。DSH 的插件管理器用 `pnpm add ` 把它装进当前 profile,所以 **Git 源直接可用**: ```sh # 1) 装进 profile(DSH 插件管理器的等价命令) cd ~/.dsh/profiles/<你的 profile> pnpm add github:Funny1Potato/dsh-onebot-hub # 本地开发:pnpm add file:D:/path/to/dsh-onebot-hub # 2) 装配:把插件加进 profile 的 cordis 补丁 # 本包自带的 cordis.patch.yml 内容等价于下面三行 # (DSH 设置页里改配置同样会即时重挂,不用手改) - insert: - id: onebot-hub name: 'dsh-onebot-hub' # 3) 重启 DSH / 热加载 profile,然后在「设置 → OneBot 枢纽」里填配置 ``` 要点: - **上游**:`upstreamUrl`(正拨)或 `upstreamListen`(等实现端拨进来,插件自己开监听端口)。 - **下游**:`downstreamTargets` 数组,四种形态 `ws-dial` / `ws-listen` / `http-api` / `http-post`。 - **浏览器半侧**(设置页):本包已声明 `dsh.client`,DSH 会自动加载 `./client`。 - 想看它能干什么:装好后按 §8 的清单复验,或者直接让 agent 调 `onebot_hub_status` 看链路。 ## 4. 配置 **这些配置项都可以在 GUI 里改**:设置页左侧多了一个 **OneBot 枢纽**(`client.js` 注册进 `settings.section`),里面按配置族分组列出全部字段,改完点保存即写进当前 profile 的 `cordis.patch.yml` 并**即时重挂插件**(效果与手改配置文件后 Loader 重载完全一致)。`cordis.patch.yml` 仍然可以直接手改,两条路互不冲突。**每个字段都有人话**:中文名(如「反向监听地址」)+ 下方小字一句说明(这项管什么、改了会怎样)+ **完整配置键**仍然并列显示(形如 `upstreamListen`,方便拿去配置文件里对照);过滤框**同时认配置键与中文名**(敲「看图」能找到 `vision.mode`)。凭据字段(`upstreamAccessToken` / `downstreamAccessToken` / `anime.recognizeToken` / `imageGen.apiKey`)**不回显明文**:留空 = 不改,只告诉你"设过没设过"。JSON 字符串字段(`actionPolicy` / `persona.presets` / `chatCommands.superUsers` / `capability.writeAllow` 等)是多行**文本框**,得按 JSON 写——这一条是已知的粗糙处,不是 bug;**`downstreamTargets` 例外**:它是"连多个下游"的入口(§19),所以有**行编辑器**(类型下拉 + 地址 + 账号 + 备注名 + token + **探针账号/下游组** + 重连,加/删/逐条停用,改完照样序列化成同一个 JSON 字符串写回),旁边留「以 JSON 编辑」逃生口。取值封闭的字段是**下拉列表**(`preset` / `deliveryMode` / `agent.mode` / `vision.mode` / `probe.isolation` / `anime.backend` / `memory.isolation.level` / `memory.isolation.crossGroupFacts` / `memory.isolation.privateFacts`),选项也带中文说明;下拉里另有"自定义…",当前值不在预设里时会**如实列出**(不静默改写)并允许切回文本框。**`agent.defaultModel` 与 `agent.defaultVisionModel` 两行也从下拉选**(`m024193`):选项是 **DSH 自己的模型清单**(`llm.listProviders()` + `llm.listModels()`,取回后拍成 `provider/model`),由宿主侧一条只读路由 `GET /onebot-hub/model-list` 提供(`lib/llm-models.js`;浏览器半侧 inject 只有 `slots`/`configForms`,拿不到 `llm` 服务)。**两个思考强度行**(`agent.defaultReasoningEffort` / `agent.defaultVisionReasoningEffort`)的档位**只从配对那个模型的 `reasoning.efforts` 出**(`llm.resolveModelInfo`;`listModels` 本身不含 reasoning),上游没声明档位就**不列这一项**、页面改成文本框并在旁边写明原因("没取到清单"/"先选模型"/"该模型没声明档位")。取不到清单时全部退回手写文本框,功能不受影响;装机状态看 `onebot_hub_status.host.modelList`。**这套文字由 `lib/client.js` 的 `FIELD_TEXT` 维护**(141 条,键是完整配置路径);`test/load-check.mjs` 会拿真 schema 的叶子清单跟它对账——**新增配置项却忘了补文字,测试直接红**;`test/client-manifest.mjs` 另外钉住 `MODEL_FIELDS` 的键集、effort↔模型的配对、以及两处 `/onebot-hub/model-list` 字面量一致。为什么这个页面要自带、而不是等宿主生成,见 §6.2「配置 UI」。另外**两项刻意的例外**:`upstreamListen` 与 `downstreamTargets` 的 schema 里**没有默认值**(没配过就是没这一项),页面靠 `ALWAYS_FIELDS` 给它们补空行才渲染得出来,并且这两行**只有「撤销」、没有「恢复默认」**——它们本来就没有"默认"可恢复(`lib/client.js` 的 `NO_RESET_KEYS`)。 | 键 | 默认 | 说明 | | --- | --- | --- | | `enabled` | `true` | 关掉后 hub 不建立任何链路 | | `upstreamUrl` | `''` | 上游实现端**正向 WS**地址,如 `ws://127.0.0.1:3001`(hub 拨号过去)。留空 = 不拨号 | | `upstreamListen` | (无默认) | 上游**反向 WS**监听地址,**地址里直接带路径**:`'127.0.0.1:14514/onebot/v11/ws'`(也接受直接粘 `ws://…` 完整 URL;`:14514/onebot/v11/ws`、`14514/onebot/v11/ws` 缺 host 时默认 `127.0.0.1`;**不写路径 = 只接受根路径 `/`**,写什么就是什么、不偷偷补默认;留空 = 不监听)。用于实现端主动拨进来的场景(LLBot/Lagrange 常见),与 `upstreamUrl` 二选一,`upstreamListen` 优先。路径对不上仍按老规矩 `close(1008)` | | `upstreamSelfId` | `''` | hub 在上游的登录账号(拨号时作为 `X-Self-ID`;监听模式下用于识别"我自己")。**留空 = 连上后自动认**:对方握手头 `X-Self-ID` → 上游事件的 `self_id` → `get_login_info` 探测,三条路任一条到了就记下(状态里 `upstreamAccount.source` 会写 `configured`/`learned`/`unknown`)。写了的**以配置为准**:观测到别的账号只回一条 `config-wins`,不改行为。下游那些"不写对方账号 = 与上游相同"的目标也靠它建链 | | `upstreamAccessToken` | `''` | 非空则以 `Authorization: Bearer ` 握手 | | `downstreamAccessToken` | `''` | 校验下游拨入 / 推事件时的 token(每条目标可覆盖)。留空 = 不校验 | | `downstreamTargets` | (无默认) | 下游连接列表(**多条**,像 LLBot 的 `ob11.connect[]`):`[{"type":"ws-dial","address":"127.0.0.1:8080/onebot/v11/ws","selfId":"30001000","nickname":"probe-bot"}]`。**四种类型**:`ws-dial`(默认,hub 拨过去)|`ws-listen`(hub 监听、对方拨进来)|`http-api`(hub 提供 HTTP API,对方 `POST //`)|`http-post`(hub 把事件 POST 给对方);也认 LLBot 的 `ws-reverse`/`ws`/`http`。**地址统一写 `host:port/path`**(scheme 可省,按类型补 `ws://`/`http://`;写了 `wss://`/`https://` 就按 TLS 走);不写路径 = 根路径 `/`。`selfId` 是这条链路上**对方(app)的账号**(拨号/POST 型当 `X-Self-ID` 发出去,也用来重签事件里的 `self_id`):**不写 = 与上游账号相同**(取 `upstreamSelfId`);上游账号**也还不知道**时**不丢这条目标**——先登记、状态里标 `pendingAccount`,hub 先不建链(拿空身份握手没意义),等学到上游账号(握手头 / 事件 `self_id` / `get_login_info`)**自动补建**(监听型本来就不需要账号,对端在握手头里自己报)。`id` 是唯一键(缺省 = 显式写的 `selfId`;没写就用 `host:port`,免得几条"默认账号"的目标互相撞成 `#2`);`enabled:false` = 配着但先不连;`reconnectInterval` 每条可不同。**探针隔离相关三个可选字段**(§5 / §19):`probeSelfId` = **同一下游的另一个 bot 账号**——写了它就等于一次声明两条链路,hub 把这条展开成"主链路 + 一条 `probeOnly` 的探针链路"(**地址不变、只有 `selfId` 不同**;探针那条 `id` 为 `~probe`、没写 `id` 就用主链路的键,口令/`downstreamId` 与主链路共用;`probeSelfId` 与主链路 `selfId` 相同或留空都**不展开**),所以不用写两条必须互相对齐、写岔了就会把注入打到真链路上的条目;**不设默认值**——链路隔离(`probe.isolation: 'link'`)离了它不生效(自动回退到 `off`);`probeOnly: true` = 这条链路只收 `onebot_invoke` 的注入、不收上游真人消息(同上,`link` 档用它;也可以不写 `probeSelfId`、直接另写一条标 `probeOnly` 的目标,两种写法等价);`downstreamId` = **逻辑下游**(缺省 = `selfId`),"两条物理链路属于同一个下游"只能由人声明,agent 猜不出来。设置页里是**行编辑器**(类型下拉 + 地址 + 账号 + 备注名 + token + **探针账号/下游组** + 重连),不用手写 JSON | | `preset` | `relay` | 策略预设,见 §5 | | `deliveryMode` | `transparent` | 事件投递模式:`transparent` / `replay` / `synthesize` | | `broadcast` | `true` | 上游事件是否分发给**所有**下游链路(关掉只发目标链路) | | `readonly` | `false` | **只读**:hub 自己绝不动作——不唤醒 agent(不花 token)、不代答聊天管理命令、也不发消息;事件照旧广播给下游("全量观察,绝不动作")。`shadow` 预设自带打开;**只能打开、不能用 `false` 关掉预设自带的那份**(不想只读就换预设) | | `deliverMessageSent` | `false` | 是否把上游的 `post_type: message_sent` 也下发给下游 | | `virtualizeMessageId` | `false` | 下发时是否把 `message_id` 换成 `virtual:::`(默认保留原值) | | `reconnectInterval` | `5000` | 断线重连间隔(ms) | | `requestTimeout` | `30000` | action 请求超时(ms) | | `heartbeatTimeout` | `120000` | 上游心跳超时(ms) | | `downstreamHeartbeatMs` | `30000` | 往 `implementation` 型下游链路推 `meta_event: heartbeat` 的间隔(ms);**`0` = 不发**。`bot-app` 型(NoneBot 这类)永远不发 | | `echoWindowMs` | `3000` | 防环去重窗口(ms) | | `maxHop` | `3` | `dsh_trace.hop` 上限,超过即丢弃 | | `actionPolicy` | `'{}'` | JSON 字符串,覆盖预设的动作策略,如 `{"get_group_member_list":"relay"}` | | `logFrames` | `false` | 打印每一帧收发(排查用) | | `persist` | `true` | 是否落盘(只读缓存与实测能力结论)。关掉即完全不碰磁盘 | | `storageDir` | `''` | 落盘目录;留空用 `~/.dsh/onebot-hub`。`persist: false` 时该项无意义 | | `agent.mode` | `assist` | 唤醒档位(每会话一套"休眠/激活"状态机,见 §21.7):`observer`(只看不说,模型完全不被调用)/ `assist`(平时**休眠**只记录,**被叫到**才醒并进激活;激活期按批量窗口持续参与)/ `active`(休眠时每 `activeTickMs` 看一眼有没有新消息,有就醒一次并进激活;被叫到同 `assist`) | | `agent.batchSize` | `6` | **激活状态下**攒够多少条消息就再唤醒一次(休眠期的消息只是攒着,醒来时一起看) | | `agent.batchMs` | `8000` | **激活状态下**批量窗口最长等待(ms),从进入激活/上次唤醒后的第一条新消息算起,到期再唤醒一次 | | `agent.awakeMs` | `300000` | 激活状态能挂多久(ms):**从"最后一次有人说话或它自己回话"算起**,这么久两边都没动静才回休眠(`0` = 不超时,一直挂着激活)。回话本身续期,所以是"说完还能再待一会儿",不是"说完立刻睡" | | `agent.activeTickMs` | `300000` | `active` 档休眠时的判断间隔(ms):每隔这么久看一眼该会话有没有新消息(`0` = 关掉节拍唤醒) | | `agent.maxActive` | `8` | 同时活着的会话 agent 上限,超出按 LRU 回收 | | `agent.idleDisposeMs` | `1800000` | 空闲多久回收会话 agent(ms,默认 30 分钟) | | `agent.tickMs` | `1000` | 编排层定时检查间隔(ms:批量到期、激活超时、`active` 节拍、空闲回收) | | `agent.silenceWhenDownstreamResponded` | `true` | **命令静默硬约束**:本轮下游已应答则不唤醒、不发言 | | `agent.wakeOnPrivate` | `true` | 私聊是否算"被叫到"(打开时私聊每条都立刻唤醒;关掉则私聊也只记录) | | `agent.wakeOnNotice` | `false` | `notice`/`request` 类事件是否唤醒(休眠态仍只记录;激活态才会按窗口唤醒) | | `agent.speakAssistantText` | `false` | **发言兜底(默认关,m30383 定案)**:本轮没调 `onebot_reply` 时,把 agent 的最终回复文本当作发言发出去。默认只有工具能发言——普通回复文本只是内部草稿;开成 `true` 才恢复"文本也是发言"的老行为 | | `agent.guidance` | `''` | 覆盖默认的全局指导段(留空用内置文本) | | `agent.defaultModel` | `''` | **hub 配置的默认聊天模型**(`m024167`):留空 = 跟 DSH 系统默认路由;选一个模型就以它为默认——激活建会话、`/model default` 复位、命令面显示的"当前"都回到它。设置页从 **DSH 的模型清单里选**(`m024193`),清单取不到时退回手写 `provider/model`(或只写 `model`,provider 沿用会话现有值) | | `agent.defaultVisionModel` | `''` | **hub 配置的默认识图模型**:留空 = 系统默认模型;选定就以它为看图默认,会话里的 `/vmodel` 仍然覆盖它。同样从模型清单里选 | | `agent.defaultReasoningEffort` | `''` | **聊天思考强度**(`m024193`):档位由**模型自己**定义(上游 `reasoning.efforts`),hub 不解释也不校验——空 = 不带这个参数,模型走它自己的默认档位。设置页只列配对模型声明过的档位,没声明就不列这一项 | | `agent.defaultVisionReasoningEffort` | `''` | **识图思考强度**,与 `agent.defaultVisionModel` 配对,同上;非空时才给识图请求带 `reasoningEffort` | | `agent.groups` | `'[]'` | **群白名单(m31030)**:JSON 数组字符串,如 `'[{"id":"123456789"},{"id":"55555","mode":"observer"}]'`。**白名单制**——名单没出现的群一律不调模型(@ 也不理);条目只认 `id`(群号)与 `mode`(可省 = 跟随全局 `agent.mode`,写 `observer`/`assist`/`active` 则只覆盖这一档,其余节奏参数仍用全局)。设置页有**行编辑器**(加行/下拉选模式/取消勾选=这条不生效),不用手写 JSON;配置表里写坏 JSON 按空名单处理 = 全部不喂模型 | | `agent.privates` | `'[]'` | **私聊白名单(m31030)**:与 `agent.groups` 同形,`id` 是 QQ 号。两张名单**都**没配置 = 功能未启用(不过滤);配置了任意一张,没列出的那一类也全拦。注意:这张表是"**谁能用 agent**",与"连哪些下游/上游"(§19)无关 | | `agent.steer` | `true` | **激活期插话(m31311)**:回合还在跑时到来的新消息,直接以【插话 N 条】递进**正在进行的回合**(宿主原语 `steer`/next-step,下一步开始前送达),而不是排到下一轮。被 @ 的和批量窗口触发的都会插。关掉则回到"等这一轮说完再看"(消息留在批量窗口里,不会丢) | | `agent.replyGapMs` | `400` | **多条消息之间的间隔(m32420)**:一次 `onebot_reply` 拆成多条后按这个间隔逐条发(`0` = 连着灌) | | `agent.replyMaxText` | `3` | **一次回复最多几条文字消息(m32420)**:`0` = 不限。超出的从尾部丢弃,并在工具回执里说清"另有 N 条文字超出上限未发" | | `agent.replyMaxImages` | `9` | **一次回复最多几张图(m32420)**:`0` = 不限。图**不占**文字额度(文字/图两个上限分开算) | | `memory.limit` | `5000` | 内存记忆条数上限(写入永远全量,隔离只作用于装配与检索) | | `memory.isolation.level` | `scoped` | 隔离级别:`strict` / `scoped`(默认,仅身份可跨会话)/ `balanced`(+ shareable 事实)/ `open`(全放,仅测试) | | `memory.isolation.crossGroupIdentity` | `true` | 身份档案是否可跨群复用 | | `memory.isolation.crossGroupFacts` | `shareable` | 跨群事实:`never` / `shareable` / `all` | | `memory.isolation.privateFacts` | `never` | 私聊内容:`never` / `sameActor` / `all` | | `memory.isolation.sensitivityAlwaysLocal` | `true` | 敏感信息(住址/证件/收入/病历…)一票否决,永不跨会话 | | `memory.isolation.overrides` | `'[]'` | JSON 数组,按 glob 覆盖某个 scope 的级别,如 `[{"match":"group:55555","level":"strict"}]` | | `memory.isolation.trustedGroups` | `'[]'` | JSON 数组,互为"同世界"的群组,如 `[["group:1","group:2"]]` | | `memory.isolation.recallEscape` | `false` | 是否允许检索越过隔离级别(`onebot_recall` 的 `scope: "all"`) | | `memory.isolation.recallEscapeNeedsApproval` | `true` | 越界检索是否必须 `confirm: true` 才给内容(`recallEscape` 为真时才起作用;为假则该次检索按 write 级要求审批) | | `memory.isolation.audit` | `true` | 是否记录跨会话记忆的放行/拦截留痕 | | `memory.isolation.auditRetain` | `200` | 审计账本保留条数 | | `memory.reminders.enabled` | `true` | 是否启用主动回忆的防骚扰安全阀(`false` 等价于窗口为 0 = 每次都提) | | `memory.reminders.windowHours` | `24` | 同一条未兑现承诺两次进 prompt 的最小间隔(小时) | | `memory.reminders.limit` | `5` | 一次最多交出去几条待办事实 | | `memory.reminders.max` | `200` | 冷却账本(`/reminders.json`)保留条数上限 | | `media.enabled` | `true` | 是否把消息里的图/语音/文件落地成 durable ref(关掉=只做文本语义化,转发照旧) | | `media.keepBytes` | `true` | 是否把字节写进 `/media/blobs`(关掉只记 sha256/大小,不存副本) | | `media.transcribe` | `true` | 语音是否试实现端转写(`fetch_ptt_text` / `voice_msg_to_text`);拿不到就标注"我听不了",**不猜内容** | | `media.maxBytes` | `8388608` | 单个媒体落地字节上限(默认 8 MiB),超了只记元信息 | | `media.fetchTimeoutMs` | `5000` | 取媒体字节(下载/换 URL)的超时(ms) | | `turns.windowMs` | `8000` | 回合配对窗口(ms):一条用户消息发出后,多久内的下游 `send_*` 算作"对它的响应" | | `turns.retain` | `400` | 内存里保留多少个已封口的回合(更早的只在 `turns/.jsonl` 里) | | `learn.threshold` | `3` | 同一用法被观测到几次才从 candidate 转 active(§17.2④;`confidence` 的语义就是观测次数) | | `learn.staleAfter` | `3` | 连续几次没复现就标 `stale` 并降置信(**不删**,§17.7) | | `learn.forgetAfter` | `6` | 连续几次没复现才真删(`delete_confidence` 达阈值),必须 ≥ `staleAfter` | | `learn.maxEvidence` | `20` | 每条用法最多留几个证据 message_id(超了丢最老的) | | `cards.maxLines` | `40` | 落盘的那份会话卡最多留几行近况(渲染进 prompt 时还会再截到 12 行) | | `cards.maxSessions` | `200` | 内存里最多同时拿几张会话卡(超了先扔最久没动的;**磁盘上那份留着**) | | `members.enabled` | `true` | 是否在 @ 到"群里还没名字的人"时按需问一次实现端(关掉就继续显示 `@用户123`) | | `members.cooldownMs` | `21600000` | 同一个 (群, 人) 的冷却:6 小时内只问一次(**失败也记账**,落 `/members/attempts.json`) | | `members.maxPerMessage` | `5` | 一条消息最多为几个人去问(超出的直接丢,不排队) | | `members.maxQueue` | `50` | 待问队列上限(满了丢新来的,宁可少知道一个名字也不积压) | | `vision.mode` | `describe` | 图片怎么看:`off`(只留 `[图片]` 占位)/ `segment`(把图片作为内容段交给 agent,需模型支持图片输入)/ `describe`(hub 自己叫一次视觉模型写描述,再拼进文本)/ `both`(两条都走)。识图默认模型由 `agent.defaultVisionModel` 决定(留空 = 系统默认模型),会话里用 `/vmodel` 换 | | `vision.prompt` | 内置中文提示 | 看图给视觉模型的用户侧提示词 | | `vision.maxTokens` | `0` | `0` = **不传** `maxTokens` 给上游,描述不做长度控制(`m24155`:不希望再看到任何因为字数被截断的事情);填 `>0` 才给单次生成加 token 上限 | | `vision.timeoutMs` | `30000` | 看图单次超时;超时就当没看见(不猜内容) | | `vision.cacheLimit` | `500` | `/vision/cache.json` 最多缓存几张图的描述(键是字节的 sha256) | | `persona.enabled` | `true` | 是否启用人设/预设层(关掉 = 不注入人设段,命令面也答"人设未启用") | | `persona.defaultName` | `default` | 默认预设名;没有为某会话绑定过预设时用它 | | `persona.presets` | `'[]'` | **JSON 数组**,启动时写进预设库的种子预设:`[{"name":"小助手","role":"…","knowledges":["…"],"hidden":false}]`。落盘内容才是权威,config 只是种子;`default` 不允许删 | | `chatCommands.enabled` | `true` | 是否启用聊天管理命令层 | | `chatCommands.prefix` | `/` | 命令前缀(`/help`)。只认**句首**前缀 | | `chatCommands.bypassPrefix` | `!!` | 让路前缀:`!!/help` 连枢纽那条回话都不要(消息本身一直都会照常转发) | | `chatCommands.superUsers` | `'[]'` | **JSON 数组**,超管 QQ 列表(`["10001"]`,支持 `"*"` 全开)。**留空 = 整条命令链沉默**:不拦任何消息,也不回任何话 | | `chatCommands.maxReplyChars` | `800` | 单条命令回话的字符上限(超了截断,避免把群刷屏) | | `memes.enabled` | `true` | 是否启用表情包库(关掉 = 不注入清单、`onebot_reply` 的 memes 一律被丢) | | `memes.topK` | `8` | 注入上下文的候选条数(最近用过的会强制入列) | | `memes.maxSend` | `3` | 一轮最多发几张表情(超出的按顺序丢) | | `memes.autoCollect` | `true` | **是否允许收录**:`false` = 完全不收集(`onebot_memes{action:'add'}` 直接拒绝,库只读);`true` = **只有 agent 能收录**(判断值不值得、写 brief 都由它做,hub 不再自动收纯表情) | | `anime.backend` | `off` | 角色识别后端:`off` / `anime-recognize`(自建接口)/ `animetrace`(公共 API)/ `both`(本地优先,未命中或报错才回落)。**默认关**:识别会发图给第三方 | | `anime.recognizeUrl` | `''` | `anime-recognize` 后端的地址 | | `anime.recognizeToken` | `''` | 上面那个后端的 Bearer token | | `anime.animetraceUrl` | `https://api.animetrace.com` | AnimeTrace 地址(可自建反代) | | `anime.minConfidence` | `0.85` | 低于此置信度的候选不渲染(宁缺毋滥)。只对**自建 `anime-recognize`** 有效——AnimeTrace 公共 API 不给数字分,它用每框的布尔 `not_confident` 说"我拿不准",那种框**一律不采纳** | | `anime.maxCharacters` | `1` | 一条消息最多报几个角色(默认只报置信度最高的那一个,m30282 定案) | | `anime.nsfwThreshold` | `0.5` | 判定 NSFW 的阈值;命中后只写 `[角色识别: 已识别但标记为 NSFW]`,**不输出角色名** | | `anime.timeoutMs` | `15000` | 本地后端超时(ms) | | `anime.cacheLimit` | `500` | `/vision/anime-cache.json` 最多缓存几张的识别结果(键是 `sha256:backend`) | | `imageGen.enabled` | `false` | 是否启用生图(**默认关**:要配了自己的接口才会工作) | | `imageGen.model` | `''` | 生图模型名 | | `imageGen.baseUrl` | `''` | OpenAI 兼容生图接口的 base URL | | `imageGen.apiKey` | `''` | 接口密钥(只进 `Authorization` 头;`stats`/回显里一律替换成 `***`) | | `imageGen.maxSize` | `1024x1024` | 尺寸上限:请求的 `size` 会**等比内缩**进这个框(不拉变形)。宽高比保持 | | `imageGen.minSize` | `''` | 尺寸下限(按面积向上放大);与上限冲突时下限优先 | | `imageGen.watermark` | `false` | 是否让实现端打水印 | | `imageGen.timeoutMs` | `120000` | 生图超时(ms)——生图本来就慢,默认给两分钟 | | `imageGen.maxPerTurn` | `1` | 一轮最多生成几张(防止模型把生图当聊天用) | | `code.scopes` | `'[]'` | **下游源码在哪,可多条**(可能有多个下游),形状 `[{"name":"下游A","path":"D:/somewhere"}]`(设置页有行编辑器:备注 + 路径,加/删随意;也认一行一个路径的纯文本,`备注 = 路径` 也行)。**这些路径不写进 prompt**:agent 需要位置时调 `onebot_code_scopes`(逐条 stat,缺的如实报 `missing`),读文件用它自己的 `read`/`grep`/`glob`——枢纽不代读、不给扫描工具,**权限由超管在自己的会话里给**(或 `/perm`,见 §17.4 与 §9)。留空 = 问位置时如实说"没配" | | `capability.writeAllow` | `'[]'` | **JSON 数组**:允许 agent 调用的 write 级 action(有副作用),支持 `*` 通配,如 `["send_like","set_group_card","delete_msg"]`。默认空 = 全拒 | | `capability.dangerAllow` | `'[]'` | **JSON 数组**:允许的 danger 级 action(踢人/禁言/改名/重启/隐藏 API)。**默认空 = 全拒**;它**不能**由 `writeAllow` 顺带放行 | | `capability.exposeSensitive` | `false` | 是否允许读凭证类接口(`get_cookies`/`get_csrf_token`/`get_credentials`)。默认关 | | `capability.cache` | `true` | 只读结果 TTL 缓存("这人是谁""这群多少人"会被反复问) | | `capability.probe` | `true` | 每次上游**连上**探一次实现端身份(`get_version_info`/`get_login_info`/`get_status`),同一条连接只探一次 | | `capability.callTimeoutMs` | `15000` | `onebot_call` 单次请求超时(ms) | | `capability.probeTimeoutMs` | `8000` | 能力探测单次请求超时(ms) | | `capability.downstreamReads` | `true` | `bot-app` 型下游发来的**只读**请求也走能力面(真实现端 + 与 agent 共享的缓存),答不了才退回虚拟世界;关掉则完全交给虚拟世界 | | `probe.isolation` | `off` | **探针隔离档位**(§19):agent 用 `onebot_invoke` 试探下游时,它的产物怎么不跟真人消息搅在一起。`off`=不隔离(**默认**,旧行为:注入与真人消息混在同一条链路上);`time`=时间隔离(**无需额外配置**):注入后开一个窗口,窗口内该会话的真人消息先攒着、到点按原序补发;`link`=链路隔离:给某条下游目标写一个 `probeSelfId`(=同一下游的**另一个 bot 账号**,地址不变),hub 会展开成第二条 `probeOnly` 链路,真人消息**一条都不投它**,注入只走它,归属退化成"从哪条链路回来的";**`link` 离了 `probeSelfId` 不生效**——一条探针链路都没有时**自动回退到 `off`**,状态里的 `isolation` 报生效档、`requested` 报配置档、`note` 说明回退原因。**链路隔离启用时,prompt 与 `onebot_invoke` 的返回体都会告诉 agent:探针的执行结果可能与用户触发时不同**(同一下游的另一个账号,那条链路上收不到群里的真人消息) | | `probe.windowMs` | `8000` | 时间隔离窗口长度(ms):注入之后多久内暂缓该会话的真人消息。窗口内又有注入会顺延 | | `probe.maxQueued` | `50` | 时间隔离的**安全阀**:单个窗口最多攒多少条,超了立刻按原序全部补发(记 `forcedReleases`,**绝不吞消息**) | | `probe.capture` | `true` | 探针链路(`probeOnly`)上的下游 `send_*` **只捕获不转发上游**:产物先进时间线给 agent 看,由它决定要不要真发(§19) | | `retention.days` | `7` | **原始报文与媒体的保留期**(`m03091`):收到的每条消息都缓存原文 + 索引(含 hub 解析不了的段、**不展开**的聊天记录),超过这么多天自动清掉——落盘的原始报文日文件、索引里的旧行、以及媒体 blob。`0` = 不清理 | | `retention.cleanupIntervalMs` | `21600000`(6h) | 保留期清理的间隔。启动时也会先跑一次;清理结果在 `onebot_hub_status` 的 `raw.lastPrune` 里 | > **两点刻意的"没有默认值"**:`upstreamListen` 与 `downstreamTargets` 的 schema 里**不写 `.default(...)`**,所以没配过的 profile 里这两项**根本不存在**(不是空串、不是 `[]`)。运行时不猜:`resolveConfig` 把它们兜底成"不监听 / 没有下游目标",`normalizeTargets('')` 与 `normalizeTargets('[]')`(老 profile 的旧默认值)也都读成空列表。设置页靠 `lib/client.js` 的 `ALWAYS_FIELDS` 给这两项补一行空行,否则用户没法把它们配回来;同时这两行**不给「恢复默认」按钮**(`NO_RESET_KEYS`)—— 它们本来就没有"默认"可恢复,只有「撤销」。 ## 5. 策略:预设、投递模式、动作模式 **预设**(`preset`,可用 `actionPolicy` 逐条覆盖): | 预设 | 事件 | 动作 | 具体是什么行为 | | --- | --- | --- | --- | | `relay`(默认) | 透传 + 广播 | `send_*` → `relay`,`get_*` → `local`,其余 `capture` | **管理者+透传**:下游说"发这条消息"→ 真发给 QQ 并把真 `message_id` 回填;只读类请求先走能力面(真实现端),答不了退虚拟世界;其它动作只记账 | | `bridge` | 透传 + 广播 | `*` → `both` | **双向桥**:下游 `send_msg` **既**真发上游(回真 `message_id`)**又**并发广播给除来源外的其它下游,应答里附 `mirrored`/`mirroredTotal` 两个计数 | | `fabric` | 透传 + 广播 | `send_*` → `mirror`,`*` → `capture` | **多下游织网**:下游 `send_msg` **不惊动 QQ**,只在其它下游之间互播(同样附镜像计数) | | `shadow` | 透传 + 广播 + **只读** | `*` → `capture` | **影子观察**:下游照旧看得见全部事件,但 hub 自己绝不动手——不叫模型、不代答命令、不发消息(命中命令只记时间线) | | `solo` | 透传,**不广播** | `*` → `capture` | 只把 hub 当一个普通 bot 用:事件不下发给别的链路(只有同号链路拿得到),下游动作全部只记账 | **投递模式**(`deliveryMode`):`transparent`(原样搬运,**默认**)、`replay`(重放)、`synthesize`(合成事件,非默认——会退回 master 的老问题)。 **动作模式**(`actionPolicy` 的取值):`local`(hub 作为实现端自己回答)、`capture`(只记账、回**带虚拟 `message_id`** 的空壳成功)、`relay`(转发上游并回填下游)、`mirror`(并发投给其它链路,回镜像计数)、`both`(relay + mirror)、`drop`(静默丢弃:只记账,回成功空壳且**连虚拟 `message_id` 都不给**)、`deny`(回 retcode 1403)。 匹配优先级:**精确键 > 通配键(去掉 `*` 后最长者)> 预设默认**。键支持 `send_*` 这样的 glob。 **探针隔离**(`probe.isolation`,§19):agent 学下游用法时会用 `onebot_invoke` 往下游**注入消息事件**试探("这命令会不会触发它")。注入不是真人在说话,但它引发的回复会跟真人消息混在同一条链路上——事后没有确定性证据能分清哪条是被探针引出来的。三档应对: - **`time`(无需额外配置)**:注入时给**这个会话**开一个窗口(`probe.windowMs`,默认 8s),窗口内该会话的真人消息**先攒着**、到点按原序补发(时间线里记 `held:probe-window` 与 `probeReleased`/`probeHeldMs`,晚到多少毫秒可查)。窗口内又有注入就顺延;攒到 `probe.maxQueued` 立刻全量按原序补发(安全阀)。窗口按会话开:探针在 A 群,B 群不受影响。 - **`link`**:给下游目标的配置项里加 `probeSelfId`(**同一下游的另一个 bot 账号**,比如 `30001999`)——hub 会把这条目标**展开成两条链路**:主链路照旧,另加一条 `probeOnly` 的探针链路,**地址与主链路完全相同,只有 `selfId` 不同**,口令与 `downstreamId` 也共用(**一个下游写一条配置**,不必写两条必须互相对齐、写岔了就会把注入打到真链路上的条目)。探针那条的 `id` 是 `~probe`(没写 `id` 就用主链路的键);`probeSelfId` 与主链路 `selfId` 相同或留空都**不展开**——所以 `link` 档**离了它不生效**,一条探针链路都没有时自动**回退到 `off`**(状态里 `isolation` 报生效档、`requested` 报配置档、`note` 说明回退)。也可以不要 `probeSelfId`、直接另写一条标 `probeOnly: true` 的目标——两种写法等价。真人消息**一条都不投**探针链路(`decideEvent` 返回 `probe-only-link`),注入不给 `linkId` 时默认走它;它上面的下游 `send_*` 默认只捕获、不转发上游(`probe.capture`),产物先进时间线给 agent 看。归属退化成"从哪条链路回来的",零推断。**代价要认**:下游把探针那条当成**另一个 bot 账号**(同进程的 NoneBot 就是同一个插件实例、同一份状态——冷却/缓存/定时任务共享),所以**探针的执行结果可能与用户触发时不同**;prompt 与 `onebot_invoke` 的返回体都会这么告诉 agent,别把探针结果当成群里真实发生过的对话。 - **`off`(默认)**:不隔离,注入与真人消息混在一起(旧行为)。 **路由权在枢纽,不在调用方**(`m02768` 真机事故后定案):早期版本把**物理链路**(`linkId`)做成工具参数,而"显式点名就照发"——结果 agent 顺手填了一条真实链路,隔离被静默绕过:一次注入的图直接进了群。现在 `onebot_invoke` / `onebot_relay_probe` 只接受 **`downstream`**(逻辑下游名 / 备注名 / 对方账号 / 目标键;缺省 = 只有一条下游就用它,多条必须点名),探针链路还是真实链路由 `probe.isolation` 决定;给了物理链路键也只会被当成"要问哪个下游"。真要故意打真实链路得显式 `allow_real_link: true`,返回体里会带 `notIsolated` 告警("本次未隔离,产物可能直接进群")。选不中下游时报中文错误并列出有哪些下游(`describeDownstreams()`),绝不静默挑一条。 **多下游的记录都带人话名字**(`labelOf`):`linkId`/`downstreamId` 常常只是 `host:port`,真实链路与探针链路只差一个 `~probe`,agent 分不出"这是哪个 bot"——所以时间线条目带 `linkLabel`、捕获与回合带 `downstreamLabel` / `responderLabels`、状态里每条链路带 `label`(备注名优先,探针链路加"(探针)")。想在记录里看到有意义的名字,就在设置页给那条目标填**备注名**。 **下游发出的媒体也会被解析**(`m02768`):上游进来的图早就有落地 + 看图 + 识番的旁路,下游 `send_*` 里的图/语音/文件原来只显示 `[图片]`,而捕获账本里塞着**完整的 base64**(真机上见过一次 101KB 的结果)。现在下游媒体走同一条路:落地成 durable ref、把 `(已存为 hub-media:xxx)` 写回文本、按需交给视觉模型看一次,并把捕获账本里的内联数据换成一行占位说明。**转发帧一个字节都不动**(`params` 原样转上游/其它下游),也不把 bot 产物收进表情包库。 判不出的情况一律**不扣**:宁可漏扣,不可误扣(把群友应得的回复吞掉比多转一条更糟)。 ## 6. 工具(DSH agent 可用) | 工具 | 参数 | 作用 | | --- | --- | --- | | `onebot_tools` | `list?` `enable?` `disable?` `all?` `slim?` | **按需取回 DSH 的其它工具**(`m02675`/`m02678`):枢纽默认只放开本插件与 `read`/`grep`/`glob`/`read_image`/`pwsh`/`job_*`/`web_*`/`todo_write`,其余(`task_board_*`、`subagent`、`workflow`、`skill`、`plugin_manager`、`compress`…)默认不给——工具目录是**每次请求都在**的缓存前缀,76 个工具约 69K 字符。`list:true` 看"已放开/可放开",`enable`/`disable` 点名增减,`all` 解除限制,`slim` 回默认;**只对本次激活有效** | | `onebot_hub_status` | 无 | 上下游链路、每条链路的 selfId/role/连接状态(含 `downstreamId` 逻辑下游名与 `probeOnly`)、策略摘要、agent 池与记忆统计、隔离级别、`probe` 段(档位、窗口、`probeLink`、窗口计数与 `held`/`released`/`forcedReleases`) | | `onebot_timeline` | `limit?` `sessionKey?` `linkId?` `direction?` `kind?` | 全知时间线:`upstream-in` / `downstream-in` / `downstream-out` / `hub-out` / `hub-in` | | `onebot_context` | `sessionKey` `actorId?` `windowLimit?` `text?` | 某个会话此刻装配进 prompt 的上下文段(指导/来源/人设/表情包/能力/待办/人物/群/记忆——**v4:会话卡与最近窗口不再进快照**,只在重启恢复时例外注入一张卡)+ 会话卡/窗口的**旁路数据**(`digest`、`window`,排查重复注入时看这里)+ 隔离审计。时间**不在这段里**:`【现在】` 跟着每批新消息走(见 §6.1),否则整份快照每分钟都会被宿主重注一遍 | | `onebot_memory_audit` | `limit?` `sessionKey?` | 跨会话记忆的放行/拦截留痕(谁、为什么、哪个级别) | | `onebot_capture` | `limit?` `action?` `linkId?` | 下游发过的每一个 action 请求(`action`、`params`、`decision`)——**学习下游用法的原始证据** | | `onebot_invoke` | `downstream?` `allow_real_link?` `mode?` `text?` `message?` `group_id?` `user_id?` `quote_message_id?` `action?` `params?` `linkId?`(仅 `mode=action`)`session_key?` | **控制下游**。默认 `mode=event`:向下游注入一条消息事件(= 以某个身份向下游说一句话/发一条命令),真实触发它的 matcher。`mode=action` 只对**实现端型**链路有效(那时 `linkId` 才是"投哪条链路")。填了 `session_key` 只是把"这次代调属于哪个会话"讲清楚(不参与任何准入判定)。**注入走哪条链路由枢纽决定,不由调用方选**(`m02768`):`downstream` 只说"要问哪个**下游**"(逻辑下游名/备注名/账号;缺省 = 只有一条就用它,多条必须点名);`probe.isolation: 'link'` 时一律走那条下游的探针链路(返回体带 `probeLink{caveat}`——**这次的结果可能与用户触发时不同**);`time` 档会给该会话开窗口并暂缓真人消息(返回体带 `probeWindow`)。**点名物理链路也不好使**:给了只会被当成"要问哪个下游",隔离不会被顺手绕过;真要故意打真实链路得显式 `allow_real_link: true`,结果里会带 `notIsolated` 告警。以谁的身份说由 `user_id` 决定(默认 hub 的上游账号)——**换 `user_id` 常常就是换结果**(下游按说话人判权限/冷却/用户级状态),所以"这条命令行不行"只对你用的那个账号成立,要验证某人就用他的账号再试一次 | | `onebot_reply` | `text?` `images?` `memes?` `parts?` `message_type?` `group_id?` `user_id?` `quote_message_id?` | 用 hub 自己的身份经上游发言(会话 agent 用的是同名的会话作用域版本,只写候选、本轮结束时统一发出)。**一次调用可以发多条消息(m32420)**:`text` 里的**空行**断条、**每张图各成一条**、连续的『文字/`@`』合成一条;`parts`(段数组,`{type:'text'|'at'|'image'|'break'}`)可以精确控制穿插与 `@` 归属(`break` 强制断开)。上限只限**文字条数**(`agent.replyMaxText`,图另算),超出的如实告知。`memes` 只能填**上下文里列出的 id**,编造的会被丢掉并明确告知(不静默变成空回复) | | `onebot_call` | `action` `params?` `refresh?` `timeoutMs?` | **通用 action 通道**:直接问上游真实现端任何 OneBot v11 action(协议层原语,不经过下游)。只读走缓存 + 中文 retcode 说明;write/danger 级默认被策略拒绝,见 §6.2 | | `onebot_admin` | `op` `params?` `confirm?` | **群管理动作**(踢人/禁言/全员禁言/管理员/退群/改名片/改群名/撤回/点赞/头衔/处理加好友与加群请求):先把人话 op 翻成 OneBot action,**不带 `confirm` 只预演**(不下发任何字节,并告诉你当前策略会不会拦),`confirm=true` 才真发。它不能绕过闸门 | | `onebot_caps` | `refresh?` | 上游实现端身份(名称/版本/协议/账号)+ **实测**可用性(`supported`/`unsupported`/`unknown`)+ 缓存命中与闸门设置 | | `onebot_profile` | `user_id?` `friends?` `self?` `refresh?` | 看"人":hub 自己是谁(`get_login_info`)、陌生人资料(`get_stranger_info`)、好友列表(`get_friend_list`) | | `onebot_group` | `group_id?` `list?` `honor?` `notices?` `refresh?` | 看"群":群资料、群荣誉、群公告(`_get_group_notice`,扩展接口,不支持时给 note)、已加入的群列表 | | `onebot_members` | `group_id` `user_id?` `limit?` `refresh?` | 看"群里的人":成员列表(默认截断到 `limit` 并给出 `total`)或单人资料 | | `onebot_avatar` | `user_id?` `group_id?` `size?` | 头像 URL。规范里**没有**头像接口:用户头像用社区惯例(`provenance: community`),群头像有实现端文档出处(`provenance: impl-doc`) | | `onebot_media` | `messageId?` `type?` `link?` `name?` `list?` `limit?` `durable?` `describe?` `action?` `file?` `message_id?` `timeoutMs?` | **两种用法**:① 落地成 durable ref(M14)——给 `messageId` 或 `link`,把图/语音/文件存进 `/media/blobs` 并登记宿主 attachments(上游 URL 会过期,ref 不会;语音会试实现端转写,拿不到就写"我听不了",不猜内容),`list: true` 看最近落地过什么,`describe: true` 顺带把图片交给视觉模型看一次并把描述写进 ref(按字节 sha256 缓存,同一张图不会问第二次);② 问实现端——`get_image` / `get_record` / `get_forward_msg`(这类结果不缓存,URL 会过期) | | `onebot_memes` | `action?` `id?` `messageId?` `link?` `keywords?` `brief?` `description?` `limit?` | **表情包库**(④):**是否收藏由 agent 判断**(prompt 里有 aigf 式的"值得才收"三条),hub 不再自动收纯表情。`action` = `list`/`add`/`update`/`remove`/`stats`/`get`;`add` **必须给 `brief`**(一句话简介,会进 prompt 清单),`description` 可省(兜底用已生成的图片描述,`get` 才看得到全文);`add` 走同一条媒体落地链(`messageId` / `link` 认 URL、本地路径、`hub-media:`),按 sha256 去重;库有硬容量(默认 200 张,超了自动淘汰少用/久未用的) | | `onebot_imagegen` | `prompt` `size?` `reference?` `message_type?` `group_id?` `user_id?` `session_key?` | **生图**(⑦):按 `imageGen` 配置调绘图后端,出图后经上游直接发出去。**没配置就说清缺什么**(`imageGen.enabled/model/baseUrl/apiKey`),不发空图;每轮有硬上限 `imageGen.maxPerTurn`(默认 1),超了明确说"这轮画完了" | | `onebot_memory` | `ops` `sessionKey?` `actorId?` | **写记忆**(M16):模型给结构化指令(`short_term`/`long_term` 的 `add`/`modify`/`delete`、`persons`、`groups`、`topics`、`self`),代码只校验、应用、落盘。`modify` 的 `index` 就是上下文里渲染出的序号;越界/未知字段会进回执的 `rejected`,**不静默** | | `onebot_person` | `userId` `name?` `sessionKey?` `raw?` | 看**人物档案**:名字(跟着群走)、群名片与角色、我知道的事实、答应过没做的、纠正过我的、印象、亲近度,以及**曾用名**(`pastNames`,改名之后还认得出是同一个人)。事实按隔离过滤,`denied` 给出被挡条数;一无所知时回 `empty: true`。给 `name` 则反过来查"这个名字是谁"(当前名优先于曾用名) | | `onebot_sessions` | `limit?` | **会话清单**:每个会话最后热闹的时间、我最后说话的时间、未读与是否被静音 | | `onebot_topic` | `id?` `status?` `limit?` | 看**话题线程**(可跨群):聊到哪一步、有没有结论。给 `id` 看一条,不给就按最近活动列出 | | `onebot_raw` | `ref?` `sessionKey?` `kind?` `direction?` `since?` `until?` `limit?` | **原始报文缓存与索引**(§16 L1,`m03065`):**收到的每一条消息/动作都留了原文与索引**——包括 hub 认不出的段类型、以及**故意不展开**的聊天记录(合并转发 / 多消息卡片)。hub 只做"能自动做的那点解析",更深的语义由 agent 自己处理:先不带 `ref` 看索引挑出要哪条,再带 `ref`(时间线条目 id,就是 `onebot_timeline` 每条里的 `rawRef`;或 OneBot 消息 id)取回**完整原始报文**与这条已落地的媒体(`media[].blob` 是本地路径),然后用它自己的工具读(换格式、OCR、解包转发节点…)。原文里的内联 base64 已换成占位说明(字节都在媒体 blob 里)。按 `retention.days` 自动清理 | | `onebot_recall` | `query?` `person?` `worldKey?` `since?` `until?` `kinds?` `groupBy?` `openOnly?` `sources?` `scope?` `confirm?` `includeWeak?` `limit?` | **翻聊天记录**(M17):一个工具多种问法——中文两字词也搜得到(两侧都切 bigram)、按人/时间窗/段类型(`image`/`file`…)/按人分组/只看没结的话题。检索**只给当前会话看得见的东西**,被挡下的条数在 `denied` 里;`scope: "all"` 是越界检索,默认降级并带 `note`,配置要求审批时需 `confirm: true` | | `onebot_turns` | `since?` `sessionKey?` `onlyResponsive?` `withOutcomes?` `linkId?` `reset?` `limit?` | **回合配对**(M6):看"哪条消息触发了下游什么"——每条入站消息开一个回合,把窗口内下游真正发出的 `send_*` 归到它名下,给出响应者、延迟(`latencyMs`)与"没人响应"的沉默回合。挂不到任何触发上的下游动作单列在 `unsolicited`(定时任务、自发消息不会被硬塞给最近那条)。纯协议层观测,不需要下游配合;`reset: true` 只清内存,落盘的 `turns/.jsonl` 不动。`linkId?`(§16.6)本实例只接一条上游链路,给的值必须对得上(`upstreamLinkId`/`upstreamSelfId`/后缀匹配),**对不上回 `ok: false` 并说明本实例接的是哪条**——这个参数不是被忽略,是明确不匹配;多上游请起多实例 | | `onebot_capabilities` | `query?` `kind?` `status?` `limit?` `linkId?` `upsert?` `forget?` `stale?` `prefixCandidates?` `reset?` `flush?` | **下游会吃什么消息**(M7-①,§17.1):一张 "Downstream Capability Map"。hub 只负责客观事实——从 `onebot_turns` 里 `silent: false` 的回合机械地攒候选(前缀命令/带 @/回复依赖/关键词)、推断各家真实前缀、数观测次数;**"这是不是命令、怎么用"由 agent 用 `upsert` 写回来**(可写 `name`/`aliases`/`args`/`usage`/`examples`/`notes`,给纯名字就新建或按名字/别名复用已有记录)。`confidence` 的语义就是**观测次数**,到 `learn.threshold`(默认 3)才转 `active`;连续没复现先标 `stale` 降置信、再久才删。全程静默:不发往 QQ、也不发往下游。落盘与实测结论共用同一个 `capabilities/.json` 的 `commands` 字段 | | `onebot_relay_probe` | `downstream?` `allow_real_link?` `linkId?` `text` `hasAt?` `hasReply?` `dryRun?` `confirm?` `group_id?` `user_id?` | **先预测再动手**(M7-③,§10):`dryRun` 默认 true —— 只回答"这句话按现有用法知识库会不会触发下游、会被谁触发、匹配到哪条形状、参数是什么",**不发送任何东西**(在群里瞎试命令的代价是真的发出消息)。判定用同一张表:精确命中命令名/别名 > 句首命令 > 关键词包含 > 正则命中;"需要 @/需要回复"的前提不满足会降级成"像但不算命中";证据不足(candidate)打八折、已过时(stale)打四折并明说把握不足。真发必须 `dryRun: false` **且** `confirm: true`,否则拒绝且一个字节都不发。**真发时走哪条链路同样由枢纽决定**(`m02768`):`downstream` 只说"问哪个下游",探针/真实按 `probe.isolation` 选,`allow_real_link: true` 才允许故意打真实链路(结果带 `notIsolated`)。**`downstream` 与 `linkId` 是两件事**:前者是**下游**(消费方),后者指**上游**链路——预测查的那张表按上游链路分(同 `onebot_turns`,对不上回 `ok: false`;拿下游名去滤上游表只会静默滤空) | | `onebot_code_scopes` | 无 | **下游源码在哪**(`m14477`):读 `code.scopes`(可多条,一条一个下游),逐条 `stat` 后回 `{name, path, exists, kind}`——`kind` 是 `dir`/`file`/`other`,路径不存在如实报 `missing`(带 `error` 里的错误码),**不编造、不代读**。地址**不常驻 prompt**:agent 需要位置时才调这个工具;读文件仍用自己的 `read`/`grep`/`glob`,权限不够就如实说。没配就回 `count: 0` + "去问超管,别猜路径" | 另外插件会往 system prompt 注入 `plugin:dsh-onebot-hub`(order 160)的指导文本。 ## 6.1 编排:记忆、上下文与唤醒(M15 最小闭环) 数据流是单向的一条管道,每一级都能单独观察与重放: ```text 上游事件 → 段语义化(segments.js: 人话文本) → L0/L1 时间线(hub.timeline) → 媒体落地(media.js: durable ref) → 看图/识番(hub.#enrichImages: 描述写回文本、识番并进同一句人话;**不再自动收藏表情包**——收不收由 agent 判断) → L1′ 回合配对(turns.js: 谁触发了谁) → 用法学习(learn.js:只从观测里学"下游吃什么") → 试探先预测(relay_probe: 会不会触发,不发) → L2 会话卡(digest,同时增量落盘 cards.js) → 上下文装配(人设/表情包 + L3 能力注入) → 睡/醒状态机 + 被叫到判定(policy) → 会话 agent(pool) → onebot_reply 候选 → send_msg 出站 ``` - **时间线是事实层**:`hub.timeline` 记录每一条 `upstream-in` / `downstream-in` / `downstream-out` / `hub-out`。它是"得知所有消息"的来源,也是会话卡、学习与翻记录的唯一底层。同一条消息在时间线里有**两个视角**(上游收到 + 我们转发给下游),事实层两个都留;但**进 prompt 之前必须去重**(`canonicalEvents`,见 §9 事故 #3),否则模型会以为用户把同一句话说成了两遍。 - **会话卡是记忆层,但不再默认进快照**(v4):`lib/memory/digest.js` 机械压缩出参与者、最近 N 条、下游是否已应答、消息量——不调模型;计数与最近条目都先过 `canonicalEvents`,所以"最近 N 条"是**真实发生过的条数**,不把转发镜像算成两条。它继续作为 `onebot_context` 的旁路数据与重启恢复的例外注入(见下条),日常装配只靠开局快照与批次。 - **会话卡也落盘**(`lib/memory/cards.js`,§24.10):L1 是**只追加**的,启动时不回放进内存环——所以重启之后检索索引还能搜到过去,而**每一轮都注入的会话卡会变成"最近 0 条消息"**(它认得出人、翻得到历史,却不知道刚才在聊什么)。卡片因此单独增量落一份到 `/cards/.json`(防抖合并写、失败只记日志、坏文件跳过);环里没有这一会话时退回落盘的那份,并且**一路标注 `resumed: true`**——卡头上写"(重启前留下的那一份,MM-DD HH:MM 为止)"、末尾再说明"这是从磁盘读回来的,现在实时窗口是空的"。**刻意不做回放**(把 jsonl 重新灌回内存环):那会把整条历史重新变成"现在",模型会以为几天前的话是刚说的。"记住多少"可以少一点,"以为刚发生过"是错。 - **上下文段(v4,`lib/memory/assembler.js`,order 升序 = 变化频率升序,利于 prompt cache)**:全局指导 0 → 来源头 10 → **人设 15** → **表情包 18** → 能力 30 → 待办 32 → 人物卡 33 → 群档案 34 → 记忆 35。**默认没有任何字符预算**(`DEFAULT_BUDGET` 全 `Infinity`,`m24155`:不希望再看到任何因为字数被截断的事情)——`clip()` 机制保留,只有调用方显式传预算才会截;长度在写入侧管(writer 超限 rejected、条数限制),渲染侧只管放与排序。人物卡与群档案来自 `Profiles`(跨会话),所以进 prompt 前会**再过一次** `filterForScope`。**会话卡 20 与最近窗口 40 已移出快照**(用户定案 m24409):宿主的 runtime-context 快照**只追加不替换**,而卡与窗口每来一条消息就变 ⇒ 整份快照重注,实测同一批消息被注入 3 次(批次/卡明细/窗口);会话卡只剩一个例外——重启恢复(`digest.resumed`)时注入一次(窗口空着期间文本恒定,不会反复触发重注),最近窗口改随**每段会话第一批**的【开局快照】注入一次。 - **隔离只作用于装配**(`lib/memory/isolation.js`):写入永远全量,`memory.isolation.level` 决定装配/检索时能看见什么;跨群条目**不带来源**进 prompt,放行与拦截都进审计账本。 - **会话白名单是最先判的闸**(`m31030`,`lib/agent/policy.js` 的 `sessionOverrideOf`):`agent.groups` / `agent.privates` 两张名单(JSON 数组,设置页行编辑器),**名单没出现的群/私聊一律不喂模型**——`observe()` 在建批量窗口之前就返回 `reason:'unlisted'`(`@` 也不理,`stats.unlisted` 计数,`agents.lastObserve.reason` 写明原因),下游出站记账与 `active` 节拍同样过闸。条目可写 `mode` 只覆盖那一档(observer/assist/active),其余节奏参数用全局;**两张名单都没配 = 功能未启用**(不过滤,兼容老配置),配了任意一张就是严格白名单制。判断顺序:白名单 → observer → 自己的消息 → 元事件 → 被叫到。 - **唤醒是显式决策**(`lib/agent/policy.js`):每个会话有一个"休眠/激活"状态(`Mind.#wakeStates`)。**硬闸(与状态无关,先判)** = `observer` 档 → 自己的消息 → 元事件 → 非消息类(未开 `wakeOnNotice`)。**被叫到优先**:`@` 我 / 引用我的话 / 私聊每条 → 立刻醒(`m02678` 用户定案:**纯文本昵称不再唤醒**——上游账号昵称就是 `test`,`latest`/`contest`/`npm test` 里都含它,就算加词边界,"npm test"这种独立词照样唤醒,而误唤醒的代价是整轮模型被叫起来说话;昵称只留在 `agents.identity` 里做展示)(**连"下游已应答"这道静默闸都不挡**,见 §9 事故 #10);非被叫到的消息才看静默闸——它的回看起点取"窗口开启时间"与"最近一个 `batchMs`"里更晚的那个,免得休眠期那个永不 flush 的旧窗口把会话永久压死。"在叫我"的比对材料(账号与一组名字)是**实时**的:`Mind.liveSelfId` 按"配置 `upstreamSelfId` → hub 学到的账号"兜底,昵称是**一组**(配置备注名 + `get_login_info` 学的真昵称),at 段认 `qq`/`user_id` 两种写法且 `@all` 不算(见 §9 事故 #11)。**休眠态**:只记录、不入模型。`active` 档多一个出口——每 `activeTickMs` 由 `Mind.tick` 在**有新消息时**唤醒一次(`tick:active`)。**激活态**:按批量窗口(`batch:full` / `batch:timeout`)再唤醒;**连续 2 轮没开口**(`AWAKE_SILENT_TURNS`)或**最后一次"有人说话或它自己回话"之后 `awakeMs` 两边都没动静**就回休眠(`m34646` 用户定案:空转基准取 `max(最后一条入站消息, 最后一轮结束)`,`lastTurnAt` 在 `flush()` 结算处打,所以回话本身续期;否则 `active` 档下"消息→等窗口→等模型→才答完"会把额度烧光,表现为刚回完话就睡回去、会话被归档。`stats.awakened` / `stats.dormant` 记账,`onebot_hub_status` 的 `agents.wakeStates` 与 `agents.lastObserve` 看得到每个会话此刻的睡/醒、以及最近一条消息为什么没醒)。 - **agent 池**(`lib/agent/pool.js`):每个 `agentKey`(默认 = 会话键)一个 DSH 会话,同一 key 串行、LRU 回收、空闲 dispose。宿主 API(`agents` / `sessionPersistence` / `agentDefaultModel` / `workspaceRegistry`)通过 `ctx.inject` 动态获取;**拿不到宿主时枢纽照常转发,只是不唤醒**。唤醒前会先 `unarchiveSession` 把会话从宿主的归档集合里摘出来(否则每一轮都会被 `archived-session-gate` 静默拒成 `blocked`,见 §9 事故 #2)。 - **激活期插话 = `steer`/next-step(`m31311` 用户定案,默认开,`agent.steer`)**:回合在飞时到来的新消息**不排下一轮**,直接递进正在进行的回合——宿主两个收件箱不同:`followup()` 排 `next-turn`(只能等回合结束),`steer()` 排 `next-step`(下一个步骤边界认领、拼进同一回合,且运行时上下文每步重新投影,插话那步模型就看得见最新窗口)。`Mind.flush` 在 `#flushing`(回合在飞)时不再返回 `batch:in-flight` 干等,而是走 `#trySteer`:把整批新消息渲染成 `【插话 N 条】会话 …|现在 …`(**不带**【开局快照】,那是每段会话一次的东西)递进去,成功才排水。**结算只归主 flush**——`whenIdle()` 会覆盖插话延长的那几步,所以回复缓冲的 take/deliver、发言记账(`#noteSpokeOutcome`)、`turnSeq` 都还是主 flush 一次做完,插话路径不碰它们(否则会双计"沉默轮"把激活误打回休眠)。一轮里多次 `onebot_reply` **后写覆盖**(最后一次生效),想改口就再调一次。**回落路径一条都不丢消息**:会话不在飞 / 宿主没有 `steer` / 递送失败 → 返回 `batch:in-flight` 照旧排队;下游刚答过(静默闸)→ 根本不插也不排水,留给下一轮正常结算。插话会把回合**延长**,所以激活空转的计时以"最后一条新消息"为准(`#expireAwake` 里 `steering` 在飞的会话不过期)。prompt 指导段里也明写一句:思考时可能有新消息送到,自己判断要不要改口。 - **一次 `onebot_reply` 可以发多条(`m32420` 用户定案,`agent.replyGapMs/replyMaxText/replyMaxImages`)**:拆条规则与 `nonebot-plugin-aigf-master` 一致(用户点名参考)——**每张图各自一条消息;连续的『文字/@』合成一条;空行或 `{"type":"break"}` 段强制断开**。几个容易踩的点:①**`@` 永远并进相邻文字那条**(用户裁决"@ 的字段不应该单独拆出来"),只有"前面没攒到文字、后面紧跟图"时才单独成一条,否则这个 `@` 就丢了;`@` 归哪一条由 agent 自己用 `break` 决定;②简写参数是"文字全在前、图全在后",要**穿插**(图夹在两段话中间)就用 `parts` 段数组;③`at` 段只认 QQ 号,填昵称丢掉并在回执里说"@ 段认不出账号"。组内相邻原子之间插**恰好一个空格**(`@` 后面尤其需要),头尾不插。发送端按 `replyGapMs` 逐条发,**每条各记一条时间线**(`refs.part/of`),`stats.sent` 逐条累加;**第 k 条失败即停**,剩下不发,`lastTurn.failedAt` 记下停在哪一条,下一批开头如实告知"第 k/N 条没发出去,后面 M 条也因此没发"。上限**只限文字条数**(用户裁决),图另算,`0` = 不限;超出丢**尾部**并在工具回执里说清少发了几条(`describePlanIssues`),编造的表情包 id 也**从静默丢改成回执明说**。会话缓冲版与直发版两条路径共用同一个 `composeReply`/`deliver`,行为一致(此前"一次一调只发一条、调 N 次真发 N 条"的不一致已消除)。 - **每轮的"状态"与"新消息"是分开的两条路**(用户定案,见 §9):**易变上下文**(人设/表情包/能力/待办/人物/群/记忆——v4 起**不含**会话卡与最近窗口,它们每条消息都变,进快照必致整份重注)走**会话级 live section**——`setup` 里注册 `systemPrompt.context({ name:'onebot-hub:context', order:100, text: () => mind.liveContext(sessionKey) })`,宿主的 `text` 支持函数,于是**每次装配 prompt 时现算**,永远最新且**不进会话记录**;**用户消息只写本批新消息**(`Mind.#batchText`,形如 `【新消息 N 条】会话 group:…` 加几行 `MM-DD HH:MM 谁:内容`,下游回应也入批——`noteDownstreamSend` 不再有 awake 门槛)。**每段会话的第一批**前面还会缀一次【开局快照】:本段开始之前的时间线窗口 + 参与者统计(`Mind.#openingText`),按条目 id 与归一化正文两道去重,已进过批次的话不再重复。以前是把整份上下文当用户消息发出去,于是转录里堆 N 份、滑动窗口互相重叠、旧上下文还会排到 system prompt 的"最近窗口"**前面**(模型拿很久以前的话当上下文)。 - **会话按群名 + 时间命名,旧会话直接归档**(用户定案,见 §9):`hostRef.create` 建完会话就调 `sessions.rename(sessionId, title)` 起个人话名字,形如 `群 今日小猪群(617770183) · 2026-10-07 13:24`(群名/人物名取自枢纽自己的档案,取不到就退群号;命名失败只留痕 `onebot_hub_status` 的 `host.lastTitle`,不挡唤醒)。交还会话时 `hostRef.retire` 调 `workspaceRegistry.archiveSession(id, { stopActivity: true })` 把旧会话**归档**(宿主自己那条通路:收进归档、不再占活跃位)——归档成了就把 id 从缓存里清掉,下一次激活现生成一个新 id。 - **激活结束 = 落盘 + 交还会话**(用户定案):回休眠(连续 2 轮没说话 / `awakeMs` 超时)时 `Mind.#retireAfterDormant` 先 `hub.flushStorage()` 把卡片与观察条目冲盘,再 `pool.retire(agentKey)`——① dispose 那个 agent(**必须先 dispose**,否则它握着会话写句柄,下一轮 create 会撞 `already owned by an active write handle`);② **每次激活都是新会话 id**(`onebot-hub:.a<时间戳><随机>`),`hasSession` **恒为 false**(从不 resume 旧转录):`hostRef.retire` 归档旧会话(宿主没暴露归档接口时退而试一下 `delete`/`remove`/`deleteSession`),然后**无论成没成**都清掉缓存里的 id,下一次激活 create 出来的一定是空转录的新会话。同时把该 agentKey 的会话账本(`#feed`,按 `seq` 去重)与回复缓冲清掉:新会话的 `seq` 从 1 重新开始,不清会把新一轮的事件全当"旧序号"丢掉。 - **回合配对是事后解释,不是唤醒判定**(`lib/turns.js`,M6):这一层回答的是最初那个问题——"**什么样的消息能触发下游 bot 的反应**"——而且只用协议层观测,不掺 LLM 判断、不要求下游配合上报。每条入站消息开一个回合,窗口(`turns.windowMs`,默认 8s)内下游发出的 `send_*` 归到它名下;窗口内又来了新的用户消息就**提前封口**(否则后续消息的响应会错配到上一条上);归属不明的下游动作单列 `unsolicited`,**绝不硬塞给最近的那条**(自发的整点播报不是对谁的回应)。它给的是 `Turn{trigger, outcomes, responders, silent, latencyMs}`:`responders` 说明"这条消息谁接得住",`latencyMs` 是"这家的响应有多慢"的原始证据,`silent` 是"这条消息没人理"——这正是 §17.2 用法学习的候选来源。**它与 `Mind` 的静默判定共用同一组事实但职责不同**:`mind.isDownstreamResponded` 要的是"此刻该不该唤醒"(挡在唤醒前),`turns` 要的是"事后怎么解释"(可以慢慢算、可以重放)。未封口的回合也在查询结果里(否则"刚发完消息想看看触发了什么"永远查不到);落盘的只有封口的那些,一行一个回合,落在 `turns/.jsonl`。 - **用法学习只算客观事实,语义判断由 agent 做**(`lib/learn.js`,M7-①,§17.1–§17.5):这一层要回答的是第二个问题——"**下游这个 bot 会吃什么消息**"。分工是硬的:hub 拿得出的是**可以数的东西**(哪些回合 `silent: false`、各家真实前缀的频次、同一个形状出现过几次、带了 @ 还是 reply、下游用了什么 action、响应多慢),而"这是不是一条命令、它叫什么名字、参数怎么给、有没有别名"是**语义判断**,由 agent 用 `onebot_capabilities` 的 `upsert` 写回来(`notes` + `evidence` 一起落,可以复核、可以重放)。所以 `confidence` 的语义就是**观测次数**而不是一个模型给的分数:到 `learn.threshold`(默认 3)才 `candidate → active`。**观测优先**:agent 写过的条目若后来又被真实观测到,`source` 并成 `both`,而且**观测到的参数形态不被静态声明覆盖**(§17.4 的合并规则);反过来「没复现」只会先标 `stale` 降置信,`learn.forgetAfter` 次连续未复现才真删。**学习全程静默**——不发往 QQ、也不发往下游(§17.2⑤),候选只从既有回合里读,绝不为了学习去打扰群。落盘沿用**同一份** `capabilities/.json`(M13-③ 用来存实测结论与只读缓存的那份),新增 `commands` 字段——"花一次超时换来的结论"和"看了几天看出来的用法"是同一类东西,没理由分两个文件。 - **源码在哪由工具给,不常驻 prompt**(`code.scopes`,`m14112` → `m14477`):位置这件事**不需要**插件提供扫描工具(自带的 `onebot_scan_code` 已于 2026-10-08 按用户要求整体移除)——但"位置"本身也不必一直占 prompt:当初的做法是把路径写进全局指导(`buildCodeRootsPrompt()`),`m14477` 之后改成**多条范围 + 按需取**——`code.scopes` 是 JSON 数组(`[{"name":"下游A","path":"D:/x"}]`,设置页有行编辑器;也认一行一个路径的纯文本,`备注 = 路径` 也行),**路径不进 prompt**,指导段只说一句"要知道下游源码在哪就调 `onebot_code_scopes`(可能有多个下游)"。agent 拿到位置后,读文件用的是 DSH **自带的** `read`/`grep`/`glob`,权限是**超管给自己会话**的(`/perm` 能改的是枢纽自建的那个 agent 会话;插件本身拿不到建会话时的权限字段——`agents.create()/resume()` 没有这个选项)。好处有三:多条下游不再互相挤、prompt 不因路径长短而变、模型想读哪部分自己决定。自带的静态面(下一条)因此**默认关**(`scan.enabled: false`,工具都不注册),留着是因为它**只读、不执行、省 token**,有些场景仍更好用。 - **先预测再动手**(`lib/learn.js` 的 `matchCapability`/`predict`,M7-③,§10):agent 学到一条用法之后,最容易犯的错是"觉得它会吃这句话就直接发"——在群里试错的代价是**真的发了一条消息**。所以 `onebot_relay_probe` 默认 `dryRun`:只按现有表回答会不会触发、被哪条形状接住、参数会是什么,一个字节都不发;要让预测变准,它跟学习的输入必须是同一张表(连 `stale` 也只降分不隐藏,因为"这条以前能触发、现在可能不行了"本身就是要看到的信息)。 - **@换称呼缺的那一半自己补上**(`lib/members.js`,§23.6):段语义化把 `at` 里的 QQ 换成称呼,靠的是档案里已有的名字——可**被 @ 的人常常一句话都没说过**(事件里只有他的 QQ,`sender` 是说话人的),于是第一条提到他的消息永远渲成 `@用户10002`。这一层补的就是最后一步:看到"这群里还没名字"的 QQ,就按需问一次实现端 `get_group_member_info`(走 `hub.callAction`,因此照旧过闸门、吃缓存、并由 `observeActionResult` 写进档案)。三条自律全为"别把上游问烦":**只问缺的**(已有名字不问)、**冷却 + 限流**(同一个 (群, 人) 默认 6 小时内只问一次、失败也记账并落 `members/attempts.json`,一条消息最多问 5 个人、队列满了就丢)、**实测不支持就停机**(`1404`/`code 100`/注册表结论 → 整层停手,否则每次都是白等一次超时)。三条刻意不做:不猜名字(拿不到就继续显示 QQ)、不问没被提到的陌生人(那会变成群体画像采集)、**不改写已经渲染好的那条消息**(下一句自然就叫对了)。 - **拟人化不进代码**(§23 原则):代码不做打字延迟、不做分条硬上限、不做计时停顿——说不说、说什么由模型按 prompt 判断;代码只守硬边界(@全体、可见性、下游已应答时静默)。 ## 6.2 能力面:把"实现端能提供的信息"做成稳定接口(M13) OneBot 标准只有 39 个 action,各家实现端各有扩展,**而且规范里没有 retcode 取值表**(原文只说响应里有 `status`/`retcode`)。所以能力面不靠猜,靠实测: - **通用通道 + 类型化封装并存**:`onebot_call` 能问任何 action(agent 未被封装的新能力不会被挡住);`onebot_profile`/`onebot_group`/`onebot_members`/`onebot_avatar`/`onebot_media` 是常用查询的封装,省掉参数猜测。 - **三分法分级(`lib/capability.js` 的 `tierOf`)**:`read` 只读(默认放行)/ `write` 有副作用(`send_*`、`set_*`、`delete_msg`…,默认拒绝)/ `danger` 默认禁用(`set_restart`、`set_group_kick`/`set_group_ban`/`set_group_admin`/`set_group_leave`、`.handle_quick_operation` 等隐藏 API、`send_packet`)。默认值是**只读是安全侧**:把写误判成只读比反过来危险得多,所以写/危险全部显式列举。凭证类只读(`get_cookies`/`get_csrf_token`/`get_credentials`/`get_online_clients`)标记为敏感,需 `capability.exposeSensitive` 才外露。 - **闸门顺序**:`danger → write → 敏感 → 已实测不支持 → 缓存 → 上游`。被拒绝的 action **一个字节都不会发到上游**(`source: 'policy'`,`retcode: 1403`),拒绝理由用中文写清怎么放行。 - **探测而不是猜(`CapabilityRegistry`)**:每次上游连上探一次实现端身份;每个 action 的可用性来自**实测调用记录**——成功记 `supported`,只有 `retcode 100/102/1400` 或实现端原文含"不支持/unsupported/not implement"才记 `unsupported`(**`1404` 目标不存在不算**:那是"这次对象不对",不是"这个接口没有")。一旦记下 `unsupported`,后续调用直接短路返回结论,不再白等一次超时;`refresh: true` 是逃生口(实现端升级后重试真链路)。`unknown` 只表示还没调用过,不代表不支持。 - **只读结果 TTL 缓存(`CapabilityCache`)**:`get_login_info`/`get_version_info` 会话期、`get_group_member_list` 60s、成员/好友/群信息 5min、群荣誉 10min、`can_send_*` 10min;`get_msg`/`get_forward_msg`/`get_image`/`get_record` **不缓存**(内容会变、URL 会过期)。返回里的 `source: 'upstream'`(实时)与 `source: 'cache'`(带 `ageMs` 年龄)是给拟人化用的——"我刚看了下"和"我记得"不是一回事。 - **retcode 翻人话**:`explainRetcode` 给常见取值配中文(`1200` 超时、`1201` 链路未连接、`1403` 枢纽策略拒绝、`1404` 目标不存在…),**未知码不硬猜**,一律把实现端 `msg` 原文带进 `note`。这张表是"常见实现端通行取值、可被实测推翻",权威永远是响应里的 `msg`。 - **面向两个消费者**(§22.2):agent 问走上游真答;`bot-app` 型下游(NoneBot 这类 bot 应用)发来的**只读**请求也走同一条路径(**同一份缓存两侧共享**),上游不可用时降级用缓存答。只读以外的 action 不走这里——下游 bot 的写入是它自己的职责,该由 relay/local 策略说了算。 - **答不了才回退虚拟世界**:能力面说"没这个接口/上游断了/实现端没见过这条消息"时,退回 `VirtualWorld`(它只看得见 hub 转发过的东西,是近似的第二选择);但正因如此,**hub 自己注入的事件**(实现端根本没有那条消息)它反而答得上——而 `get_msg` 正是下游 `_check_reply` 每次都会问的。**策略拒绝绝不回退**(回退就等于敏感闸门形同虚设),世界答不了就照实把能力面的结论回给下游。 - **两种返回形状别混**(`lib/capability.js`):`shapeResult` 是给 agent 看的(带 `source`/`tier`/`note`,说明"这是缓存还是实时");回给下游 bot 的必须是协议信封 `toWireResponse`(只有 `status`/`retcode`/`data`/`echo`,失败再补 `msg`)。`status`/`retcode` **原样透传**,不许把实现端的 `async` 改写成 `failed`。 - **可移植性提示**:`onebot_avatar` 返回 `provenance`(`spec` / `impl-doc` / `community`)——用户头像规范里没有,`q1.qlogo.cn` 是社区惯例;群头像有 go-cqhttp 文档出处。 - **结论要落盘**(`lib/storage.js`,M13-③):实测能力结论与只读缓存写在 `/capabilities/.json`(默认 `~/.dsh/onebot-hub`,`capability` 之外的 `persist`/`storageDir` 控制)。价值不在于省一次网络往返,而在于"这个实现端没有 `_get_group_notice`"这种结论是**花一次超时换来的**,不该因为重启就重新花一次:重启后 `unsupported` 直接短路、缓存过的只读查询直接答,一个请求都不发。写盘三条原则——先写 `.tmp` 再 `rename`(原子)、防抖合并写(1.5s)、**落盘失败只记日志不抛**(插件不能因为磁盘写不了就停止转发);文件坏了改名 `.bad` 保留而不是扔掉。缓存里的时间戳存**绝对时间**,所以重启后仍知道自己多旧。`onebot_caps({ reset: true })` 清空实测结论(实现端换人或升级时用),`flush: true` 立刻落盘。 - **记什么由模型决定,能不能跨会话说由代码决定**(`lib/memory/writer.js` + `lib/profile.js`,M16)。这条线是拟人化记忆唯一容易做错的地方:代码替模型判断"什么值得记"会记满"用户说了你好";模型自己决定"这条能不能说给别人听"会让私聊里的话第二天出现在群里。所以边界划在:模型给结构化指令(`onebot_memory`),代码只做四件事——校验形状、丢掉越界字段、按 **`modify → delete → add`** 的顺序应用(`modify` 先于 `add`:否则"改上一条"会错位到刚加的那条)、给回执。两条**代码侧硬边界**:① 可见性由代码按会话推断,模型声明 `visibility` 会记一条 `rejected` 但**仍按代码推断落盘**(升格不生效);② 纠错必须落 `corrections`,不当普通事实收下。系统字段(`names`/`card`/`role`)只由代码写。 - **档案分层**(`persons/.json`、`groups/.json`、`topics/.json`、`self.json`、`sessions.json`,都在 `` 下):**名字跟着群走、档案跟着人走**——同一个人在不同群有不同名片(`names[{name,scope}]`,`nameIn` 取该 scope 的**最后一条**,所以他改了群名片你就不会再叫他三天前的昵称),而事实、承诺、印象、纠正跟着人。群档案里"看到的"(群名、人数、群主、公告)只由代码从能力调用里观测写入(`hub.observeActionResult`:`get_group_info`/`get_group_notice`/`get_group_member_list`/`get_group_member_info`/`get_stranger_info`),模型只能写 `culture`/`highlights` 这类**提炼**——否则它能凭印象编出一条不存在的群规。人物资料(性别/年龄/等级)带 30 天 TTL,过期就不再当事实讲。 - **短期/长期不是模型声明的**:代码按"这条记忆是在哪个会话学到的"分块——本会话/本群学到的是**短期**,从别处带过来的才是**长期**。装配时两段分别渲染,序号与写入侧**同一份列表**(`offset + i`),模型照着序号改才改得准。 - **曾用名由代码记,不是模型管**(§24.11 的 `past_nicknames`):`names[{name,scope,at}]` 本来就是一份历史,`nameIn` 只取最后一条——于是"小明"把群名片改成"明哥"之后,再有人提"小明",它就不认识这是同一个人了,甚至会把一个人记成两个。`pastNamesIn`/`profiles.pastNames` 把这份历史翻出来(去重、排除当前名、同 scope 优先),人物卡里多一行"也曾叫过:小明",`onebot_person({ name: '小明' })` 还能反查这是谁的号——**当前名优先于曾用名**,否则改回旧名的人会被旧记录抓住。 - **话题关联只用明写的引用**(§24.4):"这几句在聊同一件事"是语义判断,代码不猜关键词——猜错一次就把无关的事塞进同一条线,而**记住了错的事比没记住更糟**。代码只做一件事:这条消息的 `reply` 段指向的正是某条已在话题里的消息(`events` 里的事件 id,或 `refs` 里 `{messageId|message_id|id|timelineId}` 任一种写法)时,把它接进那条线并补上 `worldKeys`/`members`/`lastAt`(`hub.attachTopic` → `profiles.attachTopicByReply`)。接不上就不接,`events` 一个字节都不会长。 - **记忆条目也落盘**(`/memory/store.json`,§24.10):以前只有档案/L1/能力结论/用法/提醒账本落盘,**记忆条目本身在内存**——重启一次,模型刚教它记住的事就全没了。现在 `MemoryStore` 直接接上同一个 `JsonStore`(`lib/storage.js`):`add`/`modify`/`remove` 只把改动排进防抖队列(1.5s 合并写,一次对话不会敲很多次盘),落盘失败只记日志不抛(插件不能因为磁盘写不了就停止转发),重启时逐条 hydrate —— **id 一并读回**,否则模型下一轮就没法 `modify` 昨天那条;时间戳、来源消息 id、以及代码判定的 `scope`/`visibility`/`sensitive` 都原样留住。没有 `id` 或 `text` 的坏行跳过而不是废掉整份文件(真相是可重建的派生数据,不是账本)。`storageDir: ''` 或 `persist: false` 时它自动退回纯内存——测试与"不留痕"部署就走这条路。 - **同一条记忆只留一条**(`lib/memory/store.js`,§24.11):`add` 的语义是"也许该记一条",不是"追加一行"。不去重的话记忆库会随轮数线性膨胀——**代码自己每轮都在重喂观测**(`Mind.flush` → `buildObservedEntries` 把身份条目重新产出一次),真机上同一个人的身份条目因此堆过 3 条,模型也会反复说同一件事。去重键是 `kind + scope + worldKey + 说话人 + 归一化文本`(空白折叠后才比较,**存进去的仍是原文**):`scope` 必须进键(同一句话在另一个群学会的是另一条,可见性边界不同),说话人也必须进键("他说他喜欢辣"和"她说她喜欢辣"不是一条)。命中已有条目时 `add` **返回那一条**(`id`/`ts`/原文都不动,只补上原来缺的 `refs`/`sensitive`)——调用方与模型都不会以为自己写失败了,要改内容仍然走 `modify`;`onebot_memory` 的写入报告里另给 `blocks.*.deduped`(模型同一批把同一句写进短期与长期时如实说"去重了 1 条",而不是谎报 `add: 2`)。读回时会顺带折掉历史文件里的重复并**留一行日志**(不静默删数据);索引按"先到的那条占键"整表重建(`modify` 可能把两条改成同一句话,增量维护会让键指向已经不存在的条目,于是下一次 `add` 又写出一个重复)。这条纪律不新鲜:master 的 `memory_store.py` 里 `add` 就是 `if item not in items`——当年它对、我们漏了。 - **检索层就是隔离的执行点**(`lib/memory/recall.js`,M17):翻记录是最容易绕过隔离的动作——模型不必"记得",它直接搜就行。所以每条命中都要过 `isVisible`,被挡的条数照实回在 `denied` 里(**不静默变空**);`scope: "all"` 的越界检索在 `recallEscape: false` 时降级为当前可见范围并带一句 `note`(不静默改语义),在开启且 `recallEscapeNeedsApproval: true` 时**必须 `confirm: true`** 才给内容。落盘的真相是 `timeline//.jsonl`(L1 原始层),`index.sqlite` 只是索引——删了能重建,`node:sqlite` 不可用时自动降级成内存倒排索引,两种模式检索结果一致。 - **中文检索要人工切 bigram**:FTS5 的 `unicode61` 分词器会把"今天天气不错"整段当成**一个** token(于是搜"今天"查不到它),`trigram` 又要求查询至少 3 个字符("梗图"这类两字词查不到)。所以写入与查询**两侧都切滑动 bigram**(`今天天气` → `今天 天天 天气 气不 不错`),英文数字仍按整词小写——实测 SQLite 3.51.2 已编译 FTS5,这是可选路线里唯一能用的。 - **遗忘是降权不是删除**(`lib/memory/decay.js`,M18 的地基):`strength = 0.4·提及次数 + 0.4·最近度(半衰期 14 天) + 0.2·重要度 − 0.3·被纠正次数`,低强度条目**默认不出现在检索里**但数据一个字节都没少(`includeWeak: true` 能翻出来,`onebot_timeline` 与 L1 原文也还在)。`impression` 与群文化这类提炼结果永不衰减。 - **主动回忆只交事实,不做判断**(`lib/memory/cues.js`,M18):代码**不**判断"现在该提醒他了"——原设计里的规则化触发 + 指数退避定时器已经删掉,那种判断只会变成每二十分钟复读一遍的催命机器人。它只交两样东西:从人物档案里挑出**还没兑现的承诺**(带上"当时说大约什么时候""多久没动了""他上次露面是什么时候"),渲染成**陈述句**(`renderCues`,措辞里不出现"该提醒/必须/别忘了"),提不提、什么时候提由模型自己定;以及一个纯防骚扰的安全阀——**同一条承诺 24 小时内最多进一次 prompt**(`memory.reminders.windowHours`,`/reminders.json` 记冷却账本,重启后仍记得),**唤醒失败不算提过**(否则宿主抖一次就让这条承诺沉默一整天)。隔离在这一步**再生效一次**:某个群/私聊里答应的事,不会在另一个群被"想起来"。 - **转发保真与文本人话是两件事**(`lib/segments.js` + `lib/media.js`,M14)。下游收到的是**原封不动的段数组**(一个字节都不改,§7 铁律),而时间线/进 prompt 的 `text` 是 `describeSegments` 渲染的人话:`at` 换称呼、`reply` 带上被引原文、`image`/`file` 给出语义、`record` 尽量给转写。这条分工的原因是两者要求相反——保真是给下游适配器的,人话是给模型的,谁也别迁就谁。落地几条硬规矩:① `humanText()` **同步、不碰 I/O**,下载媒体是另一条 `#resolveMedia` 异步旁路(`entry.refs.media` 回填 + 重渲染文本),所以取图慢不会拖住转发;② 媒体字节落 `/media/blobs/`(真相留在我们手里,上游 URL 会过期),同时登记宿主 `attachments` 换回 durable `attachmentId`(服务拿不到也照常跑);③ 语音转写依次试 `fetch_ptt_text`(NapCat)与 `voice_msg_to_text`(LLOneBot),**都拿不到就写"[语音 12s](我听不了语音,别猜内容)"**——宁可说听不了,也不猜内容;④ 给不出证据的地方不装作有:段找不到时 `onebot_media` 回"段不在手边",不编造一个假 ref。 - **图片:两条路,都不猜**(`lib/vision.js` + `lib/hub.js`,M14-V)。看不见图就没有共同话题可言,但"看图"有两种完全不同的做法,所以两条都做、由 `vision.mode` 选:① **`segment`:把图作为内容段交给 agent**——`Pool.wake` 的 `content` 变成 `[{type:'text'}, {type:'image', attachment}]`,模型自己看(前提是路由模型 `inputModalities` 含 `image`,`vision.imageCapable()` 查不到就**降级成只用文本**并记一行日志,不静默丢图);② **`describe`:hub 自己叫一次视觉模型**(`ctx.llm.stream`,**一次直接模型调用,不是新开 subagent 会话**——省开销也不往宿主里塞会话垃圾),把 `[图片:一只橘猫趴在窗台上。]` 写进 L1 文本与图片 ref,于是**任何模型都读得到**。三条自律:① **同一张图只花一次钱**——缓存键是字节的 sha256(`/vision/cache.json`),第二次直接命中,重启也还在;② **不猜内容**——描述 prompt 明写"看不清的地方就说看不清,不要猜",模型一句话没说时 `text` 是 `null` 且带 `error`(宁可没描述,也不要编一段),超时就当没看见;③ **段保真不因看图而变**:`segment` 手段只影响**发给 agent 的内容段**,下游收到的仍是一字节不改的段数组;`describe` 手段只改渲染出来的 `text`,并且会顺手 `cards.patch(entry)` 把会话卡里那一行也改成带描述的版本(否则窗口是新的、卡是旧的,模型会读到两个版本)。**看图失败不影响转发**:整条旁路在 `#resolveMedia` 的 `.then` 里,出任何错只记日志。 - **管理动作面是两段式的**(`onebot_admin` + `lib/admin.js`):群管理(踢人、禁言、全员禁言、设/撤管理员、退群、改名片、改群名、撤回、点赞、头衔、处理加好友/加群请求)这类**有副作用**的事,先说人话再做。人话 `op` 只从每个 op 的**字段白名单**里收集参数(其余字段一律忽略,免得把幻觉出来的参数透传出去)并翻成标准 action;**不带 `confirm` 只预演**——返回 `dryRun: true`、`willDo`("在群 55555 禁言 10001 600 秒"这种人话)、`irreversible`,以及闸门结论 `gate { allowed, reason, note, known, cacheable }`,**一个字节都不下发**;`confirm: true` 才真发。它**不能绕过闸门**:权限判断只有 `hub.callAction` 一处(`lib/admin.js` 不做第二道闸门,两处判断必然漂移),所以预演说会被拦的操作,确认了也会被拦。`writeAllow` 与 `dangerAllow` 是**两条独立白名单**:`writeAllow: ['*']` 撑不开 danger 级(`set_group_kick` 仍被拦),danger 级也可以单独列进 `dangerAllow` 而不必同时写进 `writeAllow`。 - **人设是"会话私有分叉",不是全局开关**(`lib/persona/store.js`,①):预设库一份(`/persona/presets.json`),会话与预设的绑定另一份(`bindings.json`)。`/set_role` 改的不是共用预设,而是把当前预设**复制成 `@session/` 的私有副本**再让这个群绑过去——只覆盖显式传的字段(`name`/`role`/`knowledges`),所以"在这个群多一句口癖"不会泄漏给共用同一预设的其他群。落盘内容永远是权威,`config.persona.presets` 只是启动种子;`load()` 会丢弃指向已删预设的失效绑定。渲染降级有序:name+role → `你是 X,Y`;只有 name → `你是 X。`;只有 role → 只输出 role;都没有 → 空段(不硬凑一句"你是助手")。 - **命令前缀是公共前缀,枢纽不"替下游决定"这条命令该由谁处理**(`lib/chat-commands.js`,②):`/help` 这类命令**照样投给下游**,枢纽只是**额外**回一条,被动过手脚的只有"喂给模型"这一步(钩子上带 `silent: 'chat-command'`)——同一个账号不该对同一句话答两遍(枢纽答一次、人格 agent 再答一次)。**早期版本是"超管命令同步吞掉、一个字节都不转发",那是错的**:下游完全可能也有 `/status`(NoneBot 生态里 `COMMAND_START` 默认就是 `/`),枢纽没有资格替下游判断这条命令不该送达;撞车就撞车,两个都触发,各自回答自己的那一部分。`!!` 因此从"逃生通道"改名"**让路前缀**":它只压掉**枢纽那条回话**,消息一直在路上。判定同步(`classify`)、回话异步旁路(`run`)——转发主链路不能为了等一句回话而变异步。 - **配置 UI:一半在宿主(可写字段),一半必须自带(页面)**(`lib/index.js` 的 `markEditable` + `client.js`)。 - **宿主侧:`volatile` 就是"这个字段可写"的开关。** `@deepseek-ai/dsh-settings` 只把标了 `volatile` 的字段投影给客户端(`volatileForm()`),标记方式是**遍历 schema 树、给每个叶子字段就地写 `meta.volatile`**——三件事都不能反着来:① 不给**对象节点或根节点**标(schemastery 的 volatile 同时改变取值语义,根节点标了会把整份配置包成一个 `.get()` 壳,`resolveConfig()` 一个字段都读不到;它的 `validateVolatileSchema` 也禁止 volatile 被 volatile 包住);② 必须**就地改 `node.meta`**(`.volatile()`/`.extra()` 都走 `Schema(this)` 复制节点,标在副本上等于没标);③ 遍历的"是不是节点"判据要认 **`typeof node === 'function'`**——schemastery 的 schema 节点是可调用对象,用 `typeof !== 'object'` 会一个节点都遍历不到。凭据另标 `role('secret')`,表单只收到"设过没设过"。 - **客户端侧:页面必须自己注册,宿主不会替你生成。** `@deepseek-ai/dsh-settings` 的 README 写着「`autoGenerate` … for clients that build pages from the schema; **no shipped client does so yet**」;`ui-plugin-manager` 那边也只有当某个 `plugins.row.config` 注册者点名这一行时,行详情页才存在。**只标 volatile 而不自带页面,表现就是设置里安安静静什么都没有**(本插件真的这么错过一次)。所以 `client.js` 用模块加载器声明(`package.json` 的 `dsh.client` + `exports['./client']`,不经构建、不能有 ESM `export`),注册进 `settings.section` 得到左侧 **OneBot 枢纽** 一页,读值写值全走 `ctx.configForms`(`getSnapshot()` 的 `{status,value,user,secrets,revision,writable}` + `mutate(ops, expectedRevision)`),只拿主题 token 上色。 - **保存后的落地方式也要自己接**:只改 volatile 字段的保存走 Loader 热更新(提交新值 + `loader/volatile-update`),**不重挂插件**,而本插件配置大多是"建时生效"的,所以 `apply()` 里监听这个事件、延后一拍调 `fiber.restart()` 把热更新转成正常重挂(拿不到重挂入口就记一条日志说明"要等下次重启",绝不假装生效)。这个监听**注册在 `enabled` 判断之前**——否则用户在设置页把开关打开时,没有任何东西能把插件叫回来。 - **表情包在协议上就是一张图**(`lib/memes/store.js`,④):`onebot_reply` 的 `memes` 段产出的是标准 `{type:'image', data:{file}}`——OneBot v11 **没有** meme 段,自己发明一个段类型下游只会当未知段扔掉。库里存的是字节(`memes/mN.`),索引 `memes/index.json` 的 `seq` 保证 id 跨进程单调;`collect` 按 sha256 去重;超 `maxCount`(默认 200)**自动淘汰**——淘汰序是"少用 → 久未用 → 建得早 → id 小",只有**用过**的条目受 `recentShown` 保护(否则库收满一次就集体免疫,容量永远兑现不了)。清单文本里逐字写着"只能使用下面列出的 id,不要编造 id",而**执行侧也真的会丢**不存在的 id 并回一句"这些表情包 id 不存在"——prompt 里的自律与代码里的硬边界是两件事,只有前者的话模型编一个 id 就会静默变成一条空回复。 - **一张图只读一次字节,做三件事**(`lib/hub.js` 的 `#enrichImages`,⑤⑥⑦):邮件/消息里的图落地之后走同一条链——① `vision.describeImage` 写回人话文本(`vision.mode` 含 describe);② **三道门之后**才 `anime.recognize` 出角色名并**并进同一句话**(`一只橘猫。角色识别: 猫猫`):门①是看图结构化输出的「二次元:是/否」行(真实照片/截图/界面/文字图都算否,判否直接跳过识别;判定缺失宁可多问不漏判;`m30282` 定案),门②是置信度过滤(低于 `anime.minConfidence` 的候选不渲染),门③是没认出渲染**空串**(不再出 `[角色识别: 未能识别]`)且只显示 top-1;③ 纯表情消息的**表情包判定**(看图模型按结构化输出给的「表情包/图片 + 情绪词」,协议 `sub_type=1` 并进请求当强信号)写进描述与段渲染,给 agent 收录时当依据——**hub 不自动收**,收不收由 agent 调 `onebot_memes{action:'add'}` 判断。GIF 不额外引解码器:`lib/media/gif.js` 自己解析,取关键帧拼成长图再交给视觉模型。`#shrinkImage` 缩小只在 **PNG/GIF** 上做(PNG 解-缩-重编、GIF 转 sprite sheet),JPEG 返回 null——不为它引一个解码器。 - **生图的边界在"缺什么"和"画几张"**(`lib/vision/imagegen.js`,⑦):没配置(`imageGen.enabled/model/baseUrl/apiKey` 任一缺)就返回 `ok:false` 并**逐项说清缺什么**,绝不发一张空图;出图后经上游真发出去(`base64://`)。每轮的硬上限 `imageGen.maxPerTurn`(默认 1)由 `claimImageQuota` 记账,超了回"本轮生图额度用完了(imageGen.maxPerTurn=1);下一轮再画"——**明确失败好过静默变成一张图**,生图又慢又贵,模型不该把它当聊天用。 - **`lib/vision.js` 与 `lib/vision/` 是两个东西**:前者是**看图**(把图变描述/内容段),后者是**额外视觉能力**的目录(`anime.js` 识番、`imagegen.js` 生图)。同名共存是历史原因,功能上分属 M14-V 与 §26 的 ⑥⑦。 ## 7. 保真铁律(实现依据) 这些不是风格问题,是**实测出来的协议约束**(方案文档 §19): 1. **绝不写 `to_me` / `is_tome`**——下游适配器会自己重算(`bot.py:64-145`)。hub 在下发前调用 `stripDerivedFields()` 主动剥掉。 2. **必须实现 `get_msg`**——下游 `_check_reply` 遇到 `reply` 段会主动调它(`bot.py:22-61`),这是**适配器本体**的需求,不是下游插件的。`virtual-world.js` 用 hub 缓存的消息记录回答,缓存命中不到回 1404。 3. **`self_id` 必须是该下游链路自己的账号**——`_check_at_me` 用它比对 at 段,`_check_nickname` 用下游自己的 `bot.config.nickname`。 4. **at / reply 段原样保留**——下游自己会弹掉 at 段、删掉 reply 段并 `lstrip` 文本;hub 提前处理只会双重改写。 5. **空白与段序零改写**——下游命令解析对空白敏感(`message_builder.py:50-52` 记录了"双空格会让参数解析失败")。`parseCq`/`renderCq` 保空白往返。 6. **`raw_message` 有就原样搬,没有再自己渲染**——实测适配器**不会**从段数组反推 `raw_message`。 7. **下游回复只用 `send_msg`**(`bot.py:148-190` 把 `user_id`/`group_id`/`message_type` 摊平进 params),所以 `get_msg`/`send_msg`/`get_login_info`/`get_group_member_list`/`get_status` 是最小必答集,且 **`echo` 必须原样回填**。 **防环**(`trace.js` 四道闸):`loop:origin`(带本 hub `dsh_trace` 回流)→ `loop:hop`(`hop >= maxHop`)→ `echo:duplicate`(含 `message_id` 的重传去重)→ `echo:sent`(hub 出站账本,抓"换 id 的回声")。判据用**出站账本**而不是纯内容比对,避免误杀用户连打两条相同消息。 **工具参数的 schema DSL**(DSH `@deepseek-ai/dsh-tools` 的 `assertSupportedJsonSchema` / `runSchemaCompiler`):工具 `parameters` 不是随便什么 JSON Schema 都能写,**写错是在 `defineTool` 里抛 `JsonSchemaError`,整个插件 `apply` 挂掉**(设置页一直转圈、上下游都不连、插件行挂红标,而本地测试可能一路绿灯)。规则: - 注解键 `description` / `title` / `default` / `examples` 到处可写(`description`/`title` 必须是字符串); - 对象节点**必须显式写 `additionalProperties: true|false`**(漏了就报 `must be explicitly true or false`); - `required` **只在对象属性节点上合法**,且只能是 `true` 标记——父级的 `required` 数组是宿主编译期生成的;数组 `items` 里、`oneOf` 分支里写了直接 `is not supported by the value schema DSL`; - `oneOf` 不能同时声明 `type`,至少两项;标量节点可加 `enum`/`const`;`type` 只认 `string/number/integer/boolean/null/array/object/json`。 `test/stubs/dsh-tools.mjs` **照抄了这套校验**(`load-check` 挂载),`test/tool-schema.mjs` 又测了一遍门卫本身——所以写错 schema 在本地就会红。 **启动轨迹**:apply 每次装配都会往 `/startup.log` 追加阶段标记(`config:ready` / `hub:ready` / `mind:ready` / `links:connecting` / `tools:registered` / `apply:done`),抛异常时把 `stack` 也写进去。插件起不来(设置页转圈/链路不连)先看这个文件,它能直接告诉你死在哪一步;`persist: false` 时不写。 ## 8. 验证(可复现) ### 8.1 纯逻辑冒烟(不需要网络、不需要 DSH) ```powershell cd dsh-onebot-hub node test/smoke-core.mjs node test/patch-media.mjs ``` 覆盖(`patch-media.mjs` 5 项单测 L1 原始层的**媒体回填**:append 那一刻 `mediaRefs` 就是 null(P9 的现场)、`patchMedia` 同时改内存行与落盘那份 jsonl、之后 `rawOf` 拿得到、未知 id 与关闭落盘都不抛):CQ 码转义往返、空白保留、重签不改段序/`raw_message`/`sender`、剥 `to_me`/`is_tome`、`segmentsOf` 兼容字符串、`eventKind`/`sessionKey`/`conversationKey`、信封 `makeResult`/`makeError`、四道防环闸(含"不误杀用户重复发言"与"自送账本判回声")、转发镜像判定与"会话卡/最近窗口里一句话只出现一次"(真机事故 #3)、以及能力面的纯逻辑(分级三分法、敏感只读、retcode 中文映射、TTL 三档、缓存键与命中率、注册表"1404 不下不支持结论"、统一返回形状、**回下游的协议信封 `toWireResponse` 只含 `status`/`retcode`/`data`/`echo` 且 `async` 不被改写**、缓存与注册表的序列化往返(过期条目丢弃、`ageMs` 按绝对时间算、`reset` 返回条数))、管理动作面(op→action 映射与只收集白名单字段、`required ⊆ fields`、`describe` 的中文措辞、缺参数/取值非法/未知 op 的错误文案)、落盘底座(`JsonStore` 原子写往返、坏文件改名 `.bad` 后兜底、`dir: ''` 即关闭、`schedule` 防抖合并同名待写、`safeName` 把 `up:listen:…` 这类键折成安全文件名、**中文键逐字转义**(以前所有中文话题会一起塌成同一个 `_.json`,读谁都是最后写的那个))、记忆写入(`modify` 先于 `add`、越界序号报"当前 N 条"、未知字段不静默、模型声明 `visibility` 不生效)、档案层(`noteName` 同名同 scope 只更新时间、`noteGroupCard` 让 `nameIn` 回群名片、`upsertPerson` 改不了 `names`/`groups`、`closeness` 夹到 ±5、facts 按 `text` 去重、`touchSession` 同 worldKey 只一条、`enabled === false` 时仍能读写)、检索分词(中文滑动 bigram 让"天气"搜得到"今天天气不错"、英文数字整词小写、标点即分隔)与衰减(新的比旧的高、旧的被判"弱"、弱记忆只降检索权重而不是被删)、主动回忆(只挑"还没兑现"的承诺且过一遍隔离、渲染成陈述句而不出现"该提醒/必须/别忘了"这类命令、"已兑现"与别会话的承诺都不算、24 小时安全阀内不再进 prompt 而满 24 小时可以、`windowMs: 0` 是"每次都提"而不是"永远不提"、账本有上限)、段语义化(@ 换称呼与 @全体、引用带原文或诚实说"内容不在手边"、图片/语音/文件/表情/卡片/合并转发的渲染、未知段不炸、`notice` 翻成人话)、字节嗅探(PNG/JPEG/AMR 按 magic 认类型,认不出来就是 `application/octet-stream`)、媒体落地(内联字节写成自己的 blob 并登记宿主 `attachments`、`resolveEvent` 不改写段数组、`detach` 后不再登记、`maxBytes` 超限只记元信息、`keepBytes: false` 不存副本、语音转写两个扩展都试且都失败时诚实说"我听不了")、回合配对(谁能开回合/谁算下游说话/中继镜像不算 hub 发言、新消息提前封口上一条、窗口到期惰性封口、挂不上触发的动作进 `unsolicited`、hub 说话不算下游响应、响应者按链路去重与 `latencyMs` 取首条、统计与保留上限、只落封口的回合)、用法学习(前缀只在**被响应过**的触发里统计、`splitCommand` 拆前缀与命令名、`candidateFromTurn` 认命令/@/回复/关键词四种形状、`capabilityId` 归一化大小写、观测到阈值才升 `active`、静态清单并入后 `source: 'both'` 且**观测到的参数不被覆盖**、连续未复现先 `stale` 再 `forgotten`、agent `upsert` 建 `manual` 条目、`toJSON`/`load` 往返、`renderCapabilities` 用学到的真实前缀而不是硬编码 `/`)、代码静态面(`langOf` 认后缀、字面量提取、`on_command` 的别名与 `COMMAND_START` 前缀、`on_keyword`/`on_regex` 的形状、拼出来的命令名不进表而进 `skipped`、`on_message` 只记"存在"、AST 的 JSON 面坏输出返回 `null` 好让调用方退回正则、目录遍历只认源码后缀且跳过依赖目录与到量即停)、试运行探针(精确命中/别名/句首命令的分数、带前缀的用法不吃没前缀的说法、关键词包含、正则捕获组当参数、坏正则不炸、缺 @ 或缺回复只算"像"、`message` 型最低分、状态折扣(candidate 0.64 < 阈值、stale 0.4 不够说"会触发")、空预测的措辞与"不发送")、记忆条目落盘(改动只排队不逐次落盘、重启后 id/时间/来源消息/可见性都读得回、`modify` 与 `remove` 也落盘、没有 `id`/`text` 的坏行跳过而好行照读、不接存储即纯内存且 `stats.file` 为 `null`)、记忆去重(同 `kind`/`scope`/`worldKey`/说话人/文本只留一条,空白折叠后才比较、原文照存;换群或换说话人是**另外的条**;空文本不参与合并;`{dedupe: false}` 仍能刻意造重复;读回时折掉历史重复并留一行日志而不静默删数据;`modify`/`remove`/`clear` 之后索引不会指向已经不存在的条目,也就不会"删了又加出重复";模型把同一句同时写进短期与长期时报告是 `deduped: 1` 而不是 `add: 2`)、曾用名(排除当前名、同 scope 优先、反查时当前名排在曾用名前、没有档案不乱认)、话题关联(引用话题里的消息才接线、补 `worldKeys`/`members` 且不冲掉已有标题、没有证据就返回 `null`)、会话卡落盘(消息一来就地更新而**转发镜像/重复条目/元事件都不计数**、`maxLines` 截断、写盘走防抖排队、`flush` 后新实例读得回且 `digestFor` 标 `resumed: true`、坏文件跳过、关存储时退化成纯内存)、成员名解析(`atTargets` 剔掉自己/@全体/非数字、认识的与没被提到的都不问、一条消息最多问几个、冷却期内跳过并计数、冷却账本落盘后新实例不重问、实现端"不支持"就停机并丢掉队列、"查无此人"只是失败不算不支持、关掉开关一个请求都不发)、看图(模式归一四档、没有 llm 不算可用、`imageCapable` 按 provider/model 记忆而换模型旧结论作废、描述按字节 sha256 缓存所以同一张图只问一次、带 durable `attachment` 就不重复登记、`segmentParts` 只挑图片并按 attachmentId 去重、描述渲染优先于上游 summary 且保留空白、`storage.flush()` 后新实例仍命中缓存、`mode: 'off'` 既不看不给内容段、无 llm 回"未接入"、只收文本的模型 `imageCapable() === false`、模型一句话没说时回错误而不是编一句)、下游心跳(只给 `implementation` 型链路发、`meta_event`/`self_id`/`interval`/`status` 都对、`intervalMs: 0` 一个字节不发、`stopHeartbeat`/`close` 之后都不再发)。预期 **55/55 通过**(输出 `{"passed":55,"failed":0}`)。 ### 8.2 端到端验收(真下游 + 伪上游) 准备一个真下游:`D:\DSH-workspace\DSH-QQ\test` 是一个 NoneBot 2.5.0 项目(`bot.py` 注册 OneBot V11 适配器、加载 `src/plugins/hub_probe.py` 探针插件),监听 `127.0.0.1:8080`,反向 WS 路由 `/onebot/v11/ws`。 ```powershell # 终端 1:启动下游 cd D:\DSH-workspace\DSH-QQ\test .\.venv\Scripts\python.exe bot.py # 终端 2:跑验收(脚本自己起伪上游实现端 ws://127.0.0.1:8798) cd D:\DSH-workspace\DSH-QQ\dsh-onebot-hub node test/hub-e2e.mjs ``` 脚本断言 19 项,结果同时写入 `e2e-report.json`: | # | 断言 | 依据 | | --- | --- | --- | | A | 下发帧**不含** `to_me`/`is_tome`;段序与空白零改写;`sender` 全字段保真;`self_id` 重签为下游账号;`raw_message` 原样 | §19.2 | | B | 下游 `on_command` 真被触发,且下游重算出 `to_me: true` | §19.2 | | C | 下游回复走 `send_msg`(不是 `send_group_msg`)并被转发回上游 | §19.3 | | D | 下游 `_check_reply` 主动调 `get_msg`,hub 真答;带 `reply` 的消息仍能触发命令 | §19.2 | | E | `dsh_trace` 扩展字段被下游原样保留;下游收到的 `raw_message` 非空 | §19.1 | | F | 回流内容被自送闸丢弃(`guard.stats.droppedSent >= 1`) | §19.4 | | G | `onebot_invoke(event)` 注入的事件真能触发下游并回程;`onebot_invoke(action)` 对 bot-app 型链路**快速拒绝**而不是挂到超时 | 本实现 | | H | **§18.4-1**:需要 `SUPERUSER` 的下游命令,经真事件透传后**真的被执行**(下游用真 `user_id` 判权限) | §18.2 | 实测结果:**19/19 通过**(连跑多次结果一致)。报告见 `e2e-report.json`。 ### 8.3 M15 最小闭环验收(不需要网络、不需要 DSH) ```powershell cd dsh-onebot-hub node test/m15-e2e.mjs ``` 用**真实的** `Hub` + `Mind` + `MemoryStore` + `AgentPool`,只把两端换成假的(假上游 `hub.upstream`、假宿主 `agents`,agent 的 `whenIdle` 里调用 `onebot_reply`),因此整条链路——批量窗口 → 上下文装配 → 唤醒 → 回复候选 → `send_msg` 出站 → 时间线记账——都是真代码。断言 **171 项**,其中能力面一段(`scenarioCapability`)覆盖:只读真答与 `source: upstream`、TTL 缓存命中后不再打上游、`refresh` 强制实时、消息类不缓存、write/danger/凭证三类默认拒绝且**一个字节都不发上游**、白名单精确到 action 且撑不开 danger、`dangerAllow`/`exposeSensitive` 显式放行、不支持结论短路与 `refresh` 重试、上游断线的 `1201`、探测去重(同连接一次 / `connects` 变化才重探)、快照内容;下游方向一段(`scenarioCapabilityDownstream`,起一个本地伪 `bot-app` 下游真拨号)覆盖:只读真答且回给下游的是**协议信封**(`echo` 原样、键恰为四个)、下游问过的 agent 再问直接吃缓存(**两侧共享一份缓存**)、`get_msg`(ttl=0)每次都问上游并在实现端答不了时**回退虚拟世界**、敏感只读拒绝且**不回退**、上游断线时缓存作答而没缓存的回 `1201`、时间线里 `cache`/`denied`/`offline` 都带 `capabilitySource: downstream`、`capability.downstreamReads: false` 时完全不碰上游;管理动作一段(`scenarioAdminGate`,真 Hub + 假上游账本)覆盖:预演说会被拦且**一个字节都没发**、danger 默认拒绝(`1403`/`source: policy`)、`writeAllow` 精确到 action 后真发到上游且时间线留痕(`capabilitySource: 'agent:admin'`)、`writeAllow: ['*']` 撑不开 danger、`dangerAllow` 单独列上就放行;能力落盘一段(`scenarioCapabilityPersistence`,两个真 Hub 共用同一个临时 `storageDir`,等于"重启一次")覆盖:结论与缓存落成 `capabilities/.json`、新进程读回(`storageMeta.loaded/registryEntries/cacheEntries`、`unsupported` 保留、缓存值读得回)、重启后 `unsupported` 走 `source: registry` 短路且缓存命中 `source: cache`(**一个请求都没发**)、`storageDir: ''` 即整个关闭;媒体与段语义化一段(`scenarioMedia`,起一个真 `bot-app` 下游 + 临时 `storageDir` + 假上游转写)覆盖:**转发保真**(下游收到的 `message` 里图片/语音的 `file` 与原来逐字相同)、**文本人话**(`@称呼`、`[图片](已存为 hub-media:…)`、`[语音 3s]“转写文本”`、`[表情:惊讶]`、`↩回复「小明:我发了个图」`)、durable ref(`/media/blobs` 有字节、索引 jsonl 落到 `/media//.jsonl`、宿主 `attachments.saveImage` 收到真字节并回 `attachmentId`)、`stats.blobs/attached/transcribed`、以及 `media.enabled: false` 时**只做文本语义化、不落地**(段照旧转发);回合配对一段(`scenarioTurns`,起一个真 `bot-app` 下游 + 临时 `storageDir` + 250ms 窗口)覆盖:下游的 `send_msg` 归到触发它的那条消息上(`responders: ['down:30001000']`、`latencyMs` 是数字)、新消息把上一条封成 `superseded` 且它的响应仍归它、挂不上触发的下游动作进 `unsolicited` 而不硬塞给最近那条、没人响应的回合 `silent: true` 且 `latencyMs: null`、hub 自己发言记 `hubSpoke` 而中继镜像(带 `refs.fromLink`)不算、`stats`(responsive/silent/avgLatencyMs/responders)与 `onlyResponsive`/`sessionKey` 筛选、封口回合落 `turns/.jsonl` 且一行一条、`clear()` 只清内存不动文件;用法学习一段(`scenarioLearn`,真 `bot-app` 下游 + 150ms 窗口 + `learn.threshold: 3`)覆盖:开局表是空的(不编造)、一次响应只到 `candidate`、同一命令响应三次 → `active` 且 `confidence === 3` 与三条证据 message_id、L3 注入里拿到命令名/真实前缀 `/`/参数形态 `10`、落盘进 `capabilities/.json` 的 `commands`(含 `prefixCounts: [['/', 3]]` 与 `evidence.observations === 3`)、已知用法没人响应只记一次未复现而不减证据、没人响应的闲聊既不建条目也不算 miss、`mergeCode` 并入后 `source: 'both'`、`flushStorage()` 后新建 Hub 能读回(`storageMeta.commands === 1`);会话卡落盘一段(`scenarioCards`,两个真 Hub 共用同一个临时 `storageDir`)覆盖:消息一来就地更新且转发镜像/元事件都不计数、落成 `cards/.json` 且坏文件不废整份目录、新进程读回(`cards.stats.loaded`、`skipped`)而内存环确实为空、`Mind.snapshot` 真的用了它(卡头写"重启前留下的那一份"、末尾说明"从磁盘读回来")、`storageDir: ''` 时退化成纯内存但仍可用;成员名解析一段(`scenarioMembers`,真 Hub + 假上游转成员资料)覆盖:@ 到没名字的人真的去问一次 `get_group_member_info`(**只问缺的那个**)、问回来的群名片写进档案、下一句渲染成人话时已经叫得出名字**且不再打上游**、问过的(哪怕没问出名字)进冷却、实现端回"未知 action"只问一次就停机、`members.enabled: false` 时一个请求都不发;看图一段(`scenarioVision`,真 Hub + 假上游 + 假 `attachments`/假 `llm.stream`,图片走内联 base64 字节、不碰网络)覆盖:图片描述写回 L1 文本(`[图片:一只橘猫趴在窗台上。]`)与图片 ref、会话卡里那一行同步被 `patch` 成带描述的版本(不然窗口新、卡旧)、同一张图第二次不再问模型(`streams` 不涨而 `cached` 涨)、`segment` 手段给得出内容段(**只挑图片**)而纯语音时不给、路由模型不支持图片输入时降级回纯文本、`vision.mode: 'off'` 时既不问模型也只留 `[图片]` 占位: | 组 | 断言 | | --- | --- | | 出站形态 | 会话键解析(群/私聊/非法)、`send_msg` 参数按会话摊平且 `reply` 段在首位、候选后写覆盖先写、空文本被忽略、摘要长度上限 | | 唤醒与静默 | **休眠/激活状态机**:`assist` 休眠时普通群消息只记录(进窗口)**不**唤醒、窗口到期也不唤醒;被 `@` 立刻唤醒并进激活;激活期窗口到期再唤醒一次;**连续 2 轮没开口**或**激活空转超过 `awakeMs`** 回休眠,之后再被叫到能重新进激活;`active` 休眠时**不**因普通消息唤醒,到 `activeTickMs` 节拍且窗口里有新消息才唤醒一次(`tick:active`),没有新消息则不动,且不因节拍反复唤醒;**私聊每条都算被叫到**;无候选**不凭空发言**;**被叫到时"下游已应答"不许挡**(真机事故 #10:休眠窗口的旧 `openedAt` 曾把会话永久静默),非被叫到才看静默闸、且回看只看最近一个 `batchMs`(十分钟前的下游应答不压 `tick:active`、刚发生的压得住)、有 @ 的那一批 `flush` 也不被静默;旧应答不永久静默(按 `openedAt` 判定);**"在叫我"的材料是实时的**(真机事故 #11:配置没写 `upstreamSelfId` 时用学到的账号兜底、昵称是一组且含真昵称、at 段认 `qq`/`user_id`、`@all` 不算);被归档的会话唤醒前先 `unarchive`;`unarchive` 抛错不挡唤醒;**会话级 `onebot_reply` 的候选跨轮存活**(同一个会话第二次唤醒仍发工具内容,而不是退化成 assistant 收尾旁白) | | 上下文 | 含【会话卡】【来源】与身份条目;**不含** `dsh_trace` 等下游内部字段;窗口按说话人标"我";**转发镜像不重复注入**(同一条消息的 `upstream-in` / `downstream-out` 两个视角在 prompt 里只算一条,会话卡里出现一次、窗口里出现一次) | | 记忆隔离 | `strict` 跨会话一律不可见;`scoped` 身份可见、他人群里的私事与私聊不可见;`balanced` 同批人 shareable 可见、private 仍不可见;跨群引用**不带来源**(prompt 里不出现别的群号);审计账本记下放行与拦截原因;写入全量(scope/visibility 只是标记,不丢条目) | | 池 | 超出 `maxActive` 按 LRU 回收;空闲回收;宿主缺位时给出可诊断错误而不是静默失败 | | 下游只读(M13-①) | 只读请求先问真实现端(响应只含 `status`/`retcode`/`data`/`echo`)、agent 与其**共享缓存**、`get_msg` 不缓存且答不了时回退虚拟世界、敏感只读拒绝**不回退**、上游断线走缓存/`1201`、账本留痕、`downstreamReads:false` 即完全本地 | | 管理动作(M13-②) | 预演不下发任何字节并给出 `willDo`/`irreversible`/闸门结论、danger 默认拒绝且未碰上游、`writeAllow` 精确放行后真发、`writeAllow:['*']` 撑不开 danger、`dangerAllow` 单独列上即放行 | | 能力落盘(M13-③) | 实测结论与只读缓存写成 `capabilities/.json`、新 Hub 读回后 `unsupported` 直接短路且缓存命中(一个请求都不发)、`storageDir:''` 即关闭 | | 档案层(M16) | 观测写档案(名字/群名片/角色/会话活跃时间由代码记下)、能力调用的观测结果也进档案(群名/人数/群主/公告/活跃人物**只能**从这条路进来)、人物卡与短期/长期分块进 prompt 且序号与写入侧同一份列表、私聊学到的事实不在群上下文出现、**曾用名进人物卡("也曾叫过:小明")且按当前名优先反查**、**话题关联只认引用了话题内消息的那一条(光是"话题像"不接)**、`persons/.json` 落盘后新进程读得回、`storageDir:''` 时观测仍可用(只是不落盘) | | 聊天记录检索(M17) | 中文两字词搜得到四字句、别群的记录被挡下且 `denied` 报数、私聊里的话群视角翻不出来而私聊视角看得见、按人分组/按段类型(`image`)/按人/`limit`+`total`/`openOnly` 多种问法、`scope:'all'` 默认降级并带 `note`、要求审批时无 `confirm` 一条不给而确认后翻得到别群、L1 落 `timeline//.jsonl` 且删掉 `index.sqlite` 后重开结果一致、淡忘的旧事默认不出现但 `includeWeak` 还取得到原文 | | 主动回忆(M18) | 未兑现承诺带时间与"他上次露面"渲染成陈述句、`cues` 段进 prompt、冷却期内**这一段为空**(人物卡的常驻承诺不受冷却影响)、满 24 小时可以再来、别的群/私聊里答应的事不会跑到这个群(私聊视角看得见自己的)、**唤醒失败不算提过**(`ok:false` 时冷却账本不落、`injected` 为 0)、`/reminders.json` 重启后仍记得、`reminders.enabled: false` 即"每次都提" | | 记忆落盘(§24.10) | 改动只排进防抖队列(`storage.stats.pending >= 1`,不逐次落盘)、flush 后新 `MemoryStore` 读回原条数且 **id 对得上**(否则模型没法 `modify` 昨天那条)、`ts`/`kind`/`actor`/`refs`/`sensitive` 原样留住、`modify` 与 `remove` 也落盘、手写一份含 `{nope:true}` 的 `memory/store.json` 只读回好行、`new MemoryStore({})` 的 `persistent:false`/`file:null` 且纯内存仍可用、**同一个人的身份条目连唤醒两轮仍只有一条**(观测写入每轮都会重喂一遍,去重后记在 `stats.deduped` 上)、模型把同一句同时写进短期与长期时报告里是 `deduped: 1` 而非 `add: 2` | | 媒体与段语义化(M14) | 下游收到的段数组逐字保真(图片/语音的 `file` 原样)、时间线里是同一条消息的**人话**(@换称呼、引用带原文、`[图片](已存为 hub-media:…)`、语音带转写、表情有名字)、字节落 `/media/blobs` 且索引落 `/media//.jsonl`、宿主 `attachments` 拿到真字节(`attachmentId`)、语音转写走实现端扩展、`media.enabled: false` 时不落地但转发照旧 | | 回合配对(M6) | 下游 `send_msg` 归到触发它的那条消息(响应者/延迟)、新消息提前封口上一条且响应不错配、挂不上触发的动作进 `unsolicited`、没人响应的回合 `silent`、hub 自己说话算 `hubSpoke` 而中继镜像不算、统计与筛选、封口回合落 `turns/.jsonl` | | 用法学习(M7-①②③) | 开局空表不编造、一次响应只到 `candidate`、同一命令响应三次 → `active` 且 `confidence === 3`、L3 注入拿到命令名/真实前缀/参数形态/证据量、落盘进 `capabilities/.json` 的 `commands`(含 `prefixCounts`)、已知用法没人响应只记一次未复现而不减证据、没人响应的闲聊既不建条目也不算 miss、静态清单并入后 `source: 'both'`、重启后从磁盘恢复不用重新学;**试运行探针的预测与真实一致**(预测"会触发"的那句真发出去下游真答了、预测"看不出"的那句真沉默,且两次预测本身 `sent: false`) | | 会话卡落盘(§24.10) | 消息一来就地更新(转发镜像/重复条目/元事件都不计数、`maxLines` 截断)、落成 `cards/.json`、新进程读得回而**内存环确实是空的**、`Mind.snapshot` 退回落盘那份并标注"重启前留下的那一份/从磁盘读回来"、坏文件跳过不废整份目录、`storageDir: ''` 时退化成纯内存但仍可用 | | 成员名解析(§23.6) | @ 到"这群里还没名字"的人才去问一次 `get_group_member_info`(认识的、自己、@全体、没被提到的都不问)、问回来写进档案且下一句就叫得出名字、问过就冷却(失败也记账、落盘、重启不重问)、实现端"不支持"只问一次就停机、`members.enabled: false` 时一个请求都不发、不改写已经渲染好的那条消息 | | 看图(§23.6 / M14-V) | `describe`:描述写进 L1 文本与会话卡那一行、按字节 sha256 缓存所以同一张图只问一次、模型不说话时留 `error` 不编内容、`mode: 'off'` 完全不看;`segment`:把图片作为内容段交给 agent、只挑图片、路由模型不支持图片输入就降级回纯文本;看图全程在异步旁路,失败不挡转发、**识番与看图并进同一条人话**(`一只橘猫。角色识别: 猫猫`,不是两条消息) | | 人设 / 表情包 / 命令(§26 ①②④) | 超管命令枢纽**自己回一条**(首段 `reply` 指向原消息),但**不吞消息**:同一条命令**照样投给下游**(测试里挂了真下游链路,断言它确实收到 `/help`——下游有同名命令就两个都触发);唯一被静音的是**模型**(钩子上带 `silent: 'chat-command'`,同一个账号不该对同一句话答两遍);非超管 / `!!` 前缀 / 未知命令**都不回话**(照常当普通消息走,`stats.denied`/`stats.bypassed` 各自记账);`/set_role` **只落本群**(`group:55555` 的渲染变了、`persona.render('group:66666')` 不变、两者的 `boundName` 不同);表情包**只有 agent 的 add 才入库**(群里单发一张不入库、缺 `brief` 拒绝、`savedBy:'agent'`、老条目载入即清但 seq 保留)、`renderForPrompt()` 含真实 id 与"不要编造 id";被 @ 走完一轮后 `stats.lastTurn` 的 `{ok, hadReply, spokeFrom:'tool'}` 都对 | 新增能力的单元测试(都在 `test/` 下,`node test/.mjs` 即可,不需要网络与 DSH): | 脚本 | 断言 | 覆盖 | | --- | --- | --- | | `test/persona.mjs` | 14 | `normalizePreset` 的兜底与字段清洗、`default` 不允许删、隐藏预设不出现在 `list()`、绑定落盘后新实例读得回、**`setRole` 是会话私有分叉**(共用同一预设的另一个群不受影响)、失效绑定在 `load()` 时被丢弃、`render` 的四级降级 | | `test/memes.mjs` | 15 | `normalizeKeywords` 的全部拆分方式与截断去重、`describeMeme` 四种形状、`collect` 落盘字节一致 + id 跨进程单调 + 按 sha256 去重、`renderForPrompt` 空库/全列/"不要编造 id"/最近使用强制入列、`noteUse` 持久化、`rotate` 的淘汰序与**最近用过的不淘汰**、`remove`、`matchByKeywords`、`storage: null` 降级、坏索引不崩且 `seq` 续上 | | `test/chat-commands.mjs` | 31 | 前缀与 `!!` 让路前缀、超管闸门(空 `superUsers` 时**整条链沉默**)、未知命令不拦、各命令的实际效果(`/status` `/presets` `/set_role` `/memes`…)、`run()` 的 `{ok, reply}` 形状与失败回执(`reason: 'error'`)、回复长度上限、`/perm`(无参走 `describe`、带预设名走 `setPreset`、中文别名 `/权限`、**会话键原样交给权限服务**)、`/model` 与 `/vmodel`(无参列清单并 `→` 标当前、`provider/model`、编号、`default` 清回、中文别名、识图与聊天互不影响、编号越界说"共几个"、认不出的写法只报错不写任何东西、宿主没 llm 通道时如实说)、**前缀可配**(每条 `usage` 都不带前缀、`usageLine(spec,'/')`/`(spec,'!!')` 拼得对;把前缀配成 `!` 后 `/help` 与 `/model` 的回话里全是 `!`、`/status` 不再是命令、让路前缀也能配成别的) | | `test/models.mjs` | 11 | 会话级模型选择(`lib/models.js`):参数解析三写法与 `a/b/c` 报错、清单摊平编号连续、渲染分组与 `→` 标当前与每组截断与整段截断、`SessionModels` 按会话隔离与"两类都清掉就不占行"、空会话键/未知类型不许静默、`JsonStore` 落盘往返(等于重启一次)与坏文件改名 `.bad`、注入时钟记 `at` | | `test/media-gif.mjs` | 17 | 自写 PNG 编解码(5 种 filter、`crc32` 标准向量)、自写 GIF 解析(disposal 0-3、透明索引、**交错**、NETSCAPE 循环、真实 LZW 码长增长与清空码)、`keyFrames` 三条判据、`spriteSheet` 宽度与 gap;并用仓库 `test/.venv` 的 **Pillow 12.3.0** 交叉验证(渐变 PNG 逐字节 0 处不符、256 色噪声 GIF 逐像素 0 处不符) | | `test/anime.mjs` | 28 | 后端归一化(`off` 即停机)、AnimeTrace 与自建接口两种返回形状、置信度与 NSFW 阈值过滤、`maxCharacters` 截断、缓存命中(同字节 sha256 只问一次)、超时与坏响应不抛、拿不到字节时**不编角色名**;**模型名取自 `id` 而不是展示名 `name`**(`/v1/model/list` 的项形如 `{id:'animetrace-yuri-4.2', name:'AnimeTrace Yuri 4.2', enabled:true, default:true}`,把展示名发出去会吃 500「未找到选择的模型」;没有 `default` 时退到第一个 `enabled`)、**服务端原话要出现在错误串里**(限流的"请求过于频繁"、模型列表 500 的"未找到选择的模型")、**作品名一起认**(真机形状 `data[{box, box_id, not_confident, character:[{work, character}]}]` → `[角色识别: 名字(作品)]`,作品名**一个字都不截**;没带 `work` 就只写名字;缓存里也带 `work`,**老缓存没有这个键照样读**、不失效不重问)、**`not_confident: true` 的整框不采纳**(公共 API 不给数字分,这个布尔就是它的"我拿不准";全框都拿不准 → **空串**(`m30282` 定案:没认出就不渲染,不再出"未能识别")且 `error` 为空——"没认出"不是"问不到";混合时只留正常那框)、**置信度降序后取 top-1**(`maxCharacters` 默认 1) | | `test/imagegen.mjs` | 20 | 尺寸解析与 `maxSize` 上限、未配置时逐项说缺什么、后端报错原样带回、超时、返回的字节与 `mediaType`、`watermark` 参数、`reference` 图透传(字节 → `body.image` 是 data URI)、**给了参考图却拿不出字节(URL/路径字符串)→ 明确失败且一个请求都不发**(绝不静默退化成文生图) | | `test/policy-modes.mjs` | 18 | **策略模式的真行为**(每个预设说出口的每句话都要能验):纯函数面——`bridge` 的 `*` 真的是 `both`(不是悄悄退化成 relay)、`shadow` 的 `readonly` 打开且配置写 `false` **关不掉**、任何预设都能用 `readonly:true` 打开只读、`describePolicy` 报出 `readonly` 且不再有那两个从来不是配置项的 `captureAll`/`deliverToSelf`;端到端(真 ws 假上游 + 假下游)——`bridge` 下游 `send_msg` **既**真发上游(真 `message_id` 回填)**又**广播给其它下游(应答带 `mirrored:1`/`mirroredTotal:1`,来源链路不重复收);`drop` 上游零字节、回成功空壳且**连虚拟 `message_id` 都不给**(与 `capture` 的对照就在这个句柄上)、timeline 仍记一笔 `decision:drop`;`shadow` 只读——事件照旧广播给下游、观测计数照记但不叫模型(`reason:'readonly'`、`readonly` 计数 +1)、`deliver()` 最后一闸直接放弃(上游零字节)、命中命令**不代替作答**但命令消息照旧转发下游、且**不是**被当成"已答"静默(`silent` 为 `null`);对照组 `relay` 同一条命令真的回话 + 模型被 `chat-command` 静默 | | `test/load-check.mjs` | 69 | 真 `lib/index.js` + 假 ctx 的装配(`node --import ./test/register-stubs.mjs test/load-check.mjs`)。**`test/stubs/dsh-tools.mjs` 挂载的是宿主那套真校验**,所以每个工具的参数 schema 在这里就过一遍 DSL(见 §7「工具参数的 schema DSL」):28 个工具都注册得上且参数形状正确(`onebot_invoke` 必须暴露 `user_id`,说明里要写清"换账号就是换结果")、`onebot_reply` 空内容/编造 id 的报错文案、`onebot_memes` 与 `onebot_imagegen` 的"缺什么说什么"、`claimImageQuota` 的每轮硬上限(`limit=0` 当 1)、`ChatCommands` 没配超管时**一个消息都不吞**、disposer 全释放后唤醒退化而不崩、**会话级模型选择已装配**(`hub.models` 是 `SessionModels`、默认配置下 `hub.vision.provider/model` 是空即系统默认模型、`set`/`visionFor` 按会话隔离、命令表里有 `model`/`vmodel`)、**`/perm` 改到的是本次激活那个会话**(`m33779` 真机 bug:假 `sessions.get()` **只认带 `.a<时间戳>` 后缀的 id**、问无后缀 id 一律 null——修好之后命令仍要成功,且断言它**一次都没问过那个无后缀 id**;同时钉住"没唤醒过的会话 `ensureAgent` 建起来也改得到"、"认不出的预设名只列可选项不瞎设"、回话里必须写出"只对这一次激活有效")、**自建 agent 会加入 Agent 预设**(宿主在 `system-prompt/assemble` 上有不变量:没加入任何预设的 agent 一喊模型就判失败,表现是"唤醒了但不开口";断言 `create` 的 `meta.agentPreset` 与 setup 里那次 `agentPresets.mount` 都到位)、**写句柄被占时的兜底**(`resume` 抛 `already owned by an active write handle` 时改用「原 id + `.r<时间戳>`」另起一个会话照常回话,并记进 `host.lastFork`;**别的错误照旧抛出,不许吞**)、**设置页可编辑面**(配置字段带 `volatile`、凭据带 `secret`、对象节点不带)、**设置页文字对账**(拿真 schema 的**每一个叶子字段**(124 个)去比 `lib/client.js` 的 `FIELD_TEXT`:少一条、多一条 schema 里不存在的键、或者哪条没写中文,都直接红)、**多下游目标解析**(`normalizeTargets` 四种类型与别名、统一地址写法 `host:port/path`(scheme 可省、按类型补)、保留 `enabled:false`、同 `selfId` 的 id 去重成 `#2`、每种目标自己的 `reconnectInterval`、缺地址/缺端口/拨号型缺 selfId/坏 JSON/非数组全部不抛且不静默编造)、**下游接入端不再挂宿主 webServer**(`Config.dict.downstreamPath` 已删、解析后的配置里没有它、不再往宿主注册下游 upgrade 路由)、**`clientModules` 体检**(拿不到服务时不编、拿得到时报出浏览器半侧的路径与行)、**浏览器自诊断通道**(上报路由是精确路径且只此一条、监听 `webserver/index-inject` 往表里 push 一条 head 内联脚本、脚本不含 `` 的那段探针脚本**放进假 DOM(`vm`)里真跑:不含 `` 的 error)、未捕获异常、未处理拒绝、`console.error` 原文(非字符串参数要序列化,`componentStack` 不能丢);按 `CLIENT_PROBE_PATH` + `location.search` 批量 POST(`keepalive`)、探针自己不往 console 里写东西(不自我放大)、重复装载只装一次(console 不被套两层)、`fetch` 同步抛也不影响页面;收报侧畸形/超大负载只丢弃不抛、单条文本被截断 | | `test/multi-downstream.mjs` | 30 | **一个 hub 同时连多条下游 + 四种连接形态**(像 LLBot 的 `ob11.connect[]`),全在本地进程里。第一段(拨号型 × N):假上游 + 假下游 A/B/C/D(C 配了但 `enabled:false`,D 与 A 同 `selfId`、换一个地址)——验 `normalizeTargets` 把四条都留着且 id 去重成 `30001000`/`30001000#2`、`connectDownstreams()` 只启用 3 条(禁用那条**一个连接都没收到**)、上游一条群消息**广播给三条下游**且各自 `self_id` 重签成自己的账号、`downstreamLinks` 把"配了没连"的也列出来、按 id 精确注入(`down:30002000` 只投 B;裸 `30001000#2` 也能寻址)、**单条断线只影响它自己**并按自己的 `reconnectInterval` 重连。第二段(其余三种形态):`ws-listen`(对方拨进来,真在 0 端口上听、状态里给出真实 url)、`http-api`(`POST /onebot/v11/send_msg` 走**和 ws 下游同一条 action 管线**并按策略转发上游;token 不对直接 401;请求/拒绝计数进状态)、`http-post`(事件被 POST 出去,带 `X-Self-ID` 与 JSON 体,计数与失败数进状态)、类型别名 `ws-reverse`/`ws`/`http`/`http_post` 都认。第三段:**不写"对方账号"= 与上游相同**——一条只有地址的目标经 `normalizeTargets(..., { defaultSelfId: '40004000' })` 后真的拨得出去,握手头 `X-Self-ID` 与收到事件的 `self_id` 都是 `40004000`(这才是"透传"该有的样子)。第四段:**账号晚到**——配置既没写 `upstreamSelfId`、下游也没写账号时,这条链一开始**不建**(状态里 `pendingAccount: true`、`selfId: ''`,`connectDownstreams()` 返回 0),上游连上后一条带着 `self_id` 的事件让 hub 学到账号,那条"在等账号"的链被**自动补建**出来,补建后的握手头与事件 `self_id` 都是学到的账号,状态里 `upstreamAccount.source` 变成 `learned`;同一段还钉住"显式配置优先"(配置了 `77777` 时观测到 `88888` 只回 `config-wins`,`upstreamAccount` 仍是 `77777`)与"空账号不学"(`reason: 'empty'`) | | `test/client-render.mjs` | 16 | **把 `lib/client.js` 在假 React + 假 `configForms` 里真跑一遍**(`vm`,不用浏览器):每个字段都渲染出**中文名 + 完整配置键 + 一句说明**(少于 4 个字符的"说明"不算说明)、枚举字段渲染成下拉且选中当前值(9 个枚举键逐一比对选项)、下拉改值再点保存 → `form.mutate` 收到的 op 是 `[{op:'set',path:['agent','mode'],value:'active'}]`、当前值不在预设里时**如实列出"当前值 X 不在预设里"且不静默改写**、能切到"自定义…"文本框再切回来(且不写任何东西)、**多下游行编辑器**(一条一行、**类型下拉四种**、地址用 `host:port/path` 统一样式、加/删/逐条停用、保存写回的是同一个 JSON 字符串且写 `type`+`address`、坏 JSON 退回文本框并说明原因、`以 JSON 编辑` 能切出去再切回来)、过滤框按中文名("看图")与配置键都能过滤、**读 `configForms` 抛错时安静降级**(页面出文字、上报 `kind:'configForms'`、`apply` 不抛)。这是"设置页崩过两次"留下的回归网;另外钉住**两项没有默认值的字段**:从配置值里删掉 `upstreamListen`/`downstreamTargets` 后这两行**仍然渲染**(上游那行补空串控件、下游那行补一条空行,否则用户再也配不回来),而 `user` 配置里它们**不给「恢复默认」按钮**、同一次渲染里别的字段(如 `vision.mode`)照旧有;**源码范围行编辑器**:`code.scopes` 一条一行(备注 + 路径),「添加一个范围」加一行、删除第一行回退到两条、改完保存写回的是同一个 JSON 字符串(`[{name:'',path:'D:/b'},{name:'',path:'D:/c'}]`),数组为空时渲染 0 条并只留「添加一个范围」;**凭据行按宿主给的 `secrets` 自己补出来**:宿主脱敏后 `imageGen.apiKey` 这类叶子在 `value` 里**根本不存在**,行会整行消失(用户就没地方填 key)——断言它仍渲染成密码框、徽章按 `secret.set` 说"已设置/未设置"(不能信被脱敏的 `user`)、填了新值保存写回 `[{op:'set',path:['imageGen','apiKey'],value:'sk-test'}]`、**留空不写回**(留空 = 不改);另有一条钉住**三个落点都认**:`describe()` 里**命名空间条目那层**(真宿主形状 `{namespaces:[{ns,value,secrets}]}`)、表单快照那层(`snap.secrets`)、`view` 顶层(老写法),而宿主一句都没说时**不许凭空造行**(那才是编) | | `test/patch-media.mjs` | 5 | **L1 原始层的媒体回填**(`m02432` P9):`append` 那一刻 `mediaRefs` 就是 `null`(复现现场)、`patchMedia(id, ids)` 同时改内存行与落盘那份 jsonl(`refs.media` 与 `mediaRefs` 一致)、之后 `rawOf(id)` 拿得到回填后的引用、未知 id / 空清单 / 关闭落盘(`dir:''`)都不抛 | | `test/isolation.mjs` | 38 | **探针隔离三档的真行为**(本地进程:假上游 + 假下游,不需要 NoneBot)。纯函数面:`decideEvent(..., {probeOnly:true})` → `{deliver:false, reason:'probe-only-link'}`、它比 `not:broadcast` 更强、没标 `probeOnly` 不误伤;`normalizeTargets` 把 `probeOnly`/`downstreamId` 带出来(缺省 = 该目标自己的键),**`probeSelfId` 展开成第二条 `probeOnly` 链路**(`id` 为 `~probe`、共用 `downstreamId`/账号/口令/**地址**,只有 `selfId` 不同;与主账号相同或留空**不展开**,没写 `id` 时用主链路的键加 `~probe`;只有写了 `probeSelfId` 的那条目标展开),`resolveConfig({})` 的默认档是 **`off`**。`link` 档:两条链路各自 `selfId`、**互不覆盖**、状态里有 `probeLink`/`probeOnly`/`downstreamId`、上游真人消息**一条都不投**探针链路、不给 `linkId` 时注入默认走探针链路且帧里 `dsh_trace.extra.injected`、`self_id` 重签成探针账号、返回体带 `probeLink{caveat}`("可能与用户触发时不同")、**注入帧的 `user_id` 与 `sender.user_id` 就是调用方给的账号**(换一个 `user_id` 就换一个说话人——同一句话不同账号可能不同结果,所以工具层要能指定)、探针链路的 `send_msg` 变 `capture`(不转发上游,refs 带 `probe`/`downstreamId`)而普通链路照旧转发;**一条探针链路都没有时生效档回退 `off`**(`requested` 仍报 `link`、`note` 说明回退),回退后的注入照走普通链路、不假装用了探针链路。`time` 档:注入返回 `probeWindow{isolation,until,windowMs,note}`、窗口内暂缓且**换群不隔离**、`held:probe-window` 留痕带 `upstreamMessageId`/`heldFor`(会话卡不会把它当成两件事)、到点**按原序**补发并记 `probeReleased`/`probeHeldMs`、安全阀超限立刻补发且 `forcedReleases` 计数 + 日志。`off` 档:不开窗口、不暂缓(旧行为)。**路由权在枢纽**(`m02768`):点名真实链路也照样走探针链路、只有 `allowRealLink` 才能打真实链路且结果带 `notIsolated`、点错下游名报错并列出现有下游、`labelOf` 把主链路与探针链路分得清(`down-a` / `down-probe(探针)`)。**下游媒体解析**:下游 `send_msg` 里的 base64 图片会落地成 `hub-media:xxx`、文本补 `(已存为 …)`、捕获账本里的内联数据换成一行占位说明(不再把 base64 塞给 agent) | | `test/client-manifest.mjs` | 13 | 浏览器半侧的打包契约:`dsh.client`(`platform: web` / **不许标 `immediately`** / `inject`)与 `exports['./client']`(→ `lib/client.js`)、**位置与形状照既有客户端插件**(放 `lib/client.js`、factory 自建 `module/exports`、末尾 `return module.exports`)、加载器声明的 `id` **等于包名**、没有 ESM `export`、注册进 `settings.section` 且写入只走 `configForms.mutate`、颜色只用主题 token;**只用宿主保证暴露的 React 面**(`createElement`/`useState`/`useEffect`,`useMemo` 必须先判断存在)、**任何失败都要变成页面上的文字**(`safeCall` + `try/catch` + 渲染出错误文案),不能让它 throw 出去、**`apply` 绝不许抛**(抛一次条目就是 FAILED,`web boot: 1 entry did not activate` 会把整个桌面壳拒在门外)、**读到的服务必须都写在 `inject` 里**(`ctx.configForms` 少 inject 一个就直接抛 `cannot get property "configForms" without inject`)、**错误必须自己送回宿主**(复用 `__ONEBOT_HUB_PROBE__`,没有就装兜底版,路径 `/onebot-hub/client-report`)、**枚举下拉的取值与宿主的单一出处一致**(把 `ENUM_OPTIONS` 当严格 JSON 抠出来 `JSON.parse`,再与 `AGENT_MODES`/`VISION_MODES`/`PROBE_ISOLATIONS`/`ISOLATION_LEVELS`/`CROSS_GROUP_FACTS`/`PRIVATE_FACTS`/`ANIME_BACKENDS`/`PRESETS`/`DELIVERY_MODES` 逐一比对;键集本身也被钉住)、**列表编辑器的列名必须是宿主 `normalizeTargets` 认的字段**(写一个它不认识的键 = 静默丢失,所以拿 `LIST_EDITORS` 的列去比白名单)。**这几处任意一处写错的表现都是"设置里静悄悄什么都不出现"、"入口在、里面没东西",或者干脆整个应用起不来**,所以要钉死 | ### 8.4 真宿主联调(下游 NoneBot 实跑,2026-10-06) 前置:把下游 NoneBot 起在 `127.0.0.1:8080`(工程 `D:\DSH-workspace\DSH-QQ\test`): ```powershell cd D:\DSH-workspace\DSH-QQ\test nb run # 全局 nb 会自动选中工程 .venv;.env.dev 里 DRIVER=~fastapi、PORT=8080 ``` profile 侧(`~/.dsh/profiles//cordis.patch.yml`)给 `onebot-hub` 条目配 `downstreamTargets`: ```yaml - id: onebot-hub disabled: false config: # 多条就多写几个对象;type 四种(ws-dial/ws-listen/http-api/http-post),地址统一 host:port/path # enabled:false = 配着但先不连;id 可省(默认用 selfId) downstreamTargets: '[{"type":"ws-dial","address":"127.0.0.1:8080/onebot/v11/ws","selfId":"30001000","nickname":"hub-bot"},{"type":"ws-listen","address":"127.0.0.1:8654/onebot/v11/ws","nickname":"我的另一半"}]' ``` 实测:`nb run` 起来后 1 秒内日志出现 `OneBot V11 | Bot 30001000 connected`(hub 拨号成功),随后 ```text onebot_invoke(mode=event, linkId=down:30001000, group_id=55555, user_id=10001, text='/probe hi') → delivered: true, messageId: 900000001 onebot_capture → decision: "relay", action: send_msg, text: "pong-probe" ``` 即"注入事件 → 下游 matcher 触发 → 它的 `send_msg` 被 hub 捕获/记账"整段真通。上游侧可用自建伪实现端 `test/fake-upstream.mjs`(`node test/fake-upstream.mjs 14515`,日志 `test/fake-upstream.log`):握手头实测 `x-self-id: 30001000` + `x-client-role: Universal`,事件进时间线,并且**真宿主里真的建出了会话**(`~/.dsh/sessions/…/onebot-hub~003Agroup~00253A55555/session.v4.jsonl.zstd`)。 群里整圈也实测通了(2026-10-06,群 `617770183`,下游装了 `nonebot_plugin_rollpig_plus` 0.14.3):用户发 `/今日小猪` → 枢纽按 `relay` 透明转发给下游 → 下游 matcher 触发并回卡片(`rollpig card rendered: renderer=pillow format=png image_found=True bytes=94017`)→ 枢纽把这条 `send_msg` 中继回上游(`message_id=900426435`)。时间线完整四段:`upstream-in → downstream-out → downstream-in(action) → hub-out(action)`;中继帧只多一个 `dsh_trace = {origin:'dsh-onebot-hub', hop:1, link:'down:30001000'}`,引用段/群号/消息类型都保真。**注意**:下游能收到消息的前提是 `bot.py` 用 `nonebot.load_from_toml("pyproject.toml")` 而不是 `load_plugins("src/plugins")`——后者只扫目录、**完全忽略** `[tool.nonebot].plugins` 列表(症状:插件既不加载也不报错,静默缺席)。 ### 8.5 重启 DSH 后的复验清单(一次跑完) 因为改本地插件代码必须重启 DSH 才生效(见 §9 第一条),重启后按顺序做: ```powershell # 1) 下游 NoneBot(在工程里跑,全局 nb 会自动选 .venv) cd D:\DSH-workspace\DSH-QQ\test; nb run # 2) 看链路:upstream.connected 是否为 true、events 是否在涨 # (用 onebot_hub_status 工具;真实现端没起时会是 ECONNREFUSED) # 3) 真实现端没起时,用伪上游复验"agent 会不会真的说话" node D:\DSH-workspace\DSH-QQ\dsh-onebot-hub\test\fake-upstream.mjs 14515 # 临时把 profile 里 onebot-hub 的 upstreamUrl 指到 ws://127.0.0.1:14515/onebot/v11/ws # (该文件改动会即时 reload 插件;验证完记得改回 14514) ``` 判定标准(伪上游那条事件 `[CQ:at,qq=30001000] 你好 hub,在吗?` 推过来之后): | 观察点 | 期望 | | --- | --- | | `onebot_hub_status` → `agents.pool.created` | 1(或复用已有会话时 `resumed` 1) | | `agents.pool.turns` | ≥ 1(`createUserMessage` 契约修好后才会涨;turns 为 0 且 `errors` 涨 = 老问题复发) | | `agents.pool.lastError` | 为空;有值时直接给出了失败原因 | | `agents.lastSetup.toolRegistered` | `true`(`false` = 宿主没给 `agentCtx.tools`,模型压根没出口,看 `toolError`) | | `agents.lastSetup.feedSubscribed` | `true` = 会话作用域那条 `session/event` 订阅挂上了(root 级那条是另一个入口,二者按 seq 去重) | | `agents.lastTurn.spokeFrom` | `tool` / `assistant-text` / `none`(`none` 才需要解释:看下一行的 `diag`) | | `agents.wakeStates` / `agents.awakened` / `agents.dormant` | 每个会话此刻是睡是醒:`state`(`dormant`/`awake`)、`since`(进入这个状态的时间)、`lastMsgAt`(最后一条新消息)、`silentTurns`(激活期连着几轮没开口);`awakened`/`dormant` 是进入激活/回休眠的累计次数 | | `agents.lastObserve` | 最近一条消息的判定明细:`{sessionKey, text, mentions, selfId, nicknames, atTargets, wake, reason, state, pending, downstreamResponded}`——**"它为什么不醒"看这一行**(`reason: dormant` 就是没被叫到;`silent:downstream-responded` 是下游刚答过;`mentioned:at` 是被叫到)。`mentions` 空但 `atTargets` 有值 = 账号对不上;`atTargets` 也空 = 这条根本没有 at 段(人是手打的文本)。**配置了 `agent.groups`/`agent.privates` 后**:没列出的群/私聊发消息 `reason` 应是 `unlisted`、`wakeStates` 里不出现该会话(`onebot_context` 里 `statsSnapshot.whitelist` 给出两张名单的规模);把名单里某群条目的 `mode` 改成 `observer`,@ 它也不醒且 `reason` 是 `mode:observer` | | `agents.identity` | 判定"我叫什么"的材料:`{selfId, nickname, nicknames[]}`(`selfId` 是配置或学到的账号,`nicknames` 是备注名与真昵称的合集)。被 @ 了不醒时先拿它跟 `lastObserve.atTargets` 对一眼 | | `agents.lastTurn.wakeState` / `silentTurns` | 这一轮结束时该会话的睡/醒状态与沉默轮数(连续 2 轮 → 回休眠,见 §9 的 `agent.mode` 条目) | | `agents.steered` / `agents.lastSteer` / `agents.steering` | 插话记账(`m31311`):`steered` 是累计插进去的消息条数,`lastSteer` 是最近一次 `{at, sessionKey, reason, count}`(`reason` = 触发它的那次唤醒理由,如 `mentioned:at`/`batch:timeout`),`steering` 是**此刻在飞**的插话会话清单(空 = 这次 flush 之后没有插话还在延长回合;非空说明有个回合正被新消息拖着跑,别急着判它卡住) | | `agents.lastTurn.diag` | `{turn, turnEnd, turnError, assistantText, toolCalls, toolResults}`——`turnEnd: 'error'` 带 `turnError` = 模型那一轮真报错了(`whenIdle()` 会把它吞掉);`assistantText` 有内容却没发 = 整句括号旁白被当成"不想说话";**`turnEnd: 'blocked'` = 会话被归档了**(看下一行,模型根本没被请求) | | `agents.lastTurn.unarchive` / `host.lastUnarchive` | `archived: true` = 这个会话曾被归档、枢纽在唤醒前把它摘了出来(见 §9 真机事故 #2) | | `onebot_caps` → `storage` | `enabled: true`、`loaded: true`(重启后确实读回了上次的结论)、`cacheEntries` > 0 表示只读缓存也活着;`file` 给出了落盘路径 | | `onebot_context` 的 `text` | 同一条消息在会话卡与最近窗口里**各出现一次**(各再出现一次 = 转发镜像没过滤,见 §9 真机事故 #3;`digest.messageCount` 也应当是真实条数,不是两倍) | | `onebot_caps` → `impl` | 非空(`app_name`/`app_version`/`self_id`)= 上游连上后的自动探测生效了;`unsupported` 里应是真不支持的扩展接口,`unknown` 只表示还没调用过 | | `onebot_call` 调一条写操作 | 默认应回 `ok:false` + `retcode:1403` + 中文说明(`source: 'policy'`);能调通说明白名单被放开了 | | 下游发来的只读请求(NoneBot 的 `_check_reply` 会问 `get_msg`) | 走能力面:先问真实现端,答不了回退虚拟世界(hub 注入过的消息世界答得上);敏感只读回 `1403` 且**不回退**。时间线 `hub-out` 里能看到 `capabilitySource: 'downstream'` 与 `decision: cache/call/denied/offline` | | `onebot_admin` 不带 `confirm` | 应回 `dryRun: true` + `willDo` 人话 + `gate.allowed`:这是**预演**,上游不该看到任何字节(`onebot_timeline` 里没有对应的 `hub-out`)。想真做要显式 `confirm: true`,且仍受 `writeAllow`/`dangerAllow` 约束 | | `~/.dsh/sessions/…/onebot-hub~003Agroup~00253A55555/session.v4.jsonl.zstd` | 不再只有 session 头,能看到 user 消息 + 助手回合 | | `onebot_person`(对刚发过言的人)/ `onebot_sessions` | 名字与群名片是**代码观测**记下的(`names[].scope` 应是所在群而不是别的群);`onebot_memory` 写一条 `persons` 后再 `onebot_person` 应看到 `facts`/`impression`;换一个群问同一个人,别的群 / 私聊学到的事实应不出现且 `denied` 有数 | | 让某人改一次群名片再问 (`onebot_person({ name: '旧名' })`) | 应反查到同一个 QQ 且 `past: true`、`current` 是现在这个名字;人物卡里能看到"也曾叫过:旧名"。话题关联则要**先引用**那条消息才接得上:随便闲聊一句"话题像"的话,`onebot_topic` 里 `events` 不该变多 | | `/persons/.json`、`groups/.json` | 落盘存在(默认 `~/.dsh/onebot-hub`);`onebot_topic` 能列出一小时前记下的话题(说明中文话题名没有互相覆盖) | | `onebot_recall`(先随便聊几句再问) | 用**两字**的中文词(如"天气")就能搜到含它的长句(两侧都切了 bigram);别群/私聊里的事搜不出来且 `denied` 给了条数;`scope: 'all'` 默认降级并带 `note`,要求审批时加 `confirm: true` 才给内容。落盘看 `/timeline//.jsonl`(删掉 `index.sqlite` 重启,同样的搜索结果应当还在) | | `onebot_caps` → `reminders` | `enabled: true`、`windowHours: 24`;`onebot_memory` 给某人写一条带 `due` 的 `commitments` 后,`onebot_context` 的 `sections` 里应出现 `cues` 段(措辞是"你答应过…",没有"该提醒"这类命令词),再过一条消息看同一段是否**变空**(冷却生效);冷却账本落盘在 `/reminders.json` | | `onebot_turns`(先让下游真的答一句) | `turns` 里能看到刚那条消息的回合:`responders` 含 `down:<下游账号>`、`latencyMs` 是数字、`silent: false`;没人理的那条应 `silent: true`。落盘看 `/turns/.jsonl`(封口的回合一行一条;`reset: true` 只清内存,文件不动) | | 重启后先别说话,直接 `onebot_context` | `onebot_caps` → `cards.loaded` ≥ 1、`/cards/.json` 存在;会话卡里应写着**重启前**那几句,且卡头有"(重启前留下的那一份,MM-DD HH:MM 为止)"、末尾有"这条卡是从磁盘读回来的"——**没有这句标注就是 bug**(模型会把前天的话当成刚说的) | | 有人在群里 `@` 一个从没说过话的人 | 第一条消息里他还是 `@用户123`(这是对的,别伪造),紧接着 `onebot_caps` → `members.asked/learned` 涨 1、`/members/attempts.json` 出现这个 `群:人`;**下一条**提到他时应当已经叫得出群名片,并且 `members.asked` 不再涨。若实现端回"不支持",`members.supported` 应变 `false` 且此后不再问 | | `onebot_capabilities`(先连发同一命令 3 次,每次下游都答得上) | 第三次后该条 `confidence` 到 `3`、`status: 'active'`、`evidence.observations >= 3`;`prefixHints` 里能看到这家真实前缀的频次。接着让 agent 用 `upsert` 写一次用法(名字/参数/别名),再 `flush: true`,然后看 `/capabilities/.json` 的 `commands` 字段里两条来源是否并成 `source: 'both'`。`stats.enabled` 为 false 表示 `storageDir: ''`(学习仍能跑,只是不落盘) | | `onebot_relay_probe`(先 `dryRun`,再用一句表里没有的闲聊对照) | 已知命令那句的 `prediction.wouldTrigger` 为 true、`best.name`/`best.prefix`/`best.args` 跟学到的用法一致、`prediction.sent` 为 false;闲聊那句 `wouldTrigger` false 且 `matches` 为空。**故意 `dryRun: false` 但不给 `confirm`**,应回 `refused: true`、`sent: false`(一个字节都没发)——这一条就是"先预测再动手"的立身之本 | | `code.scopes` 配好路径(默认这条路) | 直接问 agent"下游有哪些命令、怎么触发":它应当**先**调 `onebot_code_scopes` 拿位置(多条都列出来),再**自己**用 `read`/`grep`/`glob` 去读那些路径,然后 `onebot_capabilities` 写回用法(`source` 为 `agent`)、再 `flush: true`。`onebot_hub_status` 的 `code` 段应回 `scopes` 是解析后的列表、`scanTool: false`。**prompt 里不该出现路径**(`onebot_context` 看指导段能核对)。权限不够时它应当**如实说没权限读**,而不是绕路或编内容;路径写错的那些,工具里应如实标 `missing` | | 群里发一张图(或语音/文件) | 下游收到的段数组**逐字保真**(`file` 原样);`onebot_timeline` 里这条是**人话**(`[图片:一只橘猫趴在窗台上。](已存为 hub-media:…)`、`[语音 12s]“转写”`);`onebot_media({ list: true })` 的 `stats.blobs`/`attached` 应涨;`/media/blobs` 里能看到 sha256 命名的字节;宿主没给 `attachments` 时 `attached` 保持 0 但转发与落地照旧 | | 看图(`vision.mode` 默认 `describe`,所以发图就会触发) | `onebot_caps` 的 `vision` 段:`mode: 'describe'`、`llm`/`attachments` 应为 true,发第一张图后 `described` 涨 1、`cache` 里多一条;**再发同一张图**(同一个字节)应是 `cached` 涨而 `streams`/`described` 不再涨;`/vision/cache.json` 里有以 sha256 为键的描述;`onebot_timeline` 与 `onebot_context` 里那句话带 `[图片:…]`。想看 `segment` 手段就把 `vision.mode` 改成 `both`(或 `segment`),此时 agent 收到的是**带图片内容段**的消息(路由模型不支持图片输入时会自动退回纯文本并记一行日志——这一条要在 `onebot_hub_status` 的日志里看) | | 下游心跳(只有 `implementation` 型链路会收到) | `onebot_hub_status` 的日志里能看见下游连上;`downstreamHeartbeatMs` 默认 30s,所以对端等半分钟多一点应拿到 `post_type: meta_event`/`meta_event_type: heartbeat`/`self_id` 是自己/`interval` 是 30000/`status.online` 取上游连接状态。NoneBot 那条 `bot-app` 链路**永远收不到心跳**(这正是不发的证明);想立刻验就把 `downstreamHeartbeatMs` 调成 5000。`0` 关掉后对端一个字节都收不到 | | 记忆条目的落盘(让 agent 用 `onebot_memory` 记一条事实,再重启 DSH) | `/memory/store.json` 里应出现那条;重启后问 agent"你记得我刚说过什么",它还能照着 **modify** 改(id 读不回来就改不了昨天那条)。想知道有没有在写盘,看 `onebot_caps` 的 `memory.stats`:`persistent` 为 true、`loaded` 是本次读回条数 | | 记忆去重(同一个群里连着让它回两轮,再翻记忆) | `onebot_context` 的短期记忆里,**同一个人只该有一条身份条目**(`capability.memory.byKind.identity` 不该随轮数增长);`memory.stats.deduped` 涨说明重复写入被折掉了。手工改一份带重复行的 `memory/store.json` 再重启,`loaded` 应比文件里的行数少、`collapsed` 与日志里"折叠了 N 条"对得上 | | `test/fake-upstream.log` | 出现 hub 发来的 `send_msg`(= `onebot_reply` 出站)或 `get_*` | | 人设 / 命令(配好 `chatCommands.superUsers` 后,用你自己的账号在群里敲 `/status`) | 枢纽**自己回一条**(首段是 `reply`,指向你那条消息),但**消息照样转发给下游**——`onebot_timeline` 里**同时**有 `upstream-in` 与对应的 `downstream-out`;`onebot_caps` 的 `chatCommands.seen/handled` 涨。**这一条是设计取舍,不是漏拦**:`/` 是公共前缀,下游有同名命令就两个都触发,枢纽不替下游决定什么命令该由谁处理;只有"喂给模型"那一步被跳过(所以不会出现同一个人格再答一遍)。换成**非超管**账号敲同一条命令:枢纽不回复(`denied` 涨、`handled` 不涨),消息照常转发;`!!/status` 同样只转发、连枢纽那条回话都不要。`/presets` 看得到预设清单,`/set_role 名字 设定` 之后 `onebot_context` 里【人设】段落应该变了——而**另一个群**的会话说同样的话,它的【人设】不该跟着变 | | 表情包(agent 判断值得,调 `onebot_memes({action:'add'})` 收一张) | 该图进库且**带它写的 brief**:`onebot_memes({action:'list'})` 能看到新 id 与简介,`onebot_context` 的【表情包】清单里出现这一行;`action:'get'` 能看全文(description 没写时用已生成的图片描述);缺 `brief` 时 add 直接拒绝;让 agent 用 `onebot_reply` 的 `memes:['m1']` 发出去时,下游/群里收到的是一张**图**(协议上没有 meme 段);故意给一个库里没有的 id,应回"这些表情包 id 不存在"而**不是**静默发一条空消息。群里只发一张表情(不调 add)时**不入库** | | 识番(`anime.backend` 配成 `animetrace` 后发一张二次元图) | `onebot_caps` 的 `anime` 段 `calls` 涨;`onebot_timeline` 里那条人话**同时**含看图描述与 `角色识别: …`(同一句,不是两条);同一个字节再发一次走缓存(`cached` 涨、`calls` 不涨);`anime.backend: 'off'` 时一个请求都不发 | | 生图(配好 `imageGen.enabled/model/baseUrl/apiKey` 后让 agent 画一张) | 群里真收到图(`onebot_timeline` 的 `hub-out` 里有 `send_msg` 且是 image 段);同一轮再要一张应回"本轮生图额度用完了(imageGen.maxPerTurn=1)";**没配置**时(把 `enabled` 关掉)回的是"生图未启用:请先配置 …"而**不是**一张空图 | | 让 agent 回一句话(**同一个会话连聊两轮**) | 群里收到的应当是**模型调 `onebot_reply` 写下的内容**(`agents.lastTurn.spokeFrom: 'tool'`),不是它收尾时那句 `(已回:…)` 旁白——第二轮尤其要看(旧代码下会话级回复候选只活一轮,第二轮就退化成发旁白,见 §9 真机事故 #4)。旁白本身也不该被当成发言:整句被括号包住且以"已回/已回复/已发…"开头的,一律按"没打算说话"处理 | | 设置页改配置(「设置 → **OneBot 枢纽**」,改一项并保存) | 页面里应当按配置族列出 §4 的各字段(**每个字段都该有中文名 + 一句说明,并并列显示完整配置键**;凭据显示"凭据已设置/未设置"、可留空不改;过滤框敲「看图」也能过滤);保存后日志出现 `配置已更新 → 重新装配插件(<改动的字段路径>)`,且新值**当场生效**(例如改 `downstreamHeartbeatMs`,`onebot_hub_status` 里对应数值变了;改 `upstreamListen` 会重新监听——链路会断一下再回来);`cordis.patch.yml` 里出现你刚写的值。**页面空白 / 没有入口**时按这个顺序分诊:① `onebot_hub_status.host.clientReports`——**刷新一次页面**(探针在 `` 里,装好后要一次页面加载才会干活)再看这里:`kind:'apply'` 出现(文案形如 `客户端装配抛出…cannot get property "configForms" without inject`)= 浏览器半侧的 `apply` 抛过(条目 FAILED,桌面壳会报 `web boot: N entry did not activate` 拒绝启动),照 §9 的 inject 契约修;`kind:'register'` = 座位没声明或 id 撞了,原文会带 `slot "settings.section" is not declared …`;`kind:'page'` = 页面真的渲染了(会带上宿主给的命名空间清单,"入口在、里面没东西"就看它);`counts` 里若出现 `resource`(形如 `SCRIPT plugins/??…/client.js&rev=…`)= 浏览器**没取到**那个 bundle 脚本(combo 里任何一个包都会连坐报 `import failed`),`console.error` 里则是 React/渲染侧的真报错原文;② `host.clientModules`——`available:false` 或 `path:null` = 宿主没扫到浏览器半侧(查 `dsh.client`/`exports['./client']`/加载器声明的 `id` 是否等于包名);③ `install.available:false` = 这个组合没有可注册路由的 `webServer`,这条通道本来就不存在。三个读数都为空、页面依旧空白时,再让用户开一次控制台(`Ctrl+Shift+I`)把红字发来 | ### CI 与发布 > 2026-10-10 从 README 搬来(用户定案:README 只放使用者需要的,开发约定归 design-notes)。 - **CI**:`.github/workflows/ci.yml`。push 到 `main`、提 PR 或手动触发时跑 `npm test`,矩阵是 Node 22/24 × Ubuntu/Windows。装依赖用 `npm install --omit=peer --legacy-peer-deps`:宿主那几个 `@deepseek-ai/dsh-*` 是 peer 依赖(测试用 `test/register-stubs.mjs` 打桩),而 registry 上它们自己的 peer 区间互相冲突,不加 `--legacy-peer-deps` 会直接 ERESOLVE。`devDependencies` 里的 `@deepseek-ai/schemastery` 才是 `test/load-check.mjs` 用的**真** Config schema 校验器,不能省。 - **发到 npm**:`.github/workflows/publish.yml`,走 **npm Trusted Publishing**(OIDC 免 token,provenance 自动附带,仓库里不需要任何 npm secret)。`git push origin vX.Y.Z` 触发——先校验 tag 与 `package.json` 版本一致,再跑一遍测试,然后 `npm publish`;发布成功后自动建同名 GitHub Release(自动生成 notes,附上与 npm 同一内容的 tarball)。前置条件:包已在 npmjs.com 上存在,并且在 npmjs.com → 包 → Settings → Trusted publishing 里登记本仓库为 trusted publisher(repository `Funny1Potato/dsh-onebot-hub`,workflow 文件名填 `publish.yml`;2026-09 之后新建的配置默认只允许 `npm stage publish`,要手动勾上直接 `npm publish` 的许可)。**首发例外**:包还不存在时 OIDC 无处落地,先用本地登录的 npm 把 0.1.0 发出去(或 `npm stage publish`),登记好配置后,之后的版本才由 CI 发。手动触发默认只 dry-run,只跑测试和 `npm pack --dry-run`。 - 本机 `npm publish` 如果报 `DEPTH_ZERO_SELF_SIGNED_CERT`(公司/杀软 TLS 拦截),加 `NODE_OPTIONS=--use-system-ca`;CI 上不受影响。 ### 示例值约定 文档、注释、设置页提示和工具描述里的示例值**一律用编造的占位**:群号 `123456789`、QQ 号 `10001`、下游账号 `30001000`、上游反连端口 `8765`、下游地址 `127.0.0.1:8080`。真机上用的群号、QQ 号、端口、密钥不要写进来(`docs/design-notes.md` 里的真机复盘记录除外——那是事故日志,改动前先问)。 ## 9. 已知限制与未实现 - **每条消息都带日期和时间(`m34049`)**:用户原话"所有消息都加上日期和时间吧"。之前消息行只有 `HH:MM`——群里攒批常常跨好几分钟,更糟的是**跨天**:`08:12 你还在吗` 可能是昨天早上,模型按刚发生接话就接出一条不存在的时间线;从磁盘读回的**旧会话卡**更明显(卡尾明写"这些是重启之前说的",却只给时分,看不出隔了几天)。现在新增 `lib/stamp.js`(`mdhm` / `full`),三条渲染路径统一升级:新消息批行与插话行(`Mind.#batchLines`)、live context 的最近窗口与开局快照(`renderWindow`、`Mind` 里读回落盘卡那几行)、落盘会话卡(`renderDigest` 的行与"重启前留下的那一份,… 为止")。格式分工:**行内 `MM-DD HH:MM`**,批头继续用 `full` 给绝对锚点(`【新消息 N 条】…|现在 YYYY-MM-DD HH:MM`)——行里再写一遍年份只是每条多 5 个字符。没有时间戳的占位从 `--:--` 改成 `--`(一眼就不像时间)。回归:`test/smoke-core.mjs` 新用例锁住三条路径的精确行格式与坏值不抛;`test/m15-e2e.mjs` 钉住批行带时间、批头带完整日期。 - **下游链路分两种 `kind`**,`onebot_hub_status` 会列出来: - `bot-app`(hub 拨号过去,对端是 NoneBot 这类 bot 应用):OneBot v11 里它**只发** action 请求、**不收** action 请求,所以只能靠**注入事件**驱动它(`onebot_invoke` 默认的 event 模式)。 - `implementation`(对端拨进 hub,它以为 hub 是 bot 应用):hub 可以合法地向它发 action 请求(`onebot_invoke` 的 action 模式)。 对 `bot-app` 链路调 action 模式会被**立即拒绝**(否则会静默挂到超时)。 - **M16 档案层已落地**(§24.2/§24.11,见 §6.2):人物/群/话题/self/会话五档档案 + 模型驱动写入(`onebot_memory`)+ 只读入口(`onebot_person`/`onebot_sessions`/`onebot_topic`);**曾用名**由代码从 `names[]` 历史翻出(进人物卡、可反查),**话题关联**只接明写的引用(`reply` 指到话题里已有消息)。**仍未实现的部分**:话题的**语义**归属仍只由模型显式维护——代码不猜"这几句像在聊同一件事",那属于 §23.5 的判定,猜错一次就把无关的事塞进同一条线。 - **记忆条目落盘已落地**(§24.10,见 §6.2):`MemoryStore` 接上同一个 `JsonStore`,落 `/memory/store.json`;`add`/`modify`/`remove` 只排进防抖队列(1.5s 合并写),落盘失败只记日志不抛,重启后逐条 hydrate 且 **id/时间戳/来源/可见性都读得回**(id 读不回模型就没法 `modify` 昨天那条),坏行跳过而不废整份文件。`storageDir: ''` 或 `persist: false` 时自动退回纯内存。**至此**档案、L1 聊天记录、能力结论与缓存、用法表、提醒账本、记忆条目、**L2 会话卡**七样都落盘。 - **M18 主动回忆已落地**(§24.6/§24.7,见 §6.2):代码只把**还没兑现的承诺**连同时间线索当事实交出去(`lib/memory/cues.js` 挑、`renderCues` 渲染成陈述句),提不提由模型判断;原设计里的规则化触发与指数退避定时器**已删除**,只留一个防骚扰安全阀——同一条承诺 24 小时内最多进一次 prompt(`memory.reminders.*`,账本落 `/reminders.json`),唤醒失败不算提过。隔离在挑选时再生效一次,衰减(`lib/memory/decay.js`)只影响检索排序与去留、不删数据。 - **M17 聊天记录检索已落地**(§24.9,见 §6.2):`onebot_recall` 一个工具多种问法(关键词/按人/时间窗/段类型/按人分组/只看没结的话题),中文按滑动 bigram 索引与切词(两字词搜得到长句),检索**逐条过 `isVisible`** 并把被挡条数回在 `denied` 里,`scope: 'all'` 越界检索默认降级(带 `note`)或按配置要求 `confirm`。L1 原始层落 `timeline//.jsonl`,`index.sqlite`(FTS5)只是可重建的索引,`node:sqlite` 不可用时降级为内存倒排索引。淡忘用 `lib/memory/decay.js` 的强度公式**降检索权重而不是删数据**(`includeWeak: true` 仍翻得到)。 - **M15 已落地最小闭环**(§6.1):时间线 → 会话卡 → **十二段**上下文装配 → 批量唤醒 → 会话 agent → `onebot_reply` → 出站,以及隔离级别与审计。档案、会话清单、L1 聊天记录、能力结论、用法表、提醒账本、**记忆条目**与 **L2 会话卡**都已落盘。L3 Capability 注入**已生效**(见 M7-① 条)。**仍未实现的部分**:L1 只追加、启动时**不回放进内存环**(这是刻意的:回放会把整条历史重新变成"现在"),所以重启后"最近窗口"这一段是从空开始的——会话卡有落盘那份兜底,但逐条窗口没有;真要让窗口也跨重启,得引入"从 jsonl 取尾部 N 条并标注时间"的机制,目前没做。 - **§26 若干聊天能力已落地**(人设 / 聊天管理命令 / 表情包库 / 图片增强 / 二次元识别 / 生图,见 §4 配置表与 §6.2): - **①人设**:预设库 + 会话绑定落盘;`/set_role` 走**会话私有分叉**,改一个群不会泄漏给共用同一预设的其他群。**未实现**:预设的继承/组合(现在是一次性复制,父预设后续改动不会自动跟到分叉上——这是有意的,否则"这个群的特殊设定"会被别人的编辑悄悄改掉)。 - **②聊天管理命令**:`/help` `/status` `/presets` `/set_preset` `/set_role` `/preset_role` `/memes` `/reload_meme` `/perm` `/model` `/vmodel` `/reset`(**这里写的是默认前缀 `/`**;回话里的命令一律按 `chatCommands.prefix` 渲染——改成 `!` 就回 `!model …`、`!status …`,见 §9),全部走 `chatCommands.superUsers` 超管闸门。**枢纽不吞消息**:命中时它自己回一条,同一条消息**照常投给下游**(`/` 是公共前缀,下游有同名命令就两个都触发——不替下游决定什么命令"该由谁处理"),被跳过的只有"喂给模型"这一步;`!!` 是"连枢纽那条回话都不要"的让路前缀(消息照常转发)。**未实现**:命令表不可自定义(想加命令得改代码);没配 `superUsers` 时整条链**沉默**(不猜谁是管理员)。`/perm` 是 `m14477` 加的:看或改**枢纽自建的那个 agent 会话**的权限预设——会话 id 是 `onebot-hub:<会话键>.a<时间戳><随机>`,也就是**本次激活**那一个(`m33779` 之前这里写的是无后缀 id,而 `964ddcf` 之后真正在跑的会话早就带后缀了,于是命令永远"找不到会话")。宿主里权限只能设在**已存在**的 `Session` 上,所以命令会先把会话建起来(用与唤醒同一套 setup,`onebot_reply` 工具一个不少),再经 `ctx.sessions.get(id)` + `ctx.permissionPresets.set(session, name)` 设上去。**权限故意不持久**(`m33800` 用户定案:"每次会话给一次权限才安全"):权限挂在 `Session` 对象上,而每次激活都是全新会话,所以下次唤醒自然回到部署默认——`describe` 与成功回话里都要把这条说出来。它**不动超管自己会话的权限**(插件做不到:`agents.create()/resume()` 的选项里根本没有 permission 字段),预设名不是部署里有的就**不改**并把可选项列出来。 - **③社交能量(已移除,2026-10-08 用户要求"把社交能量系统移除吧")**:原形态是每会话一个 `[0,1]` 的值,进 prompt、给 `onebot_invoke` 当代调闸门。**未实现**:没有跨会话的"整体作息"(每个会话独立,所以"它今天心情不好"其实只是这个会话被聊累了)、没有长期情绪曲线(只有回归基线 + 噪声)。 - **④表情包库**:**agent 决定收不收、agent 自己写简介**(aigf 式"值得才收"判断进 prompt;`add` 必须带 `brief`,清单只显示一行简介、`get` 看全文)、`onebot_reply` 按 id 发、容量硬上限自动淘汰、`onebot_memes` 可整理。**未实现**:不做**自动**按关键词配图(发不发、发哪张由模型判断——代码替它决定"这句话配这张图"是拟人化最容易翻车的地方)。 - **⑤图片增强 / ⑥二次元识别 / ⑦生图**:看图链一次读字节、描述写回人话文本、识番并进同一句、GIF 转 sprite sheet 再交给视觉模型、`anime.backend: 'off'` 即停机;生图按 `imageGen` 配置调后端并**每轮硬上限**。**未实现 / 依赖外部**:识番与生图都要外部服务(AnimeTrace / 自建接口 / 绘图后端),没配就明确说"未启用";`#shrinkImage` 只处理 PNG/GIF(JPEG 不为它引解码器,返回 null 走原图);生图只出图,**不能编辑已有图片**(`reference` 只是透传给后端的一个参数,能力取决于后端)。 - **配置 UI 已落地:宿主侧标 `volatile` + 自带浏览器半侧页面**(见 §4 表头与 §6.2「配置 UI」):`Config` 的每个叶子字段标 `volatile`(凭据另标 `role('secret')`),`client.js` 注册进 `settings.section`,在「设置 → **OneBot 枢纽**」里按配置族分组列出全部字段,**每个字段都有人话**:中文名 + 下方一句说明(`lib/client.js` 的 `FIELD_TEXT`,键是**完整配置路径**——按最后一段查表会撞车,`enabled`/`limit`/`timeoutMs` 在好几族里含义完全不同),同时并列显示完整配置键(用户要拿它去 `cordis.patch.yml` 里对照),过滤框同时认配置键与中文名;**取值封闭的字段是下拉列表**(`preset`/`deliveryMode`/`agent.mode`/`vision.mode`/`anime.backend`/`memory.isolation.*` 共 8 个键,选项与标签写在 `lib/client.js` 的 `ENUM_OPTIONS` 里,另有"自定义…"逃生口:当前值不在预设里就如实列出、不静默改写),过滤框 + 未保存计数 + 逐字段"撤销/恢复默认"(**例外**:`upstreamListen`/`downstreamTargets` 没有默认值,只有"撤销",见 §9 的对应条目),保存经 `ctx.configForms.mutate()` 写进 profile 的 `cordis.patch.yml` 并经 `fiber.restart()` 即时重挂。**`FIELD_TEXT` 与 schema 的对账由 `test/load-check.mjs` 钉住**(真 schema 的 135 个叶子 → 少一条 / 多一条 / 哪条没中文都红),因为客户端半侧不能 import 宿主代码,这两份只能靠测试保持一致。**已知粗糙 / 未实现**:JSON 字符串字段(`actionPolicy`/`persona.presets`/`chatCommands.superUsers`/`capability.writeAllow|dangerAllow`/`memory.isolation.overrides|trustedGroups`/`scan.extensions`)是多行文本框,要照 JSON 写——**没有**拆成结构化表单(那要先改 Config 类型,而现有 profile 里已经存着 JSON 字符串,拆型会牵动兼容);**唯一的例外是 `downstreamTargets`**("连多个下游"的入口,见 §19)——它是行编辑器,但底层仍写回同一个 JSON 字符串;没有把 41 个分组折叠/搜索之外的导航优化(没有"常用项"提炼)。改插件的 `lib/*.js` 之后要重启 DSH 才会加载新代码(进程内 ESM 缓存),但 **`client.js` 有热更新**(宿主 `fs.watchFile`)——刷新页面即可;设置页改的是**配置值**,不需要重启。 - **扫描下游源码不再靠插件工具:位置交给 agent 自带工具(2026-10-07,`m14112` → `m14477`;2026-10-08 用户要求"插件的扫描下游的工具也可以移除了",`lib/scan.js` 与 `onebot_scan_code`、`scan.*` 配置已整体删除)**:用户的要求是"不需要配置工具给它,只需要在 prompt 中告知下游 bot 位置并给权限即可,agent 可以用 dsh 自带的工具进行分析,权限由 superuser 授权";后来(`m14477`)追加三条:**允许设置多个范围**、**超管能用指令给会话权限**、**下游地址不必一直在 prompt 里,agent 需要时用工具取**。当前形态:配置族 `code` 只有一项 `code.scopes`(JSON 数组 `[{"name":"下游A","path":"D:/x"}]`,设置页是行编辑器;也认一行一个路径的纯文本,`备注 = 路径` 也行),`parseCodeScopes()` 三种写法都认、按路径去重、**坏 JSON 不丢配置**(正则抠出 `"path"` 值救回来);**路径一个字都不进 prompt**——`buildHubGuidance(hub.config)` 只在配了范围时加一句"要知道位置就调 `onebot_code_scopes`(可能有多个下游)",没配就一个字都不写(不花 token),也不再拼 `agent.guidance`(原 `buildCodeRootsPrompt()` 已删)。新工具 `onebot_code_scopes`:无参数、**始终注册**(不受 `scan.enabled` 影响),逐条 `stat` 后回 `{name, path, exists, kind}`——`kind` 是 `dir`/`file`/`other`,不存在就 `missing` + `error` 里的错误码,没配就回 `count: 0` + "去问超管,别猜路径";**它只回答位置,不读文件**。`/perm [预设名]` 是给"给权限"那句话的入口:宿主里权限只能设在**已存在的 `Session`** 上,所以命令先用 `Mind.ensureAgent()`(与唤醒同一套 setup,`onebot_reply` 工具一个不少)把本次激活那个会话(`onebot-hub:<会话键>.a<时间戳><随机>`)找出来(或用 `Mind.ensureAgent()` 建起来),再 `ctx.sessions.get(id)` → `ctx.permissionPresets.set(session, name)`;**权限只对这一次激活有效**——下次唤醒是全新会话、自动回到部署默认,这是 `m33800` 用户要的("每次会话给一次权限才安全"),所以命令的回话里会明写这一点。`m33779` 修的真机 bug:它以前按**无后缀** id 去 `sessions.get()`,而 `964ddcf` 之后在跑的会话 id 每次激活都带 `.a…` 后缀 —— 命令只会回"找不到它,改不了权限";`test/load-check.mjs` 现在用"只认带后缀 id"的假 `sessions` 钉住这个回归(并断言它一次都没问过无后缀 id);没给名字就只报现状(`catalog().options` 列可选项、`current(session)` 报当前值),名字不在部署里就**不改**并列出可用项,服务缺了就直说。**"给权限"的边界要说清**:实测宿主 `agents.create()/resume()` 的选项里**没有** permission/sandbox 字段(只有 `meta.agentPreset`/`meta.cwd` 与 `agentOptions`),所以插件**只能**改自己建的那个会话,**动不了超管本人会话的权限**——与用户"权限由 superuser 授权"一致。自带的静态扫描器因此**默认关**:`scan.enabled: false` 时 `onebot_scan_code` **根本不注册**(工具数 27 → 26)。设置页:`FIELD_TEXT` 的 `code.roots` → `code.scopes`(改由行编辑器渲染,`LIST_EDITORS` 加 `code.scopes`,列 = 备注 + 路径;`renderListEditor` 按 `kind` 分派清洗/新增/空文案/标题,`downstreamTargets` 也补了 `kind: 'targets'`),`FAMILY_LABELS.code` 改成「下游源码范围(多条;agent 需要时取)」,`onebot_hub_status` 的 `code` 段键名改为 `scopes`;schema 叶子与 `FIELD_TEXT` 键**都还是 131**(重命名,不增不减)。回归:`test/load-check.mjs` 56 项(范围解析三种写法/去重/坏 JSON 救援、指导段含工具名但**不含路径与备注**、`onebot_code_scopes` 对 dir/不存在路径分别报 `dir`/`missing`、默认配置实例工具 26 个且无 `onebot_scan_code`)、`test/chat-commands.mjs` 29 项(`/perm` 的会话键透传与中文别名、服务缺失降级)、`test/client-render.mjs` 14 项(范围行编辑器的加/删/写回 JSON、空数组渲染成"0 条 + 添加")、`test/client-manifest.mjs` 13 项(`code.scopes` 列只认 `name`/`path`)。 - **策略模式补齐:`both` / `drop` / `readonly` 真的按说的做(2026-10-07)**:自查(用户问"这五个 preset 到底怎么处理消息"逼出来的)发现四处**文案跑在实现前面**:① `both` 在 `#handleDownstreamAction` 与 `#routeOutbound` 里都和 `relay` 落进同一个分支——`bridge` 预设**实际等于 `relay`**,从不广播;② `drop` 根本没有 `case`,落进 `default` 后**行为等同 `capture`**(还照样给虚拟 `message_id`);③ `shadow` 的 `event.readonly` **全 lib 无人读**,配置里的 `readonly` 又根本不在 schema 里(`config.readonly` 恒为 `undefined`);④ `describePolicy` 报出的 `captureAll`/`deliverToSelf` **不是配置项**(永远 `undefined`,后者是真实字段 `deliverMessageSent` 的重复名字)。用户「补齐吧」后的改法:`Hub` 新增 `get readonly()`(读 `policy.event.readonly === true`),`config.readonly` 变成**真配置项**(schema `z.boolean().default(false)` + `resolveConfig` 透传),语义是"**只能打开、关不掉预设自带的那份**"(`shadow` 的定义就是只读,让配置把它关掉会造出"影子观察却能说话"的矛盾状态;想不只看就换预设);`Mind` 加三道闸(`observe()` 里记一条 `reason:'readonly'` 就返回、`flush()` 里批次照轮转但不往下走、`deliver()` 最后一道闸一个字节都不发)与 `stats.readonly` 计数;`#routeOutbound` 的 `both` 与 `mirror` 通过 `extra` 把镜像条数交还下游(`virtual-world` 只透 `extra` 下的字段,出站路由的内部标记不外漏),`drop` 回 `__silent` 哨兵让 world 回**连虚拟 `message_id` 都没有**的成功空壳(与 `capture` 的分界就在这个句柄上);命中命令时只读只记时间线不代答,且钩子的 `silent` 保持 `null`(不冒充"已回话")。回归:新增 `test/policy-modes.mjs`(18 项,纯函数 + 真 ws 上下游端到端),`test/load-check.mjs` 55 项(`readonly` 是真字段、`shadow` 关不掉、`describePolicy` 不再报那两个幽灵名),README §5 的预设表从"用途"改成**"具体是什么行为"**。 - **监听地址与路径合并(2026-10-07)**:删掉 `upstreamListenPath`,`upstreamListen` 一项写完整——`127.0.0.1:14514/onebot/v11/ws`(也认 `ws://127.0.0.1:14514/…` 这种完整 URL;**不写路径 = 只接受根路径 `/`**)。理由:路径本来就是"这个监听地址的一部分",拆成两项只会让人改了地址忘了路径,然后对着 `close(1008)` 发呆。**刻意不补默认路径**:偷偷补一个 `/onebot/v11/ws`,就会出现"我明明写的是 `127.0.0.1:14514`,它却只接受 `/onebot/v11/ws`"这种查不出来的错配——比不监听更难排查;写什么就是什么,错配时日志里会直接打出"期望路径"。兼容:老 profile 里残留的 `upstreamListenPath` 会被 schemastery 原样留着(不报错、不进设置页、不再参与解析),`test/load-check.mjs` 把"解析认各种写法"与"旧键不再生效"都钉住了。 - **两项刻意的"没有默认值" + 这两行不给「恢复默认」(2026-10-07)**:`upstreamListen` 与 `downstreamTargets` 的 schema 里**不写 `.default(...)`**——没配过的 profile 里这两项**根本不存在**(不是 `''`、不是 `'[]'`),因为"默认监听某个端口"或"默认连某条下游"都是替用户猜他想要什么,而这两项天生就是要显式填的。运行时不猜:`resolveConfig` 兜底成"不监听 / 没有下游目标",老 profile 里残留的 `''` / `'[]'`(旧默认值)也照样读成空。代价是设置页会失去这两行(schemastery 解析后键直接消失,`lib/client.js` 就渲染不出来),所以页面侧用 `ALWAYS_FIELDS` 给它们补空行(`upstreamListen` 空串控件、`downstreamTargets` 一条 `ws-dial` 空行),并且用 `NO_RESET_KEYS` **去掉这两行的「恢复默认」按钮**(本来就没有默认可恢复,只留「撤销」);其余字段照旧。回归:`test/load-check.mjs` 拿 `Config.dict..meta.default === undefined` 断言并以 `upstreamUrl`/`preset` 当对照;`test/client-render.mjs` 断言"把这两个键从值里删掉后两行仍然渲染"以及"`user` 里它们没有恢复默认按钮、同一次渲染里别的字段有"。 - **下游目标不写"对方账号" = 与上游相同(2026-10-07)**:`downstreamTargets` 里每条目标的 `selfId` 现在可以留空——留空就取 `upstreamSelfId`(用户要求)。这条链路上"对方(app)是谁"因此默认与上游同一个账号:`X-Self-ID` 用手上游账号握手(`ws-dial`/`http-post`)、事件重签 `self_id` 也变成**原样透传**(`transparent` 投递模式的常见诉求)。`normalizeTargets(raw, { defaultSelfId })` 是唯一入口,`resolveConfig` 把 `upstreamSelfId` 传下去;**上游账号也没配**时没有身份可报 → 这条目标**不丢**、先标 `pendingAccount` 不建链,等 hub 学到账号再补建(见下一条;不猜一个账号出来),监听型(`ws-listen`/`http-api`)本来就不需要,照常保留。`id` 只认**显式**写的 `selfId`:没写就用 `host:port`——否则几条都走默认账号的目标会互相挤成 `#2`/`#3`,寻址时根本分不清谁是谁(顺带修了旧代码 `entry.id ?? selfId ?? …` 的空串漏洞:以前监听型不写 selfId 会得到 `id: ''`)。回归见 `test/load-check.mjs`(解析规则)与 `test/multi-downstream.mjs` 的 T9(真拨号 + 真透传)。 - **上游账号自动学 + 下游"等账号"的链自动补建(2026-10-07)**:原来的顺序问题是死的——下游默认账号在 `resolveConfig`(**解析配置那一刻**)就算好,而那时上游通常还没连上,`ws-dial` 的 `X-Self-ID` 又是**握手时**就要发出去的;于是"上游账号没配"会让"不写对方账号"的下游目标一起失效。现在 `Hub` 自己学账号(`learnUpstreamAccount(selfId, source)`):三条路——监听模式下对方握手头里的 `X-Self-ID`(`UpstreamListener` 新增 `onIdentity` 回调,`lib/link.js` 的 `#attach` 里上报)、**上游每条事件的 `self_id`**(最省事的一条,不发额外请求)、`index.js` 的身份探测 `get_login_info`(探测门槛顺势放宽成"没写账号、或没写昵称")。账号从"未知"变"已知"那一刻,`#adoptUpstreamAccount()` 把那些 `needsUpstreamAccount()` 的目标——`ws-dial`/`http-post` 型且 `selfId` 为空——真建起来(**只补建还没建过的**,所以不会打断已有连接;id 用地址,`down:…` 寻址不变)。**配置优先**:显式写了 `upstreamSelfId` 就绝不被观测推翻,不一致只回一条 `reason: 'config-wins'`;账号**变了**(重连换号)只记日志,不重建已建的链路。状态里新增 `upstreamAccount: { selfId, source: 'configured'|'learned'|'unknown' }`,目标那条在等账号时带 `pendingAccount: true` 与一句 `note`——"在等账号"和"连不上"必须分得清(后者才是故障)。回归:`test/multi-downstream.mjs` T10(不建 → 学 → 补建 → 握手头/事件 `self_id` 都是学到的账号 → 状态变 `learned` → 配置优先),`test/load-check.mjs` 的 `normalizeTargets` 用例改成"账号未知时**不丢条目**、标 `pendingAccount`、不编造账号"。 - **下游连接面:四种形态 + 统一地址(2026-10-07,§19)**:一个 hub 可以同时挂 N 条下游,每条一个 `type`——`ws-dial`(默认,我们拨过去)|`ws-listen`(我们监听、对方拨进来)|`http-api`(我们提供 HTTP API)|`http-post`(我们把事件 POST 给对方),别名 `ws-reverse`/`ws`/`http` 也认(LLBot 的词汇)。**地址一律 `host:port/path`**(scheme 可省,按类型补 `ws://`/`http://`;`wss://`/`https://` 走 TLS;不写路径 = 根路径 `/`),**写什么就是什么**。`ws-listen`/`http-api` 由 hub **自己在那个端口上监听**(`lib/link.js` 新增 `DownstreamHttpListener`:一个目标一个 http 服务器,upgrade 交给该目标专属的 `DownstreamEndpoint`,HTTP 请求按 `POST /` 灌进**和 ws 下游同一条 action 管线**);因此删掉了 `downstreamPath` 与"挂在 DSH webServer 上的下游端点"——配置里写的端口必须真在听。`http-post` 用 `fetch` + 自己的 AbortController(超时定时器 unref 并清掉,不留活 handle)。状态里每条目标都报 `type`/`url`/`listening`/`upgrades`/`requests`/`rejected`/`posts`/`failures`/`lastError`。测试见 `test/multi-downstream.mjs`(23 项:广播、按 id 寻址、断线重连、三种新形态与 token 拒绝)。 - **多下游(§19,2026-10-07)**:一个 hub 可以**同时连多条**下游,像 LLBot 的 `ob11.connect[]`。配置仍是 `downstreamTargets` 这个 JSON 字符串(**schema 没动**,profile 里已存的值兼容),每条目标:`id`(唯一键,缺省 = 显式写的 `selfId`;没写 selfId 就用 `host:port`,同 key 自动 `#2`)、`type`(四种形态,见上一条)、`address`(统一 `host:port/path`,旧的 `url` 仍然认)、`selfId`(**不写 = 与上游账号相同**,见 §4 该行;上游账号也还不知道时先留着、等学到账号自动补建,见上上条)、`nickname`、`accessToken`、`enabled`(配好但先不连)、`reconnectInterval`(这条自己的重连间隔)。运行时改动:`#dialers` 从"按 selfId"改成**按 id**(同 selfId 两条不再互相覆盖/泄漏),`#attachDownstream` 把算好的 `linkId` 写进 `session.linkId` 并贯穿上下游帧处理与 `#resolveDownstream`(`down:` / 裸 `` / 裸 `` 三种写法都认),`downstreamLinks` 现在**把配了没拨、没连上的也列出来**(带 `connected/enabled/url/connects/reconnects/lastError`)——多下游最容易踩的坑就是"状态里空空如也,分不清是没配还是连不上"。上游事件照旧**广播给所有已连上的链路**(`#dispatchUpstream` 的循环,逐条 `retagEvent` 重签 self_id)。设置页里是**行编辑器**(加/删/逐条停用/逐字段填 + 「以 JSON 编辑」逃生口)。测试:`test/multi-downstream.mjs`(15 项,自足、不需要 NoneBot)+ `test/load-check.mjs` 的 `normalizeTargets` 用例 + `test/client-render.mjs` 的行编辑器用例。 - **M13 能力面:agent 侧 + 下游只读侧 + 管理动作侧已落地**(§6.2):`onebot_call` 通用通道 + `onebot_caps`/`onebot_profile`/`onebot_group`/`onebot_members`/`onebot_avatar`/`onebot_media` 类型化封装、三分法闸门、实测能力注册表、只读 TTL 缓存、retcode 中文映射、连接后自动探测实现端身份;`bot-app` 型下游的只读请求也走同一条能力面(与 agent **共享一份缓存**,答不了才回退虚拟世界,策略拒绝不回退,开关 `capability.downstreamReads`);群管理有类型化入口 `onebot_admin`(13 个 op,**两段式**:先预演给出人话 `willDo` 与闸门结论、不下发字节,`confirm: true` 才真发,且不能绕过闸门)。L3 Capability 注入已随 M7-① 落地(能力段按学到的用法渲染,见 §6.2)。 - **M14 媒体与段语义化已落地**(§23.6/§22.6,见 §6.2):`lib/segments.js` 把段结构渲染成人话(@换称呼、引用带被引原文、图片/语音/文件/表情/卡片/合并转发各有语义、未知段给占位),`lib/media.js` 把会过期的上游 URL 换成 durable ref(字节落 `/media/blobs/`,同时登记宿主 `attachments` 拿 `attachmentId`;语音试 `fetch_ptt_text`/`voice_msg_to_text` 转写,取不到就诚实标注"听不了")。**转发保真与文本人话是两条并行的线**:下游收到的一直是原封不动的段数组。看图(`lib/vision.js`)两种手段都已落地:`segment` 把图作为内容段交给 agent(模型不支持图片输入就降级),`describe` 由 hub 直接叫一次视觉模型写描述、按字节 sha256 缓存、写进 L1 文本与会话卡,`vision.mode: 'off'` 可整体关掉。 - **M6 回合配对已落地**(§16.4/§16.5,见 §6.1/§6.2):`lib/turns.js` 把"哪条消息触发了下游什么"配成 `Turn{trigger, outcomes, responders, silent, latencyMs}`,`onebot_turns` 是只读入口(`onlyResponsive` 直接给 §17.2 的候选来源)。窗口内新消息**提前封口**、挂不上触发的动作进 `unsolicited`、只落封口的回合到 `turns/.jsonl`。**仍未实现的部分**:M10 的 Turn 级判定仍由 `lib/mind.js` 的 `isDownstreamResponded`(按 `sessionKey` + `openedAt`)负责——两处共用事实但职责不同(一个挡在唤醒前,一个事后解释);§16.6 里 `onebot_turns` 的 `linkId?` 参数已落地为**单链路诚实版**(给的值对得上才作数,对不上回 `ok: false` 并说明本实例接的是哪条——不是静默忽略;跨链路筛选得等真有第二条上游,那时起多实例或多链路路由才有意义)。 - **M7-①②③ 用法学习已落地**(§17.1–§17.5,见 §6.1/§6.2):`lib/learn.js` 维护一张 Downstream Capability Map——hub 从回合配对里机械地攒候选、推断各家真实前缀、数观测次数,语义判断(这是不是命令、怎么用)由 agent 用 `onebot_capabilities` 写回来;到 `learn.threshold` 才 `active`,没复现先 `stale` 再 `forgotten`(**降置信而不是直接删**)。第二类证据是**代码声明**:`onebot_scan_code` 只读扫下游源码(**`scan.enabled: true` 才挂**;默认这条路是"把源码位置写进 prompt,让 agent 用自带文件工具读",见 §9 首条)(Python 走 `ast.parse`,拿不到就退回正则面),把 `on_command`/别名/`COMMAND_START` 并进同一张表,冲突时观测优先(`source: 'both'`)。落盘沿用 M13-③ 那份 `capabilities/.json`(新增 `commands` 字段),并且**已接进上下文装配**(L3 注入不再是空的)。第三件是**先预测再动手**:`onebot_relay_probe` 默认只按这张表回答"这句话会不会触发下游",不发任何东西。**仍未实现的部分**:`linkId?` 已落地(与 `onebot_turns` 同一套:对不上就明确报不匹配;`onebot_relay_probe` 的 `link` 只决定真发投给谁、`linkId` 才用来限定上游),跨链路查表仍要等多上游实例。 - **下游心跳已落地**(§19):`DownstreamSession.startHeartbeat()` 每隔 `downstreamHeartbeatMs`(默认 30s)推一条 `meta_event: heartbeat`(`self_id`/`interval`/`time`/`status.online`,`status.online` 取上游是否连着)。**只发给 `implementation` 型**链路——对端把 hub 当 bot 应用、按协议它在等心跳;`bot-app` 型(NoneBot 这类)实测不认也没必要,一个字节都不发。`downstreamHeartbeatMs: 0` 即整体关掉;`close()` 里先停表再关 socket(否则心跳会往死链路上打)。心跳检测(对上游)仍是原来的 `heartbeatTimeout`,两者互不干扰。 - `synthesize` 投递模式保留了配置项,但按方案定位是非默认的危险路径。 - `virtualizeMessageId` 默认关闭:同一 `message_id` 广播给多个下游时可能相互冲突,需要时再打开。 - **改本地插件代码必须重启 DSH(实测)**:DSH 进程内对插件模块走 Node ESM 模块缓存——改 `lib/*.js` 后,即使把 profile 条目 `disabled` 切 `true→false`(或直接改 `cordis.patch.yml` 触发重新 apply),跑的还是缓存里的旧模块;用工具 touch 文件也不会触发重载。只有重启进程(或走会清缓存的重载入口)才加载新代码。排查时可看 `onebot_hub_status` 里 `agents.pool.stats` 的 `lastError` 判断跑的是不是新代码。 - **`createUserMessage` 契约(踩过坑)**:`@deepseek-ai/dsh-llm` 的 `createUserMessage({content, source})` 要求 `content` 是**内容段数组**(`[{type:'text', text}]`)且 `source.kind` 必填;传裸字符串会直接抛(症状:会话文件只有 174 字节的 session 头、`pool.created` 有值但 `turns` 为 0、`pool.errors` 递增)。`lib/agent/pool.js` 已按官方用法(`dsh-onebot`)修正;`test/stubs/dsh-llm.mjs` 与 `test/m15-e2e.mjs` 的假宿主都会校验这个形状。 - **`ctx.agents.create/resume` 返回的是句柄,不是会话(踩过坑)**:宿主返回 `{ agent, dispose, … }`——`followup` / `whenIdle` 在 `handle.agent` 上,`dispose` 在句柄上(官方用法是 `const { agent } = entry.handle`)。我们一开始把返回值当会话用,症状是 `agent.followup is not a function`。`lib/agent/pool.js` 的 `ensure()` 已做 `handle.agent ?? handle` 兼容、`dispose()` 优先调句柄;两个测试的假宿主现在都**照真形状返回句柄**,这类错不会再漏过测试。 - **上游方向不是想当然的(实测纠正)**:`ws://127.0.0.1:14514/onebot/v11/ws` 并不是"实现端的正向 WS 服务端",而是 **LLBot-Desktop 的反向 WS 拨号目标**——`D:\LLBot-Desktop-win-x64\bin\llbot\data\config_3371846367.json` 里 `ob11.connect[]` 唯一 `enable:true` 的一项是 `{"type":"ws-reverse","url":"ws://127.0.0.1:14514/onebot/v11/ws"}`,正向 WS(3001)与 HTTP(3000)全是 `false`,3080 只是它的 WebUI(`/onebot/v11/ws` 返回 404)。这种实现端**主动拨出来**,所以必须用 `upstreamListen`(hub 监听 14514)而不是 `upstreamUrl`(hub 拨号)。判断口诀:`type: "ws-reverse"` = 它拨出来 = 我们监听;`type: "ws"` = 它监听 = 我们拨号。 - **心跳会把模型白白叫醒(已修)**:`meta_event` 这类非消息事件以前也会 `batch.push()`,窗口(`batchMs`,默认 8s)到期后由 `scanDue` → `flush` 照常唤醒 agent——实测线上每条 60s 心跳都跑了一个模型回合(`onebot-hub:other%3Ameta_event` 会话 `turns` 一直涨)。现在 `Mind.observe` 先做一次"值不值得叫人"的前置判定:`self` / `mode:observer` / `kind:*`(心跳、未开 `wakeOnNotice` 的通知)**不入批量窗口**,时间线照记。`test/m15-e2e.mjs` 有对应断言。 - **最近窗口的"我"判错了(已修)**:`lib/memory/assembler.js` 的 `renderWindow` 曾用 `e.selfId` 判"是不是我说的",而**入站事件的 `selfId` 是事件里的 `self_id`(= 本 bot 账号)**,于是自己收到的每条消息都被渲染成 `我:…`(实测把用户私聊的 "111" 写成 `我:111`,模型很可能因此当成自己的回声而不回复)。现在改为按**说话人** `e.actor.user_id` 判定,只有 `direction === 'hub-out'` 或说话人就是自己才标"我"。 - **诊断用 `agents.lastTurn` + 会话账本(§21.8)**:真宿主的 `session.v4.jsonl.zstd` 要等会话关闭才落盘(实测跑完一轮仍是 174~313 字节、只有 session 头),所以光看 `sent: 0` 分不清"模型没调 `onebot_reply`"还是"调了没发出去"。`mind.stats.lastTurn` 记 `{sessionKey, at, reason, ok, error, hadReply, spokeFrom, reply, sent, retcode, diag}`。`diag` 来自**宿主自己的会话账本**:插件订阅 `ctx.on('session/event', (session, event) => …)`(post-commit 的追加 feed),按 `session.id` 前缀 `onebot-hub:` 认领并反解 agentKey,记下 `assistant/message` 的正文、`tool/call`、`tool/result` 的错误、`turn/end` 的原因。订阅挂了**两条**:root 级一条(依赖事件是否冒泡到插件根作用域),会话作用域一条(`setup(agentCtx)` 里的 `agentCtx.on`,并用 `agentCtx.effect` 随会话释放)——`lastSetup.feedSubscribed` 就是后者的落地情况;两条同时收到同一事件时按 `event.seq` 去重(同一会话内 seq 单调,不去重那句话会被发两遍)。为什么非有它不可:`Agent.kick()` 是 `try { while (await this.turn()); } catch (_error) {}`,**回合内的模型/工具错误被静默吞掉**,`whenIdle()` 解析 ≠ 回合健康(DSH 文档原话:"`whenIdle()` does not mean one follow-up finished")。`test/m15-e2e.mjs` 覆盖"回合以 error 结束时原因留在账本里""工具调用与结果留痕",`test/load-check.mjs` 覆盖两条订阅与 seq 去重。 - **发言兜底 `agent.speakAssistantText`(默认开)**:宿主 agent 的**最终回复文本就是它的发言**——模型没调 `onebot_reply` 并不等于"它选择沉默",上一轮的真机现象(`toolRegistered: true` 却 `hadReply: false`)就是被当成沉默处理的。现在 `Mind.flush` 在"没有工具候选 + 唤醒成功 + 本轮账本里有 `assistantText`"时用 `reply.capture({text})` 把这段话发出去,`spokeFrom` 记为 `assistant-text`;工具候选仍优先(`tool`)。不想说话的表达方式:整句括号旁白(`(不回复)` / `(沉默)`,或"(已回:…)"这类自述收尾,见 `isSilentMarker`——只认整句被括号包住,长度上限 24 字符,自述收尾不受此限),或把 `agent.speakAssistantText` 关掉退回"只有工具能发言"。提示词随之改成诚实的说法:`onebot_reply` 的 description 明确"你写在普通回复文本里的内容**同样**会发出去,这个工具是为了精确控制(附图/引用)",会话作用域提示词(`systemPrompt.context`,name `onebot-hub:reply`,order 110)同理,并说明"不该开口就只写一句括号旁白或干脆不写"。`test/m15-e2e.mjs` 覆盖文本兜底、工具优先、括号旁白静默、开关关闭四种情形。 - **诊断用 `agents.lastSetup`**:`agentCtx` 作用域里的回复工具是通过 `agentCtx.tools?.register?.(...)` 注册的,**注册失败是静默的**——真宿主若不暴露 `agentCtx.tools`,模型就没有任何出口,只能写普通回复文本,症状和"它自己决定沉默"完全一样(都是 `hadReply: false`)。现在 `setup` 把结果写进 `mind.stats.lastSetup`:`{sessionKey, hasToolsApi, hasPromptApi, promptOk, toolRegistered, toolError, feedSubscribed}`,`onebot_hub_status` 的 `agents` 段可读;注册失败会记 `toolError` 并打日志。`test/load-check.mjs` 有两条断言分别覆盖注册成功与"没有 tools 时必须留痕"。 - **`source.kind: 'plugin'` 会让整轮在写会话日志时就死掉(已修,真机事故)**:唤醒 agent 时 `createUserMessage({content, source})` 的 `source` 原本写成 V3 时代的 `{kind:'plugin', plugin:'dsh-onebot-hub', form:'notice', summary}`。`createUserMessage` 本身不校验形状,**到事件被写入 v4 会话日志时才拒**:`lib/types/message-sources.js` 的 `source()` 明文 `if (… || source.kind === 'plugin') throw new SessionFormatError('format v4 message requires a producer-owned source kind')`。症状极具迷惑性——`whenIdle()` 照常解析、`pool.errors` 为 0、`spokeFrom: 'none'`,只有 `lastTurn.diag.turnEnd === 'error'` 配 `turnError` 才看得出来(模型压根没跑)。现在 `source` 是 `{kind:'onebot-hub', form:'chat', summary}`(生产者自己的 kind,元数据字段随意);`test/stubs/dsh-llm.mjs` 与 `test/m15-e2e.mjs` 的假 `createUserMessage` 都照真规则拒绝 `kind: 'plugin'`,这条回归不会再溜过去。 - **会话被归档会让每一轮都静默地 `blocked`(已修,真机事故 #2)**:症状与事故 #1 几乎一样——`spokeFrom: 'none'`、`pool.errors: 0`、`lastSetup` 全好,只是 `diag.turnEnd` 从 `'error'` 变成 `'blocked'`。机制在 `@deepseek-ai/dsh-agent` 的 `AgentLoop.turn()`:`const decision = await this.preStep(target, { turn, step }); if (decision.kind === 'reject') { turnEnds = { kind: 'blocked' }; return false; }`——**blocked 就等于 `agent/pre-step` 被某个监听器拒绝**;而 `preStep` 里 `this.inbox.claim(...)` 已经先把消息领走,所以被拒时输入被**丢弃**(`accountsForClaim` 认为 blocked 也算消费)。拒它的插件是宿主的 `archived-session-gate`:会话只要在 `workspaceRegistry.archivedSessionIds` 里,每一步都 reject,模型请求从未发出。会话日志的样子极具辨识度(用 `test/_read-session.mjs` 解 `session.v4.jsonl.zstd`):`turn/start` → `agent/inbox/spliced {removedCount: 1, inserted: []}` → `turn/end {reason: {kind: 'blocked'}}`,中间**没有** `step/start`、没有 `assistant/message`、没有 `request/header`。枢纽的对策不是"请用户别归档",而是唤醒前自己 `unarchiveSession`:`AgentPool.wake` 的第一步就是 `await this.#unarchive(agentKey)`(`lib/agent/pool.js`),`lib/index.js` 从 `sctx` 取 `workspaceRegistry` 并**先查 `archivedSessionIds` 再决定要不要写盘**(未归档时 `unarchiveSession` 不写盘,所以常态零成本、幂等);摘归档失败**不挡唤醒**(记进 `lastTurn.unarchive.ok === false`,这一轮照走)。`test/m15-e2e.mjs` 的 `scenarioArchivedSessionGate` 覆盖三种情形:被归档(`unarchive` 必须早于 `followup`)、未归档(`archived: false`、不写盘、不多算 `pool.stats.unarchived`)、`unarchive` 抛错(照常发言,错误留痕)。 - **模型调了工具、群里却收到它的收尾旁白(已修,真机事故 #4)**:真宿主里用户敲 `/status`,模型用 `onebot_reply` 排队了「在。`/status` 没动静不是我装死……」,群里收到的却是它收尾时写的「(已回:说明 `/status` 沉默的原因是超管名单为空,需要配置才生效。)」。根因是**回复候选只活了一轮**:会话级 `onebot_reply` 工具是在 `setup`(`lib/index.js` 的 `agentCtx.tools.register(makeReplyTool(reply, hub, log))`)注册的,而 `AgentPool.ensure()` 命中已有会话时**直接返回缓存、不再跑 `setup`**——工具因此永远握着**第一次唤醒那一轮**的 `ReplyBuffer`;偏偏 `Mind.flush` 每轮都 `new ReplyBuffer()` 并把它换进 `#replies`,于是工具写进旧缓冲、flush 读新缓冲,`reply.take()` 恒为 `null`,兜底逻辑(`agent.speakAssistantText`)就顶上来替它发言了。修法是让缓冲**跨轮稳定**:`Mind` 新增私有 `#replyFor(agentKey)`(没有就建、有就复用,工具拿到的引用从此不变)与公开 `releaseReply(agentKey)`,flush 改成 `const reply = this.#replyFor(agentKey); reply.clear();`(开轮前清掉上一轮没取走的候选,代价仅此),删掉原来的 `this.#replies.delete(agentKey)`。同时把 `isSilentMarker` 收紧:整句被括号包住**且以"已回/已回复/已发/已发送/已告知…"开头**的旁白一律按"不打算说话"处理(不再受 24 字符上限约束——真机发出去的那句有 30 多字)。回归用例:`test/m15-e2e.mjs` 的 `scenarioReplyBufferSurvivesSecondWake` 用同一个会话连唤醒两轮,断言 `pool.stats.created === 1`、`resumed === 0`、两轮都发工具内容且 `spokeFrom === 'tool'`(**先在旧代码下确认它会红**:把 `#replyFor` 换回 `new ReplyBuffer()` 后该用例 FAIL、142/143,改回来 143/143)。**操作教训**:用 `(Get-Content -Raw).Replace(...)` 做这种"临时改回旧代码"的实验时,必须先确认替换真的命中(`$raw.Contains($old)`——文件是 LF 而旧串写成 `` `r`n `` 时 `Replace` 静默无操作,测试照旧 PASS,结论完全不可信);另外 `[System.IO.File]::ReadAllText("lib\\mind.js")` 按**进程 cwd** 解析相对路径,`cd` 到子目录后必须用绝对路径。 - **同一条消息被数成两条,模型以为用户说了两遍(已修,真机事故 #3)**:症状不在枢纽的日志里,而在**模型说的话**上——群里用户只发了 1 条 `/今日小猪`,模型却回"你连着发了两次 /今日小猪"(并且下游 bot 也只回了一次,说明上游确实只收到一条)。根因是**时间线的双视角**:`Hub.#deliver` 把上游事件转发给下游时,会以 `direction: 'downstream-out'` 再记一条同内容条目(带 `refs.upstreamLinkId` / `refs.upstreamMessageId`),它的 `sessionKey`、`actor.user_id`、`text` 与那条 `upstream-in` **完全相同**;而 `Mind.snapshot` 把 `timeline.bySession(sessionKey)` 的**全部方向**都喂给了会话卡(`buildDigest`)与最近窗口(`renderWindow`),于是同一句话被渲染两次、参与者计数也翻倍(实测会话卡"最近 4 条消息"里的两条用户消息各出现两遍)。修法是加一层**规范化**:`lib/capture.js` 导出 `isForwardMirror(entry)`(`direction === 'downstream-out'` 且带 `refs.upstreamLinkId` / `refs.upstreamMessageId`)与 `canonicalEvents(entries)`,`buildDigest` 与 `renderWindow` **入口处**各自过滤一次——纯渲染函数自我保护,不依赖调用方记得去重;时间线本身(`onebot_timeline`、`hub.timeline`)仍保留两个视角供追溯。注意判定只认"转发出去的上游消息":下游自己推来的 `downstream-in` `send_*`(下游 bot 真的在说话)不是镜像,不能一起杀掉。`test/smoke-core.mjs` 覆盖判定与"卡/窗口各一次",`test/m15-e2e.mjs` 的 `scenarioReply` 手工补上镜像条目,断言 prompt 里那句话出现 2 次而不是 3 次(卡一次 + 窗口一次)。 - **浏览器半侧的 inject 契约(真事故,2026-10-07)**:cordis 的 `ctx` 是反射代理,`vendor\cordis\src\reflect.ts:144` 对**没在 `inject` 里声明过的**服务属性直接抛 `cannot get property "" without inject`(连 `!!(ctx && ctx.configForms)` 这种"只探一下"的读法也照抛不误)。而 `exports.inject` 是**唯一的声明处**,`packages\client\AGENTS.md:144` 也写明 `dsh.client.inject` 只是展示用的依赖边、不决定激活顺序——顺序由 `ctx.slots.inject(座位, 回调)` 这种"等座位出现再注册"的机制负责。当初 `inject` 里只写了 `['slots']`,`apply` 里读 `configForms` → 抛 → 条目状态 FAILED → `packages\client\web\src\boot-client.ts:83` 的 `assertEntriesActive` 报 `web boot: 1 entry did not activate / dsh-onebot-hub: failed`,桌面壳据此**拒绝启动**(`crash-2026-10-06T17-25-03-215Z-web-boot.log`)。两条硬规矩由此而来:**① `apply` 整体包 try/catch,绝不外抛**(注册失败只上报,`slots.inject` 回调里自带 try/catch 并回 no-op);**② 读到的每个服务都要列进 `exports.inject`**——`test/client-manifest.mjs` 会扫源码把没声明的 `ctx.<服务名>` 揪出来。另外 `immediately: true` 只许基础设施行用(`packages\client\AGENTS.md:144`),普通功能行标了会被塞进 stage-one 共享批次。 - **浏览器自诊断通道(`lib/client-probe.js`,只为分诊"设置页空白"而存在)**:宿主**不落盘**渲染进程日志(实测 `C:\Users\94512\AppData\Roaming\@deepseek-ai\dsh-desktop\logs\` 里只有崩溃转储,正常启动什么都没有),而"设置页空白"的全部证据都在浏览器控制台里——所以枢纽自己把它接回来:① 监听 `webserver/index-inject` 往 index 的 `` 注入一段**内联探针脚本**(`kind:'script', placement:'head'`,在任何模块 bundle **之前**执行);② 探针把 `error`(捕获阶段,能拿到资源错误的 `target`)/`unhandledrejection`/`console.error`/`console.warn` 与几个里程碑防抖批量 POST 到精确路径 `/onebot-hub/client-report`(同源、`keepalive: true`);③ 枢纽把内容存进**内存环形缓冲**(`MAX_BATCHES=40` / `MAX_TOTAL_ITEMS=400` / 单条 1200 字符 / 请求体 64KB),经 `onebot_hub_status.host.clientReports` 读出来。`kind: 'resource'` 是最有用的一类:宿主把多个包的 `client.js` 拼成一个 combo 脚本(`plugins/??a/client.js,b/client.js&rev=…`),**只要那个 bundle 脚本没取到,这批里每个包都报 `import failed`**(旧崩溃日志里 222KB 全是这种级联),而探针能直接说出是哪个 URL。`client.js` 里另有一份**兜底探针**(`globalThis.__ONEBOT_HUB_PROBE__` 不在时自己装一个最小的),保证注入没生效时仍有回传路径。**边界**:纯诊断,不落盘、不外发、不改行为;进程重启缓冲即空;注入要**一次页面加载**才生效(改完代码后刷新页面再看);宿主没有 `webServer` 时不装,并如实回 `install.available: false`。 - **唤醒了却不开口(已修,真机事故 #5):心跳把模型叫起来 + 聊天会话的写句柄被占着就整轮失败**:用户报"改之前还能回消息,现在没回复了,而且还在一直对 heartbeat 事件做出反应"。现场两条独立症状:①`other:meta_event` 会话以 `reason: 'batch:timeout'` 连唤了 3 轮(每轮模型都回"不回复:heartbeat 元事件",`hadReply: true`、`sent: true`),而 group/private 三条真聊天消息 `hub-out` 一条都没有;②`agents.pool { created: 1, resumed: 0, errors: 4, lastError: {agentKey:'private:945126014', message:'session "onebot-hub:private%3A945126014" is already owned by an active write handle'} }`。**心跳那半**:profile 里 `agent.wakeOnNotice: true`,而 `shouldWake` 的 notice 分支只看 `entry.text` 有没有内容(`[元事件 heartbeat]` 恰好有),于是元事件混进批量窗口,窗口到期就被 `scanDue` 唤醒——修法是 `lib/agent/policy.js` 的 `shouldWake()` 在 `isSelf` 判定之后直接 `if (kind === 'meta_event') return { wake: false, reason: 'kind:meta_event' };`(`kind = event?.post_type ?? entry?.kind ?? 'unknown'`):心跳不是聊天内容,连批量窗口都不该进(`mind.observe()` 见到 `reason` 以 `kind:` 开头就不入批)。**写句柄那半**:asar 取证——`session "" is already owned by an active write handle` 的唯一来源是 `SessionAlreadyOwnedError`,只在 `claimWrite(id)` 里抛,而 `open(id, "write")` 正是走 `this.tracker.claimWrite(id)`,所以 `agents.resume()`(内部 `persistence.open(id, "write")`)撞上"本进程已持有该会话写句柄"就整轮失败;`create(header)` 则在 `hasPending || findLog(id) !== void 0` 时抛 `SessionAlreadyExistsError`、**抛之前不取所有权**(注释称其发布是 rollback-covered)。三层修法(`lib/agent/pool.js` + `lib/index.js`):**先领养**——`ensure()` 第一步 `#adoptLive()`,用宿主 `agents.get(sessionId)`(文档原话 "Look up a live agent.")把**已经活着的 agent** 直接接管(`stats.adopted`、记录标 `adopted: true`、`handle: null`,`dispose()` 仍尽量走 `agent.dispose()`);`resume` / `create` 抛错时也先试领养再决定要不要抛(`create` 撞号仍回退 `resume`)。**再兜底换 id**——`hostRef.resume` 的 catch 里只认这一条错误:命中且宿主给了 `agents.create` 就用 `chosenSessionIds` 把该 `agentKey` 换到「原 id + `.r<时间戳>`」的**新会话**上照常回话,并记 `host.lastFork = {agentKey, from, to, at, error}`(`hasSession`/`create`/`getLive`/`unarchive` 一律经 `sessionIdOf()` 取当前 id);**别的错误照旧抛,不许吞**——"宁可换一个会话 id 也不静默不回话",而枢纽自己的上下文(会话卡/记忆/最近窗口)本来就不依赖宿主那份转录。诊断上新增 `pool.stats.lastErrors`(环形 5 条):一个会话占住写句柄后 `lastError` 只剩最后一条,看不到**第一手**错误,而第一手错误正是判断"是不是 `wrapSetup` 里新加的 `agentPresets.mount` 让 create 中途失败留下 claim"的关键(重启后 `onebot_hub_status` 的 `agents.pool.lastErrors` 可读)。回归:`test/m15-e2e.mjs` 146 项(领养成功即 `ok === true`、`adopted === 1`、`errors === 0`;元事件开着 `wakeOnNotice` 也不叫模型,同时**反向**钉住一条 `notice` 在窗口到期时仍会唤醒,别把通知一起误杀)、`test/load-check.mjs` 58 项(`host.lastFork` 指向 `.r…` 新会话;换个别的错误必须照旧 reject)。 - **凭据字段在设置页整行消失(已修,真机事故 #6)**:用户问"生图功能的 key 怎么在设置里面没有配置的地方?"——页面上确实**没有那一行**。根因在宿主:`settings.describe({ redactSecrets: true })` 会把 schema 里标了 `role('secret')` 的字段**从 value 里删掉**(不是清空;`redact.js` 的函数注释原话 "Remove every `role('secret')` field a schema declares from a value",walker 里 `if (stripped !== void 0) rebuilt[key] = stripped;`),而 `lib/client.js` 的页面行来自 `flatten(snap.value)` → `imageGen.apiKey` / `anime.recognizeToken` / `upstreamAccessToken` / `downstreamAccessToken` 四行**整行不存在**(用户已经手改过 `imageGen.enabled/model/baseUrl`,却永远没法在页面上填 key)。宿主其实给了替代信息:**secrets**(`[{ path, set }]`,只报"设过没设过",不回显明文;宿主类型 `SettingsSecretView`),插件以前只拿它把输入框变密码框。修法(`lib/client.js`):① 由它建 `secretMap`,`ALWAYS_FIELDS` 之后再补一轮——`fields` 里没有的凭据键**自己补一行**(`kind: 'string'`、`value: undefined`、`secret: true`),`secretKeys` 由它派生;② 徽章不再用 `userKeys.has(key)`(`snap.user` 同样被脱敏,已设的凭据会被显示成"未设置"),改成 `secretSet = secretMap.get(key)?.set === true` 或本次输入非空;③「恢复默认」按钮对凭据行改看 `secretSet`。保存语义不变:**留空 = 不改**。回归在 `test/client-render.mjs`(补行、密码框、徽章说真话、保存 op、留空不写回)。**这个修法第一次上真机时又没生效(同一事故的第二次)**:补行是补了,但读的是 `view.secrets`——真宿主的形状是 `describe()` → `{ writable, hasDocument, namespaces: [namespaceView…] }`,而 `namespaceView(descriptor)` 返回的 `{ ns, schema, value, base?, user?, applies, secrets, revision }` 里**才有 `secrets`**(宿主注释:"Project one redacted descriptor onto its wire view, field by field"),`view` 顶层根本没有这个键 → `secretMap` 是空的,一行也没补出来。改成**三个落点都认**(`snap.secrets` → 命名空间条目的 `secrets` → `view.secrets`,哪个有就用哪个),并加了一条回归钉住这三处,同时钉住"宿主一句都没说时不许凭空造行"。教训:宿主给的数据要先在源码里看清它挂在**哪一层**,别照着名字猜。 - **二次元识图配了却不工作(已修,真机事故 #7):把"展示名"当模型名发给了 AnimeTrace**:用户报"二次元识图的功能我配置了为什么没能工作"。先排除了两个假象:`anime.attempts: 0` 不等于识别器坏——那张图(07:10)是**重启前**处理的(`cordis.patch.yml` 的 mtime 15:12:47 晚于它),且 `vision/cache.json` 证明那次只走了描述。真正的原因在模型名:`GET /v1/model/list` 的项是 `{ id: 'animetrace-yuri-4.2', name: 'AnimeTrace Yuri 4.2', provider: 'amt', enabled: true, default: true }`,`pickModelName()` 原来取 **`name`(给人看的展示名)** 塞进 multipart 的 `model` 字段,服务端于是回 **HTTP 500 + `{"code":17729,"zh_message":"未找到选择的模型"}`**;而插件只报 `AnimeTrace HTTP 500`,现场完全看不出原因(限流时是另一回事:`code:17737`"请求过于频繁,请稍后再试",429)。修法(`lib/vision/anime.js`):① `pickModelName()` 的字段优先级改成 **`id` → `model` → `model_name` → `model_id` → `name`**,数组分支按 `default` → `enabled` → 首个挑;② 新增 `detailOf(payload, raw)`,把服务端的 `zh_message`/`message`/`detail` 压平截断后拼进错误串(`AnimeTrace HTTP 500(未找到选择的模型)`),**识图失败时宁可多说一句**。真机复验:用修好后的插件代码打同一张图 → `[角色识别: エルウェシィ, 呉羽, ロキシー・ミグルディア・グレイラット]`(`attempts:1, hits:0, misses:1, failed:0`)。回归在 `test/anime.mjs`(`id` 优先于展示名、无 `default` 时挑 `enabled`、限流与"模型找不到"的原话都要出现在错误串里)。 - **认得出人了,但只给名字、还可能是瞎猜(已修,真机事故 #8):AnimeTrace 的作品名与"我拿不准"**:用户问"这是谁"时模型只拿到 `[角色识别: エルウェシィ, 呉羽, ロキシー…]` 三个光名字(同名角色满天飞,模型也认不出是哪个作品),而公共 API **不给数字置信度**——`POST /v1/search` 的真机返回是 `data:[{ box, box_id, not_confident: false, character: [{ work: 'ハミダシクリエイティブ', character: '和泉妃愛' }] }]`,那个 `not_confident` 布尔就是服务端在说"我拿不准"。旧代码只取 `character[].character`(`readTraceNames`)、把 `work` 整个丢掉,也从不看 `not_confident`。用户定案(原话):"**可以,不要截断。模型报可信度低的识别结果不要采用,显示识别失败即可**"。修法(`lib/vision/anime.js`):① `readTraceNames` → `readTraceCharacters(data)`,返回 `[{ name, work, confidence: null }]`,渲染成 **`[角色识别: 名字(作品)]`**,作品名一个字都不截(去重键是 `名字\u0000作品`,同名的不同作品各算一条);② **`not_confident === true` 的整个框丢掉**,全框都拿不准就渲染 `[角色识别: 未能识别]`(与"后端挂了"的空串区分开:这是"看过了,认不出");③ 缓存里也存 `work`,并容忍**老缓存没有这个键**(读成 `work: null`,既不失效也不重问)。回归在 `test/anime.mjs`(真机形状 + 超长作品名不截断、`not_confident` 整框丢弃、混合时只留正常框、缓存带上 `work` 且老条目照样读)。 - **"图生图"悄悄变成"文生图"(已修,真机事故 #9):参考图字符串被静默丢掉**:用户报"生图功能貌似没法正常传参考图"(模型自己复盘:"它是把整张画重来了一遍")。现场看**请求本身是成功的**(`imageGen.requests:3 / ok:3`),所以不像报错;证据在那一轮的工具调用上——模型传的是 `onebot_imagegen{prompt:'严格保持参考图中的卡通猪脸造型…', reference:'C:\\Users\\94512\\.dsh\\onebot-hub\\media\\blobs\\8d8f….jpg'}`,即**一个本地路径字符串**,而 `lib/index.js` 把 `args.reference` **原样透传**给 `lib/vision/imagegen.js`,后者只有 `if (reference && reference.bytes)` 才写 `body.image`——**字符串没有 `.bytes`,参考图就此消失**,请求退化成纯文生图(也就没有报错可看)。修法三处,核心是"**要传就真传,传不了就明说**":① `lib/media.js` 新增公共方法 `readRef(link, {kind})`,把参考图写法(`data:` / `base64://` / `http(s)://` / `file://` / 本地路径 / `hub-media:`)统一读成 `{bytes, mediaType, provenance}`,读不出来返回 `{error}`;它**不落盘、不登记、不改状态**(与 `resolve()` 的分工就在这里);② `lib/index.js` 的 `onebot_imagegen` 在**领每轮额度之前**先 `readRef` 把参考图读成字节,读不出来就直接把原因回给模型(这一轮根本没画,不该扣额度),参数说明也补上 `hub-media:`;③ `lib/vision/imagegen.js` 的 `generate()` 遇到"传了参考图却拿不出字节"(字符串、没有 `bytes` 的对象)**直接 `#refusal` 失败、一个请求都不发**,错误串写明"参考图要传字节({bytes, mediaType})…",杜绝这类静默降级再发生。回归在 `test/imagegen.mjs`(字符串参考图 → 明确失败且不请求、没有 `bytes` 的对象同样失败、字节参考图照旧是 data URI)与 `test/smoke-core.mjs`(`readRef` 的五种写法 + 认不出时的文案 + "不许登记新 ref")。**顺带一条给用户的实话**:这类接口的图生图是"按参考图重画",做到像素级只擦掉一行字不现实,要精确改图得用带 mask 的编辑接口。 - **`agent.mode` 从"三档唤醒规则"改成"每会话休眠/激活状态机"(`m01733` 用户定案)**:旧语义里 `assist` 是"攒够 `batchSize` 条或窗口到 `batchMs` 就唤醒"——也就是**群里没人叫我,我也会被窗口叫起来**;`active` 更直接,**每条消息都唤醒**。用户要的是"平时别说话,被叫到才参与一会儿,参与完自己睡回去",于是改成一套**每会话的 `dormant`/`awake` 状态**(状态存在 `Mind.#wakeStates`,判定仍集中在 `lib/agent/policy.js`,所以测试可以只喂状态、不搭整套编排):① **休眠态**只记录(进窗口、时间线照记),唯一出口是**被叫到**——`@` 我 / 引用我说过的话 / 文本里有我昵称 / **私聊的每一条**(`resolveCalled()`,私聊"每条都算叫到"是用户明确要的);`active` 档多一个出口:`Mind.tick` 里每 `activeTickMs`(默认 5 分钟)**看一眼该会话有没有新消息**,有就唤醒一次并进激活(`tick:active`),没有就什么都不做。② **激活态**:被叫到照样立刻醒,其余按批量窗口(`batch:full` / `batch:timeout`)再唤醒一次;窗口从**进入激活/上次唤醒之后的第一条新消息**算起(沿用 `BatchWindow.openedAt`,所以没人说话不会空转唤醒)。③ **退出激活**(任一命中):**连续 `AWAKE_SILENT_TURNS`(=2) 轮没开口**(`#noteSpokeOutcome`:有候选就清零,没候选就 +1,够 2 就回休眠——"agent 认为无需回复"就是这么表达的:写一句括号旁白(`(不回复)`)或干脆不写)、**激活后 `awakeMs`(默认 5 分钟)没有新消息**(`#expireAwake`,由 `Mind.tick` 检查;没有这条就会永远挂着"激活")。硬闸与状态无关、先判:`observer` 档 / 自己的消息 / `meta_event` / 未开 `wakeOnNotice` 的非消息类 / **下游已应答的硬静默**。可观测性:`stats.awakened`/`stats.dormant` 计数,`onebot_hub_status` 的 `agents.wakeStates` 列出每个会话的 `state/since/lastMsgAt/silentTurns`,`lastTurn.wakeState`/`silentTurns` 记这一轮结束时睡还是醒;会话作用域提示词(`systemPrompt.context`,name `onebot-hub:reply`)也如实告诉模型"连续 2 轮不开口或 N 分钟没新消息我就会回休眠,所以沉默是结束一段对话的正常手段"。回归:`test/m15-e2e.mjs` 的 `scenarioBatchTimeout`(休眠只记录/窗口到期也不醒/被 @ 立刻醒并进激活/激活期窗口再唤醒/连续 2 轮沉默回休眠/再被叫到重新进激活)与 `scenarioActiveTick`(active 不每条都醒、没到节拍不醒、到节拍且有新消息唤醒一次、无新消息不唤醒、空转超时回休眠、被叫到同 assist、私聊每条都算叫到),另有一条纯函数断言"激活态的通知按窗口唤醒、休眠态的通知不唤醒"。 - **识图模型不再是配置项:默认系统默认模型,要换在会话里用 `/vmodel`;聊天模型同理用 `/model`(`m01993` 用户定案)**:原来 `vision.provider`/`vision.model` 两个配置项把"看图用哪个模型"钉死在 profile 里——全部署只有一个看图模型,换一次要改配置、重挂插件甚至重启;而"用哪张模型看这张图"本来就是**会话级**的选择。现在:① **删掉这两个配置项**(`vision.mode/prompt/system/maxTokens/timeoutMs/cacheLimit` 保留),看图请求**不指定 provider/model** → 宿主 `llm` 走系统默认模型;② 新增会话级选择(`lib/models.js` 的 `SessionModels`,落 `/sessions/models.json`,**只存选择、不碰调用**):`chat` 与 `vision` 两类,按会话键(`group:…`/`private:…`)分开存,重启后仍在;③ 两个超管命令 `/model`(聊天模型)与 `/vmodel`(识图模型),中文别名 `/模型`、`/视觉模型`,参数只认三种写法——`provider/model`、**清单编号**(无参列表里的序号)、`default`(或 `默认`/`reset`/`清空`)回系统默认;**故意不支持只写模型名**(`a/b/c` 这种斜杠歧义也直接报错),因为"沿用哪个 provider"猜错比让人多打几个字更贵;无参时列出 `llm.listProviders()`+`llm.listModels(id)` 给的清单(每组最多 12 条、整段最多 700 字,`→` 标当前),宿主没给清单就如实说;④ **聊天模型立即生效**:每个 agent 有一个可变 selection 对象交给宿主 `installModelSelection(agentCtx, selection)`(宿主每轮装配 prompt 时读 `selection.current`),`/model` 改的就是它——宿主自己会追加一条 `[model changed: …]` 的会话通知;会话还没建(休眠中)时只落盘,建的时候 `selectionFor()` 带上覆盖,`agentOptions` 也随之取覆盖值;⑤ **识图模型的调用路径**:`hub.models.visionFor(sessionKey)` 由 `hub.js` 的 `#enrichImages` 与 `onebot_media(describe:true)` 作为 `override` 传给 `Vision.describeImage()`,只作用于那一次调用、不动模块默认值;⑥ **已知取舍**:描述缓存键**只按图片 sha256**(用户明确选的)——换了识图模型,同一张图**不会**重新看,仍吃旧模型的描述;想强制重看就换一张图或删 `vision/cache.json`。⑦ `/reset` 顺手把本会话的模型选择也清回系统默认(它本来就管"本会话的设定")。回归:`test/models.mjs`(11 项)、`test/chat-commands.mjs`(两条命令的全部写法与失败形态)、`test/smoke-core.mjs`(`override` 只作用于单次调用)、`test/m15-e2e.mjs` 的 `scenarioVision`(覆盖真的进了 `llm.stream`、且别的会话不继承)、`test/load-check.mjs`(装配层面 `hub.models` 在、`hub.vision` 默认必须为空、命令表里有这两条)。 - **命令文案不许写死 `/` 前缀(`m02289` 用户指出)**:`chatCommands.prefix` 是可配的(默认 `/`,也可以配 `!`、`#`、`。`…),但回话文案里曾经处处写死斜杠——`/help` 会回"用 /status"、`/model` 的清单提示写"例如 `/model 3`"、`/perm` 的提示写"敲 /perm <预设名>"。用户把前缀改成 `!` 之后,照回话敲的命令全都不是命令(被当普通聊天走)。改法:① `COMMAND_SPECS` 的 `usage` **去掉前缀**(写 `energy [0~1]` 而不是 `/energy [0~1]`),新增导出的 `usageLine(spec, prefix)` 负责拼当前前缀,help 逐条渲染它;② 每条命令的 `run(ctx)` 里凡是要提命令的地方一律用 `ctx.prefix`(`/energy` 的"比如 {prefix}energy 0.6"、`/set_preset`/`/set_role`/`/preset_role`/`/memes del` 的用法回话);③ `renderModelList()` 改成接收 `command` + `prefix`(不传就只给通用提示,**不编一个 `/model` 出来**),清单末尾的"发编号就能切"用配置前缀;④ `/perm` 的提示在 `lib/index.js` 里读 `config.chatCommands.prefix` 拼(它属于 `services.perms`,拿不到 `ctx.prefix`);⑤ 设置页 `vision.mode` 的说明也改成"按下面的命令前缀(默认 /vmodel)",不写死。回归:`test/chat-commands.mjs` 的"命令表"用例断言每条 `usage` 都不带前缀、`usageLine(spec,'/')`/`usageLine(spec,'!!')` 拼得对,另一条用例把前缀配成 `!` 跑完 `/help`、`/model`(清单与报错文案都是 `!model`)、并断言 `/status` 在这套配置下**不再是命令**、让路前缀也可以配成别的;`test/models.mjs` 断言 `renderModelList` 用调用方给的前缀且不给命令名时不编。 - **被叫到了却没反应(已修,真机事故 #10):休眠窗口 + 陈旧"下游已应答"把会话永久静默**:用户报"为什么会话被叫到了没反应"。现场很干净也很误导——`agents.woken: 0`、`pool.created / resumed: 0`、`wakeStates` 里那个群是 `dormant`、`batches.pending: 7`,也就是说**模型一次都没被叫**;时间线上的关键两条是 08:59:27 的 `@test 帮我查一下今日小猪`(at 段指向机器人的账号)和 08:58:26 / 08:59:01 的两条 `downstream-in send_msg`(NoneBot 的今日小猪插件在答别人)。根因是我上一轮状态机改造引入的回归:`shouldWake()` 把**"下游已应答 → 硬静默"排在"被叫到"之前**,而 `Mind.observe` 喂给它的回看起点是 `batch.openedAt`——**休眠窗口永远不会 flush**,于是 `openedAt` 停在几十分钟前,只要那之后有过任何一次下游应答,这个会话就被**永久静默**(连 @ 都压得住)。旧代码每 `batchMs` flush 一次,`openedAt` 会刷新,所以这个坑是新挖的。修法三处:① `shouldWake()` 把 `resolveCalled()` 提到静默闸**之前**——被叫到(@我 / 引用 / 昵称 / 私聊)直接醒,静默闸只压"没人叫我"的消息;② `Mind` 新增 `#silenceSince(batch, now)` = `max(窗口开启时间, now - max(1000, batchMs||8000))`,`observe`/`scanDue`/`flush` 全部改用它(**回看有界**:同一轮里的应答照样压得住,陈年老账不算);③ `Mind.flush` 用 `#calledIn(batchEntries, sessionKey)` 判断这一批里有没有人在叫我,有就跳过静默闸。顺手补上**诊断闭环**:`Mind.lastObserve`(`{sessionKey, text, mentions, wake, reason, state, pending, downstreamResponded}`)进 `agents.lastObserve`——下次"它为什么不醒"不用再靠猜,`reason` 直接写清楚(`dormant`=没被叫到、`silent:downstream-responded`=下游刚答过、`mentioned:at`=被叫到)。回归在 `test/m15-e2e.mjs` 的 `scenarioSilenceLookback`(纯函数层优先级、端到端的真机复现、普通消息仍被静默、`active` 节拍下陈年应答不压 / 刚刚的应答压住)与 `test/chat-commands.mjs` 无关的既有静默用例。 - **"我是谁"必须实时兜底**(真机事故 #11):@ 我 的判定要拿**账号**去比对 at 段、拿**名字**去比对文本,而这两个值都可能是"配置没写、要等上游告诉我们"的。所以 `Mind.liveSelfId` 按 **配置 `upstreamSelfId` → hub 学到的上游账号**兜底,`hub.learnUpstreamAccount()` 一学到就通过 `onUpstreamAccount` 钩子同步给 `Mind`;名字是**一组**(配置里的备注名 + `get_login_info` 学来的真昵称,`setIdentity` 累加),因为群里人 @ 的是真昵称、不是我们内部起的备注名。`onebot_hub_status` 的 `agents.identity` 直接显示这两个值,`agents.lastObserve` 还带 `atTargets`/`mentions`/`selfId`/`nicknames`。 - **被叫到了还是没反应(已修,真机事故 #11):@ 我 的账号取自空配置、真昵称又被备注名挡住**:事故 #10 修完重启,用户再说"还是没反应",而这次现场换了一副面孔——`batches.pending: 1`、`wakeStates.state: 'dormant'`、`downstreamResponded: false`,看着"什么都对,就是没醒"。真正说话的是新加的诊断:`agents.lastObserve.mentions: []`——**群里那条 `@test 帮我查一下今日小猪` 根本没被认成"在叫我"**(at 段指向机器人,却既没匹配上 `at`、也没匹配上 `nickname`)。两个独立原因叠在一起:① 配置里 `upstreamSelfId` 是空的(账号本来就能从握手/事件里学到,所以配不配都该能用),而 `Mind.selfId` 只认配置 → `String(qq) === String('')` 恒假;② 配置里写了 `upstreamNickname: 'dsh-hub'`(内部备注名),于是 `get_login_info` 学到的**真昵称 `test`** 被 `if (!config.upstreamNickname)` 挡在门外 → 手打的 `@test` 也匹配不上。修法:① `Mind.liveSelfId`(配置 → hub 学到的账号兜底)替掉所有判定点的裸 `this.selfId`;② 新增 `hub.hooks.onUpstreamAccount`,`learnUpstreamAccount()` 一学到账号就同步给 `Mind`(此前只存在 hub 里,判定层不知道);③ 名字改成**一组**(`Mind.#nicknames` + `setIdentity` 累加),`detectMention` 收 `nicknames` 列表、在 `raw_message` 与各 text 段上做包含匹配;④ `detectMention` 的 at 段同时认 `qq` 与 `user_id` 两种写法(不同实现端字段不同),`@全体成员`(`all`)不算叫我;⑤ `get_login_info` 那一路**无条件**喂给 `Mind`(不再因为"配置写了备注名"就不学真昵称)。诊断也补齐:`agents.lastObserve` 增加 `selfId`/`nicknames`/`atTargets`,`statsSnapshot` 增加 `agents.identity`——下次"@ 了不醒"能一眼分清是**没有 at 段**(`atTargets: []`,人是手打的文本)还是**账号对不上**(`atTargets` 有值但 `mentions` 空)。回归在 `test/m15-e2e.mjs` 的 `scenarioMentionIdentity`(账号兜底、昵称累加、`user_id` 写法的 at 段、`@all` 不认、手打 `@真昵称`、以及"空配置 + @ 我 必须唤醒"的真机复现)。 - **探针隔离:注入产物别跟真人消息搅在一起(§19 M7-④,`m19607` 用户定案"做①和③、默认时间隔离、时间隔离做重版")**:agent 学下游用法要**注入事件**试探(`onebot_invoke` 的 event 模式),而注入引发的下游回复会跟真人消息混在同一条链路上——**事后没有确定性证据**能分清哪条是被探针引出来的(`reply` 引用、目标会话不同能判,同群同窗口无引用则不能:NoneBot 每个事件一个 task **并发**,回包顺序与触发顺序没有契约)。所以不在事后猜,而在**结构上隔离**。配置族 `probe`(**刻意不叫 `isolation`**——那个键已经被记忆隔离占了):`probe.isolation`(`off` 默认 / `time` / `link`)、`probe.windowMs`(8000)、`probe.maxQueued`(50)、`probe.capture`(true)。① **时间隔离(配 `time` 启用,无需额外配置,"重版")**:`sendMessageToDownstream` 成功后给该会话开窗口,窗口内该会话的真人消息**暂缓入队**而不是照投(时间线照记 `decision: 'held:probe-window'`,并带上与正常转发相同的镜像 refs,所以会话卡不会把同一句话说成两件事);到点用 `#releaseProbeWindow()` **按原序**补发(refs 带 `probeReleased`/`probeHeldMs`/`probeSessionKey`,后写 `#armProbeTimer()` 用 200ms `setTimeout` + `unref()` 自续);窗口内又有注入就顺延;**安全阀**:攒到 `maxQueued` 立刻全量按原序补发并记 `forcedReleases` + 一条日志(**绝不吞消息**)。② **链路隔离**:`downstreamTargets` 每条可加 `probeSelfId`(=**同一下游的另一个 bot 账号**;hub 展开成第二条 `probeOnly` 链路,**地址与主链路完全相同、只有 `selfId` 不同**,`id` 为 `~probe`,口令/`downstreamId` 与主链路共用——**一个下游一条配置**,不用写两条互相对齐的条目;与主账号相同或留空**不展开**),也可以干脆另写一条 `probeOnly: true` 的目标(两种写法等价),再加 `downstreamId`(逻辑下游名——`selfId` 是"枢纽替对方扮演的账号",不是身份证据,agent 不该靠它分辨"两条链路是不是同一个下游")。真人消息**一条都不投** `probeOnly` 链路(`decideEvent` 的 `probeOnly` 分支,返回 `{deliver:false, reason:'probe-only-link'}`,**比 `not:broadcast` 更强**);注入走哪条链路**由枢纽决定**(`hub.pickInjectLink`,见下一条 `m02768` 记录):`link` 档一律走探针链路、其它档走真实链路;**调用方点物理链路也绕不过去**(`linkId` 只当"要问哪个下游"解释),要故意打真实链路得显式 `allowRealLink`(结果带 `notIsolated`);选不中下游时**抛中文指路错误**并列出有哪些,不静默乱投);它上面的下游 `send_*` 默认 `capture`(只记账不转发上游),时间线 refs 标 `probe: true`。**同端口不同 `selfId` 是可行的**(`nonebot/adapters/onebot/v11/adapter.py:255-258`:同一个 `self_id` 的第二条连接会被 `close(1008, "Duplicate X-Self-ID")`,换一个账号就各是各的 bot)——所以不必再开端口;**代价**是下游把探针那条当成另一个 bot 账号(同一个进程 = 同一份插件状态:冷却/缓存/定时任务共享),**探针的执行结果可能与用户触发时不同**。`probe.isolation` 的默认值是 **`off`**(隔离是"用户决定要不要"的事);`link` 档**离了 `probeSelfId` 不生效**:`get probeIsolation()` 在配了 `link` 却一条探针链路都没有时回 **`off`**,状态里 `isolation` 报生效档、`requested` 报配置档、`note` 说明回退(配置有误不静默);`link` 生效时 `buildHubGuidance()` 往 prompt 里写一段"探针的执行结果可能与用户触发时不同",`sendMessageToDownstream` 的返回体也带 `probeLink{caveat}`。③ **`off`**:旧行为。四种档位共用的口径:判不出一律**不扣**(宁可漏扣不可误扣)。可观测:`onebot_hub_status` 的 `probe` 段(档位/note/windowMs/maxQueued/capture/`probeLink`/活动窗口数/`stats{windowsOpened, held, released, forcedReleases, injections}`),每条链路的 `downstreamId`/`probeOnly` 也在状态里。回归:`test/isolation.mjs`(**30 项**,见 §8.1 表,含"默认 `off`""`link` 无 `probeSelfId` 回退""注入返回体带 caveat"三类)、`test/client-manifest.mjs` 的枚举单一出处新增 `PROBE_ISOLATIONS`、`test/client-render.mjs` 的枚举键比对 8 → 9。用户后续定案(`m20346`):**改走"同端口不同 `selfId`"**——`probeAddress` 换成 `probeSelfId`(不设默认值)、隔离默认 `off`、`link` 没配 id 时回退 `off`、并在 prompt 里告知"探针结果可能与用户触发时不同"。 - **工具层允许 agent 指定说话人账号,并明说"换账号就是换结果"(`m20730` 用户要求)**:`onebot_invoke` 早就有 `user_id`(注入事件的说话人,不写 = hub 的上游账号),但说明里只写"默认用 hub 的上游账号"——使用者很容易把它当装饰,以为"这条命令到底行不行"是全局结论。现在把后果写进工具说明与参数说明:下游大多按说话人做**权限判断**(超管/管理员/黑名单)、**冷却**、以及**用户级状态**(积分、开关、绑定、好感度、进行中的对话),同一句话这个账号能触发、那个账号可能被拒、或者干脆被当成另一个人;所以结论只对**你用的那个账号**成立,要验证某人的体验就用**他的账号**再试一次。`onebot_relay_probe` 的 `user_id` 参数与 hub 指导段的能力面一行也补了同一句。**行为侧没改**(`sendMessageToDownstream` 原本就是 `const speaker = user_id ?? upstreamSelfId ?? session.selfId`,同时写进事件的 `user_id` 与 `sender.user_id`)——`test/isolation.mjs` 新增两条断言把"注入帧的说话人就是调用方给的账号、换一个 `user_id` 就换一个说话人"钉住(30 → **32**),`test/load-check.mjs` 加一条工具面断言(`user_id` 必须声明、说明里必须出现"换 `user_id`"与"换结果"、参数说明要写"不写 = 默认")(59 → **60**)。 - **注入的链路由枢纽决定,不再让调用方点物理链路(`m02768` 用户定案"不应该只给 agent 一个工具,实际走的链路由程序决定")**:真机事故——`onebot_relay_probe` 真发那次,调用方**顺手填了** `link: "down:127.0.0.1:8080"`(真实链路),而老代码 `#pickProbeLink` 是"显式点名就原样照发",于是链路隔离被**静默绕过**:注入的 `/今日小猪(message_id 900000001)` 记在主链路上、下游回的图被 `relay` 转进真群(`onebot_timeline` 12:07:49/12:07:50/12:07:52、`onebot_capture` 那条 `decision: relay`)。三处修法:① **工具面只暴露"哪个下游"**——`onebot_invoke` 的 `linkId` 收窄成 `mode=action` 专用,新增 `downstream`(逻辑下游名/备注名/账号/目标键)与 `allow_real_link`;`onebot_relay_probe` 的 `link` 参数删除,同样换成 `downstream` + `allow_real_link`;② **路由归枢纽**——新增 `hub.pickInjectLink({downstream, allowRealLink})`:名字命中后先展开到该下游的**全部物理链路**(真实链路与探针链路共用 `downstreamId`,只按命中的那一条走会漏掉探针链路),再按 `probeIsolation` 挑:`link` 档一律探针链路、其它档真实链路;`#pickInjectLink` 把老参数 `linkId` 也只当"下游名"解释(非 `link` 档才允许按链路键精确投递,测试与高级用法用);`allowRealLink: true` 是唯一能打真实链路的入口,结果里必带 `notIsolated` 告警;选不中下游时抛中文错并列出有哪些(`describeDownstreams()`);③ **多下游记录带人话名字**——新增 `hub.labelOf(linkId)`(备注名 → 逻辑下游名 → 目标键,探针链路加"(探针)"),时间线条目带 `linkLabel`、捕获/回合带 `downstreamLabel`/`responderLabels`、`onebot_hub_status` 每条链路带 `label`;原来的 `linkId`/`downstreamId` 原样保留(过滤、对账要用)。顺带修了**下游媒体不解析**:下游 `send_*` 里的图/语音/文件走 `#resolveDownstreamMedia()` 落地成 durable ref(文本补 `(已存为 hub-media:xxx)`、按需交给视觉模型看一次、**不收进表情包库**),捕获账本里的内联数据用 `stripInlineMedia()` 换成一行占位说明——真机上见过一次 101KB 的捕获结果(整段 base64),现在看到的是引用。**转发帧一个字节都不动**。回归:`test/isolation.mjs` 32 → **38**(点名真实链路仍走探针链路、`allowRealLink` 才打真实链路且带告警、点错下游名报错、`labelOf` 分得清主/探针、下游 base64 图片落地 + 捕获账本不再塞 base64),`test/load-check.mjs` 60 → **63**(工具面:`downstream`/`allow_real_link` 必须在、`linkId` 必须注明只对 `mode=action`、旧的 `link` 必须删掉、`hub.labelOf`/`pickInjectLink` 必须在、指导段要提记录里的名字与媒体引用)。 - **原始报文全量缓存 + 索引、聊天记录不展开、超期自动清理(`m03065` / `m03091` 用户要求)**:用户原话(`m03065`):"收到的所有消息(包括解析不了的)都应缓存下来并记录索引,并允许agent在需要时尝试使用自己的工具进行解析。聊天记录也应当缓存下来但不展开也不解析,当agent需要解析时再由agent操作。" 追加一句(`m03091`):"缓存超过一定天数自动清理即可"。做法:① **L1 行里直接带原文**——`RecallStore.rowOf()` 现在存 `payload`(整条事件/action 的原始报文)与 `mediaRefs`,内联 base64 用 `stripInlineMedia()` 换成一行占位(字节本来就在 `media/blobs` 里),所以"认不出的段类型"不再只有一个 `[type]` 占位:**原文、字节、索引三样都在**;② **索引与取件号**——`onebot_timeline` 每条带 `rawRef`(= L1 条目 id),新工具 `onebot_raw` 不带 `ref` 时给索引视图(可按会话/类型/方向/时间窗过滤,报出 `segmentTypes` 与 `mediaRefs`),带 `ref`(条目 id 或 OneBot 消息 id)时回**完整原文 + 这条已落地的媒体(含 `media[].blob` 本地路径)**,agent 拿自己的工具读它(换格式、OCR、解包转发节点…);③ **聊天记录不展开也不解析**——合并转发段只留 `[合并转发 N 条(id …):内容未展开]`(原来会给前 5 条节点摘要,那本身就是替 agent 做了解析),`com.tencent.multimsg` 多消息卡片同样只留 `[聊天记录卡片:内容未展开]`;要里面的内容由 agent 取原文或调 `get_forward_msg`;④ **保留期**——新配置族 `retention`(`days` 默认 7、`cleanupIntervalMs` 默认 6h):启动时跑一次、之后周期跑 `hub.pruneOld()`,删掉超期的**日文件 + 内存/索引里的旧行 + 媒体 blob**(三样一起动,免得"重启前还查得到、重启后就没了")。`days: 0` = 不清理,最近一次结果记在 `onebot_hub_status` 的 `raw.lastPrune`。回归:`test/smoke-core.mjs` 56 → **59**(原文缓存与按 ref/消息 id 取回 + jsonl 里也有原文、聊天记录不展开、保留期清理三样),`test/load-check.mjs` 工具数 27 → **28**(默认配置实例 26 → **27**)、schema 叶子 135 → **137**。代价说清:原文进 jsonl 会让时间线文件变大(内联数据已剥离),默认只留 7 天——要留证据就当场处理,或把 `retention.days` 调大。 - **上下文不再随每轮用户消息重复注入;激活结束清空会话转录(用户定案)**:用户问"每次唤醒会不会把上一次唤醒时的上下文重复注入一遍"。查证结论:**system prompt 侧不会重复**——`AgentPool.ensure()` 命中 `#agents` 里已有条目就直接返回(`lib/agent/pool.js:57-62`),`setup` 只在会话首次建立/领养时跑一次,`onebot-hub:reply` 那段是静态字符串;**但每轮的用户消息原本就是整份装配结果**(`mind.js` 的 `pool.wake({ text: snapshot.text })`,而 `snapshot.text` = guidance/persona/energy/memes/来源头/摘要/能力/**最近 40 条窗口**/记忆/人物/群/cues),宿主又把历史用户消息全留着 → 滑动窗口跨轮重叠、摘要与窗口重合、旧上下文还会排到 system prompt 那个"最近窗口"**前面**。三处修法(用户定案"其它部分按你说的改" + "每次激活状态结束后直接落盘记忆并清除会话记录"):① **易变上下文改走会话级 live section**——`setup` 里注册 `systemPrompt.context({ name: 'onebot-hub:context', order: 100, text: () => mind.liveContext(sessionKey, { actorId }) })`;宿主的 `text` **支持函数**(asar 里 `sandbox:policy` 就是 `text: (context) => …`),于是每次装配 prompt 时现算、永远最新,且**不进会话记录**;`mind.liveContext` 走 `snapshot(..., { audit: false })`(一次回合可能装配多次,全记审计会把账本刷满)。② **用户消息只放本批新消息**(`Mind.#batchText`:`【新消息 N 条】会话 …` + 几行 `谁:内容`),转录里因此只剩真实对话。③ **激活结束交还会话**——`#backToDormant` → `#retireAfterDormant`:先 `hub.flushStorage()`(卡片与观察条目冲盘),再 `pool.retire(agentKey)`——**先 dispose**(否则它握着会话写句柄,下一轮 create 会撞 `already owned by an active write handle`),再 `hostRef.retire`:依次试 `sessions`/`sessionPersistence`/`agents` 上的 `delete`/`remove`/`deleteSession`(能删就删、会话 id 保持稳定),**删不掉就换一个会话 id**(`.a<时间戳>`)让下一次激活 create 出空转录的新会话(宿主没暴露删除接口时旧会话文件会留着);同时把该 agentKey 的 `#feed` 与回复缓冲清掉——新会话的事件 `seq` 从 1 重新开始,不清会被 `noteSessionEvent` 的 seq 去重当成旧序号全丢。回归:`test/m15-e2e.mjs` 171 → **173**(上下文改从 `mind.liveContext()` 断言、用户消息只含本批、激活结束 `pool.stats.retired`/`disposed` 记账、**新会话仍能被重新叫醒**),`test/load-check.mjs` 63 → **64**(setup 必须注册 `onebot-hub:context` 且它的 `text` 是函数、`hub.pool.retire` 必须存在)。 - **真机复验上一条改动:新装配生效了,顺手抓出两个老 bug(用户"试了一下,你看看情况")**:① **生效的证据**——`agents.retired: 2`、`pool.lastRetire: { sessionId: 'onebot-hub:group%3A617770183', removed: false, rotated: true }`(宿主没暴露删除接口,走了**换会话 id** 那条路;新 id 在 `lastTurn.unarchive.sessionId` 里看得见,形如 `…amuy56any`),而 `lastTurn.reason: 'mentioned:at'`、`spokeFrom: 'assistant-text'`、`sent: true` —— **交还之后新会话照样被叫醒、照样回话**;`raw.rows: 769`、`retentionDays: 7`、`lastPrune` 真跑了;agent 也确实用上了 `onebot_raw`(取回了 `tk7av5` 的原文)。② **bug:真昵称永远学不到**——现场 `agents.identity.nicknames: []` 而 `capability.impl.nickname: 'test'`。两个原因叠在一起:(a) `index.js` 的身份探测读的是**信封**(`hub.callUpstream()` 返回 `{status, retcode, data, echo}`)却写 `info.user_id` → 恒 `undefined` → 整段被跳过;(b) 账号早已在握手里学到,于是 `learnUpstreamAccount` 走 `reason: 'known'` 直接返回,**昵称根本不会通知编排层**。后果很具体:at 段能叫醒、**手打 `@真昵称` 叫不醒**(事故 #11 要修的就是这条,当时只改了条件、没拆信封)。修法:解包 `callUpstream().data`;`learnUpstreamAccount(selfId, source, nickname)` 让**昵称单独同步**(新增 `#notifyIdentity`,账号与昵称分开给,任一后到都要传下去);`probeImplementation()` 的身份探测也把 `login.nickname` 喂给编排层;`index.js` 的 hook 改成 `({ selfId, nickname }) => mind.setIdentity({ … })`。③ **bug:聊天记录(合并转发)取回太难用**——`lastTurn.diag.toolCalls` 里 agent 为了一坨聊天记录**试了四次才成功**(`onebot_media{file}` → `1200` 超时、`onebot_call{message_id}` → `1200`、`onebot_call{id}` → `1400`、最后 `onebot_call{id, timeoutMs: 90000, refresh: true}` 才 OK):各家实现端参数名不同(LLOneBot 只认 `id`),而 `onebot_media` 只传 `message_id`、默认超时 30s 又比这种大请求的实际耗时短。修法:`get_forward_msg` **同时给 `id`/`file`/`message_id`**(实现端会忽略多余的键)、默认超时提到 **60s**、说明里写明"可能几十秒、30s 会 1200";`onebot_raw` 找不到时对**长串不透明 id** 补一句"这像合并转发/媒体的 id,聊天记录用 `onebot_media(get_forward_msg)` 取"(agent 当时正是这么白试了一轮)。回归:`test/m15-e2e.mjs` 173 → **174**("账号已在、昵称后到"必须经 hook 传到判定层;后又在 177 里加了"下游消息进上下文/进本批"两条),`test/smoke-core.mjs` 59 里加了一条断言(长串 id 的提示语,不开新用例),`test/load-check.mjs` 64 → **65**(`onebot_media` 必须收三个参数名 + 说明里的 60s)。**另记一笔(未修,待观察)**:这次重启后 `vision.failed: 1`、`lastError: "视觉模型没有回任何文字"`(此前缓存里已有 9 条成功描述),像一次偶发空回复;再出现就按它查。 - **重启之后新消息又被塞进旧会话(用户实测,已修)**:用户报"重启以后新消息会被放到旧的会话里面"。根因是**会话 id 的缓存只是进程内的一张表**:`chosenSessionIds`(`lib/index.js`)在重启后是空的,于是 `sessionIdOf(agentKey)` 回退到默认 id `onebot-hub:`,而 `hostRef.hasSession()` 拿这个 id 去 `sessionPersistence.list()` 一查——**上一轮那个会话还在**,于是 `resume` 把**旧转录**读了回来("激活结束清转录"这件事只在本进程内成立)。修法:① `sessionIdOf` 第一次用到就生成**带时间戳 + 随机后缀**的新 id(`onebot-hub:.a<时间戳><随机>`)并缓存,同一激活内复用;② `hostRef.hasSession` 改成**恒 false**——既然每次激活的 id 都是新的,"查到旧会话"就只可能是误用,直接 create 空转录的新会话(撞 id 的概率可忽略,万一撞了 `ensure()` 里还有"create 抛 already exists 就回退 resume"那条兜底);③ `hostRef.retire` 无论删没删掉会话都**清掉缓存项**,下一次激活现生成新 id(顺手试的 `delete`/`remove`/`deleteSession` 保留:宿主哪天暴露了就用上)。代价如实说:宿主会话库里会攒下"每个激活一个会话",且**没有删除接口**,它们可能出现在宿主的会话列表里(枢纽侧不再读回它们,只占磁盘)。回归:`test/load-check.mjs` 里"唤醒一次 agent"那条断言改成"会话 id 必须是每次激活现生成的新 id(`/^onebot-hub:group%3A55555\.a[0-9a-z]+$/`)且 `resumed` 必须为 0"。 - **会话按群名 + 时间命名、旧会话直接归档(用户定案)**:用户要求"会话按群名+时间命个名吧,然后旧的会话直接归档即可"。① **命名**:`hostRef.create` 建完会话即调 `sessions.rename(sessionId, title)`(宿主里那条 "Rename a Session and update its title projection without opening its history",已从 asar 里确认:一个 `rename(sessionId, title)` 返回 `{ok, value:{title, seq}}` 的 cordis 服务),标题形如 `群 今日小猪群(617770183) · 2026-10-07 13:24`——名字取自枢纽自己的**档案**(`hub.profiles.group(key).name` / `person(uid).name`,都是观测来的),取不到就退群号/QQ 号;**改名叫不 await、失败只留痕**(`onebot_hub_status` 的 `host.lastTitle`),绝不因此挡住唤醒。② **归档**:`hostRef.retire` 改成调 `workspaceRegistry.archiveSession(id, { stopActivity: true })`(宿主自己的归档通路:收进归档集合、不再占活跃位),成了就清掉会话 id 缓存,下一次激活现生成新 id;归档接口拿不到时才退而试 `delete`/`remove`/`deleteSession`(现在宿主没暴露删除)。相比上一版"删不掉就把旧会话留在列表里",现在旧会话会被**归档**,列表干净得多。回归:`test/load-check.mjs` 65 → **66**(假 `sessions`/`workspaceRegistry` 记录调用:`rename` 必须是本次激活的会话 id + 标题含人话名字与时间;`archiveSession` 必须带 `stopActivity: true` 且目标就是刚才那个会话)。 - **下游 bot 的消息没进会话 / 窗口里出现空的"下游 bot:"(用户实测,已修)**:用户报"下游bot发的消息没能进入会话"。查现场发现两处:① **枢纽自己的账被当聊天渲染**——`onebot_memory` 这类动作在时间线里是 `direction: hub-in, linkId: agent:memory`(没有文本),`renderWindow` 以前照单全收,于是窗口里出现 `22:51 下游 bot:`、`22:59 下游 bot:` 这种**空行**(用户看到的"下游 bot 发言"全是空的,真正的下游消息反而被淹没);② 下游条目的说话人一律写成泛称"下游 bot",多下游/探针链路时分不清是谁。修法(`lib/memory/assembler.js` 的 `renderWindow`):**只渲染"这个会话里真实发生过的话"**——跳过 `linkId` 以 `agent:` / `hub:` 开头的内部条目(工具调用、探针窗口、记忆写入…)与**没有文本**的条目;下游条目的说话人改用 `refs.downstreamLabel`(带"(探针)"后缀)。顺带修了 `mind.snapshot()` 的**来源头**:它以前取"窗口最后一条条目的 `linkId`",最后一条恰好是 `agent:memory` 时就写出 `【来源】… 链路 agent:memory` 这种莫名其妙的东西——现在取"最近一条**真实会话**条目"的链路,取不到退 `hub.upstreamLinkId`。此外让下游消息**进本批**(`Mind.noteDownstreamSend`:激活态下推进批量窗口,下一次唤醒的"本批新消息"里带 `下游→<名字>:…`;**不叫醒**——"下游已应答 → 保持沉默"不变;休眠态不入批,反正它已经在上下文窗口里)。回归:`test/smoke-core.mjs` 里 `renderWindow` 那条加了"内部账不进窗口、不许有空行、下游消息带人话名字"三条断言;`test/m15-e2e.mjs` 174 → **177**("下游消息进会话上下文且带名字不空行"、"下游消息进本批但不自己叫醒模型"、以及来源头不再出现 `agent:memory`)。 - **移除社交能量与自带扫描器(用户要求)**:用户原话"把社交能量系统移除吧,还有插件的扫描下游的工具也可以移除了"。删掉的:`lib/energy.js` 整个模块(含"每会话一个 `[0,1]` 值、按会话语义漂移/回归/被 @ 加成/代调成本"的整套机制)、配置族 `energy.*`(8 个键)、prompt 里的【社交能量】段(装配器的 `energy` 槽位与 `DEFAULT_BUDGET.energy`)、`/energy` 命令与 `/status` 里的那行、`/reset` 的"社交能量回初值"、`onebot_invoke` 的**代调能量闸**(`hub.energy.tryInvoke`,失败时"拒绝代调"的分支)、`lastTurn.energy` 诊断字段;以及 `lib/scan.js` 整个模块与 `onebot_scan_code` 工具、配置族 `scan.*`(7 个键)、`buildHubGuidance()` 里"自带扫描器"那一段、`onebot_hub_status` 的 `code.scanTool`。留下的:`code.scopes` + `onebot_code_scopes`(源码**位置**照旧按需取,读文件用 agent 自己的工具)。`onebot_relay_probe` 的说明里"依据 `onebot_capabilities` / `onebot_scan_code` 那张表"改成只提前者。测试:删掉 `test/energy.mjs`;`test/smoke-core.mjs` 删掉"代码静态面"整个用例(59 → **58**);`test/m15-e2e.mjs` 删掉社交能量的三项断言(177 → **176**,被 @ 那一轮改成只断言"唤醒 → 调 onebot_reply → 真发出去");`test/chat-commands.mjs` 删掉 `/energy` 用例与相关断言;`test/load-check.mjs` 删掉"自带扫描器"用例、`Config.dict.energy`/`scan` 的抽查与 `scan` 配置,工具数 28 → **27**(两个实例现在都是 27),schema 叶子 137 → **122**。旧的 `energy/state.json` 留在 `/` 下没人读了(可自行删)。 - **注入上下文自查与修复(`m02432` 用户要求"看一下会话日志,我感觉注入的上下文有很多问题",`m02678` 定案)**:拿真实会话日志(`session.v4.jsonl.zstd`,`test/_dump-session.mjs` 解)逐条对账,改掉十处:① **时钟搬出 live 段**——`buildSourceHeader` 原来带一行 `【现在】YYYY-MM-DD HH:mm`,而宿主只在**整份文本变了**的时候才追加一份 runtime-context 快照(`RuntimeContextProjection.project`:`if (this.retained?.text === snapshot) return;`),分钟精度的时间戳于是让每次回话都把 3613 字符的快照重注一遍(实测 3 分钟一轮 = 4 份,`surfaceOp` 全是 `append`、没有一次 `replace`)。现在时间进每批新消息的头(`Mind.#batchText`:`【新消息 N 条】会话 …|现在 …`),live 段只在真变时才追加。② **记忆/表情包预算**:`DEFAULT_BUDGET` 的 `memes` 500→700、`person` 800→1000、`memory` 1200→2000,且 `renderMemoryBlocks` **逐条**截到 200 字符——以前整段切会把长期记忆/人物卡整段挤没(实测 `truncated:[{memes,644},{memory,676}]`)。③ **下游说话人用人话名**:`renderWindow` 对 `direction === 'downstream-in'` 优先 `refs.downstreamLabel`(含"(探针)"),不再显示 `127.0.0.1:8080`。④ **来源头的链路只认上游**:`linkId` 取最近一条 `upstream-in`(以前取"最近一条真实条目",下游一发言来源就写成 `down:…`)。⑤ **`onebot_raw` 看得到媒体**:`RecallStore.rowOf()` 在 `append()` 那一刻就把 `mediaRefs` 冻住了,而图/语音是**之后**才异步落地的 → 新增 `patchMedia(id, ids)`(改内存行 + 重写当天那份 jsonl),`hub.#resolveMedia`/`#resolveDownstreamMedia` 落地后回填;回归 `test/patch-media.mjs`(5 项)。⑥ **`onebot_media{list:true}` 逐条截到 160 字符**(实测一次回 49933 字符,够顶爆一轮上下文)。⑦ **`onebot_memory` 的 `ops` 收对象/字符串/双重编码**(新增 `coerceOps()`,最多连解三层并剥 ```json 围栏),`writer.js` 的拒绝理由改成"收到的是什么"——真机上模型把对象 stringify 了两遍,只回一句"指令必须是对象"它下一轮还是照样错。⑧ **会话改名走 `workspaceRegistry.rename(sessionId, title)`**(认 id 的那条,回 `{ok, value:{title, seq}}`);`sessions`/`sessionTitle` 的 `rename()` 要一个**活着的 Session 对象**,拿 id 调会抛 `session "undefined" is not live in this store`——真机就是这条把会话名一直卡住的。⑨ **工具面默认精简**(用户定案"保持现有预设 + `tools.restrict({allow})` 白名单,第三方插件的工具都不要加"):新增 `onebot_tools`(`list`/`enable`/`disable`/`all`/`slim`,**只对本次激活有效**),`wrapSetup` 在 `presetsSvc.mount()` 与逐会话 `setup()` **之后**挂白名单——此时预设带来的工具才都在 `agentCtx.tools.schemas()`(省略 scope = 全局视图)里;默认放开本插件全部工具(**`onebot_reply` 除外**:它是 setup 里注册在 agent 作用域 own 层的,写进 `allow` 会让 `restrict` 直接抛"scope-local 名字")+ 11 个 DSH 原生工具(`read`/`grep`/`glob`/`read_image`/`pwsh`/`job_list`/`job_kill`/`job_output`/`web_search`/`web_fetch`/`todo_write`),第三方(`task_board_*` 17 个=`@linxin666/dsh-web-all/task-board`(+github)、bili 的 `acp_cache`/`acp_status`/`compress`/`decompress`/`search_context`)与一批重型原生工具(`subagent*`/`workflow`/`plugin_manager`/`write`/`edit`/`create_goal`…)改为按需。账:工具数 76 → 38,`request/header` 69592 → 约 24500 字符(那 69592 里 99.7% 就是工具目录:description 23718 + parameters 41250)。诊断进 `agents.lastSetup.toolFace`(`slim`/`keep`/`universe`/`allow` 或 `error`——精简失败**不挡这一轮**,模型看到全部工具也能聊)。⑩ **纯文本昵称不再唤醒**:`detectMention` 只剩 `at`/`reply`(`visibleText`/`matchesName` 一并删掉),`nickname`/`nicknames` 仍接收但只做展示;回归 `test/m15-e2e.mjs` 改为断言 `[]` 且 `@` 仍然唤醒。 - **记忆/人物去截断 + 表情包改成 agent 自管(`m22950`/`m22989`/`m23141` 用户定案,方案 v2,`m23230`"开干吧")**:用户原话"直接把截断去掉。表情包的保存方式不对,应该参考 nonebot-plugin-aigf 让 agent 决定是否保存并让 agent 自己写表情包的简要介绍。可以给工具让 agent 管理表情包,允许 agent 根据对话内容更新表情包介绍、新增、删除表情包,prompt 默认只显示表情包简介,当 agent 需要时可以查看单个表情包的完整描述。"四步落地:① **渲染侧截断全删**——`assembler.js` 的 `renderMemoryBlocks` 去掉逐行 200 截断与"每块只渲最后 12 条",`renderPerson` 的 facts/commitments/corrections 不再 slice,整段也不再被 `clip()` 砍尾(短期在前长期在后的排布下,原来的 `slice(0,2000)` 永远先腰斩长期记忆);写入侧改成**超限 rejected 让模型改短**(`MAX_MEMORY_TEXT` 400 / `MAX_FACT_TEXT` 300,不再静默 clip),条数不设限;`memes` 行仍一行式(`describeMeme`),段预算 700→400。② **图片打标**——`vision.js` 走结构化输出(首行"表情包/图片"判定 + 表情包时给 3 个情绪词),协议 `sub_type=1` 并进请求当强信号,按 sha256 惰性重判;`segments.js` 按判定渲染 `[可能是表情包|情感:…|内容:…]`。③ **表情包 agent 自管**——`MemeStore` 条目增 `brief`(进清单的一句话,agent 必填)、`description`(完整描述,不再截 200,`get` 才看得到)、`savedBy`(老条目无此字段**载入即清**、图片文件删掉、seq 保留 id 不复用);`onebot_memes` 增 `action:'get'` 与 `brief` 参数,`add` 缺 brief 直接拒绝、description 兜底链 `agent 填的 → 消息已生成的图片描述 → 空`、`hub-media:` 走 `readRef()` 取字节;`update` 可改 brief。④ **删自动收集**——`hub.#enrichImages` 的纯表情判断(`#isStickerLike`/`minBytes`)整块移除,配置 `memes.minBytes` 删除,`memes.autoCollect` 语义改成**总闸**(false = 连 agent 也不许收录;true = 只有 agent 能收录),`onebot_media{save:true}` **废弃**(schema 与分支删除),prompt 静态层加 aigf 式"值得才收"收藏判断(值得反复用的梗图/语气图 + 库里没有类似的才 `add`;随手截图、风景照、bot 自己生的图不收;正文里绝不出现 id),guidance 的 `memes.*` 一行与空库文案同步改。回归:`test/memes.mjs` 16(+老条目清理、seq 保留)、`test/m15-e2e.mjs` 177(自动收集两用例改成 agent 语义)、`test/load-check.mjs` 65(add 缺 brief 拒绝、`get` 无 id 报错),全部套件绿。 - **下游 bot 的合并转发应答进得了批次,但内容不展开(`m28267` / `m29922` 用户定案"下游bot的消息的处理和群友的消息用相同的方法")**:真机事故——下游回 `send_group_forward_msg` 时内容在 `params.messages`(node 数组)里,而动作路径只认 `params.message`,整条提不出文本,空 body 被 Mind 的挡批闸吃掉,【新消息】里永远看不到下游应答过。修法:① `protocol.js` 新增 `segmentsFromParams()` 共享入口——`send_msg` 形状走 `params.message`(数组/CQ 字符串),`send_*_forward_msg` 形状只包一个 forward 占位段(`[合并转发 N 条:内容未展开]`,**与群友聊天记录同待遇**;节点原文挂在 `data.nodes` 上,agent 要看就自己 `onebot_raw` 取原文或调 `get_forward_msg`);② `stripInlineMedia()` 抽出 `stripSegments()` 并跟到 `params.messages` 这层(节点里同样可能带几百 KB 的 base64);③ `#resolveDownstreamMedia` 按需摊平节点内容**只用于媒体落地**——字节进了 blob,`stripInlineMedia` 占位里"已落地成媒体引用"才是真话,文本重渲染仍走占位段不会把内容摊回正文(`renderCq` 不吃 forward 占位段:nodes 会被序列化成 `[object Object]`)。同一批的显示层卫生:下游动作发言人不再亮 `downstream-plugin`(优先 `downstreamLabel`)、会话卡滤空文本行、开局快照的存量卡分支同步滤空、"重启前的旧事"措辞改准确。回归:`test/smoke-core.mjs`(segmentsFromParams 两种形状 + strip 跟进转发节点 + 卡片滤空,61 不变)、`test/m15-e2e.mjs`(scenarioDownstreamForward:时间线/批次里是占位、`岀猪车` 不出现、params.messages 原文留在时间线,181 不变)。 - **会话白名单:不同群/私聊各自能用不能用(`m31030` 用户定案)**:`agent.groups`(群号)/`agent.privates`(QQ 号)两张 JSON 数组名单,**白名单制**——没列出的会话一律不调模型(`@` 也不理);条目只认 `id` 与 `mode`(可省 = 跟随全局 `agent.mode`,写了就只覆盖那一档,批量节奏等仍用全局)。闸在 `observe()` 最前面:`stats.unlisted` 计数、`agents.lastObserve.reason:'unlisted'` 写明为什么不理、不建批量窗口不进唤醒状态;`noteDownstreamSend` 与 `active` 节拍同闸;`scanDue` 对已移出名单的会话顺手清窗。**两张名单都没配 = 功能未启用**(不过滤),配了任意一张即严格白名单(另一类没配 = 那一类全拦);设置页两键都是**行编辑器**(加行/模式下拉/取消勾选=这条不生效),与 `downstreamTargets` 同一套交互。部署配置已顺手把群 `617770183` 加入名单(跟随全局)。回归:`test/smoke-core.mjs` 纯函数与 Mind 闸两例、`test/m15-e2e.mjs` 新增 `scenarioWhitelistGate` 五连(未列出群/私聊 @ 不理、observer 覆盖压过 @、名单内照常唤醒)。 - **激活收尾防误杀:agent 手头有活时空转超时顺延(`m30055` 会话取证后定案,只做方案 1+2)**:实测 4 个被归档截断的激活会话全是两类"自证行为"——**链路取证癖**(`onebot_timeline`/`onebot_capture`/`onebot_turns` 连环查"下游到底收到没",一个激活 30 次调用)与**看图不信任**(`onebot_tools` 把被工具面精简裁掉的 `read_image` 自己开回来,甚至起 subagent 读 blob 文件);回合被拖过 `awakeMs`(激活空转上限,默认 5 分钟)后照常归档,宿主 `stopActivity` 把在飞回合连同 subagent 一起掐掉(真机见到 `Error: subagent run was cancelled`)。两层修法:① **防误杀**——`AgentPool` 新增 `isBusy()`(排队中与在飞的都算忙;`#pending` 计数在 `enqueue` 进、`finally` 出,自清;`rec.busy` 只盖得住已开跑的,盖不住排队的),`mind.#expireAwake` 对手头有活的 agent **顺延**到下一拍再判,活干完自然超时回休眠——守卫只顺延、不放飞;② **指导段对着掐点行为下药**——"排查类问题要快出结论"(`onebot_turns` 带 `withOutcomes` 一轮就能回答,只有解释不了才补一次 `onebot_timeline`/`onebot_capture`,两三次调用内必须给结论,别连环追,群友还在等回复)**不限识图档**;"更不要绕道看图"(禁用 `onebot_tools` 把 `read_image`/`read` 开回来、禁为看图另起 subagent)**仅在 `vision.mode` 为 `describe`/`off` 时**进指导段(`segment` 档模型看得见图,禁令不该出现)。回归:`test/m15-e2e.mjs` 新增防误杀四连断言(忙时顺延不归档、忙完超时正常回休眠,181 → **185**),`test/load-check.mjs` 补指导段两档断言(排查纪律恒在、绕道禁令只在 describe/off)。 - **发言只走工具 + hub-media 图片解析 + 发送失败要交代 + 二次元三道门(2026-10-09,真机事故 #9/#10,`m30282`/`m30383`)**:真机当晚三件事——`onebot_reply` 带的 `hub-media:` 图片引用被**原样**发给上游,LLOneBot 认不得 → `send_msg` `failed -1935986436`,而工具早回了「已排队回复」,模型以为自己发出去了;`/status` 的截图(真实照片)也被跑角色识别,渲染出 `[角色识别: 未能识别]`;还有一句 35 字旁白「(不回复——探针那条就是我刚刚已经转发进群的内容,下游已应答,不重复)」漏进了群。修法四件:① **`onebot_reply` 的 `images` 先解析再排队**:`hub-media:` 引用查媒体库换成 blob 本地路径(与 memes 同待遇),查不到就**当场报错**(`图片引用不可用:…`)——"已排队"必须是真话;② **发言只有一条出口(用户定案,原话:"收尾发送的文字不要进对话,改成仅能通过工具发送消息吧。在prompt中说明清楚这一点。")**:`agent.speakAssistantText` 默认 `false`,普通回复文本只是内部草稿,`isSilentMarker` 保留只服务显式开兜底的逃生门;`onebot_reply` 的描述与 hub 指导段、默认指导词三处都写明"不调工具就是沉默";③ **发送失败要交代**:`deliver` 失败记一次性 `#sendFailures`,下一批的开头先插一行 `【系统提示】你上一轮排队的回复没有发出去(status=… retcode=… error):「摘要」…`,取走即清——模型必须知道"没发出去",才能选择重试或沉默;④ **二次元三道门(用户定案)**:看图结构化输出加「二次元:是/否」行(真实照片/截图/界面/文字图都算否),判否**不问**角色识别(判定缺失照旧问,宁可多问不漏判);渲染侧置信度降序后只取 top-1(`anime.maxCharacters` 默认 3→**1**),没认出渲染**空串**(不再 `[角色识别: 未能识别]`);"答了但没认出"也落缓存防重问;门①上线前的老缓存条目缺 `anime` 字段 → 惰性重判一次再落缓存。回归:`test/anime.mjs`(没认出=空串、置信度排序截取、`maxCharacters` 默认 1、没认出落缓存)、`test/m15-e2e.mjs`(门①判否不问后端/判是照常、失败提示一次性且详情齐全、默认策略文本兜底不发言+显式开兜底照旧)、`test/smoke-core.mjs`(「二次元」行解析:全角冒号/括号说明容忍、缺行 null、认不出的值不给假判定、惰性重判+重判落缓存)、`test/load-check.mjs`(hub-media 解析成 blob 路径、失效引用当场报错)。 - **发送失败的消息不算聊天记录(`m30859` 用户报"之前发送失败的消息也进聊天记录里了")**:修3 让模型知道失败之后,反过来暴露了记账面的问题——`mind.deliver` 照旧把失败条目记进时间线(这层**保留**,排查要用),但 `timeline.onRecord` 四路分发里三个消费面把它当成了"我说过的话":① `renderWindow` 渲染成 `我:…`,下一轮窗口里模型看到自己"说过"这句话,与【系统提示】的"没发出去"自相矛盾;② `cards.isCardWorthy` 的 `action.startsWith('send')` 把 failed 的 send_msg 收进会话卡并**落盘**,重启都还在;③ `onebot_recall` 把它当聊天记录搜出来。修法:三个消费面按 `direction === 'hub-out' && decision === 'failed'` 过滤(时间线与 `onebot_raw` 原文不动);`RecallStore` 新增 `verdictOf(id)` 反查一条的裁决、`search` 跳过失败行并在 `failedHidden` 里报数(note 注明"原文用 onebot_timeline / onebot_raw 查");`Cards` 新增 `pruneLinesWhere(fn)`,启动时(`hub.purgeFailedSends()`,boot 里 fire-and-forget)按裁决把存量卡里的失败行清掉——这些行是老代码写进卡并落了盘的。回归:`test/smoke-core.mjs`(检索跳过/原文仍在/裁决反查/窗口不渲染 + 不入卡/存量可清/messageCount 下修,61 → **63**)、`test/m15-e2e.mjs` scenarioSendFailureNotice(窗口与会话卡都不含失败的话)。 - **激活期插话:新消息直接递进正在进行的回合(`m31311` 用户定案,默认开)**:用户原话"我觉得应该允许新消息插话才对,激活后批量窗口触发下一轮后直接插话,在prompt中告诉agent,在思考时可能会有新消息发来,可以根据情况判断。"实现走宿主两个收件箱的区别(asar 实测):`followup()` 排 `next-turn`(**只能等回合结束**),`steer()` 排 `next-step`(下一个步骤边界被认领、拼进**同一回合**;运行时上下文每步重新投影,插话那步模型就看得见最新窗口与记忆;官方示例 `handle.agent.steer({content, source})`)。四处改法:① **`AgentPool.steer(agentKey, {text, summary, parts})`**——只对**在飞**会话生效(`rec.busy` 由 `wake()` 置位/`finally` 清位,两者之间无异步间隙,所以"刚好收尾的瞬间"这种竞态不存在),宿主没有 `steer` 或递送抛错一律返回 `null`;成功返回 waiter(`() => agent.whenIdle()`),`wake()` 与 `steer` **共用同一条 `#userMessage()` 构造路径**(内容段数组 + `source.kind:'onebot-hub'`——传裸字符串模型那边就是收到一坨没头没尾的东西);② **`Mind.#trySteer()`**——`flush()` 在 `#flushing`(回合在飞)时不再返回 `batch:in-flight` 干等,而是把整批渲染成 `【插话 N 条】会话 …|现在 …` 递进去,**成功才 `batch.flush()` 排水**(递不进去消息留在窗口里,一条都不丢);③ **结算只归主 flush**——`whenIdle()` 覆盖插话延长的那几步,所以 `reply.take()`/`deliver`/`#noteSpokeOutcome`/`turnSeq` 仍由主 flush 一次做完,插话路径**不碰**回复缓冲与账本(否则会双计"沉默轮"把激活误打回休眠);一轮里多次 `onebot_reply` **后写覆盖**(想改口就再调一次,最后一次生效);④ **激活空转不误杀**——`#expireAwake` 对 `steering` 在飞的会话这一拍先不过期(回合被新消息拖着跑不算空转),`stats.steered`/`lastSteer`/`statsSnapshot.steering` 记账可观测。静默闸口径与主 flush 一致:下游刚答过就**不插也不排水**,留给下一轮正常结算。指导段新增一句"思考时可能有新消息送到,自己判断要不要改口"。回归:`test/m15-e2e.mjs` 新增 `scenarioSteerInterject`(假宿主用"账本 + 游标 + 单飞行驱动器"复刻认领语义:脚本卡在第一步 → 第二批走插话 → 断言**首批候选没提前出站**、驱动器按 `['turn','steer']` 顺序认领、整轮只出站一次且是插话后的回复、窗口与时间线不丢账,199/199)、`test/load-check.mjs`(`agent.steer` 默认 `true`)。 - **一次 `onebot_reply` 发多条(`m32388` 用户提问 → `m32420` 四问定案 → `m32513` 补充)**:用户原话"调用一次onebot_reply时应该可以发多条消息吧"——现状是会话缓冲版**一调只排一条**(一条消息内可以塞引用+文字+多图+多表情,但发不出多条独立气泡),一轮调多次则是**后写覆盖**;另有个直发版调 N 次真发 N 条,两条路径行为还不一致。四问裁决:表达方式"**参考一下aigf-master**"(取证:`nonebot_plugin_aigf_master/unimsg.py:398-449` `compose_unimsg_list` 把段数组拆成消息列表——连续 text/at 合成一条、**每张图独立成一条**、`__init__.py:392-417` 连发无间隔无上限且整个 for 包在一个 try 里;**aigf 的"多条"只由图片切分产生,纯文字永远一条**)、节奏"固定小间隔、可配"、上限"默认 3、可配"、失败"**第 k 条失败即停**,剩下不发,下一批开头如实告知");追加两问:文字拆不拆 → "**拆吧,不过@的字段不应该单独拆出来**",上限口径 → "**只限文字条数(图另算)**";`@` 归哪一条由 agent 自己用 `parts` 里的 `{"type":"break"}` 决定(已向用户说明并举例)。实现:`lib/reply.js` 重写并新增导出 `composeReply({text,images,memes,parts,quote,maxText,maxImages})`(返回 `{messages:[{segments,text,kind}],dropped,skipped,usedParts,…}`)与 `describePlanIssues(plan)`,`ReplyBuffer` 改为构造时钉上限(`{maxText,maxImages}`)并保留 `segments/text/images/memes` 旧字段供旧调用点;拆条实现是"原子 → 分组":`pushText(atoms,value)` 把空行分段**之间插 `{kind:'break'}`**(不插 break 就会被"连续文字合成一条"合回去——这是写测试时真踩到的第一个 bug),组内相邻原子之间插**恰好一个空格** `SPACE_SEG`(头尾不插,防两个 text 段渲染端黏成一坨、`@` 后不接空格粘文字),图先 flush 再单开一条(否则图前的文字会被甩成两条、且开头没攒到文字的 `@` 会丢)。发送端 `Mind.deliver` 改成循环:`gapMs` 由 `agent.replyGapMs` 钉(`i>0` 才等)、**每条各记一条时间线**(`refs:{part:i+1,of:n}`)、`ok` 才 `stats.sent += 1`、失败 `break` 并把 `part/of/rest` 写进 `#sendFailures`(下一批开头的提示升级为"第 k/N 条没发出去,后面 M 条也因此没发"),返回 `{sentCount,total,failedAt,results}`,`lastTurn` 同步记 `sentCount/totalMessages/failedAt`(`sent` 改为 `sentCount > 0`——发出去一条就算这轮说过话)。工具侧:两个 `onebot_reply`(会话缓冲版 + 直发版)现在**共用同一个 `composeReply`**,行为一致;`REPLY_TOOL_PARAMS` 加 `parts`,回执多行讲清"拆成 N 条消息(1.文字 2.图 …),按顺序隔一小段发"、超出上限的部分、以及**编造的表情包 id(从静默丢改成明说)**,只有**完全排不上任何消息**才 throw。配置三个新键 `agent.replyGapMs`(400)/`replyMaxText`(3)/`replyMaxImages`(9),设置页有中文名与说明。回归:新增 `test/reply.mjs` **21/21**(纯函数:拆条规则、上限只限文字、超限丢尾部并如实告知、`@` 昵称丢掉且 skipped、`ReplyBuffer` 后写覆盖、`describeReply` 共 N 条)、`test/m15-e2e.mjs` 新增 `scenarioMultiMessageReply`(4 条顺序出站且每条只装自己那段、间隔真的等了、逐条 timeline `refs.part`、`failAt=2` 时只发 2 条且 `sentCount=1/failedAt=2`、下一批提示含"第 2/4 条没发出去/后面 2 条也因此没发",204/204)、`test/load-check.mjs`(直发版"文字一条+图一条 = 2 次 send_msg"、`需要 text、parts、images 或 memes`、三个新键默认值,68/68)。 - **一次 `onebot_reply` 的参数 schema 让整个插件起不来(真机事故 #11,2026-10-09)**:用户重启 DSH 后设置页永远停在"正在读取本插件的配置…"、上下游都没连、插件行挂红标,而**本地 68 项 `load-check` 全绿**。取證路径(DSH 不落盘日志,GUI 的红标文字取不到,所以先给插件加了自取证据的手段):① 排查掉配置/patch 坏值、语法/import、存储文件损坏(43 个 json/jsonl 全可解析)、`lib/index.js` 单独 import 正常,并确认"14514 没监听"= apply 挂在 `connectUpstream()`(`lib/hub.js:1335-1353`)之前,而工具注册(`lib/index.js:2206` 的直发版 `onebot_reply`)**就在它前面**;② 新增 `lib/startup-log.js`:apply 每次装配往 `/startup.log` 追加阶段标记(`config:ready`/`hub:ready`/`mind:ready`/`links:connecting`/`tools:registered`/`apply:done`),抛异常时把 `stack` 写进去,`persist:false` 时不写(测试不污染用户目录);③ 用户重启后日志直接给出答案——`JsonSchemaError: unsupported JSON schema: parameters.parts.items.required is not supported by the value schema DSL`,栈顶是 `@deepseek-ai/dsh-tools` 的 `assertAuthorKeys`。根因:`0820720` 给 `parts` 元素写了 `required: ['type']`,而宿主 DSL 里 **`required` 只在对象属性节点合法**(`allowRequired: true` 只在 property-map 的子节点传入,数组 `items` 与 `oneOf` 分支都是 `false`),属性节点上的 `required` 还只能是 `true` 标记(真数组由宿主编译期生成)。asar 实测的完整规则(`runSchemaCompiler`):注解键 `description/title/default/examples` 到处可写;**对象节点必须显式写 `additionalProperties: true|false`**;数组节点 `type/items`;`oneOf` 不能同时声明 `type`、至少两项;标量可加 `enum`/`const`;`type` 只认 `string/number/integer/boolean/null/array/object/json`。修法:① `lib/reply.js` 删掉 `REPLY_PART_ITEM.required`、补上 `additionalProperties: false`(必填性改由说明文字与运行时容错承担,并在 `normalizeParts` 里把"写了别的 type 又没内容"的段也说出来,不再静默丢);② **`test/stubs/dsh-tools.mjs` 照抄宿主校验**(`defineTool` 里真调 `assertSupportedJsonSchema(definition.parameters)`),这类事故从此在本地就会红;③ 新增 `test/tool-schema.mjs` **15 项**专测门卫本身(11 种非法写法必被拒、常见合法组合必过、插件自己的参数表必过)。写桩时踩到的坑值得记一笔:`authorError` 一开始写成"返回错误",漏一个 `throw` 就等于整套校验静默放行——现在它自己抛。回归:`test/load-check.mjs` **68/68**、`test/reply.mjs` 21/21、`test/tool-schema.mjs` 15/15、其余 18 套件全 exit 0(`test/hub-e2e.mjs` 需真 NoneBot,不在本地验收面)。 - **`/perm` 一直找不到会话(`m33779` 问答带出来的真机 bug,2026-10-09)**:用户问"改完权限下一次激活会恢复默认吗",顺着这个问题查下去发现两个问题。①**不持久**:宿主把权限设在 `Session` 对象上,而 `964ddcf` 之后每次激活都开全新会话——所以"改一次长期有效"本来就做不到;`m33800` 用户裁决**就这样、而且要的就是这个**(原话:"会话的权限不应该持久给,每次会话给一次权限才安全"),于是把语义写进回话(`describe` 与设置成功都明写"只对这一次激活有效,下次唤醒自动回到部署默认")。②**根本改不到**:`perms.sessionFor()` 按**无后缀** id(`onebot-hub:group%3A617770183`)去 `sessions.get()`,而真正在跑的会话 id 每次激活都带 `.a<时间戳><随机>` 后缀 ⇒ 命令只会回"会话建起来了,但在宿主的 sessions 里找不到它,改不了权限"。根因是两张 `chosenSessionIds` 表各建各的:一张在 `perms` 旁边、一张在 agent 装配块里(后者才是真正被写入的那张)。修法(`m33800` 选方案 C:**只修 id 不一致,不做持久化**):把 `chosenSessionIds` 提到外层、`liveSessionIdOf()` 两边共用,`sessionFor` 先查本次激活的 id、没有就 `ensureAgent()` 建起来再查、还是没有就说人话。`test/load-check.mjs` 新增一条(68 → **69**):假 `sessions.get()` **只认带 `.a` 后缀的 id**(问无后缀 id 一律 null,正是真机的形状),断言命令仍然成功、**一次都没问过那个无后缀 id**、`presets.set` 拿到的是带后缀的会话对象、没唤醒过的会话经 `ensureAgent` 也改得到、认不出的预设名只列可选项。回归:load-check 69/69,其余 18 套件全 exit 0。 - **把"权限不够就开口要"写进指导段(`m33950` 用户要求)**:用户原话"另外在prompt中可以说若出现权限不够时可以跟superuser要一下权限"。DSH 的权限是**超管给的**、而且**一次授权只管这一次唤醒**(`m33800`),所以模型侧正确的动作既不是绕路、也不是假装能做,而是**说清缺什么权限、要它干什么,让超管敲命令**。`buildHubGuidance()` 新增一段,两个分支:①**配了 `chatCommands.superUsers`**——教 `<前缀>perm <预设名>`,并给出"只读资料 → `read-only`、要改文件或跑命令 → `workspace-write`"的对应关系,同时写明"授权只对这一次唤醒有效,别指望上次给过",外加"别用 `onebot_tools` 给自己开后门"(与已有的看图绕道禁令同一条纪律);②**名单为空**——整条命令链沉默(不猜谁是管理员),改成"如实说缺什么权限、要它干什么",**一个字都不提命令**(教模型引导群友敲一条没人能执行的命令是净负资产)。顺带修了两处同源的老问题:①那段命令表原来写死 `/help /status … /perm`,用户把前缀改成 `.` 之后就在教模型敲错命令(`m02289` 那条纪律漏了指导段)——现在命令名从 `COMMAND_SPECS` 生成、前缀按 `chatCommands.prefix` 插值,让路前缀那句也从 `!!` 改成"`!!`(可配)"并用实际前缀举例;② `code.scopes` 那段原来让 agent 权限不够就"如实说没权限读"就完事,现在指向"开口要权限"。回归:`test/load-check.mjs` 指导段用例加 8 条断言(`.` 前缀下不许出现 `/perm`、两个分支各写什么、`read-only`/`workspace-write` 要给出、读源码那条必须指向开口要),仍是 **69/69**;其余 19 套件全 exit 0。 - **示例值体检 + CI/发布流水线(`m34284`)**:用户要求查一遍"默认值和配置举例是不是都是编造的值、有没有混进真机上用的值"。全仓体检结论与取舍:①**zod 默认值全干净**(149 处 `.default()` 逐条看过,没有一个真值);②**真值混进 4 处会外发的 lib 代码**——`lib/client.js:437` 设置页群白名单的 placeholder 写的是真群号、`lib/client.js:177` `upstreamListen` 的说明举的是真上游反连端口、`lib/index.js:2384`/`2412` 两个工具描述(**进 prompt**)举的 `params` 里含真群号与用户本人 QQ、`lib/index.js:170/686/690/3738` 与 `lib/link.js:801-802` 注释里带着真群号/真 QQ(LLBot 的 `config_3371846367.json`)/真端口/真安装路径——全部换成占位(群 `123456789`、QQ `10001`、反连端口 `8765`、路径 `D:\LLBot-Desktop\data\config_.json`);③`README.md:47` 同样换成 `127.0.0.1:8765/onebot/v11/ws`;④**`127.0.0.1:8080` 保留**(下游地址占位与两个工具描述里的例子)——它既是真机上的 NoneBot 端口,也是社区里最通用的例子,换掉只会让文档更难抄;⑤`docs/design-notes.md` 这类**真机复盘日志保留真值**(叙事要能对上现场),只把**配置表里的举例**(`:129` `agent.groups` 那行)换成占位,README 里新增「示例值约定」一节把规则写死。CI:新增 `.github/workflows/ci.yml`(push/PR/手动触发,矩阵 Node 22/24 × Ubuntu/Windows,跑 `npm test`)与 `.github/workflows/publish.yml`(打 `v*` tag 触发,先校验 tag 与 `package.json` 版本一致、再跑测试、`npm publish --provenance`,需要 `NPM_TOKEN`;手动触发默认 dry-run)。`package.json` 的 `test` 脚本从 10 个套件补齐到**全部 20 个**(此前漏掉 reply/chat-commands/models/memes/persona/anime/media-gif/imagegen/patch-media/tool-schema),并为每个套件补 `test:*` 快捷方式。装依赖必须 `npm install --omit=peer`:`@deepseek-ai/*` 是宿主提供的 peer 依赖,不 omit 的话 npm 会去 registry 拉一整套宿主包。回归:`npm test` 全绿(20 个套件,exit 0;smoke-core 66、reply 21、chat-commands 31、m15-e2e、load-check 69/69)。 - **CI 首跑两红,抓出两个只在干净环境里才成立的隐式依赖(`m34284` 加 CI 后的连着两次失败)**:本地 `npm test` 一直全绿,CI 一上去就连炸两次,两次都是"本地不可能发生"的那类问题。①**第一次红在 `npm install`**:`ERESOLVE unable to resolve dependency tree`——registry 上那几个宿主包(`@deepseek-ai/dsh-tools@0.0.1-rc.1` 要 `dsh-agent@^0.0.1-rc.1`,而 `@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6` 要 `^0.1.0-rc.6`)**自己的 peer 区间互相冲突**;`--omit=peer` 只决定"要不要装",不阻止"解析",所以四个矩阵作业全死在 Install。换 `--legacy-peer-deps`(npm 彻底不管 peerDependencies 的开关)才过。②**第二次红在 `load-check`(61/69)**:`Config is not a function` ⇒ `lib/index.js:26` 那个 `await import('@deepseek-ai/schemastery')` 静默降级成 `null`(这正是它故意写的降级路径),于是所有依赖 Config schema 的对账检查集体失明。根因是 **`@deepseek-ai/schemastery` 从来没被声明进 `package.json`**——本地能用是因为 DSH 装依赖时把它连同 `@deepseek-ai/cosmokit` 一起塞进了插件目录的 `node_modules`,而 `test/stub-hooks.mjs:8-9` 的注释明写"schemastery 仍走真包(Config schema 因此是真校验)",也就是**整套装配测试的立身之本是一个没声明的依赖**。修法:把 `@deepseek-ai/schemastery@3.18.4` 补进 `devDependencies`(它 `publishConfig.access: public`,registry 上有),CI 装完复跑 `load-check: 69/69`。教训记在这里:**"本地绿"不等于"依赖是声明过的"**,`node --import` 打桩能掩盖缺包,干净环境一跑就现形。 - **CI 第三次红:一个写死的本机路径(同一件事的第三次现身)**:这次只剩 1 条 `load-check` 断言红:「下游源码范围」那套把 `code.scopes` 配成 `D:/LLBot-Desktop-win-x64/bin/llbot`(**本机 LLBot 安装目录**)并断言 `exists === true`——本地当然成立,CI 上四个作业全红。改用**仓库自己**(`process.cwd()`,统一成正斜杠)当"确实存在的目录",不存在的那条照旧;顺带把 `test/hub-e2e.mjs` 里写死的 `D:\DSH-workspace\DSH-QQ\test\probe_out.jsonl` 改成 `HUB_E2E_PROBE_LOG` 环境变量(默认系统临时目录),把 `test/stub-hooks.mjs` 注释里的 `C:\Users\94512\AppData\Local\Programs\…` 改成 `<安装目录>`。**这正是本次"示例值体检"没做完的那一半**——当时只查了默认值、设置页与工具描述,`test/` 里写的机器路径属于"不会外发但会跑到别人机器上"的另一类真值。 - `get_group_member_list` 等只读 action 在 `relay` 预设下会转发上游;上游不可用时返回 1201 而非降级到缓存。 - **回复决策清单进提示词(参考 aigf-master,用户要求)**:用户要求"参考aigf-master在prompt中加上'对哪些消息不要回复'的判断,如果已有就告诉我"。盘点结论:**没有**成体系的判断——只有三条散落的窄规则(`lib/mind.js:87` DEFAULT_GUIDANCE 的"下游 bot 已经应答过的话题,不要重复应答"、`onebot_reply` 工具说明与 `onebot-hub:reply` 会话段里那句"纯灌水、只有表情、下游 bot 已经应答过"),而 aigf-master 有一张完整的回复决策表(取证:`.recon/new/prompt_builder.py.txt:438-450`,old/ 同:@你→回;回复你之前的消息→回;对所有人说的→通常不回;话题无关/不感兴趣→不回;不确定是否在和你说话→不回;群友不感兴趣别继续发言;不要点评图片除非被@;合并聊天记录→不回;小程序/卡片→不回;语音/视频/文件内容不可见→不主动回;QQ表情/链接/位置/音乐可识别→通常不是发给你的;[系统]通知→一般静默(迎新/被戳例外);另有 `:182`/`:226`"插件/bot 已响应→不要回复")。补法:`lib/index.js` setup 的 `onebot-hub:reply` 会话段把那句窄规则扩成七条"要不要回"清单——①被@/被引用/私聊要回、不确定就不回;②对所有人喊话(含@全体成员)/话题无关/纯灌水/纯表情通常不回,群友不感兴趣别硬续;③[合并转发]/[卡片]占位与系统通知一般不回(迎新、回应被戳例外);④不主动点评群友发的图;⑤内容拿不到的(语音转写失败/文件/视频)不主动回、别装看过;⑥可识别的分享类小卡片通常不是发给你的;⑦下游已应答不重复应答(与 DEFAULT_GUIDANCE 口径一致)。**没移植的**:社交能量措辞(该系统已删,见上面"移除社交能量与自带扫描器"一条);"@你/回复你→回"的正面判定已由 `policy.js` 的 `resolveCalled` 结构化承担(不叫醒就轮不到提示词说话)。回归:`test/load-check.mjs` replySpec 断言补四条(决策清单入口"要不要回"、"合并转发"+"系统通知"两词、"不确定是不是在和你说话"+"别硬续"、"下游 bot 已经应答过"仍在),69/69。 - **发布流水线改走 npm Trusted Publishing(用户要求"发版0.1.0吧,npm用trust publisher来发")**:`.github/workflows/publish.yml` 从 NPM_TOKEN secret 改为 OIDC——`permissions: id-token: write` 保留、Publish 步骤裸 `npm publish`(provenance 自动附带,删掉 `--provenance --access public` 与 `env.NODE_AUTH_TOKEN`)、setup-node 去掉 `registry-url`(免得往 .npmrc 写一行 `${NODE_AUTH_TOKEN}` 占位符)、新增 Upgrade npm 步骤(Trusted Publishing 要求 npm ≥ 11.5.1,node 22 自带的是 10.x)。关键机制(npm 官方文档 2026-10 取证):①**包必须先在 npmjs.com 上存在**,trusted publisher 配置挂在包的 Settings → Trusted publishing 下——首发无法走 OIDC(新包只能本地登录发布或 `npm stage publish`);②配置三要素:org/user、repo、workflow 文件名(精确、区分大小写);③2026-09-03 之后新建的配置默认只允许 `npm stage publish`,要手动勾上直接 `npm publish` 的许可;④新配置须在 **2 天内**完成首次成功发布,否则作废重建。README「CI 与发布」同步改写并写明首发例外:0.1.0 先本地发,登记配置后 CI 接管。本机实测:registry.npmjs.org 从 Node 侧被 TLS 拦截(`DEPTH_ZERO_SELF_SIGNED_CERT`),`NODE_OPTIONS=--use-system-ca` 当下无效、ping 直接挂起——README:117 记过的老毛病,当下更严重;本地首发能否走通取决于用户当时的网络环境。 - **0.1.1 验证 Trusted Publishing 链路 + tag 自动建 GitHub Release(用户指出"只打 tag 没发 release")**:0.1.1 只改版本号,用于真刀真枪验证 OIDC 链路——`git push origin v0.1.1` → CI 发布成功(`+ dsh-onebot-hub@0.1.1`,provenance 签进 sigstore 透明日志),注意刚发完 `npm view` 有几分钟延迟。**缺了 release 这步**:publish.yml 只发 npm 不建 GitHub Release,用户点出来后补两件事——①v0.1.1 的 release 手动补建(`gh release create v0.1.1 --verify-tag`,附件用 `npm pack dsh-onebot-hub@0.1.1` 拉下来的与 npm 同内容的 tarball);②把建 release 固化进 workflow:`permissions.contents: read → write`(`gh release create` 用 `github.token`),Publish 成功后新增 GitHub Release 步骤——`npm pack --pack-destination dist` 出 tarball,`gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --generate-notes dist/*.tgz`,release 已存在(重跑)时 `|| gh release upload --clobber` 重新挂资产;只在 tag + 非 dry-run 时跑。**没给 0.1.0 补 release**:GitHub 上没有 v0.1.0 这个 tag(首发是手动 publish 的),现在补 tag 会触发 publish workflow 然后 npm 409 撞版本号报红——要补就在 GitHub 网页上手动建 release 指向当时的 commit。 - **识图三连修:空回复重试、失败构成统计、晚到补描(服务器实测"图片全是裸 [图片]",用户 2026-10-10 报)**:用户在服务器上发现"agent 收到的聊天记录里完全没有图片描述",并给了会话日志(开局快照里 09:25–10:21 的六张图全是裸 `[图片](已存为 hub-media:…)`,56 分钟前的也没描述)。排查分三层:①**媒体落地没问题**(引用都在)——排除落地失败;②**门禁全开**(`onebot_hub_status` 的 vision 段:mode=describe、enabled/llm/attachments 全 true)——排除配置没开;③**真凶是模型**:`described: 21 / failed: 77`(≈78% 失败),`lastError: "视觉模型没有回任何文字"`——mimo-v2.6-flash(xiaomi-token-plan-cn)对图片经常"跑完了但一个字不回",成功样例(lastText,推特截图描述)质量完全正常,所以手动调 `onebot_media` "有时正常"(22% 成功率试几次总碰上)。三处修:①**空回复重试一次**(`lib/vision.js` describeImage 的请求块改成最多两 attempts 的循环,只在"模型没回字"时重试;超时/网络错误不重试——最坏延迟翻倍不值当,交给失败日志与补描兜底);②**失败构成进 stats**(`errorCounts`:错误文本→次数,上限 16 键、满了挤最冷的;lastError 只留最后一条,分布才能看出"是空回多还是超时多",`onebot_hub_status` 直接看);③**晚到补描**(`lib/hub.js` 看图写回后调 `mind.noteLateDescribe(entry, descriptions)`;`lib/mind.js` 用"已渲染 id"表(`#renderedIds`,#batchLines 渲染时逐条登记,400 条上限)判断这条裸 `[图片]` 是否已经跟着某一批发走过了——发过才进一次性队列 `#lateDescribes`,下一轮唤醒在批次行之后追加一块【图片补描 N 条】按说话人对上(`#batchText` 与插话消息 `#steerMessage` 共用 `#takeLateDescribeLines`,取走即清;段会话成功结算时随 `#openingPresented` 一起清空——卡片补丁已把描述写进卡片行,再留着就是重复)。回归:`test/load-check.mjs` 70/70(新增补描行为用例:首轮裸图→补描块带描述与说话人→下一轮不再出现→没渲染过的不补;顺带记一个坑——`resolveConfig` 恒产出两张空名单,`sessionOverrideOf` 据此判"白名单已启用",测试会话要临时进名单),smoke-core 66/66(抓到一个真 bug:重试循环初版漏了"成功即 break",每次看图都白跑两次请求——测试的 `streams===1` 断言当场拦下)。 - **生图三件套:引擎可切换(内置/第三方插件)、发送时机可配(直发/agent 决定)、生成的图也要有描述(用户 2026-10-10 定案)**:用户三条决策——`m01427`"在hub的配置项里加个选项,可选择hub自带的openai格式兼容的生图,也可选择调用第三方插件的生图功能";`m01453`"加一个配置项,可选择生图完成后是由agent决定是否发群聊还是直接发。生成的图还应该能够describe出来";对接方式选 **B agent 直用**(否决了"hub 后台转调第三方工具"——取证结论:dsh-image-gen 的工具结果里 `savedTo` 只在 `exec.agent?.session.header.cwd` 存在时才有,后台转调拿不到文件,只有 agent 直用才能落文件)。①**配置族扩展**:`imageGen.engine`(`builtin` 默认 / `plugin`)、`imageGen.delivery`(`direct` / `agent` **默认**,用户拍板接受改变老行为)、`imageGen.plugin.tool`(工具名或逗号分隔数组)、`imageGen.plugin.timeoutMs`(180000,min 1000 钳位)。②**`engine=plugin`**:`onebot_imagegen` 不注册;`defaultToolAllow` 并入第三方生图工具(显式配置 → 已知名单 `PLUGIN_IMAGE_TOOL_KNOWN=['generate_image','generate_images','edit_image','image_generate','animate_image']` → `/image/i` 模糊排除 `onebot_*`/`read_image`,结果 log 一次);setup 加 `onebot-hub:imagegen`(order 112)引导段——有工具教"直接调用,结果含 Attachment ID 与本地路径,hub 会自动落地+补描述,发 QQ 用 onebot_reply images",没工具明说"别假装生成";**`tools/post-execute` 瀑布监听**(`ctx.on`,宿主 API `packages/core/tools/src/index.ts:1787-1826`):对生图工具 `await next()` 拿 decision,从 value/content 里提取 `*.png|jpe?g|webp|gif` 路径(`extractGeneratedImage`),`readFile` 后 `media.saveBytes` 落地成 `hub-media:` 引用 + `vision.describeImage` 补描述,追加 `[hub] 图片已落地/描述/要发到 QQ 用 onebot_reply` 文本块进 content;非生图工具与 isError 原样透传,全程 try/catch 失败不影响原工具调用。③**`onebot_imagegen`(builtin)**:成功后先 `landGeneratedImage`(`media.saveBytes` kind:image 落成 `hub-media:` 引用 + 宿主 attachments 登记),再 `describeGeneratedImage`(vision 开着就描述,`override: hub.models.visionFor(sessionKey)` 跟随识图模型会话化),`delivery=agent` 时**不发送**——返回 `{mode:'agent', hubMedia, description, note:"要发就调 onebot_reply images:[…],不想发就不发"}`(落地失败退回直发 `mode:'direct-fallback'`);`delivery=direct` 照旧直发但返回里带 `hubMedia`/`description`。④**`media.saveBytes` 补宿主 attachments 登记**(kind=image 时 `saveImage`,失败不挡落地)。额度逻辑不变。回归:`test/load-check.mjs` 70 → **76**(config 归一、agent 模式不发送+描述齐、direct 模式直发+描述、落地失败退直发、plugin 引擎不注册 onebot_imagegen+post-execute 三例+setup 引导段与 allow 名单),client.js 设置页四键中文名对账。调试教训:load-check 必须带 `--import ./test/register-stubs.mjs` 裸跑会把 agent 通道动态 import 全打断(见下一条),一次 12 条"失败"里 10 条是这么来的。 - **调试事故:裸跑 load-check 的 12 条假失败(2026-10-10)**:`node test/load-check.mjs` 直接跑报 64/76、9 条 wake 路径全挂(`pool.host.create 非 function`、缺 `onebot-hub:context` 等),日志根因 `Cannot find package '@deepseek-ai/dsh-llm'`——`package.json` 的 test 脚本是 `node --import ./test/register-stubs.mjs test/load-check.mjs`,`test/register-stubs.mjs` 把 `@deepseek-ai/dsh-*` peer 包注册成 stub;裸跑没有 stub → agent 通道动态 import 全挂 → hostRef 永不接线。**教训:本地跑 load-check 必须带 `--import ./test/register-stubs.mjs`,或直接 `npm.cmd test`**(`node_modules` 里只有 `@deepseek-ai/{cosmokit,schemastery}`+ws,peer 依赖不落盘)。真正的 bug 只有两条且都在测试侧:`plugin.timeoutMs: 999` 期望 999 但 normalize 按 min 1000 钳位(改成 1500 并补钳位断言);post-execute isError 分支 handler 原样返回无 content 的 decision,断言对 undefined 取 `.length` 崩(改 `content?.length ?? 0`)。 - **内置生图对齐 OpenAI 官方规范;不做厂商适配(用户定案,2026-10-10)**:用户问内置生图有没有做百炼/智谱/千帆适配,取证(官方 OpenAPI 规范 openai/openai-openapi,全文存 `.recon/openai-openapi.yaml`,CreateImageRequest :46615)后用户拍板:"按openai官方的格式来就好了,不兼容的直接用第三方插件就行了。"——**不加** qwen/zhipu/qianfan 协议选项。官方要点:必填只有 model+prompt;`response_format` 已标 deprecated("Unsupported for GPT image models, which always return base64-encoded images",`url` 只服务已退役的 dall-e);gpt-image-2 起任意宽高**必须能被 16 整除**(宽高比 1:3~3:1,上限 3840x2160);错误走 HTTP 状态码 + `{error:{...}}`。据此两处收紧:①**`watermark` 默认不发**(不是官方参数面里的键,严格实现可能 400;显式配置 `imageGen.watermark:true` 才透传给支持它的中转),`client.js` 设置页说明同步改;②**size 对齐 16 步进**(`generate()` 里 `normalizeSize(..., {align: 16})`——16 的倍数必是偶数,对只要求偶数的老网关安全)。保留 `response_format:'b64_json'`(deprecated 但仍在官方 schema 里,保底让兼容网关直接回 base64,省一次下载往返;url 分支本来就能接)。已知的非官方形状:参考图走 JSON `image:[dataURI]`(官方 /v1/images/edits 是 multipart)——**有意保留**:真机中转站验证过的是这条 JSON 路,改成 multipart 反而会弄坏现网。回归:`test/imagegen.mjs` 20 → **21**(新增"默认无 watermark 键 + size 16 步进 + 显式开启才透传"),全量 npm test 绿。 - **生图实弹演练揪出双料类型谎报;onebot_media 的 link 落地也补上 describe(2026-10-10)**:用户"用我现在配置的模型参数生成一张图看看"。实弹结果:豆包 Seedream 正常出图(请求 1024x576 被 minSize 面积顶成 2560x1440——normalizeSize 里 minSize 优先于 maxSize 的既定行为),但 describe 报 "Declared image type does not match its bytes.",media 统计 blobs:1/attached:0。查魔数:**字节是 JPEG(FF D8 FF E0 JFIF),却带着 image/png 的声明**——两层都错:① `lib/vision/imagegen.js` 的 `decodeB64` 对裸 base64(b64_json 最常见形态,不带类型信息)一律兜底成 `image/png`(`DEFAULT_MEDIA_TYPE`);② `lib/media.js` `saveBytes` 里声明类型赢过字节嗅探(`mediaType ?? sniffed.mediaType`)。修法:① imagegen 加 `sniffImageType`(PNG/JPEG/GIF/WebP 魔数,与 media.js 同表),**嗅探优先**、data: 前缀声明只当兜底;② saveBytes 只在"两边都是 image/* 且嗅探确定命中且不一致"时改判信字节,其余场景保留声明(嗅探没命中 ≠ 声明错了)。顺手发现 `onebot_media` 的 `describe:true` 只在"按消息 id"那条路生效,直接给 link 落地的图悄悄跳过(与工具说明不符)——把 describe/anime 块抽成 `describeImageRefs` 两条路共用,link 路补上 `descriptions` 返回。回归:imagegen 21→**22**、load-check 76→**77**、smoke-core +1(saveBytes 类型打架改判用例),全量绿。教训同事故 #9:**上游/调用方给的类型声明只是主张,字节魔数才是事实**。 - **识图失败要亮出来 + 没有描述就不做角色识别(用户定案,2026-10-11)**:用户在服务器会话日志里发现"识图没描述但有二次元识别"。机制(`lib/hub.js` `#enrichImages`):describe 与 anime 是同一次字节后的两步,旧门②"判定缺失宁可多问不漏判"(`judged?.anime !== false`)——describe 挂了(判定拿不到)时 anime 照跑,就出现"孤零零一个角色名、没有任何画面描述"的状态。用户两步定案:"按b改吧,再加上一个判断:无描述时不给角色识别的结果。"修法:①**失败亮进上下文**——describe 最终失败(空回重试后仍空/超时/报错)时,`ref.text` 写 `(识图失败:原因)`(原因截 160 字),agent 知道"没描述"是失败不是没内容,hub 日志的 `看图失败(不影响转发)` 行保留;②**门②收窄**——`animeOn && bytes && parts.length && judged?.anime !== false`:没有描述(含 vision.mode 配置上就没 describe 的情况)一律不问角色识别;判定缺失(描述在、模型没按格式答)仍照问。行为变化:anime-only 配置(describe 关、只开识番)从此不再出角色名——这是用户要的"宁缺毋滥"。**枚举下拉补齐**:`imageGen.engine`/`imageGen.delivery` 之前在设置页渲染成文本框(用户:"新增的几个配置项怎么没有下拉选择,补一下")——单一出处定为 `lib/vision/imagegen.js` 导出的 `IMAGEGEN_ENGINES`/`IMAGEGEN_DELIVERIES`,宿主归一化(`lib/index.js`)与设置页 `ENUM_OPTIONS`(`lib/client.js`)都改引它,`test/client-manifest.mjs` 的键集断言同步。回归:`test/m15-e2e.mjs` 210→**211**(新增门②断言:描述失败不问后端、上下文带失败原因;写测试时踩到空回重试会吃第二个队列项——`describeQueue.push('')` 只够一次尝试,重试会 fallback 到默认描述,必须 push 两个空串),client-manifest 15/15,全量 npm test 绿。 - **表情包积极性:正向引导三处 + 库容量配置化(用户定案,2026-10-11)**:用户"表情包发送的积极性不是很强",点名对比 `nonebot-plugin-aigf-master` 的 prompt。取证结论(`.recon/new/prompt_builder.py.txt` / `meme_store.py.txt`):两边的**硬规则几乎同构**(不编造 id / 正文不带 id / 结构化字段发送),差异全在**语言取向**——aigf 的清单标题点明用途"## 可用的表情包(用于发送)"、每条带"适用场景"标签、还有能量<20% 时"用几个字或表情包打发"的软提示;我方清单从头到尾全是禁令措辞(不要编造/只能用列出的),**零正向引导**,模型自然把清单当约束读而不是当弹药库用。修三处:①清单头加用途与正向引导——"表情包库(**用于发送**,共 N 个;…;**气氛合适就发一个,别让库白攒着**)"(`lib/memes/store.js` renderForPrompt);②关键词标签对齐 aigf——`describeMeme` 的 `m3(开心、得意)…` 改成 `m3(**适用场景**:开心、得意)…`,告诉模型"遇到这个气氛就可以发"而不是干巴巴的检索词;③回复决策清单(`lib/index.js`)补一条正向场合:"一句话就够、或气氛正好的场合,可以用一个表情包代替文字回……这也是'开口',不算沉默"——原清单九条全是"通常不回",没有一处"什么时候可以花小钱开口"。**库容量配置化**:`maxCount=200` 一直是 MemeStore 构造参数硬编码,配置面没有它(用户:"配置项里面没有,可以加上")——`memes.maxCount`(schema/归一化/构造三处接线 + 设置页 label,重启生效)。**清理逻辑对比结论:不重写**——aigf `_cleanup_score = oldness*seldom`(归一化加权,分数大先删、recent 软保护垫底、只清自动收集);我方淘汰序"少用 → 久未用 → 建得早 → id 小"+ 最近用过受 `recentShown` 保护,意图等价且保护更明确,保持现状。回归:`test/memes.mjs` 格式断言同步(describeMeme/清单行),renderForPrompt 新增"用于发送 + 气氛合适就发一个"断言,全量 npm test 绿。 - **README 瘦身:CI/发布与示例值约定搬进本文(`m02693` 用户定案"应该写到design-note里面,而不是readme")**:README 的定位是"使用者只需要看根目录的 README"——安装、配置、行为约定、工具清单;而「CI 与发布」(ci.yml 矩阵与 `--legacy-peer-deps` 的缘由、publish.yml 的 Trusted Publishing 流程、`DEPTH_ZERO_SELF_SIGNED_CERT` 的本地解法)与「示例值约定」(文档/注释/设置页/工具描述里的占位值规范)是**开发者的仓库约定**,读者是改这个仓库的人。两节原文搬进 §8 末尾(「CI 与发布」「示例值约定」),README 删去并保留指向 design-notes 的那一行;内容一字未改(0.1.2 起的 Trusted Publishing 流程以这版为准,§9 里 `m34284` 那条"需要 NPM_TOKEN"是当时的历史记录)。纯文档移动,无行为改动、无回归需要跑。 - **aigf-master 表情包数据迁移(`m02597` 用户要求"帮我把aigf-master的表情包数据转换成hub的数据吧",`m02635` 定"先发版0.1.3,发完再转")**:数据勘察——`nonebot_plugin_aigf_master/memes/` 下 137 个图片文件、`memes.json` 29 条管理员收录(`{id, path, keywords, description}`)、`collected.json` 100 条自动收集(多 `usage_count` 与 `saved_at`(epoch 秒 float)),`path` 是相对数据目录的裸文件名;129 条有元数据、8 个孤儿文件。落点:`MemeStore.importAigf(dir)` + `scripts/import-aigf-memes.mjs` CLI(`scripts/` 进 `files` 清单,npm 安装的服务器也能用)。**为什么不做 `onebot_memes` 工具 action**:运行中的 hub 进程没有新代码,工具路会踩"活进程内存态之后落盘盖掉导入"的坑;脚本离线跑,先停 DSH 或确认没有表情包变更再执行,重启后随装配载入。**为什么不走 `collect()` 逐条收**:aigf 的 `usage_count`/`saved_at` 会被 collect 抹成 0/now——迁移要保真(usage 决定清单排序与淘汰次序),且上百条逐条落盘浪费;importAigf 一次读、批量收、**一次落盘**。映射:description → brief+description(aigf 描述就是一句话简介,两用)、keywords 原样、usage_count → uses、saved_at → createdAt、lastUsedAt=null(迁移来的"用过"不是这个 bot 用过,不进保护池)、savedBy='import:aigf-master'(载入侧凭它认正身)、source='aigf-master'、aigf 侧 id 存 messageId 便于回溯。收编次序:管理员收录优先、自动收集按 usage_count 降序——容量不够先保人工挑的;容量是硬约束,装不下**不淘汰现有条目**,如实计入 skippedOverCap。去重 sha256 批内互查+对已有库;媒体类型魔数嗅探优先(`sniffMemeImage`,PNG/JPEG/GIF/WebP)、扩展名兜底、都认不出跳过——**嗅探与扩展名都认不出才跳**(.jpg 里装文本这种病态文件按扩展名收,真迁移里不存在;本测试用 `.dat` 锁"都认不出才拒"的语义)。回归:`test/memes.mjs` 新增 5 条(保真映射+真落盘、批内/对库双去重、缺文件+认不出+空/不存在目录、容量硬约束不淘汰现有、savedBy 重建存活+seq 续上)。 - **工具面 allow 白名单改 deny 遮罩:预设工具晚到不再永久失踪(实测 bug,`m03069` 定要求"不管 default 是谁都该顺利加载 default 预设里的工具")**:现象——hub 建的会话(本机 desktop 档 + 服务器)工具面里 `web_search`/`web_fetch` 为 0、连系统提示都没有,桌面 GUI 新会话却有。根因:`wrapSetup` 在 `presetsSvc.mount` 返回后立刻按 allow 白名单遮工具,而 mount 返回 ≠ 预设子插件都注册完(web 工具来自 effect-scoped 的 `@deepseek-ai/dsh-tool-web`,晚到)——allow 取"当下视图"的交集,晚注册的名字被丢出名单;`restrict({allow})` 按名字遮 global 层 → **永久**不可见。修法取 fail-open 方向(deny 化,参考梁神 tool-catalog 的 deny 分页 + 先解除再读 schemas 的套路):`defaultToolDeny` 只遮"当下真实存在"的名字——keep = `SLIM_KEEP_EXTRA`(`vision.mode` 无 `segment` 时去 `read_image`)∪ `onebot_*`(排 `onebot_reply`)∪ `pluginImageTools`,deny = known − keep,晚到的预设工具天然不在名单里=可见;`applyToolDeny` **先解除旧限制、再求值**(惰性 fn 形式:限制挂着读 `schemas()` 是遮后视图,被遮的名字缺席,照抄进新名单等于焊死"恢复可见"的路),求值/apply 抛错一律降级"不遮"并 log。`onebot_tools` 语义随之翻转:enable=从 deny 删、disable=加入、all=解除、slim=回默认名单;纯查询也借"解除→抓全景→挂回"窗口抓真实全景(全景视图只在旧限制已解除、新限制还没挂的窗口里抓得到)。**新用例当场拦下第二层 bug**:`applyToolDeny` 先原地清空 `rec`(`rec.deny=null`)再求值惰性 fn,而 `onebot_tools` 闭包读的 `rec0` 与 `rec` 是**同一个对象**——任何一次 `onebot_tools` 调用都会把遮罩名单静默清空(查询分支永远读到已清空的 null → 重挂空名单 → 遮罩悄悄解除)。修法:进门前把 `rec0.deny` 拍成快照,闭包只读快照。调试插曲:探针一度显示"set 写入 deny 非空、随后 get 读到 null、中间无写入",加 mapId 才发现是三个 hub 实例三个 Map 的假象,真因就是上面的原地突变。回归:load-check 76→**78**(pg 用例断言翻转到 deny 语义 + 新用例"onebot_tools:遮罩语义"覆盖 list/enable/disable/all/slim 五路),全量 npm test EXIT=0。 - **预设切换:遮罩管不到的预设工具只能"整档换预设"(`m04436` 服务器日志 → `m04657`/`m04664` 用户定案)**:0.1.4 服务器实测推翻了"deny 遮罩能收走预设大件"的预期——服务器会话请求头里 subagent/workflow/goal/task_board 全都在,`onebot_tools{list:true}` 的"全世界"只有 29 个工具,`enable:"subagent,web_search"` 一律 unknown。挖宿主源码定性:`tools.restrict()` 的文档原文 "**Restrictions intersect; scoped registrations remain visible**"——它只遮 **global 层**;而 `agentPresets.mountPreset` 源码自带 `scopeOf(ctx) === undefined` 抛错,预设插件(tool-web/tool-fs/subagent/goal…)全部注册进 **agent 作用域层**,每会话一份。遮罩规则只看挂载途径不看官方与否:经预设挂载的(含第三方预设插件)谁都遮不到,profile 直装不走预设的才是遮罩管辖范围(梁神 `tool-catalog.mjs:572` 的注释亲口承认同一件事)。顺带解开两个悬案:①服务器请求里 `load_workspace_dependencies` 缺席 = deny 遮罩在真实干活(它是 profile 直装的全局工具);②`onebot_reply` 在遮罩名单里却被防御性剔除(`:1900` 的 `n !== 'onebot_reply'`),行为对、注释前提("own 层")与服务器现实不符。修法定案(用户 `m04657`:"把hub默认的预设指定为精简后的,取回工具时直接切换到dsh的默认预设,进入休眠后切回精简预设";`m04664`:"切换后需要在prompt中说明一下开放了工具使用"):新增 `agent.preset` 配置(精简预设 id,空=profile 默认)+ `onebot_tools` 新 `preset` 参数(default/slim 两档排队)。实现走**建会话时换装**而不是 select:预设挂在会话上、resume 会断言预设没变(`assertPresetUnchanged`)、`agentPresets.select` 首回合后锁死(agent-preset/locked)——好在枢纽会话本来就"每次激活一个全新的",换预设 = 唤醒前把当前会话 retire(`applyPresetSwitch`,挂在 `presetPool` 转发代理的 wake/retire 里:wake 前检查换装、retire=休眠回切时清三张表),下一次 `ensure` 自然 create 进新预设(`meta.agentPreset` + setup 里 `mount(agentCtx, pid)`,`presetForAgent` 每次 create 现算并按**意图**记 `presetApplied`,解析失败退回 profile 默认也不空转);新会话 setup 从 `presetNotice` 取一次性说明段 `onebot-hub:preset`(order 108)告诉模型"工具面变了、休眠会切回、提前切回用 preset 参数"。`onebot_tools` 响应加 `preset:{config,current,queued,note}`,描述写明"预设作用域工具遮罩管不到,enable/disable 报 unknown 属正常,大件开关用 preset 整档换"。fork 复制会话换预设的提案(用户 `m04628`)核实后否掉:child 继承源预设且带历史,select 必锁。回归:load-check 78→**79**(新用例"agent.preset:精简预设建会话 + 整档切换 + 休眠回切 + 未知预设退回"),stub `fakePresets.resolve` 改为带 id 按名解析、未知抛 agent-preset/not-found。 - **预设定稿:插件自带精简预设、点名一次性+强制回程票、点名后立即重开激活(用户 m04972 三点反馈推翻首版)**:首版(上一条)让用户填 `agent.preset`、点名排队到"下一次激活"才生效——用户三点推翻:①"精简预设由插件直接定义一个啊,为什么还要手动定义???"(配置删了,装配时 `agentPresets.register({ id: 'onebot-hub-slim', plugins: [] })` 自带一个空插件预设,重复注册 try/catch 退默认;`agent.preset` 配置族整体删除);②"切换预设后应该在下一次激活时强制切回来,而不是由 agent 决定"(换到 default 的那一刻就排**回程票** `presetChoice.set(key,'__config__')`,下次 create 必回 slim;休眠不清票所以跨休眠也生效,agent 想续留就再点名一次);③"排队制是什么意思?agent需要工具时还得等下一次激活才能切预设?"(真 bug:点名走工具调用,回复末尾就休眠 retire 了,排队永远不落地——修法=`mind.reawake(sessionKey)` 裸唤醒(跳过批次门/静默闸/快照),工具里 fire-and-forget 排队(池子按 agentKey 串行天然排在本回合后,**await 会死锁**),本回合结束立刻交还旧会话、以目标预设建新会话并从 `presetNotice` 注入 `onebot-hub:preset` 说明段(order 108,m04664"切换后需要在prompt中说明"))。状态机要点:**create 就是点名的落地时刻**(`presetForAgent` 里消费 `__dsh_default__`、记 `presetApplied` 意图账与 `presetSessionPid` 活会话账);`presetFormOf` = choice 优先、否则沿用 applied(否则换装当轮又被 create 建回 slim,换装蒸发);`__config__` 回程票只在 applySwitch 的"已是目标形态防空转"与"真换装"两处消费;resume 一律用 `presetForResume`(活会话建会话时的那份——宿主断言 resume 不能换预设,把待落地点名算进 resume 会被真宿主直接拒)。调试揪出的两个低级坑:①`mind.reawake` 的 IIFE 同步段引用自己 `const task`(TDZ "Cannot access 'task' before initialization")——首行 `await Promise.resolve()` 让赋值先落地;②编辑冲突留下两行 `presetChoice.delete(key)`(条件行+无条件残留行),回程票落地即被误删——`grep presetChoice` 全量数引用才抓到。回归:load-check **79**/79(内置预设 meta+mount、点名→lastReawake→standard+说明段、round(2) 强制回 slim、跨休眠点名仍生效、unknown 拒收),stub `fakePresets` 加 `register`。后续打磨(m05515"切回精简不需要说明"):说明段只开全量时设(回精简=回到常态,没什么可交代),`presetNoticeText` 收成单文案、两处 set 加 `desired === 'default'` 门,测试断言翻转为"回切不再新增说明段";`onebot_tools` 工具描述同期重写(默认面有什么→全局工具开关→预设大件只能整档换→一次性语义,去掉过时的"45K 字符"数字)。 - **识图 8MB 上限不再掐图片(用户 m04935"识图功能是不是有8MB的大小限制?有的话把限制去掉")**:`media.maxBytes` 默认 8MiB 本是"超限只记元信息",但对图片这条链成了变相识图上限——blob 不落盘→`#enrichImages` 拿不到字节→"没有图片字节"→`[图片](识图失败)`。三处修:①`lib/media.js` blob 落盘门改 `kind === 'image' || bytes.length <= maxBytes`(图片放行落盘),超限对图片**不标 error**(识图环节见 error 会直接放弃)、也不登记宿主 attachments(主请求别被巨图撑爆——segment 模式仍然瘦身);`#readLocal` 的 4×maxBytes 早退同样豁免图片;②`lib/hub.js #enrichImages` describe 前压小:`#shrinkImage` 循环至多 3 次直到 ≤ maxBytes(PNG 解码等比缩 60% 重编码 / GIF 关键帧拼图,产物都是 PNG → `describeType='image/png'`;第三参 typeOverride 处理"第二轮迭代字节已是 PNG 而 ref.mediaType 还是旧值"的分支错位;JPEG 无解码器解不了就原样交模型,上游拒了如实报失败),sha256 仍传全图哈希(描述缓存稳定);anime 路径拿全尺寸字节+自己的 shrink 回调,不受影响;③配置文案同步(`media.maxBytes` note 改"图片不受此限…看图前会先压小")。测试坑:MediaStore 的 blob 门挂在 `#log`(JsonlLog,仅 `keepBytes` 时存在)上——测试造 oversized 图不能传 `keepBytes:false`,否则blob 门整体关闭,跟图片放行无关。回归:smoke-core 新用例"超大图放行落盘且不标 error(识图要看字节),非图片照旧拒绝",全量 npm test 绿。 - **slim 预设补 11 个原生工具(用户 m05750"常用 11 个原生工具也放到 slim 预设里吧")**:动机——本机 slim 会话实测 `onebot_tools{list:true}` allowedCount:28(只有 onebot_*),read/grep/pwsh/web 等原生工具一个都没有。定性:**原生工具不是 core 全局注册,而是预设插件提供的**(standard 会话请求头 54 工具里它们全在预设作用域层,`SLIM_KEEP_EXTRA` 留不住不存在的名字)。权威组成的出处:`@deepseek-ai/dsh-web-app` 的 bundle patch 注册 `agent-preset-registry`(`default: standard`)后注明 "shipped preset declarations follow as separate patch files under `presets/`"——`presets/standard.patch.yml` 就是 standard 预设的 authoritative 插件清单。改法:新增 `SLIM_PRESET_PLUGINS`,逐行照抄其中属于这 11 个工具的 6 行——`tool-fs`(read/write/edit/read_image)、`tool-fs-search`(grep/glob,`config.sampleOverCapGlobResults:false`)、`tool-pwsh`(非 win32 `disabled`,同官方)、`tool-jobs`(job_*)、`tool-web`(web_search/web_fetch,`config.fetch:true` + `searchTimeoutMs:60000`)、`tool-todo`(todo_write,`config.allowParallelInProgress:true`)。两个已知前提与官方预设同款、不另设防:①fs/web/attachments 等 provider 服务(dsh-fs-sandbox、dsh-web、dsh-attachment…)由 dsh-base 全局层提供,profile 没有 dsh-base 时这些行挂不上——registry 对激活失败**不抛**(吞进 record.broken 只 warn),但挂载失败的预设在 mount 时抛 agent-preset/invalid、每个会话创建都得撞一次,所以 register 后立刻 `resolve(SLIM_PRESET_ID)` 体检一次,broken 就当没注册过退回 profile 默认;②`dsh-tool-fs` 的 Config 只有 read 限流四项、没有 per-tool 开关,write/edit 随包一起进(官方 standard 也这样)。测试:stub `fakePresets.register` 把 definition 记进 `recorded.presetRegisters`,预设用例加断言(slim 清单正好 6 行、包名齐全、tool-web config 照抄、tool-pwsh disabled 跟平台走),load-check 79/79、全量 npm test EXIT=0。 - **一轮多次 onebot_reply 只有最后一次发出的机制与 14:22 消息不全事故(用户问"onebot_reply只有最后一次调用才发出的约束还在吗?")**:约束**还在**,三层:①工具描述明说"一轮里后一次调用覆盖前一次"(lib/index.js makeReplyTool);②代码层面 `ReplyQueue` 只持**一个**候选(lib/reply.js capture 直接覆盖 `#candidate`),唤醒结束后 mind.js 只 `take()` 这一个候选投递——前几次排队的内容**静默蒸发**;③设计动机是"agent 改口重发",但实测 agent 的真实用法是**分批发长报告**,覆盖语义正好把前面的批次吃掉。14:22 会话(amv6anseisd2t)实锤:一个回合里 3 次 onebot_reply(6/4/1 段),每次回执都说"已排队回复…拆成 N 条…按顺序隔一小段发"——**回执撒谎**(前两次根本没发出);再叠加每次调用的文字条数上限(`replyMaxText` 默认 3,m32420)丢了 3+1 段,而丢段的回执写"超出上限(undefined)"——capture 没把 plan.maxText 抄进候选、describePlanIssues 只拿到 {dropped},上限显示成了 undefined(显示 bug,实际是 3)。模型于是相信"报告已发完"(下一轮原话"报告已发完,保持沉默")。**已改**(用户点名):"点名 onebot_tools 切换预设后直接结束本轮即可自动切换、不要做额外动作"四处落位(onebot_tools 描述、preset 参数描述、点名即时 note 两分支、presetNoticeText)。**待用户定案**的语义修复选项:A. 新排队时把上一条未发的候选**立即发出**(分批全到达,改口场景会草稿+终稿都发);B. 累积到回合末一起发;C. 只改诚实回执+放宽上限。两处无论如何都该修的小项:覆盖回执警示、"(undefined)"→实际上限。 - **reply 多次调用语义定案 A:新排队时先把上一条未发的立即发出(m06083 用户定案,推翻"后写覆盖")**:14:22 事故(上一条)的语义修复。定案原话(意译):"感觉还是新排队时把上一条未发的立即发出好一点,再在 onebot_reply 工具的说明中写清楚调用后会直接发出消息,并且允许多次调用。"(讨论中用户先提议过 B+撤回(m06019),我动手后喊停(m06078),最终选 A、撤回手段不再要求)。实现:`makeReplyTool` 加第 4 参 `{ deliverNow }`——execute 先做**全部校验**(图片/表情包解析、空内容检查;新调用自身有错时上一批原封不动留给回合末,不会被白 flush),然后 `reply.take()` 上一批经 `deliverNow(candidate)` 立即发出(接线=`mind.deliver(sessionKey, candidate)`,与回合末同一条出站链;只读模式 deliver 内部返 null,回执如实说"没有发出(只读模式)"),再 capture 本批。回执口径:冲正成功"上一条回复已直接发出(N/M 条)"、失败"发送失败(第 X/Y 条没发出去,retcode …);本次内容照常排队"(deliver 的 failedAt 是 1 起数,别再 +1);本批"已排队回复(下一次调用或回合结束时发出)"。顺带修两小项:①capture 候选带上 `limits:{maxText,maxImages}` 与 `skipped`——"超出上限(undefined)"显示 bug 的根治;②`describePlanIssues` 拿到实际上限。缓冲层不变(同刻最多一批,最后一批回合末发出),mind.js 零改动。工具描述按定案重写:"调用后会**直接发出消息**——一轮里**允许多次调用**,长报告分多次调用发正是推荐用法:每次新调用都会先把上一次已排队、还没发出的内容立即发出去"。测试:m15 插话场景(scenarioSteerInterject)改用**真** makeReplyTool(setup 里 `{ reply, mind: mindRef, sessionKey }` 接线),断言翻转为两批按序出站(第一批被第二次调用冲正、插话回复回合末出站);load-check 新用例"onebot_reply(会话缓冲版):新排队先把上一条立即发出"覆盖冲正成功/失败回执与描述断言,78→**80**。坑:lib/index.js 里有**两个同名 onebot_reply**——top-level 直发版(hub 自己发言,`ctx.tools.register`)与会话缓冲版(agentCtx setup),load-check 的 `recorded.tools.find` 拿到的是直发版,描述断言必须对缓冲版真例化来做。 - **插话闸门从"每回合一次"改为"每批次一次":长回合里每一批消息都插得进去(m08218 用户定案)**:用户发现"每过 `agent.batchMs` 就提交一批,如果一回合只能插一次,后面的批次不就一直堆着?"——机制确认(lib/mind.js):回合在飞时 `scanDue` 每 `tickMs` 挑中到期批次(`BatchWindow.shouldFlush`:攒满 `batchSize` 或 `openedAt + batchMs`,lib/agent/policy.js),`flush()` 进门转 `#trySteer`;旧代码 `#steering` 是"单会话单飞行"(`Map`,要等 `whenIdle()` 整轮空闲才清),第二批起一律返回 `steer:pending`**且不排水**(只有成功递给宿主才 `batch.flush()`),于是每一拍都重试、每一拍都 pending,全堆到回合结束当**一整坨**进下一轮——批与批的边界、与回合内已发生对话的时序都丢了。改为每会话一组任务(`Map>` + `#steerInFlight()`):**闸门降为"每批次一次"**,批次自身就是节流器(一批最多 `batchMs` 一次);同一批不会被插两次,因为 `pool.steer()` 成功后**同步** `batch.flush()`(中间没有 await,单线程下不会被重入)。连带改动:`#expireAwake` 的"手头有活就不睡"改判 `#steerInFlight(sessionKey) > 0`;状态输出 `steering` 从 `[sessionKey]` 改成 `[{sessionKey, count}]`。**已知代价**(用户接受,不加 `steerMaxPerTurn` 阀):`batchMs` 默认 8s,一个长回合最多能插进约"回合秒数/8"次 → 模型多跑几步、多烧 token;"递给宿主但还没被认领时回合就结束"的窗口随批次数放大(现在就是"递给宿主即排水",宿主没有可靠的"已认领"信号,真丢消息再考虑未认领回灌)。**插话注入位置**(顺带查清,`@deepseek-ai/dsh-agent-loop/lib/index.js`):hub `pool.steer` → 宿主 `steer(input) = send(input, "next-step", true)` 进 **next-step 收件箱**(不是 next-turn),每个**步骤边界**由 `preStep` 的 `inbox.claim()` 认领成这一步的 user 消息(上一段工具结果之后、本次模型调用之前);回合收尾判定会看 `inbox.nextStep.length === 0`,插话还在就再跑一步、不丢;动态上下文只在**真的变了**时才附一条快照消息(`RuntimeContextProjection.project`,`retained?.text === snapshot` 就跳过)。插话正文按 `#steerMessage` 渲染=【插话 N 条】头 + 与 `#batchText` 同一套条目渲染,**不带开局快照**(本段已给过)。测试:m15 新场景 `scenarioMultiSteerInterject`——一个回合卡住时连插三批,断言步骤序列 `['turn','steer','steer','steer']`、三批按序认领(正文各含自己的内容)、首批+三批回复共四次按序出站、飞行中 `steering[0].count === 3`、收尾后 `steering` 归零、四条都在窗口里;主套件 211→**214**。 - **记忆的"提升为长期"与"积极删短期"(`m08521` 用户定案,点名参考 aigf-master)**:用户要求"加上及时删除过时短期记忆和将重要记忆转化为长期的引导"。取证(`D:\DSH-workspace\nonebot-plugin-aigf-master\nonebot_plugin_aigf_master\prompt_builder.py:293-317`):aigf 的短期块写着"对话有新进展→修改;话题结束或被取代→删除;重要且长期有效的信息→**提升到长期记忆**(群友的个人信息写入群友信息,群规/重大事件写入长期记忆),写入后从短期记忆删除"、"保留当前话题的要点即可(建议不超过 8 条)",长期块写着"被证伪、过时、不再适用→delete(积极使用)"、"值得记:群规、群内重大事件、长期有效的事实"。**在 aigf 里"提升"能成立,是因为短期/长期是两个独立数组**(`memory_store.py`),写 `long_term.add` 就是往另一个数组写;我们这边只有一份库、块由 `scope` 推,模型把同一句话写进 `long_term` 只会撞上"已有同一条"——库里什么都没变,光加引导会变成骗人的话。所以两处一起改:①**引导**——`lib/mind.js` 的 `DEFAULT_GUIDANCE`(`m08521` 后在"每次回复前都评估一遍记忆"那条后面新增两条)+"`lib/index.js` 的 `onebot_memory` 工具描述"写明"短期是临时的:话题结束/被取代/已解决或过期的用 delete 及时清掉"与"重要且长期有效的写 `long_term.add` 就是提升:同文本会**就地提升**成长期并移到长期块,别先删短期里那条(删了就等于把这条删掉)"。②**让提升真能生效**——条目新增存储位 `term`(`'short'|'long'`,**不进去重键**,所以提升不会造出第二条);`memoryBlocks` 的分块口径变成"长期=提升过的(`term:'long'`)**或**别处学来的(`scope` 不是本会话/本世界),其余是短期";`applyMemoryOps` 的 add 命中同文本(可见列表里就有,或 `store.add` 的 `deduped` 认出"其实早有这条"——后者覆盖"同一批里前面几行刚写进去")且块为 `long_term` 时走 `tryPromote` → `store.promote(id,{visibility})`,回执里记 `promoted`;升不动时在 `rejected` 里说清是哪种("这条已经是长期记忆"/"同文本的条目是在别的会话里学到的"/"私聊里学到的不能跨会话说"/"内容被判为敏感,连代码也不升格")。**硬边界没动**:能不能升格仍由 `inferWriteVisibility` 判(私聊来源一律 `private`、敏感内容一票否决),模型给的 `visibility` 照旧被忽略并记 rejected。**兼容**:老落盘文件没有 `term`,读回一律当 `short`,改前行为不变。验收:`test/smoke-core.mjs`(同一批先 short 再 long 同一句话=`promoted:1`、条目 `term:'long'` + `visibility:'shareable'`、渲染上离开短期块进长期块;私聊里同文本提升被拒且条目仍是 `short`/`private`)、`test/m15-e2e.mjs`(M16 新增"提升:同文本写进 long_term 会就地升格并移到长期块",含"第二次再写如实拒绝")、`test/load-check.mjs`(工具级 `promoted:1` + 二次写拒绝)。**没做的**:没给 `modify` 开"改 `scope`/`visibility`"口子(那正是硬边界),也没做"自动降级回短期"——提升只单向。**渲染(`m08663` 用户指出)**:`renderMemoryBlocks` 原来把短期/长期两块直接 `.join('\n')` 拍平,长期标题紧贴在短期最后一条记忆下面,看着像同一段里的下一行而不是另起一块;现已改成**两块之间空一行**,并把标题里的"序号同上"改成"序号可用于 modify/delete"(每块序号都从 0 起、各自对各自的列表,"同上"会被读成"接着上面的号数往下数")。验收加在 `test/smoke-core.mjs` 的提升场景之后(渲染形状断言:两块之间一个空行、各自从 0 起、只有一块时不留多余空行、空库渲染为空串)。 - **注入文本的换行统一渲染成 `⏎`,能力清单补上标题(`m08893` 用户报"注入的内容还有不少换行问题" → `m08997` 用户"改吧")**:真机现场(11:29 那轮私聊)开局快照里,模型自己发出去的那条"完整清单(可见 28 个):\n【消息与会话】…\n【本体】…"变成了**一条 200 多字的单行**,五个分组标题全被挤没。回 `recall/…/20261013.jsonl` 核对原文:**入库存的是带换行的真身**(`recall` 检索也确实能搜到换行内容),丢失发生在渲染层。根因是**五个渲染点各写各的**:`#batchLines`(`Mind`,【新消息 N 条】/【插话 N 条】)用 `replace(/\s*\n\s*/g,' ⏎ ')`——**只有它是对的**;`#openingText` 卡行(`Mind`,重启后回退那条分支)、`renderWindow`(`memory/assembler.js`,开局快照另一分支 + `onebot_context` 工具结果)、`renderDigest`(`memory/digest.js`,恢复卡)、`noteDownstreamSend`(下游 bot 说的话进批次)都是 `replace(/\s+/g,' ')`——**换行直接被吃掉**;另有 `noteLateDescribe`(图片描述补注)把换行换成普通空格。修法:新增 `lib/text.js` 的 `flatBreaks(text)`(换行含 `\r\n` → ` ⏎ `,行内连续空白仍压一格),上述**六处统一改调它**。**为什么是 `⏎` 而不是还原真换行**:一条消息在注入文本里裂成多行,就能伪造 `- ` 列表项、`【短期记忆】` 这类段标题——那是实打实的注入面(`flatBreaks` 的输出 `split('\n').length === 1` 有断言守着);`⏎` 保住"这里断过行"的信息,又不改变注入文本的结构,一行还是那条消息。**顺带**:①`renderCapabilities` 以前只吐光秃秃几行 `- /今日小猪 → send_msg ~522ms(置信度 6)`,夹在表情包清单后面看不出是什么,现在带标题 `【下游会吃的命令(观测 N 条,置信度=真被触发过的次数)】:`(无观测时仍返空串不占段);②验收在 `test/smoke-core.mjs`(`flatBreaks` 五条断言 + `renderWindow` 断言"一条消息一行且断行留痕" + 能力段三条形状断言),smoke-core 68→**69**,主套件与 load-check 80/80 全绿。**没做的**:表情包标题仍写"共 N 个"却只列 `memes.topK`(默认 8,`lib/memes/store.js` + `lib/mind.js` 调用点),用户在批准范围外,没改;回合中改记忆会触发宿主把整份 runtime 快照重注一遍(`RuntimeContextProjection.project`,只差一行也整份追加),属宿主侧机制,只能少触发。 - **表情包清单标题把"列出的条数"和"全库总数"分开写(`m09114` 用户指出)**:真机上是 128 个表情包、`memes.topK` 默认 8,标题却写"共 128 个"而清单只有 8~13 行,读起来像漏注入了一百多个。`MemeStore.renderForPrompt` 的标题改成 `表情包库(用于发送;下面列出常用的 N 个,全库共 M 个;…)`——`N` 取实际渲染行数(`limit` 高频项 + `recentShown` 最近用过,去重后),`M` 才是 `total`。**为什么两个数都要写**:只写一个必然在某种配置下撒谎(写 total 就与清单对不上,写列出的又让人以为库里就这些),而这类"数字对不上"的标题会让模型以为清单不可信、去猜 id。验收在 `test/memes.mjs` 那条"最近使用过的必须出现"里加两行断言(标题形如匹配 + 标题里的 N 等于实际 `- m` 行数)。 - **live 上下文在一轮之内冻结:回合中改记忆不再把整份上下文重注一遍(`m09285` 用户定案方案 A)**:上一条留下的尾巴(表情包标题那次的"没做的②")查清了根因。宿主 `@deepseek-ai/dsh-agent-loop` 的 `preStep` 在**每个步骤边界**都做 `assemble → project`,而 `RuntimeContextProjection.project` 的判据只有 `if (this.retained?.text === snapshot) return;` ——**文本变一个字节就把整份上下文 append 成一条 user message(追加,不替换)**。真机日志实锤(11:29 那轮私聊 `%TEMP%\snap.txt`,转录 seq 9 与 seq 19 两份快照,3649/3619 字符,相隔 10.4 秒):两份**唯一**的差异是模型自己删掉短期记忆 `4. FunnyPotato 是 QQ 945126014`(已同文本提升到长期)——为了这一行,重注了整份 3.6KB。修法:`Mind` 加 `#liveCache`(sessionKey → `{text, at, hits, composed}`),`liveContext()` 命中缓存就返回同一份,`invalidateLiveContext()` 在**投递前**调(`pool.wake` 的两处:正常 flush 与 `reawake` 换预设);插话走 `pool.steer` 不重新投递,故同一轮内插话也不刷新——语义正好是"一轮之内文本稳定"。代价:模型本回合看不到自己刚写的记忆(`onebot_memory` 的回执照常拿得到),下一轮解冻就能看到。**为什么没走"把 live 快照从 `systemPrompt.context` 改成 `.section`"那条路**:那条确实永不进转录,内容也一字不变(`section.text` 同样是函数、同样每步现算),但**系统提示是前缀**,记忆一改就整段前缀失效、整段对话历史重新计费——比追加在末尾贵得多,等于拿缓存换转录整洁。**顺带查清并更正一条误判**:`memory/assembler.js` 的 `renderCues`(【还没兑现的事】段,`ago()` 输出"42 分钟前/5 小时前"这类相对时间)曾被怀疑是"每分钟重注"的元凶——实机那两份快照里**根本没有这一段**(`collectCues` 没选出可见的 open 承诺),之前的计数是匹配到了指导语里「上下文里的「还没兑现的事」…」那一行,属误报。冻结落地后这个隐患也随之无害(同一轮文本不动,跨轮才重算,正是想要的)。验收:`test/m15-e2e.mjs` v4 场景里加两条——"回合中改记忆不会让 live 上下文变"(改完再取必须逐字相等,且冻结的那份不含刚写的)与"投递前解冻后拿到新记忆、`composed === 1`";`statsSnapshot` 新增 `liveContext` 诊断行(`chars/at/ageMs/hits/composed`,真机上 `composed` 应远小于 `hits`)。主套件 217/217、load-check 80/80 全绿。 - **【本群认识的人】:上下文里不再只有当前对话者那一张卡(`m09409` 用户批准"可以,不用限死 10 个,加个配置项")**):用户问"agent 能不能看到记忆里全部本群群友的信息"——取证结论是**不能**:`lib/mind.js` snapshot 只传 `person: profiles.person(actorId)`(单数),`renderPerson` 渲染的就是那一张卡;别人的档案不进上下文。**唯一扫到所有人的地方**是主动回忆(`collectCues` 拿 `profiles.persons()`),所以上下文里可能出现"谁欠了什么",但不涉及 facts。机器上 9 份档案(`~/.dsh/onebot-hub/persons/*.json`,全在 `group:617770183`)里只有本人有 facts=3,其余 8 个是拉成员表建档的空壳——**即使注入也没内容**,这正是"群友记忆其实几乎不存在"的事实。改法:新增 `renderPeopleRoster`(`lib/memory/assembler.js`)+新段 `people`(order 34,在人物卡 33 之后、群档案 35 之前;group/memory 两条 order 顺延),数据来自 `profiles.persons()`,条数走**新配置项 `memory.peopleRoster`**(默认 20,`0`=整段不注入;用户明确否掉"写死 10")。四条取舍:①**只列本群**——判据是档案里有 `groups[worldKey]` 这条记录(`noteGroupCard` 写的),不按群过滤等于跨群名单泄漏;②**排除当前对话者与 bot 自己**(前者有完整卡片,后者不是"认识的人");③**不展开别人的 facts 正文**,只给条数——facts 必须逐条过 `filterForScope`(§24.5)才准进 prompt,在名单里展开等于绕过隔离,正文是 `onebot_person` 的活;④**不渲染相对时间**——宿主 `RuntimeContextProjection.project` 按 `this.retained?.text === snapshot` 逐字节判重,"3 小时前"这种一变就把整份快照 append 一遍(这正是 `renderCues` 会抖的坑),排序用 `g.lastSentAt ?? relationship.lastInteractAt ?? updatedAt` 但**只排不渲染**。额外两处细节:名片/角色与名字重复的要去重(真机上一半人名片=昵称,否则渲染成"战舰世界yuyuko助手(2854209449)|战舰世界yuyuko助手");`impression` 逐条不过滤,与人物卡保持一致(人物卡里它本来就是原样渲染的),真正带可见性标记的只有 facts 而 facts 只出条数。**私聊不注入这一段**(`assembleContext` 判 worldKey 前缀)。回归:`test/smoke-core.mjs` 新增一条 12 项断言(一行一人/只列本群/排除当前对话者与 bot/不展开 facts/无相对时间/上限走配置且超列时明说"还有 N 人"/`0` 关闭/私聊为空/assembleContext 里人物卡在名单之前),`test/m15-e2e.mjs` 新增一条真链路断言(`noteGroupCard`+`upsertPerson` → `liveContext` 里出现本群人、不含他群人、不含 facts 正文),`test/load-check.mjs` 断言默认 20,并给 `lib/client.js` 的 FIELD_TEXT 补中文名与说明(否则"每个配置项都有中文名"的用例会红)。全量 npm test 绿、load-check 80/80、m15-e2e 218/218。 - **清掉 guidance 里"同一轮里多次调用只有最后一次会发出去"的旧覆盖语义(`m09597` 用户点名)**:用户问 prompt 里那条规则还在不在。取证:`grep '只有最后一次|最后一次会发|多次调用'` 全仓 8 处,**唯一还写着旧语义的地方是 `lib/mind.js` 的 `DEFAULT_GUIDANCE`**(硬边界里那条插话指引的尾部:"…想改口就再调一次 onebot_reply——同一轮里多次调用只有最后一次会发出去。"),而它与 `m06083` 定案 A(每次新调用先 `reply.take()` 上一批经 `deliverNow` 立即发出,再排本次)**直接矛盾**;工具描述(`lib/index.js` `makeReplyTool`)、`lib/reply.js` 头注释都已是正确口径,`docs/design-notes.md` 里那条是历史记录(照旧不动)。**为什么必须删而不是留**:这句不只是过时——它正好教模型"前几批会蒸发",于是分批发长报告时它以为说完了;`14:22` 事故里模型"报告已发完,保持沉默"的根因就是这条(当时配套的覆盖回执一起骗了它)。改法:换成"每多调一次,上一批排队的会立刻发出去,一轮里多次调用的内容都会按顺序到达,不会只剩最后一次",并在上方留注释写明旧版是定案 A 之前的覆盖语义、留着会诱发 14:22 那种误判。锁死:`test/load-check.mjs` 在"回复指令/prompt 三处一致"那组用例里加两条——`DEFAULT_GUIDANCE` **不得**包含"只有最后一次",且必须含"多调一次|多次调用的内容都会按顺序到达"(为此在 load-check 里 `import { DEFAULT_GUIDANCE } from '../lib/mind.js'`)。全量 npm test exit 0(load-check 80/80、m15-e2e 218/218)。 - **上下文快照的"逐字节判重"与段内易变字段:roster 排序改天粒度、cues 改绝对日期(`m09699` 用户定案 A)**:用户看日志发现"每批次进来都会再给会话加一段 Current runtime context",问是不是应该只加一次。**机制先查清**(app.asar `@deepseek-ai/dsh-agent-loop`):`RuntimeContextProjection.project()` 里 `if (this.retained?.text === snapshot) return;` ——**不是每回合无条件追加**,内容逐字节相同就不加;而且 `retained` 跨回合保留(构造时从 session 历史里回放自己那条 user/message 恢复,之后随 owned 事件更新),所以"整场会话只加一次"在机制上成立,前提是文本真的不变。**真机取证**(群 617770183,14:21–14:28,`amv7q24t9qykx`):2 回合 → 2 段,逐行 diff(6040 vs 6038 字符)**只差【本群认识的人】这一行的顺序**。**根因三跳**:`lib/hub.js` `noteGroupCard(…, { lastSentAt: at })` **每条群消息**都刷新发言人时间戳 → `lib/memory/assembler.js` `renderPeopleRoster` 的主排序键就是 `g.lastSentAt` → 有人说话就重排 → 6KB 快照变一个字节 → 整份重注。回合之间至少一条新消息,于是"每批次必变"。**教训**:`m09409` 只做到"不渲染相对时间",漏了"排序键本身每条消息都在动"。修法(定案 A):① roster 主排序键改成"最近露面**所在的那一天**"(复用文件内 `day()`,本地时区 YYYY-MM-DD),同一天按 QQ 兜底 → 一天最多重排一次,标题同步改成"按最近露面的先后排,同一天内按 QQ 排";② `renderCues` 的"3 小时前/2 天前提过"改成**绝对日期**(`day()`),`ago()` 整个删掉——同一类抖动,cues 当时漏了 roster 那条规矩。文件头新增**硬约束 3**:段内不许出现每回合都在变的东西(时间写绝对日期、排序键取天粒度)。**回归**:`test/smoke-core.mjs` 新增一条(同一天 +10 分钟重渲染必须逐字相同;跨天重排顺序 老张/小王/小李),并给 cues 那条补两条断言(不出现"X 分钟/小时/天前"+一小时后重渲染逐字相同)。**真机验证**:直接读 `~/.dsh/onebot-hub/persons/*.json`(9 份)跑渲染器,把所有人 `lastSentAt` 往后推 10 分钟 → 文本 IDENTICAL。代价:名单不再精确到"谁刚刚说了话"(模型从对话本身就能知道谁在说),换来每回合少 6KB 重注。全量 npm test exit 0(smoke-core 71/71、m15-e2e 218/218、load-check 80/80)。 - **README 补全 12 条聊天命令 + 新增 `docs/config.md` 配置全表(`m09841` 用户:"给readme加上所有的指令列表及其用法,在docs中加上一个所有的配置项及其作用的介绍文档")**:两块交付。①README 新增「聊天里的命令(12 条)」一节(命令/别名/作用三列 + 三条语义:命令不吞消息、前缀撞车两个都触发、只在开头认前缀;另点明 `/perm` 只管本次激活),并把「常用配置」表降级为"真正需要手改的那些"、指向 `docs/config.md`。②`docs/config.md` 覆盖 **137 项**配置(= schema 叶子数,与 `lib/client.js` 的 FIELD_TEXT 条数一一对应),每项给「键/类型/默认/作用」,按 20 个族分组(上游、下游、事件投递、链路维护、探针隔离、证据与保留、agent、能力闸门、媒体、回合配对、用法学习、代码与群资料、识图、人设、聊天命令、表情包、番剧识别、生图、记忆、总开关),每族开头一句"这族管什么",结尾附落盘物与排查入口。**生成方式是一次性脚本**(`tmp-logscan/gen-config-doc.mjs`,不入库):`import` 真 schema 走一遍 `dict` 拿到叶子路径 + `meta.default`/`Config({})` 拿到默认值,浏览器半侧那份 `FIELD_TEXT`(`label`+`note`)用括号配对抠出来 `JSON.parse` 合并——**说明文字只有一份来源**(设置页的中文名),所以文档和设置页天然一致。**默认值的坑**:schemastery 的默认值不住在 `node.default`,得读 `node.meta.default`,且 volatile 字段要用 `.get()` 解包;两者取不到才显示 `—`(`upstreamListen` / `downstreamTargets` 这类"不配就是空"的键)。**防漂移**:`test/load-check.mjs` 新增一条「配置文档:docs/config.md 里每个 schema 叶子都有一行,且没有多余的键」——正则抠表格第一列的反引号键,与 schema 叶子清单对账,少一行(加了配置忘了写文档)多一行(文档里留着已删的键)都红,和 FIELD_TEXT 那条对账同一个思路。load-check 80→**81**。 ## 10. 相关文档 - 方案(完整版,按阅读顺序重排):`../docs/dsh-onebot-hub-方案-完整版.md`(§0 一页纸 → §16 全知时间线、§17 用法学习、§18 透传保真度、§21 记忆/上下文/Agent 接入、§22 能力全覆盖、§23 聊天拟人化、§24 拟人化记忆、§19 M2 前置实验) - 方案(原稿,章节号与完整版一致):`../docs/dsh-onebot-hub-方案.md` - OneBot v11 action 清单(标准 39 + 扩展 177,含"同名不同形"与"标准拿不到的信息"):`../docs/onebot11-action-inventory.md` - OneBot v11 协议:`.research/onebot-11/`