diff --git a/Docs/api-client.md b/Docs/api-client.md index e3cf21a..d709180 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -1,6 +1,6 @@ # Cyrene v2 客户端 API 契约 -> **契约版本:`v0.80`**(上一版 `v0.79`,其条目见 §6;`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`)。 +> **契约版本:`v0.81`**(上一版 `v0.80`,其条目见 §6;`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 @@ -22,6 +22,8 @@ > **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`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。 +> **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` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。② **新增 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 形状**(浏览器是**模型面工具**,不进客户端契约面)。⚠️ **③④ 的形状取自切片 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`**;组合根**未注入 `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` 则零改动)。 @@ -192,7 +194,7 @@ | 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** | +| 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) | | B9 | GET | `/api/v1/admin/observability/audit` | 审计链 | | B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 | | B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) | @@ -203,7 +205,7 @@ | 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:""}}`;该行不在 `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)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** | +| 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** | > **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 @@ -819,6 +821,32 @@ - **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 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 触发 + 她可调用的「自排一次思考」工具 + 每窗口上限 + 审计)。**本版无新端点、无新字段表**,只有两处**取值域加性扩张**与一条**口径澄清**。 +> 🔴 **可达性(先读这条,别误判)**:本版落地的是**判定侧(C1-1 `94da6bc`)与自排本体(C1-2 `ed50053`)** —— **她的入口工具 `request_think` 尚未接线**(属**下一片**,在办)⇒ **在生产里 `trigger="self_wake"` 与那 4 条审计动词目前还不会真的出现**。本节**登记的是形状**(写侧代码已在树里),客户端可以先把字段读进去,但**不要**把它当「一定会有」的活跃数据(同 §7.1 的登记口径)。 + +**(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`(可直接显示的中文;`` 是她写的理由) | +|---|---|---| +| `backthink.self_wake.queued` | `backthink` | `已排入一轮自主思考,理由:` | +| `backthink.self_wake.coalesced` | `backthink` | `已合并到排队中的那一轮(不额外占额度),理由:` | +| `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`),且**目前没有 admin 端点可读可写**(写路径属后续切片 C2,未做)⇒ 客户端/面板**不要假定**能查到或改到这个值;要显示就用审计行 `result` 的原文。 + ### 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)。 @@ -884,7 +912,8 @@ | `source` | `"persisted"`(本页**至少有一轮**来自落盘;可能同时含内存里的在飞路)/`"live"`(本页**全部**来自内存:落盘源未接线,或该会话还没有落盘轮)/`"none"`(没有任何轮次) | | `nextBefore` | 本页**最早一轮**的开始时刻(无轮次时 `""`)。`hasMore:true` 时**原样回带**成下一次请求的 `before` 即可继续向更早翻页 | | `hasMore` | 库里**还有更早的轮次**没被本次返回(用 `nextBefore` 继续取) | -| `truncated` | 本次**不是全部**:`hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。面板据此标注「更早的轮次未显示」,**绝不静默少一块** | +| `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` | @@ -893,7 +922,7 @@ 1. **它是「多轮并联」,不是「最近一轮」。** 老端点(§4.11)继续负责「最近一轮 + `lanes[]` + `turns[]`」,本端点负责「按时间串起来的多轮」。两者**读同一份落盘数据**,但**语义不同**:本端点的 `rounds[]` 是**时间轴**(正序、分页),老端点的 `graph` 是**你要的那一轮**。 2. 🔴 **一句话轮(round)与路(leg)不可混用**(`design/19 §6.0`):`rounds[]` 的一格 = **一轮**(一条用户消息引出的全部工作),一轮里**每一路**在 `lanes[]` 里各有 `{turnId,graph}`。真并行时同一轮会有 2~3 条路 —— **不要把两条路画成两轮**。 -3. 🔴 **保留期只说真话**:过期轮次**不会**出现在 `rounds[]` 里(已被清扫),本端点**不猜、不补空块**。面板要说明「更早的轮次已过保留期」时,判据只能是 `retentionEnabled` + `retentionHours` + `truncated`(三者一起读),**不得**因为「返回的轮次比想象中少」就断言被清扫了。 +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` 帧、不改任何会话的实时图、不动保留期、不写审计。 @@ -909,6 +938,13 @@ - **`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 新增) @@ -1200,7 +1236,19 @@ > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** -### v0.80(当前契约版本) +### 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`)**对客户端零形状影响**,故未逐条登记(浏览器是**模型面工具**,不进客户端契约面)。 + +### v0.80(上一版) **三批增量 —— ① 链路图时间轴出口(切片 W1)|② 搜索 provider 热重载 + 主 provider / 降级链(切片 W3)|③ 入站语音 AMR 也可回放(切片 W4)**(依据:主人 2026-10-06 要「web 后台的全链路流程图 = **所有消息按时间顺序串起来**,而不是只有最新一个来回」;面板的意义就是「不改 `.env` 不重启」⇒ 配置面必须真热生效;微信 / QQ 的 AMR 入站语音不落副本 = 无法回放 / 多端同步。) @@ -1473,8 +1521,10 @@ | **`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) | 🟡 **已实现;登记时仍在工作树、待提交** | 作用:把「更早的轮次**被保留期清扫**」与「分页还有更多 / 本来就没有更早的轮次」**分开**(`truncated` 只答「本次是不是全部」)。形状 / 判据 / 三个盲区见 **§4.11.1(a)(b)-3**;`round` 跨重启单调的同期修复见 **§4.11.1(e)**。⚠️ **它是在本版登记之后才 commit 的**(本片无提交权限)⇒ 客户端可用,但**提交后请按 `git show` 逐字复核键名与判据** | +| **她自排的入口工具 `request_think`**(→ 使 `GET /admin/backthink` 的 `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**真的可能出现**;v0.81 登记) | 🔴 **未落地(下一片在办;判定侧与自排本体已落地)** | 已落地:判定侧 C1-1 `94da6bc`、自排本体 C1-2 `ed50053`(含窗口上限 + 容量 1 待跑槽 + 4 条审计)。**缺的是她的入口工具**(`request_think`,`internal/services/backthink/thinktool.go` 尚未提交)⇒ **生产里目前不会产生自排轮**。形状见 §4.10(d);客户端**不要**依赖该取值一定出现 | -> ⚠️ **工作树另有未提交改动(未冻结、未登记为契约项)**:当前工作树存在与本文档无关的未提交改动(例如助手回复的 `

` 段落包裹规整 `internal/runtime/turnreply/`、前端 `turn` 分桶接线等)。它们**尚未 commit、未经契约评审**,本文档**不逐项登记**其形状;客户端**不要**按当前工作树形态编程 —— 等它们冻结成下一版契约条目后再对齐(本表只登记已冻结的项)。 +> ⚠️ **工作树另有未提交改动**:2026-10-06 晚的工作树上有**并行切片**的在途改动 —— 其中 **X1 的时间轴两项**(`retentionBoundaryReached` + `round` 跨重启播种)**已按「登记形状 + 明标未提交」进上表**;其余(她自排的入口工具 `request_think`、浏览器 SSRF 收口、`capacity` / 面板 `Backthink.tsx` 等)**尚未冻结、本表不登记** ⇒ 客户端**不要**按当前工作树形态编程。⚠️ 本注原举的 `

` 段落规整(`internal/runtime/turnreply/`)与前端 `turn` 分桶**均已提交**(不再是在途项),故删除该例。 > 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是**归档时的代码状态**,**已过期**。 ### 7.2 确认为「未实现」(别当已有)