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

456 KiB
Raw Blame History

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。 ⚠️ 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。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。
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)。嗅探裁决 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。
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。会话不存在/已删除 → 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 删除的收尾口径一致)。

⚠️ 本节两项都已落地(账号在线状态 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 时,服务端定向发送一帧:

    {"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。
interrupt {sessionId,scope?,turnId?} 打断。见 §4.8。
voice_input {audio,format?,text?,sessionId?,deviceId?,clientMsgId?} 语音回合:STT → 编排器 → 结算时 TTS 定向回本连接。见 §4.5。
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
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[] —— 同一个结构体:

{
  "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 同形):

{"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。
  • 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 新增 · ✅ 已实现)

帧形状(纯增量)

{"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:

{
  "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 诊断发现)—— 它不影响本族的规则层判定与状态机,但影响「整理候选是否可复现」,登记在此以免被当成实现缺陷。

① 轮末自动落地(默认生效):整理跑完一轮后会自动把两类规则层已判定为高置信的候选落地,不再等人点:

  • 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(幂等、只改候选状态、不碰记忆):

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 同形状)

{
  "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。

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 汉字),饱和态可放大 7090 倍)。
  • 计数不变:无新端点 ⇒ §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 条目。