Files
Cyrene_v2/Docs/api-client.md
T
AskaEth bdf1494319
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(contract): v0.98 erratum - sixth speak error code is invalid_args not not_configured
2026-10-27 22:01:50 +08:00

2259 lines
456 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.96`**(上一版 `v0.95`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
> **v0.71 是链路图补版(已实现,纯增量)**:新增 1 条管理只读端点 **`GET /api/v1/admin/flow/graph`**,把 `ws.graph_update` 的**最近一轮快照**从「只能靠实时帧拿」变成「打开页面即可只读拉取」(§2.2 B13 / §4.11)。修的是「页面在轮次结束后才打开(或刷新过)⇒ `/#/admin/flow` 与后台思考行的『查看完整链路导图』永远空着」—— `graph_update` **只走实时帧**,前端图桶(`flowGraphs`)**不持久化**,此前**没有任何**补拉通路。**无破坏性变更**(新端点;`graph_update` 帧形状、角色门、发布会话范围**一字未改**)。
> **v0.72 是主动推送可见性版(已实现,含一处行为变更)**:`proactive_message` 那条消息现在**落进主会话历史**(`web` / `assistant` / `mode:"chat"`,先落库再发帧),帧新增可选 `id`/`createdAt`(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前**只广播帧、一行都不写**,刷新即丢且前端零消费点。**两条路由互斥 ⇒ 主会话里恰好一条(不双份)**;`proactive_message` 帧**逐字不变的部分**是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。
> **v0.73 是链路图本地落盘版(已实现,纯增量)**:链路图从「只活在进程内存」变成**每轮一行落进本地库**(新表 `flow_graphs`,迁移 `0022`),`GET /api/v1/admin/flow/graph` 在 v0.71 之上**增量**:新增可选查询参数 `turnId`(省略 = 最近一轮;**先查内存 `LastGraph`,miss 才回退落盘**)、响应新增 `turnId` / `source` / `turns[]` 三键(**多轮历史回放**,`design/19 §4` 的 P3)。修的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然每次重载之后页面就没数据了」—— v0.71 只覆盖「后端进程活着时的最近一轮」,后端一重启、或翻更早的轮次,仍然什么都查不到。**无破坏性变更**(旧键 `sessionId`/`graph` 形状语义不变,`graph_update` 帧、角色门、发布会话范围一字未改;老客户端不读新键即行为与 v0.71 相同)。落盘范围**只有**发布会话白名单内的会话(`flowSessionAllowed`),隔离渠道会话绝不入库。详见 **§2.2 B13 / §4.11**。
> **v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量)**:`message_persisted` 的 `data` 新增**可选**结构化判据 `proactive:true` —— IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧,客户端据此在**非前台**补一发浏览器通知(此前通知只挂在 `proactive_message` 上,而 `auto` 提示在主人有已连接 IM 渠道时**优先走 IM** ⇒ 实际最可能发生的路径恰恰不弹)。判据是**结构化字段**(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧**永不**带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条**不带**该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 **§3.4.4 后的「`message_persisted` 的 `proactive` 判据」**。
> **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **该默认值已由 v0.93 改为 `90`** —— 当时登记的值是准确的,见 §6 v0.93);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
> **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。
> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。
> **v0.85 是「整理不再只产建议」版(已实现并推库 `6d92536`;**新增 1 条 writeAuth 端点 + 一处行为变更(整理默认自动落地)+ `link` 候选停产;无破坏性变更**)**:① **新增端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)** —— 按**动作**批量把 `pending` 候选标成 `rejected`(**幂等、只改候选状态、绝不碰记忆行**)。`action` **必填**且是**闭集**(`merge|link|archive|reclassify|keep`,未知值 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400`。`200 {data:{action,limit,total,rejected,truncated}}`(`total` 是命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 `truncated:true`)。审计 `memory.tidy.reject`(只带计数)。语义见 **§4.15(e)**。② 🔴 **行为变更(默认生效):整理在轮末会「自动落地」两类高置信动作**,不再只产建议(主人 2026-10-07 口径「该删就删」)——**可自动的只有**:`merge`(**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」)、`archive`(理由 = `one_off_event` **且不像习惯**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、**受限来源(群聊/陌生人)**。六条护栏:软动作(正文不改、行不删)/ **每轮上限 50**(硬顶 500,超限如实 `truncated`)/ 审计 `memory.tidy.apply`(`auto=true`)/ **可 revert**(回滚路径与人工应用同一条)/ 幂等 / **一键关回只产建议**(`$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`,**零新 env 键**)。⚠️ **面板侧的候选列表仍可能显示历史 `link` 行**,直到用 B25 清掉。③ **`link` 候选停产**:`link` 在 `design/23 §14` 里被列为**未细化**(既无落点也无数据模型),且 `applyable()` 只认 merge/archive/reclassify ⇒ **应用 link 恒 `skipped`,产出它只是往待办里灌噪音**。现在规则层不再产出(判 `keep`);`TidyLink` 常量保留**只为读历史行**。存量用 B25 清(主会话已执行)。④ **计数**:`writeAuth` 段 24 → **25**、管理路由总数 78 → **79**(`14 + 25 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 **27**,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **25**)。⑤ ⚠️ **诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**(不能重启后端);`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(本次不改变任何判定结论,升版会让既有候选按幂等键复制出重复行)。
> **v0.84 是「显示名 + 段落标签硬约束」版(已实现并推库:`c46053e` 显示名 / `8051906` 段落修复 / `bc1dee3` provider 配置面去 env 化;**一处形状变更(插件动作响应)、一处新增硬约束;无破坏性变更**)**:① **`plugin.providers` 候选响应形状:裸字符串数组 → 对象数组 `{"options":[{"value":<id>,"label":<中文显示名>},…]}`**(§4.14(c))。修的是主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名,你甚至不愿意带个括号把中文名写上去」—— 根因不是漏写文案,而是**整条链路没有「显示名」这个位置**(字段声明只有 `optionsFrom`,动作回的就是纯 id 数组)。🔴 **两条硬口径**:**(i) `label` 仅供显示,`value` 才是落盘值**(把 `label` 提交进 `provider` 会写坏配置文件);**(ii) 前端解码器兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**零影响**。表格单元格按 `value→label` 索引显示、查不到回落原值;多选(降级链)改为「候选开关(显示 label、写 value)+ 落盘值输入框」。② **产出侧新硬约束(§4.16)**:助手文本(落库 / 对外 / IM 镜像)**不得出现未配对的段落标签**(落单 `<p …>` 或 `</p>`)—— `一个 <p> = 一段`,**段落不嵌套**。修的是安卓端登记的 `<p><p>正文</p>` 畸形(他们按非贪婪契约必然把 `<p>` 显示给用户);**实测两半都是产出侧造成**:规整器**原样放过**该形状,且会在「末段漏 `</p>`」时**亲手套出**它(并顺带修掉一个幂等漏洞:落单 `</p>` 会越跑越多)。🔴 **历史行不回填**,消费方**仍应保留**剥落单标签的容错。③ **模型面登记(非客户端契约面,供跨端对照)**:`web-fetch` 的**浏览器渲染降级档已接线**(`design/28 §1 M5 / §8 B5`),其返回体用 **`fetchedBy`**(`direct` / `browser`)**如实标注正文实际来自哪条路**,两条档的失败各自留痕(`browserError` / `directError`),**绝不静默替换**;被 SSRF 拒绝的目标**不换路**。④ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**。⑤ ⚠️ **诚实边界**:**`web-search` 的真机工具路径在登记时仍有一处未部署的修复**(智谱对「热搜/资讯」类查询返回的条目 `link` 整批为空,旧判据把「无链接」当「不是结果」整批丢光 ⇒ 工具报 `no results` 而面板探针报 `ok` —— 因为探针**不过 `Finalize`**,对这一类缺陷**结构性失明**)。修复已推库(`2c56bc9`:无链接条目照原样保留、只有 link/title/content/snippet 全空的空壳行才不算结果),**要等下次 `build` + `restart` 才真机生效**。**教训登记**:**别拿面板探针通不通来推断工具路径通不通**。
> **v0.83 是记忆整理「应用面」版(已实现并推库:切片 TIDY-1 `454ddb0` + 读侧 status 闸 `9126d65` + 网关端到端回归 `f847769`;**纯增量、无破坏性变更**)**:① **新增 5 条 `writeAuth` 端点**,补上「整理只产建议、永远不落地」这个两天没闭环的缺口 —— **B20** `GET /api/v1/admin/memories/candidates`(候选列表)、**B21** `POST …/candidates/{id}/apply`(单条应用)、**B22** `POST …/candidates/apply?runId=&limit=`(批量应用;`runId` 省略 = **全部待定**)、**B23** `POST …/candidates/{id}/revert`(单条回滚)、**B24** `POST …/memories/tidy/run`(手动**真跑**一轮:写候选 + 留痕,**不自动应用**)。语义与形状见 **§4.15**。🔴 **三条硬口径(客户端按此编程)**:**(i) 幂等不是错误** —— 已决定过的候选回 `200` + `status:"skipped"`,界面**不得**画成失败;**(ii) 超限必须如实** —— 批量回执的 `total` 是**真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内,两者不等时 `truncated:true`(不许把「本轮 100 / 共 342」画成「全部完成」);**(iii) 永不物理删除** —— merge / archive 都是**软动作**(只改 `status`,正文一个字不改、行一行不删),因此 `revert` 无损可逆。② **读侧 status 闸(行为修复,形状零变化)**:记忆的**四条列表读路径**(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 **`status='active'`** 的行 ⇒ 被软合并(`'merged'`)与被归档(`'archived'`)的记忆**不再被召回、不再进上下文、也不再出现在面板 `GET /admin/memories`**(此前它们照样出现 ⇒ 等于「清了也白清」)。🔴 **刻意不过闸的两处**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕**本身没有 status 概念**,且它是软删行的查看与撤销入口 —— 加闸等于审计自我抹除)。③ **调度不再饿死(内部行为,对客户端零形状影响)**:整理从「ticker 从进程启动算起、重启十几次就永远走不完 24h」改成**到点即跑 + catch-up**,以 `memory_tidy_runs` 的留痕行为**持久事实**判「上次到底跑过没有」,并把手动真跑端点(B24)做成 panic button。④ **计数**:`writeAuth` 段 19 → **24**、管理路由总数 73 → **78**(`14 + 24 + 40`;按 `routes.go` 逐条核对:经 `writeAuth` 挂载的 **admin** 路由实有 **26** 条,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**)。⚠️ **诚实边界(别当已验证)**:**生产库全程零动作** —— 1191 行记忆与 77 条待定候选**都没有被应用或清理**(等主人拍板,见 §4.15(d));**面板上的真机端到端未跑**(前端操作面在本版登记时尚未落地,并行切片在做);旧候选的 `run_id` 是空串 ⇒ 「**按 run 批量**」找不到它们,要清它们必须走**全部待定**那一档;候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore`),**未**同步进迁移 `0019`。
>
> **v0.82 是三面增量版(① 已实现(切片 Y1 `215eedf`);② 已实现(切片 Y3 `4213b26`);③ 形状已定、代码在树(切片 Y4 `e0a6bca` + 组合根挂点);**纯增量、无破坏性变更**)**:① **`tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)** —— 工具卡从此能在**实时**显示「这次调用拿到了什么」,不必等事后翻回看面。它修的是「实时只看到工具名与状态、结果要事后才知道」。🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态** —— **内容与回看面 `toolCalls[].resultSummary` 同源同口径**(同一份**已脱敏**文本 `trace.RedactText`:凭据形状清洗 + 截断)、**同一个上限 `≤500` rune**(超长按 rune 截断、末位 `…`);**空值 ⇒ 整键缺席,绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空;`tool_execution.start` / `.update` **不带**该键。⚠️ **失败也带**(本版口径):调用失败 / 取消 / 超时的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与回看面(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。详见 **§3.4.2**。② **自排的成本可见面**(切片 Y3,design/12 §N C.5/C.6)—— `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(取值域 = **闭集**:三个内建标签 + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`;值**未知或为空** ⇒ **`400 invalid_trigger`**,**绝不静默忽略** —— 静默忽略会让面板在显示全部的同时声称「正在筛选」;合法值**下推 SQL**,因此 `limit` 数的是**匹配**条数);新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e),**三态** `available`/`running`/`reason` + `gate` 快照 + `configFile`)。⚠️ **`trigger` 的「显示口径」仍是开放的**(客户端按字面值显示、不做闭集校验)—— **筛选白名单**与**显示取值域**是两件事,别混(见 §4.10(d))。③ **`browser` 插件管理面板(B4)**(切片 Y4,design/28 §8)—— `GET /api/v1/admin/panels` 的面板目录新增 `browser` / `settings`(「浏览器与截图」,4 个区块:配置表单 + 状态 / 指纹 / 截图三张只读表),该插件声明**六个动作**(3 读 3 写,见 §4.14(d));**不开任何新路由**(走既有 A14 通用动作分发),因此 §2.0 计数**只**因 ② 的 self-wake 端点 +1。**模型面**(非客户端契约面)另有一条:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`)。**④ 本版把 §4.10(d) 一句已成假话的口径改正**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`;启动日志 `self wake tool registered tool=request_think`)⇒ `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**在生产里会真的出现**。**⑤ 计数**:`writeAuth` 段 18 → **19**、管理路由总数 72 → **73**(`14 + 19 + 40`)。⚠️ **登记时的工作树状态(如实)**:③ 的**组合根两个挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)与 ① 的**失败也带摘要**(`internal/runtime/loop/tool_frame_summary.go`)在本版登记时**仍在工作树、未提交** ⇒ 提交前,跑着的后端里**没有**这些行为;提交后请按 `git show` 逐字复核。
> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。—— ✅ **本条已过期,已由 v0.82 ④ 改正**:`request_think` 已接线(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`),生产里**会真的出现**。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面(`GET /admin/panels` 多一条目录项 + 六个动作走既有 A14),见 **§4.14(d)**。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
---
## 0. 基础约定
### 0.1 基址与版本
- **Base URL**:`http://<host>:<PORT>`。`PORT` 默认 **8080**(`env:"PORT" default:"8080"`);生产一般经反代/HTTPS,运行实例可能自定义端口。
- **框架唯一对外端口**:单二进制、单端口(运行实例实测 `:10942`)。REST、WS、管理 API、静态资源**全在同一端口**。🆕 **2026-10-07 口径补充(v0.88)**:这条「单端口」约束**只约束 Cyrene 框架自身**;**扩展插件**(渠道适配器 / 工具 / MCP server / 插件自拉的本地伴随服务)**可以开自己的监听端口、可以有自己的进程形态** —— 那些端口**不在本契约范围内**(客户端一律只连这一个网关端口)。原「`qq-obv11` 渠道 OneBot 反连 server 模式」那条例外随之**由「例外」升格为一般规则的实例**(见 `design/08 §4`)。
- **版本前缀**:
- 用户侧 REST:`/api/v1/{resource}`
- 管理侧 REST:`/api/v1/admin/*`
- 实时:`/ws/chat`(WebSocket)
- 破坏性变更升 v2;本文档内的纯增量变更不升。
### 0.2 认证与授权
| 凭据 | 用法 | 作用面 | 备注 |
|---|---|---|---|
| **Bearer JWT** | `Authorization: Bearer <JWT>` | 用户侧 + 管理侧**全部已认证端点** | RBAC:`owner` > `admin` > `viewer`(递增)。access token TTL **24h**(`tokenTTL`)。 |
| **`X-API-Key`** | 请求头 `X-API-Key: <key>` | 用户侧 + 管理侧全部已认证端点 | **命中签发表即 `owner` 级**(不做角色分档,裁决 15.4.3)。**带该头时只走 key 分支**:未知/已吊销/空值 → `401`,**绝不回落 Bearer**(fail-closed)。key 表未注入 = 空表(带头一律 401)。 |
| **WS 握手** | `?token=<JWT>` 查询参数**或** `Authorization: Bearer <JWT>` | 仅 `/ws/chat` | **只认 JWT**(`auth.Verify`)。**不接受 `X-API-Key`**。失败在升级前回 `401 {"error":{"code":"unauthorized",...}}`。 |
- JWT claims 的 `role` 决定授权分档;`sub` 是账号标识。
- `X-API-Key` 命中后 `sub` 记作 `apikey:<脱敏 id>`(审计可定位哪把 key,但绝不含密钥材料)。
- **身份/权限绝不来自提示词或 LLM 判断**(项目红线):一律由 channel 层强类型 `actor` 元数据 + 白名单 + 策略引擎决定。
### 0.3 响应信封
- 成功:`{"data": <载荷>}`(个别端点直接回二进制,见 §4.5 语音)。
- 失败:`{"error":{"code":"<机器可读码>","message":"<人读文案>"}}`。
- 状态码全集(代码实际使用):`200 201 202 206 304 400 401 403 404 409 413 415 416 429 500 501 502 503`。
- ⚠️ **`502` 是 v0.78 引入**(插件动作执行失败 ⇒ `POST /admin/plugins/{id}/actions/{action}` 的 `action_failed`,见 §4.14(b));其余状态码集合未变。
- ⚠️ **`206` / `304` / `416` 是 v0.79 起「实际使用」**(`GET /api/v1/media/{name}` 落地,见 §1.2 U19 / §4.5):`206`(`Range` 部分内容)/ `304`(条件请求)由 Go 标准库给出;`416`(`Range` 语法非法或范围不可满足)是**非信封**的 —— 标准库直写 `text/plain` + `Content-Range: bytes */<size>`,**不套** `{"error":…}` ⇒ 客户端**不要**拿 `error.code` 去解析 `416`。
- **客户端按 `error.code` 分支,不要按 `message` 文本**(文案会随修改变动)。
### 0.4 三档标注惯例
本文档与「变更记录」用三档描述每次变更对客户端的影响:
- **纯增量**:只新增端点 / 帧 / 可选字段 / 取值。老客户端不读新键时**行为逐字不变**。
- **行为变更**:既有端点/帧的语义或响应面**变了**,客户端需改处理(如新状态码、新时点)。
- **破坏性**:既有字段/形状被移除或改义,老客户端会坏。
### 0.5 未接依赖时的诚实边界(fail-visible)
后端某些端点依赖注入的存储/服务;未接线时**明确报错或退化为诚实空结果**,绝不返回假数据:
| 依赖 | 未接线时的行为 |
|---|---|
| `HistoryStore` | `GET /sessions` 回退到渠道会话视图;`POST /sessions`、`GET /sessions/{id}/messages`、`PATCH/DELETE /sessions/{id}`、`POST /sessions/{id}/read`、`PATCH/DELETE /messages/{id}` → `501 not_implemented` |
| 语音服务 | `POST /audio/*` → `500 voice_unavailable`;WS `voice_input` → `error` 帧 `{"message":"voice unavailable"}` |
| 渠道事件存储(`internal/runtime/events`) | `GET /admin/channels/events` 与 `.../events/counters` → `500 channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a);绝不回全 0 假汇总) |
| LLM usage 出口 | `GET /admin/llm/usage` → `500 usage_unavailable`(绝不回全 0 假汇总) |
| 活跃 SQLite 连接 | `GET /admin/system/storage` → `500 storage_unavailable` |
| 可观测日志缓冲 | `GET /admin/observability/logs` → `501`(组合根注入前) |
---
## 1. 用户侧 REST 端点
### 1.1 公开端点(无需认证)
| # | 方法 | 路径 | 状态码 | 要点 |
|---|---|---|---|---|
| P1 | GET | `/api/v1/health` | 200 | 存活探针 → `{data:{status:"ok"}}` |
| P2 | POST | `/api/v1/auth/login` | 200 / 400 / 401 / 500 | body `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`。access 24h;`role` 恒 `owner`;`refreshToken` 仅在接了 refresh 存储时出现(`AUTH_REFRESH_TTL` 默认 720h)。凭据错 → `401 unauthorized`。 |
| P3 | POST | `/api/v1/auth/refresh` | 200 / 400 / 401 / 500 | body `{refreshToken}` → 新 access + 新 refresh(默认**单次使用轮换**)。未知/已吊销/过期 → `401 invalid_grant`。`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh。未接存储 → `500 refresh_unavailable`。 |
| P4 | POST | `/api/v1/auth/logout` | 200 / 400 / 401 / 500 | 带 `{refreshToken}` → 撤单份 `{data:{revoked:bool}}`(幂等,未知也 200);带 Bearer 且无 body → 撤该账号全部 `{data:{revoked,revokedCount}}`(**两种响应形状不同**)。两者都无 → `400`。 |
> 登录/刷新/登出**公开**是刻意的:非透明 refresh token 本身就是凭据,故过期 access 的客户端仍能轮换/吊销它。它们对非法 token **fail-closed**。
### 1.2 用户端点
| # | 方法 | 路径 | 鉴权 | 状态码 | 要点 |
|---|---|---|---|---|---|
| U1 | POST | `/api/v1/chat` | owner/admin | 200 / 202 / 400 / 409 / 500 / 503 | 主对话入口,见 [§4.6](#46-chat-请求与响应面)。body `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?}`。 |
| U2 | GET | `/api/v1/sessions` | 任意已认证 | 200 / 500 | 会话列表 `{data:[{id,type,title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`。**排序:`pinned DESC, (type='main') DESC, created_at ASC`** —— 置顶的次级会话会排在主会话之前,**勿假定 index 0 是主会话**。主会话 `id="web"`、`type="main"`;子会话 `web:<uuid>`、`type="sub"`。history 未注入时回退渠道会话视图。 |
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
| U10 | POST | `/api/v1/documents` | owner/admin | 201 / 200 / 400 / 413 / 415 / 500 | **文档上传**,`multipart/form-data` 字段 `file`。首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同 `name` + `deduped:true`**(可当成功)。单文件上限 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 用默认)。见 [§4.5](#45-图片--文档--语音链路)。 |
| U11 | GET | `/api/v1/documents/{name}` | **owner/admin(必须 Bearer)** | 200 / 404 | 文档读取。与图片读侧**刻意相反**(必须鉴权)。响应 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`,**永不内联渲染**。名字非法/穿越/不存在统一 `404 document_not_found`。 |
| U12 | GET | `/api/v1/sessions/{id}/messages` | owner/admin | 200 / 400 / 404 / 500 / 501 | 热分页历史。query `?limit=&before=&after=&platform=&q=`,见 [§4.2](#42-分页与游标)。**会话不存在/已删除 → `404 session_not_found`**;**存在但无消息 → `200 {"data":[]}`**。 |
| U13 | POST | `/api/v1/sessions` | owner/admin | 201 / 400 / 501 | 新建子会话。body 可选 `{personaId?}` → `{data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。未知 personaId → `400`。**会话角色创建时固定、之后不可变**(见 U15)。 |
| U14 | DELETE | `/api/v1/sessions/{id}` | owner/admin | 200 / 403 / 500 / 501 | 删除子会话及其消息 → `{data:{deleted:<id>}}`。主会话不可删 → `403 forbidden`。 |
| U15 | PATCH | `/api/v1/sessions/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 会话元数据。body `{title?,pinned?}`(**至少一个**)。`title` trim、≤80 rune、空/纯空白 → `400 "title must not be blank"`(**空串不能清空标题**)。**`personaId` 键出现即 `400`**(任何取值,含 `null`/`""`),且**先于**会话存在性检查(未知会话 + personaId 也回 `400`)。响应只回显本次变更字段 `{data:{id,title?}}` / `{id,pinned?}`。 |
| U16 | POST | `/api/v1/sessions/{id}/read` | owner/admin | 200 / 400 / 404 / 500 / 501 | 推进已读游标。body `{before?}`(毫秒;缺省=现在)→ `{data:{sessionId,readAt}}`。游标**会话级单所有者、只进不退**(读一处 = 该账号所有端已读)。成功广播 `session_read` 帧;失败不广播。 |
| U17 | PATCH | `/api/v1/messages/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 编辑单条消息。body `{content}`(非空、≤4000 rune)→ **`{data:<消息 DTO>}`**(`data` 直接就是完整消息,**不再包一层 `message`**)。只改 `content`。成功广播 `message_edited`。 |
| U18 | DELETE | `/api/v1/messages/{id}` | owner/admin | 200 / 404 / 500 / 501 | 删除单条消息 → `{data:{deleted:<id>}}`。成功广播 `message_deleted`。 |
| U19 | GET | `/api/v1/media/{name}` | **owner/admin(必须 Bearer)** | 200 / 206 / 304 / 401 / 403 / 404 / 416 | **语音副本播放 / 取字节**(**v0.67 登记,v0.79 已实现**,切片 L45)。`Content-Type: audio/*`(按**存储名扩展名**映射,不查库、不嗅探);`Content-Disposition: inline; filename="<name>"`;`X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**(条件请求走 `Last-Modified`);支持 `Range` 与 `HEAD`;名字非法 / 穿越 / 不存在统一 `404 media_not_found`。⚠️ **`416` 不是 JSON 信封**(标准库直写 `text/plain` + `Content-Range: bytes */<size>`)。详见 §4.5 语音。 |
> **`GET /api/v1/files/{name}` 无鉴权**(能力 URL);**`GET /api/v1/media/{name}` 与 `GET /api/v1/documents/{name}` 必须 Bearer owner/admin**(与图片读侧「128-bit 名即凭据」的取向**刻意相反**);其余全部用户端点至少要求已认证。
---
## 2. 管理侧端点与权限矩阵
> 仅管理面板与授权客户端使用。RBAC `owner`/`admin`/`viewer`;**写操作全程审计**。
> 本节与 `internal/services/gateway/routes.go` **逐条一致**。
### 2.0 权限矩阵(口径与计数)
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 unauthorized`(fail-closed,全部管理路由无一例外)。
- **角色分档**(✅ 放行,❌ `403 forbidden`):
| 档位 | 中间件 | 条数 | 说明 |
|---|:--:|:--:|---|
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
| **owner/admin 读档** | `writeAuth`(owner/admin) | **31** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)**、**记忆整理调优键读写(v0.92,B26/B27)**、**渠道非消息事件与静默窗口四条(v0.95 补登,B28–B31)** |
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
| **合计** | — | **85** | `14 + 31 + 40`(v0.95) |
**管理路由总数 = 14 + 31 + 40 = 85 条(`routes.go` 逐条枚举;补齐 B28–B31 后与代码逐条相等)。**
> 🔴 **本节此前的「18 + 43 + 43 = 104」是一个口径错误,已改正**(2026-10-26 由切片 CONTRACTMATRIX 逐条复算):那三个数**不是本表三段的分组计数**,而是**整张表按中间件用量全文统计**的结果 —— `writeAuth` 全文 **43** = 本表 **31** + 用户面 12(`chat` / `upload` / `documents` / `media/{name}` / `history` 等);`ownerAuth` 全文 **43** = 本表 **40** + `audio/*` **3**;而「`viewerAuth` 18」**根本不是 viewerAuth**(它全文就是 **14**,且全部是本表路由),18 是「用户面被中间件包住」的条数(12 + 3 + 3)。把 18 与两个 43 相加 ⇒ 用户面那 18 条被**重复计入一次**。正确口径:被四种中间件包住的路由 = **85(管理)+ 18(用户面)= 103**,另有 6 条未包(4 条公开 + `GET /api/v1/files/{name}` + `/ws/chat`),字面量注册合计 109,再加 `mountWebhooks` 一条动态注册 ⇒ `grep -c` 得 110。
> ⚠️ 同理,此前「差 23 条遗漏行」也是巧合:**23 恰好是用户面路由总数**(§1.1 的 P1–P4 共 4 条 + §1.2 的 U1–U19 共 19 条)—— 用户面本来就一条不缺。真实缺口是 **4 条**(全在 `writeAuth`),已补为 B28–B31。
> ✅ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的形状级条目已登记**,见 §6 v0.94 / v0.95。
> ✅ **本表现与 `routes.go` 逐条相等**(A14 / B31 / C40 = 85),此前的「已知文档债」警告已失效、已删除。反向也核过:81 行旧条目里**没有一条**是幽灵行,且**零条分档错误**。⚠️ 唯一一处**文档已自行标注**的差异是 B17(代码 `writeAuth`,`design/23 §11` 对同族标 owner-only)—— 那是对**设计文档**的差异,不在本节范围。
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
> **v0.79 计数变更**:`writeAuth` 段新增两条(B16 `GET /admin/memories/reviews`、B17 `POST /admin/memories/{id}/revert`,切片 L45)⇒ 14 → **16**、总数 68 → **70**。⚠️ 同批落地的 `GET /api/v1/media/{name}`(§1.2 U19)是**用户侧**端点,**不**进入本矩阵。
> **v0.80 计数变更**:`writeAuth` 段新增一条(B18 `GET /admin/flow/timeline`,切片 W1)⇒ 段内**实有 18 条**、**管理路由总数实有 72 条**(`14 + 18 + 40`)。**本版一并回补了自 v0.76 起累积的 −1 记账滞后**(见下一段),故上表「条数」记 **17 → 18**、总数记 **71 → 72**。它只读、不审计,与同族的 B13(`GET /admin/flow/graph`)**同门同档**。
> ✅ **记账滞后已修正(2026-10-06,本版回补)**:按上一段「计数口径」那句(= 三张清单**列出的行数**)核,**§2.2 实有 18 行**(B1–B18)、**§2.1 实有 14 行**(A1–A14)、**§2.3 实有 40 行** ⇒ 逐行相加 **14 + 18 + 40 = 72**,与上表「条数」序列(`writeAuth` **18** / 总数 **72**)**已一致**(`routes.go` 交叉核对:admin 侧经 `writeAuth` 挂载 20 条,减去 §2.4(a) 按历史口径不计入本表的 `channels/events` 与 `.../counters` ⇒ **18**)。**根因**(S6 报告 §6.1 发现):v0.76 新增 B15 时**漏把条数 +1**,此后 v0.78 / v0.79 / v0.80 各版沿旧序列 +1 而未回补 ⇒ 那个 **−1 滞后从 v0.76 一路带到 v0.80 的登记值**。
> ⚠️ **上面 v0.78 / v0.79 两个计数段的绝对值仍沿用当时未回补的写法**(其**增量正确** —— `viewerAuth` 12 → 14、`writeAuth` 各版 +1 / +2 —— 但**绝对值比真实行数少 1**);**§6 里各版条目(v0.76 ~ v0.80)的同类计数同理**。按本文件「历史条目保留当时状态」的惯例**不逐版改数**,**以本表 + 三张清单的行数为准**。
> **v0.82 计数变更(实数,已按 `routes.go` 核对)**:`writeAuth` 段新增一条(**B19 `GET /admin/backthink/self-wake`**,切片 Y3)⇒ 段内 **18 → 19**、总数 **72 → 73**。三张清单的行数同步为 **§2.1 14 行**(A1–A14)/ **§2.2 19 行**(B1–B19)/ **§2.3 40 行**(C1–C40)。**核对方式(可复现)**:`internal/services/gateway/routes.go` 里经 `writeAuth` 挂载的 **admin** 路由**实有 21 条**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **19**,与 §2.2 的行数一致;`viewerAuth` 挂载 **14**、`ownerAuth` 挂载 **40**(逐条列举见 `grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*<middleware>'`)。⚠️ **本版另一个「看似该 +1、其实不动计数」的面**:`browser` 插件的六个动作(§4.14(d))**不开任何新路由** —— 它走**既有** A14 `POST /admin/plugins/{id}/actions/{action}`,因此 §2.1 的 14 与总数都**不变**(A14 那一行本就写明「写门落在 handler 内」,动作名由**声明**白名单决定,声明多一个插件不新增路由)。
> **v0.83 计数变更(实数,已按 `routes.go` 逐条核对)**:`writeAuth` 段新增**五**条(**B20–B24**:候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑,切片 TIDY-1)⇒ 段内 **19 → 24**、总数 **73 → 78**。三张清单行数同步为 **§2.1 14 行** / **§2.2 24 行**(B1–B24)/ **§2.3 40 行**。**核对方式(可复现)**:`grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*writeAuth' internal/services/gateway/routes.go` = **26**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**(`viewerAuth` **14** / `ownerAuth` **40**,与上表一致)。
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上表的 **writeAuth 条数**(v0.83 为 **24**)。逐条以代码为准。
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
### 2.1 viewer 可读(14 条,`viewerAuth`)
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
| A1 | GET | `/api/v1/admin/personas` | 人格清单(含 admin 专属 `thinking` 块) |
| A2 | GET | `/api/v1/admin/sessions` | 会话清单 |
| A3 | GET | `/api/v1/admin/plugins` | 能力/插件清单 |
| A4 | GET | `/api/v1/admin/plugins/installed` | 已安装插件清单 |
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 已实现**(本次落地;落地前基线 `f91a83f`):各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b);各元素**可能**带可选 `directMsgPerMin:number`(**v0.69 新增,已实现**):该账号**非主人私聊(陌生人)**每分钟每发送者上限,是账号**顶层**字段(与 `groupMsgPerMin` 同一条写路径,**不在 `config` 里**)。语义:**键缺席或 `0` ⇒ 保护性默认 6 条/分**(陌生人必须有流控,缺省绝不解释成「关闭」)、**正值 ⇒ 该值**、**负值 ⇒ 关闭该限制**(主人的显式选择)。⚠️ 因 `omitempty`,**`0` 与未设置线上同形**(既有约定,未改)**不含任何凭据** |
| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 |
| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 |
| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 |
| A9 | GET | `/api/v1/admin/observability/metrics` | 运行时指标快照 |
| A10 | GET | `/api/v1/admin/observability/trace` | 运行时 span |
| A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) |
| A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) |
| A13 | GET | `/api/v1/admin/panels` | **插件管理面板接入点快照(v0.78 新增,切片 PLG-2)**:**按调用者角色过滤**后的导航条目集合 —— viewer 只拿到只读区块(`status`/`log`/`table`,且 table 的行内动作被摘空、`form` 整体不出现、区块全空的面板整条不出现)。响应 `{data:{role,groups[],pluginIds[]}}`。未接面板中枢 → **`501 panels_unavailable`**(**绝不回空列表**)。形状与两条给客户端的口径见 §4.14(a)。**v0.82 起目录里多一条内置页**:`browser` / `settings`(「浏览器与截图」,切片 Y4,见 §4.14(d))⇒ 声明接线后 `pluginIds` 为 `["browser","web-search"]`(**已排序**)—— ⚠️ **登记时该挂点仍在工作树、未提交**,因此**在跑的那份产物里没有它**(真机已核:`:10942` 上 `GET /admin/panels` 需 owner 凭据,但**启动日志全篇 0 条 `browser panel declared`** ⇒ 该面板未接线)|
| A14 | POST | `/api/v1/admin/plugins/{id}/actions/{action}` | **插件动作分发(v0.78 新增,切片 PLG-2)**。⚠️ **读写面不同档**:路由走 `viewerAuth`,但**写动作在 handler 内按 owner/admin 收紧**(`form.submit` / `table.rowActions` 是写;`table`/`log` 的 `source` 与下拉 `optionsFrom` 是读,对 viewer 开放)。**只接受该插件声明过的动作名**(未声明 ⇒ `404 action_not_declared`,**不是**「没权限」)。请求体可选 JSON(空体 ⇒ `payload=null`);**宿主不接受任何来自声明的请求地址**(不给插件开 SSRF 面)。✅ **v0.79:生产执行器已接线**(切片 L1/PLG-2)—— 内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` 都已登记生产处理器,**真可用**(真读写可变源、探针真发一次 HTTP);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。错误码与响应形状见 §4.14(b)(c) |
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
### 2.2 owner/admin 读(31 条,`writeAuth`)
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
| B1 | GET | `/api/v1/admin/memories` | 记忆条目(含他人账号数据) |
| B2 | GET | `/api/v1/admin/channels/messages` | 适配器消息日志,`?adapter=&platform=` 三档粒度(`before`/`limit`) |
| B3 | GET | `/api/v1/admin/channels/{platform}/messages` | 单平台跨隔离会话消息(最新优先) |
| B4 | GET | `/api/v1/admin/llm` | LLM provider 栈(组 DTO,含 `contextWindow` 等) |
| B5 | GET | `/api/v1/admin/llm/usage` | LLM 用量汇总(切片 U1,v0.64)。`?since=&until=&purpose=`;缺省近 24h、跨度上限 90 天(超 → `400`);`hitRate = cacheHit/(cacheHit+cacheMiss)`,分母 0 ⇒ `0`。**不含凭据与消息正文**。未接线 → `500 usage_unavailable` |
| B6 | GET | `/api/v1/admin/llm/catalog` | 模型目录候选(`?refresh=1` 强制在线刷新)。既无快照又抓不到 → `503 catalog_unavailable` |
| B7 | GET | `/api/v1/admin/users` | 账号/API key 清单 |
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**默认是精确档位**,带 `urgencyLowerBound:true` 时才是下限,见 §4.10(b))与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增;档位的精确语义与 `urgencyLowerBound` 为 v0.70**。⚠️ 每条的 `trigger` 是**开放的**取值域(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮的**事件主题** / **`self_wake`(v0.81 登记)** = 她自己排起的一轮思考),客户端**按字面值显示**、不做闭集校验,见 §4.10(d)。🆕 **v0.82 新增可选 `?trigger=` 服务端筛选**(切片 Y3):取值域 = **闭集**(= 上面那些取值,真源 `backthink.TriggerFilterValues()`);**未知值或空值 ⇒ `400 invalid_trigger`**(**绝不静默忽略**);合法值**下推 SQL**,`limit` 数的是**匹配**条数。⚠️ **「筛选白名单是闭集」与「显示取值域是开放集」不矛盾**,见 §4.10(d)(e) |
| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 |
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读;v0.75 追加 3 键;v0.77 追加 1 键)**:记忆整理 **8** 键 + 后台思考心跳 3 键(`ENABLE_BACKGROUND_THINKING`/`THINK_INTERVAL_SEC`/`THINK_QUIET_INTERVAL_SEC`)+ 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
| B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量;v0.77 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,lanes[],turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;**v0.77 新增 `lanes[]`**(**最近一轮每一路**的图,每项 `{turnId,graph}`,按路的开始时间升序,恒为非 null 数组 —— 真并行时这才是全貌,`graph` 只给最新一路),`graph` 的既有语义**一字未改** ⇒ 老读取方零改动;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 |
| B14 | POST | `/api/v1/admin/memories/tidy/dry-run` | **记忆整理「试算」(v0.75 新增,只读语义的 POST)**:跑同一套整理规则、把候选**直接回在响应里**,**零副作用**(不写任何表)。请求体可为空。响应 `{data:{dryRun:true,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated?,candidates[]}}`;`candidates` 恒为非 null 数组(空数组 = 「算过了,没有建议」,与「没算出来」的错误响应**必须能区分**)。失败:未接线 → `500 tidy_unavailable`、配置关闭 → `409 tidy_disabled`、语义召回不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`。**不审计**(只读计算)。语义与形状见 §4.12 |
| B15 | GET | `/api/v1/admin/memories/tidy/dry-run/{runId}` | **异步试算结果查询(v0.76 新增 · ✅ 已实现(v0.77 校正))**:配合 `POST …/memories/tidy/dry-run?async=1`(后者仍属 B14,`async=1` 时回 `202` + `{data:{runId,status:"running",startedAt}}`)轮询取回结果。响应 `{data:{status:"running"|"done"|"failed", result?, error?}}`:**`running` 时 `result`/`error` 两键都缺席**(绝不回一个空 `result` 冒充「跑完了但没有建议」);`done` 时 `result` 与 §4.12 同步响应的 `data` **逐字节同形**(**同一套 JSON,不是第二份**);`failed` 时 `error` 为 `{code,message}`(`code` 沿用 §4.12 的失败码)。⚠️ **结果只活在进程内存**(单二进制、零跨进程):TTL **30 分钟**(从**完成时刻**起算)、同时只允许 **1** 个在跑(**第二个请求复用既有 `runId`**,不引入新错误码);进程重启后旧 `runId` **如实失效** ⇒ `404 tidy_run_not_found`,**绝不假装还在跑**。鉴权与 B14 同档(`writeAuth` = owner/admin)、同样**不审计**。语义见 §4.12.1 |
| B16 | GET | `/api/v1/admin/memories/reviews` | **入库前审查(D3)留痕列表(v0.79 新增,切片 L45;✅ 已实现)**。`?limit=`:缺省 **100**、`≤0` 同缺省、> **500** 钳到 500、非整数 → `400 bad_request`(钳位不报错,与渠道事件查询面同一约定)。响应 `{data:[…]}`,元素键 camelCase:`id` / `itemId` / `personaId` / `scope` / `projectId`(可选)/ `decision` ∈ `skip\|merge\|modify\|create` / `reason`(可选,规则依据)/ `veto`(可选,命中的否决规则)/ `targetId`(可选,命中的既有条目)/ `mergedIds`(**恒为数组**)/ `score` / `recallMode`(可选)/ `degraded` / `ruleVersion` / `sessionId`(可选)/ `createdAt`(**UTC RFC3339**)。🔴 **不含记忆正文**(命中的 id 由客户端回表取原文)。未接线 → `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕);读取失败 → `500 reviews_failed`。只读、不审计 |
| B17 | POST | `/api/v1/admin/memories/{id}/revert` | **撤销一次软合并(v0.79 新增,切片 L45;✅ 已实现;design/23 §8「回滚」)**。**无请求体**。200 `{data:{reverted:"<id>"}}`;该行不在 `merged` 状态(id 拼错 / 已回滚过)→ `404 not_found`;未接线 → `500 memory_review_unavailable`;存储失败 → `500 revert_failed`。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`,并从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1);**行永不物理删除**。**落审计** `memory.review.revert`(who / result ∈ `ok\|error`;**只记 item id,不记正文**)。⚠️ 门是 `writeAuth`(owner\|admin);`design/23 §11` 表格对同族 `candidates/{id}/revert` 标的是 owner-only —— **口径差异待主会话裁决**(若以设计为准,路由换成 `ownerAuth` 即可) |
| B18 | GET | `/api/v1/admin/flow/timeline` | **链路图时间轴 / 多轮串联(v0.80 新增,切片 W1;v0.81 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionBoundaryReached,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。🆕 **v0.81(切片 X1)**:① 新增 **`retentionBoundaryReached`**(bool)= 「该会话确有更早的轮次已被保留期清扫」——**与 `truncated` 分意**,只有它 `true` 时面板才可说「更早的轮次已过保留期」(判据/盲区见 **§4.11.1(a)**);② **`round` 跨重启不再重复**(发布器先从落盘播种计数器,见 **§4.11.1(e)**)⇒ 按 `round` 分组不再把重启前后两轮并成一块。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** |
| B19 | GET | `/api/v1/admin/backthink/self-wake` | **自排闸只读快照(v0.82 新增,切片 Y3;design/12 §N C.5 的读侧)**,只读、不审计、不写库。**无参数**。响应 `{data:{available,running,reason?,configFile?,gate?}}` —— **三态必须可区分**(面板**不得**把「看不到」画成「0 次」):① `available:false` ⇒ 本进程给不出快照(后台思考未接线 / 实现不提供读口 / 闸不在),此时 **`gate` 缺席**、`reason` 说明原因(**HTTP 仍是 `200`**,不假装有一个 0/0 的闸);② `available:true, running:false` ⇒ 已接线但不在运行,`gate` **仍带**配置面数字(上限/窗口/代码默认值),`reason` 说明「此刻她排不出新的一轮」;③ `available:true, running:true` ⇒ 正常态,`reason` 缺席。`gate` 快照 = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`(`used` 与工具收据 `SelfWakeReceipt.Used` **同一口径**;`limit`/`windowSec` 是**进程生效**值,已 normalize)。🔴 **`limitIsDefault` / `windowIsDefault` 的语义**:报告生效值**恰好等于**代码默认值 —— **不是**「配置文件里没写这个键」的断言(网关**不读** `backthink.json`,与 config-facts 的 `isDefault` 同一条诚实口径:无从区分「配了 4」与「没配」)。`configFile` = **相对落点**(`config/backthink.json`,相对 `$DATA_DIR`)。⚠️ **它不含敏感信息、也不是写口**:改上限/窗口要改配置文件 + 重启(面板写路径**未做**)。语义与三态口径见 **§4.10(e)**。✅ **真机已核(路由存在性,无需凭据)**:`:10942` 上 `POST /api/v1/admin/backthink/self-wake` → **`405` + `Allow: GET, HEAD`**、`GET` 同路径无 token → **`401`**(负对照:同法打一个不存在的 admin 路径 → `404`)⇒ 这条路由**确在**运行中的产物里(⚠️ **不代表**已用真凭据拿到过 `data`;后者**未验**) |
| B20 | GET | `/api/v1/admin/memories/candidates` | **整理候选列表(v0.83 新增,切片 TIDY-1)**,只读、不审计。`?status=`(**省略 ⇒ 默认只回 `pending`**;落库取值是**闭集** `pending` / `applied` —— `revert` 会把候选退回 `pending`;⚠️ **`rejected` 尚未实现**,没有 reject 端点)+ `?limit=`(缺省 **100**、`>500` 钳到 500、非整数 → `400 bad_request`)。响应 `{data:{candidates:[…],count:N}}`,`candidates` **恒为非 null 数组**(空数组 = 「真没有候选」,与「没接线」的错误响应必须能区分)。每条:`id` / `action` ∈ `merge\|link\|archive\|reclassify` / `status` / `sourceIds[]`(恒为数组)/ `targetId?` / `personaId` / `scope` / `projectId?` / `kind?` / `proposedContent?` / `mergedContent?` / `reason` / `ruleVersion` / `runId?` / `scoreMin` / `scoreMax` / `createdAt`(**UTC RFC3339**)/ `evidence`。失败:未接线 → `500 tidy_unavailable`(**绝不回空数组**假装没有候选)、读取失败 → `500 tidy_candidates_failed`。语义见 **§4.15(a)** |
| B21 | POST | `/api/v1/admin/memories/candidates/{id}/apply` | **应用一条候选(v0.83 新增,切片 TIDY-1;design/23 §7/§8)** —— 唯二真正改记忆的粒度之一。**无请求体**。`200 {data:{candidateId,action,status,reason?,targetId?,affectedIds[]}}`:`status` ∈ `applied`(真改了)/ `skipped`(**幂等**:这条已被决定过 ⇒ 不是错误,界面不得报红)。🔴 **别把这个 `status` 与 B20 候选行上的 `status` 混读**:这里是**本次操作的结果**(`memory.TidyApplyStatus`,另有 `failed` 一档),B20 那个是**候选的落库状态**(闭集 `pending`/`applied`)。`affectedIds` = 这次真的被改动的 `memory_items` id(**恒为数组**)。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(`result` 以 `ok` 开头 + 动作/结果/受影响行数;**绝不带正文**)。🔴 **软动作**:merge = 被吸收行 `status='merged'` + `merged_into=<存活 id>`,存活行 `merged_from` 追加 + `merge_count`+1 + `confidence` 取较大者 + `updated_at` 刷新;archive = `status='archived'`;**正文一个字不改、行一行不删** |
| B22 | POST | `/api/v1/admin/memories/candidates/apply?runId=&limit=` | **批量应用候选(v0.83 新增,切片 TIDY-1)**。`runId` 给出 ⇒ 只应用**该轮产出的**候选;**省略 ⇒ 全部待定**(面板的「清全部」档)。`limit` 省略/空 ⇒ 领域侧默认 **100**;`>500` **钳到 500**;非整数或负数 → `400 bad_request`。`200 {data:{runId?,limit,total,truncated,applied,skipped,failed,merged,archived,reclassified,results[]}}`。🔴 **两条读数铁律**:① `total` = **真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内 ⇒ 两者不等时 **`truncated:true`**(客户端**必须**如实显示「本轮处理 N / 共 M」);② `results[]` 逐条给 `status`(**失败隔离**:一条失败不拖累其余)。失败:`runId` 查不到(**留痕行**不存在)→ `404 tidy_run_not_found`(与「这一轮没有待定候选」**不是一回事**);未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(带 run_id / limit / total / truncated / applied / skipped / failed)。⚠️ **旧候选(TIDY-1 之前产出的 77 条)的 `run_id` 是空串** ⇒ 「按 run 批量」找不到它们,要清它们只能走**全部待定**这一档 |
| B23 | POST | `/api/v1/admin/memories/candidates/{id}/revert` | **回滚一条已应用的候选(v0.83 新增,切片 TIDY-1;design/23 §8「回滚」)**。**无请求体**。响应形状与 B21 **逐字同形**(`{candidateId,action,status,reason?,targetId?,affectedIds[]}`)。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`、从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1)、候选退回 `pending`;**行永不物理删除**。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_revert_failed`。**落审计** `memory.tidy.revert`(与 B17 的 `memory.review.revert` **不同**:B17 按**记忆 id** 撤销 D3 的软合并,本条按**候选 id**,故不复用同一动词) |
| B24 | POST | `/api/v1/admin/memories/tidy/run` | **手动真跑一轮整理(v0.83 新增,切片 TIDY-1)** —— 不等 23:00 的 panic button:按**同一套规则与配置**立刻跑一轮,**写候选 + 写运行留痕**,**不自动应用**(人工在环,能落地的只有 B21/B22)。**无请求体**。`200 {data:{runId,trigger:"manual",ruleVersion,durationMs,partitions,scanned,compared,pairs,vetoed,errors,truncated[],vetoReasons{},candidatesCreated,candidatesDuplicate,candidatesByAction{}}}`。🔴 **如实计数**:`candidatesCreated` = 本次**新建**候选数、`candidatesDuplicate` = 被**幂等键**挡下的重复数(同一批数据重复跑 ⇒ 全进 duplicate,**不是失败**);`errors` 非 0 面板必须显示;`truncated[]` 是**被护栏截断**的分区标签(扫描/比对/候选上限,或墙钟预算 `[budget]`)。失败:配置关闭 → `409 tidy_disabled`;超时 → `503 tidy_timeout`(**已跑部分仍写入了留痕**);只接线了试算(没接真跑)→ `500 tidy_run_unavailable`;未接线 → `500 tidy_unavailable`;失败 → `500 tidy_run_failed`。**审计不在这里写** —— 由领域侧记一条 `memory.tidy.run`(`trigger=manual` + run_id + 计数),**一次点击一条**。语义见 **§4.15(b)** |
| B25 | POST | `/api/v1/admin/memories/candidates/reject?action=&limit=` | **按动作批量拒绝整理候选(v0.85 新增,切片 TIDY-AUTO)** —— 把某一类 `pending` 候选一次性标成 `rejected`(**幂等**:同一调用再来一次 ⇒ `total:0`;**只改候选 status,绝不碰记忆行**)。`action` **必填**、**闭集** `merge\|link\|archive\|reclassify\|keep`(未知/空 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400 bad_request`。`200 {data:{action,limit,total,rejected,truncated}}`。🔴 **两个计数口径不同**:`total` = 命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 **`truncated:true`**(客户端必须如实显示)。**落审计** `memory.tidy.reject`(只带计数)。用法见 **§4.15(e)**(`link` 停产后的存量清理就靠它)。⚠️ **不是「删除」**:候选行仍在,只是不再待办 |
| B26 | GET | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键读取(v0.92 新增,切片 M4)**,只读、**不审计**。**无参数**。`200 {data:{path,present,hot,hotError?,specs[],values{},envValues{}}}`。🔴 **三块分工(客户端按此编程)**:`specs[]` = **取值规则唯一真源**(`{key,kind,unit?,min?,max?,fallback}`;`fallback` 只给**来源** `env:<键名>` | `panel:<键名>` | `code-default`,**不给数值**);`values` = **盘上**的值,**键缺位 ⇒ 该键在对象里不出现**(= 跟随兜底),**不是 0**;`envValues` = 键缺位时**下一档(`.env`)**的进程生效值(连 `.env` 档也没有 ⇒ 该键不出现;网关绝不 fopen `.env`)。管 7 键:`intervalSec`/`quietIntervalSec`/`quietStartHour`/`quietEndHour`/`ingestGate`/`autoApply`/`tempTtlSec`;同文件不受管的键(`t1Judge` 等)不回显。🔴 **`hot` 恒为 false** —— 读不改变任何东西,这是**如实占位**,别读成能力声明。失败:数据目录未接线 → `500 tuning_unavailable`。语义见 **§4.17** |
| B27 | PUT | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键写入(v0.92 新增,切片 M4)**。门同 B26(`writeAuth`)。请求体 = **扁平 JSON 对象**,只放要改的键;值 int(秒/钟点)或 bool;**`null` = 清除该键**(回到下一档兜底)。🔴 **三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**;**未知键(含拼错)也归「非法」,绝不静默忽略**。`200` 与 B26 **同形状**,但值是**写完重新读盘**的真值(带 `hot`/`hotError`)。错误码:`400 invalid_payload`(值非法/未知键/体不是对象/体是 `null`)|`409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)|`500 tuning_unavailable`|`500 tuning_write_failed`(落盘失败,盘上未改动)。**两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。🔴 **`hot` 诚实口径**:`hot:true` = 值已叠到运行期(整理档会重排心跳,否则新周期要等旧周期那一觉睡完);`hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「**重启后端后生效**」—— **绝不把「落盘了」说成「生效了」**。**落审计** `memory.tidy.tuning`(`result` ∈ `ok\|error`)。语义见 **§4.17** |
| B28 | GET | `/api/v1/admin/channels/events` | **渠道非消息事件列表(倒序)**,只读、**不审计**。此前只登记在 §2.4(a) D1,本条是它的矩阵行。query:`platform`(闭集 `qq`/`kook`/`telegram`/`weixin-ilink`/`webhook`,未知 ⇒ `400`,**绝不降级成「不过滤」**)/ `tier`(`t1\|t2\|t3`,其它 ⇒ `400`)/ `category`(🔴 **不做白名单**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 `400`)/ `channelId` / `since` / `until`(RFC3339 或 Unix 毫秒;跨度 > **90 天** ⇒ `400`;**两个都不给就不注入任何时间条件** —— 端点绝不替客户端造下界)/ `limit`(`≤0` 或缺省 ⇒ 存储层默认 **100**;> **500** 钳到 500 **不报错**;非整数 ⇒ `400`)/ `offset`(非法整数 ⇒ `400`,负值归 0)。响应 `{data:{events:[…],total}}`,`events` **恒为 `[]` 不是 `null`**。元素键见 §2.4(a)。**不含正文、不含媒体 URL、不含凭据**。未接线或读库失败 ⇒ `500 channel_events_unavailable`(**绝不回全 0 假汇总**)。⚠️ `weixin-ilink` 事实上无此类事件 ⇒ 对它查询恒为空,**不得用别平台事件顶替** |
| B29 | GET | `/api/v1/admin/channels/events/counters` | **渠道事件逐类计数(近似值)**,只读、**不审计**。此前只登记在 §2.4(a) D2。query 仅 `platform`(空 = 全部平台;未知 ⇒ `400`,与 B28 同一把校验)。响应 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}` —— `approx` 与 `counters` **同级**。🔴 **`approx` 恒为 `true` 且不是装饰**:计数是**内存聚合 + 每 30s flush**,读到的表**不含待 flush 部分** ⇒ 只少不多、是个**下界**;面板必须如实标注,别画成精确账。未接线或读库失败 ⇒ `500 channel_events_unavailable` |
| B30 | GET | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的读(v0.95)**,只读、**不审计**。此前只登记在 §6 v0.95,本条是它的矩阵行。**无参数**。`200 {data:{path,present,hot,default{…六键…},patch{},conversations{},rules{…},ownerPrivate:"never"}}`。🔴 **三块分工(客户端按此编程)**:`default` = **生效值**(代码默认 + 盘上 `default` 段,**已 clamp**);`patch` = **盘上原值**,**键缺位 ⇒ 该键不出现**(= 跟随默认,**不是 0**);`conversations` 的键是 `platform\|channel`(可读形态,NUL 已换成 `\|`),值是**生效值**。`hot` 恒为 `true`(桥每次发车前重读同一个 store ⇒ **这是事实,不是承诺**)。🔴 `ownerPrivate` 恒为 `"never"` = **只读事实**:主人私聊的车道类型由 actor 元数据判定,**没有任何请求键能翻转它**,面板只显示一行说明、不给开关。取值区间:`windowMs` **300–15000**、`maxWaitMs` 下界**跟随窗口**且 ≤ **30000**、`maxBatchMessages` **1–50**;越界是 **clamp + WARN,不是 `400`**。`path` 是 `$DATA_DIR/config/coalesce.json` 的落点(**零新 `.env` 键**)。未接线 ⇒ `500 coalesce_unavailable` |
| B31 | PUT | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的写(v0.95)**,门同 B30(`writeAuth` = owner/admin)。请求体 `{default?:{enabled?,windowMs?,maxWaitMs?,maxBatchMessages?,applyToStrangers?,strangerWindowMs?}, conversations?:{"<platform>\|<channel>": {…} 或 null}}`。🔴 **三态(硬要求)**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**(校验全部发生在落盘**之前**,是结构性的、不靠回滚)。**严格解码**:未知键(含拼错)与「一个以上的 JSON 对象」都归 `400 invalid_payload`,**绝不静默忽略**;**负数**同样 `400 invalid_payload`(越界的合法数字**不是** 400 —— 由运行期 clamp + WARN)。`default` 缺位 ⇒ 全局不动;`conversations` 里值为 `null` ⇒ **删掉该会话覆盖**(回到全局 default),没提到的会话一概不动。**值一个都没变时不写盘**(连 mtime 都不动)但仍回 `200`。`200` 响应与 B30 **同形状**,是**写完重新读盘**的真值。错误码:`400 invalid_payload`(未知键/负数/体形态错)|`400 bad_request`(读体失败)|`500 coalesce_unavailable`|`500 coalesce_write_failed`(落盘失败,**盘上未改动**)。**落审计** `channels.coalesce`(`result` ∈ `ok\|error`;⚠️ **空补丁也记一条 `ok`**) |
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
### 2.3 owner-only(40 条,`ownerAuth`)
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
| C1 | POST | `/api/v1/admin/plugins/policy` | 设置能力可达性标记(工具策略,落盘 `tool_policies`) |
| C2 | POST | `/api/v1/admin/personas` | 创建人格 |
| C3 | PUT | `/api/v1/admin/personas/{id}` | 更新人格(`identity.systemPrompt` 必填,缺失/空白 → `400`) |
| C4 | GET | `/api/v1/admin/owner-profile` | 主人档案读取 |
| C5 | PUT | `/api/v1/admin/owner-profile` | 主人档案写(落盘 `configs/users/owner.yaml` + 热更新活档案;审计 `owner.profile.update`) |
| C6 | POST | `/api/v1/admin/users` | 创建账号 |
| C7 | POST | `/api/v1/admin/users/apikey` | 签发 API key(明文仅返回一次) |
| C8 | DELETE | `/api/v1/admin/users/apikey/{id}` | 吊销 API key |
| C9 | POST | `/api/v1/admin/iot/{id}/control` | 控制设备 |
| C10 | POST | `/api/v1/admin/iot/scenarios` | IoT 场景写 |
| C11 | POST | `/api/v1/admin/iot/rules` | IoT 规则写 |
| C12 | POST | `/api/v1/admin/automations` | 创建自动化 |
| C13 | POST | `/api/v1/admin/automations/reminders` | 创建定时提醒(到点发 `reminder.trigger`) |
| C14 | POST | `/api/v1/admin/automations/{id}/run` | 手动运行自动化 |
| C15 | DELETE | `/api/v1/admin/automations/{id}` | 删除自动化 |
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 为真实状态**,并按需回可选 `pending`/`ready`/`reason`) |
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
| C24 | GET | `/api/v1/admin/channels/{platform}/view` | 平台专属只读视图 |
| C25 | GET | `/api/v1/admin/channels/{platform}/friends` | 好友/联系人清单 |
| C26 | POST | `/api/v1/admin/channels/{platform}/friend/handle` | 好友申请处理 |
| C27 | POST | `/api/v1/admin/channels/{platform}/message/test` | 发测试消息 |
| C28 | PUT | `/api/v1/admin/channels/{platform}/config` | 保存渠道账号配置(**按 id patch** 语义,见 C29 注) |
| C29 | DELETE | `/api/v1/admin/channels/{platform}/config` | 删除渠道账号。**必须显式寻址**:`?id=<账号 id>` 或 `?empty=1`(v0.66),见下注 |
| C30 | POST | `/api/v1/admin/plugins/install` | 安装插件 |
| C31 | POST | `/api/v1/admin/plugins/uninstall` | 卸载插件 |
| C32 | POST | `/api/v1/admin/memories` | 写记忆 |
| C33 | POST | `/api/v1/admin/memories/search` | 搜记忆 |
| C34 | POST | `/api/v1/admin/rag` | 索引文档(切分 + 嵌入) |
| C35 | POST | `/api/v1/admin/rag/delete` | 删文档(项目条目 + 级联章节) |
| C36 | POST | `/api/v1/admin/system/revoke-keys` | 全部吊销 API key |
| C37 | POST | `/api/v1/admin/system/rotate-jwt` | 轮换 JWT 密钥 |
| C38 | POST | `/api/v1/admin/system/backup` | 备份 |
| C39 | POST | `/api/v1/admin/system/restore` | 恢复 |
| C40 | GET | `/api/v1/admin/system/storage` | 存储状态**只读**(v0.60)。`{data:{main,documents,vector}}`;**不含凭据**;未接活连接 → `500 storage_unavailable` |
> **C28 的 patch 语义(v0.54 起)**:`config` 内**键省略 = 保留已存值**;给出非空值 = 覆盖;**显式 `null`/`""` = 清除该键**(`0`/`false` 是真实取值,不算空)。顶层便捷字段(`token`/`baseURL` 等)只在非空时并入补丁,**要清 `bot_token` 必须走 `config` 的显式 `null`/`""`**。
> **C28 的账号级限流字段(v0.69 修正)**:`groupMsgPerMin` / `msgPerMin` / `maxMessages` / **`directMsgPerMin`** 都是账号**顶层**字段(**不在 `config` 里**,C28 的 patch 三态**不适用**于它们)。它们是**值语义**:请求体省略 ⇒ `0` = 未设置(对 `directMsgPerMin` 即保护性默认 6 条/分)。**要保住已设的值必须把它回送**;面板先由 `GET /admin/channels` 回显、再原样写回(`frontend/web/src/admin/modules/channels/channelUtils.ts` 的 `limitInitialOf` / `limitRequestBody`)。⚠️ v0.68 及更早的后端**完全忽略** `directMsgPerMin`:老客户端不受影响,但**要表达「关闭流控(-1)」必须对 v0.69+ 后端发**。
> **C18 破坏性**:只覆盖 `.env` 声明过非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`),`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改;组集合不增减。
> **C19/C20 的 `accountId`(v0.66,多账号平台必需)**:请求体 = `{name, accountId?}`(`name` 必填:平台名或精确适配器名)。
> - 给出 `accountId` ⇒ **只**驱动该账号的适配器(账号 id 非敏感;`"default"` 映射到该平台的裸适配器名,即无 id 账号的注册口径)。账号**完全不存在** ⇒ `404 account_not_found`(文案附该平台已知账号 id 列表);账号**已配置但当前没有活适配器** ⇒ `409 account_not_registered`(常见原因:缺该账号必需凭据、或保存后尚未重载)。两种都**零连接**,**绝不静默回落到第一条**(「点了连接却连上别的账号」正是此改动要修的缺口)。
> - 缺省 `accountId` ⇒ 平台级旧语义(老客户端零影响):精确同名适配器 → 该平台 `channels.json` **有效配置顺序第一条** → 裸平台名兜底;仍找不到 ⇒ `404 not_found`。⚠️ 第 2 步在 v0.66 起是**确定性的**(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)。
> - 响应(open/close 同形):`{data:{name, resolved, connected, accountId?}}` —— `name` 是请求里的名字(旧契约不变),`resolved` 是**实际被驱动**的适配器注册名(多账号平台上与平台名不同),`accountId` 仅在请求指定时回显,`connected` 为操作后的状态。**凭据永不出现**。审计动作名不变(`channel.open` / `channel.close`)。
> **C29 的两种寻址(v0.66,都必须显式给出)**:
> - `?id=<账号 id>` ⇒ 删该 id 那一条。⚠️ **`id` 不存在时仍回 `200`**(`removed:1`),与「刚删过」不可区分 —— 客户端按幂等处理。
> - `?empty=1` ⇒ 删该平台上**全部无 id 的历史条目**(这类条目改不动:带空 id 保存会被当成新账号另开一条)。**无可删 ⇒ `404 not_found`**,不写盘、不重载、不假装删过。
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档)。✅ **已并入矩阵(v0.95 补登)**:B28 = `GET …/channels/events`、B29 = `GET …/channels/events/counters`。🔴 **形状级真源从此以 B28/B29 为准**,下面 D1/D2 两行**保留仅作 v0.67 冻结版对照**,改端点时只改 B28/B29,别在四处一起改。
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
| D1 | GET | `/api/v1/admin/channels/events` | 非消息事件列表(**倒序**)。query:`platform` / `tier` / `category` / `channelId` / `since` / `until` / `limit` / `offset`。响应 `{data:{events:[…], total}}`。`since` / `until` 按 **UTC ISO-8601 定宽字符串**做**字典序**比较(与 `GET /admin/llm/usage` 同口径);**跨度上限 90 天**(超 → `400`);`platform` 未知 → `400`。**不含正文、不含任何媒体 URL、不含任何凭据 / `flag`** |
| D2 | GET | `/api/v1/admin/channels/events/counters` | 逐类计数汇总(**近似值**)。query:`platform`。响应 `{data:{approx:true, counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,`approx:true` 如实标注「计数为聚合近似值」(计数是**内存聚合 + 周期 flush**,非逐条实时)。**落地口径**:`approx` 与 `counters` **同级**(都在 `data` 内,见 §7.4-11 的落地记录) |
- **RBAC 档位已定(落地口径)**:两个端点走 **`owner|admin`**(`writeAuth`),与同族的渠道消息日志面(§2.1 B2/B3、`/admin/channels/messages`)同档 —— 事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是给 viewer 的监控投影。**viewer 为 `403`**(已进 `rbac_viewer_test.go` 的 `ownerAdminReadRoutes` 覆盖)。
- **参数缺省与错误码(落地口径,见 §7.4-11)**:`limit` ≤0 或未给 ⇒ 用存储层缺省 `100`;超硬上限 ⇒ **钳到 `500`**(不报错,`total` 仍如实给出筛选条件下的总条数);非法整数(如 `limit=abc`)⇒ `400 bad_request`;`since`/`until` 非法 ⇒ `400 bad_request`;`platform` 未知 ⇒ `400 bad_request`;`tier` 非 `t1/t2/t3` ⇒ `400 bad_request`。⚠️ **`category` 不做白名单校验**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 400。
- **时间窗缺省(落地口径)**:**没给 `since`/`until` 就不注入时间条件**(不是「近 24h」)—— 端点绝不替客户端造一个下界(那正是「静默裁剪」)。只给 `since` 时按「现在」判跨度(超 90 天 ⇒ 400),但**不**把「现在」写进过滤条件。
- **数据源两张表**(`design/26 §4.1`,表名**复数**):`channel_events`(`tier` 为 `t2`/`t3` 的事件与**所有** `unknown` 逐条留存,**参与保留期 GC**)与 `channel_event_counters`(`t1` 与**所有**帧的计数,按 `(platform, account_id, platform_type)` upsert,**不参与 GC**)。
- `tier` 取值域:`t1`(只计数)/ `t2` / `t3`(逐条留存;T3 是「谁对谁做了什么」的定向交互记录)。`category` 取值域(**归一化类别**,设计原文给出 `heartbeat` / `lifecycle` / `join` / `leave` / `recall` / `ban` / `admin` / `friend_request` / `group_request` / `poke` / `reaction` / `button` / `vote` / `item` / `link` / `unknown`,并在末位带 `…` ⇒ **非穷举**,客户端**未知取值原样展示、不报错**)。
- `events[]` 元素 = 一条 `channel_events` 行的投影。**落地口径(见 §7.4-9):键名一律 camelCase,逐字如下** —— `id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole`(`owner`/`member`/`system`/`unknown`,结构化解析所得)/ `targetId` / `selfRelated`(bool)/ `messageId` / `summary` / `createdAt` / `updatedAt`(后两者是 **UTC RFC3339**,与 `llm/usage` 的时间回显同口径)。`actorId`/`targetId` 是 **id 不是昵称**;`summary` 是**固定文案**(如「群成员加入」,**不含任何平台原文**)。无数据时 `events` 是 `[]`(不是 `null`)。
- **未接线存储时**:两个端点都回 **`500 channel_events_unavailable`**(对齐 `usage_unavailable` / `storage_unavailable` 的诚实边界口径,**绝不回全 0 假汇总**)。读库失败同样是 `500 channel_events_unavailable`(错误文案不同)。
- 三个新 env 键(`design/26 §4.4`,已登记进 §5):`CHANNEL_EVENT_RETENTION`(默认 `720h`)、`CHANNEL_EVENT_GC_INTERVAL`(默认 `1h`)、`CHANNEL_EVENT_COUNTER_FLUSH`(默认 `30s`)。
- ⚠️ **微信渠道不提供非消息事件**:查询面 / 面板对该渠道**如实显示空 + 说明文字**,绝不伪造、绝不用别的渠道事件顶替(落地时已有测试钉住:`platform=weixin-ilink` 的响应里绝不出现别的平台的事件)。
#### (b) `GET /api/v1/admin/channels` 的账号项:`accountOnline`
- `accounts[]` 元素**可能**带可选 `accountOnline:bool`(**v0.67 登记,已实现** —— 管理面已落地,见 §7.2):该账号**在平台侧是否在线**。
- **与 `connected` 是两件事**(这是本案的全部理由):`connected` = **本项目的适配器是否连着**(WS 会话连通);`accountOnline` = **账号本身在平台上是否在线**。OneBot 实现(NapCat 等)的进程与正向 WS 可以一直连着,而背后的 QQ 账号已经掉线 —— 此时 `connected:true` 而 `accountOnline:false`,消息**既收不到也发不出**。
- **三态**(`known` 口径):`accountOnline:true` = **确知在线**;`accountOnline:false` = **确知离线**;**键缺席 = 状态未知**(尚未探到:探活未接线 / 刚启动 / 一直失败 / 该平台没有独立账号状态面)。**绝不可把「未知」画成「离线」**,也不可画成「在线」—— 那正是 2026-10-05 事故里「面板仍显示已连接、日志一片空白」的同一类坑。
- 状态源:`AccountStatus()`(`plugins/channels/qq_obv11/probe.go`),其 `known` 区分「确知离线」与「尚未探到」;本字段的**缺席**即承载 `known=false`。当前**只有 `qq-obv11`** 有这条独立状态面:周期约 **60s** 主动探活 OneBot `get_status`(读 `{online, good}`),与上游可配的 `meta_event` 心跳**并列**(心跳优先、探活兜底),**只在状态变化时**记一条带 `source` 的日志。
- **已实现口径**(本次落地):管理列表按**可选接口断言**读它 —— 适配器实现了 `AccountStatus()` 才读,否则**键缺席**;`known=false` 时同样**键缺席**(绝不用 `false` 冒充离线)。**不另发** `known` 同级键:缺席本身即承载 `known=false`。当前**只有 `qq-obv11`** 会带该键(telegram / webhook 等没有独立状态面)。
- ⚠️ **字段名(`accountOnline`)是本契约所拟**:设计只规定「独立状态面 + `AccountStatus()` + `known`」,**未给管理面字段名**(见 §7.4-13)。实现沿用了本契约所拟的名字(未改名)。
#### (c) `GET /api/v1/admin/channels/{platform}/login-status` 的账号项:`authState` / `authDetail`(**v0.78 新增,切片 CODE-F3,已实现**)
- 该端点响应新增**可选** `authState:string` + `authDetail:string`(`omitempty`):该账号**会话凭据**的三态与**如实的**中文说明。**无新端点、无新路由**(沿用既有扫码登录状态端点,`owner-only`,v0.39 起就有)。
- **取值是闭集**:`"active"`(**有效**:iLink 接受过当前凭据)|`"rejected"`(**被拒**:iLink 以 `errcode=-14` 拒绝/作废了这份会话凭据,**需要重新扫码绑定**)|`"unknown"`(**未知**:没有凭据,或有凭据但还没有一次被 iLink 接受的调用可以作证)。
- **与 `status` 是两件事**(本字段的全部理由):`status` 是**扫码登录流程**的状态机(`idle`/`waiting`/`confirmed`/`failed`/`need_verifycode`);`authState` 是**凭据在 iLink 侧是否有效**的结论。一次 `confirmed` 的扫码只说明「拿到了凭据」—— 它是否被接受要等一次成功的调用(那时 `authState` 才从 `unknown` 变 `active`)。因此**允许**出现 `status:"confirmed"` 且 `authState:"unknown"`:**这是事实,不是矛盾**。
- 🔴 **`errcode=-14` 是「被拒」不是「过期」**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用);`-14` 表示 iLink 不再接受这个会话(已解绑 / 在别处重新登录 / 协议层拒绝),**不是时间性过期**。`authState:"rejected"` 的说明只说「已被 iLink 拒绝,需要重新扫码绑定」。**未知同样绝不许写成「过期」**(也不许写成「未连接」)—— 服务端文案已由测试钉死(响应体全文不得出现「过期」)。
- **键缺席 ≠ `unknown`**:适配器**没有这条状态面**时两个键**缺席**(缺席 = 该账号没有这个状态面);`"unknown"` 是**值**,表示**有状态面但尚未确认** —— 面板必须能区分这两件事。
- 数据源 = 适配器的**可选能力接口** `SessionStatus() (state, detail string)`(`plugins/channels/weixin_ilink/session_status.go`),网关经类型断言读取(`internal/services/gateway/admin_channel_status.go` 的 `sessionStatusAware`,穿 `namedAdapter` 包装)。**当前只有 `weixin-ilink` 会带这两个键**;其它平台字段缺席,行为一字不变。
- `authDetail` **绝不含任何凭据**。
- **行为变更 / 破坏性**:无(两个键可选、纯增量;`status` / `botId` / `botToken` / `err` 与 v0.39 起逐字一致)。
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
#### (d) 渠道连接态三态:`pending` / `ready` / `reason`(**v0.90 新增,已实现**)
- **出现位置**:`POST /api/v1/admin/channels/open` 与 `.../close` 的响应体、`GET /api/v1/admin/channels` 的**平台行**与 `accounts[]` **账号行**(三处同名同义)。
- **三个键都是可选的,取不到就整键缺席**(同 `needsRescan` / `lastError` 口径):`connected:true` 时,或适配器没有这条状态面时,`pending` / `ready` / `reason` 一律不出现。
- `reason` 只可能在 `pending:true` 时出现,是**已脱敏**的原因短句(`access_token` 等凭据绝不出现在其中)。
- **判据是结构化字段**(适配器自报的闭集状态),不是正文、不是提示词 —— 客户端据此把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **口径归属**:**适配器自报**(`ConnectOutcome`,网关经类型断言探测);核心 `sdk.Channel` 契约**刻意不扩展** ⇒ 没实现该状态面的平台响应**逐字节不变**。
- ⚠️ **为什么 `ready` 不带 `reason`**:它本来就不是坏消息(监听已就绪、就等对面来连),给它编一个原因等于把「等接入」说成「出问题了」。
---
## 3. WebSocket `/ws/chat`
### 3.1 握手
- `GET /ws/chat`,凭据取自 `?token=<JWT>` 或 `Authorization: Bearer <JWT>`(**只认 JWT**)。
- 校验失败:升级前回 `401 {"error":{"code":"unauthorized",...}}`。
- 方法非 GET → `400 bad_request`。
- 帧大小:**读上限 4096 字节/帧**(`wsReadLimit`)。超限由传输层直接断连 —— 因此**真实音频/图片二进制必须走 REST**,WS 只发小载荷。
- keepalive:服务端周期 `Ping`(54s),客户端应回 `Pong`;读超时 60s。
- 非 JSON 帧 → 定向 `{"type":"error","data":{"message":"invalid json"}}`(连接保留)。
### 3.2 握手 `history` 回放(角色门:只发 owner+admin)
- 连接注册后,**仅当该连接是 `owner`/`admin`** 时,服务端**定向**发送一帧:
```json
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。
### 3.3 上行帧(客户端 → 服务端)
> **角色门(fail-closed)**:**只有 `owner`/`admin` 连接可以发送任何应用内上行帧**。非 owner/admin 发任何上行 JSON 帧 → **定向**回 `{"type":"error","data":{"code":"forbidden"}}`(只到发送者,不广播、不驱动模型、不落库)。该门是 `route()` 的第一条语句且**默认拒**:未登记的新帧类型在显式登记前一律被拒。协议级帧(ping/pong/close)由 gorilla 内部处理,不受影响。
帧壳:`{"type":"<type>","reqId?":"<string>","data":{...}}`。`reqId` 保留未用。
| type | data | 语义 |
|---|---|---|
| `chat` | `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?}` | 会话消息。**与 REST `POST /chat` 同形**。见 [§4.6](#46-chat-请求与响应面)。 |
| `interrupt` | `{sessionId,scope?,turnId?}` | 打断。见 [§4.8](#48-interrupt-的-scope-与-turnid)。 |
| `voice_input` | `{audio,format?,text?,sessionId?,deviceId?,clientMsgId?}` | 语音回合:STT → 编排器 → 结算时 TTS 定向回本连接。见 [§4.5](#45-图片--文档--语音链路)。 |
| `mode_switch` | 任意(透传) | 行为模式广播;服务端以 `mode_switch` 下行帧回放给(owner/admin)连接。 |
| `subagent_ask` | `{id,mode?,n?,query?}` | 审计某子代理上下文。`mode` ∈ `""/full/tail/keyword`。定向回 `subagent_event`;未接 auditor → `error {"message":"subagent audit unavailable"}`。 |
| `task_output_query` | `{taskId}` | 取任务输出。定向回 `task_output`;未接 task store → `error {"message":"task output unavailable"}`。 |
上行错误应答(均**定向**,`{"type":"error","data":{...}}`):
| 情形 | data |
|---|---|
| 非 owner/admin 发送任意上行帧 | `{"code":"forbidden"}` |
| 未知 type | `{"message":"unknown event type"}` |
| 非 JSON | `{"message":"invalid json"}` |
| `interrupt` scope 非法 | `{"code":"bad_request"}` |
| `interrupt` 指定 `turnId` 不存在(仅 scope:"current") | `{"code":"bad_request"}` |
| `chat` 待处理队列满 | `{"code":"chat_busy","message":...,"sessionId":...,"clientMsgId?":...,"messageId?":...}`(**只发发送者**;同时广播 `pending_full` 状态帧) |
| `chat` 携带的文档不合法 | `{"code":"<too_many_documents\|document_not_found\|chat_documents_unavailable>"}` |
| `voice_input` 无 audio 也无 text | `{"message":"voice audio or text required"}` |
| `voice_input` 未接语音服务 | `{"message":"voice unavailable"}` |
### 3.4 下行帧(服务端 → 客户端)与角色门
> **唯一投递入口 `hub.broadcast` 即角色门**:它按帧类型查 `frameReachesAll` 表决定受众。
> - **全员白名单(7 个「状态机信号」,owner/admin/viewer 都收)**:`session_busy`、`pending_ack`、`pending_drain`、`session_idle`、`session_interrupted`、`pending_full`、`session_read`。它们**只报告会话忙闲/游标,不含任何内容**。
> - **其余全部下行帧 = fail-closed 默认档:只发 owner + admin**。新增帧类型在有人显式登记前一律落此档(漏分类不泄露内容)。
> - 分类表在 `internal/services/gateway/ws_frame_scope.go`,扩白名单属契约变更。
帧壳:`{"type":"<type>","turnId?":"...","seq?":<int>,"sessionId?":"...","data":{...}}`(`turnId`/`seq` 为空则键省略)。
#### 3.4.1 全员白名单(7 帧)
| type | 帧壳 | data | 说明 |
|---|---|---|---|
| `session_busy` | sessionId | `{sessionId,mode,activeTurns}` | 会话转忙。`mode` ∈ `thinking/executing/subagent` |
| `pending_ack` | sessionId | `{sessionId,mode,queueDepth,reason?}` | 忙时消息入队。`reason` **仅在显式声明 `parallel` 却落回排队时**出现,取值 `"parallel_limit"` |
| `pending_drain` | sessionId | `{sessionId,mode:"thinking",queueDepth,activeTurns}` | 续跑弹出队首 |
| `session_idle` | sessionId | `{sessionId,mode:"idle",activeTurns:0}` | 转空闲。**时点收紧(v0.65)**:所有在途路收尾**且** pending 空才发(单路时与旧行为逐字相同)。见 [§4.6](#46-chat-请求与响应面) |
| `session_interrupted` | sessionId | `{sessionId,mode,dropped,cancelledTurns,activeTurns}` | 打断结果。`scope:"all"` ⇒ `mode:"idle"`、`dropped`=清空的 pending 数、`activeTurns:0`;`scope:"current"` ⇒ `mode` 是当时忙碌通道(非 idle)、`dropped:0` |
| `pending_full` | sessionId | `{sessionId,mode,max,clientMsgId?,messageId?}` | 队列满(`max` 为上限)。`clientMsgId` = 被拒消息的幂等键(未携带则键缺席);`messageId` **仅当该消息此前已落库/已认领时**出现(服务端绝不在此处新铸 id) |
| `session_read` | sessionId | `{readAt}` | 已读游标推进(毫秒)。同账号所有端据此清角标 |
#### 3.4.2 结构化流帧(owner+admin;帧壳带 sessionId + turnId + seq)
| type | data |
|---|---|
| `message.start` / `message.delta` / `message.end` | `{index, delta?, streamId?}` |
| `thinking.start` / `thinking.delta` / `thinking.end` | `{index, delta?, streamId?}` |
| `tool_call.start` / `tool_call.delta` / `tool_call.end` | `{id, index, delta?, streamId?}` |
| `tool_execution.start` / `tool_execution.update` / `tool_execution.end` | `{tool, phase, id?, delta?, resultSummary?}`(`phase` ∈ `running/done/error`;**phase 绝不塞进 delta**;**`resultSummary` 只在 `end` 帧、且只在「真的有结论」时在场**(v0.82)—— **成功 ⇒ 结果摘要;失败/取消/超时 ⇒ 该次错误文本的摘要**(两边都走同一份脱敏路径);**只有「真的没有结果」才整键缺席**(成功但结果为空、或错误文本为空),**绝不用空串顶位**。上限 **≤500** rune(超长按 rune 截断、末位 `…`),与回看面 `toolCalls[].resultSummary` 同源同口径,读侧不做二次脱敏也不放宽) |
| `turn.start` / `turn.end` | 无 data(帧壳即边界信号) |
| `stream_chunk` | `{delta}`(非流式路径的单段文本) |
| `stream_end` | `{stopReason, steps?, error?}` |
> `streamId`(可选)= 同一次模型流(一次 ChatStream 调用)的多帧关联键;`turnId` = 整个回合恒定。两者分工不同。
> `tool_execution.update`:wire 映射已实现,但**工具分发器目前没有中途进度上报源**,故实际**暂无生产者**(`start`/`end` 真实发出)。
>
> **`resultSummary` 的缺席语义(v0.82,安卓端按此编程)**:它是 `tool_execution.end` 的**可选**键 —— **键在场 ⇔ 这次调用有了结论**;客户端**只做正向断言**,人缺席时显示「工具名 + 状态」即可。映射表(**客户端不要按 `phase` 反推键在不在**,只按「键在不在」分支):
| 情形 | `.end` 帧上的 `data.resultSummary` |
|---|---|
| 调用成功且结果非空 | **在场**(已脱敏 + 已截断;图侧同源文本通常 ≤80 rune,网关再按 ≤500 兜底) |
| 调用失败 / 取消 / 超时(`Invoke` 返回 error) | **在场** —— **该次错误文本的脱敏渲染**(与回看面「失败调用给图上的 `error` 摘要」同一说法) |
| 调用成功但结果为空(nil / 空串) | **缺席**(整键不出现) |
| 错误文本本身为空 | **缺席** |
| `tool_execution.start` / `.update`(即使有人误挂摘要) | **缺席**(契约只在 `end` 帧给) |
| 被静音的内部流程(backthink / 子代理) | **整帧不发**(既有行为,一字未改) |
> 「绝不回空串顶位」有两道保险:loop 侧留空 + 网关侧 `s != ""` 才写键(切片 Y1 的变异 A 证明这道守卫真的在起作用)。⚠️ **与回看面的差异(v0.82 起已收窄)**:v0.82 之前本帧**失败时整键缺席**、而回看面对失败调用给的是 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」。v0.82 把两侧统一为「失败也带该次错误文本的摘要」,**客户端从此只需一套判据**。⚠️ **未确证**:本键的**真机**(`:10942` 发布产物)端到端**未跑**,见 §7.4-30。
#### 3.4.3 编排帧(owner+admin)
| type | 帧壳 | data | 说明 |
|---|---|---|---|
| `turn_begin` | sessionId + **turnId** | `{kind,messageIds,leg,activeTurns}` | 每起一路(含续跑路)**恰好发一帧**(v0.66 落地)。`kind` ∈ `"turn"`(新回合/**续跑**)/`"parallel"`(显式并行且照准,**仅供展示**);`messageIds` = 该路已落库用户消息的权威 id 数组(可为空数组,**恒为数组不为 null**);`leg` = 会话内路序号(1 基,会话内单调递增);`activeTurns` 同忙态帧。客户端据此把气泡置「已受理·回答中」并按 `sessionId + turnId` **两级分桶**;服务端口径是「按 `messageIds` 是否含本气泡的 `messageId`」判定归属,`kind` **不是**判据。 |
| `steer_ack` | sessionId + **turnId** | `{messageId,atStep}` | 忙时新消息被在飞回合吃进同一回合:`messageId` 是权威 id,`atStep` 是模型步序号(1 基)。语义 = 「已读、未答」⇒ 客户端把该气泡渲染灰态,直到该路 `message.*`/`turn.end` 到达(**不需要第二个确认帧**)。帧壳 `turnId` 与吃进它的那一路 `turn_begin` 同值(v0.66 落地)—— 并行下没有它配不上正确的 `turn.end`。 |
| `chat_duplicate` | sessionId | `{sessionId,clientMsgId,messageId}` | `clientMsgId` 判重命中:**没有新回合、没有新内容帧**,客户端把本地乐观气泡对齐到既有 `messageId` |
> **`turn_begin` 的帧序(v0.66,按实现)**:`session_busy`(新回合)或 `pending_drain`(续跑)→ `message_persisted`(user) → `turn_begin`。
> 顺序是刻意的、不可调换:`turn_begin.data.messageIds` 必须是**已落库的权威 id**,因此它只能在用户消息落库之后发;客户端因此总能先看到 `message_persisted`,再收到把该气泡钉到某一路的 `turn_begin`。
> 起第 1 路与起并行路**共用同一条启动路径**,帧序完全同形 —— 客户端只需实现一遍「`turn_begin` → 灰态」。
#### 3.4.4 持久化 / 历史帧(owner+admin)
| type | 帧壳 | data | 说明 |
|---|---|---|---|
| `message_persisted` | sessionId | `{message:<historyMessage>,unreadCount?:<int>,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选 `unreadCount`(v0.76,该会话此刻的**绝对未读数**,**只在 >0 且算得出来时**出现 —— 见 §4.13);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) |
| `channel_persisted` | sessionId | `{message:<historyMessage>}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_edited` | sessionId | `<historyMessage>`(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_deleted` | sessionId | `{id}` | 删除单条消息 |
> **`thinking` 可达性口径(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**全部读取面**(`GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、握手 `history` 回放、`message_persisted` / `channel_persisted` / `message_edited`)**恒为缺席**。原因不是「有意清空」,而是 `history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 没有该字段、Anthropic 流式收尾直接丢弃思考文本)⇒ 该键在线上**永远不会出现**。
> **`message_persisted` 的 `proactive` 判据(v0.74,纯增量)**:帧 `data` 里**可选**的布尔键 `proactive:true` 是「这条是她**主动**说的话」在线上**唯一**的结构化判据,由后端的主动推送通道打上(`gateway.BroadcastProactivePersisted`)。客户端读到它就应走与 `proactive_message` **同一套**提醒逻辑(页面不在前台时补一发浏览器通知、同一条只打扰一次),因此「她该不该提醒我」**与后端走了哪条路由无关**。
> - **为什么需要它**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。修的是这条边界(主人 2026-10-05 裁定「她说话该不该提醒我,与走哪条路由无关」)。
> - **判据为什么不会误伤普通消息**:只有主动推送那条通道会调用带判据的广播入口;普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口,那条路径**永不**往帧顶放该键。客户端只应做 `data.proactive === true` 的**严格相等**判断 —— 缺失、`false`、非布尔值都按「不是主动消息」处理。
> - **去重键 = `message.id`**:主动推送镜像回主会话时,**同一条**消息会先发一条**不带**判据的普通帧(v0.72 起的行为,逐字不变,任何客户端都能渲染),随后追加一条带判据的帧。两帧同 `message.id` ⇒ 合并成一个气泡(消息 store 按 id 去重),提醒也只发一次(提醒去重表按同一个 id 记)。**`proactive_message` 与带判据的 `message_persisted` 同时到达时同样只提醒一次**。
> - **落库失败时不发判据帧**:没有落库行就没有权威 `id`,而 `message_persisted` 的载荷要求非空 `id`(缺了客户端会整帧丢弃)⇒ 那种情况下只记 WARN、不弹通知(绝不发一条会被丢掉的帧假装提醒过)。
> - **⚠️ 判据不落库**:它只活在**实时帧**上(写入那一刻才知道这条是不是主动推送),库里的行没有对应列(不改 schema)。因此刷新后从 `GET /sessions/web/messages` / 握手 `history` 回放读到的那一行**不带**该信息 —— 这是预期的:通知只对「实时到达」有意义,翻历史不该弹提醒。
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
> **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
| type | 帧壳 | data(要点) |
|---|---|---|
| `subagent_event` | — | `{id,name,status,role,mode}`(进度:dispatched/started/done/failed/aborted);`subagent_ask` 的应答另有 `AuditResult` 形状 |
| `subagent_result` | **sessionId = 父会话** | 批次结果 `{sessionId,status,summary,agents:[{id,goal,role?,mode?,status,summary?,text?,steps?}]}`。**不落库**(父会话转录无新消息、重连回放不补发) |
| `task_output` | — | `{taskId,output,status}`(`task_output_query` 应答);亦桥接自 `task.output` 主题 |
| `task_done` | — | 桥接自 `task.done` |
| `device_update` | — | 桥接自 `iot.device_update` |
| `automation_result` | — | 桥接自 `automation.result` |
| `reminder` | — | 桥接自 `reminder.trigger` |
| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**v0.77 起 `data` 另可带 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号)** ⇒ 真并行时同一会话**每一路各发自己的帧**,两路不再互相顶掉)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照**(该端点 v0.77 起另有 `lanes[]` 给出每一路)。缺席语义见 §4.11(d) |
| `capacity_awaken` | sessionId | `{fromState,toState,tool}` |
| `mood` | (无 sessionId) | `{mood,personaId,affection}`;角色级情绪,仅变化时发 |
| `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId,id?,createdAt?}`(已消毒,绝不含凭据/媒体字节)。**v0.72**:`id`/`createdAt` 是这条消息在**主会话历史**里的权威行 id 与落库时间戳(同现/同省),落库失败时键缺席 —— 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在) |
| `approval_notice` | sessionId | `{actor,tool,op,detail,explain,allowed,at}`(主代理代审小字;`explain` 由后端产出、客户端原样渲染) |
| `mode_switch` | — | 上行 `mode_switch` 的回放(透传 `data`) |
| `observability_log` | — | `{entries:[{time,level,source,msg}...]}`,上限 200,最新在前 |
| `observability_trace` | — | `{spans:[...]}`,上限 100,最新在前 |
| `observability_metrics` | — | 扁平 `AdminMetrics` 快照 |
| `panels_changed` | (无 sessionId) | **插件面板集合变更信号(v0.78 新增,切片 PLG-2)**:`{reason,pluginId}`;`reason ∈ {declare,install,uninstall,enable,disable}`,`pluginId` **可为空串**(无法归因到单个插件时)。🔴 **帧刻意不带任何面板内容** ⇒ 客户端据此**按自己的角色重拉一次** `GET /api/v1/admin/panels`(A13)。**只发 owner/admin**(未进 `frameReachesAll` 白名单 ⇒ **viewer 收不到**,须自行刷新/轮询)。传输 = 进程内 bus 主题 `ws.panels_changed`(**零新端口**) |
| `channel_event` | (无 sessionId) | **渠道事件实时帧(v0.78 新增,切片 E7)**:`{event:{…}}`,`data.event` 与 `GET /admin/channels/events` 的 `events[]` 元素**同一个对象**(v0.67 登记的 `ChannelEventJSON`,键名 camelCase)。⚠️ **`id` 键可能整个缺席**(实时帧对应**刚落库、id 尚未回读**的那一行;列表端点读已落盘行,`id` 恒有值)。**只发 owner/admin**(未进 `frameReachesAll` 白名单;viewer 走 REST 只读档)。🔴 **增量提示、不是权威列表**:帧是 best-effort(hub 发送缓冲满即丢该连接的那一帧)⇒ 客户端**必须**保留 REST 轮询兜底;去重建议按(`platform`,`accountId`,`platformType`,`createdAt`)或以轮询结果为准重排。⚠️ **当前只覆盖账号状态迁移这一路**(见 §7.2) |
> **observability_* 的订阅**:owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个即订阅;该凭据的 WS 连接全断即退订(无轮询、无常驻 goroutine)。
> **`channel_event` 当前实际会产生的事件(v0.78,切片 E7)**:只有**账号状态迁移** —— 即「**她自己的账号**上线 / 离线 / 未知」这一路。落库行为 `platform`(如 `qq-obv11`)/ `accountId` / `tier="t2"`(高价值 ⇒ **逐条落行**)/ `category="lifecycle"` / `platformType ∈ {get_status, meta_event.heartbeat, meta_event.lifecycle, account_status}` / `selfRelated:true` / `actorId:""` / `actorRole:"system"` / `channelId`·`channelType`·`groupId`·`messageId` 均空 / `summary` = **九句固定文案之一**(`账号在线(首次确知)`、`账号离线(首次确知)`、`账号状态未知(尚未探到)`、`账号离线(在线→离线)`、`账号上线(离线→在线)`、`账号上线(未知→在线)`、`账号离线(未知→离线)`、`账号状态未知(在线→未知)`、`账号状态未知(离线→未知)`,**不含任何平台原文**)。采样由网关**常驻**(约 60s 一拍,**不看面板也在跑**),**未知(`known=false`)也照常落行**(「尚未探到」≠ 离线)。当前**唯一**有账号状态面的平台是 `qq-obv11`;将来若把 `meta_event` 心跳也接进同一状态面,`platformType` 会跟着变。
> **`proactive_message` 的落库口径(v0.72,行为变更)**:后台思考决定「此刻主动开口」时,这条消息现在是**主会话(`web`)历史里的一条真实 `assistant` 消息** —— 走活跃客户端路由(在线 owner/admin 连接)的那一路此前**只广播帧、一行都不写** ⇒ 刷新/重载后什么都补不回来(既不显示气泡,也补不出历史)。本版 `SendProactive` **先落库、再发帧**:
> - 落库行:`sessionId="web"`、`role="assistant"`、`content` = 帧里的 `content`(后端已净化的同一份文本,系统元数据绝不进正文)、`platform` = 帧里的 `platform`、`mode="chat"`、`turn_id` **留空**(主动推送不属于任何一路回合)。因此 `GET /sessions/web/messages`、握手 `history` 回放、`message_persisted` 都能读到它。
> - **`id`/`createdAt` 随帧下发**,客户端应据此把实时气泡对齐到历史行(按 id 去重;刷新后由历史行接续,不是第二个气泡)。**落库失败时这两个键缺席**,但同时:帧照发(实时气泡不丢),后端记 WARN 并返回错误 ⇒ 后台思考记录里的 `route/detail` 会写明**未落库**(不会再说成「已推送」)。
> - **无在线 owner/admin 连接时也会落库**:推送器如实报告「没有活跃客户端」(`pushed=false`),这条话仍作为一条真实记录留在主会话里,下次打开就能看到。
> - **IM 路由(v0.74 起也提醒)**:`im_bridge` 路由本就把消息镜像回主会话并广播 `message_persisted`(逐条一条,**不双份**),v0.74 起在落库之后**追加**一条帧顶带 `proactive:true` 的同类帧 ⇒ 客户端走与 `proactive_message` 同一套提醒逻辑(见 §3.4.4 后的「`message_persisted` 的 `proactive` 判据」)。两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里**恰好一条**。
#### 3.4.6 定向帧(不经 `hub.broadcast`,只到指定连接)
| type | 说明 |
|---|---|
| `history` | 握手回放(§3.2,仅 owner/admin) |
| `voice_output` | 语音回合结算后**定向回发起 `voice_input` 的那条连接**。帧壳**无 sessionId**;data `{audio(base64),format,text,reply,sessionId,deviceId?,provider?}`。定向目标是连接本身,**服务端不按 deviceId 路由**;连接已断不补投 |
| `error` | 上行拒绝 / 处理函数级失败(形状见 §3.3 与 §4.1) |
---
## 4. 契约细节
### 4.1 错误响应与错误码
- 形状:`{"error":{"code":"<string>","message":"<string>"}}`。
- 中间件级(Bearer/角色门)固定:`401 unauthorized` / `403 forbidden`。
- 常用业务码(按状态分组):
| 状态 | code(示例) | 场景 |
|---|---|---|
| 400 | `bad_request`、`image_too_large`、`too_many_documents`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**)、`invalid_trigger`(**v0.82**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c));**`GET /admin/backthink?trigger=` 的值未知或为空**(**v0.82**,见 §4.10(e-1);`browser` 面板的**未知字段**也走 `invalid_payload`,见 §4.14(d)) |
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
| 403 | `forbidden`、`unknown_role`(**v0.78**,面板快照的角色无法识别,理论不可达、fail-closed) | 角色不足;主会话不可删;**插件写动作的调用者不是 owner/admin**(v0.78,见 §4.14(b)) |
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、v0.79 已实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b));**回滚的记忆条目不在被合并状态**(`not_found`,v0.79,见 §2.2 B17);**候选 id 不存在**与**「按 run 批量应用」的留痕行不存在**(**v0.83**,B21/B23 与 B22 —— 后者刻意与「这一轮没有待定候选」区分开) |
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**)、`browser_unavailable` / `browser_session_not_open` / `config_rejected`(**v0.82**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**记忆整理被配置关闭**(`tidy_disabled`,v0.75 试算既有口径、**v0.83 的手动真跑 B24 复用同码同义** —— `MEMORY_TIDY_ENABLED=false`);**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c));**`browser` 面板(v0.82,见 §4.14(d))**:本机没有可用浏览器来源 / 当前没有打开的页面(面板**不替模型开页**)/ 这次保存没能生效**已按字节回滚** |
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
| 429 | `rate_limited` | 限流(框架级) |
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效;⚠️ **回滚本身失败**时也用这个码并明说「盘上可能已经变了」—— **v0.82 的 `browser` 面板复用这两个码**,见 §4.14(d))、**记忆整理应用面(v0.83,切片 TIDY-1)**:`tidy_unavailable`(整理写面未接线;**候选列表 / 应用 / 回滚共用**,与只读试算组同名同义)、`tidy_candidates_failed`(读候选失败)、`tidy_apply_failed`(单条或批量应用失败)、`tidy_revert_failed`(回滚写失败)、`tidy_run_unavailable`(**只接线了只读试算、真跑入口没接** —— 「没跑」与「跑了没产出」必须能分辨)、`tidy_run_failed`(真跑失败) | 内部错误或依赖未接线(**绝不伪装成功**) |
| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明,但宿主没有登记它的处理器** —— 🔴 **v0.79 起不再是兜底档**:内置 `web-search` 的动作已全部登记(见 §4.14(b)(c)),这条只在「声明写了却没实现」时出现 |
| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**)、`browser_action_failed`(**v0.82**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);中间一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**);末一个 = `browser` 面板的截图 / 指纹自检**执行失败**(**v0.82**,见 §4.14(b)(d)) |
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable`、`tidy_timeout`(**v0.83** 手动真跑超时:**本轮未完整跑完,已跑部分仍写入了留痕** —— 不假装跑全了) | 派发/上游不可用 |
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
> **⚠️ `416` 不是错误信封(v0.67 登记,**v0.79 已实现**)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
> **`400` 的渠道事件用法(v0.67 已实现,见 §2.4(a))**:`GET /admin/channels/events` 对 `platform` 未知、`tier` 非法、`since`/`until` 跨度 > 90 天、`since`/`until` 时间点非法、`limit`/`offset` 非整数都回 `400`(**落地用的 `code` 是通用的 `bad_request`**,见 §7.4-11)。
### 4.2 分页与游标
`GET /api/v1/sessions/{id}/messages`:
- `limit`:默认 **50**,生效范围 **[1,200]**(大于 200 **钳到 200**,不报错);非数字或 `<1` → `400`。
- `before`(毫秒 `createdAt`):返回**严格早于** before 的消息,**倒序**(默认方向,向后翻历史)。
- `after`(毫秒 `createdAt`):返回**严格新于** after 的消息,**正序**(重连后的前向增量追赶)。
- `before` 与 `after` **互斥**(同传且都 >0 → `400`);`after` 非数字/负 → `400`。
- `q`:会话内内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义),**倒序**、仍遵守 `before`/`limit`;**`q` 与 `after` 互斥**;空 `q` = 未传。
- `platform`:可选来源平台过滤(Web 端不传 = 全量;IM 适配器传本平台)。
- **形状校验(`limit`/`before`/`after` 非法、互斥冲突)先于 404 判定**:形状错就是 `400`,与目标会话存在性无关。
### 4.3 幂等(`clientMsgId`)
- 上行可选字段(REST `chat` body 与 WS `chat` 帧**同形**;`voice_input` 亦然)。
- **同一会话内同一个非空 `clientMsgId` 只承认第一条**。重复到达:
- REST → `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}`
- WS → 下行帧 `chat_duplicate`(§3.4.3)
- **不落库、不起回合、不发任何内容/流式新帧**。
- 判重由 SQLite 部分唯一索引 `(session_id, client_msg_id) WHERE client_msg_id != ''` 原子裁决(并发重发只有一个赢家)。
- `clientMsgId` **回填进消息 DTO**(空则省略),读接口与 `message_persisted` 给出同一答案 —— 客户端据此对齐本地乐观气泡(替代「靠文本相等」的脆弱匹配)。
- 省略它时行为逐字不变(老客户端零影响)。
### 4.4 消息 DTO(`historyMessage`)
`GET /sessions/{id}/messages` 的数组元素、`PATCH /messages/{id}` 的响应 `data`、`GET /admin/channels[/{platform}]/messages` 的元素、`message_persisted` / `channel_persisted` 的 `data.message`、`message_edited` 的 `data`、握手 `history` 回放的 `messages[]` —— **同一个结构体**:
```json
{
"id": "<消息 id>",
"sessionId": "web",
"role": "user|assistant",
"content": "...",
"mode": "chat",
"mood": "",
"image": "",
"documents": [ {"name":"...","mime":"...","size":123,"chars":8000,"truncated":false,"status":"extracted"} ],
"thinking": "...", // 恒缺席(全仓无写入者;见 §3.4.4 口径更正)
"platform": "web",
"deviceId": "web:chrome:a1b2c3",
"senderName": "...", // 第三方发送者显示名;空 = 主人
"adapterId": "kook:3bef...", // IM 来源适配器;Web 来源为空
"personaId": "...", // 空 = 全局默认角色
"clientMsgId": "...", // 空则省略
"turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径)
"toolCalls": [ // v0.78;🔴 只在 GET /sessions/{id}/messages 这一个出口填充(见下)
{ "name": "web_search", "status": "ok", "durationMs": 1200,
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
],
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
"mime": "audio/ogg",
"size": 12345,
"durationMs": 3200, // 可选:渠道如实给出时才有(绝不估算)
"url": "/api/v1/media/a1b2c3….ogg" // 与 name 同生共死(服务端相对路径)
},
"createdAt": 1700000000000
}
```
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId`/`toolCalls`/`voice` 均为 `omitempty`(空时键缺席;`voice` 是**指针**,非语音消息整键缺席,**不是** `{}` 也不是 `null`;`toolCalls` 见下)。
> ⚠️ **`toolCalls` 只在一个出口填充(v0.78,切片 CODE-TC)**:字段加在共享 DTO 上,但**只有** `GET /api/v1/sessions/{id}/messages` 的 handler 会填它 —— 其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住它们不产出该键)。客户端不要在那些面上等 `toolCalls`。
**`turnId` 语义(v0.66,可选)**:
- 形状:字符串,与**该路**的实时流帧帧壳 `turnId`、以及 `turn_begin` 帧壳 `turnId` **同值**(编排器生成,见 §3.4.3 / §4.8)。
- **同一路的 user 行与 assistant 行同值** ⇒ 客户端在**重连 / 回放**时可用它精确配对「回复 ↔ 提问」,并恢复回复侧的分组小标(真并行下两路回答交错,只按顺序渲染会配错)。
- 退化为空(**键省略**)的三类情形:① 加入该列(迁移 `0018`)之前的历史行;② 未经编排器的写入路径(**IM 渠道桥等**,其行 `turn_id` 留空是预期行为);③ 忙时入队 / 被转向吃进的消息 —— 它们**落库那一刻**还没有自己的路,那一帧 `message_persisted` 因此**不带** `turnId`,客户端从随后的 `turn_begin`(帧壳 `turnId` + `data.messageIds`)得知归属;行本身由**实际处理它的那一路**随后补写(先到先得)。
- 列口径:`chat_messages.turn_id TEXT NOT NULL DEFAULT ''`(**不用 NULL**,于是「旧行」与「没写」是同一个值)。
**`toolCalls` 语义(v0.78 落地,切片 CODE-TC;可选,仅 `GET /sessions/{id}/messages`)**:
- 形状:数组,每条 `{name,status,durationMs?,argsSummary?,resultSummary?}`。`name` / `status` **恒在**;`status ∈ {ok,error,unknown}`(**闭集三态**);后三个键 `omitempty`(图上给不出该项时**键缺席**)。
- `name` = 图的工具节点里 `props.tool`(回落节点 `label`)。`status` 映射:图节点 `done → ok`、`error → error`、其余(`running`/`idle`/空)→ `unknown` —— **未知一律落 `unknown`**,绝不把没结论的调用说成成功。
- `durationMs` = 图上量到的起止差(毫秒);**缺任一时间戳、或不足 1ms ⇒ 键缺席,绝不估算、绝不回 `0` 冒充**。
- `argsSummary` / `resultSummary` 逐字来自**已落盘的轮次链路图**(`flow_graphs`)里**出图时已脱敏**的 props(`args_summary` / `result_summary`);**失败的调用在 `resultSummary` 里给的是图上的 `error` 摘要**(与面板同一份已脱敏文本)。**读侧不做二次脱敏、也不放宽**。两者各自 ≤ **500** 字符(超长按 rune 截断,末位 `…`)。
- 单条消息最多 **20** 条(超出按**图序**取前 20 条)。
- 🔴 **缺席语义(契约核心)**:`toolCalls` 出现 ⇔ 这条消息所属的那一轮**确实调用过工具**;图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / **该路标识查不到图** / 图源未接线)**以及**这一轮没调过工具,**都是键缺席**,**绝不回 `[]` 冒充「没调过工具」** ⇒ 客户端只做**正向断言**(有键 ⇒ 调过工具),**不得**把「没有这个键」读成「没调过工具」。
- 数据源接线:`Deps.FlowGraphs`(`*flowgraph.SQLStore`)为 nil 时**只是没有这一面** —— 端点仍 `200`、正文一字不变(**不**像管理端点那样回 `500 flow_unavailable`)。查图失败只记一条带来源的 WARN 并让字段缺席(**不让可视化存档的故障把整页历史变成 500**)。
- ✅ **覆盖率已补齐(v0.79,切片 CODE-TC-2)**:编排器驱动的**每一路**(含**普通单路回合**与**队首续跑路**)的图现在都带权威 `turnId`(= 该轮消息行的 `chat_messages.turn_id`)⇒ **这些回复都能回看到工具**(此前只有真并行路可以)。仍**缺席**的只剩两类:① **拿不到权威标识的写入路径** —— 未接编排器的调用方、**后台思考**(`sess-backthink` 在发布会话白名单内,但它的 ctx 上没有权威标识)、旧后端;② **v0.79 之前落盘的历史单路行**(`lane_turn_id` 为空且**无可回填来源** —— 那时的图里根本没存这个标识,用会话 id / 时间戳 / 消息表 join 去补都是**编造身份**)。见 §7.2、§7.4-23、§7.4-25。
- 🔴 **一处形状变化(v0.79)**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 客户端若要给 `GET /admin/flow/graph?turnId=` 取值,**一律用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(老客户端自拼的 `web-7` **查不到新行**)。见 §4.11(a)(d)。
- 新增的读侧依赖:`FlowGraphStore` 增只读方法 `GraphsByTurnID(ctx, sessionID, laneTurnIDs)`(一页一次 `IN` 批量查,禁 N+1);新表列 `flow_graphs.lane_turn_id`(迁移 `0023`,等值索引 `idx_flow_graphs_lane_turn_id (session_id, lane_turn_id)`)。**老行**(v0.77 之前的落盘)该列回填后仍为空 ⇒ 如实查不到。
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
- **🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**(这条消息是一条可播放的语音)。🔴 **v0.98 变更**:出站降级行(她请求语音但 TTS 超时/失败转文字)与入站转写失败行都是 `status:"failed"` 且**不可播**——按旧判据(仅键存在)会被误渲染成语音气泡;`status=="failed"` 的行按**文字消息 + 降级小标**显示(`fallbackReason` 如 `"tts_timeout"` 可做特殊提示)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
- `url` 是**服务端相对路径** `/api/v1/media/<name>`(唯一派生字段,由服务端一处拼出)⇒ **客户端绝不自己拼路径**。
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
- ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
**`documents` 数组语义(核心)**:
- 形状:`{name,mime,size,chars,truncated,status,reason?}`,**≤5 条/回合**,顺序 = 客户端发送顺序。
- **0 个附件时该键省略**(不是 `[]`,与引入文档前逐字相同)。
- `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型附件清单,正文没进上下文)/ `unreadable`(读不到正文)。
- **`chars` / `truncated` 是「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数(截断时为截断后值),**不是文件字数**;`truncated` = **注入**是否被预算截断,**不是文件是否被截断**。全文长度只有 `document-read` 工具的 `totalChars` 能看到。
- `reason`(可选,`omitempty`):**只在 `status:"unreadable"` 时下发**(`extracted`/`listed` 时键缺席)。逐字就是服务端注入给模型的那份原因(同一字符串),客户端**不必自己拼**。当前文案集合(服务端常量,逐字):
- `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`
- `无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传`
- `无法抽取正文:PDF 损坏或无法解析`
- `无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)`
- `无法抽取正文:内容不在可抽取的纯文本范围内`
- `无法抽取正文:文档文件不存在或已被清理` / `…:读取文档失败` / `…:文档引用形态非法` / `…:文档上传目录未配置`
- `无法读取或解析失败`(解析器整条消息失败时的诚实兜底)
- ⚠️ **只放文案**:不含落盘路径、不含凭据;原始错误只进服务端日志。
- **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。
- 时点:`status`/`chars`/`truncated`/`reason` 由**回合结束后**的回写写进 `document` 列 ⇒ 只在**读取面**(`GET /sessions/{id}/messages`、握手回放)可见;`message_persisted` 在用户消息落库时就发出(那时还没有结论)。
- 列被写坏时(外部改动/格式变更)**绝不静默当无附件**:回一条 `status:"unreadable"` 的最小引用。
### 4.5 图片 / 文档 / 语音链路
**图片**:
- 两条入口:`POST /api/v1/upload`(multipart,**单文件字节 ≤ 5 MiB**)取**绝对同源 URL**;或 `chat.image` 直接内联 base64 `data:` URI / http(s) URL。
- ⚠️ **按形态写清(v0.66 补充说明,更正旧措辞「解码后 5 MiB」)**:`chat.image` 的**唯一入口判据是 `image` 字符串长度,不是「解码后字节数」** —— `POST /api/v1/chat` 的 `handleChat` 在**解码 / 落库 / 入模型之前**调用 `checkChatImageSize(image, limit)`(`internal/services/gateway/rest.go`),判定就是 `len(image) <= limit`;`limit` = `CHAT_IMAGE_MAX_BYTES`(默认 `5242880`)按 base64 膨胀 4/3 换算出的**字符**上限 = `ceil(n/3)*4 + 64`,默认 **6990572 字符**(那 64 字符是 `data:` 前缀余量)。**超一字符**即 `400 {"error":{"code":"image_too_large"}}`(`message` 带上限与本次长度,文案指路先上传)。
- **base64 / `data:` URI 形态**:受上述 **6990572 字符**约束,**等价于解码后约 5 MiB**。`data:` 前缀越长,留给载荷的字符越少;**裸 base64**(无前缀)在恰界(正好 6990572 字符)时解码 ≈ `5242929` 字节 = **5 MiB + 49 字节** ⇒ **「解码后 5 MiB」是换算口径**,逐字节的硬判据在代码里**不存在**(视觉解码层的兜底预检 `vision/limits.go` 同样是**按字符串长度**判 `MaxInlineImageChars`)。要与「一张图 5 MiB」严格对齐,客户端按 `ceil(n/3)*4 + 64` 自查即可,不要按解码字节数试探。
- **http(s) URL 形态**(含 `/api/v1/upload` 返回的 `/api/v1/files/<32hex>.<ext>`):判据与形态无关(任何**短于 6990572 字符**的串都通过),而正常 URL 只有约 60 字符 ⇒ **实践中 `image_too_large` 不会因 URL 形态触发**。URL 形态真正的天花板在**视觉取回层**:`internal/runtime/multimodal/vision/image.go` 的 `maxFetchedBody = maxImageBytes = 50 << 20`(**50 MiB**;其**视觉/OCR 工具路径**下同源 upload 产物优先走本地直读 `readFile`,用同一常量,而**主模型直收路径**仍走一次 HTTP 自取),另有 **30 s** 取回超时。⚠️ 该 50 MiB 是 `io.LimitReader` 的**截断**上限 —— **超出部分被静默丢弃、不返回错误** ⇒ 客户端**不要**把「服务端会拒绝超大 URL 图」当契约,也不要把 50 MiB 当成能用满的额度。
- **upload 形态**:`POST /api/v1/upload` 自带 **5 MiB** 单文件硬上限(`maxUploadBytes = 5 << 20`;超限 `413 {"error":{"code":"payload_too_large"}}`)⇒ 经 upload 拿到的同源 URL 图,有效上限仍是 **5 MiB**,在**上传那一刻**定死,与本接口的字符串长度判据无关。
- 上限来源 `CHAT_IMAGE_MAX_BYTES`:`≤0` = 用默认(**不存在「禁用上限」的取值**);**只能收紧、不能放宽**(钳到 vision 解码层硬顶 `vision.MaxInlineImageBytes` = 5 MiB)。
- `data:` 前缀与类型嗅探**都不参与**这个体积判据:前缀只通过那 64 字符余量影响能塞多少;类型裁决(**字节嗅探**为唯一裁决权;HTML/SVG/判不定 → 明确报错,绝不静默当 PNG)发生在**视觉层**,属回合内错误,**不是**入口 `400`。
- **WS `chat` 帧到不了这条判定**:WS 上行(`streamChat`)**没有任何图片体积校验**,读上限 4096 字节/帧由传输层直接断连 ⇒ **WS 上发真实图片必须「上传 + URL」**。
- 视觉 URL 由服务端取回内联为 `data:` URI 再交 provider。**主模型能否收图**看生效组的 `multimodal`(见 §5),与 `LLM_VISION_MODEL`/`LLM_OCR_MODEL` 无关。
**文档**:
- 上传 `POST /api/v1/documents`(owner/admin,字段 `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)。
- **类型裁决 = 字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 `.pdf`)**,**成对判定**(`.pdf` 必须嗅探为 `application/pdf`,纯文本类必须 `text/plain`)。HTML 改名 `.pdf` ⇒ `415`;PDF 改名 `.txt` ⇒ `415`。`mime` 是**嗅探基类型**(`data.json` → `text/plain`;真 PDF → `application/pdf`)。
- 落盘名 = `sha256(bytes)[:32] + ext`(内容寻址;原始文件名永不参与路径 ⇒ 同字节同扩展名天然去重)。
- 读取 `GET /api/v1/documents/{name}` **必须 Bearer owner/admin**,一律 `attachment` + `nosniff`(永不内联)。
- 对话携带:`chat.documents = [{"name":"<服务端存储名>"}]`(**只给 name**,绝不接受 URL;多带的 `mime`/`url`/`size` 一律被服务端覆盖)。**≤5 条**,超出 `400 too_many_documents`(先于任何文件系统访问);任一条非法/不存在 → **整个请求被拒**(`404 document_not_found`),绝不静默丢附件。附件部件挂在**同一条 user 消息**上;**纯附件回合**(无文本、无图片)同样成立。
- 内置工具 `document-read`(模型侧,客户端一般只需知道它存在):注入被截断时清单行给续读游标 `offset`。策略默认 owner-only(群聊/陌生人天然读不到)。
- **PDF(v0.61)**:文本层 PDF ⇒ `extracted`;扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`;加密与损坏可区分。
**语音**:
- REST 源(`/api/v1/audio/*`,均 **owner-only + 审计**,绕开 WS 4KB 帧限):见 U5–U7。
- `/transcribe`:`{audio,format?,deviceId?}` → `{data:{text,provider?}}`。
- `/speech`:`{text,voice?,format?,instruction?}` → **原始音频字节**(`Content-Type: audio/<format>`)。`instruction`(≤100 字符,汉字记 2)只对复刻/设计音色生效,系统音色 `longxiaochun_v3` 被忽略。
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
- **音频副本何时产生**:渠道适配器入站拿到语音字节后,转写在**流水线最前端**归一(见上),而**落盘在闸门之后** —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音**盘上不留任何文件**(与 IM 附件同一纪律)。落盘 = 内容寻址(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重),产出该消息的 `voice` 引用(DTO 见 §4.4)。**不可播格式(`amr`)在落盘之前先转成可播副本(`amr→wav`)**;转码不可用时**不落副本、不打假 url、消息一个字不丢**(带稳定 `category` 的 WARN;10 类:`no_bytes`/`not_playable`/`transcode_not_wired`/`unsupported_format`/`input_too_large`/`output_too_large`/`decode_failed`/`encode_failed`/`timeout`/`busy`)。⚠️ **现状(v0.80 / 切片 W4 起)**:组合根**已注入 `VoiceTranscoder`** ⇒ `amr` 先转 `.wav` 再落副本(`mime: audio/wav`);可播格式仍原样落、**不转码**。⚠️ `transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**)。
- **播放端点 `GET /api/v1/media/{name}`**(**owner/admin,必须 Bearer**,与 `GET /api/v1/documents/{name}` 同一套门):
| 项 | 口径 |
|---|---|
| 名字形态 | 32 位小写十六进制主名 + **音频白名单**扩展名(**10 项**:`.wav` `.mp3` `.aac` `.ogg` `.oga` `.opus` `.m4a` `.webm` `.flac` `.amr`);`..` 等路径穿越全形态拒绝 |
| `Content-Type` | 按**存储名的扩展名**映射,**恒为 `audio/*`**(`.wav→audio/wav`、`.mp3→audio/mpeg`、`.aac→audio/aac`、`.ogg`/`.oga`/`.opus→audio/ogg`、`.m4a→audio/mp4`、`.webm→audio/webm`、`.flac→audio/flac`、`.amr→audio/amr`)。**不查库、不重新嗅探**(扩展名与字节在**保存时**已成对裁决) |
| `Content-Disposition` | `inline; filename="<name>"` —— 与文档读侧的 `attachment` **刻意相反**(音频要被播放器消费;因下发类型恒 `audio/*` + `nosniff`,内联不构成文档侧那类 XSS 面) |
| 其他响应头 | `X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;**不设 `ETag`**(不自造语义,条件请求走 `Last-Modified`) |
| 状态码 | `200` 全量 / `206 Partial Content`(`Range: bytes=0-`、`bytes=N-M`、`bytes=-N`,**由 Go 标准库 `http.ServeContent` 处理 —— 客户端不要自研 range 解析**)/ `304 Not Modified`(条件请求)/ `404 media_not_found`(**名字形态非法 / 路径穿越 / 文件不存在三者同一响应**,不泄露「存在与否」以外的信息)/ `401` / `403`(中间件) |
| `HEAD` | 支持(`GET` 模式同时匹配 `HEAD`)⇒ 客户端可先探长度再决定要不要整段拉 |
| ⚠️ `416` | **不是 JSON 信封**:`Range` 语法非法或范围不可满足 ⇒ 标准库直写 `text/plain` + `Content-Range: bytes */<size>`,**不套** `{"error":{…}}`。唯一例外:**空文件** + 范围不可满足 ⇒ 忽略 `Range` 回 `200`(标准库的特别处理) |
| 日志 | 404 **不记**错误日志(可预期的输入问题);不记任何字节内容 |
- **三条读侧路的边界(别混)**:
| 端点 | 鉴权 | Disposition | 名字形态 | 语义 |
|---|---|---|---|---|
| `GET /api/v1/files/{name}` | **无**(能力 URL) | `inline`(按图 MIME) | 128-bit 随机名 + 图片扩展名 | 图片,给 `<img>` / provider 直接吃 |
| `GET /api/v1/documents/{name}` | **Bearer owner/admin** | `attachment` | 32 hex + **文档白名单**(23 项) | 文档,**永不内联渲染** |
| `GET /api/v1/media/{name}`(v0.67 登记 · **v0.79 已实现**) | **Bearer owner/admin** | `inline; filename=…` | 32 hex + **音频白名单**(10 项) | 语音副本,供播放器消费 |
- ⚠️ **音频 URL 不能直接贴进 `<audio src>`**:读侧要 Bearer(与图片读侧「128-bit 名即凭据」**刻意相反** —— 语音是私密内容,不是「谁拿到 URL 谁就能听」)⇒ 客户端须**带 token 取字节**(Web:`fetch` → `blob:` → 播放;原生:认证 HTTP 客户端)。这是**有意**的设计,不是缺陷。
- ⚠️ **可播放性未逐一实测**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了 ⇒ 客户端**以实测为准**,播不了就照实提示(不假装;**绝不给一个播不了的 `url`**)。「能播」必须是**格式级**结论,不得靠「扩展名看起来像音频」就宣称可播。
- ⚠️ **没有「删除一条语音副本」的 API**:保留期 GC 是唯一出口(`DOCUMENT_RETENTION`,默认 168h)。
### 4.6 `chat` 请求与响应面
**请求体**(`POST /api/v1/chat` 与 WS `chat` 帧 `data` **同形**):
```json
{"sessionId":"web","text":"...","image":"<base64/data:/URL>","deviceId":"web:chrome:a1b2c3",
"mode":"chat","clientMsgId":"<uuid>","documents":[{"name":"a1b2....md"}],"parallel":true,"stream":false}
```
- `sessionId` 省略 → `"web"`。
- `deviceId`:客户端端标识(如 `web:chrome:a1b2c3`),随消息落库。
- `mode`:行为模式(如 `chat`/`code`/`home`/…),随该回合落库;缺省回落 `chat`。
- `image`:base64 `data:` URI、裸 base64 或 http(s) URL。**上限按形态不同**(判据是**字符串长度**,不是解码后字节),见 [§4.5](#45-图片--文档--语音链路)。
- `stream`:仅 REST 用,见下。
**同步(非 `stream`)响应面**:
| 情形 | 状态 | 载荷 |
|---|---|---|
| 正常跑完 | `200` | `{data:{reply,steps,stopReason}}` |
| 幂等键重复 | `200` | `{data:{duplicate:true,messageId,sessionId}}` |
| 忙、已续接排队 | `202` | `{data:{queued:true,queueDepth}}` |
| 忙、显式并行且照准 | `202` | `{data:{parallel:true,turnId:"turn-…"}}`(**拿不到同步 reply**;文本经 WS 流式到达) |
| 队列满 | `409` | `{error:{code:"chat_busy"}}` |
| 空回合(无 text/image/documents) | `400` | `{error:{code:"bad_request"}}` |
| **`image` 字符串超长**(**仅 base64 / `data:` 形态会触发**;URL 形态不会) | `400` | `{error:{code:"image_too_large"}}` —— 在解码/落库/入模型**之前**拒;上限 = `CHAT_IMAGE_MAX_BYTES` 换算出的**字符**上限(默认 **6990572 字符**),见 §4.5 |
| 未接 loop | `500` | `{error:{code:"chat_unavailable"}}` |
| 回合内部报错 | `500` | `{error:{code:"chat_failed"}}` |
**`stream:true` 响应面(行为变更,v0.56/G2;**受理成功后才回**)**:
| 情形 | 状态 | 载荷 |
|---|---|---|
| 空闲已起回合 | `200` | `{data:{stream:true}}` |
| 忙、已续接排队 | `202` | `{data:{stream:true,queued:true,queueDepth}}` |
| 队列满 | `409` | `{error:{code:"chat_busy"}}` |
| 幂等键重复 | `200` | `{data:{duplicate:true,messageId,sessionId}}` |
| 已升为独立并行路 | `200` | `{data:{stream:true,parallel:true,turnId:"turn-…"}}` |
| 未接 loop | `500` | `{error:{code:"chat_unavailable"}}` |
| 仅未接编排器时的总线派发失败 | `503` | `{error:{code:"chat_dispatch_failed"}}` |
> **不再有「先回 200 再看有没有落地」**;`202`/`409` 真的会出现。派发用的回合上下文与请求作用域脱钩(`WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。
**忙时并行(v0.65 起,`parallel:true`,仅显式声明时生效)**:
- **触发权在客户端**:服务端**不做任何启发式自动升级**。空闲时 `parallel` 无效果(照常起第 1 路)。
- 会话**忙**且单会话并发额度未满(默认 **2**,硬上限 **3**)⇒ 本条**升为一条独立并行路立即起**(见上表 `{parallel:true,turnId}`;该 `turnId` 随后出现在这一路的 `turn_begin` 帧壳与全部流帧帧壳上)。
- 额度已满、**或真并行被 `<2` 关掉** ⇒ 落回排队语义(`202 {queued:true,queueDepth}`)**并在 `pending_ack` 带 `reason:"parallel_limit"`**。
- 在途路数经 `activeTurns` 出现在忙态帧(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted`)与 `turn_begin.data` 中。
- `session_idle` 时点收紧(**仅并行时与旧行为不同**):所有在途路收尾**且** pending 空才报空闲(单路时逐字不变)。
- **每一路起跑都发一帧 `turn_begin`**(v0.66 落地,见 §3.4.3)——客户端据此把用户气泡钉到路、并按 `sessionId + turnId` 两级分桶。
- **省略 `parallel` 的客户端行为逐字不变**。
- ⚠️ **env `CHAT_MAX_PARALLEL_TURNS` 未接线**(见 §5、§7):生产**恒为默认 2**。
### 4.7 枚举权威表
`GET /api/v1/meta/enums` → `{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`。
- `platform`:`web`、`kook`、`qq-obv11`、`weixin-ilink`、`telegram`、`webhook`。
- `mood`:`calm`、`happy`、`excited`、`sad`、`angry`(**可扩展**,非封闭集合)。
- `mode`:`chat`、`code`、`home`、`work`、`docs`、`voice`、`vision`、`agents`、`settings`。
- `unknown` 恒 `"display-raw"`:**未知取值原样显示**——不丢弃、不报错、不替换占位符。判定**只比对 `id`**(大小写敏感);`label` 仅默认显示。服务端新增取值是非破坏性变更。
- ⚠️ **未确证**:上表逐值取自 `internal/contract/enums`(本文档经归档版转述,**未逐一回代码核对**)。客户端**应直接读本端点**而非硬编码。
### 4.8 `interrupt` 的 `scope` 与 `turnId`
上行 `{"type":"interrupt","data":{"sessionId":"web","scope":"current","turnId":"turn-…"}}`。
- `scope` 取值域 `{"", "all", "current"}`(**大小写敏感**,只容忍首尾空白):
- 省略 / `""` / `"all"`:取消**全部在途路**并**清空待处理队列**(历史语义)。
- `"current"`:只取消在途路(**多路时为每一路**),**保留**待处理队列(队列消息按正常续跑规则继续执行)。
- 其它取值 → **定向**回发送者 `{"type":"error","data":{"code":"bad_request"}}`(不广播、不打断任何会话,绝不静默按默认执行)。
- **`turnId`(v0.66 落地)**:**只在 `scope:"current"` 时生效**,只取消该 `turnId` 那一**路**(其余在途路与待处理队列不受影响)。省略 scope 或 `scope:"all"` 时按契约**忽略** `turnId`(服务端只留一条 debug 日志,响应面不变)。
- 目标不存在(已收尾/已取消/id 写错)⇒ 定向回 `{"code":"bad_request"}`(只到发送者,**不广播、不动任何会话**),**绝不**静默降级成「停全部」。
- 省略 `turnId` 的 `scope:"current"` = 作用于**每一路**(v0.65 原口径,逐字不变)。
- **`scope:"all"` 不发 `session_idle`**:客户端靠 `session_interrupted` 收口。
- 命中空闲会话为 no-op。
### 4.9 会话角色绑定
- 会话角色(`personaId`)**创建时确定、之后不可变**(design/22 §8-1):`POST /sessions {personaId}` 在创建时绑定;`PATCH /sessions/{id}` 携带 `personaId` 键一律 `400`。
- 空/缺省 = 继承全局默认角色(`owner.yaml:defaultPersona`)。
- 消息 DTO 的 `personaId` = **该消息说话时的角色**(assistant = 生成它的角色,user = 当回合角色;空 = 默认角色)。
### 4.10 v0.68 新增:配置事实面与后台思考行的紧急度
#### (a) `GET /api/v1/admin/config-facts`(只读,`owner`/`admin`)
响应 `{data:{facts:[…], quietHours:{…}}}`:
| 键 | 类型 | 说明 |
|---|---|---|
| `facts[].key` | string | env 键名,**与 `.env.example` 逐字一致**(后端有测试钉住) |
| `facts[].value` | string | **进程生效值**(已解析:bool → `"true"`/`"false"`;整数 → 十进制;比例 → 归一化小数如 `"0.8"`)。**不是** `.env` 原文 |
| `facts[].default` | string | 该键的**声明默认值**(`internal/config` 结构体 `default:"…"` 标签,唯一真源) |
| `facts[].isDefault` | bool | `value == default` ⇒ 面板显示「默认」(**不代表 `.env` 里没写过这个键**:网关按红线**不读 `.env`**,无从不区分「默认层」与「结构体默认」) |
| `quietHours.startHour` / `endHour` | int | 安静时段的生效钟点(本地时区小时)。⚠️ 报的是**运行时真正生效**的那一个:两端都写 0 时按「未填」回落到声明默认(见下 `fallbackToDefault`) |
| `quietHours.disabled` | bool | 窗口是否**关闭**(两个相等的**非 0** 钟点,如 `23/23`) |
| `quietHours.label` | string | 面板可直接显示的文案:`"23:00-07:00"` 或 `"已关闭"` |
| `quietHours.fallbackToDefault` | bool,**可选**(`true` 才出现) | 两端都被写成 `0` ⇒ 视为**未填**,运行时回落到默认窗口(`23:00-07:00`,此时 `startHour`/`endHour` 已是回落后的值)。⚠️ **`0/0` 不是「关闭」**:backthink 与记忆整理都会做这次回落,事实面按同一口径报,否则会把「未填」谎报成「已关闭」 |
- **固定 13 条事实**(记忆整理 **8** 键 + 后台思考心跳 3 键 + 安静时段 2 键):`MEMORY_TIDY_ENABLED`、`MEMORY_TIDY_INTERVAL_SEC`、`MEMORY_TIDY_QUIET_START_HOUR`、`MEMORY_TIDY_QUIET_END_HOUR`、`MEMORY_TIDY_RECALL_THRESHOLD`、`MEMORY_TIDY_SCAN_LIMIT`、`MEMORY_TIDY_CANDIDATE_LIMIT`、`MEMORY_TIDY_COMPARE_LIMIT`、`ENABLE_BACKGROUND_THINKING`、`THINK_INTERVAL_SEC`、`THINK_QUIET_INTERVAL_SEC`、`QUIET_START_HOUR`、`QUIET_END_HOUR`。
- 🆕 **v0.75 追加(同一端点、无版本化形状变更)**:`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / `THINK_QUIET_INTERVAL_SEC` 三键并入事实表(design/12 §N A4)—— 安静时段降频上线后,面板必须能读到「普通心跳周期」与「安静窗口内的心跳周期」。⚠️ `THINK_QUIET_INTERVAL_SEC` **≤0 = 关闭降频**(回落普通间隔,不是「半夜不思考」),事实面照原值回,不替用户改写。⚠️ 这几个键都是**启动期**读取的:改了要**重启**才生效(不可热改)。⚠️ `ENABLE_BACKGROUND_THINKING` 是**未接线**的死配置(全仓无消费者,见 §5「配了不生效」):它被列进事实表是为了**让面板看得见**,`value` 只是它解析出来的值,**不代表它会生效**;面板文案必须标明(见 `frontend/web/src/admin/modules/configFacts.ts`)。
- 🆕 **v0.77 追加(同一端点、无版本化形状变更)**:`MEMORY_TIDY_COMPARE_LIMIT` 并入事实表(切片 MT-A)—— 它是**每分区每轮真正参与两两比对**的条目上限,默认 `2000`;此前**写死为 `400`** 且没有 env 键(实测后果:459 条的分区被截到 400 ⇒ `compared(705) < scanned(784)`,观测口径被上限决定)。列进事实表是为了让面板看清「这项是不是还是默认值」。与记忆整理那组键一样是**启动期**读取的:改了要**重启**才生效。
- 🔴 **不含任何凭据**:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),**也不回凭据类键的「是否已配置」** —— 本端点只覆盖上面 13 个非敏感调参键。
- **「默认 / 已改」怎么读**:`isDefault:true` ⇒ 面板显示「默认」(生效值等于该键的声明默认值);`isDefault:false` ⇒ 显示「已改」。⚠️ 它**不是**「`.env` 里没写过这个键」的断言 —— 网关按红线**不读 `.env`**,无从不区分「`.env` 给的值恰好等于默认」与「完全没配」。**哪一项没配**因此不会被伪造成一个值:面板拿 `value` + `isDefault` 如实展示即可。
- 13 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。
- **安静时段的语义(重要,别照旧实现写文案)**:安静时段**只服务「定时主动思考 → 主动搭话」**(`design/12 §6` 策略五),**不静默回复** —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案**不得**写成「夜间全静默」。
#### (b) `GET /api/v1/admin/backthink` 条目的紧急度字段(v0.68 引入;**v0.70 起是精确档位**)
| 键 | 类型 | 说明 |
|---|---|---|
| `urgency` | string,**可选** | `"routine"` / `"warning"` / `"critical"`(`internal/urgency` 的档位词汇)。**默认 = 该轮实际生效的精确档位**(随记录落库)。**只有与 `urgencyLowerBound:true` 同时出现时才读作「下限」**(`warning` = 至少 high、可能更高) |
| `urgencyLowerBound` | bool,**可选**(`true` 才出现,**v0.70 新增**) | 该行**没有落库档位**(`urgency` 列出现之前的旧行,或从未走到档位判定点的行)⇒ `urgency` 是由安静时段窗口规则**反推出的下限**,不是精确档位 |
| `quietHoursBypassed` | bool,**可选**(`true` 才出现) | 该轮**穿透了安静时段**:轮次时间落在安静时段窗口内**且**该轮判定为「要推送」 |
- **取值优先级(v0.70,客户端按这个顺序读)**:
1. `urgency` 存在且 `urgencyLowerBound` 缺席 ⇒ **精确档位**,可直接显示(`critical` 即安全域,`warning` 即 high);
2. `urgency` 存在且 `urgencyLowerBound:true` ⇒ **下限**,只可显示为「≥ X」(`≥ 高` = 至少 high、可能更高;`≥ 例常` = 只确定不低于最低档),**不得**显示成精确值;
3. `urgency` 缺席 ⇒ **未知**(存档里没有这个事实),面板显示「未知」而**不是**「例常」——把未知画成「例常」与把下限画成精确值,是同一类「把猜当事实」。
- 三个键都可能缺席,缺席 ≠ 常规档位。旧行(`urgency` 缺席)出现 `urgencyLowerBound:true` 的情况**不存在**:没有值就没有可标记的下限。
- 旧行(无落库档位)与 `quietHoursBypassed` 的口径(**v0.68 起不变**):只在该轮时间**落在安静时段窗口内**时给出下限 —— 窗口内被放行 ⇒ `warning`(由 `backthink.Decide` 的级联可证:安静时段只放行 ≥ `high` 的轮次)且 `quietHoursBypassed:true`;窗口内被扣下 ⇒ `routine`。窗口之外的旧行判定不看档位、存档里也没有档位,故**不下发**。窗口口径与 B12 的 `quietHours` **同一份**(含「`0/0` 视为未填、回落到默认」这条),因此 0/0 的部署也不会漏报穿透。
- `quietHoursBypassed` 与既有的 `bypassed`(v0.66 前已有,含义是**事件驱动绕过限频闸**)是**两件事**,别混用:前者说「安静时段」,后者说「限频门」。
- ⚠️ **实时帧(v0.70 更正)**:`/ws/chat` 下行的 `backthink` 帧其 data 是 bus 上发布的原始 `backthink.Record`(Go 字段名 `ID`/`Trigger`/`ShouldPush`/`PushResult`…)。**该记录自 v0.70 起带 `urgency`(该轮实际生效的精确档位,非下限)** ⇒ 实时行同样可显示精确档位,不必等重开页面。**但实时帧仍不带 `urgencyLowerBound` 与 `quietHoursBypassed`**:这两个键是网关读历史时按安静时段窗口推导的,**不在记录里**(`urgencyLowerBound` 对实时行也不会出现——实时行要么有精确档位、要么无档位)。⚠️ 原 v0.68 表述「实时帧不带这两个键」对 `urgency` **已过期**,本版更正;对 `quietHoursBypassed` 仍然成立。
#### (c) 档位的来源与诚实边界(**v0.70 重写**)
- **精确档位(默认路径)**:`backthink.Record.Urgency` 由 backthink 在档位判定点原样落库(该轮实际生效的档位,含她自报紧急度上抬后的结果)⇒ 网关**优先读它**并**原样下发**,与安静时段窗口、与时间戳都无关。因此「白天的一轮」如今也有精确档位(v0.68 时代窗口之外一律显示「未知」)。
- **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。
- 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。
#### (d) v0.81 新增:她自排轮(`self_wake`)的用户可见面
> 依据 `design/12 §N C`(自排:结构化 self-initiated 触发 + 她可调用的「自排一次思考」工具 + 每窗口上限 + 审计)。**v0.81 当时无新端点、无新字段表**,只有两处**取值域加性扩张**与一条**口径澄清**。
> ✅ **可达性(v0.82 已改正,别按旧口径读)**:v0.81 落地的是**判定侧(C1-1 `94da6bc`)与自排本体(C1-2 `ed50053`)**,当时她的入口工具 `request_think` **尚未接线** ⇒ 那一版写的是「生产里还不会真的出现」。🔴 **这句已过期**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`;启动日志自证 `self wake tool registered tool=request_think`)⇒ **`trigger="self_wake"` 与那 4 条审计动词在生产里会真的出现**。客户端可以把它当活跃数据读(仍**不保证每轮都有** —— 自排要过闸,见下)。
**(d-1) `GET /api/v1/admin/backthink` 的 `trigger` 出现新取值 `"self_wake"`**
| 键 | 类型 | 说明 |
|---|---|---|
| `trigger` | string,**恒在**(B8/§2.2) | 这一轮**为什么**被触发。取值**不封闭**,当前实际会出现:`"heartbeat"`(周期性自主反思)/ `"startup"`(启动轮自己的第一轮)/ **事件轮的 `Subject` 字符串**(IoT 设备变化、提醒等,形如事件主题)/ **`"self_wake"`(v0.81 新增)** = **她自己排起的一轮思考**(`internal/services/backthink/trigger.go` 的 `SelfWakeSource`;`design/12 §N C`)。⚠️ 面板**按字面值直显**即可;**不要**对 `trigger` 做闭集校验(事件轮那一支本来就是开放集合) |
- **自排轮的实质**:她(在主人会话里或后台思考轮里)调用内部工具 `request_think(reason, urgency?)` 排起一轮 —— 记录行 `trigger="self_wake"`、`urgency` 照旧是**该轮实际生效**的档位(她自报的档位只**上抬**、绝不压低,见 §4.10(c))。
- 🔴 **边界(别过度解读这个键)**:① 自排**只保证「她能被叫醒思考」** —— 这一轮**要不要真的发出去**仍由既有 `decide` 级联 + `PushGate` 决定(安静时段 / 限频 / 冷却照旧生效,**不**因为是她自排就绕过);② **`self_wake` 不会出现在 IM 渠道消息上**,也不代表「消息已发出」;③ **不给客户端任何新的操作入口**(自发自排是她的内部能力,不是客户端的 API)。
**(d-2) 新增 4 条审计动词(经 B9 `GET /api/v1/admin/observability/audit` 可查)**
| `what` | `who` | `result`(可直接显示的中文;`<reason>` 是她写的理由) |
|---|---|---|
| `backthink.self_wake.queued` | `backthink` | `已排入一轮自主思考,理由:<reason>` |
| `backthink.self_wake.coalesced` | `backthink` | `已合并到排队中的那一轮(不额外占额度),理由:<reason>` |
| `backthink.self_wake.capped` | `backthink` | `已达自我唤醒上限(本窗口 %d/%d,窗口 %s),本轮未排队` |
| `backthink.self_wake.denied` | `backthink` | `非主人环境,拒绝自排(actor=%s)` |
- 只有 `queued` 会**真的**新增一轮思考;`coalesced` = 已有一轮在排队 ⇒ 合并进去(**不额外占额度**);`capped` = 触到每窗口上限(**默认 4 次/小时**);`denied` = 非主人环境(安全闸拦下,**不做事**)。
- ⚠️ **上限值不在契约里冻结**:它的真实来源是 **`$DATA_DIR/config/backthink.json`**(`selfWakePerWindow` / `selfWakeWindowSec`,**启动期读一次、改文件需重启**;**不新增 env 键** —— `.env` 已冻结,见 §5 与 `AGENTS §10`)。✅ **v0.82 起有只读出口了**:新增 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e))给出「本窗口已用 N / 上限 M / 窗口秒数」的**真读数**,不必再从审计行 `result` 的原文里抠。🔴 **仍没有写口**:改上限/窗口依旧只能改配置文件 + 重启(面板写路径**未做**)⇒ 客户端/面板**不得假定**能改到这个值。
#### (e) v0.82 新增:自排的成本可见面(读侧)
> 依据 `design/12 §N C.5`(每窗口上限 + 容量 1 待跑槽)与 `§N C.6`(服务端筛选)。**两条都只读**。
**(e-1) `GET /api/v1/admin/backthink?trigger=<值>` —— 服务端筛选(v0.82)**
| 项 | 值 |
|---|---|
| 查询参数 | `trigger`(**可选**)。**缺席** ⇒ 不过滤(既有行为,老客户端零影响);**在场但为空** ⇒ **`400 invalid_trigger`**;**不在白名单** ⇒ **`400 invalid_trigger`**(错误文案里**列出可选取值**,客户端可直接显示) |
| 白名单(**闭集**) | `heartbeat` / `startup` / `self_wake` + **本服务订阅的事件主题**。真源 = `internal/services/backthink/trigger_domain.go` 的 `TriggerFilterValues()`(内建标签 + `EventSubjects()`)—— **网关不另抄一份清单**(否则新增订阅主题时会出现「数据里有、筛选却 400」的静默漂移) |
| 筛选方式 | **下推 SQL**(`ListByTrigger`),因此 `limit` 数的是**匹配**条数,而不是「取一页再筛掉不匹配的」(后者会给面板一个**静默的假空值**:「0 条」而库里明明有) |
🔴 **为什么未知值必须报错、不能静默忽略**:静默忽略会让面板在「显示的是全部」的同时声称「正在筛选自我唤醒」—— 那是一个**假的筛选结果**。**空值同理**(客户端把想筛选的值弄丢了却以为在筛)。
⚠️ **与 (d) 的「取值域不封闭」不矛盾(这是本版最容易被误读的一处)**:
- **显示口径**((d),仍然成立):`Record.Trigger` 的取值**对客户端是开放的** —— 事件主题那一支本来就是开放的;客户端**按字面值显示、不做闭集校验、不隐藏不认识的取值**;
- **筛选白名单**((e-1)):`?trigger=` 是**客户端传进来的参数**,打错了必须报错,而不是静默返回一个空列表(静默的空列表会被读成「这段时间没有这一类的思考」)。
- 两者面向的是**不同方向的数据流**(一个是我们给出去、一个是客户端传进来),因此**不冲突**;判据的真源仍是**同一处**(`trigger_domain.go`)。
**(e-2) `GET /api/v1/admin/backthink/self-wake` —— 自排闸只读快照(v0.82 新增)**
**为什么有它**:`C.5` 的「本窗口已用 N / 上限 M」此前**只**出现在**被拒那一轮**的审计文案里(`backthink.self_wake.capped` 的 `result`)—— 也就是**「已经被拒过一次」才第一次看得见**。面板要能**提前**告诉主人「还能自排几次」,就得有一个只读快照出口。
**鉴权**:`writeAuth`(**owner / admin**;viewer → `403`)。理由:它含**她自排的成本口径与运行状态**,不是给 viewer 的监控投影(与列表端点 B8 同门同档)。**只读、不审计、不写库**。
**响应**:`{data:{available,running,reason?,configFile?,gate?}}`
| 键 | 类型 | 说明 |
|---|---|---|
| `available` | bool,**恒在** | 本进程**能不能**给出快照。`false` 的含义是「看不到」,**不是**「0 次」 |
| `running` | bool,**恒在** | 自排此刻**是否真的能排**(未 Start / 已 Stop ⇒ `false`:此刻 `WakeNow` 会返回 `ErrSelfWakeUnavailable`) |
| `reason` | string,**可选**(缺席 = 正常态) | 人类可读的原因。三种非正常态各一句:未接线 / 实现不提供读口 / 闸不在 / 未在运行 |
| `configFile` | string,**可选** | 要改哪个文件 —— **相对落点** `config/backthink.json`(相对 `$DATA_DIR`)。绝对路径随部署而变,接口只给相对落点 |
| `gate` | object,**可选**(`available:false` 时**整键缺席**) | 见下表 |
`gate` 子对象:
| 键 | 类型 | 说明 |
|---|---|---|
| `pending` | bool | 容量 **1** 的待跑槽是否被占用(有一轮自排排着队还没跑) |
| `used` | int | 本窗口已用额度,与工具收据 `SelfWakeReceipt.Used` **同一口径**(同一次取数:过期的记录先被丢弃,再计数) |
| `limit` / `windowSec` | int | **进程生效**的上限与窗口(已 normalize:`≤0` 的配置不会走到这里)。窗口单位是**秒** |
| `defaultLimit` / `defaultWindowSec` | int | 代码默认值 |
| `limitIsDefault` / `windowIsDefault` | bool | 生效值**恰好等于**代码默认值 |
🔴 **`*IsDefault` 不是「配置里没写这个键」(诚实边界,与 B12 config-facts 同一条口径)**:网关**不读** `backthink.json`,因此**无从区分**「配了 4」与「没配(默认也是 4)」⇒ 客户端**只能**把它读作「生效值是否等于默认值」,**不得**显示成「未配置 / 已自定义」。要表达「未配置」需要另一次取数(当前**没有**)。
**三态(面板按此显示,别把「看不到」画成「0 次」)**:
| `available` | `running` | `gate` | `reason` | 面板该说什么 |
|:--:|:--:|:--:|---|---|
| `false` | `false` | **缺席** | 有 | 「读不到自排状态」+ 原因原文。**不要**显示 0 / 0 |
| `true` | `false` | 有(配置面数字) | 有 | 「后台思考未在运行:此刻她排不出新的一轮」+ 仍显示生效上限/窗口 |
| `true` | `true` | 有 | **缺席** | 正常:显示「本窗口已用 `used` / 上限 `limit`」+ 待跑槽占用 |
- **只在 `running:true` 时才可以说「她还能排 N 次」**:`used` 是**已用**额度(`limit - used` 是剩余,但闸的判定始终由滑窗与待跑槽做,**读本快照不参与判定、也不改变闸的状态** ⇒ 读它永远不会消耗额度)。
- **`available:false` 的 HTTP 状态是 `200`**(不是 5xx):这是「本进程给不出这张快照」的**如实答复**,与 B8「未接线时返回空列表」同族 —— 面板据 `reason` 显示即可,**不要**当成请求失败重试。
### 4.11 链路图快照 + 多轮历史回放(`GET /api/v1/admin/flow/graph`;v0.71 新增,v0.73 增量,v0.77 增量)
**为什么有这条**:`graph_update`(§3.4.5)是**纯实时帧** —— 后端在每个 span 开始/结束时广播一次,**不重放**;前端把图存在**内存**的会话图桶里(`flowGraphs`)。于是「**轮次结束后才打开页面**」或「**刷新过页面**」看到的永远是空态(`/#/admin/flow` 的「暂无流程」、后台思考行「查看完整链路导图」的「该会话还没有 graph_update 快照」)。v0.71 补上**打开页面时按会话只读拉一次最近一轮的快照**;v0.73 再补上**本地落盘 + 多轮历史回放** —— 图从「只活在进程内存」变成每轮一行落进本地库(新表 `flow_graphs`,迁移 `0022`),后端重启后仍能查回,且可按 `turnId` 翻更早的轮次(`design/19 §4` 的 P3)。
#### (a) 请求与响应
| 项 | 值 |
|---|---|
| 方法 / 路径 | `GET /api/v1/admin/flow/graph` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 `design/19 §3`「flow 可视化仅 owner/admin」一致 |
| 查询参数 | `sessionId`(**可选**)。省略 / 空串 ⇒ 主会话 `web`(`internal/runtime/history.MainSessionID`)。其它取值就是该会话 id,如 `sess-backthink`(后台思考,design/20 §2)、`web:<uuid>`(次级会话)。**v0.73 新增** `turnId`(**可选**):省略 / 空串 ⇒ **最近一轮**(**先查内存 `loop.FlowPublisher.LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远是最新的);给了就是该会话已落盘的某一轮 —— **取值一律用响应里的 `turns[].turnId` / `graph.id`**(🔴 **v0.79 起单路也带 `#{回合标识}` 后缀**,形如 `web-7#turn-3f9c…`;**不要自己拼** `{会话}-{序号}`),且该值**可能含 `#`**、作 query 值必须 URL 编码 |
| 响应体 | `{data:{sessionId, turnId, source, graph, lanes, turns}}`(**v0.73**;v0.71 只有 `{sessionId,graph}`;**v0.77 新增 `lanes`**)。`sessionId` = 解析后的会话;`turnId` = **实际返回的那一轮**的标识(无图时 `""`);`source` ∈ `"live"`(内存最近一轮)/`"persisted"`(落盘的历史轮)/`"none"`(没有图);`graph` = **该轮**的图或 `null`;`lanes` = **最近一轮每一路**的图 `[{turnId,graph}]`,按路的开始时间升序(最早的在前)—— 与 `?turnId=` **无关**(翻历史轮时 `lanes` 仍报「当前这一轮的各路」,`graph` 才是你指定的那一轮),恒为非 null 数组(图源未接线 / 没有路时是 `[]`);`turns` = 该会话**已落盘轮次**摘要 `[{turnId,createdAt}]`,**最新在前**(未接线持久化或没有轮次时是 `[]`,恒为非 null 数组) |
| `graph` / `lanes[].graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,turnId?,round?,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径)。`turnId` / `round` 为 **v0.77 新增的可选键**(缺席语义见下方 (d)) |
| 归档 | **不审计**(只读查询,与同档的 `/admin/llm`、`/admin/config-facts` 一致) |
| 失败 | 图源(内存 + 落盘)**都没接线** ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这一轮没有图」**);该轮**确实**没有图 / 该 `turnId` 不存在 ⇒ `200` + `graph:null`(`source:"none"`)。落盘查询本身失败(库不可用)⇒ 同样 `500 flow_unavailable`(**不把「读库失败」伪装成「没有这一轮」**) |
#### (b) 语义硬约束(客户端必须照这个读)
1. **省略 `turnId` = 「最近一轮」而不是「你要的那一轮」。** 省略或空串时后端**先**取内存里该会话最新的一张图(`loop.FlowPublisher.LastGraph`,即正在跑/刚跑完的那一轮),**取不到才**回退到落盘的最新一行 —— 因此「最新一轮永远是最新的」。**不给 `turnId` 就不要假定它等于你眼前那条记录那一轮**:后台思考行上点开的图可能不是该行那轮 ⇒ 面板**必须**说明(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`)。要精确取某轮就带上 `turns[].turnId`。
2. **给了 `turnId` = 精确取那一轮。** 该 `turnId` 正好等于内存里最新一轮 ⇒ 回内存图(`source:"live"`,更实时);否则读落盘行(`source:"persisted"`)。**该轮不存在 ⇒ `graph:null` + `source:"none"`**(诚实空态),**绝不**用别的轮次顶替。
3. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图、也不落盘。
4. **`graph:null` 是诚实的空。** 该会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图、也从不落盘)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。
5. **落盘只覆盖发布会话白名单内的会话,且每轮一行**(`EndRound` 时写入,**不是每 span 一行**):图里可能含用户输入文本 ⇒ 客户端按既有 §1.1 脱敏口径读即可,但**不得**把图当成「有全部原文」的凭据。落盘件按保留期清扫(`FLOW_GRAPH_RETENTION`,默认 168h),**过期轮次会被回收** ⇒ `turns[]` 只反映「仍在保留期内」的轮次。
6. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。
7. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点补的是「已经跑完、帧已经错过」的那些轮次(最近一轮 + 可选的任意历史轮)。
8. **`lanes[]` = 最近一轮每一路的全貌(v0.77)。** 真并行(`parallel:true`)时同一会话同一轮有多条路,后端**每一路各建一张图、各落一行**;本端点的 `graph` 只给其中**最新的一路**(向后兼容,老的读取方零改动),`lanes[]` 才是全部(每项 `{turnId,graph}`,按路的开始时间升序,恒为非 null 数组;前端在 `lanes.length > 1` 时按**并列泳道**呈现)。⚠️ `lanes[]` **不跟随 `?turnId=`**:它恒报「当前这一轮的各路」(取内存里的实时图),`graph` 才是你要的那一轮。
#### (c) 与 design/19 的关系
`design/19 §4` 把「多轮历史回放」列为 **P3**;v0.71 落实了 P3 里**最小的一步**(每会话最近一轮的只读快照),**v0.73 把 P3 补全**(本地落盘 + 按 `turnId` 取任意保留期内的轮次 + 轮次列表);**v0.77(切片 FV2)补上 `lanes[]`,并把图按 `{会话}#{路}` 分桶**(此前同一会话并行两路会互相顶掉,见 §7.4-19)。三者都**不改** design/19 §1 的发布会话范围(`web` / `web:<uuid>` / `sess-backthink`,裁决 01.4.5)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 `flow_graphs`(迁移 `0022`),GC 口径照 `events`/`documents`(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。**v0.80(切片 W1)**另开 **§4.11.1** 的**时间轴**端点(`GET /api/v1/admin/flow/timeline`)负责「**多轮按时间串联**」—— 本小节的语义(最近一轮 / 按 `turnId` 取某一轮 / `lanes[]` / `turns[]`)**一字未改**(两条出口读同一份落盘数据,分工见 §4.11.1(c))。
#### (d) `turnId` / `round` 的增量键与**缺席语义**(v0.77)
`graph_update` 帧的 `data` 与本端点返回的每张图(`graph` / `lanes[].graph`)**同一个结构**;v0.77 起多两个**可选**键(老客户端忽略未知键仍可用)。⚠️ **v0.79 变化**:`turnId` **不再只对真并行路出现** —— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)都带它,且 `graph.id` 据此对单路也追加 `#{回合标识}`(见下);`round` 的语义**一字未改**。
| 键 | 类型 | 语义 |
|---|---|---|
| `turnId` | string | 这张图所属**路**的权威回合标识(与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同值**,design/15 §2.5.9)。**缺席**语义:这一路**没有权威标识**(未接编排器的调用方、**后台思考 `sess-backthink`**、旧后端,以及 **v0.79 之前的单路历史帧**)—— 此时客户端按「该会话那一张图」处理,**不得**臆造一个 id。⚠️ **v0.79 起**编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)都带该键 ⇒ 它**不再只对真并行路出现**。⚠️ 它**不是** `?turnId=` 的参数(后者是 `graph.id`,两者不同源,见 §7.4-23) |
| `round` | number | 这张图所属的**会话轮次号**(每会话单调;同一条消息引出的多条路**共享**同一个值)。客户端据此只保留最新一轮的泳道、丢掉更早轮次的路。**缺席**(`0`)语义:旧后端 / 未设置 —— 客户端保守处理(保留已收到的路,**不按轮次淘汰**),**不得**臆造轮次号 |
- 真并行(上行 `chat.parallel`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),因此两路不再互相覆盖;每路的图也**各占一行落盘**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 因此不再把另一路整行顶掉)。
- `graph.id` 的形状自 v0.77 起是 `{会话}-{序号}[#{权威回合标识}]`(序号是发布器**进程内**的建图序号、重启即从头开始)。🔴 **v0.79 起**后缀对**单路也追加**(此前只对真并行路)—— 即**每一条编排回合**的 `graph.id` 都含 `#`(`web-7` → `web-7#turn-3f9c…`);**老行**(v0.79 之前落盘的**单路**行)仍是 `{会话}-{序号}`。它是 `?turnId=` 的参数值,**不透明**,**可能含 `#`** ⇒ 作 query 值必须 URL 编码(Web 前端用 `URLSearchParams`,已正确编码),且**客户端一律用响应给出的值、不要自拼**。
---
### 4.11.1 链路图**时间轴**(`GET /api/v1/admin/flow/timeline`;v0.80 新增,切片 W1)
**为什么有这条**:`§4.11` 的老端点回答的是「**最近一轮**」与「**已落盘轮次的键列表**(`turns[]`)」—— 两者都不足以把多轮**按时间串起来**;`graph_update` 又只覆盖当前这一轮。于是面板上「所有消息按时间顺序串起来」这件事此前**没有任何数据出口**(主人 2026-10-06 原话)。本端点补上它,且**不改**老端点的任何语义。
#### (a) 请求与响应
| 项 | 值 |
|---|---|
| 方法 / 路径 | `GET /api/v1/admin/flow/timeline` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 B13 / `design/19 §3` 同档 |
| 查询参数 | `sessionId`(**可选**;省略 / 空串 ⇒ 主会话 `web`)、`limit`(**可选**;缺省 **50**、硬上限 **200**(超上限即钳住)、`≤0` 同缺省、非整数 → `400 bad_request`)、`before`(**可选游标**;省略 = 最新那一页;给定时只返回**开始时刻严格早于它**的轮次;取值 = 上一次响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒;非法 → `400 bad_request`) |
| 响应体 | `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionHours,retentionEnabled}}` |
| `rounds[]` | **恒为非 null 数组**(没有轮次时是 `[]`),按**时间正序**(最早在前)。每项 `{turnId,round,startedAt,endedAt,lanes[]}`;`lanes[]` 恒为非 null 数组(每项 `{turnId,graph}`,按路的开始时刻升序) |
| `rounds[].turnId` | 这一轮**首路**的**图 id**(`graph.id`,真 id)。多路时它只代表首路,全貌在 `lanes[]`(每路的图 id 见 `lanes[].graph.id`、每路的**路标识**见 `lanes[].turnId`)⚠️ 与 `lanes[].turnId` **不同源**(前者是图 id = 老端点的 `?turnId=` 参数值;后者是路标识,见 §4.11(d)) |
| `rounds[].round` | number 或 **`null`**。`null` = **未知**(那批行早于轮次号落库)⇒ 客户端**不要**当成 `0`;**不保证连续**(轮次号 = 该轮首路的建图序号,真并行时中间会跳号) |
| `rounds[].startedAt` / `endedAt` | **UTC RFC3339 毫秒**(与本端点游标同一口径)。`endedAt` 为**空串** = 这一轮**尚未收尾**(内存里的在飞轮)⇒ 面板显示「进行中」,**不得**补一个结束时刻 |
| `rounds[].lanes[].graph` | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5;`{id,nodes[],edges[],sessionId,turnId?,round?,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) |
| `source` | `"persisted"`(本页**至少有一轮**来自落盘;可能同时含内存里的在飞路)/`"live"`(本页**全部**来自内存:落盘源未接线,或该会话还没有落盘轮)/`"none"`(没有任何轮次) |
| `nextBefore` | 本页**最早一轮**的开始时刻(无轮次时 `""`)。`hasMore:true` 时**原样回带**成下一次请求的 `before` 即可继续向更早翻页 |
| `hasMore` | 库里**还有更早的轮次**没被本次返回(用 `nextBefore` 继续取) |
| `truncated` | 本次**不是全部**:`hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。面板据此标注「更早的轮次未显示」,**绝不静默少一块**。⚠️ **它不携带「是否被保留期清扫」这一信息**(v0.81 收紧措辞):`truncated:true` **不等于**「更早的轮次过期了」—— 也可能是「还有下一页没取」。能够分开这两件事的是 **v0.81 新增的 `retentionBoundaryReached`**(见下一行)⇒ **只有它 `true` 时**才可以说「更早的轮次已过保留期」 |
| `retentionBoundaryReached` | **bool,v0.81 新增**(切片 X1)。**`true` = 该会话确有更早的轮次已被保留期清扫删掉,且本次已取到它现存最早的一端**。判据三条**缺一不可**:① 保留期清扫已启用(`retentionEnabled:true`);② `hasMore:false`(本次不是半页);③ 现存最早一轮的**轮次号 > 1**(轮次号每会话**自 1 起、跨重启单调** ⇒ 号更小的轮次曾发过号、又不在库里,而 `flow_graphs` 的唯一删除者是保留期清扫)。⚠️ **盲区(如实,登记在契约里)**:⑴ 一个会话若**整段历史都被清扫**(一条可用行都不剩)⇒ 发号从 1 重新开始 ⇒ 本字段为 **`false`(少说,绝不多说)**;⑵ 同一会话里有**轮次号未知**的老行 ⇒ `false`;⑶ 唯一可能**错报**的路径是该会话**最早的轮次落盘失败**(号已用掉、行从未进库)⇒ 会被算成「已清扫」(概率低,只在库写失败时出现)。⇒ **客户端话术**:它 `true` ⇒ 「更早的轮次已过保留期」;它 `false` 且 `truncated:true` ⇒ 「更早的轮次未显示」;`source:"live"` ⇒ 「只拿到最近一轮」 |
| `retentionHours` / `retentionEnabled` | 落盘保留期(小时,**下取整**)与它**是否启用**(`FLOW_GRAPH_RETENTION`,既有 env 键、默认 168h)。`retentionEnabled:false` ⇒ 「更早的轮次」**不是**被清扫掉的,**不要**说成过期 |
| 归档 | **不审计**(只读查询,与 B13 同档) |
| 失败 | 内存与落盘**都没接线** ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这个会话没有轮次」**);落盘读取失败 ⇒ 同样 `500 flow_unavailable`(**不把「读库失败」伪装成「没有这一轮」**);`limit` / `before` 非法 ⇒ `400 bad_request` |
#### (b) 语义硬约束(客户端必须照这个读)
1. **它是「多轮并联」,不是「最近一轮」。** 老端点(§4.11)继续负责「最近一轮 + `lanes[]` + `turns[]`」,本端点负责「按时间串起来的多轮」。两者**读同一份落盘数据**,但**语义不同**:本端点的 `rounds[]` 是**时间轴**(正序、分页),老端点的 `graph` 是**你要的那一轮**。
2. 🔴 **一句话轮(round)与路(leg)不可混用**(`design/19 §6.0`):`rounds[]` 的一格 = **一轮**(一条用户消息引出的全部工作),一轮里**每一路**在 `lanes[]` 里各有 `{turnId,graph}`。真并行时同一轮会有 2~3 条路 —— **不要把两条路画成两轮**。
3. 🔴 **保留期只说真话(v0.81 定稿口径)**:过期轮次**不会**出现在 `rounds[]` 里(已被清扫),本端点**不猜、不补空块**。**两件事必须分开读**:`truncated` 的成因**只有两个**(分页未到底 / 落盘源未接线),**不含**「被清扫」;「更早的轮次是不是被清扫掉了」由 **`retentionBoundaryReached`** 单独回答(判据与盲区见上方 (a) 表)。⇒ 面板要说「更早的轮次已过保留期」时,**唯一**判据是 **`retentionBoundaryReached:true`**;**不得**因为「返回的轮次比想象中少」、`truncated:true` 或 `retentionEnabled:true` 就断言被清扫了(最后一个只说明「清扫开着」,不说明「发生过清扫」)。⚠️ 该字段**少说也不说谎**:它 `false` **不等于**「没被清扫」(整段历史被清扫 / 有轮次号未知的老行 / 最早那一轮落盘失败,三种盲区见 (a) 表)。
4. **分页不重不漏**:`before` 恒为「严格更早」,`nextBefore` 恒为「本页最早一轮」。逐页往更早翻时,**已返回过的轮次不会再出现**。⚠️ 游标精度到**毫秒**(与 `startedAt` 字符串同一口径);同一毫秒里启动的两个**不同**轮次在极端情况下可能被一并跳过 —— 实际不成立(同一会话的轮次启动间隔远大于 1ms),但这是**真实存在的理论边界**,不作隐瞒。正解(若将来需要)是复合游标 `(startedAt, roundKey)`,属**新契约**、本版未做。
5. **内存里的在飞轮会出现**(`source` 可能是 `live`、`endedAt` 可能是空串),这是**有意**的:面板打开时最新一轮还在跑,不该在时间轴上缺席。它与刚落盘的那一份**同一条路只出现一次**(按图 id 去重,以落盘那份为准)。
6. **只读、不发布、不落盘**:拉时间轴**不产生** `graph_update` 帧、不改任何会话的实时图、不动保留期、不写审计。
7. **白名单外的会话是诚实空态**:隔离渠道会话(群聊/陌生人 `sess-xxx`)从不建图、也从不落盘 ⇒ `rounds:[]` + `source:"none"`。**这不是**「这一轮没有链路」。
8. **不替代 `graph_update`**:实时帧仍是「正在跑的那一轮」的实时来源;本端点补的是「多轮 + 已错过帧」。
#### (c) 与老端点 `turns[]` 的分工(别把两者混成一条时间轴)
`§4.11` 的 `turns[]` 是**按路(leg)**列的条目(一轮两路会显示两条,形状 `{turnId,createdAt}`);本端点的 `rounds[]` 是**按轮(round)**分组的时间轴(一轮两路并进同一格的 `lanes[]`)。⇒ 面板要「轮次列表(含各路)」就用本端点;要「按图 id 精确取某一轮」仍用老端点的 `?turnId=`。
#### (d) 未确证(如实)
- **`graph_json` 破损行**:SQL 侧用嵌套 `json_valid` **保护**(坏行不会拖垮整条查询),但该行若落进本页,解码会**如实报错** ⇒ `500 flow_unavailable`(与既有 `graph` / `graph_turns` 口径一致:读不懂就报错,绝不把坏行当「没有图」)。**这条路径没有测试**(2026-10-06 复核:`internal/services/gateway/admin_flow_timeline_test.go` 现有 9 条用例,无一条喂坏 `graph_json` ⇒ 结论仍是「未确证」)。
- **前端渲染与真机面板联调**不在本条证据内(后端出口的单测覆盖见切片 W1 报告)。**代码级已确证**:前端有 `view="timeline"` 模式(`frontend/web/src/admin/modules/Flow.tsx`)+ `flow/TimelineView.tsx` / `useFlowTimeline.ts`;真机 `GET /api/v1/admin/flow/timeline`(**无凭据**)回 **`401 unauthorized`**(路由已随在跑产物部署,**不是** `404`)。**仍未确证**:浏览器里真面板跑通 —— **为什么验不了**:本轮禁重启后端 / 禁换产物,且取不到 owner 凭据,无法做面板联调(红线:不重启后端、只读验证)。
#### (e) v0.81 行为修复:`rounds[].round` 跨重启**不再重复**(切片 X1)
- **修的是什么**:轮次号原本只是发布器**进程内**计数 ⇒ 后端一重启就从 **1** 重来,而 `flow_graphs` 保留 **168h** ⇒ 时间轴按轮次号分组时把**重启前那一轮**与**重启后那一轮**并成**一块**(真机实测 `session=web`:同一分组键下 `startedAt` = 13:52:53 / 15:42:27 / 18:40:44 三次真实轮被并成「第 1 轮 · 3 路」的失真块)。
- **修法**(`internal/runtime/flowgraph/roundseed.go` 的 `MaxRound` + `internal/runtime/loop/flow_roundseed.go`):发布器在**本进程第一次**给某会话发号之前,从落盘侧读一次该会话**已落盘的最大轮次号**,把计数器播到它之上;同一会话的并发首轮会**等这一次播种**结束,而不是各自用未播种的计数器发号。落盘出口没有该能力时(测试桩 / 别的实现)不播种,行为与修复前逐字相同。
- **对客户端的意义**:契约里「`round` = 该轮次号(每会话单调)」这句现在**真的成立**(v0.81 之前只是声明)⇒ 客户端**可以**放心用 `round` 做分组与显示;块与块之间仍**不画因果连线**(时间相邻 ≠ 因果,见 §4.11.1(b)-2)。
- ⚠️ **边界(如实)**:① 播种**尽力而为** —— 读库失败只记一条 WARN 并退回**进程内计数**(= 修复前的行为,**轮次号仍可能重复**),且**不重试** ⇒ 那种情况下客户端仍应以 `startedAt` 为准;② 库里为空 / 该会话历史已被**整段**清扫 ⇒ 从 1 起(与修复前一致);③ `rounds[].round` 仍**可为 `null`**(老行没有轮次号),且**不保证连续**(真并行时中间会跳号)。
---
### 4.12 记忆整理「试算」(`POST /api/v1/admin/memories/tidy/dry-run`;v0.75 新增)
**为什么有这条**:记忆整理是一件**低峰定期后台任务**(`design/23 §4`):默认只在安静时段(`MEMORY_TIDY_QUIET_START_HOUR`–`_END_HOUR`,默认 `23:00–07:00`)由心跳开跑。于是在它第一次开跑之前,库里**既没有候选也没有轮次留痕** ⇒ 面板只能空着,主人无法判断「整理会不会乱合并」。本端点把**同一套规则**当场算一遍:候选直接回在响应里,**一行都不写**。
#### (a) 请求与响应
| 项 | 值 |
|---|---|
| 方法 / 路径 | `POST /api/v1/admin/memories/tidy/dry-run` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同页的 `/admin/config-facts`、`/admin/memories` 同档 |
| 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT` / **`_COMPARE_LIMIT`(v0.77 新增)**),端点**自己不带任何旋钮** |
| 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` |
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
| `recallMode` | **如实上报本轮实际选用的召回路径**(不再是「配置态」):`"embedding"`(语义向量召回)|`"lexical"`(零依赖词项余弦)|`"mixed"`(**部分分区降级**、其余走向量)|`""`(**没有候选可比** ⇒ 本轮没做召回,不是降级、也不该显示成空)。⚠️ v0.91 起语义收紧:旧实现把「全量降级」静默报成 `embedding`,面板因此看不出降级 |
| `durationMs` | 本次试算的墙钟耗时(毫秒) |
| `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) |
| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受 **`MEMORY_TIDY_COMPARE_LIMIT`** 与**时间预算**两道闸约束 —— 因此 `compared < scanned` 时要看 `truncated` 是不是指出了原因)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) |
| `truncated` | **可选**数组:被护栏截断的分区标签(`<分区>(scan)` / `(compare)` / `(candidates)` / **`(budget)`**)。它**不是**候选;**键缺席 = 本轮没有任何闸被打满**(客户端可把 `candidates:[]` 读成「规则判过了,确实没有」)。前三种说明「配置的配额比本轮实际看到的少」;**`(budget)` 是 v0.77 新增的第 4 种** —— 被**墙钟时间预算**截断(整分区跳过,或两两比对中途收手):成因是**本次调用的时间上限**而不是配置配额(运维动作不同),命中它时「候选少」由**预算**而非规则决定 |
| `candidates[]` | **恒为非 null 数组**(没有建议时是 `[]`)。每个元素见下表 |
| 归档 | **不审计**:它是只读计算(跑规则、不落库),与 `/admin/config-facts`、`/admin/flow/graph` 同一档 |
候选元素(`candidates[i]`):
| 字段 | 说明 |
|---|---|
| `action` | `merge` / `link` / `archive` / `reclassify`(`keep` 不进候选队列)。**`link` = 相似但互不包含 ⇒ 只建链接、绝不合并**(design/23 §1.3 的「红牛 vs 冰美式」) |
| `sourceIds` | 被处理的记忆 id 数组(恒为非 null)。`merge` 时是被并入方(可多条);`link` 时是那两条 |
| `targetId` | `merge` 时的**存活条目**(超集);其余动作为 `""`(键省略) |
| `personaId` / `scope` / `projectId` / `kind` | 分区键与被处理条目的 kind |
| `proposedContent` / `mergedContent` | `merge` 的 diff 预览:被并入的原文 / 存活条目的内容(其余动作为 `""`) |
| `reason` | 机器可读理由(如 `containment_a_subset_b` / `similar_but_not_contained` / `one_off_event` / `kind_mismatch`)。**客户端按它分支,不要按本地文案** |
| `ruleVersion` / `scoreMin` / `scoreMax` | 规则版本与召回分数区间(当前实现两者相等:一对一个分数) |
| `evidence` | 判定证据:`similarity`(只用它**召回**,不是判据)、`recall_mode`、`containment`(`a_subset_b`/`b_subset_a`/`both`/`none`)、`containment_evidence[]`(缺哪一要素)、`veto_reason`、`kind_a`/`kind_b`/`proposed_kind`、`content_a`/`content_b`(两条原文,供面板 diff 预览) |
#### (b) 硬约束(客户端必须照这个读)
1. 🔴 **零副作用**:本端点**不写任何表** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测把它钉死。因此试算**不会**让面板的「候选队列」多出东西,也**不影响**当晚那次真跑的结果。
2. 🔴 **同一套规则,不是第二套实现**:候选由与低峰定时轮**共用**的代码路径产出(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限)。后端另有一条单测:同一批数据上「试算候选」与「真跑一轮落库的候选」逐字段一致。
3. 🔴 **失败绝不伪装成「没有建议」**:未接线 / 配置关闭 / 语义召回不可用 / 超时都**如实回错误码**(见下表),**不回** `candidates:[]`。客户端**不得**把 `candidates:[]` 与错误态渲染成同一件事 —— 前者是「算过了,这一轮确实没有建议」,后者是「没算出来」。
4. **语义召回不可用时不静默降级**:若进程接了 embedder 而它不可用(如 `llama.cpp / :11434` 连不上),试算**报 `tidy_recall_unavailable`** 而不是偷偷回退到词项余弦 —— 否则面板会把「语义路径没跑起来」当成「没有候选」。⚠️ 这是**试算专属**的严格:低峰定时轮与手动轮仍按既有口径回退到零依赖的词项余弦(`recallMode:"lexical"` 时可从 `evidence.recall_mode` 逐条读到)。
5. **只算不写 ⇒ 不审计,也不产生候选队列**:本端点不写审计链、不产生任何后续动作;apply/reject 是**另外的**、尚未实现的写端点(`design/23 §11`,本版**不做**)。
6. **它是「现在就能看」,不是「调度改了」**:试算**不受安静时段约束**(低峰窗口是心跳的调度口径,不是规则的一部分),也**不改变**整理周期与心跳(试算不写 `lastRun`、不影响 `nextDelay`)。面板**按下才跑**:它是一次真实规则计算(扫描 + 两两召回 + 包含判定),**不是看板指标** ⇒ 客户端**不要**自动轮询它。
#### (c) 失败码
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|---|---|---|---|
| `500` | `tidy_unavailable` | 组合根未注入试算出口(仅异常部署/测试) | 显示「未接线」,**不要**显示「没有建议」 |
| `409` | `tidy_disabled` | `MEMORY_TIDY_ENABLED=false`(显式关闭整理) | 提示「整理已被配置关闭」,并指向该 env 键 |
| `500` | `tidy_recall_unavailable` | 语义召回(embedding 服务)不可用 | 显示具体原因类别;**不要**显示 0 候选 |
| `503` | `tidy_timeout` | 试算超过服务端墙钟上限(3 分钟) | 可提示重试;**不要**显示 0 候选 |
| `500` | `tidy_dry_run_failed` | 其它内部失败(分区查询/规则执行出错) | 显示错误;原始内部细节**不会**出现在 `message` 里 |
> ⚠️ 本端点的错误 `message` 只说明**原因类别**,原始错误(可能含内网地址等内部细节)只进服务端日志。
### 4.12.1 记忆整理「试算」的**加性异步**(`?async=1` + B15;v0.76 新增 · ✅ 已实现(v0.77 校正))
**为什么有这条**:试算在**真机**上实测 **89.9 秒 / 744 条**(784 条那轮同量级)。同步 HTTP 会被反代/客户端超时切断,而**把上限抬高**(治理「护栏被打满 ⇒ 候选数被上限而不是规则决定」)只会让它更慢。因此加一条**可选**的异步路径:**默认行为一字不改**(不带 `async` 仍是 §4.12 的同步语义),只在显式带 `async=1` 时改走后台。
| 项 | 口径 |
|---|---|
| 触发 | `POST /api/v1/admin/memories/tidy/dry-run?async=1`(请求体与 §4.12 同:可为空)。**只有字面量 `1` 触发异步**:`?async=0` / `?async=true` / 缺省 / 空值一律走同步(老客户端逐字不变) |
| 立即响应 | **`202`** + `{data:{runId,status:"running",startedAt}}`;`runId` 为进程内不透明字符串(客户端当不透明值用,**不要**解析其结构);`startedAt` 是**那次运行真正开始**的墙钟时刻(UTC RFC3339)—— 它是那次运行的时间,**不是本次请求的时间** |
| 轮询 | `GET /api/v1/admin/memories/tidy/dry-run/{runId}`(B15)⇒ `running` / `done`(带 `result`)/ `failed`(带 `error`);**`running` 时 `result` 与 `error` 两键都缺席**(**不造空 `result`** 冒充「跑完了但没有建议」) |
| 结果形状 | `done` 时的 `result` = §4.12 同步响应的 `data` **逐字节同形**(两条路径走**同一个**投影函数,后端有单测按**原始 JSON 逐字节**比对;含 `candidates` 恒非 null) |
| 并发 | **同时只允许 1 个在跑**;第二个请求**复用既有 `runId`**(实现取「复用」而非「拒绝」⇒ **不引入新错误码**),回的 `startedAt` 是**那次正在跑的运行**的开始时刻;上一个跑完后,新请求才是新 `runId` |
| 墙钟上限 | 与同步**同一个 3 分钟**(`tidy_timeout` 的语义对两条路径都成立);超时 ⇒ 轮询回 `failed` + `tidy_timeout`(错误链含 `context.DeadlineExceeded`) |
| 生命周期 | **进程内存**(不落库、不新开端口);TTL **30 分钟**(从**完成时刻**起算,`running` 的记录不会被 TTL 淘汰;另有 32 条的条数闸兜底);**进程重启 ⇒ 旧 `runId` 失效** |
| 未知 / 过期 runId | `404` + `{error:{code:"tidy_run_not_found"}}`(见 §4.1 的 404 行);「从未存在」「超 TTL」「进程重启过」三种原因**刻意不区分**(进程内不留跨重启痕迹) |
| 鉴权 | 与 B14 同档(`writeAuth` = owner/admin);**不审计** |
| 零副作用 | 🔴 与同步路径**同一条硬约束**:异步跑的是**同一个** `TidyService.DryRun`(同一套规则)⇒ 同样**一行都不写** `memory_tidy_candidates` / `memory_tidy_runs`、**绝不改 `memory_items`**(memory 包与网关两侧都有**逐行快照比对**的测试钉住) |
| 失败 | 沿用 §4.12 的失败码(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout` / `tidy_dry_run_failed`);`failed.error = {code,message}` 走**同一个**失败码映射点 ⇒ 两条路径不可能漂移;**不回空候选数组假装「没有建议」**。⚠️ `MEMORY_TIDY_ENABLED=false` 时异步**仍接受**(回 `202`),失败在**轮询**里如实报 `tidy_disabled`(同步路径仍是立刻 `409`) |
| 破坏性 | **无**(新可选参数 + 新端点;老客户端逐字不变) |
> ✅ **落地校正(v0.77)**:本小节与 B15 在 v0.76 登记时是「契约先行、尚未落地」;**切片 MT-A 已落地**(`internal/runtime/memory/tidy_dryrun_async.go`、`internal/services/gateway/admin_memory_tidy_async.go`、`routes.go` 注册 `GET …/dry-run/{runId}`)。上表各行即**实现口径**,不再有「实现口径见提交」的占位。仍然如实的两条边界:① 后台运行**不随进程关停取消**(关停时在途的异步轮次会被丢掉 ⇒ 客户端自然拿到 `404`);② 结果**不是缓存**(每次调用重算,两次之间数据变了结果就可能不同)。
---
### 4.13 未读实时面:`message_persisted.data.unreadCount`(v0.76 新增 · ✅ 已实现)
**帧形状(纯增量)**
```json
{"type":"message_persisted","sessionId":"web",
"data":{"message":{…,见 §4.4},"unreadCount":3}}
```
| 项 | 口径 |
|---|---|
| 键名 / 类型 | `data.unreadCount`,整数 |
| 含义 | 该会话此刻的**绝对未读数** = 比会话读游标(`chat_sessions.read_at`)**严格更新**的消息条数。**不是**「自上次以来新增几条」,也**不是**每端各一份 |
| 口径来源 | 与 `GET /api/v1/sessions` 的元素 `unreadCount` **同一个谓词**(`created_at > read_at`)、**同一条游标** ⇒ 两条面在同一时刻给出同一个数字(后端有单测逐会话比对两者相等) |
| 出现条件 | **只在 `>0` 且确实算得出来时出现**(Go 的 `omitempty` 语义)。**缺席 = 老行为**(v0.75 及更早即如此),客户端**忽略未知键**即可 |
| 缺席的三种情形 | ① 未注入历史存储;② 注入的存储没有单会话未读视图;③ 该会话**没有会话行**(隔离渠道会话 `sess-*`、后台思考会话 —— 它们连读游标都没有)。三种情形后端都**不**写 `unreadCount:0`:**「算不出来」绝不冒充「已读完」** |
| 何时该用 | 客户端落下这帧时**直接用该数字刷新角标**(不必再拉一次 `GET /sessions`)。键缺席时**保持原值**并回落 `GET /sessions` 收敛 —— 不要当成 0 |
| 何时会归零 | 由 `session_read` 帧(§3.4.1 状态帧表 / §1 U16)在任一客户端 `POST /sessions/{id}/read` 后广播;**多端共享单游标**:Web 读了,移动端的红点也清 |
**多端共享单游标的取舍(与安卓侧已对齐,不是缺陷)**
后端只有一个主人账号、这一层没有按设备的身份 ⇒ 游标是**会话作用域、单所有者**(`chat_sessions.read_at`,迁移 `0010`):**任一端已读即所有端已读**。因此 `unreadCount` 是「主人还没看过的消息数」,**不是**「这台设备还没同步的消息数」。
- 🔴 客户端**不得**把它当「本端未读」渲染成「这条消息我没收到」(那就把跨端同步误报成了漏消息)。
- 🔴 **不要**自行改成「每端独立未读」(会引入第二份游标、并在多端之间互相打脸);如确需改,属**契约裁定**事项,须先写进本节。
**兼容性(逐字无破坏)**
- 纯增量:**新可选键**,无新帧、无新端点、无既有键改名或语义变化。
- 老客户端(不读 `unreadCount`)行为**逐字不变**;新客户端读到缺键时的正确答案是「不知道」,不是「0」。
- `channel_persisted`(隔离渠道会话)走**同一个**载荷构造函数 ⇒ 口径天然一致;实践上它永远不带该键(那类会话没有 `chat_sessions` 行,见上表第 4 行)。
**服务端实现位置**(供对照,非契约):`internal/services/gateway/broadcast.go`(帧构造,Web 路径与 IM 路径**共用同一个函数**)、`internal/services/gateway/session_read.go`(`unreadCountOf` + 可选窄接口 `sessionUnreadCounter`)、`internal/runtime/history/session_read.go`(`UnreadCount`)。
### 4.14 插件管理面板接入点与动作分发(v0.78 新增,切片 PLG-2 / PLG-4)
#### (a) `GET /api/v1/admin/panels` —— 面板接入点快照(**v0.78**)
**门**:`viewerAuth`(owner / admin / viewer 皆可,见 §2.1 A13)。**响应按调用者角色过滤**:viewer 只拿到只读区块(`status` / `log` / `table`,且 table 的**行内动作被摘空**、`form` **整体不出现**、区块全空的面板**整条不出现**)。**未接线面板中枢时回 `501 panels_unavailable`**(**绝不回空列表**把「没接线」伪装成「没有页面」)。
响应 `data`:
```jsonc
{
"role": "owner", // 本次快照按哪个角色裁剪(回显,便于客户端自检)
"groups": [ // §5.2.1 的 nav.group 一级
{
"id": "plugins", // 分组 id(本期闭集只有 plugins)
"title": "插件", // 分组显示名 = 宿主文案(分组本身没有声明)
"order": 20, // 分组排序 = 组内最小 order
"items": [ // nav.item
{
"pluginId": "web-search",
"id": "providers",
"title": "搜索提供商",
"description": "…", // 可选,缺省省略
"icon": "search", // 可选(宿主内置图标名),缺省省略
"order": 20,
"route": "plugin/web-search/providers", // **相对**管理面板前缀
"sections": [ /* design/09 §5.2 的 section 声明**原样**下发(字段名逐字同形) */ ]
},
{
"pluginId": "browser", // **v0.82 新增**(切片 Y4,见 §4.14(d))
"id": "settings",
"title": "浏览器与截图",
"icon": "browser", // 前端把它映射到侧边栏的「图片」图标(图标表闭集里没有专门的浏览器图标)
"order": 30,
"route": "plugin/browser/settings",
"sections": [ /* 4 个区块:form 配置 + 3 张只读 table(状态 / 指纹 / 截图) */ ]
}
]
}
],
"pluginIds": ["browser", "web-search"] // **声明过面板**的插件 id(**已排序**);≠ 已安装列表。⚠️ v0.82 的 `browser` 项在本版**登记时仍待提交**,提交前真机上是 `["web-search"]`
}
```
- `sections[]` 就是 `pkg/plugins/sdk` 的 `Section` 形状(`type` / `id` / `title` / `fields` / `submit.action` / `source.action` / `columns` / `rowActions` / `limit`),**宿主不翻译**;插件**不注入任何 JS/CSS/HTML**。`type ∈ {form,status,table,log}`(`form` 是可写区块,其余只读)。
- `route` 是**相对片段**(`plugin/<pluginId>/<panelId>`,**不含** `ADMIN_PATH` 前缀)⇒ 前端拼成 `<prefix>/plugin/<pluginId>/<panelId>`。
- `pluginIds` 是给「插件与能力」页做**对照**用的:拿它与 `GET /api/v1/admin/plugins/installed` 的 `name`/`id` 求**差集**即可得到「**已装但未声明任何页面**」的插件,客户端应把这类插件**如实显示成「未声明管理页面」**,**不得**藏掉。
**错误**:`501 {error:{code:"panels_unavailable"}}`(未接线);`403 unknown_role`(角色无法识别,理论不可达,fail-closed)。
> ⚠️ **当前运行期快照不是空的**:内置插件在**组合根装配期**声明给中枢 ⇒ 正常部署下有两个条目:
> - `web-search`(`plugins/tools/io/search` 的 `AdminPanels()`,随 v0.78 起);
> - `browser` / `settings`(`plugins/tools/browser` 的 `AdminPanels()`,**v0.82 新增**,切片 Y4,见 §4.14(d))—— 它写在 `cmd/cyrene/wire_frontend.go` 的 `wirePanelHub` 里(`wireBrowserPanel(hub, a, a.log)`,**登记时该挂点仍在工作树、未提交**);删掉这一行**没有任何编译期或运行期症状**,只是目录里少一页、六个动作全 `404 action_not_declared`,因此有一条**源码级**用例盯着它。🔴 **在跑的那份产物里没有它**(真机已核:启动日志全篇 **0 条 `browser panel declared`**,而该函数成功声明时必打这一行;同一次启动里 `self wake tool registered tool=request_think` 是有的)⇒ 那一刻真机上 `pluginIds == ["web-search"]`。
>
> ⇒ 接线后 `pluginIds == ["browser","web-search"]`(**已排序**)。热装插件(MCP 等)**目前恒为「未声明管理页面」**(声明来源 `manifest.json` 的读取未实现,见 §7.2)。
#### (b) `POST /api/v1/admin/plugins/{id}/actions/{action}` —— 插件动作分发(**v0.78**)
**路径**:`{id}` = 插件 id(面板目录键,本期即插件名,如 `web-search`);`{action}` = 插件**声明过的**动作名(如 `plugin.listProviders`)。
**门**:路由中间件 `viewerAuth`,**写动作在 handler 内再按 owner/admin 收紧**(见 §2.1 A14)。
| 情形 | 状态 | `error.code` | 说明 |
|---|---|---|---|
| 面板中枢未接线 | 501 | `panels_unavailable` | 未接线就说未接线 |
| 动作名未在声明里出现过 | 404 | `action_not_declared` | §5.2.1「只接受该插件声明过的动作名」;**未声明不是「没权限」** |
| 插件已被显式停用 | 409 | `plugin_disabled` | 声明还在,入口与动作都下线 |
| `kind=write` 且调用者不是 owner/admin | 403 | `forbidden` | 写面 = `form.submit` + `table.rowActions`(§5.4.1) |
| 已声明,但宿主**没有登记它的处理器** | 501 | `action_not_implemented` | ✅ **v0.79 起不是兜底档**:内置 web-search 的动作已全部登记 ⇒ 这条只在「声明写了却没实现」时出现 |
| 请求体不是合法 JSON,或超过 1 MiB | 400 | `bad_request` | 绝不把坏体当空体继续跑 |
| 载荷形状不对(缺 `values` / 缺 `provider` / 类型错) | 400 | `invalid_payload` | **v0.79**;由动作处理器给出(`pluginmgr.ActionCodeInvalidPayload`) |
| provider 名本期未登记 | 400 | `unknown_provider` | **v0.79**;`plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` |
| 该家没配齐(缺 key 或缺接口地址) | 400 | `not_configured` | **v0.79**;`plugin.testProvider` |
| 可变源存在但读不出来(损坏/权限) | 500 | `config_unreadable` | **v0.79**;**不覆盖**原文件 |
| 可变源写不进去(磁盘/权限) | 500 | `config_write_failed` | **v0.79**;本次改动**没有**生效 |
| 探针(`plugin.testProvider`)的上游结论 | 502 | `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown` | **v0.79**;`no_results` 的文案明说「连通正常但没查到结果」 |
| 其它执行失败(内部错误) | 502 | `action_failed` | **兜底档**;详细原因只进后端日志 |
**请求体**:可选 JSON。**空体 ⇒ `payload=null`**(读来源动作通常无参)。**原样交给执行器**,宿主不解释插件的数据形状、**不认识任何 URL**(**不给插件开 SSRF 面**)。
**响应 `data`**:`{pluginId,action,kind,result}` —— `kind ∈ {read,write}`(`read` = `table`/`log` 的 `source` **与字段下拉的 `optionsFrom`**;`write` = `form.submit` / `table.rowActions`;同名动作同时出现在读面与写面时**一律按 write**);`result` = 动作处理器返回值**原样透传**。
**审计**:**每一次结算都落一行**,`what = "plugin.action"`,`result ∈ {ok,error,denied,not_declared,unimplemented,unavailable,bad_request}`。⚠️ 持久化审计记录仍只有 `who/what/when/result` 四字段 ⇒ **未含 pluginId / action**(它们只在**结构化日志行**里,见 §7.2);**v0.79 新增**:服务端日志行附加结构化失败码 `code` —— **只进日志,不进 DB 记录体、不含请求体**。
> ✅ **v0.79 的生产路径事实(切片 L1/PLG-2 执行面接线)**:组合根在 `wirePanelHub` 里调用 `wireBuiltinPluginActions`,把内置 `web-search` 的**四个动作 + 下拉候选来源**登记到面板中枢 ⇒ **这些动作真的执行**(真读写可变源、探针真发一次 HTTP),**不再一律 501**;装配期另把「**声明了却没有实现**」的动作与候选来源各记一条 WARN(`action_not_implemented` 因此是**缺口信号**而非常态)。⚠️ **残留(如实,见 §7.2)**:✅ **v0.80 起已热重载**(保存后重建工具 + 原子替换注册表条目,**下一轮生效、不需要重启**;若新配置会让后端下次起不来则**按字节回滚**并回 **`409 config_unstartable`**);探针**只用 `httptest` 假上游**验证过,**未对真实上游做过一次连通验证**(此项仍未做)。
#### (c) `web-search` 插件的动作面(v0.78 声明,**v0.79 起真可用**,切片 PLG-4 / L1;**v0.80 起可配主 provider / 降级链 + 保存后热重载**,切片 W3;供上面 (b) 的分发面使用)
插件 id `web-search`;可变源落点 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename)—— ⚠️ **不进客户端契约**(客户端一律走动作端点,不直连文件)。组合根「**先读可变源、缺失回落 `.env` 播种**」:文件一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用(⚠️ **运维视角的行为变更**,见 §5)。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面;本切片未改 `.env` / `.env.example`)。
`plugin.listProviders` 返回的表格行(`rows[]` 元素):`{ "provider": "bocha", "state": "ok", "enabled": true }`(**凭据与地址都不回**)。`state` 取值(闭集)与判定顺序(根因在前):`unknown`(名字本期没登记)→ `disabled`(显式停用)→ `not_configured`(名字已登记但没配齐:云家缺 key / searxng 缺地址)→ `ok`(其余)。⚠️「凭据失效」**不是** `state` 取值(它只能由一次真实调用得知,属 `plugin.testProvider` 的结论)。
四个动作 + 一个候选来源(**本插件声明的全部动作**;全部已登记生产处理器):
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|---|---|---|---|---|
| `plugin.listProviders` | 列出各条 provider 配置 | read(table `source`) | `{}` | `{"rows":[{"provider":"searxng","state":"ok\|not_configured\|disabled\|unknown","enabled":true}]}`(**凭据/地址不回**) |
| `plugin.providers` | 下拉候选:本期**全部已登记**的 provider(**带显示名**) | read(字段 `optionsFrom:"plugin:providers"`) | `{}` | `{"options":[{"value":"zhipu","label":"智谱 BigModel(zhipu)"},…]}`(稳定排序,**含尚未配置的家**)。🆕 **v0.84 形状变更(对象数组)**:此前是**裸字符串数组**(`{"options":["zhipu",…]}`)。🔴 **两条硬口径**:① **`label` 仅供显示,`value` 才是落盘值** —— 把 `label` 写回 `provider` 字段会把配置文件写坏(提交给 `plugin.saveProvider` 的必须是 `value`);② **前端解码器本就兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**不受影响**(零破坏性)。前端「候选开关」写回的是 `value`;表格单元格按 `value→label` 索引显示、查不到回落显示原值 |
| `plugin.saveProvider` | 新增/修改一条(按 `provider` **upsert**);**同一次提交可带两个文件级字段** `primary` / `fallback`(主 provider 与降级链,**缺位 = 不改动**) | write(form `submit`) | `{"values":{"provider":"bocha","apiKey":"…","baseUrl":"https://…","enabled":true,"primary":"bocha","fallback":["searxng"]}}` | `{"saved":true,"provider":"bocha","created":true,"enabled":true,"primary":"bocha","fallback":["searxng"],"hotReload":{"applied":true,"registered":true}}`(`primary` / `fallback` 是**落盘后**的取值,没给也如实回显;`hotReload` 见下) |
| `plugin.testProvider` | 测试连通(**真发一次最小检索**) | write(table `rowActions`) | `{"row":{"provider":"bocha"}}`(亦可给 `{"values":{…}}` 或顶层 `provider`) | `{"ok":true,"provider":"bocha","count":1,"latencyMs":123}`;**没通 = 非 2xx**(见错误表),**不在 200 里回 `ok:false`** |
| `plugin.reauthProvider` | 重配凭据:把该条带回表单 | write(table `rowActions`) | `{"row":{"provider":"bocha"}}` | `{"values":{"provider":"bocha","apiKey":"****cdef","baseUrl":"https://api.bocha.cn","enabled":true,"primary":"bocha","fallback":["searxng"]}}`(**`apiKey` 只回掩码**;前端对 secret 字段一律不回填输入框,**空 = 不改动**)。⚠️ **v0.80 起 `values` 另带两个文件级字段** `primary`(字符串)/ `fallback`(字符串数组)—— **不带它们**的话,面板「清空降级链」在客户端三态写回里会落成「缺位 = 不改动」(**清不掉**) |
`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan` / **`tencent_wsa`** / **`metaso`**),否则 `400 unknown_provider`。🆕 **v0.87:名单由六家扩到八家**(新增腾讯云·元宝搜索与秘塔搜索),**端点形状与错误码一字未改**。
**(v0.80 新增)两个文件级字段的三态写回**(与条目字段同一套哨兵语义):`values.primary` **缺位** ⇒ 保留盘上值;给字符串(含空串 / `off` / `none`)⇒ 采用 —— **空串与 `off` / `none` 都是「关掉这把工具」的哨兵**(`off` / `none` 大小写与空白不敏感),`WEB_SEARCH_PROVIDER` 与面板**共用同一套关闭语义**(不另造第二套)。`values.primary` 指名一家**已登记但未配齐**的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒,因为那份配置会让后端下次起不来)。`values.fallback` **缺位** ⇒ 保留;给数组(或逗号 / 顿号 / 分号 / 换行分隔的字符串)⇒ 采用(去空白、去重、丢空项);元素**未登记** ⇒ `400 unknown_provider`;给非数组 / 非字符串 ⇒ `400 invalid_payload`(**不静默当空数组** —— 那等于一次保存悄悄清空降级链)。降级链**允许**放本期已登记但**尚未配齐**的家(运行期会跳过它)。
落盘:`$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename),**重启后仍然生效**,且 **v0.80 起保存后立即生效(热重载)**:`plugin.saveProvider` 成功落盘后,组合根会**按落盘后的配置重建 web-search 工具并替换能力注册表里的条目** ⇒ **下一轮对话即用新接线,不需要重启后端**(design/09 §5.5 的端到端判据)。响应新增机器可读事实 **`hotReload:{applied,registered}`**:`applied=true` 表示这次保存真的触发了热重载(`false` = 宿主没有能力注册表可换,只落了盘);`registered` 报告保存**之后** web-search 在不在模型的工具面里(主 provider / 端点被显式关闭时为 `false` —— 这是设计语义,不是失败;⚠️ `applied:false` 时该键**恒为 `false`**、不携带信息)。
**保存是一次事务**:若这次改动会让 web-search **没有可用后端**(design/27 §3 的启动期 fail-fast:主 provider 未登记 / 未配齐),端点返回 **`409 config_unstartable`**,且**文件按字节回滚**、运行中的工具保持原样(决不留「盘上新、内存旧」,也决不留「重启后起不来」的配置)。⚠️ 回滚本身也失败时**如实**回 **`500 config_write_failed`** 并明说「盘上可能已经变了」,**不假装改动没发生**。
⚠️ **触发面只有本动作**:直接改写可变源文件(不经面板)**不会**触发热重载 ⇒ 仍需重启后端(或从面板点一次保存)。这是本动作的**明确边界**(不做文件监听)。
`plugin.testProvider` 失败时**不**回 200:走上面 (b) 的错误表(`unknown_provider` / `not_configured` ⇒ 400;`unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown` ⇒ 502)。🔴 **`no_results` 表示「连通正常但没查到结果」**(空结果 ≠ 不通)⇒ 前端文案**必须**与真正的失败区分开,别把它显示成「该家不可用」。🔴 错误响应**绝不**含上游 URL 与凭据(只给稳定码 + 固定文案)。
> ⚠️ **v0.79 行为变化(登记)**:`optionsFrom` 声明的候选来源现在会进**动作索引** ⇒ `plugin.providers` 从「`404 action_not_declared`」变为**可调用的读动作**。前端此前对该动作 404 的降级分支(文本框 + 「候选项未能加载」)因此**不再触发**。⚠️ 另有一条**前端缺口**(不属本契约面):行内动作的成功分支只显示「已完成,正在刷新」,丢弃 `result` ⇒ 探针成功时的 `count` / `latencyMs` 在界面上仍不可见。
#### (d) `browser` 插件的动作面(**v0.82 新增**,切片 Y4;design/28 §8 B4)
插件 id `browser`,面板 id `settings`(「浏览器与截图」,`route = plugin/browser/settings`)。**不开任何新路由** —— 六个动作全部走上面 (b) 的通用分发端点 `POST /api/v1/admin/plugins/browser/actions/{action}`;可变源落点 `$DATA_DIR/config/plugins/browser.json`(**0600**、temp+rename;**文件名 = 插件 id**)—— ⚠️ **不进客户端契约**(客户端一律走动作端点,不直连文件)。
**页面构成(4 个区块)**:① `form` 配置表单(保存即热重载);② / ③ / ④ 三张只读 `table`(运行状态 / 指纹自检 / 最近截图)。
> **为什么三块活数据都用 `table` 而不是 `status`**:`status` 区块按 §5.2.1 的接入点表**不消费 `source`**(没有来源就没有活数据),硬要用只能给一份声明期的静态文本 —— 那才是真正的「看起来有、其实没有」。`table` 是声明面里唯一**既有来源又能带按钮**的形状。
**六个动作(3 读 3 写;全部已登记生产处理器)**:
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|---|---|---|---|---|
| `plugin.getBrowserState` | 运行状态只读事实(**不启动浏览器**) | read(table `source`) | `{}` | `{"rows":[{"source","available","headless","viewport","profileDir","concurrency","idleTimeoutSeconds","shotMaxBytes"}]}`。**恒为 1 行**(事实只有一份;并发上限恒为 1)⇒ 多行会诱导读者以为「有多个浏览器在跑」 |
| `plugin.getFingerprint` | **上一次**指纹自检的读数(**不启动浏览器**) | read(table `source`) | `{}` | `{"rows":[{"webdriver","screen","timezone","language","userAgent","checkedAt"}]}`。**从未自检过 ⇒ 一行如实写「未自检」**(不编造数字、不回空数组) |
| `plugin.listShots` | 最近截图**统计**(**最新在前**,最多 20 条) | read(table `source`) | `{}` | `{"rows":[{"at","bytes","size","format","degraded"}]}`。🔴 **不含路径、不含内容、不含 URL**(面板是只读投影,viewer 也看得到表格行 ⇒ 把主人的浏览内容投到那里是越界)。没截过图时**如实为空表** `{"rows":[]}` |
| `plugin.saveBrowserConfig` | 保存浏览器配置(落盘 + **热重载**) | write(form `submit`) | `{"values":{…}}`(**只发改动过的字段**;缺位的键**保持原值**,**绝不**把「没提到」当「清空」) | `{"saved":true,"applied":<bool>,"registered":<bool>,"reason":"<原因码/文案>"}`。`applied=true` = 这次保存**真的触发了热重载**(按落盘后的配置重建工具 + 原子替换注册表条目 ⇒ **下一轮生效、不需重启**);`applied=false` 且保存前**本来没有**可用工具 ⇒ 文件留下、如实报 `false` + `reason`(那种情形没有能力可丢,报错反而会让主人**没法先配好**) |
| `plugin.shootPage` | 拍一张**当前页面** | write(table `rowActions`) | `{"row":{…}}`(载荷不被解释,只为触发) | `{"shot":{…}}`,`shot` = `browser` 工具 `screenshot` 的返回值原样(见下面「模型面」一节),含 `path` / `bytes` / `sha256` / `format` / `width` / `height` / `dimensionsKnown` / `degraded` / `turnAttachment` / `hint` |
| `plugin.checkFingerprint` | 重新做一次指纹自检(**会启动浏览器**) | write(table `rowActions`) | `{"row":{…}}` | `{"rows":[…]}`(与 `plugin.getFingerprint` 同形,刷新后的读数) |
**字段名(`values` 的键,逐字与 `$DATA_DIR/config/plugins/browser.json` 的 JSON tag 一致)**:`headless`(bool)、`executable`(string,留空 = 自动探测本机 Chrome/Chromium)、`cdpEndpoint`(string,显式给出时取代自动探测)、`profileDir`(string)、`viewportWidth` / `viewportHeight`(number,声明面已给 min/max)、`idleTimeoutSeconds`(number)、`concurrency`(number,**声明面 min = max = 1**:同一 profile 起两个实例会互锁)。🔴 **未知字段一律拒(`400 invalid_payload`),绝不静默忽略** —— 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
**错误码(本插件在 (b) 通用表之上追加的,宿主只透传「码 + 文案 + HTTP 状态」)**:
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|---|---|---|---|
| `400` | `invalid_payload` | 载荷不是对象 / 缺 `values` / `values` 不是对象 / **未知字段** / 字段类型错 | 显示文案原文;**不要**当成「保存成功」 |
| `409` | `browser_unavailable` | 本机**没有**可用浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)⇒ `plugin.shootPage` / `plugin.checkFingerprint` 做不了 | 提示「先去配置里填可执行文件或 CDP 端点并保存」;面板可以配 ⇒ 不是死路 |
| `409` | `browser_session_not_open` | `plugin.shootPage` 时浏览器**没有打开的页面** | 提示「先让她打开一个页面(或在有头窗口里人工接管)」—— **面板刻意不替模型开页**(否则它就成了「任意 URL 的截图器」) |
| `409` | `config_rejected` | 这次保存**没能生效**(新配置会让工具不可用),**已按字节回滚**到保存前那一份 | 显示原因原文;**盘上没有变**,不必再手动改回 |
| `500` | `config_unreadable` | 面板配置存在但**读不出来**(损坏 / 权限)⇒ 本次**没有**改动它 | 显示「配置读不出来」,指向服务端日志;**不要**重试保存 |
| `500` | `config_write_failed` | 配置写不进去(磁盘 / 权限);⚠️ **回滚也失败**时也用这个码,文案明说「盘上的文件可能已经变了」 | **不假装改动没发生**;提示可能需要人工检查盘上文件 |
| `502` | `browser_action_failed` | 浏览器动作本身执行失败(截图 / 自检) | 详细原因只进后端日志;显示「执行失败」+ 文案 |
> **`applied:false` + `reason` 是正常结论,不是错误**:保存前**本来就没有**可用工具时(例如第一次配一个还没起来的容器端点),文件留下、`200` + `applied:false`。客户端**不要**把 `applied:false` 渲染成失败 —— 要说的是「已保存,但这次没有东西可换/没生效」+ `reason`。
**模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(**闭集**,v0.82):
| 取值 | 含义 |
|---|---|
| `queued` | 截图已入**本会话的待送附件箱**,将在本回合的下一次模型调用里作为附件送出(主模型收不了图时以视觉/OCR 的文字形态) |
| `no-session` | 本次调用拿不到会话 id(子代理 / 未接编排器的调用),**未入箱** ⇒ 由模型自己按 `path` 去读 |
| `outbox-full` | 该会话的箱子已满(**每会话最多 4 张**、TTL 10 分钟),**丢掉了最旧的那张**(如实报,不静默) |
| `disabled` | 本工具没有附件箱(`New(nil)` 这类夹具),未入箱 |
配套的 `hint` 字段是一句**如实**的下一步提示(`queued` 时说「已经作为图片附件送出」;其余档说「图没进上下文,按路径读」)。**「入箱」到「进上下文」靠 `loop.Options.TransformContext` 上挂的钩子**(`cmd/cyrene/wire_browser_context.go`)—— 它**取走即消费**(同一张图不会被送两次),也因此**不会**进 checkpoint、不会进后台记忆提取的输入面。⚠️ **该挂点在 v0.82 登记时仍在工作树、未提交**;缺了它,`turnAttachment` 仍会照报 `queued` 而图**一张都不会进上下文**(**零症状**的坏法,因此有一条源码级用例盯着 `wire_runtime.go` 那一行)。
---
### 4.15 记忆整理「应用面」(B20–B24;**v0.83 新增,切片 TIDY-1**)
> 依据 `design/23`(§7 人工在环 / §8 软动作与回滚 / §10 status 列 / §11 管理面)。**背景**:v0.75 起只有「试算」(B14/B15 —— 只回建议、**零副作用**)⇒ 主人的实测口径是「候选 77 条**全 pending**、两天**一条没应用**」。本版把**执行面**补上:候选能列、能应用、能回滚,整理能手动真跑。
#### (a) 端点族一览
| 端点 | 语义 | 关键参数 | 审计 |
|---|---|---|---|
| B20 `GET …/memories/candidates` | 列候选(含 `id`/`runId`/证据) | `status`(省略 = 全部)、`limit`(缺省 100 / 钳 500 / 非法 400) | 无(只读) |
| B21 `POST …/candidates/{id}/apply` | 应用**一条** | 无体 | `memory.tidy.apply` |
| B22 `POST …/candidates/apply` | **批量**应用 | `runId`(省略 = **全部待定**)、`limit`(缺省 100 / 钳 500 / 非法 400) | `memory.tidy.apply` |
| B23 `POST …/candidates/{id}/revert` | 回滚**一条**已应用 | 无体 | `memory.tidy.revert` |
| B24 `POST …/memories/tidy/run` | 手动**真跑**一轮(写候选 + 留痕,**不自动应用**) | 无体 | 领域侧记 `memory.tidy.run`(一次点击一条) |
形状逐字段见 §2.2 的 B20–B24 行。**门一律 `writeAuth`**(owner\|admin)—— 候选正文含隐私,与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档。⚠️ `design/23 §11` 表格把**回滚**标为 owner-only,而实现用了 `writeAuth`:**口径差异待裁决**(若以设计为准,`routes.go` 里把那两行换成 `ownerAuth` 即可,一行一处;与 §2.2 B17 的同类差异是同一件事)。
#### (b) 三条硬约束(客户端与面板都按这个编程)
1. **幂等不是错误**:执行闸是**候选状态的原子条件更新**(`WHERE id=? AND status='pending'` + `RowsAffected`),记忆行侧还有第二道闸(`AND status='active'`)。因此并发重复点击**只有一个赢家**,其余拿到 `200` + `status:"skipped"` —— 面板**不得**把它渲染成失败,也**不得**据此提示「重试」。
2. **超限必须如实**:批量回执的 `total` 与 `applied+skipped+failed` **口径不同**(前者 = 命中筛选的**真**待定数,后者只覆盖 `limit` 之内),差集由 **`truncated:true`** 表达。🔴 这是「不许把本轮 100 / 共 342 画成全部完成」的契约级要求。
3. **软动作、永不物理删除**:merge = 被吸收行 `status='merged'` + `merged_into`;archive = `status='archived'`;**`content` 一个字不改、行一行不删** ⇒ `revert`(B23)必然无损可逆。同时,**软删行必须真的从读侧消失**(v0.83 行为修复):记忆的四条列表读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)只回 `status='active'`,因此被软合并/归档的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。🔴 **只有两处刻意不过闸**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕本身没有 status 概念,且它是软删行的查看入口 —— 加闸等于审计自我抹除)。
#### (c) 调度:到点即跑 + catch-up(对客户端零形状影响)
原实现是「`interval=24h` 的 ticker 从**进程启动**算起」⇒ 后端两天重启十几次,**24h 永远走不完**(`memory_tidy_runs` 只有 1 行,这就是「两天没跑」的一半根因)。现在以 **`memory_tidy_runs` 的留痕行**作为「上次到底跑过没有」的**持久事实**,启动时与心跳到点都判一次(三条判据),**重启不饿死**。B24 是它的手动入口(panic button),与心跳**同一套规则、同一份配置**。
#### (d) 诚实边界(**别当已验证**)
- 🔴 **生产库全程零动作**:1191 行记忆与 77 条待定候选**都没有被应用或清理**。本版交付的是**能力**,不是「已经清过了」—— 真跑与批量应用要主人拍板后执行(可分批、可 revert)。
- **面板上的真机端到端未跑**:本版登记时**前端操作面还没落地**(前端切片并行在做),因此本版证据是**存储层单测 + 网关端到端测试 + 变异自证**,不含「面板点一下真生效」。
- **旧候选的 `run_id` 是空串**(77 条在生产库里都是这样)⇒ 「**按 run 批量**」(B22 带 `runId`)**找不到它们**;要清它们必须走**全部待定**那一档。
- 候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore` 的 `ensureTidySchema`),**未**同步进迁移 `0019`;组合根每次启动都会跑到前滚,因此生产库拿到该列没问题,但「迁移是不是唯一真源」这条口径**有欠**。
- **`memory.recall` 的语义召回在本机不可复现**(远端 embedder 对同一文本返回不同向量,TIDY-2 诊断发现)—— 它**不影响**本族的规则层判定与状态机,但**影响「整理候选是否可复现」**,登记在此以免被当成实现缺陷。
#### (e) v0.85:「不再只产建议」+ `link` 停产 + 按动作批量拒绝(B25)
**① 轮末自动落地(默认生效)**:整理跑完一轮后会**自动**把两类**规则层已判定为高置信**的候选落地,不再等人点:
- `merge`:**字面重复**(两侧正文逐字相同)**或**「事实集合等价 ∧ 相似度 ≥ **0.95**」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:习惯类会因正文里带钟点被误判成一次性事件,**归档它是错的**)。
🔴 **不自动**(别指望它替你干这些):`reclassify`、**低置信** merge、跨分区 / 跨角色、**任何改正文**、**受限来源(群聊 / 陌生人)**。
**六条护栏**:软动作(只改 `status` / `merged_*`,**正文不改、行不删**)· **每轮上限 50**(硬顶 500,超限**如实** `truncated` + 真总数)· 审计 `memory.tidy.apply`(`auto=true`,**不带正文**)· **可 revert**(回滚路径与人工应用**同一条**)· 幂等(候选状态条件更新)· **一键关回只产建议**:写 `$DATA_DIR/config/memory-tidy.json` → `{"autoApply": false}`(调上限同文件 `autoApplyPerRound`;**启动期读一次,改了要重启**;**零新 env 键**)。
**② `link` 候选已停产**:`link` 在 `design/23 §14` 里是**未细化项**(既无存储落点、也无数据模型),而 `applyable()` 只认 `merge/archive/reclassify` ⇒ **应用一条 `link` 只会拿到 `skipped`**。⇒ 规则层不再产出它(判 `keep`)。`TidyLink` 常量保留**只为读历史行**与「不可应用」判定。
**③ 存量清理走 B25**(幂等、只改候选状态、不碰记忆):
```bash
BASE=http://127.0.0.1:10942
# ① 先看现状(默认只看 pending;limit 缺省 100、钳 500)
curl -s -H "X-API-Key: $KEY" "$BASE/api/v1/admin/memories/candidates?status=pending&limit=500"
# ② 一次性拒绝全部 link 候选(幂等;limit 缺省 1000、硬顶 10000)
curl -s -X POST -H "X-API-Key: $KEY" \
"$BASE/api/v1/admin/memories/candidates/reject?action=link&limit=1000"
# 期望 {"data":{"action":"link","limit":1000,"total":501,"rejected":501,"truncated":false}}
# ③ 复核:同一调用再来一次 ⇒ total:0 / rejected:0(**不是错误**)
```
🔴 **只该整类拒 `link`(必要时 `keep`)** —— `merge/archive/reclassify` **有执行语义**,交给自动档或人工,别整类拒掉。
⚠️ 本条的 `total/rejected/truncated` 口径与 B22(批量应用)**同构**:`total` 是真命中数,处理数只覆盖 `limit` 之内。
### 4.16 助手文本的段落标签约束(**v0.84 新增,产出侧硬约束**)
**约束(一句话)**:助手文本在**落库、对外读接口、IM 镜像**里**不得出现未配对的段落标签** —— 不许有落单的 `<p …>` 或 `</p>`。语义上:**一个 `<p>` = 一段,段落不嵌套**。
**为什么写进契约**(这曾是真实纠纷):安卓端从 `GET /api/v1/sessions/web/messages` 读到过一条 `content` 形如
```
<p><action>满意地点点头</action></p>
<p>真乖♪ 这可是今天最让人家放心的一句。</p>
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
```
他们的解析契约 `P_BLOCK_RE = <p(\s[^>]*)?>([\s\S]*?)</p>` 是**非贪婪**的 ⇒ 第三段必然匹配成「外层 `<p>` 开、内容 = `<p>正文`、`</p>` 收」⇒ **用户直接看到字面的 `<p>`**。这不是他们解析器的口味问题:**任何按该契约定实现的消费方(Web 面板 / IM 桥 / 第三方)都会踩**。
**实测根因(两半都在产出侧,已修 `8051906`)**:
1. 规整器**原样放过**该形状(分块正则非贪婪 ⇒ `<p><p>X</p>` 被当成「一个已完整包裹的 `<p>` 块」逐字保留;更糟的是旧的覆盖检查用**同一非贪婪正则**,护栏本身是盲的);
2. 规整器还会**亲手制造**它:段首 `<p>` 未闭合(模型末段漏 `</p>`)时**无条件再套一层** —— 实证:模型吐 `<p>那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**;
3. 顺带修掉一个**幂等漏洞**:落单 `</p>` 旧行为 ⇒ `<p>正文。</p></p>`,再跑一次又变 ⇒ 越跑越多。
**修后的处置口径(产出侧,逐条有测试)**:`<p><p>X</p>` ⇒ `<p>X</p>`;`<p>X`(漏闭)⇒ 补 `</p>`(**补闭不补开**);`<p>正文<p>` ⇒ `<p>正文</p>`(合并不吞字);`<p>A</p><p>` ⇒ `<p>A</p>`(丢空开标签、不造空气泡);`<p></p>` ⇒ 整对丢;落单 `</p>` ⇒ 丢标签、**正文不动**。
**🔴 客户端要做 / 不用做的**:
- **不用做**:不必为这个畸形写特例解析器(产出侧已保证不再产出)。
- **仍要做**:**保留**你现有的「剥落单标签」容错 —— ① **历史行不回填**(库里已经有畸形的行);② 产出链**不止一条**(渠道镜像 / 主动推送 / 后台思考各有包装点),不能假设全都干净。
- **护栏(不许退化)**:`1 < 2`、`a<b`、`<p2>`、`<p`(无 `>`)、代码围栏内的 `<p>`、`<action>` 块内的 `<p>`、自闭合 `<p/>` —— 这些**都不是**段落标签,一律原样保留(产出侧与消费侧**同一条口径**)。
**一个保守处置(待设计裁决,生产库 0 例)**:`<p>A\n\n<p>B</p>`(未闭合段落之间夹正文)产出侧选择**合并** ⇒ `<p>A\n\nB</p>`,**不**做「插入式隐式收尾」(那要猜作者意图)。客户端不必为此适配。
### 4.17 记忆整理调优键的读写面(B26 / B27;**v0.92 新增,切片 M4**)
| 项 | 值 |
|---|---|
| 方法 / 路径 | `GET` 与 `PUT` `/api/v1/admin/memories/tidy/tuning` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档 |
| 审计 | GET **不审计**;PUT 落 **`memory.tidy.tuning`**(`result` ∈ `ok\|error`) |
| 落盘 | `$DATA_DIR/config/memory-tidy.json`(原子写:temp + rename;**值一个都没变时不写盘**) |
它救的是「这些键只能改文件 + 重启后端」——面板从此是配置的一等来源(`.env` 只做兜底)。
#### 管的 7 个键
| key | kind | unit | 取值 | 兜底来源 |
|---|---|---|---|---|
| `intervalSec` | int | sec | ≥ 1 | `env:MEMORY_TIDY_INTERVAL_SEC` |
| `quietIntervalSec` | int | sec | ≥ 1 | `panel:intervalSec` |
| `quietStartHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_START_HOUR` |
| `quietEndHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_END_HOUR` |
| `ingestGate` | bool | — | — | `code-default` |
| `autoApply` | bool | — | — | `code-default` |
| `tempTtlSec` | int | sec | ≥ 1 | `code-default` |
🔴 **`specs` 是取值规则唯一真源**:面板据此渲染与就地校验 ⇒ 不会出现两套规则。`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
#### 响应(GET 与 PUT 的 200 同形状)
```json
{
"path": "config/memory-tidy.json",
"present": true,
"hot": false,
"hotError": "…(仅 hot=false 时出现)",
"specs": ["…见上表,每项 {key,kind,unit?,min?,max?,fallback}…"],
"values": {"intervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": false},
"envValues": {"intervalSec": 86400, "quietStartHour": 23, "quietEndHour": 7}
}
```
🔴 **`values` 的缺位语义**:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键;同文件里**不受管**的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**,但 PUT 时**原样保留**。
🔴 **`envValues`**:键缺位时的**下一档(`.env`)进程生效值**(与 `/admin/config-facts` 同源 `s.cfg`,网关**绝不 fopen `.env`**);某键连 `.env` 档也没有值 ⇒ 该键不出现(例:`intervalSec ≤ 0`、安静时段两端都为 0 = 未填)。
#### 写侧三态(硬要求,逐字)
| 请求体里 | 结果 |
|---|---|
| 键**缺位** | 不改动 |
| **合法值** | 采用 |
| **非法值** | `400 invalid_payload`,且**盘上一个字节不动** |
| **未知键**(含拼错) | 归「非法」⇒ `400`,**绝不静默忽略** |
| 值 `null` | **清除该键**(回到下一档兜底) |
- 请求体 = **扁平 JSON 对象**(值类型 int 秒/钟点 或 bool)。
- **PUT 的 200 是「写完重新读盘」的真值**,不是回显请求体。
#### 错误码
| code | 何时 |
|---|---|
| `400 invalid_payload` | 值非法 / 未知键 / 体不是对象 / 体是 `null` |
| `409 tuning_corrupt` | 盘上那份**运行期读不走** ⇒ **拒绝覆盖**(文案说明此刻正回落 `.env`/代码默认) |
| `500 tuning_unavailable` | 数据目录未接线 |
| `500 tuning_write_failed` | 落盘失败(盘上未改动) |
#### 🔴 `hot` 的诚实口径(客户端按此渲染)
- `hot:true` = 值**已叠到运行期**:整理档会 `Stop→写→Start` **重排心跳**(否则新周期要等旧周期那一觉睡完 —— 主人把 24h 改成 3h 就「等于没改」);审查档重算 `ingestGate` / `tempTtlSec`。
- `hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「已保存到面板配置文件,**重启后端后生效**」。
- **绝不把「落盘了」说成「生效了」。**
#### `recallMode` 的渲染(前端,承接 v0.91)
试算卡片(B14)的 `recallMode` 现在四态如实渲染:`embedding` ⇒「召回来源:语义向量」|`lexical` ⇒「召回来源:词项余弦(未接向量,已降级)」|`mixed` ⇒「召回来源:部分分区降级(其余走向量)」|`""` ⇒「召回来源:无候选可比(本轮未做召回)」——**不再渲染成空标签**。未登记取值 ⇒「…(未登记取值)」(不吞信息)。「尝试过但向量源不可用」走**错误路径**(`error.code === "tidy_recall_unavailable"`),面板额外渲染一句解释,**不套在别的错误码上**。
## 5. env 与模型配置(与契约相关)
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
| 键 | 默认 | 与契约的关系 |
|---|---|---|
| `PORT` | `8080` | 网关唯一端口(含 REST/WS/admin) |
| `HOST` | `0.0.0.0` | 监听地址 |
| `ADMIN_PATH` | `admin` | 管理面板**前端路由前缀**(`/`+值)。**不是 REST 契约**:`/api/v1/admin/*` 一律不藏。非法值回退 `/admin`;路径形如面板入口但前缀不符 → `404` |
| `CHAT_IMAGE_MAX_BYTES` | `5242880`(5 MiB) | `chat.image` **字符串长度**上限的换算源(**单位是字节**,按 base64 4/3 膨胀换成字符:`ceil(n/3)*4 + 64`,默认 **6990572 字符**;只作用于 **base64 / `data:` 形态**,URL 形态不走这道检查)。`≤0` = 用默认;**只能收紧**,钳到 vision 硬顶(见 §4.5) |
| `CHAT_DOCUMENT_MAX_BYTES` | `20971520`(20 MiB) | `POST /documents` 单文件上限(`≤0` 用默认;与图片上限**刻意解耦**) |
| `AUTH_REFRESH_TTL` | `720h` | refresh token 有效期(access 固定 24h) |
| `AUTH_REFRESH_ROTATE_DISABLED` | `false` | `true` = 关闭 refresh 单次轮换 |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 占位 | 登录凭据(`POST /auth/login`);必填 |
| `JWT_SECRET` | 占位 | 必填、≥32 字符(签名 JWT) |
| `DATA_DIR` | `./data` | 数据目录;接受保留字 `platform`(大小写不敏感)→ 平台标准目录 |
| `DB_SQLITE_PATH` | `./data/cyrene.db` | SQLite 路径 |
| `DB_PG_URL` | 空 | pgvector **可选增强**;配置了必须真实走语义/向量路径。是否生效另由 `GET /admin/system/storage` 的 `vector.{enabled,ready}` 如实报告 |
| `ALLOWED_ORIGINS` | 示例值 | CORS + WS Origin 白名单(同 host 与回环别名额外放行) |
| `WS_MAX_CONNECTIONS` | `1000` | WS 连接上限(配置项,**未确证**是否在网关强制生效 —— 代码只见解析,未见到接线点) |
| `LLM_MULTIMODAL` | `false` | **主模型自身能否收图**的 `.env` 等价物(只作用于 `.env` 合成组;有有效 `models.json` 时以该组 `multimodal` 为准)。`true` = 图片直进主模型;`false`/留空 = 图片交 vision/OCR 工具。**非法值启动即报错** |
| `LLM_VISION_MODEL` / `LLM_OCR_MODEL` | 空 | **只供视觉/OCR 工具**调用;**不代表主模型能收图** |
| `LLM_CONTEXT_WINDOW` | 空 → 131072 | 上下文窗口(预算推导依据);负数启动报错 |
| `CONTEXT_MAX_TOKENS` | 空 → 按窗口推导 | `>0` 显式覆盖;`0`/负 = 关闭压缩。⚠️ 若 `.env` 里还留着 `32768`,它就是显式覆盖、推导不生效 |
| `CONTEXT_BUDGET_RATIO` | `2/3` | 预算占窗口比例(接受 `2/3` 或 `0.6667`) |
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
| `LLM_USAGE_RETENTION` | `720h` | `llm_usage` 保留期(`0`/负 = 关闭清理) |
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值)。`THINK_INTERVAL_SEC`(`900`)/ **`THINK_QUIET_INTERVAL_SEC`(`3600`,v0.75 新增)**:落在安静窗口内时,**周期性心跳**改用后者降频、窗外回落前者;**`≤0` = 关闭降频**(回落普通间隔,**不是**「半夜不思考」)。⚠️ 它**只降周期性心跳**:事件驱动(安全域/急性事件)照旧立刻触发、照旧穿透安静时段 |
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆**。**v0.75 起**:关闭时试算端点(B14)也**拒绝作答**(`409 tidy_disabled`),不绕过这个选择 |
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7`。⚠️ 窗口**只管心跳调度**:试算端点(B14)**不受它约束**(否则「现在就想看」正是它存在的理由) |
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定)。试算端点(B14)用**同一个**值 |
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / **`200`** | 每分区每轮的扫描上限 / **候选**上限(⚠️ 两者都是**每分区**的量,不是全库)。候选上限的默认值 **v0.77 由 `20` 抬到 `200`**(首轮真跑四个分区同时打满旧值 ⇒ 候选数由上限而不是规则决定);命中它时响应 `truncated` 带 `<分区>(candidates)`。试算端点(B14)用**同一对**上限(不另造一套) |
| `MEMORY_TIDY_COMPARE_LIMIT` | `2000` | **v0.77 新增**:每分区每轮**真正参与两两比对**的条目上限(性能护栏,两两相似度是 O(n²·dim))。此前**写死为 `400` 且没有 env 键**,实测把 459 条的分区截到 400 ⇒ `compared(705) < scanned(784)`(观测口径被上限决定)⇒ v0.77 抬到 `2000` 并开放为 env。命中它时响应 `truncated` 带 `<分区>(compare)` + 日志 WARN。`≤0` = 用默认 |
| `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) |
| `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
| `MEMORY_REVIEW_TIMEOUT_SEC` | **`90`**(v0.93 起;v0.77–v0.92 为 `20`) | 单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN。🔴 **默认值的唯一真源在 memory 包**(`internal/runtime/memory` 的 `defaultReviewTimeout`)—— 该 env 键**不设自带默认值**,未配置读出 `0` = 「没配」,由 `normalizeReviewConfig` 单点落地;**显式写正数才压过它**(不做「保底抬到 90」的加码)。⚠️ `20s` 已被实测证伪:批量 8 时一条完整召回链墙钟 **21.5–28.2s** ⇒ 20s 预算会让审查**整条失败且不写留痕**(见 §6 v0.93) |
| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) |
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记,已接线**(E4 `afe9afb`;`.env.example` 同步于 `f9c5b56`):`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记,已接线**(E4 `afe9afb`):渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
| `FLOW_GRAPH_RETENTION` | `168h` | **v0.73 新增,已接线**(切片 F2):`flow_graphs` 轮次图保留期;`≤0` = 关闭 GC(显式选择,「配了却不生效」不存在)。过期轮次从 `turns[]` 消失(见 §4.11(b)-5 / §7.4-20) |
| `FLOW_GRAPH_GC_INTERVAL` | `1h` | **v0.73 新增,已接线**(切片 F2):链路图保留期清扫间隔(与 `CHANNEL_EVENT_GC_INTERVAL` / `DOCUMENT_GC_INTERVAL` 同范式;启动期扫一次 + 定时 + 失败不退出) |
| `WEB_SEARCH_*`(`WEB_SEARCH_PROVIDER` / `_FALLBACK` / `_MAX_RESULTS` / `_ENDPOINT` / `_TIMEOUT` / 各家 `_API_KEY` / `_BASE_URL`) | 见模板 | **v0.78 起语义收窄(切片 PLG-4,运维视角的行为变更)**:web-search 的**可变源**改为 `$DATA_DIR/config/plugins/web-search.json`(0600、文件名 = 插件 id)。组合根**先读可变源、文件缺失才回落 `.env` 播种**(即 `.env` 只作**引导面**):**文件一旦落盘,改 `.env` 不再影响运行中的部署**。🔴 **不新增任何 env 键**(`.env` 已冻结);客户端一律走插件动作端点(§4.14(c)),**不直连该文件** |
> ⚠️ **v0.78 的「`.env` 只播种」口径**(切片 PLG-4):这与 §5 开头「**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层」是**同一条原则**在搜索 provider 上的落地 —— 此前 web-search 是纯 `.env` 面,v0.78 起它也有了面板/文件配置面。**无 env 键新增/删除**;模板里既有 `WEB_SEARCH_*` 键**保持现状**。
**与契约相关但「配了不生效」的键**:
- **`CHAT_MAX_PARALLEL_TURNS`:未接线**。编排器有构造期旋钮 `Deps.MaxParallelTurns`,但**组合根未从任何 env 读它** ⇒ **生产恒为默认 2**(`.env` 里配了不生效)。**本文档不把它登记为可用旋钮**。
- **`ENABLE_BACKGROUND_THINKING` / `THINK_SILENCE_TIMEOUT_SEC`:未接线**(全仓无消费者;`DevDocs/audit/07-backthink-automation-iot.md` §1-17,是否接线待主人拍板)。`ENABLE_BACKGROUND_THINKING` 自 v0.75 起被列进 B12 的只读事实面 —— 那是为了**让面板看得见这个死开关**,**不是**「它已生效」的声称:`facts[].value` 只是解析值,配 `false` 也不会关掉后台思考。`THINK_SILENCE_TIMEOUT_SEC` **未列入**事实面。
- 旧 `MCP_*` 扁平键:生产装配只读 `MCP_SERVERS_JSON`。
- `GETUI_*`:SDK 未接入,预留占位、当前不生效。
---
## 6. 变更记录
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.98(当前契约版本)
**「她的出站语音」版(M57 / M58 / M59;一处判据变更如实标注,其余纯增量)**
- **模型侧新工具 `speak`**(不是客户端端点,客户端零新调用):她自主决定某条回复用**语音**发。参数 `{text, emotion?}`;执行 = 按字数算超时预算(`clamp(3000 + 25×rune, 3000, 20000)` ms,`utf8.RuneCountInString` 一字一算)→ emotionTTS(发音人按生效人格,v0.96 链不动)→ 成功落音频副本(内容寻址 0600);**超时/失败返回结构化错误**(`tts_timeout` / `tts_failed` / `tts_no_audio` / `tts_store_failed` / `text_too_long` / `invalid_args`,均带预算与实际耗时)——她能在后续输出里读到成败并自然改用文字。
- 🔴 **speak 豁免能力收缩**(quiet/awake 等状态也广告)但**不豁免安全门**:群聊/陌生人在工具策略面按「未知工具」deny(fail-closed,design/11 §3)、owner 恒放行;「群聊是否放行 speak」留待后续裁决。
- ⚠️ `OutboundVoice.DurationMs` 恒 `0`(本版不解析音频时长);客户端**不得**以 `durationMs==0` 判定失败。
- **出站 `voice` 落列与降级标记(M58)**:她请求语音且合成成功 ⇒ assistant 消息的 `voice` 列写 `{source:"tts", status:"transcribed", name, mime, size, durationMs}`(**与入站 DTO 形状一致**,`source` 新值 `"tts"` = 她说的合成语音;`url` 由网关派生,规则同入站);**她请求了但降级**(超时/失败)⇒ 写 `{source:"tts", status:"failed", fallbackReason}`(如 `"tts_timeout"`)——**`name`/`mime`/`size`/`url` 四键整体缺席**,正文照发文字。降级事实**随消息落库**(不只实时帧)——历史回看可见。
- 🔴 **判据变更(唯一 breaking,如实标注)**:🎤 判据从「`voice` 键存在」改为 **「`voice` 键存在 && `status != "failed"` = 可播语音气泡;`status == "failed"` = 文字消息 + 降级小标**(`fallbackReason` 供特殊显示,如「语音超时,已转文字」)。**入站同样适用**:入站转写失败的行本就是 `status:"failed"` 且不可播——判据统一后语义更干净。旧判据客户端会把降级行误渲染成语音气泡。
- **渠道侧(M59)**:QQ OneBot v11 以 `record` 段发**真语音条**(`base64://` 内联首选,Napcat 自行转 silk;真机 silk 效果**待验证**);微信 iLink 无语音发送能力 ⇒ 该渠道降级纯文本(但全端语音副本仍可见——IM 文本与 Web/安卓语音气泡并存)。**出站 Media 由语音事实单独构造**,绝不透传入站 Media(2026-10-04 事故纪律)。
- **全部读面自动生效**:`source` 闭集在 `voice` 包单点(`asr`/`platform`/`tts`);REST 读面 / 实时帧 / 握手回放 / `message_edited` 经同一投影点 `attachVoiceTo`,首帧即带(M44 顺序纪律沿用)。
### v0.97
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
### v0.96
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
- **`POST /api/v1/audio/speech` 新增可选 `sessionId`**(纯增量,`omitempty`)。发音人不再由请求体决定,而是按该会话的**生效人格**解析:**会话绑定 → 全局默认人格 → 空**。
- 🔴 **省略 `sessionId` 不等于「没有角色」**:此时按**全局默认人格**选发音人(常规安装必有全局默认人格 ⇒ 仍走 `personas` 映射)。只有**连全局默认人格都没有**时才落到 `voice-tts.json` 的 `default`(没配则不合成)。
- ⚠️ **不要**写成「`sessionId` 为空 ⇒ 发音人置空」——那会让本口永远用 `default` 那个声音,正是「换了人格还用老角色」的病害。
- 请求体的 `voice` 仍**接受但不生效**(前向兼容);音色只能由服务端 `personas` 映射决定。
- 产物**恒为 WAV**(服务端无转码),实际格式一律按**字节嗅探**判定,客户端**不要**照抄 `Content-Type`。请求体 `format` 仅为前向兼容接受。
- **新增两个错误码**(状态码仍是 `500` —— 这是**服务端配置缺失**,不是客户端的错):
| `error.code` | 含义 |
| --- | --- |
| `tts_persona_unmapped` | 这个生效人格在 `personas` 里没有映射(去面板补) |
| `tts_no_character` | 连生效人格都没有,且没配 `default` |
| `tts_failed` | 其余合成失败(原口径) |
- 🔴 **管理面两条读端点是「脱敏投影」,不是原始数据**(M42):
- `GET /api/v1/admin/observability/logs`(A10):每行 `msg` 先过凭据打码,**上限 4096 rune**(够一行日志全文,但不会把 MiB 级行塞进 JSON)。`time`/`level`/`source` **原样**,条目数与最新优先序不变。
- `GET /api/v1/admin/observability/trace`(A11):每个 span 先过 `SanitizeRecord` ⇒ `props` 变**白名单投影**,表外键折成 `omitted: {键名 → {bytes, runes, sha256 前缀}}`(**仍能回答「这次输出了多大、两次是不是同一份」**);事件里按定义是正文的键同样折成 Digest。**字段名与帧形状不变**,客户端**不得依赖**未声明的 props。
- `id/name/kind/status/startedAt/endedAt/durationNs`、`level/source/time`、条数与排序——**排障字段一律保留**。
### v0.95
**「渠道静默窗口可写」版 —— 群里「一鼓作气打三句」只回一次,且参数在面板上调(切片 M23)**
- **新增 `GET /api/v1/admin/channels/coalesce`(读)** 与 **`PUT` 同路径(写)**。门 = `writeAuth`(owner|admin;`viewer` → 403、未认证 → 401),与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面。GET 只读不审计;PUT 落审计 **`channels.coalesce`**(`result` ∈ `ok|error`)。
- **写面是「三态补丁」**(键缺位 ⇒ 不动;合法值 ⇒ 采用;非法值 ⇒ **`400 invalid_payload` 且盘上一个字节都不动** —— 校验全部发生在落盘之前):
| 键 | 含义 |
| --- | --- |
| `enabled` | 是否启用静默窗口合并(bool) |
| `windowMs` | 首条到达后再等多久收批(ms) |
| `maxWaitMs` | **硬上限**:从首条算起最多等多久,到点就发车 |
| `maxBatchMessages` | 一批最多攒几条,够数立即发车 |
| `applyToStrangers` | 是否对陌生人/成员车道也应用(bool) |
| `strangerWindowMs` | 陌生人/成员车道的独立窗口(ms) |
- **每会话覆盖**:请求/响应带 `conversations` 映射(会话 id → 同一组键的补丁),因此窗口是**按会话**算的,不是适配器顶层一个值。
- **热生效是事实不是承诺**:桥每次发车从**同一个** store 实例读生效值 ⇒ 保存后下一次发车即用新值,响应里 `hot:true`。
- 🔴 **主人私聊不受此设置影响,且没有任何键能翻转它**:车道类型由 actor 元数据决定(`!msg.IsGroup() && actor.IsOwner()` ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**)。响应里下发 `ownerPrivate:"never"` 是**只读事实**,面板据此只显示一行说明、不给开关。
- 🔴 **一批 = 一个 `turnID` = 一次 `loop.Run` = 一条助手回复**,但批内 N 条仍是**各自一行 `user`**(同 `turnID`、顺序 = 到达顺序),助手回复与它们同 `turnID`。⇒ 客户端按 `turnID` 聚合时,一个回合会看到 N 条用户消息 + 1 条助手消息。
- **零新 `.env` 键**(design/13 §2.1):配置落 `$DATA_DIR/config/coalesce.json`。
### v0.94
**「MCP 管理面 + 工具来源」版 —— 新增一页声明式管理面板,并给能力条目加可选 `origin`(切片 M20 / M30)**
- **前端路由 `/admin/plugin/mcp/servers`**。它**不是新路由/新端点**:这是在既有面板目录里声明的一页(`id = mcp`,`panel = servers`),字段与动作都由 `pkg/plugins/sdk` 的 `Panel` 描述,前端用**既有通用渲染器**画。
- **所有动作走既有的通用分发端点 `POST /api/v1/admin/plugins/mcp/actions/<action>`**(插件只给动作名、不给 URL)。**7 个动作**:
| 动作名 | 档 | 作用 |
| --- | --- | --- |
| `plugin.mcpServers` | 读 | 全部 server 的状态快照(表格数据源) |
| `plugin.mcpTransports` | 读 | 传输形态候选(`stdio` / `sse` / `http`) |
| `plugin.installMcpServer` | 写 | 安装(或刷新)一台 server |
| `plugin.reconnectMcpServer` | 写 | 断开重连并重新发现工具 |
| `plugin.enableMcpServer` | 写 | 启用一台被停用的 server |
| `plugin.disableMcpServer` | 写 | 停用一台 server |
| `plugin.uninstallMcpServer` | 写 | 卸载一台 server |
- **鉴权在两处**:路由挂 `viewerAuth`(读动作 viewer 可用),**写动作由分发器再查 `callerHasWrite`(owner|admin)**,每次写动作落审计。失败码是**稳定闭集**(前端按码本地化,不解析文案):`invalid_config` / `install_failed` / `reconnect_failed` / `enable_failed` / `disable_failed` / `uninstall_failed` / `server_not_found` / `server_disabled`。
- 🔴 **安全提示(客户端/运维必读)**:`transport=stdio` 的 `command` + `args` 等于**在本机执行任意命令**,安装即生效。⚠️ **本版存在一处档位不对称(已 file finding,未在本版修)**:面板的写动作是 **owner|admin**,而 REST 的 `POST /api/v1/admin/plugins/install` 与 `/uninstall` 是 **owner-only**(`ownerAuth`)⇒ 一个非 owner 的 `admin` 可以通过面板动作装上一台 stdio server,却走不通 REST 安装口。单 admin 部署无实际差别,多账号部署请把 admin 视同 owner 收敛。
- **工具条目新增可选 `origin` 对象**:`{ "kind": "mcp", "server": "<server 名>", "transport": "stdio|sse|http" }`。**宿主内置能力整键缺席**(不是空对象)—— 「没有 `origin`」正是「宿主自己提供」的表达。
- 🔴 **归属只来自注册那一刻的元数据,绝不解析工具名字前缀**:MCP server 的工具名由它自己给,宿主不加前缀;实测一台 `halo` server 的工具分属 4 个不同前缀族 ⇒ 按前缀猜归属必错。
- **这是呈现隔离,不是权限隔离**:`tool_policies` 没有 server 维度,`mcp:<server>` 策略行是惰性的 ⇒ **本版不提供「按 server 开关工具」,也不给假开关**。
### v0.93
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
- 🔴 **`MEMORY_REVIEW_TIMEOUT_SEC` 的默认值 `20` → `90`**,并把默认值**收到唯一真源**:它**不再自带 env tag 默认值**,未配置读出 `0` = 「没配」,由 memory 包 `normalizeReviewConfig` 单点落地 `defaultReviewTimeout`。
- **未配置 ⇒ `90s`**;**显式写正数 ⇒ 原样生效**(**不做**「保底抬到 90」的加码);**写 `0` / 负数 ⇒ `90s`**。
- 🔴 **它修的是一个「看起来改了、其实没改」的事故**:v0.92 那批切片把 memory 包的 `defaultReviewTimeout` 从 `20s` 改成 `90s`,但 config 包的 env tag `default:"20"` **永远产出非零的 20** ⇒ `normalizeReviewConfig` 的「`≤ 0` 才填默认」分支**永远不走** ⇒ **90s 是死代码**,线上启动行仍打印 `timeout=20s`。
- **为什么必须修**:批量上限抬到 8 之后,一条完整召回链实测 **21.5–28.2s** ⇒ 20s 预算下审查**整条失败、且不写留痕** ⇒ 召回降级、灰区恒空、T1 发不出调用 ⇒ 「重复记忆收不动」这条链**只修了一半**。
- **为什么旧断言拦不住**:既有跨包断言比的是 **memory 包的常量**(「我打算用多少」),而病害发生在**另一个包**(config 的 tag),单包自洽、跨包漂移看不见。本版新增一条**走真实组装路径**的断言(`LoadMemoryReviewConfig` → `ReviewConfig` → `normalizeReviewConfig` → **生效值**,再断言它 > 实测最坏链长 + T1 子超时),并在注释里写明「**比常量 vs 比生效值**」的区别。
- **兼容性**:**纯行为修复,无 API 形状变化**;`.env` / `.env.example` 文件内容**一字未动**(该键从未进过模板,符合 `.env` 冻结口径);受影响的只有「没显式配这个键」的部署 —— 它们的单条审查预算由 20s 变 90s。
- **诚实边界**:若某部署**显式**写了 `MEMORY_REVIEW_TIMEOUT_SEC=20`,本版**不会**替你抬到 90(那是显式配置,尊重它)。另有两处**散文副本**仍写着旧值(`internal/runtime/memory/preinsert_t1.go` 的一处注释),已 file finding、不在本版改动面内。
### v0.92
**「记忆整理调优键可写」版 —— 管理面新增 2 条 `writeAuth` 端点,把面板从「只能改文件 + 重启」救出来**(依据:主人 2026-10-13 口径「`.env` 只做兜底…这些键优先从面板配置上取」「频率必须调…白天间隔短一点、静默期长一点」;实现切片 M4)
- **新增 `GET /api/v1/admin/memories/tidy/tuning`(B26)** 与 **`PUT` 同路径(B27)**。管 7 个键:`intervalSec` / `quietIntervalSec` / `quietStartHour` / `quietEndHour` / `ingestGate` / `autoApply` / `tempTtlSec`。门 = `writeAuth`(owner|admin;`viewer` → 403,未认证 → 401),与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档;GET 只读**不审计**,PUT 落审计 **`memory.tidy.tuning`**(`result` ∈ `ok|error`)。
- 🔴 **响应三块的分工(客户端按此编程,别自己算默认值)**:
- `specs[]` 是**取值规则唯一真源**(`kind` / `unit` / `min` / `max` / `fallback`),面板据此渲染与就地校验 ⇒ 不会出现两套规则;`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
- `values` 是**盘上**的值:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键,同文件里不受管的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**。
- `envValues` 是键缺位时**下一档(`.env`)**的进程生效值(与 `/admin/config-facts` 同源,网关**绝不 fopen `.env`**);连 `.env` 档也没有值 ⇒ 该键不出现。
- 🔴 **写侧三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**。**未知键(含拼错)也归「非法」,绝不静默忽略**。值 `null` = **清除该键**(回到下一档兜底)。错误码:`400 invalid_payload` | `409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)| `500 tuning_unavailable`(数据目录未接线)| `500 tuning_write_failed`(落盘失败,盘上未改动)。
- **两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。PUT 的 200 与 GET **同形状**,但值是**写完重新读盘**的真值。
- 🔴 **`hot` 语义(绝不把「落盘了」说成「生效了」)**:`hot:true` = 值已叠到运行期(整理档会 `Stop→写→Start` **重排心跳**,否则新周期要等旧周期那一觉睡完;审查档重算 `ingestGate`/`tempTtlSec`);`hot:false` = 已落盘但运行期没接上,`hotError` 给原因,面板文案必须是「**重启后端后生效**」。GET 的 `hot` **恒为 false**(读不改变任何东西)——那是如实占位,**别读成能力声明**。
- **兼容性**:**纯增量**(新端点;既有端点/帧一字未改),老客户端零影响。
- **计数**:`writeAuth` 段 25 → **27**;管理路由总数 79 → **81**(`14 + 27 + 40`)。字段级说明见 **§4.17**。
### v0.91
**「召回来源如实上报」版 —— `recallMode` 取值域扩两个 + 语义收紧**(实现 `5967272`,切片 M9;起因 = M6 诊断发现旧实现把「全量降级」静默报成 `embedding`,见 `DevDocs/audit/去重失效诊断-2026-10-13.md`)
- `recallMode` 现在**如实上报本轮实际选用的召回路径**,取值域:`"embedding"` | `"lexical"` | **`"mixed"`(部分分区降级、其余走向量 —— v0.91 新增)** | **`""`(没有候选可比 ⇒ 本轮未做召回 —— v0.91 新增;它不是降级,也不该被显示成空标签)**。
- 🔴 **行为变更(客户端须跟进)**:旧实现报的是**配置态**(进程接没接 embedder),因此「向量路径实际失败、回退到词项」会被显示成 `embedding` —— 面板**看不出降级**。现改为实际态。**试算端点(B14)与整理轮日志/审计同源**;整理轮另新增 `recall_degraded_partitions` 计数,用于回答「哪几个分区降级了」。
- **兼容性**:按 `embedding|lexical` 二分读的老客户端需自行兜底新值(`mixed` → 「部分降级」;`""` → 「无候选可比」)。前端渲染由后续切片修正(`MemoryTidyDryRunCard`)。
- **附带的稳定性修复(对客户端透明,无契约影响)**:embedding 批上限改为按「单次请求预算 = 客户端超时的一半」**推演**得出,并在 embedder 内部强制切片(任何调用方给多大的批都安全);**客户端 5s 超时值本身一字未改**。它修的是「客户端先放弃、服务端继续算 ⇒ 队列堆积级联」这条真机制(M9 实测:空闲态单条 ~0.03s(ASCII)/ ~0.16s(40 汉字),饱和态可放大 70~90 倍)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。字段级说明见 **§4.12** 的 `recallMode` 行。
### v0.90
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
- **三态闭集(口径落在适配器自身,网关只做映射)**:
| 状态 | 含义 | 线上投影 |
|---|---|---|
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
### v0.89
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
- 🔴 **背景(实测)**:她输出的**不是 Markdown**(最近 12 条 assistant 消息里 `**` 记号 0 个、列表行 0 个),而是**有限的一小组 HTML 标签**:`<p>` 96 次、`<action>` 34 次、**`<b>` 6 次**。`<p>` / `<action>` 各端早已处理,**`<b>` 此前没有任何一端处理 ⇒ 用户直接看到裸标签**。
- **新增渲染口径(跨端一致)**:助手消息正文里,以下**行内标签**应被渲染成对应样式,而**不是**按字面显示:
`b` / `strong`(粗)· `i` / `em`(斜)· `code`(等宽)· `u`(下划线)。
⚠️ **白名单最终定为上面六项** —— 主人 2026-10-07 明确「**下划线确实可以保留**」⇒ `u` 加回;`s`(删除线)**仍不在其内**(她 100 条输出里 0 命中);`br` **不是行内标签**,见下一节(它是分隔符)。
- **🔴 `<br>` 是「气泡/消息分隔符」,不是行内标签(同日追加,主人口径)**:主人原话「**对消息内的 `<br>` 始终处理为多条消息隔断**」。
- **语义与 `<p>` 同级**:`<br>` 两边的文本**必须成为两条独立的消息**(IM 侧)/ **两个独立的气泡**(Web 侧),**而不是**同一条消息里的一个换行。
- `<br>` / `<br/>` / `</br>` 三种写法**等价**(HTML5 口径)。
- **空气泡照旧丢弃**(与既有 `<p>` 空气泡口径一致);`<br>` 两侧都是空白 ⇒ 不产生多余空气泡。
- ⚠️ **影响面(各端须知)**:一条含 `<br>` 的回复在 IM 上会变成**多条消息**(与 `<p>` 一样受既有的气泡间隔节流约束)。
- ⚠️ **实现状态**:本条发出时**实现跟进中**(Web 侧 `2dae469` 当时把 `<br>` 当行内换行,正在改;IM 出站侧当时连行内标签都还没剥,正在改)。**以本契约为准,不以当日实现为准。**
- **🆕 非 chat 模式(单卡正文)的口径(同日追加)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置 等**单卡正文**界面**不是气泡模型** ⇒ 那些模式里:**`<br>` ⇒ 一个真换行(不切分)**、**行内白名单照常出样式**、白名单外仍逐字原样。⚠️ **这些模式里也不许出现字面标签**。各模式**既有的较强排版约定优先**(例:工作模式的按行切清单、代码模式的 `<pre>` 等宽块 —— 通用规则不得覆盖它们)。
- **🔴 `<br>` 在哪**不**分隔(同日追加,跨端口径)**:`<br>` **只在「普通文本层」分隔**。以下两种位置**不分隔**:① **角色注记内部**(`<action>` / `<narration>` 里)—— 否则注记正文会被切成聊天气泡、把本该隐藏的旁白发给对方;② **白名单行内元素内部**(尤其 `<code>`)—— 它是**成对**元素,从中间切开必然制造不平衡、触发「整段降级」,反而把裸标签露给用户。
- **🔴 三条硬口径(各端必须照此实现,否则跨端显示不一致)**:
1. **白名单外一律按纯文本原样显示** —— `<script>` 显示成字面量 `&lt;script&gt;`,**字符一个不丢**;
2. **属性一律不解析** —— `<b class=x onclick=y>` 只取标签名,**属性不得变成任何 DOM/视图属性**;
3. **结构不平衡**(未闭合 / 落单闭标签 / 交错嵌套)⇒ **该段不产生任何行内渲染,且字符一个不丢**。
⚠️ **同日晚修订**:具体粒度(「整段降级为原文」还是「只让坏的那一对保持字面」)**降级为实现细节**,契约不再强制统一 —— 这类输入极罕见(安卓扫 100 条、Web 扫 12 条,均 0 例),为它让任一端返工不划算。
- **🆕 行内标签在 IM 出口的行为**:IM 渠道只收纯聊天文本 ⇒ **成对且平衡的白名单行内标签在投递前剥除标签、保留内部文本**(`<b>18:35</b>` ⇒ `18:35`);**白名单外与结构不平衡的标签一律原样保留**(它们是文本,逐字不丢)。这与既有的 `<action>`/`<narration>`「IM 出口整体剥除」**语义不同**(那两个是角色扮演标记,要丢;行内标签是排版标记,剥样式留文本)。**`<br>` 不在此列** —— 它按上一节走「切分成多条消息」。
- **大小写口径(各端自行决定,契约不强制)**:Web 端**不敏感**(`<B>` = `<b>`);安卓端**只认小写**(与它 `parse` 只认小写 `<p>` 同口径)。两者都能通过本契约的硬口径,**这一条留待各端自定**(已与安卓端协商)。
- 🔴 **安全红线**:助手文本是**不可信内容**(模型生成 + 用户可诱导)⇒ **任何端都不许把整段内容当 HTML 注入**(Web 端禁 `dangerouslySetInnerHTML`,原生端禁等价手段)。正确姿势是**解析成结构化节点、交给各自的安全渲染原语**(Web:React 子节点自动转义)。Web 端已由「**全程无 `dangerouslySetInnerHTML`** + 节点类型里**没有属性位**」两条结构性保证锁住,并以「白名单外的 `script` 必须逐字原样」为测试断言。
- **范围克制**:**不做块级**(段落仍由 §4.16 的 `<p>` 机制表达)· **不做 `<a>`**(链接有独立安全面,且她目前不用)· **不做 Markdown**(她不用)。
- **不改既有**:`<action>` / `<narration>` 的既有行为逐字不变(Web 灰斜体、IM 出口剥除);**注记内部不参与行内渲染**(注记里写 `<b>` 仍是字面量)。
- **无回归证据**:Web 端改动前后拿 11 个消息样例过**真实气泡切分链路**逐字节对照 —— 7 个逐字节相同,4 个差异恰是含白名单标签的样例。
- **兼容性**:**服务端零变更**(无新端点/帧/字段,§2.0 计数不变)。对本契约的读者而言,本版是**新增的渲染约定**:老客户端不跟进 ⇒ 行为与 v0.88 相同(**只会继续显示裸标签**,不会崩)。
### v0.88
**「单端口红线的适用范围澄清」版 —— 不改任何端点 / 帧 / 字段,只澄清一个边界**(依据:主人 2026-10-07 口径补充「关于不允许开新端口的红线,我加一个补充。那是对 cyrene 框架自身的约束,而不是扩展插件的。」)
- 🔴 **澄清内容**:「单二进制、单端口、零跨进程 IPC」这条红线**只约束 Cyrene 框架自身** —— 框架不另开监听端口、不 HTTP 互调(§0.1 已更新措辞为「**框架**唯一对外端口」)。
- **扩展插件不受该约束**:`plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随进程/服务,**可以开自己的监听端口、可以有自己的进程形态**。**那些端口不在本契约范围内** —— **客户端一律只连这一个网关端口**,不得假定能从插件端口取到任何契约面。
- 原「`qq-obv11` 渠道 OneBot 反连 server 模式」那条例外**由「例外」升格为一般规则的实例**(规格仍见 `design/08 §4`)。
- **插件端口的四条护栏(主人 2026-10-07 确认采纳)**:① 端口/地址必须由配置**显式给出**,不得默认开启、不得写死;② **默认绑 `127.0.0.1`**,要对外暴露必须由 `.env` 显式决定;③ 必须**登记日志来源与审计**;④ 端口**不得成为绕过身份/权限判定的入口**。
- **兼容性**:**零破坏性变更** —— 无新端点、无新帧、无字段形状变化、**§2.0 路由计数不变**(`14 + 25 + 40 = 79`)。唯一对客户端的要求是:**别把「插件监听端口」当成契约面**(此前也从未有过这种端口与契约的关联)。
### v0.87
**「搜索 provider 扩到八家」版 —— 名单扩充,端点形状与错误码一字未改**(切片 `agent-389`,本次提交;依据:主人「我看之前不是走有头CDP扒了好几家搜索提供商的接口方式在本地参考文档吗?为什么不都做一下适配?」+「优先只做国内能用到的几家」。)
- **新增两家已登记接入位**:`tencent_wsa`(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**)与 `metaso`(秘塔搜索,`POST {base}/api/v1/search`)。
- **契约面影响(只有两处,且都是名单扩充)**:① `plugin.saveProvider` 的 `values.provider` 合法名单 **六家 → 八家**(见 §4.14 的三态写回那段);② `plugin.providers` 候选新增两条 `{value,label}`(中文名见 §6 本版上方那条的逐字表)。**§2.0 计数不变**(无新端点,维持 `14 + 25 + 40 = 79`)。
- **前端零改动**:候选是数据驱动的(`optionsFrom`),中文名只是 `label`。
- ⚠️ **诚实边界(如实,别当已验)**:① **腾讯成功响应未真机实测**(无 key)—— 依据是官方文档 + 本机无 key 探测(缺 `Authorization` 也回 **HTTP 200** + `Response.Error`);② **秘塔响应 schema 全部来自第三方来源且互相打架** ⇒ 代码走**宽松解析**(多字段名回退),**未真机验证**,拿到真 key 后必须按真实报文收窄;③ 两家的请求体都**只发官方确证为必选的字段**(腾讯只发 `Query`),条数靠本地截断。
- **零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`;`.env` / `.env.example` **未动**(`.env` 冻结口径不变)。
### v0.86
**「搜索结果条数上限可配」版 —— 面板多一个 number 字段 + 两个动作各多一个键**(切片 `bff57bd` + `c68387a`;依据:主人 2026-10-07 口径「返回五条结果会不会太少了?」+ 上一片登记的「`maxResults` 在面板上根本改不了」那条真缺陷。)
- **上限真源与语义**:运行期真源是**面板文件** `$DATA_DIR/config/plugins/web-search.json` 的 `maxResults`(**压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`),是**真上限** —— 模型的 `count` 只能更小(`count:50` ⇒ 只给上限那么多;`count:3` ⇒ 给 3;缺省/非法 ⇒ 上限)。代码默认值 **5 → 10**,三处口径已一致。
- **契约增量(三项,纯加性)**:① 面板声明新增字段 `maxResults`(`type:"number"`, `min:1`,前端无需改动);② `plugin.saveProvider` 的 `values` 新可写键(**三态**:缺位不改动 / 正整数采用 / 其余 **`400 invalid_payload`** 且**盘上不动**);③ 响应回显 —— `saveProvider.data.result.maxResults`(**0 = 未声明**)与 `reauthProvider.values.maxResults`(盘上有值才出现)。
- **计数**:**无新端点** ⇒ §2.0 维持 **`14 + 25 + 40 = 79`**。
- **验证证据**:整树 `go build ./...` 通过;三包 vet/测试绿;端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(真 action hub → 落盘 → 热重载 → `WithSearchMaxResults` → 真 dispatcher,**未重启后端**);主会话用 `-overlay` 摘掉落盘那一行 ⇒ 两条用例变红。
- ⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(没有「去掉上限」的手势;要回 `.env` 兜底得改文件);**小数/负数一律拒**;文件未声明该键时 `reauth` **不回填**(该框为空,看不出当前生效值)—— 与「面板显示的是该条、不是生效值」的既有口径一致。
### v0.85(上一版)
**「整理不再只产建议」版 —— 新增 1 条端点(B25)+ 一处行为变更(轮末自动落地)+ `link` 候选停产**(切片 TIDY-AUTO `6d92536`;依据:主人 2026-10-07 口径「该删就删,怎么这个记忆整理机制这么保守呢」;`design/23 §7/§13/§14`。)
- **① 新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)**:按**动作**批量把 `pending` 候选标成 `rejected`。`action` **必填 + 闭集**;`limit` 缺省 1000 / 硬顶 10000;返回 `{action,limit,total,rejected,truncated}`;审计 `memory.tidy.reject`。**幂等**(再来一次 `total:0`),**只改候选状态、绝不碰记忆行**。详见 **§2.2 B25 / §4.15(e)**。
- **② 行为变更:轮末自动落地默认开**(可自动仅两类:字面重复 ∨ 事实等价 ∧ ≥0.95 的 `merge`;`one_off_event` 且不像习惯的 `archive`);六条护栏(软动作 / 每轮上限 50 / 审计 `auto=true` / 可 revert / 幂等 / 一键关回只产建议,开关落 `$DATA_DIR/config/memory-tidy.json`,**零新 env 键**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、改正文、受限来源。详见 **§4.15(e)**。
- **③ `link` 候选停产**:`design/23 §14` 把它列为未细化(无落点无模型),`applyable()` 也不认它 ⇒ 产出它只是噪音。存量清理由 B25 走(主会话已执行)。`TidyLink` 常量保留**只为读历史行**。
- **④ 计数**:`writeAuth` **24 → 25**、总数 **78 → 79**(`14 + 25 + 40`;`routes.go` 核:`writeAuth` 挂载的 admin 路由实有 27,按历史口径减 2 ⇒ 25)。
- **⑤ 诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**;`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(不改变任何判定结论;升版会让既有候选按幂等键复制重复行);面板「整理候选」页签仍未落地(前端切片)。
### v0.84(上一版)
**「显示名 + 段落标签硬约束」版 —— 一处形状变更(插件动作响应)+ 一处新增产出侧硬约束 + 一条模型面登记**(依据:主人报障「下拉里全给英文名,你甚至不愿意带个括号把中文名写上去」;安卓端登记 `<p><p>正文</p>` 畸形;`design/28 §1 M5 / §8 B5` 的浏览器降级档。切片:显示名 `c46053e`、段落修复 `8051906`、provider 配置面去 env 化 `bc1dee3`、搜索 linkless 修复 `2c56bc9`。)
- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)` / **`腾讯云·元宝搜索(tencent_wsa)`** / **`秘塔搜索 Metaso(metaso)`**(🆕 **v0.87 起八家**)。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。
- **② 新增产出侧硬约束:助手文本不得出现未配对的段落标签**(落单 `<p …>` / `</p>`)。见 **§4.16**。修的是安卓登记的 `<p><p>正文</p>`;**两半根因都在产出侧**(规整器原样放过 + 在「末段漏 `</p>`」时亲手制造),另修一个幂等漏洞。**历史行不回填**、消费方**仍应保留**容错。
- **③ 模型面登记(非客户端契约面)**:`web-fetch` 的浏览器渲染降级档已接线,返回体用 `fetchedBy`(`direct` / `browser`)**如实标注**正文实际来自哪条路;两条档的失败各自留痕,**绝不静默替换**,被 SSRF 拒绝的目标**不换路**。
- **④ provider 配置面「去 env 化」(对客户端零形状影响,登记供跨端对照)**:新增 provider 不再需要新的 `.env` 键 —— 面板文件 `$DATA_DIR/config/plugins/web-search.json` **是权威**,`.env` 只在文件缺失/为空时**播种**(文件一旦存在,「文件里没有的家」**不从 env 复活** —— 这是既有回归用例钉住的口径,本次未改);各家「没配 baseUrl 就回落代码默认值」的默认值表移进接入位表(`search.Descriptor.DefaultBaseURL`),**逐字未变**。
- **行为变更**:**有一处(且是"变真")**:① 让「文件里没有的家不从 env 复活」这条既有口径在**新家**上也成立(此前新家根本进不来);② 段落标签约束让产出**不再畸形**(对不产畸形的部署零影响)。
- **破坏性**:**无**(`plugin.providers` 只改**候选**形状且前端三形状兼容;无键被删、无端点被删、无既有语义被改)。
- **计数**:**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**(§2.2 B1–B24)。
- **验证证据(本版实测)**:`go build ./...` + 各包 `vet` 绿;相关包测试全绿;三处变异由主会话**亲自**复现打红(把 `option.value` 换成 `option.label` ⇒ 渲染测试红,输出正是会写坏配置的 `"bocha, 智谱 BigModel(zhipu)"`;摘掉配对修复接线 ⇒ 段落用例红;把「文件里的 key」忽略 ⇒ 4 条 provider 投影用例红)。
- ⚠️ **诚实边界**:① **`web-search` 真机工具路径在登记时仍有一处未部署的修复**(详见 v0.84 横幅与 §7.2)—— 已推库、**未 build/restart**;② 显示名的**真机浏览器点击**未跑(项目无 jsdom,证据止于渲染层)—— **且 `internal/webui/dist` 是 `go:embed` 进二进制的,前端改动必须重新 `build` 才生效**(子代理曾误称"纯前端改动不需要重启",已纠正);③ **`viewer` 角色看不到 form 区块** ⇒ 表格对 viewer 仍显示裸 id(未做);④ 腾讯云元宝 / 秘塔两家的适配**尚未实现**(文档已逐页落盘)。
### v0.83(上一版)
**记忆整理「应用面」—— 五条 `writeAuth` 新端点 + 读侧 status 闸(行为修复)**(切片 TIDY-1 `454ddb0` + `9126d65` + `f847769`;依据:`design/23` §7/§8/§10/§11;主人实测口径「候选 77 条全 pending、两天一条没应用」+「重复无效记忆都一千多条了」。)
- **① 五条新端点(B20–B24)**:`GET …/memories/candidates`(候选列表,只读不审计)、`POST …/candidates/{id}/apply`(单条应用)、`POST …/candidates/apply?runId=&limit=`(批量;`runId` 省略 = 全部待定)、`POST …/candidates/{id}/revert`(单条回滚)、`POST …/memories/tidy/run`(手动真跑,写候选 + 留痕、**不自动应用**)。语义与逐字段形状见 **§2.2 B20–B24** 与 **§4.15**。审计:应用/回滚走 `memory.tidy.apply` / `memory.tidy.revert`(**只记计数与动作、不带正文**);真跑的 `memory.tidy.run` 由**领域侧**记(一次点击一条)。
- **② 读侧 status 闸(行为修复,形状零变化)**:记忆的四条**列表**读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 `status='active'` ⇒ 被软合并(`'merged'`)/被归档(`'archived'`)的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。修的是「软动作写了 status,读侧却不看 ⇒ 清了也白清」(生产库已有 10 条 `merged` 行是现成反例)。🔴 **两处刻意不过闸**:`Get`(撤销软合并要先读得到被吸收的行)与 `GET /admin/memories/reviews`(留痕表本身没有 status 概念,且它是查看入口 —— 加闸等于审计自我抹除)。
- **③ 调度不再饿死(对客户端零形状影响)**:整理由「ticker 从进程启动算起、重启十几次就永远走不完 24h」改为**到点即跑 + catch-up**,以 `memory_tidy_runs` 留痕行为**持久事实**判「上次跑过没有」;配 B24 作手动 panic button。
- **行为变更**:**有(一处)**:② 让软删行从检索/上下文/面板里**真的消失** —— 这正是本版的目的;对**不软删任何东西**的部署,行为与 v0.82 逐字相同。
- **破坏性**:**无**(五条都是新端点;② 是读侧收紧,不删键、不改键义、不改既有端点形状)。
- **计数**:`writeAuth` 段 **19 → 24**、管理路由总数 **73 → 78**(`14 + 24 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 26,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ 24)。§2.2 行数 **B1–B24**。
- **验证证据(本版实测)**:`go build ./...` + `go vet` 绿;`internal/runtime/memory` 包全量测试绿 + 新增读路径逐条断言(4 条列表 + 检索端到端「内容逐字相同的两条,软合并后只召回一条」+ `Get` 反向护栏);`internal/services/gateway` 新增面板端到端用例(真 `sql.DB` + 真 store + 真 HTTP handler);**两组变异**(overlay 把闸门改成恒真)都被打红。
- ⚠️ **诚实边界**:① **生产库全程零动作**(1191 行记忆 / 77 条候选都还没应用或清理 —— 交付的是**能力**);② **面板真机端到端未跑**(前端操作面在本版登记时未落地,并行切片在做);③ 旧候选 `run_id` 为空串 ⇒ 只能用「全部待定」档清;④ 候选表 `run_id` 列只在存储包前滚、**未**入迁移 `0019`;⑤ 远端 embedder 对同一文本返回不同向量 ⇒ **语义召回不可复现**(不影响规则层与状态机,但影响「候选是否可复现」)。
### v0.82(上一版)
**三面增量 —— ① `tool_execution.end` 带可选 `resultSummary`(工具执行结果实时可见,切片 Y1 `215eedf`)|② 自排的成本可见面:`?trigger=` 服务端筛选 + 新端点 `GET /admin/backthink/self-wake`(切片 Y3 `4213b26`)|③ `browser` 插件的管理面板与六个动作(切片 Y4 `e0a6bca`)**(依据:安卓端按 ① 把工具卡 UI 定死;`design/12 §N C.5/C.6` 的自排成本口径与筛选;`design/28 §8 B4` 的面板入口。)
- **① `tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)**:`{tool, phase, id?, delta?, resultSummary?}`。数据来源 = **loop 侧**在工具返回值落定的那一刻生成(`internal/runtime/loop/tool_frame_summary.go`),走的是**与出图同一条**脱敏路径(`trace.RedactText`:凭据形状清洗 + 截断),**不从上游原文反解、不新开脱敏通道**。修的是「实时只看到工具名与状态、要事后翻回看面才知道结果」。**§3.4.2**
- 🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态**。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空。**绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。`tool_execution.start` / `.update` **不带**该键。
- 🔴 **失败也带(本版口径)**:调用**失败 / 取消 / 超时**的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与**回看面**(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。⚠️ **本版之前**(切片 Y1 的首版实现)失败时是**整键缺席**、回看面却有 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」;本版把这处张力收掉了。
- **预算**:与回看面 `toolCalls[].resultSummary` **共用同一个常量与同一个截断函数**(`maxToolCallSummaryRunes = 500`,超长按 rune 截断、末位 `…`)。网关侧**只截断、不做二次脱敏**(与回看面「只搬运、不加工」同一口径)。
- **② 自排的成本可见面(切片 Y3)**:
- `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(design/12 §N C.6)。取值域 = **闭集**(`heartbeat` / `startup` / `self_wake` + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`);**值未知或为空 ⇒ `400 invalid_trigger`**(**绝不静默忽略** —— 那会让面板在显示全部的同时声称「正在筛选」);合法值**下推 SQL** ⇒ `limit` 数的是**匹配**条数。🔴 **「筛选白名单是闭集」与「`trigger` 的显示取值域不封闭」不矛盾**(前者约束客户端传进来的参数、后者约束我们发出去的显示值),见 §4.10(e-1)。
- 新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(design/12 §N C.5 的读侧)。响应 `{data:{available,running,reason?,configFile?,gate?}}`,`gate` = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`。**三态必须可区分**(面板不得把「看不到」画成「0 次」);`available:false` 时 `gate` **整键缺席**且 `reason` 说明原因、**HTTP 仍是 `200`**。🔴 **`limitIsDefault` / `windowIsDefault` 只说「生效值恰好等于代码默认值」**,**不是**「配置文件里没写这个键」(网关不读 `backthink.json`,与 B12 的 `isDefault` 同一诚实口径)。🔴 **它只读、不审计、不写库**,也**不含任何可改配置的入口**(改上限/窗口仍要改文件 + 重启)。**§2.2 B19 / §4.10(e-2)**
- 🔴 **同版改正一处已成假话的旧口径**:v0.81 写的「`request_think` **尚未接线** ⇒ `self_wake` 与 4 条审计动词在生产里不会出现」**已过期** —— 入口工具**已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`;启动日志自证 `self wake tool registered tool=request_think`)⇒ **它们在生产里会真的出现**。§4.10(d) 与 §7.1 已同步改。
- **③ `browser` 插件的管理面板与六个动作(切片 Y4;design/28 §8 B4)**:`GET /api/v1/admin/panels` 的目录新增 **`browser` / `settings`**(「浏览器与截图」,`route = plugin/browser/settings`,4 个区块 = 1 个 `form` 配置 + 3 张只读 `table`),该插件声明 **6 个动作**(3 读 `plugin.getBrowserState` / `plugin.listShots` / `plugin.getFingerprint`;3 写 `plugin.saveBrowserConfig` / `plugin.shootPage` / `plugin.checkFingerprint`),**全部已登记生产处理器**,**不开任何新路由**(走既有 A14 通用动作分发)⇒ **§2.1 的 14 与总数不因本项变化**。保存 = **落盘 + 热重载**(重建工具 + 原子替换注册表条目 ⇒ 下一轮生效);保存前**本来有**可用工具而新配置让它不可用 ⇒ **回滚文件** + **`409 config_rejected`**;保存前**本来没有**可用工具 ⇒ 文件留下 + `200` + `applied:false` + `reason`(**不报错**,否则主人没法先配好)。🔴 **读面绝不启动浏览器**(面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界);真正会起浏览器的只有 `plugin.shootPage` / `plugin.checkFingerprint` 两个写动作(`plugin.saveBrowserConfig` 的「热重载」只在**重建工具**时按配置探测可执行文件)。**§4.14(d)**
- **③ 附:模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`,配一句如实的 `hint`)—— 让**模型与面板**都能看出「这张图到底进没进上下文」,而不是只在日志里。⚠️ **`queued` 与「进上下文」之间还有一环**:`loop.Options.TransformContext` 上挂的装配钩子(`cmd/cyrene/wire_browser_context.go`)负责**取走即消费**地把它并进本轮视图。
- **行为变更**:**无**(既有键逐字未改;新增的都是**可选键 / 新端点 / 新声明**)。
- **破坏性**:**无**(`resultSummary` 是新可选键,老客户端不读即行为逐字不变;`?trigger=` 缺席即不过滤;`GET /admin/panels` 多一条目录项 —— 按 `pluginIds` 差集渲染「未声明管理页面」的客户端零影响)。
- **计数**:`writeAuth` 段 **18 → 19**(新增 B19 `GET /admin/backthink/self-wake`)、管理路由总数 **72 → 73**(`14 + 19 + 40`);§2.2 行数 **B1–B19**。**本版无其它新路由**(③ 走既有 A14)。
- ⚠️ **诚实边界**:① **①③ 的形状取自「工作树实现」** —— 登记时 ① 的「失败也带」(`internal/runtime/loop/tool_frame_summary.go`)与 ③ 的**两个组合根挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)**都还在工作树、尚未提交** ⇒ **提交前生产里没有这些行为**;提交后请按 `git show` 逐字复核(见 §7.4-30 / §7.4-31);② ① 的**真机**(`:10942` 发布产物)端到端**未跑**;③ ② 的**真机面板端到端未跑**(面板写路径**未做**,只有读侧);④ ③ 的**真机 e2e 未跑**(既有 `wire_browser_e2e_test.go` 默认 `Skip`,需要真 Chrome + 可执行文件路径)。
### v0.81(上一版)
**一批增量 + 两处真缺陷修复 —— ① 她自排轮(`self_wake`)的用户可见面(形状登记;生产暂不可达)|② 时间轴新增 `retentionBoundaryReached` 并与 `truncated` 分意(切片 X1)|③ `round` 跨重启不再重复(切片 X1 的行为修复)**(依据:`design/12 §N C` 的自排切片 C1-1 `94da6bc` / C1-2 `ed50053`;`design/19 §7` 时间轴的保留期诚实口径与 §6.1 的「`round` 每会话单调」;`AGENTS §9.5` 的对照要求。)
- **① 她自排轮的用户可见面(切片 C1-1 / C1-2)**:`GET /api/v1/admin/backthink` 的行 **`trigger` 新增取值 `"self_wake"`**(= 她自己排起的一轮思考;`trigger` 的取值域**不封闭**:`heartbeat` / `startup` / 事件主题 / `self_wake`);**新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`(`who` 恒 `backthink`,经既有 B9 `GET /admin/observability/audit` 可查,`result` 是可直接显示的中文)。**无新端点、无新字段表**(两处均为取值域的**加性**扩张)。边界:自排**只保证「她被叫醒思考」**,要不要真发出去仍由既有 `decide` 级联 + `PushGate` 决定(安静时段/限频/冷却照生效);每窗口上限(默认 **4 次/小时**)**不在契约里冻结**(**不新增 env 键** —— `.env` 已冻结)。🔴 **可达性**:**她的入口工具 `request_think` 尚未接线**(下一片在办,`thinktool.go` 尚未提交)⇒ **该取值与这 4 条动词在生产里目前还不会出现**;本版**登记形状**,客户端不得当活跃数据依赖。**§4.10(d)**
- **② 时间轴的保留期信号落地 + `truncated` 分意(切片 X1)**:`GET /api/v1/admin/flow/timeline` 的响应新增 **`retentionBoundaryReached`**(bool)—— **`true` = 该会话确有更早的轮次已被保留期清扫删掉,且本次已取到它现存最早的一端**。判据三条**缺一不可**:① `retentionEnabled:true`;② `hasMore:false`;③ 现存最早一轮的**轮次号 > 1**(轮次号每会话自 1 起、跨重启单调 ⇒ 号更小的轮次曾落盘、又不在库,而 `flow_graphs` 的唯一删除者是保留期清扫)。⇒ **`truncated` 从此只表示「本次不是全部」**(分页未到底 / 落盘源未接线),**不携带**清扫信息;面板说「更早的轮次已过保留期」的**唯一**判据是 `retentionBoundaryReached:true`。⚠️ **盲区(写进契约,不掩盖)**:整段历史都被清扫 ⇒ `false`(**少说,绝不多说**);有轮次号未知的老行 ⇒ `false`;最早那一轮**落盘失败**(号已用掉、行从未进库)⇒ 可能**错报**为 `true`(概率低,只在库写失败时出现)。**§2.2 B18 / §4.11.1(a)(b)-3**
- **③ `round` 跨重启不再重复(切片 X1 的行为修复)**:轮次号原本只是发布器**进程内**计数 ⇒ 重启就从 1 重来,而 `flow_graphs` 保留 168h ⇒ 时间轴按轮次号分组时把**重启前那一轮**与**重启后那一轮**并成**一块**(真机实测 `session=web`:同一分组键下三次真实轮被并成「第 1 轮 · 3 路」)。修法 = 发布器**本进程第一次**给某会话发号前,从落盘侧读一次**已落盘的最大轮次号**并播种计数器(`flowgraph.MaxRound`),同一会话的并发首轮会等这一次播种。⇒ 契约里「`round` 每会话单调」这句**真的成立**。⚠️ 边界:播种是**尽力而为**(读库失败只 WARN 并退回进程内计数、**不重试** ⇒ 仍可能重复,客户端此时应以 `startedAt` 为准);库里为空 / 历史被整段清扫 ⇒ 从 1 起。**§4.11.1(e)**
- **行为变更**:**有(但都是「变真」而非「变形」)**:① 两处取值域**加性**扩张(只显示 `trigger` 原文的客户端零影响);② ③ 是**行为修复**(重启后轮次号不再重复 ⇒ 分组不再并块);② 是**字段加性**扩张 + 对既有 `truncated` 的**读数收紧**(此前把它当清扫信号读的客户端会发现自己原来读到的是假话)。
- **破坏性**:**无**(无键被删、无键被改义;`truncated` 的**定义**未变,变的是「不得拿它当清扫信号」这句口径)。
- **计数**:**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 B1–B18 共 **18** 条;②③ 都是往既有 B18 的响应/行为上加东西)。
- ⚠️ **诚实边界**:① 自排的**端到端生产验证未做**(入口工具未接线,见 ①);② **②③ 的形状与判据取自切片 X1 的「工作树实现」**(本版登记时该切片**尚未提交**,`f1fbc24` 之后仍在工作树)⇒ **commit 后请按 `git show` 逐字复核键名与判据**;③ ② 的三条盲区**没有测试能覆盖到「整段被清扫」那一支**(属设计登记的诚实边界);④ 时间轴**真机面板端到端**仍未跑(同 v0.80 ①);⑤ 同波其余切片(技能渐进披露 L2 `6475eb8`、示例技能 V3 `4a04ad4`、浏览器 B1/B2 V5 `f1fbc24`)**对客户端零形状影响**,故未逐条登记。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面,见 §4.14(d)。
### v0.80(上一版)
**三批增量 —— ① 链路图时间轴出口(切片 W1)|② 搜索 provider 热重载 + 主 provider / 降级链(切片 W3)|③ 入站语音 AMR 也可回放(切片 W4)**(依据:主人 2026-10-06 要「web 后台的全链路流程图 = **所有消息按时间顺序串起来**,而不是只有最新一个来回」;面板的意义就是「不改 `.env` 不重启」⇒ 配置面必须真热生效;微信 / QQ 的 AMR 入站语音不落副本 = 无法回放 / 多端同步。)
- **① 链路图时间轴出口(切片 W1,`design/19 §7`)**:新增 1 条 `writeAuth`(owner\|admin)**只读、不审计**端点 **`GET /api/v1/admin/flow/timeline`**(`?sessionId=&limit=&before=`)—— 把多轮链路图**按时间正序**返回,每轮 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形)。数据源 = **已落盘的 `flow_graphs`**(迁移 `0022`/`0023`,保留期 `FLOW_GRAPH_RETENTION` 默认 168h)**+ 内存里的最近一轮**(在飞 / 刚落盘失败的那一轮);`limit` 缺省 **50** / 硬上限 **200**(超上限钳住、`≤0` 同缺省、非整数 `400 bad_request`);`before` 游标取「开始时刻**严格早于**它」的轮次、响应回带 `nextBefore`(**原样回带即可连续翻页**,分页不重不漏);响应另给 `retentionHours` / `retentionEnabled` / `truncated` 供面板**如实**标注「更早的轮次已过保留期」。**未接线(内存 + 落盘都没接)⇒ `500 flow_unavailable`**,**绝不回空 `rounds`**(不假装「这个会话没有轮次」)。权限矩阵:`writeAuth` 段实有 **18** / 总数实有 **72**(**本版同时回补了自 v0.76 起的 −1 记账滞后**,见 §2.0)。🔴 **老端点 `GET /admin/flow/graph`(B13)逐字未改**(`admin_flow.go` 零改动,`routes.go` 只加一行路由 + 注释)⇒ **老客户端零影响**。**§2.0 / §2.2 B18 / §4.11.1**。
- **② 搜索 provider 热重载 + 主 provider / 降级链(切片 W3;裁决 2026-10-06 #1 / #2)**:`plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(新增窄方法 `registry.ReplaceTool` —— **不是** `Remove`+`Register`:中间无空窗,且**保留运行期策略**)⇒ **下一轮对话即用新配置,不需要重启**(design/09 §5.5)。**保存是一次事务**:快照文件 → 落盘 → 重建 → 换条目;③ 失败 ⇒ **文件按字节回滚**(temp+rename)+ **`409 config_unstartable`**(会 make-下次起不来的配置绝不留在盘上);回滚也失败 ⇒ 如实 `500 config_write_failed`。响应新增机器可读事实 **`hotReload:{applied,registered}`**。声明面**增补两个文件级字段** `primary`(`select`)/ `fallback`(`multiSelect`,候选来源 `plugin:providers`)—— **缺位 = 不改动**;`primary` 给空串 / `off` / `none` = **关掉这把工具**(与 `WEB_SEARCH_PROVIDER` 共用同一套关闭语义);点名「已登记但未配齐」的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒);`fallback` 元素未登记 ⇒ `400 unknown_provider`、形状错 ⇒ `400 invalid_payload`。`plugin.reauthProvider` 的 `values` **追加** `primary` / `fallback`(否则面板「清空降级链」在客户端三态写回里清不掉)。**新增错误码**:`primary_not_configured`(400) / `config_unstartable`(409)。🔴 **本版改正三处已成假话的旧口径**(此前写「面板保存后**不**热替换运行中的工具、需重启」、「面板**无**主 provider / 降级链字段」):§4.14(b) / §4.14(c) / §7.2 / §7.4-26 / §7.4-27 已改。⚠️ **触发面只有本动作**:直接改写可变源文件(不经面板)**不**触发热重载,仍需重启。**§4.1 / §4.14(b)(c) / §7.2**。
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
### v0.79(上一版)
**四批增量 —— ① 插件面板动作真可用(切片 L1/PLG-2 的执行面接线)|② 「入库前审查」(D3)管理面落地(切片 L45)|③ 语音消息的用户可见面落地(切片 L45)|④ 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2,**唯一一处形状变化**)**(依据:面板动作此前一律 501 ⇒ 面板「点了没反应」;主人要「入库前审查可查、可回滚」;安卓/Web 要「语音能回放、消息能标出语音来源」;`toolCalls[]` 覆盖率此前只到真并行路 ⇒ 普通单路聊天与队首续跑回复回看不了工具。)
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
### v0.78(上一版)
**六面增量 —— ① 消息读取面 `toolCalls[]` 落地(覆盖率受限)|② 插件管理面板接入点(`GET /admin/panels` + 插件动作分发端点 + `panels_changed` 帧)|③ 搜索 provider 可变源(`web-search` 插件)|④ 账号上下线落事件 + 渠道事件实时帧 `channel_event`|⑤ 微信 iLink 凭据三态(`authState`/`authDetail`)|⑥ 语音消息落库面(迁移 `0020`)**(依据:安卓侧要「回看这条回复当时调了什么工具」;design/09 §5.2.1 的插件面板接入点 + §5.4 的安全与权限;design/27 的网络搜索提供商 + design/13 §2.1 的插件配置落点;design/26 §2.6/§4.5 的账号状态事件与实时面;`errcode=-14` 被读成「过期」的误判风险;design/24 §10 的语音落库面。)
- **① 消息读取面 `toolCalls[]` 落地(切片 CODE-TC)**:`GET /api/v1/sessions/{id}/messages` 的消息对象新增**可选** `toolCalls`(`omitempty`),每条 `{name,status,durationMs?,argsSummary?,resultSummary?}`(`name`/`status` 恒在,`status ∈ {ok,error,unknown}` 闭集三态;后三键图上给不出时**缺席**)。数据源 = **已落盘的轮次链路图** `flow_graphs`;🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**,**绝不回 `[]`**(客户端只做正向断言)。**为打通读侧补齐**:迁移 `0023_flow_graphs_lane_turn_id` 给 `flow_graphs` 加**按路查询键** `lane_turn_id`(等值索引 `idx_flow_graphs_lane_turn_id (session_id, lane_turn_id)`)+ 回填;`FlowGraphStore` 增只读方法 `GraphsByTurnID`(一页一次 `IN` 批量查,禁 N+1);`history.go` 的 `handleListMessages` 在编码前调 `attachToolCalls`。**只在该出口填充**(其余 DTO 出口逐字不变,有单测钉住)。⚠️ **覆盖率受限(如实,v0.78 时)**:只有**真并行路**的图带权威 `turnId` ⇒ **普通单路回合的回复查不到图、`toolCalls` 缺席**(补齐在 `internal/runtime/loop/**` 写侧,**不在本版**)。→ **✅ v0.79 已补齐**(切片 CODE-TC-2:编排器驱动的每一路都带权威 `turnId` ⇒ 覆盖率扩大到**全部编排回合**;见 §6 v0.79 ④)。**§4.4 / §7.2 / §7.4-23 / §2.2 B13**。
- **② 插件管理面板接入点(切片 PLG-2,纯增量为主)**:新增 1 条 viewer 读端点 **`GET /api/v1/admin/panels`**(**按调用者角色过滤**后的接入点快照 `{role,groups[],pluginIds[]}`,viewer 只拿只读区块;未接线 ⇒ `501 panels_unavailable`)与 1 条动作分发端点 **`POST /api/v1/admin/plugins/{id}/actions/{action}`**(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 owner/admin 收紧 ⇒ 未授权 `403 forbidden`;停用插件 ⇒ `409 plugin_disabled`;**每次结算都落审计** `what="plugin.action"`;**插件只给动作名、不给 URL** ⇒ 不开 SSRF 面);WS 下行帧 **`panels_changed`**(`{reason,pluginId}`,`reason ∈ {declare,install,uninstall,enable,disable}`,**只发 owner/admin**,**不带内容**⇒ 客户端按角色重拉快照)。权限矩阵:`viewerAuth` 12 → **14**、总数 66 → **68**。🔴 **v0.78 时生产路径硬事实**:**无任何生产动作执行器** ⇒ **当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。**§2.1 A13/A14 / §3.4.5 / §4.14(a)(b)**。
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
- **破坏性**:**无**。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
### v0.77(上一版)
**链路图按路分桶(FV2)+ 记忆整理护栏抬高 / 诚实截断 / 试算异步落地(MT-A)+ 「入库前审查」env 键登记(MT-B)—— 三批都是纯增量**(依据:主人 2026-10-05 报「并行两路时流程图互相顶掉」、以及「工具回看面要先让链路图带上按路的回合标识」;「护栏被打满 ⇒ 候选数由上限而不是规则决定」要治;`design/23 §15.1 D3` 的入库前审查上线)。
- **① `graph_update` 按路分桶(切片 FV2)**:帧 `data` 新增**可选** `turnId`(这张图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳同值)与 `round`(会话轮次号);图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ **单路行为逐字不变**),因此真并行时**每一路各发自己的帧、各落一行**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 不再把另一路整行顶掉)。`GET /api/v1/admin/flow/graph` 响应新增 **`lanes[]`**(最近一轮每一路的图 `[{turnId,graph}]`,按路开始时间升序,恒为非 null 数组;`graph` 仍是「最新一路」⇒ **老的读取方零改动**),前端在 ≥2 路时按**并列泳道**呈现 —— **§2.2 B13 / §3.4.5 / §4.11(a)(d) / §7.2 / §7.4-19**。⚠️ 留白:`turns[]` 仍按**路**列条目(一轮两路显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路。
- **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 MT-A)**:每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认 **`20 → 200`**;两两比对上限从**写死的 `400`** 改为 env 键 **`MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`)**;配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(被**墙钟时间预算**截断:整分区跳过,或两两比对中途收手 —— 成因是**本次调用的时间上限**而不是配置配额);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询:并发**复用既有 `runId`**、与同步**同一个 3 分钟**墙钟、`done` 的 `result` 与同步 `data` **逐字节同形**、`running` 时 `result`/`error` **双缺席**、重启后旧 `runId` 如实 `404`)—— **§4.10(a) / §4.12 / §4.12.1 / §5**。
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **默认值已由 v0.93 改为 `90`** —— 键名与文件内容未动,只是代码侧默认值,见 §6 v0.93)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
- **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。
- **破坏性**:无。
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
### v0.76(上一版 · ⚠️ **逐条状态不同 —— ①已由 v0.78 实现(覆盖率受限);②③已实现**)
**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **✅ 已由 v0.78 落地**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(**已由 v0.77 落地校正**)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。
- **① 工具回看面(✅ 已在 v0.78 实现 —— 见 §6 v0.78 ① 与 §4.4;**覆盖率受限**)**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 补迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID`,进程内递增的建图序号)⇒ **无法按消息 join 到它那一轮的图**。⚠️ **v0.78 校正(切片 CODE-TC)**:v0.77 已给链路图加**按路的回合标识**,v0.78 又补上**按路查图的读侧入口**(迁移 `0023` 的 `lane_turn_id` + `GraphsByTurnID`)⇒ `toolCalls[]` **已实现**;但🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**:编排器驱动的每一路都带权威标识 ⇒ 覆盖率扩大到**全部编排回合**(见 §6 v0.79 ④ / **§4.4**、§7.2、§7.4-23)。
- **② 未读面(纯增量 · ✅ 已实现)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**只在 >0 且确实算得出来时**出现;**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项同键(**该键自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。形状、缺席语义与三种缺席情形见 **§4.13**;帧表已同步(**§3.4.4**)。
- **③ 记忆整理试算加性异步**:`POST …/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`(新增 B15 轮询端点)。**不带 `async` 仍是同步** ⇒ 老客户端(含现有面板)逐字不变。结果活在**进程内存**(TTL 30 min、并发 1、重启即失效 ⇒ 如实 `404`)。⚠️ **v0.77 落地校正**:本项登记的时点标注「契约先行」,**切片 MT-A 已实现**,实现口径见 **§4.12.1**(并发=**复用**既有 `runId`、与同步**同一个 3 分钟**墙钟、`result` 与同步 `data` **逐字节同形**、新错误码 **`tidy_run_not_found`**)。
- **行为变更**:无(②只在既有帧里加可选键;③只在显式带参时改变路径;①未落地 ⇒ 无变更)。
- **破坏性**:无。
- **⚠️ 迁移号预告**:本版实现若需新迁移,编号从 **`0023`** 起(`0020` 预留给语音切片、`0021`/`0022` 已用)。
### v0.75
**记忆整理「试算」端点 —— 新增 `POST /api/v1/admin/memories/tidy/dry-run`:跑同一套整理规则、把候选直接回在响应里,且一行都不写**(依据主人 2026-10-05 要求「现在就想看到在 736 条上它会建议合并哪些,而不等到 23:00」;对齐 `design/23 §4`「低峰定期后台任务 + 手动可触发的只读试算」)。
- **纯增量**:新增 1 条 `writeAuth`(owner/admin)管理的**只读语义 POST**(§2.2 B14 / §4.12)。既有端点、既有帧、既有 env 键语义**一字未改**。
- **同版追加(无形状变更)**:`GET /api/v1/admin/config-facts` 的事实表由 9 条扩到 **12 条** —— 并入 `ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / **`THINK_QUIET_INTERVAL_SEC`(安静窗口内心跳周期,默认 `3600`;`≤0` = 关闭降频)**,支撑 design/12 §N「静默时段降频」(§2.2 B12 / §4.10(a))。`facts[]` 形状与 `quietHours` **一字未改**,只是多了 3 个元素 ⇒ 老客户端零影响。
- **零副作用是硬约束**:端点走的是与低峰定时轮**共用**的规则路径(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限),但**候选不落库** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测钉死(另有一条断言「试算候选 == 真跑一轮落库的候选」)。
- **失败绝不伪装成「没有建议」**:未接线 → `500 tidy_unavailable`、`MEMORY_TIDY_ENABLED=false` → `409 tidy_disabled`、语义召回(embedding)不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`、其余内部失败 → `500 tidy_dry_run_failed`。**不回** `candidates:[]` 来表示失败 —— 空数组唯一含义是「算过了,这一轮没有建议」。
- **试算专属的严格**:语义召回不可用时,**试算报错而不静默降级**到词项余弦(否则面板会把「语义路径没跑起来」读成「没有候选」)。低峰定时轮与手动轮的降级口径**不变**。
- **不审计、不调度**:它是只读计算(不写审计链),**不受安静时段约束**(窗口只是心跳调度口径),也**不改变**心跳节奏与周期。
- **行为变更**:无。
- **破坏性**:无(老客户端不发这个请求就零影响)。
- ⚠️ **本版不做**:apply / reject / revert 候选的**写**端点仍是 `design/23 §11` 的未实现项(见 §7),面板当前只做「看建议」。
### v0.74(上一版)
**IM 路由的主动消息也补浏览器通知 —— `message_persisted` 新增可选结构化判据 `proactive:true`**(依据主人 2026-10-05 裁定 C 的必然推论「她说话该不该提醒我,与走哪条路由无关」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
- **纯增量**:
① **`message_persisted` 的 `data` 新增可选键 `proactive:true`**:IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧 —— 帧类型、角色门、`message` DTO 形状**一字未改**,只多一个布尔键(**§3.4.4**)。
② 客户端据此走与 `proactive_message` **同一套**提醒逻辑(非前台补一发通知;去重键同为 `message.id`)。老客户端不读该键即行为与 v0.73 完全相同。
- **修的是什么(根因)**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由此前只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。这就是 P0(v0.72)如实报回的边界。
- **判据是结构化的**:主会话落库行的 `platform`/`adapter_id` **区分不出**主动推送与普通回复(主动推送写的是解析后的真实平台,例如 `weixin-ilink`,与普通回复行同一形状)⇒ 判据放在**落库那一刻的通知通道**上(`gateway.BroadcastProactivePersisted`),随帧顶到达客户端。**绝不**读正文关键词、**绝不**用提示词/模型判断(项目红线:身份与判定靠元数据)。
- **不误伤**:普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口 `BroadcastPersisted`,那条路径**永不**放该键 ⇒ 客户端不可能把普通消息误判成主动消息。客户端只应做 `data.proactive === true` 的严格相等判断。
- **行为变更**:无(普通镜像帧照旧发一条;主会话里仍**恰好一条**行,P0 的「不双份」红线未动)。
- **破坏性**:无。
- ⚠️ **诚实边界**:① 判据**不落库**(不改 schema)⇒ 刷新后从历史读回的行不带它(预期:通知只对实时到达有意义)。② **落库失败时不发判据帧**:没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃,发它等于假装提醒过 —— 那种情况下只记 WARN。③ 「她主动说的话」在三类帧上到达的完整清单见 §3.4.4 与 §3.4.5。
### v0.73(上一版)
**链路图本地落盘 + 多轮历史回放 —— 图从「只活在进程内存」变成每轮一行落进本地库,`GET /api/v1/admin/flow/graph` 增量支持按 `turnId` 取历史轮**(依据主人 2026-10-05 追加要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」—— v0.71 只做了其中最小一步)。
- **纯增量**:
① **落盘**:每轮收尾(`EndRound`)把该轮的图写进新表 `flow_graphs`(迁移 `0022`),**每轮一行**(不是每 span 一行);只落**发布会话白名单内**的会话(复用 `flowSessionAllowed`,与实时发布同一判据)。新 env 键 `FLOW_GRAPH_RETENTION`(默认 `168h`)/ `FLOW_GRAPH_GC_INTERVAL`(默认 `1h`),GC 口径照 `events`/`documents`(启动期扫一次 + 定时 + 失败不退出;`≤0` = 关闭;**不许无限增长**)。
② **`GET /api/v1/admin/flow/graph` 新增 `?turnId=`**:省略 = 最近一轮(**先查内存 `LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远最新);给了 = 精确取该落盘轮。
③ **响应新增三键 `turnId` / `source` / `turns[]`**:`source` ∈ `live`/`persisted`/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮 —— `graph`/`sessionId` 两键的既有语义一字未改。 —— **§2.2 B13 / §4.11**。
④ 前端:图桶 `flowGraphs` 纳入 store 持久化(刷新即刻有图)+ 历史轮次选择 UI + 诚实文案(白名单外会话永远没有图;未跑新轮次前的旧轮次靠落盘)。
- **行为变更**:无(`graph_update` 帧形状、角色门、发布会话范围全部未改;`graph`/`sessionId` 语义未改)。
- **破坏性**:无(新查询参数可选、新响应键可选;老客户端不读即行为与 v0.71 相同)。
- ⚠️ **诚实边界**:落盘只覆盖**保留期内**的轮次(默认 7 天)—— `turns[]` 不是全历史;`graph_update` 两路互顶的既有裁决**本版未动**(§7.4-19)。当前仍**没有图**的轮次见 §7.4-20。
### v0.72(上一版)
**主动推送可见性 —— 主动消息成为主会话里的一条真实消息,并给帧补上权威行 id**(依据主人 2026-10-05 报「后台思考的主动推送一个都没推出来」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
- **行为变更**:
① **`proactive_message` 那条消息现在会落进主会话历史**(`web`、`role: assistant`、`mode: "chat"`、`turn_id` 留空)。此前后台思考走**活跃客户端路由**时只广播 WS 帧、**一行都不写** ⇒ 刷新/重载后既不显示气泡、也补不出历史;那句 `delivered to 2 active client(s)` 只表示「帧被 2 个连接收下」,**不代表进了会话**。顺序改为**先落库、再发帧**。
② **帧新增两个可选键 `id` / `createdAt`**(同现/同省):这条消息在主会话历史里的权威行 id 与落库时间戳。客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后由历史行接续)。**落库失败时两键缺席** —— 客户端不应把「没有 id」当成一条可持久化的消息。
③ **没有在线 owner/admin 连接时也落库**:推送器如实回 `pushed=false`(没有活跃客户端),但那句话仍作为真实记录留在主会话里。
④ **后台思考记录里的 `PushResult.Detail` 措辞**:活跃客户端路由改为 `delivered to N active client(s), persisted to main session`(未落库时写明 `not persisted…`,无在线客户端但已落库时写明 `no active client, recorded in main session`)—— 面板因此不会把「帧发出去了、刷新就没了」误读成「已推送」。
- **纯增量**:帧的形状只在**新增可选键**;落库行的形状就是既有 `historyMessage`(`§4.4`),因此既有读取面(`GET /sessions/web/messages`、握手 `history` 回放、`message_persisted`)**逐字不变**。
- **破坏性**:无。老客户端按未知键忽略 `id`/`createdAt` 即可;但它会把主动消息**多看到一次**(历史行)—— 这是本版的目的,不是缺陷。
- **不双份(已验证)**:两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端;其余先 IM 桥、IM 投递失败才降级活跃客户端),而 IM 路由本就把消息镜像回主会话 ⇒ 同一条主动消息在主会话里**恰好一条**。契约细节见 §3.4.5 后的「`proactive_message` 的落库口径」。
- ⚠️ **诚实边界**:被频率门(`PushGate`)拒绝的推送**不落库**(限流就是「这一条不发」,绝不留下一条它没发过的记录)。
### v0.71(上一版)
**链路图补版 —— 新增只读端点 `GET /api/v1/admin/flow/graph`,让「轮次结束后才打开的页面」也能看到最近一轮的链路图**(依据主人 2026-10-05 第二次报「`/#/admin/flow` 停在暂无流程图数据 + 后台思考行提示该会话还没有 graph_update 快照」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」里的最小一步)。
- **纯增量**:
① **新 admin 只读端点 `GET /api/v1/admin/flow/graph?sessionId=<id>`**(`owner`/`admin`,`?sessionId=` 省略 = 主会话 `web`):返回 `{data:{sessionId,graph}}`,`graph` 与 `graph_update` 帧的 `data` **逐字同形**;该会话还没有图 ⇒ **如实 `null`**(绝不画假图);图源未接线 ⇒ `500 flow_unavailable`(**不假装「这一轮没有图」**)—— **§2.2 B13 / §4.11**。权限矩阵随之更新:`writeAuth` 12 → **13**,总数 64 → **65**。
② §3.4.5 `graph_update` 行补一句「**纯实时、不重放**」并指向本端点。
③ §7.4 新增两条诚实边界:**「最近一轮」≠「你要的那一轮」**(后台思考行上的图可能不是该行那轮;面板必须在图上说明)、**图源是进程内内存**(后端重启后如实回 `null`)。
- **修的是什么(根因)**:`graph_update` **只走实时帧**,前端图桶 `flowGraphs` **不持久化**(`store/persistOptions.ts` 的 `partialize` 不含它),且此前**没有任何 REST/WS 补拉通路** ⇒ 页面在轮次结束后打开、或刷新过页面,就永远空着。本端点补上唯一缺的那一环。
- **行为变更**:无(既有端点/帧一字未动)。
- **破坏性**:无(新端点;`graph_update` 帧形状、`frameReachesAll` 角色门、发布会话范围(裁决 01.4.5)**全部未改**)。
- ⚠️ **诚实边界**:只回**每会话最近一轮**;**多轮历史回放**(按 turn/时间点取任意一轮)**仍未实现**(`design/19 §4` P3)—— 见 §7.4-18。**`graph_update` 两路互顶**的既有裁决**本版未动**(§7.4-19)。
### v0.70(上一版)
**后台思考行的紧急度精确化 —— `urgency` 默认即精确档位,另加 `urgencyLowerBound` 标记下限**(依据主人 2026-10-05 裁定「要**精确**」;P4 只能给下限,本版把精确值送到两面:管理 REST 与实时帧)。
- **纯增量**:
① `GET /api/v1/admin/backthink` 条目的 `urgency` **默认语义改为该轮实际生效的精确档位**(backthink 在档位判定点随记录落库,含她自报紧急度上抬后的结果)—— **§2.2 B8 / §4.10(b)(c)**。
② 新增**可选** `urgencyLowerBound:true`(只在为 `true` 时出现):标记该 `urgency` 是**旧行的下限推导值**而不是精确档位(旧行 = `urgency` 列出现之前的行,以及从未走到档位判定点的行)。客户端据此区分「精确档位」/「≥ 下限」/「未知」三态。
③ **实时 `backthink` 帧带上记录里的 `urgency`**(精确值)⇒ 本次会话新到的行不必等重开页面就有档位。⚠️ 实时帧仍不带 `urgencyLowerBound` / `quietHoursBypassed`(两者由网关按安静时段窗口推导,不在记录里)—— 更正 §4.10(b) 原「实时帧不带这两个键」的表述。
④ §7.4-17 补「客户端不得把『下限』显示成精确值、不得把缺席显示成『例常』」。面板三态文案与悬停说明见 `frontend/web/src/admin/modules/backthinkUrgency.ts`。
- **行为变更**:`urgency` 的**读法**变了(v0.68/0.69 一律读作下限;v0.70 默认读作精确值,唯有带 `urgencyLowerBound:true` 才是下限)。取值域(`routine`/`warning`/`critical`)与 `quietHoursBypassed` 语义**一字未改**。
- **破坏性**:无(新键可选;把 `urgency` 当「下限」显示的老客户端最坏是显示得比事实保守,不会谎报精确值)。
- ⚠️ **诚实边界**:旧行(本列之前落下的)**没有**精确档位可显示 —— 它们仍走下限额推导并带 `urgencyLowerBound:true`;未知仍是未知(不编值)。见 §4.10(c) / §7.4-17。
### v0.69(上一版)
**`directMsgPerMin` 的 gateway 写入路径修复**(切片 P1-T;缺口 = 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分)。
- **纯增量 / 缺口修复**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它 —— §2.2 A5 行 / §2.3 C28 注 / §7.2。
- **行为变更**:无(对不带该键的老客户端,行为与 v0.68 相同)。
- **破坏性**:无。
- ⚠️ **诚实边界**:账号级限流字段仍是**值语义**(请求体省略 ⇒ `0` = 未设置),要保住已设的值必须回送 —— 见 §7.3。
### v0.68(上一版)
**管理面「配置事实」只读端点 + 后台思考行的紧急度/穿透字段**(依据主人指正「你多久没把新功能同步管理面板更新了?」与裁决 14;**本版全部已实现、非契约先行**)。
- **纯增量**:
① **新 admin 端点 `GET /api/v1/admin/config-facts`**(`owner`/`admin`,只读、不审计):返回 9 条配置事实(记忆整理 7 键 + `QUIET_START_HOUR`/`QUIET_END_HOUR`)的**进程生效值**(`value`)+ **声明默认值**(`default`)+ `isDefault`,外加 `quietHours{startHour,endHour,disabled,label}` 的窗口解释 —— **§2.2 B12 / §4.10(a)**。**不含任何凭据值**(连凭据类键的「是否已配置」都不回)。权限矩阵随之更新:`writeAuth` 11 → **12**、总数 63 → **64**。
② `GET /api/v1/admin/backthink` 的条目增 2 个**可选**字段:`urgency`(`routine`/`warning`/`critical`)与 `quietHoursBypassed`(`true` 才出现)—— **§2.2 B8 / §4.10(b)**。语义:`urgency` 是**存档可证的下限**(`warning` = 至少 `high`),`quietHoursBypassed` = 「这轮在安静时段里仍被放行发出去」。
③ §5 登记 9 个键的默认值与语义(此前只以 `THINK_*`/`QUIET_*` 概括)。
- **行为变更**:无(既有两个端点的既有键一字未改;新字段都是可选键,老客户端不读即零影响)。
- **破坏性**:无。
- ⚠️ **诚实边界**:后台思考的**精确**档位(`high` 还是 `critical`)在存档里**已丢失**(`backthink.Record` 不带重要度,推送成功的行其 `detail` 被投递说明覆盖)⇒ 本版只下发**可证下限**,窗口外的轮次干脆不下发(面板显示「未知」),**绝不猜**。见 §4.10(c) / §7.4-17。
### v0.67(上一版)
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
- **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`(🔴 **v0.98 增 `tts`** = 她的出站合成语音,含 `fallbackReason` 降级标记与判据变更,见 §4.4 / §6 v0.98),`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在;**降级行四键整体缺席**);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
⑥ 新 admin 端点 **`GET /api/v1/admin/channels/events/counters`**(逐类计数汇总 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`)—— §2.4(a);未接线存储 ⇒ **`500 channel_events_unavailable`**(新增错误码,**绝不回全 0 假汇总**)。**已落地**(`approx` 与 `counters` 同级;`platform` 未知 ⇒ `400 bad_request`)。
⑦ `GET /api/v1/admin/channels` 的 `accounts[]` 增**可选** `accountOnline`(**账号在平台侧是否在线**,与 `connected`(适配器/WS 是否连着)**是两件事**;**键缺席 = 状态未知 ≠ 离线**)—— §2.4(b)。**已落地**(管理面按可选接口断言读 `AccountStatus()`;`known=false` 或适配器无该状态面 ⇒ 键缺席)。
⑧ 新 env 键 3 个:`CHANNEL_EVENT_RETENTION`(`720h`)/ `CHANNEL_EVENT_GC_INTERVAL`(`1h`)/ `CHANNEL_EVENT_COUNTER_FLUSH`(`30s`)—— 已在 §5 登记(本切片**不新增**语音相关 env 键;语音上限/保留期复用既有常量与 `DOCUMENT_*`)。
⑨ **WS 协议零变化**:`media` 是 REST 端点;无新帧、无新上行字段。
- **行为变更**:
⑩ **QQ 语音消息的 `content` 不再含 `[语音]` 占位标记**(旧行为 → 移除):今天从正文里认 `[语音]` 的客户端**必须**改为看结构化字段(**`voice` 键存在 = 语音消息**)。⚠️ **这是本版唯一会打到老客户端的地方**(见 §7.2)。
⑪ `GET /api/v1/admin/system/storage` 的 `documents` 段(件数 / 字节)**自此包含语音副本**(音频副本与文档**同表**:`SELECT COUNT(*), SUM(size) FROM documents`)—— **形状不变**,变的只是**口径**(面板文案宜标注「含语音副本」)。
- **破坏性**:无(新端点、新可选字段、新错误码都是增量;`documents` / `/upload` / `/files` / `/audio/*` 的既有端点**一字未改**;WS 零变化)。
### v0.66(上一版)
**并行帧层上线 + 消息回合标识落库 + 渠道按账号操作;并修正 v0.65 的两处过期/写错的陈述**(依据 design/15 §2.5、design/08 §4;提交 `c0dc435` / `d64b26d` + `d804c4e` / `42279b7` / `a918819` / `679cee0`)。
- **纯增量**:
① 新下行帧 **`turn_begin`**(owner+admin,**不进** `frameReachesAll` 白名单):帧壳 `{type,sessionId,turnId}`、data `{kind,messageIds,leg,activeTurns}`,**每起一路(含续跑路)恰好一帧**(`c0dc435`)。
② **`steer_ack` 增帧壳 `turnId`**(data `{messageId,atStep}` 不变)(`c0dc435`)。
③ **上行 `interrupt.turnId`**:仅 `scope:"current"` 生效(其它 scope 按契约忽略);目标路不存在 ⇒ **定向** `{type:"error",data:{code:"bad_request"}}`(复用既有 code),不广播、不动会话(`c0dc435`)。
④ 消息 DTO 增**可选** `turnId`(`omitempty`,空则键省略;同一路的 user 与 assistant 同值)—— 一次覆盖 `GET /sessions/{id}/messages`、`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、WS `message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放(`d64b26d` + `d804c4e`;迁移 `0018`)。
⑤ `POST /admin/channels/open|close` 请求体增**可选** `accountId`;未知 id ⇒ `404 account_not_found`,已配置未注册 ⇒ `409 account_not_registered`;响应增 `resolved` / `accountId`(`42279b7`)。
⑥ `DELETE /admin/channels/{platform}/config` 增**显式** `?empty=1`(与 `?id=` 互斥 ⇒ `400`;无可删 ⇒ `404`);响应增 `empty` / `removed`(`42279b7`)。
⑦ `GET /admin/channels` 的 `accounts[]` 增**可选** `connected`(键缺席 = 状态未知 ≠ 未连接)(`42279b7`)。
⑧ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 `20`;**只约束 IM 入站语音**,Web `voice_input` 不受其约束)(`a918819`)—— 已在 §5 登记。
- **行为变更**:
⑨ **IM 用户消息现在带 `documents`**(同一 DTO、无新字段;0 附件仍**省略**该键)(`679cee0`)—— v0.65 曾预告,本次随该提交真正上线。
⑩ `turn_begin` 的**帧序**:`session_busy`/`pending_drain` → `message_persisted`(user) → `turn_begin`(`messageIds` 必须是已落库的权威 id,故只能在落库之后发)(`c0dc435`)。
⑪ **不带 `accountId` 的 open/close 目标现在是确定性的**:按 `channels.json` 有效配置顺序第一条(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)(`42279b7`)。
⑫ `DELETE .../config?empty=1` 由「被当成 id 缺失 ⇒ `400`」变为**真的删除无 id 条目**(显式化;`?id=` / `?empty=` 语义边界见 §2.3 注)(`42279b7`)。
- **破坏性**:无(新帧对新客户端可见、老客户端忽略即可;所有新增字段皆可选;无既有字段被移除或改义)。
- ⚠️ **本版更正 v0.65 的两处陈述(文档写错,不是代码变更)**:
- **`thinking` 口径**:本文档曾写「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」—— **与实现不符**。`history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 无该字段、`finalizeAnthropicStream` 直接丢弃思考文本)⇒ 该键在**所有**读取面**恒缺席**;思维链的**唯一出口**是实时 `thinking.*` 帧(owner/admin)。已在 §3.4.4、§4.4 按实测改写。
- **v0.65 §7 的「未实现」清单**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **现已全部实现并上线**(`c0dc435`、`d64b26d`+`d804c4e`、`354b08a`),§7 已相应改写。
- ⚠️ **v0.66 补充说明(描述性更正,不动形状/阈值,不升版本号)**:**`chat.image` 的体积判据是「字符串长度」而不是「解码后字节」** —— 旧措辞「内联上限 = 解码后 5 MiB」只对 **base64 / `data:` 形态**成立,且它本身是**换算口径**(`len(image) <= 6990572` 字符,见 §4.5)。**URL 形态不触发 `image_too_large`**(URL 串约 60 字符,必然通过):其天花板在视觉取回层的 **50 MiB**(`io.LimitReader`,**超出静默截断**、不报错);**`POST /api/v1/upload` 自身是 5 MiB 字节硬限**(`413 payload_too_large`)。四处已按实现写清(§4.5 图片、§4.6 响应面、§5 环境变量表、U8 行 + 错误码表)。**无端点 / 帧 / 字段形状变更,无默认值变更**。
### v0.65
**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**(依据 design/15 §2.5,主人 2026-10-05 裁决 B)。
- **纯增量**:① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口同形);② `session_busy`/`session_idle`/`pending_drain`/`session_interrupted` 的 `data` 增 `activeTurns:int`;③ `session_interrupted.data` 增 `cancelledTurns:int`;④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`;⑤ REST 两条新应答形状(同步 `202 {parallel:true,turnId}` / `stream:true` 的 `200 {stream:true,parallel:true,turnId}`);⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s)。
- **行为变更**:⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列,同一 DTO、无新字段);⑧ IM 各渠道语音进上下文(云端 ASR 转写为文本,失败落一行中文诚实提示);⑨ `session_idle` 时点收紧为「所有在途路收尾且 pending 空」(**仅并行时生效**)。
- **破坏性**:无(省略 `parallel` / 不读新键的客户端行为逐字不变)。
- ⚠️ **回填**:上条 ⑦(IM 附件引用落库)与 ⑥(`VOICE_STT_TIMEOUT_SEC`)在 v0.65 冻结时**尚未提交**,已分别在 `679cee0` / `a918819` 落地,见 v0.66。
### v0.64
**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**。
- **纯增量**:新端点 `GET /api/v1/admin/llm/usage`(owner/admin 读)、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`,`≤0` = 关闭清理)。
- **行为变更 / 破坏性**:无(`model` 层新增的是**可选** sink,不注入时行为逐字不变)。
- ⚠️ v0.64 曾写「`llm_usage.session_id` 恒为 `NULL`」,**已由 v0.65 更正**:loop 已把会话 id 播种进运行 ctx 并落库(空串仍映射 `NULL`);但 `GET /admin/llm/usage` **仍不按 `session_id` 过滤/分组**,响应形状不变。
### v0.63
**客户端登记两条落地:`documents[]` 增可选 `reason` + `GET /sessions/{id}/messages` 对不存在会话回 `404 session_not_found`**。
- **纯增量**:① `documents[]` 元素增**可选** `reason`(`omitempty`,只在 `status:"unreadable"` 时下发)。
- **行为变更**:② `GET /api/v1/sessions/{id}/messages` 对**不存在/已删除**会话由 `200 {"data":[]}` 改为 **`404 session_not_found`**(客户端须处理 404)。判据是会话注册表 `chat_sessions`:隔离 `sess-*` 也回 `404`(其消息走 `/admin/channels/{platform}/messages`)。
- **破坏性**:无。
### v0.62
**面板「重置为 env 默认」动作**:新增 owner-only `POST /api/v1/admin/llm/reset-from-env`(body 必须 `{"confirm":true}`)。
- **纯增量**:新端点 + 新审计动作 `llm.resetFromEnv` + 2 个新错误码(`env_defaults_unreadable` / `models_unreadable`),复用既有 `llm_persist_failed`/`llm_reload_failed`/`llm_unavailable`。
- **行为变更 / 破坏性**:无。
### v0.61
**PDF 文本层抽取:白名单第 23 项 `.pdf` + 类型裁决改为 `(扩展名, 嗅探)` 成对裁决**。
- **行为变更**:① `.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取」;② 类型裁决收紧为成对判定(HTML/纯文本改名 `.pdf` ⇒ `415`;PDF 改名 `.txt` ⇒ `415`)。
- **纯增量**:白名单 22 → 23 项(唯一新增 `.pdf`),其余 22 个文本类判定与响应一字未变。
- **破坏性**:无。
### v0.60
**管理侧只读「存储状态」端点 + 面板存储卡接真数据**:新增 `GET /api/v1/admin/system/storage`(**owner-only**、只读、无审计动作)→ `{data:{main,documents,vector}}`。
- **纯增量**:新端点 + 前端新行为;既有端点/帧/字段形状一字未动。
- **行为变更 / 破坏性**:无。
> **更早版本(v0.28 → v0.59)**:见归档 `Docs/archive/api-client.v0.65.md` §5。
> **未实现的契约变更记录**:归档 §5 里 v0.56–v0.59 的多条「未做/诚实边界」在 §7 重新登记。
---
## 7. 在飞切片与「未实现 / 未确证」
> 本节是**诚实边界**:让客户端**不要以为已有**、也不要**误以为没有**。
### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
| 项 | 状态 | 冻结位置 |
|---|---|---|
| 新下行帧 **`turn_begin`** | **已上线**(`c0dc435`) | §3.4.3;`session_events.go`、`session_parallel.go` |
| **`steer_ack` 的帧壳 `turnId`** | **已上线**(`c0dc435`) | §3.4.3;`session_steer.go`(`emitTurnState`) |
| **`interrupt.turnId`** | **已上线**(`c0dc435`) | §4.8;`ws_hub_turn.go`、`session_interrupt.go` |
| **消息 DTO `turnId`**(含 `chat_messages.turn_id` 迁移 `0018`) | **已上线**(`d64b26d` + `d804c4e`) | §4.4;`history.go`、`session_turn_id.go` |
| **写类工具文件级互斥**(读共享 / 读写与写写互斥,被占住回 `ErrFileBusy` 文案给模型) | **已上线**(`354b08a`、`ef3dcb6`)—— **纯服务端行为,无客户端可见契约面** | `internal/runtime/tool/{fileclaims.go,legfile.go,tool.go}`、`loop/tools.go` |
| **保留期清扫的「可分辨键」`retentionBoundaryReached`**(v0.81 新增,切片 X1) | ✅ **已上线(`2bf3b53`)** | 作用:把「更早的轮次**被保留期清扫**」与「分页还有更多 / 本来就没有更早的轮次」**分开**(`truncated` 只答「本次是不是全部」)。形状 / 判据 / 三个盲区见 **§4.11.1(a)(b)-3**;`round` 跨重启单调的同期修复见 **§4.11.1(e)**。⚠️ v0.81 登记时它**仍在工作树**(该版已如实标注);**现已提交** ⇒ 按 `git show 2bf3b53` 复核键名与判据 |
| **她自排的入口工具 `request_think`**(→ 使 `GET /admin/backthink` 的 `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**真的可能出现**) | ✅ **已接线(切片 X2 `2d211c2`)** —— v0.81 登记时的「🔴 未落地」**已作废** | 判定侧 C1-1 `94da6bc` + 自排本体 C1-2 `ed50053`(窗口上限 + 容量 1 待跑槽 + 4 条审计)+ **入口工具 `request_think` 已注册**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,`internal/services/backthink/thinktool.go`)⇒ **生产里会真的产生自排轮**;启动日志自证 `self wake tool registered tool=request_think`。形状见 §4.10(d);⚠️ 自排轮仍要过既有 `decide` 级联 + `PushGate`,**不保证每轮都有**。**读它用了多少额度的出口**见 **§4.10(e-2) / §2.2 B19** |
| **`tool_execution.end` 的 `resultSummary`**(v0.82 新增,切片 Y1 `215eedf`;**「失败也带」在工作树**) | 🟡 **已实现;「失败也带」登记时仍在工作树、待提交** | 形状与缺席语义见 **§3.4.2**。⚠️ 提交前跑着的后端里**没有**该键;提交后请按 `git show` 复核「失败路径给 error 摘要」这条口径(`internal/runtime/loop/tool_frame_summary.go` 的 `toolEndFrameSummary`) |
| **`browser` 面板与截图闭环的组合根挂点**(v0.82 新增,切片 Y4 `e0a6bca`) | 🔴 **登记时未接线**(代码已落库,两个生产挂点在工作树) | 声明与六个动作见 **§4.14(d)**;`wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)` 与 `wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),` 是**登记时**才进工作树的(删掉都**零症状**,各有源码级用例盯着)。🔴 **在跑的那份产物里没有它们**(真机已核:启动日志全篇 0 条 `browser panel declared`)⇒ 那一刻 `pluginIds == ["web-search"]`、`browser` 动作全 `404 action_not_declared`、`turnAttachment` 照报 `queued` 但图不进上下文 |
> ⚠️ **工作树另有未提交改动(v0.82 登记时的实测状态)**:**与本版两个面相关**的在途文件共 **5 个**(此外工作树还有同波其它切片的在途改动,与本契约无关、不影响本文档):
> - **① `resultSummary` 的「失败也带」**:`internal/runtime/loop/tool_frame_summary.go` + `..._test.go`(已按「登记形状 + 明标未提交」进上表);
> - **③ `browser` 的两个生产挂点**:`cmd/cyrene/wire_runtime.go`、`cmd/cyrene/wire_frontend.go`、`cmd/cyrene/wire_browser_context_test.go`(同样已进上表)。
>
> 其余在途项(若有)**尚未冻结、本表不登记** ⇒ 客户端**不要**按当前工作树形态编程。⚠️ v0.81 时代本注举的项(X1 的时间轴两项、`request_think`、浏览器 SSRF 收口、`capacity` / 面板 `Backthink.tsx`)**均已提交**(`2bf3b53` / `2d211c2` / `a820d99`),不再是在途项。
> 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是**归档时的代码状态**,**已过期**。
### 7.2 确认为「未实现」(别当已有)
| 项 | 现状 |
|---|---|
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
| **✅ 「工具回看面」**(消息 DTO `toolCalls[]`) | **已实现(v0.78 落地,v0.79 起覆盖全部编排回合)**(切片 CODE-TC + CODE-TC-2):形状、缺席语义与**覆盖率**见 **§4.4**;读侧依赖 = 迁移 `0023` 的 `flow_graphs.lane_turn_id`(等值索引)+ `FlowGraphStore.GraphsByTurnID`(一页一次 `IN` 批量查,禁 N+1)。✅ **v0.79**:编排器驱动的**每一路**(含普通单路回合与队首续跑路)的图都带权威 `turnId` ⇒ **普通单路聊天与队首续跑回复现在也能回看工具**。⚠️ **仍缺席**:后台思考(`sess-backthink`)、未接编排器的调用方、v0.79 之前的单路历史行。⚠️ 单路在 **HTTP 出口**的端到端**未由本切片确证**(写侧已验,gateway 读侧待补)。见 §7.4-23 / §7.4-25 |
| **✅ v0.76 登记的「未读面」**(`message_persisted.data.unreadCount`) | **已实现**(本切片):形状、缺席语义与三条缺席路径见 **§4.13**,帧表见 §3.4.4。⚠️ `GET /api/v1/sessions` 的同名键**自 D4 起就已在**(`historySessionSummary.UnreadCount`),本片**未改**它 —— 所以「加同键」这句对列表面其实是**既有事实**,不是本版新增 |
| **`graph_update` 按路分桶** | **✅ 已实现(v0.77,切片 FV2)**:帧 `data` 新增每路的 `turnId` 与 `round`,图按 `{会话}#{路}` 分桶 ⇒ **每一路各发自己的帧、各落一行**(不再互相顶掉);管理面 `GET /admin/flow/graph` 新增 `lanes[]`。形状与缺席语义见 §3.4.5 / §4.11(a)(d),登记见 §7.4-19。⚠️ 真并行**未做真机端到端**(见 §7.4-24) |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 `StreamKindToolExecutionUpdate` 的生产者)⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
| **`chat_messages.turn_id` 上的 IM 行** | IM 渠道桥**不走**网关编排器 ⇒ 其行 `turn_id` 留空属**预期**(契约里该键缺席),不是缺陷 |
| **记忆整理候选的 apply / reject / revert + 候选列表 + 运行历史**(`design/23 §11`) | ⚠️ **已部分实现(v0.83,切片 TIDY-1)** —— v0.75 只加了「试算」(B14,**不改任何记忆**);v0.83 补上了**执行面**:`GET /admin/memories/candidates`(B20,只按 `status` 筛)、`POST .../candidates/{id}/apply`(B21)、`POST .../candidates/apply`(**批量**,B22)、`POST .../candidates/{id}/revert`(B23)、`POST /admin/memories/tidy/run`(**手动真跑**,B24)**都已实现并推库**。🔴 **仍缺三样(别假定它们存在)**:① **`reject`**(把候选标成「不采纳」而不应用)**没有端点**;② **运行历史读侧** `GET /admin/memories/tidy/runs` **没有**(真跑的留痕行**已经落库**在 `memory_tidy_runs`,只是没有读口);③ 候选列表**不能按 `runId` 筛**(只能按 `status`)—— 按 run 只能走**应用**那一档(B22 的 `runId`)。⚠️ **别与 v0.79 的 `POST /admin/memories/{id}/revert` 混淆**:后者是**入库前审查(D3)软合并**的回滚(按**记忆 id**,§2.2 B17),与「整理候选」(按**候选 id**)是**两件事** |
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind)。⚠️ **v0.83 起服务端已按 status 过滤**(四条列表读路径只回 `active`)⇒ 被软合并 / 被归档的记忆**不再出现在这一页**;但**字段面仍未补齐** ⇒ 面板在这一页**看不到** `status`/`mergedInto`/`mergeCount`,也就**无法显示「哪些已被合并」**(那要看 **B20 候选列表**或 **B16 审查留痕**) |
| **✅ 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **已实现(v0.79,切片 L45;v0.77 登记时无 HTTP 入口)**:两条都走 `writeAuth`(owner\|admin),形状见 §2.2 B16/B17。⚠️ **残留**:① 留痕列表**只有 `limit`、没有分页游标**(无 `before`/`offset`);② `memory_ingest_reviews` **无 TTL / GC**;③ 回滚**只回滚「被吸收行」这一侧**(不恢复存活行被抬高的 `confidence`);④ 门与 `design/23 §11` 表格的 owner-only **有差异**(待裁决)。同样是**新审计动词** `memory.review.revert`(`design/23 §16.4` 表里还没有它,需设计侧补记) |
| **✅ v0.78 插件动作的「执行器」**(`POST /admin/plugins/{id}/actions/{action}` 的动作本体) | **已接线(v0.79,切片 L1/PLG-2 执行面接线)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions`;单一全局 `SetExecutor` 已删除),组合根在 `wirePanelHub` 里登记内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` ⇒ **这些动作真执行**(真读写可变源、探针真发一次 HTTP)。**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(不再是兜底档)。✅ **v0.80(切片 W3)**:面板保存后**热重载**(重建工具 + 原子替换注册表条目,**下一轮生效、不需重启**;会让后端下次起不来的配置 ⇒ **按字节回滚** + `409 config_unstartable`);面板**已可配主 provider / 降级链**(`primary` / `fallback[]`)。⚠️ **残留**:**探针只用 `httptest` 假上游**(未对真实上游连通);**启用态不跨重启持久化**;**真机面板端到端未跑**。见 §4.14(b)(c) / §7.4-26 / §7.4-27 |
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。有声明的是**内置**插件(组合根装配期 `Declare`):`web-search`(v0.78 起)、**`browser` / `settings`(v0.82 起,切片 Y4)** |
| **🔴 v0.82 `browser` 面板与 B3 截图闭环的「生产挂点」** | **登记时未接线**(切片 Y4 `e0a6bca`):声明与六个动作的代码已落库(§4.14(d)),但**组合根两行**(`wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`wire_runtime.go` 的 `TransformContext`)**在工作树、尚未提交** ⇒ 提交前:真机上 `pluginIds == ["web-search"]`、`POST /admin/plugins/browser/actions/*` **全部 `404 action_not_declared`**、`browser` 工具的 `screenshot.turnAttachment` 照报 `queued` 而图**不会**进上下文。**客户端不要**按未提交形态编程(同 §7.1 那一行) |
| **🔴 v0.82 `browser` 面板 / `resultSummary` 的「真机端到端」** | **未确证**:两者都只有测试进程内的证据(`httptest` / 真 hub / 真 loop),`:10942` 上的发布产物是旧构建 ⇒ 真机端到端一律未跑。见 **§7.4-30 / §7.4-31** |
| **`PUT/POST /api/v1/admin/plugins/{id}/enable\|disable`** | **未实现**(切片 PLG-2):`Manager.SetEnabled` 已实现并有测试,但**没有 HTTP 面** ⇒ 客户端**不得假定**能经 REST 启停插件(`panels_changed` 的 `enable`/`disable` 两种 `reason` 因此当前**不会**在运行期出现)。⚠️ 且启用态**不跨重启持久化**(`PluginConfig` 无 `enabled` 字段,`SetEnabled(false)` 只在进程内生效,重启按「已安装」恢复为启用) |
| **`panels_changed` 帧对 `viewer` 可见** | **未实现(刻意)**:该帧**未**登记进 `frameReachesAll` 白名单 ⇒ 按 fail-closed 默认档**只发 owner/admin**,**viewer 收不到**(须自行刷新/轮询重拉 A13)。放开需先改 `ws_frame_scope.go` + 在 `design/11 §7.1` 登记理由(**不在本版**) |
| **插件动作审计的持久化粒度** | **未含 pluginId / action**(切片 PLG-2):持久化审计记录沿用既有 `AuditStore.Append(ctx, who, what, result, ts)` 四字段 ⇒ 只落 `what="plugin.action"`;插件 id 与动作名只在**结构化日志行**里。要进持久化记录需改 `AuditStore` 签名(**不在本版**) |
| **🔴 v0.78 渠道事件实时面的「其余全部事件」** | **未接线**(切片 E7):覆盖桥接层写入的**全部**渠道事件的 `events.PublishingStore` 装饰器**已实现但组合根未接**(`grep` 全仓无 `NewPublishingStore` 的生产调用点)⇒ 目前**只有账号状态迁移这一路**会产生 `channel_event` 帧;其余渠道事件仍**只能靠 REST 轮询**。接线只需组合根两处(`events.NewPublishingStore(...)` + 把 `wire_channels.go` 的 `EventStore` 换成它),**⚠️ 只装饰这一处**(网关侧 `Deps.EventStore` **不要**也换,否则账号状态行会被推两次) |
| **🔴 v0.78 `GET /admin/channels` 的 `accounts[]` 每账号凭据三态** | **未实现**(切片 CODE-F3):`authState`/`authDetail` **只**落在 `GET /admin/channels/{platform}/login-status`(§2.4(c));**每账号**版本需要给 `AdminChannelAccount` 加两字段 + 一个合并器 + 一次取数(收集器 `liveAccountAuthStates()` 已实现并有单测,**无调用方**)⇒ **客户端不得假定 A5 的 `accounts[]` 会带这两个键** |
> ⚠️ **本表(§7.2)的两条口径**:① 标 **✅** 的行(如 v0.67 的两项、`directMsgPerMin`、`graph_update` 按路分桶、`toolCalls[]`、`unreadCount`)**留在表里仅为与上一版对照**,它们**已经是可用契约面**,见各行指向的章节;② 标 **🔴** 的行**不要当已有**。
### 7.3 诚实边界(既有事实)
- `subagent_result` **不落库**:父会话转录里没有新消息、**重连回放不补发**。
- **`proactive_message`(v0.72 起)落库**:成为主会话 `web` 的一条 `assistant` 行(见 §3.4.5 后的「落库口径」)。**仍未落库的情形**(都是如实、预期的):① 被 `PushGate`(频率门 / 冷却 / 按风格窗口)或安静时段挡下的推送 —— 「这一条不发」就没有记录,**绝不留下一条它没发过的消息**;② 后端未接线历史存储(`HistoryStore: nil`,仅测试与异常部署)时只发帧不写库,进程重启即丢。
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
- `POST /audio/speech` 的请求体 `voice`/`format` **不生效**;**v0.96 起发音人不再是服务端 env 决定**,而是按 `sessionId` 的**生效人格** → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射(见 §6 v0.96)。产物恒 WAV。
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
- **消息 DTO 的 `turnId` 只在 Web/网关回合存在**:IM 渠道桥**不经过**网关编排器 ⇒ 其落库行的 `turn_id` 为空、DTO 里 `turnId` 键缺席(预期行为)。`turn_begin` / `steer_ack` / `interrupt.turnId` 同理只存在于网关回合。
- **`GET /api/v1/media/{name}` 读侧要 Bearer owner/admin(v0.67 登记)**:音频 URL **不能**直接贴进 `<audio src>`(浏览器不带 token)⇒ 客户端须带 token 取字节(Web:`fetch` → `blob:`)。这是**有意**的(语音是私密内容,与图片读侧「128-bit 名即凭据」的取向刻意相反)。
- **微信(`weixin-ilink`)入站语音没有音频副本、不可回放(v0.67 登记)**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `voice.source:"platform"` 且**无 `name`/`url`**,只保留 🎤 标记。
- **`416` 不是 JSON 信封(v0.67 登记)**:`GET /api/v1/media/{name}` 的 `Range` 错误由 Go 标准库直写 `text/plain`,**不套** `{"error":{…}}` ⇒ 客户端别拿 `error.code` 解析它(§0.3 / §4.1 / §4.5)。
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
- **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
- **`panels_changed` 只发 owner/admin(v0.78 边界)**:该帧**不在** `frameReachesAll` 白名单 ⇒ **viewer 收不到**「插件面板变了」的通知,须自行刷新/轮询重拉 `GET /admin/panels`。且当前运行期只可能在**装配期**推一次(内置声明 `declare`),`enable`/`disable`/`install`/`uninstall` 四种 `reason` 需要**没有 HTTP 面的** `SetEnabled` / 插件安装端点才会出现(见 §7.2)。
### 7.4 「未确证」清单(客户端请以实测/代码为准)
1. **`GET /meta/enums` 逐值**(platforms/moods/modes)取自归档转述,本文档**未逐一回 `internal/contract/enums` 核对**。客户端应直接读端点,不要硬编码。
2. **`conflict`(409) 通用错误码**除 `chat_busy` 外,还有哪些端点会带它 —— 未逐一确证。
3. **各管理写端点的精确请求体字段与状态码**:本文档 §2.3 只给「方法/路径/鉴权/一句话要点」,**未逐字段展开**(归档 `Docs/archive/api-reference.v0.65.md` 有历史详细版,但可能漂移)。客户端实现管理写操作前请回代码或参考该归档并**以代码为准**。
4. **`observability_metrics` 的完整 `AdminMetrics` 字段集**:本文档只说明它是扁平快照,未展开字段。
5. **`subagent_event` 在 `subagent_ask` 应答路径上的精确字段**(`orchestration.AuditResult` 形状):未展开。
6. **`account_not_registered`(409) 的适用面**:本文档只确证了 C19/C20(open/close)会带它;`POST /admin/channels/test`、`/{platform}/message/test` 等其它渠道端点是否也有等价的账号级错误码 —— **未逐一确证**。
7. **`turn_begin.data.leg` 在长会话中的取值范围**:代码里是会话内单调递增序号(非「1/2 路」固定枚举),但本文档**未确证**客户端应如何展示 >2 的取值 —— 建议只当诊断信息用。
8. **`GET /admin/channels` 的 `accounts[]` 除 `connected` 外的字段集**:本文档未逐字段展开(该结构在 v0.66 只是**新增**一个可选键;v0.67 另登记了一个可选 `accountOnline`,见 §2.4(b))。
9. **(v0.67 登记 · 已落地确证)`GET /admin/channels/events` 的 `events[]` 元素 JSON 键名**:**已定为 camelCase**,逐字与 `internal/services/gateway/admin_channel_events.go` 的 `channelEventDTO` 一致:`id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole` / `targetId` / `selfRelated` / `messageId` / `summary` / `createdAt` / `updatedAt`(时间为 **UTC RFC3339**;`counters[]` 同为 camelCase,风格一致)。客户端**可以**硬编码这些键名了。
10. **(v0.67 登记 · 已落地确证)`events.total` 的语义**:是**筛选条件下的总条数**(**不受** `limit`/`offset` 影响;与 `events` 同源条件),因此 `total > events.length` 是正常的分页状态。
11. **(v0.67 登记 · 已落地确证)渠道事件查询的参数默认值与错误码名**:`limit` 未给 / `≤0` ⇒ 存储层缺省 **`100`**;超硬上限 ⇒ **钳到 `500`**(不报错);`offset` 负值 ⇒ `0`;**非法整数 ⇒ `400 bad_request`**。`platform` 未知 / `tier` 非 `t1|t2|t3` / `since`-`until` 跨度超 90 天 / 时间点非法 ⇒ 一律 **`400` + `code:"bad_request"`**(未新造专用错误码)。`category` **不做白名单**(未知类别 = 精确匹配 ⇒ 空结果)。`approx` 的**嵌套层级已定为 `data.approx`**(与 `data.counters` 同级)。
12. **(v0.67 登记 · 已落地确证)两个渠道事件端点的 RBAC 档位**:**`owner|admin`**(`writeAuth`),与渠道消息日志面(B2/B3)同档;**viewer 为 `403`**。依据:事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是 viewer 的监控投影。
13. **(v0.67 登记 · 已确证)`accounts[].accountOnline` 的字段名与是否有 `known` 同级键**:字段名 `accountOnline` 为本契约所拟(设计只规定「独立状态面 / `AccountStatus()` / `known` 三态」,未给管理面字段名),**实现沿用了它、未改名**;**不发** `known` 同级键 —— 「未知」由**键缺席**承载。哪些平台会带该键**已确证**:只有适配器实现了 `AccountStatus()` 的平台(当前仅 `qq-obv11`)。
14. **(v0.67 登记)音频各容器在各客户端的可播放性**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端**以实测为准**,播不了就照实提示。
15. **(v0.67 登记)`GET /admin/system/storage` 的 `documents` 段是否拆出语音副本**:设计**本期不拆**(不新增 `kind` 列 / 不按 ext 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
16. **(v0.67 登记 · 前向提示)`voice.url` 指向的可能不是原件,而是转码后的副本**:`design/24 §10.14` 已把「源格式不可播 ⇒ 转码成可播副本(唯一必需的路 = `amr → wav`)」立为口径,转码能力规格见 `design/25-转码能力.md`(**已接线**:工具面 `transcode` 工具(`cmd/cyrene/wire_transcode.go`)+ 入站语音副本归一;**v0.80 切片 W4 补齐了 `channelbridge` 侧的 `VoiceTranscoder` 注入** ⇒ `amr → wav` 这条路在生产装配下**真的会走**)⇒ 客户端**不要假定 `url` 一定是某一种音频格式**,一律**按响应的 `Content-Type` / `mime` 如实处理**,播不了就照实提示(**绝不给一个播不了的 `url` 假装能播**)。⚠️ 本条**不在 `design/24 §10.11` 的差异清单内**(§10.11 与 §10.13 均无「零转码」绑定表述),登记它是因为「`url` 的字节格式」会直接影响播放端;**最终口径以 `design/25` 为准**。
17. **(v0.68 登记 · v0.70 更正)`urgency` 的「下限」与「精确」之分**:
- **v0.68 原文(当时成立,现已过期)**:`urgency` 一律是「档位下限」,`warning` 读作「至少 `high`(也可能是 `critical`)」;**窗口之外的轮次不下发该键**(`backthink.Record` 不带重要度、推送成功的行 `detail` 被投递说明覆盖 ⇒ 无损精确档位已不可能),要精确档位需 backthink 侧把每轮 `Import` 落进记录(当时未做)。
- **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。
- **客户端两条硬约束(v0.70 新增)**:① **不得把带 `urgencyLowerBound:true` 的「下限」显示成精确档位**(`≥ 高` ≠ `高`;同形就等于把推导冒充事实);② **不得把键缺席显示成「例常 / routine」**(缺席 = 存档里没有这个事实),也不得据此推断安全域。
18. **(v0.71 登记 · v0.73 更新)「最近一轮」≠「你要的那一轮」**:`GET /api/v1/admin/flow/graph`(§4.11)在**省略 `turnId`** 时回该会话**最新**的一张图(先内存 `loop.FlowPublisher.LastGraph`,miss 才落盘)。**后台思考行**上点开的图因此可能不是该行那一轮 ⇒ 面板**必须在图上说明**(当前 `frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),客户端**不得**把它渲染成「这一行的链路」。**v0.73 起**客户端要精确取某轮就带 `turns[].turnId`(**多轮历史回放已实现**,`design/19 §4` 的 **P3**)。
19. **(v0.71 登记 · v0.73 更新 · v0.77 更新)图源是**进程内内存**,但**每轮收尾会落盘**(v0.73)**:`loop.FlowPublisher` 的每会话最近一图仍是**内存**(design/19 §1 的实时口径),但 `EndRound` 会把该轮写进 `flow_graphs`(迁移 `0022`)⇒ 后端重启后、该会话还没跑新轮次时,省略 `turnId` 的查询**回退落盘**即可拿到最近一轮(`source:"persisted"`),不再一律 `graph:null`。⚠️ **仍会 `graph:null` 的情形**:该会话本就在**发布会话白名单之外**(隔离渠道会话,从不建图也从不落盘)、或该会话**从未跑过带 span 的轮次**、或该轮已被**保留期 GC 回收**(默认 168h)。**两路互顶已在 v0.77 修复(切片 FV2,登记裁决改为「按路分桶 + 会话级并列泳道」)**:图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ 单路逐字不变),每一路**各发自己的帧**(帧 `data` 带该路 `turnId` 与 `round`)、**各占一行落盘**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 因此不再把另一路整行顶掉),管理面 `GET /admin/flow/graph` 用 **`lanes[]`** 一次给出**最近一轮每一路**的图、前端 ≥2 路时按**并列泳道**呈现(见 §4.11(a)(d))。⚠️ 仍如实的留白:`turns[]` 仍按**路**列条目(一轮两路会显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路;真并行**未做真机端到端**(见 §7.4-24)。
20. **(v0.73 登记)落盘只覆盖「保留期内」且只覆盖白名单会话**:`turns[]` 是**已落盘且未被 GC 回收**的轮次摘要,**不是全历史**(`FLOW_GRAPH_RETENTION` 默认 168h,`≤0` = 关闭 GC ⇒ 那时才会无限增长,属显式选择)。落盘范围与实时发布**同一白名单**(`flowSessionAllowed`:`web` / `web:<uuid>` / `sess-backthink`)—— **群聊 / 陌生人会话永远不会出现在 `turns[]` 里**。此外**正在跑、尚未 `EndRound`** 的那一轮不会在 `turns[]` 中出现(它是内存里的「实时」那一轮,`source:"live"`),这是如实而非缺失。
21. **(v0.74 登记)`message_persisted` 的 `proactive` 判据只活在实时帧上,不落库**:它是「这条是她主动说的话」在线上**唯一**的结构化判据(帧顶布尔键,由 `gateway.BroadcastProactivePersisted` 打上),**没有对应数据库列**(本片不动 schema)。因此 ① `GET /sessions/{id}/messages`、握手 `history` 回放、`GET /admin/channels/**` 读到的历史行**不带**它(预期:通知只对实时到达有意义);② 客户端**不得**从 `platform` / `adapterId` / `turnId` 等行字段反推「是不是主动消息」—— 主会话里主动推送写的是**解析后的真实平台**(例如 `weixin-ilink`),与普通回复行同一形状,反推必然误伤。**落库失败时不发该帧**(没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃)。
22. **(v0.75 登记)试算端点(B14)是「只读计算」,它**不改**任何状态**:它不写 `memory_tidy_candidates` / `memory_tidy_runs`、不改 `memory_items`、不写审计链、**也不推进心跳**(不写 `lastRun`、不影响 `nextDelay`)。因此 ① 试算**不会**让「候选队列」(尚未实现,见 §7.2)多出任何东西;② 试算结果**不是缓存**:每次调用都重新算一遍,两次调用之间数据变了结果就可能不同(这是「同一套规则」的正常语义,不是不确定性),客户端**不要**把它当成会稳定的列表去 diff 或自动轮询;③ **`recallMode:"lexical"` 是降级信号**:它表示这轮没有走到语义向量(进程没接 embedder),此时召回更保守(命中更少)—— 若进程**接了** embedder 而它不可用,试算**直接报错**(`tidy_recall_unavailable`),**不会**在响应里假装成 lexical。
23. **(v0.76 登记 · v0.77 更新 · v0.79 更新)消息 DTO 的 `turnId` 与链路图的轮次键:图**已带上每路的回合标识**,但两者仍是两套标识**:
- **消息侧**:`chat_messages.turn_id` = 编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制;同路的 user 行与 assistant 行同值)。
- **图侧两个键**:① **`graph.turnId`(v0.77 新增)=该图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同源同值** ⇒ 客户端**可以**用它把图与消息行**按路配对**;**v0.79 起编排器驱动的每一路(含普通单路回合)都带它**;② `graph.id` / `flow_graphs.turn_id` 仍是**图侧轮次键**(形状 `{会话}-{序号}[#{权威回合标识}]`,**不透明**;序号是发布器**进程内**递增的建图序号、重启即从头开始),也就是 `GET /admin/flow/graph?turnId=` 的参数值。
- **客户端三条硬约束**:① 要路标识就**读 `graph.turnId`**,**不得**解析 `graph.id` 去取它;② **仍不得**拿消息的 `turnId` 当 `?turnId=` 用(两者不同源);③ 也**不得**把图上的序号当成会话里的回合序号(重启后序号会重复)。
- 🔴 **`graph.id` 形状变化(v0.79,唯一一处)**:`#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`);**老行**(v0.79 前落盘的单路行)仍是 `{会话}-{序号}`。⇒ **一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼**(自拼的 `web-7` 查不到新行);该值可能含 `#`,作 query 值须 URL 编码。
- **`toolCalls[]` 覆盖率(v0.79 已补齐)**:v0.77 起每路各落一行、图带 `graph.turnId`;v0.78 补上**按路查图的读侧入口**(迁移 `0023` 的 `flow_graphs.lane_turn_id` + `FlowGraphStore.GraphsByTurnID`);v0.79(切片 CODE-TC-2)让**每一路**(含普通单路回合与队首续跑路)都带权威标识 ⇒ **普通单路聊天与队首续跑的回复现在也能回看工具**。⚠️ **仍缺席**:后台思考(`sess-backthink`)、未接编排器的调用方、v0.79 之前的单路历史行。⚠️ `graph.id` **仍不等于**消息的 `turn_id`(两者不同源,不能用 `graph.id` 当 `chat_messages.turn_id`)。见 §4.4、§7.2、§7.4-25。
24. **(v0.77 登记)「按路分桶」尚未做真机端到端**:真并行(上行 `chat.parallel`)的修复是在**单测层**验证的(真 `Loop.Run` × 2 + 网关照同的 ctx 通道;网关侧走真 HTTP 处理器 + 真发布器),**没有**对运行中的后端发过一次真实 `chat.parallel` —— 因为线上产物是旧构建、且换二进制 / 重启有专门流程。因此**未确证**:真 WS 客户端在真并行下看到的两路帧**时序**(形状与分桶已由单测钉死,时序上等价但无实测)。另有一条**已知的窄竞态**(未观测、也未加锁消除):若一路恰好在另一路起跑的瞬间收尾,第二条路可能被算作**新的一轮**(此时两路仍各有各的图与落盘行,只是面板不把它们并排展示)。
25. **(v0.78 登记 · v0.79 更新)`toolCalls[]` 的覆盖率与端到端未确证**:✅ **覆盖率已补齐(v0.79,切片 CODE-TC-2)** —— 编排器驱动的**每一路**(含普通单路回合与队首续跑路)的图都带权威 `turnId` ⇒ 字段不再只覆盖真并行路。⚠️ **仍未确证 / 仍缺席的点**:① **单路在 HTTP 出口的端到端未由本切片确证** —— 写侧(`loop` 的 3 个新用例,含真 `Run` → 真库 → 按消息那个 id 查回)已验,但网关读侧(`internal/services/gateway/history_toolcalls_gap_test.go` 那条「钉住现状缺口」的用例走的是**老入口** `StartRound`,其前提句已失效)由主会话补验;**没有**对运行中的后端发过一次真消息来实测 `GET /sessions/web/messages` 拿到单路 `toolCalls`。② **「存库里的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等」仍是代码级结论**(两处都取 ctx 上的权威标识)。③ **老行**(v0.79 之前的单路落盘)该列仍为空,**不可能**补齐(那时的图里没有该字段)。④ **后台思考**(`sess-backthink`)仍缺席。⑤ 生产库 `data/cyrene.db` 现存行的回填命中率**未统计**(未碰生产库)。
26. **(v0.78 登记 · v0.79 更新)插件管理面板接入点 / 动作执行面的真机未确证**:`GET /api/v1/admin/panels` 与 `POST /admin/plugins/{id}/actions/{action}` 的**真实运行期响应未在 `:10942` 上验过**(换产物 / 重启有专门流程,本切片禁止)。所有证据来自 `httptest` + 进程内 bus。⇒ **未确证**:真机上 `data.pluginIds == ["web-search"]`、`groups[0].items[0].sections` 的两段声明、`route` 的前缀拼接姿势(相对片段,前端拼 `<prefix>/plugin/<pluginId>/<panelId>`)。**v0.79 新增未确证项**:① 面板动作(`plugin.listProviders` 等)在**真机上**真的回 200 + `result`(单测层已用 `httptest` 真发 HTTP 验过探针);② **真机探针未打过真实上游**(无 key);③ ~~面板保存 provider 后运行中的 web-search 工具不换接线(需重启)~~ ⇒ **v0.80(切片 W3)已改为保存后热重载**(`hotReload:{applied,registered}`),**真机端到端仍未跑**(真机上「保存后下一轮真的走新配置」未验;未换产物前旧二进制里仍是启动期接线)。另:`panels_changed` 的分组文案「插件」是**宿主文案**(design 未规定文案来源),改口径只改一个函数。⚠️ 且**未换产物前**,旧二进制里**没有**这些路由,属预期。**为什么验不了(2026-10-06 复核)**:本波禁换产物 / 禁重启后端,且**取不到 owner 凭据** ⇒ 真机面板 e2e 无法做;代码侧已复核确证「非对象响应**不**追加 `hotReload`」(`cmd/cyrene/websearch_reload.go` 的 `withReloadOutcome`:非 `map[string]any` 原样返回)。
27. **(v0.78 登记 · v0.79 更新)`web-search` 可变源的运行期口径未确证**:组合根「先可变源、缺失回落 `.env` 播种」已有单测钉住(含「凭据不复活」「损坏文件不改写」「停用主 provider ⇒ 启动 fail-fast」等 7 条),并有一条**真机 SearXNG e2e**(`provider=searxng` 真返回结果)证明既有链路未被打断。**v0.79**:动作执行器已接线 ⇒ `plugin.saveProvider` 的**落盘链路**在 `cmd/cyrene` 的单测里真跑过(临时目录 + 真 `runtimeconfig.SaveWebSearch`)。⚠️ **仍未确证**:「在**真机面板**上新增一家 provider → **下一次搜索真的走它**」这条**端到端**(✅ v0.80 起**保存后工具热重载**:换注册表条目、**下一轮生效**(见 §4.14(c)),但**真机端到端仍未跑**,见 §7.2 / §6 v0.80 ②)。**为什么验不了(2026-10-06 复核)**:真机面板 e2e 需要 owner 凭据 + 真在面板上改一次配置(本波禁改数据、禁换产物 / 禁重启后端),且**无任何第三方 key** ⇒ 只能停在 `httptest` + 单测级证据。
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
31. **(v0.82 登记)`browser` 面板 / B3 截图闭环未做真机端到端**:证据止于 `cmd/cyrene` 的真 hub + 真能力注册表用例(`wire_browser_panel_test.go`:声明进目录、六个动作真能分发、读写面分类、保存真热重载 + 回滚;`wire_browser_context_test.go`:真 loop + 探针适配器 → 模型请求里确实出现 image 部件)。⚠️ **仍未确证**:① 真机面板上点开 `/admin/plugin/browser/settings` 能看到 4 个区块与真实行(需 owner 凭据 + 换产物);② 真机上保存一次浏览器配置后**下一轮**真的用新配置(热重载的真机确认);③ 真机**有头**窗口里「拍一张 → 图真的进下一轮上下文」的端到端(既有 `wire_browser_e2e_test.go` **默认 `Skip`**,它需要真 Chrome + 可执行文件路径 + 真模型);④ **登记时两个挂点尚未提交** ⇒ 上述三项在提交前**不可能**成立。**为什么验不了**:同上(禁换产物 / 禁重启后端),且本机浏览器来源与凭据未在测试环境确证。
---
*本文件由「以代码为真源」维护(当前 `v0.98`;`v0.97` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.98`(她的出站语音 —— M57 `speak` 工具 + 字数预算 / M58 出站 voice 落列 + 降级标记 + 判据变更 / M59 QQ record 段)同步了 `internal/runtime/loop/**`(`speak_tool.go` / `voice.go` / `request.go` / `tools*.go` 及测试)、`internal/runtime/voice/voice.go`(`SourceTTS` / `Ref.FallbackReason`)、`internal/services/channelbridge/**`(`outbound_voice.go` 及 e2e / `bubbles.go` / `inbound*.go` / `persist*.go` / `proactive*.go`)、`internal/services/gateway/history_voice.go`、`plugins/channels/qq_obv11/outbound_segments.go` 及测试与本文件(§4.4 / §6 / 旧横幅加注);`v0.97` ~ `v0.82` 各版的文件级范围见各自 §6 条目。*