From 180128c20758d5948c633e7b59b6749350527b60 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 08:23:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(contract):=20v0.77=20=E7=99=BB=E8=AE=B0=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20graph=5Fupdate=20=E6=8C=89=E8=B7=AF?= =?UTF-8?q?=E5=88=86=E6=A1=B6=20+=20MT-A=20=E4=BA=8B=E5=AE=9E/=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=8F=A3=E5=BE=84=20+=20MT-B=20=E6=97=8B=E9=92=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三批一并登记(都由 DOC-1 文档代理按代码逐处核对,含 文件:行 证据;我复核了四个核查点): ① FV2:graph_update 帧加 turnId/round(omitempty)、图按 {会话}#{路} 分桶(单路逐字不变)、 GET /admin/flow/graph 加 lanes[](恒非 null,graph 仍是「最新一路」⇒ 老读取方零改动)、 新增 §4.11(d) 专写增量键与缺席语义;§7.4-19「两路互顶未修」→ 已在 v0.77 修复; §7.4-23 改写为 4 条(明写「toolCalls[] 前提已具备但本身仍未实现,别读成已实现」); 新增 §7.4-24(真并行未做真机端到端 + 已知窄竞态) ② MT-A:事实面 12→13(MEMORY_TIDY_COMPARE_LIMIT)、候选默认 20→200、truncated 第 4 档 (budget)、 异步实现口径(并发复用 runId / 同一 3 分钟 / result 逐字节同形 / running 时双缺席 / 重启 404 tidy_run_not_found) ③ MT-B:4 个旋钮写清「不进 .env(.env 已冻结)⇒ 有效行为=代码默认值,调优入口待接线」; 它建议的两条 admin 端点只进「未实现」清单(机器证据:代码与前端 zero 命中) 顺手清掉我早前一次编辑在版本行留下的重复文本(DOC-1 指出但按边界没动)。 --- Docs/api-client.md | 117 +++++++++++++++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 37 deletions(-) diff --git a/Docs/api-client.md b/Docs/api-client.md index e3f570d..e8bb0fc 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -1,6 +1,6 @@ # Cyrene v2 客户端 API 契约 -> **契约版本:`v0.76`**(上一版 `v0.75`,其条目见 §6;`v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`;`v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 +> **契约版本:`v0.77`**(上一版 `v0.76`,其条目见 §6;`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 @@ -18,7 +18,9 @@ > **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 是三面增量版(⚠️ **逐条状态不同**:②③ 见各条;**① 未实现且当前无实现路径**)**:① **消息读取面新增 `toolCalls[]`(🔴 本版**未实现** —— 客户端**不要**按它编程)** —— 原定:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。**未实现的原因(实测确认,非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 前者是编排器生成的随机路标识(`turn-` + 16 位十六进制),后者是 `trace.FlowGraph.ID` = `-<轮次序号>`(发布器**进程内**递增计数);两者**无法按消息字面 join**,映射只能靠猜时间窗 ⇒ 按项目口径**不做**。补齐需要先让链路图带上**回合标识**(新键 + 新迁移),**不在本版**。详见 **§7.2**。② **未读面(✅ 已实现)** —— `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.76 是三面增量版(⚠️ **逐条状态不同**:**① 未实现且当前无实现路径**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(🔴 本版**未实现** —— 客户端**不要**按它编程)** —— 原定:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。**未实现的原因(实测确认,非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 前者是编排器生成的随机路标识(`turn-` + 16 位十六进制),后者是 `trace.FlowGraph.ID` = `-<轮次序号>`(发布器**进程内**递增计数);两者**无法按消息字面 join**,映射只能靠猜时间窗 ⇒ 按项目口径**不做**。补齐需要先让链路图带上**回合标识**,**不在本版**。⚠️ **v0.77 校正**:链路图的**回合标识已加上**(`graph.turnId`,纯增量、**未用新迁移**)+ 每路一行落盘 ⇒ 该**前提已具备**;但 `toolCalls[]` **本身仍未实现**(还缺一个「按路查图」的读侧入口)—— 见 **§7.2**、§7.4-23。② **未读面(✅ 已实现)** —— `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`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。 > **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**。 @@ -174,10 +176,10 @@ | 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 键)**:记忆整理 7 键 + 后台思考心跳 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 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 | +| 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 新增,⚠️ 契约先行 · 尚未落地)**:配合 `POST …/memories/tidy/dry-run?async=1`(后者仍属 B14,`async=1` 时回 `202` + `{data:{runId,status:"running",startedAt}}`)轮询取回结果。响应 `{data:{status:"running"|"done"|"failed", result?, error?}}`:`done` 时 `result` 与 §4.12 同步响应的 `data` **同形**(**同一套 JSON,不是第二份**);`failed` 时 `error` 为 `{code,message}`(`code` 沿用 §4.12 的失败码)。⚠️ **结果只活在进程内存**(单二进制、零跨进程):TTL **30 分钟**、同时只允许 **1** 个在跑;进程重启后旧 `runId` **如实失效** ⇒ `404 tidy_run_not_found`,**绝不假装还在跑**。鉴权与 B14 同档(`writeAuth` = owner/admin)、同样**不审计**。语义见 §4.12.1 | +| 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 | > **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 @@ -404,7 +406,7 @@ | `device_update` | — | 桥接自 `iot.device_update` | | `automation_result` | — | 桥接自 `automation.result` | | `reminder` | — | 桥接自 `reminder.trigger` | -| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**无 `turnId`** ⇒ 未按路分桶)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照** | +| `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 去重、刷新后仍在) | @@ -445,7 +447,7 @@ | 400 | `bad_request`、`image_too_large`、`too_many_documents` | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数 | | 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 | | 403 | `forbidden` | 角色不足;主会话不可删 | -| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、尚未实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5) | +| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、尚未实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1) | | 409 | `chat_busy`、`account_not_registered` | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66) | | 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 | | 415 | `unsupported_media_type` | 图片/文档类型拒绝 | @@ -719,11 +721,12 @@ | `quietHours.label` | string | 面板可直接显示的文案:`"23:00-07:00"` 或 `"已关闭"` | | `quietHours.fallbackToDefault` | bool,**可选**(`true` 才出现) | 两端都被写成 `0` ⇒ 视为**未填**,运行时回落到默认窗口(`23:00-07:00`,此时 `startHour`/`endHour` 已是回落后的值)。⚠️ **`0/0` 不是「关闭」**:backthink 与记忆整理都会做这次回落,事实面按同一口径报,否则会把「未填」谎报成「已关闭」 | -- **固定 12 条事实**(记忆整理 7 键 + 后台思考心跳 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`、`ENABLE_BACKGROUND_THINKING`、`THINK_INTERVAL_SEC`、`THINK_QUIET_INTERVAL_SEC`、`QUIET_START_HOUR`、`QUIET_END_HOUR`。 +- **固定 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`)。 -- 🔴 **不含任何凭据**:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),**也不回凭据类键的「是否已配置」** —— 本端点只覆盖上面 12 个非敏感调参键。 +- 🆕 **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` 如实展示即可。 -- 12 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。 +- 13 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。 - **安静时段的语义(重要,别照旧实现写文案)**:安静时段**只服务「定时主动思考 → 主动搭话」**(`design/12 §6` 策略五),**不静默回复** —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案**不得**写成「夜间全静默」。 #### (b) `GET /api/v1/admin/backthink` 条目的紧急度字段(v0.68 引入;**v0.70 起是精确档位**) @@ -749,7 +752,7 @@ - **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。 - 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。 -### 4.11 链路图快照 + 多轮历史回放(`GET /api/v1/admin/flow/graph`;v0.71 新增,v0.73 增量) +### 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)。 @@ -760,8 +763,8 @@ | 方法 / 路径 | `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:`(次级会话)。**v0.73 新增** `turnId`(**可选**):省略 / 空串 ⇒ **最近一轮**(**先查内存 `loop.FlowPublisher.LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远是最新的);给了就是该会话已落盘的某一轮(形如 `web-7`,取自 `turns[].turnId` / `graph.id`) | -| 响应体 | `{data:{sessionId, turnId, source, graph, turns}}`(**v0.73**;v0.71 只有 `{sessionId,graph}`)。`sessionId` = 解析后的会话;`turnId` = **实际返回的那一轮**的标识(无图时 `""`);`source` ∈ `"live"`(内存最近一轮)/`"persisted"`(落盘的历史轮)/`"none"`(没有图);`graph` = 该轮的图或 `null`;`turns` = 该会话**已落盘轮次**摘要 `[{turnId,createdAt}]`,**最新在前**(未接线持久化或没有轮次时是 `[]`,恒为非 null 数组) | -| `graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) | +| 响应体 | `{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`(**不把「读库失败」伪装成「没有这一轮」**) | @@ -774,10 +777,23 @@ 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` 取任意保留期内的轮次 + 轮次列表)。二者都**不改** design/19 §1 的发布会话范围(`web` / `web:` / `sess-backthink`,裁决 01.4.5)、**不改** `graph_update` 两路互顶的既有裁决(见 §7.4-19)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 `flow_graphs`(迁移 `0022`),GC 口径照 `events`/`documents`(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。 +`design/19 §4` 把「多轮历史回放」列为 **P3**;v0.71 落实了 P3 里**最小的一步**(每会话最近一轮的只读快照),**v0.73 把 P3 补全**(本地落盘 + 按 `turnId` 取任意保留期内的轮次 + 轮次列表);**v0.77(切片 FV2)补上 `lanes[]`,并把图按 `{会话}#{路}` 分桶**(此前同一会话并行两路会互相顶掉,见 §7.4-19)。三者都**不改** design/19 §1 的发布会话范围(`web` / `web:` / `sess-backthink`,裁决 01.4.5)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 `flow_graphs`(迁移 `0022`),GC 口径照 `events`/`documents`(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。 + +#### (d) `turnId` / `round` 的增量键与**缺席语义**(v0.77) + +`graph_update` 帧的 `data` 与本端点返回的每张图(`graph` / `lanes[].graph`)**同一个结构**;v0.77 起多两个**可选**键(老客户端忽略未知键仍可用;单路时两个键的行为与 v0.75 逐字相同): + +| 键 | 类型 | 语义 | +|---|---|---| +| `turnId` | string | 这张图所属**路**的权威回合标识(与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同值**,design/15 §2.5.9)。**缺席**语义:这一路**没有权威标识**(单路的历史帧、未接编排器的调用方、旧后端)—— 此时客户端按「该会话那一张图」处理,**不得**臆造一个 id。⚠️ 它**不是** `?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 起是 `{会话}-{序号}[#{路}]`(序号是发布器**进程内**的建图序号、重启即从头开始;路标识只在有权威标识时追加)—— 它是 `?turnId=` 的参数值,**不透明**,且**可能含 `#`**,作为 query 值必须 URL 编码(Web 前端用 `URLSearchParams`,已正确编码)。 --- @@ -791,14 +807,14 @@ |---|---| | 方法 / 路径 | `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`),端点**自己不带任何旋钮** | +| 请求体 | **可为空**(端点不读 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"`(零依赖词项余弦,仅当进程没有接 embedder) | | `durationMs` | 本次试算的墙钟耗时(毫秒) | | `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) | -| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受性能护栏上限约束)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) | -| `truncated` | **可选**数组:被护栏截断的分区标签(`<分区>(scan)` / `(compare)` / `(candidates)`)。它**不是**候选;键缺席 = 本轮没有任何截断。命中它说明「配置的配额比本轮实际看到的少」 | +| 观测口径 | `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` 同一档 | @@ -836,23 +852,26 @@ > ⚠️ 本端点的错误 `message` 只说明**原因类别**,原始错误(可能含内网地址等内部细节)只进服务端日志。 -### 4.12.1 记忆整理「试算」的**加性异步**(`?async=1` + B15;v0.76 新增 · ⚠️ 契约先行、尚未落地) +### 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 同:可为空) | -| 立即响应 | **`202`** + `{data:{runId,status:"running",startedAt}}`;`runId` 为进程内不透明字符串(客户端当不透明值用,**不要**解析其结构) | -| 轮询 | `GET /api/v1/admin/memories/tidy/dry-run/{runId}`(B15)⇒ `running` / `done`(带 `result`)/ `failed`(带 `error`) | -| 结果形状 | `done` 时的 `result` = §4.12 同步响应的 `data` **逐键同形**(含 `candidates` 恒非 null) | -| 生命周期 | **进程内存**,TTL **30 分钟**;**同时只允许 1 个在跑**(第二个请求如实拒绝或复用既有 `runId`,实现口径见 `Docs` 这一版的实现提交);**进程重启 ⇒ 旧 `runId` 失效** ⇒ `404 tidy_run_not_found` | +| 触发 | `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);**不审计** | -| 零副作用 | 🔴 与同步路径**同一条硬约束**:异步路径同样**一行都不写** `memory_tidy_candidates` / `memory_tidy_runs`、**绝不改 `memory_items`**(单测必须**在异步路径上**也钉住逐行快照比对) | -| 失败 | 沿用 §4.12 的失败码(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout` / `tidy_dry_run_failed`);**不回空候选数组假装「没有建议」** | +| 零副作用 | 🔴 与同步路径**同一条硬约束**:异步跑的是**同一个** `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`) | | 破坏性 | **无**(新可选参数 + 新端点;老客户端逐字不变) | -> ⚠️ **契约先行的诚实标注**:本小节与 B15 在 v0.76 登记时**尚未实现**(形状由设计先行定下,供客户端与实现并行推进)。落地后须回本文件就近更正,并在 §6 的 v0.76 条目里去掉「契约先行」标注。 +> ✅ **落地校正(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`);② 结果**不是缓存**(每次调用重算,两次之间数据变了结果就可能不同)。 --- @@ -930,9 +949,15 @@ | `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` / `20` | 每分区每轮的扫描上限 / 候选上限。试算端点(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` | `20` | **v0.77 登记**:单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN | +| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) | -> **上面 12 个键(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入);在此之前面板没有任何 env 读取面。 +> **上面 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` 优先) | @@ -956,13 +981,24 @@ > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** -### v0.76(当前契约版本 · ⚠️ **逐条状态不同 —— ②已实现;①未实现(阻塞);③契约先行**) +### v0.77(当前契约版本) -**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **未实现**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(契约先行)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。 +**链路图按路分桶(FV2)+ 记忆整理护栏抬高 / 诚实截断 / 试算异步落地(MT-A)+ 「入库前审查」env 键登记(MT-B)—— 三批都是纯增量**(依据:主人 2026-10-05 报「并行两路时流程图互相顶掉」、以及「工具回看面要先让链路图带上按路的回合标识」;「护栏被打满 ⇒ 候选数由上限而不是规则决定」要治;`design/23 §15.1 D3` 的入库前审查上线)。 -- **① 工具回看面(🔴 未实现 —— 客户端不要按它编程)**:原定 `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。原定数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 实测阻塞(非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID` = `-<轮次序号>`,进程内递增计数)⇒ **无法按消息 join 到它那一轮的图**,而按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需先给链路图加**回合标识**(新键 + 新迁移 `0023` 起),**不在本版**。详见 **§7.2**。 +- **① `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`)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 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(上一版 · ⚠️ **逐条状态不同 —— ①未实现(阻塞);②③已实现**) + +**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **未实现**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(**已由 v0.77 落地校正**)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。 + +- **① 工具回看面(🔴 未实现 —— 客户端不要按它编程)**:原定 `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。原定数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 实测阻塞(非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID`,进程内递增的建图序号)⇒ **无法按消息 join 到它那一轮的图**,而按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需先给链路图加**回合标识**,**不在本版**。⚠️ **v0.77 校正**:链路图的**回合标识已加上**(`graph.turnId`,纯增量、**未用新迁移**)+ 每路一行落盘 ⇒ 这个**前提已具备**;但 `toolCalls[]` **本身仍未实现**(还缺一个「按 `graph.turnId` 查图」的读侧入口),见 **§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`)。 +- **③ 记忆整理试算加性异步**:`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` 已用)。 @@ -1193,15 +1229,16 @@ | **✅ 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 注 | -| **🔴 v0.76 登记的「工具回看面」**(消息 DTO `toolCalls[]`) | **未实现(本版阻塞,客户端别按它编程)**:形状见 §6 v0.76 ①、§4.4。原定数据源是**已落盘的链路图**,但两者的轮次键**不同源** —— `chat_messages.turn_id` 是编排器生成的随机路标识(`turn-` + 16 位十六进制),`flow_graphs.turn_id` 是 `trace.FlowGraph.ID` = `-<轮次序号>`(发布器**进程内**递增计数)⇒ **无法按消息字面 join 到它那一轮的图**;按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需要先让链路图带上**回合标识**(新键 + 迁移 `0023` 起),**不在 v0.76** | +| **🔴 v0.76 登记的「工具回看面」**(消息 DTO `toolCalls[]`) | **未实现(本版阻塞,客户端别按它编程)**:形状见 §6 v0.76 ①、§4.4。原定数据源是**已落盘的链路图**,但两者的轮次键**不同源** —— `chat_messages.turn_id` 是编排器生成的随机路标识(`turn-` + 16 位十六进制),`flow_graphs.turn_id` 是 `trace.FlowGraph.ID`(发布器**进程内**递增的建图序号)⇒ **无法按消息字面 join 到它那一轮的图**;按时间窗硬凑映射属于「猜」,项目口径**不做**。⚠️ **v0.77 校正**:链路图已带上**每路的回合标识**(`graph.turnId`,纯增量、未用新迁移)且**每路一行落盘** ⇒ 「按消息回看那一轮的工具」的**前提已具备**;但 `toolCalls[]` **仍未实现** —— 还缺一个「按路查图」的读侧入口(把 `graph.turnId` 接到消息面上),且 `graph.id` **仍不等于**消息的 `turn_id`。见 §7.4-23 | | **✅ v0.76 登记的「未读面」**(`message_persisted.data.unreadCount`) | **已实现**(本切片):形状、缺席语义与三条缺席路径见 **§4.13**,帧表见 §3.4.4。⚠️ `GET /api/v1/sessions` 的同名键**自 D4 起就已在**(`historySessionSummary.UnreadCount`),本片**未改**它 —— 所以「加同键」这句对列表面其实是**既有事实**,不是本版新增 | -| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** | +| **`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.75 **只**加了「试算」B14):试算只「算给我看」、**不改任何记忆**。`GET /admin/memories/candidates`、`POST .../candidates/{id}/{apply,reject,revert}`、`GET /admin/memories/tidy/runs`、`POST /admin/memories/tidy/run` **均尚未实现**。客户端**不要**假定它们存在(`design/23` 的第一版口径本就是「只产建议,apply 才写记忆」,apply 未做的前置原因见该节开放点) | | **`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) | +| **🔴 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **未实现 —— 目前没有任何 HTTP 入口**(v0.77 登记 · 切片 MT-B):审查结论已逐条落表 `memory_ingest_reviews`(读侧能力 `ListIngestReviews` 已就绪),`Store.RevertMerged` 的回滚能力也在,但 `internal/services/gateway/routes.go` 里**没有**对应路由 ⇒ **客户端不得假定这两个端点存在**(请求只会拿到 404)。同样**未登记**为可用的是 MT-B 报告建议的审计动词 `memory.review.ingest`。⚠️ 本行是「未实现」清单,**不是**可用端点清单 | ### 7.3 诚实边界(既有事实) @@ -1246,14 +1283,20 @@ - **客户端两条硬约束(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.73)**:`loop.FlowPublisher` 的每会话最近一图仍是**内存**(design/19 §1 的实时口径),但 `EndRound` 会把该轮写进 `flow_graphs`(迁移 `0022`)⇒ 后端重启后、该会话还没跑新轮次时,省略 `turnId` 的查询**回退落盘**即可拿到最近一轮(`source:"persisted"`),不再一律 `graph:null`。⚠️ **仍会 `graph:null` 的情形**:该会话本就在**发布会话白名单之外**(隔离渠道会话,从不建图也从不落盘)、或该会话**从未跑过带 span 的轮次**、或该轮已被**保留期 GC 回收**(默认 168h)。**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。 +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:` / `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 登记 · 已按代码与库中数据实测确认)消息 DTO 的 `turnId` 与链路图的 `turnId` / `graph.id` 是**两套互不相干的标识**:前者是编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制,落 `chat_messages.turn_id`;同路的 user 行与 assistant 行同值),后者是发布器的**会话 + 轮次序号**(`-`,如 `web-6`,即 `trace.FlowGraph.ID` / `flow_graphs.turn_id`,**进程内**递增、重启即从头开始)。**两者不可互推、不可互相查询** —— 客户端**不得**拿消息的 `turnId` 去 `GET /admin/flow/graph?turnId=`(那是图侧的轮次键),也**不得**把图上的轮次序号当成会话里的回合序号(序号会在后端重启后重复)。这也是 v0.76 的 `toolCalls[]` 至今**未实现**的直接原因(见 §7.2)—— 若要按消息回看「那一轮调了什么工具」,必须先让链路图带上回合标识。 +23. **(v0.76 登记 · v0.77 更新)消息 DTO 的 `turnId` 与链路图的轮次键:图**已带上每路的回合标识**,但两者仍是两套标识**: + - **消息侧**:`chat_messages.turn_id` = 编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制;同路的 user 行与 assistant 行同值)。 + - **图侧两个键**:① **`graph.turnId`(v0.77 新增)=该图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同源同值** ⇒ 客户端**可以**用它把图与消息行**按路配对**;② `graph.id` / `flow_graphs.turn_id` 仍是**图侧轮次键**(形状 `{会话}-{序号}[#{路}]`,**不透明**;序号是发布器**进程内**递增的建图序号、重启即从头开始),也就是 `GET /admin/flow/graph?turnId=` 的参数值。 + - **客户端三条硬约束**:① 要路标识就**读 `graph.turnId`**,**不得**解析 `graph.id` 去取它;② **仍不得**拿消息的 `turnId` 当 `?turnId=` 用(两者不同源);③ 也**不得**把图上的序号当成会话里的回合序号(重启后序号会重复)。 + - **`toolCalls[]` 的前提已具备、但它本身仍未实现**:v0.77 起每路各落一行、图带 `graph.turnId` ⇒ 「按消息回看那一轮调了什么工具」在**数据形状上**已经可行;但还缺一个**按路查图的读侧入口**(把 `graph.turnId` 接到消息面上),因此 `toolCalls[]` 仍是**未实现**(见 §7.2、§6 v0.76 ①)。⚠️ 别把这句读成「已实现」。 + +24. **(v0.77 登记)「按路分桶」尚未做真机端到端**:真并行(上行 `chat.parallel`)的修复是在**单测层**验证的(真 `Loop.Run` × 2 + 网关照同的 ctx 通道;网关侧走真 HTTP 处理器 + 真发布器),**没有**对运行中的后端发过一次真实 `chat.parallel` —— 因为线上产物是旧构建、且换二进制 / 重启有专门流程。因此**未确证**:真 WS 客户端在真并行下看到的两路帧**时序**(形状与分桶已由单测钉死,时序上等价但无实测)。另有一条**已知的窄竞态**(未观测、也未加锁消除):若一路恰好在另一路起跑的瞬间收尾,第二条路可能被算作**新的一轮**(此时两路仍各有各的图与落盘行,只是面板不把它们并排展示)。 --- -*本文件由「以代码为真源」维护(当前 `v0.76`;`v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 据此把 ①`toolCalls[]` 明确标为**未实现**(原「契约先行」口径作废);v0.76 的 ③ 试算加性异步由另一片维护、①**未落地**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。* +*本文件由「以代码为真源」维护(当前 `v0.77`;`v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.77`(链路图按路分桶 FV2 + 记忆整理护栏/异步 MT-A + 入库前审查 env 键 MT-B)同步了 `internal/runtime/trace/graph.go`、`internal/runtime/loop/**`(`flow.go` / `flow_branch.go` / `run.go` / `steer.go`)、`internal/runtime/orchestration/**`、`internal/runtime/flowgraph/**`、`internal/runtime/memory/**`(`tidy*.go` / `preinsert*.go`)、`internal/config/**`(`config.go` / `memory_review.go`)、`internal/services/gateway/**`(`admin_flow.go` / `contracts.go` / `admin_config_facts.go` / `admin_memory_tidy*.go` / `routes.go`)、`frontend/web/src/store/**`、`frontend/web/src/admin/modules/flow/**` 与本文件(§2.2 B12/B13/B15 / §3.4.5 / §4.1 / §4.10(a) / §4.11 / §4.12 / §4.12.1 / §5 / §6 / §7.2 / §7.4);`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 据此把 ①`toolCalls[]` 明确标为**未实现**(原「契约先行」口径作废;v0.77 起链路图已带 `graph.turnId`,该前提已具备而 `toolCalls[]` 仍未实现);v0.76 的 ③ 试算加性异步**已由 v0.77(MT-A)落地校正**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*