,"reason":"<原因码/文案>"}`。`applied=true` = 这次保存**真的触发了热重载**(按落盘后的配置重建工具 + 原子替换注册表条目 ⇒ **下一轮生效、不需重启**);`applied=false` 且保存前**本来没有**可用工具 ⇒ 文件留下、如实报 `false` + `reason`(那种情形没有能力可丢,报错反而会让主人**没法先配好**) |
| `plugin.shootPage` | 拍一张**当前页面** | write(table `rowActions`) | `{"row":{…}}`(载荷不被解释,只为触发) | `{"shot":{…}}`,`shot` = `browser` 工具 `screenshot` 的返回值原样(见下面「模型面」一节),含 `path` / `bytes` / `sha256` / `format` / `width` / `height` / `dimensionsKnown` / `degraded` / `turnAttachment` / `hint` |
| `plugin.checkFingerprint` | 重新做一次指纹自检(**会启动浏览器**) | write(table `rowActions`) | `{"row":{…}}` | `{"rows":[…]}`(与 `plugin.getFingerprint` 同形,刷新后的读数) |
**字段名(`values` 的键,逐字与 `$DATA_DIR/config/plugins/browser.json` 的 JSON tag 一致)**:`headless`(bool)、`executable`(string,留空 = 自动探测本机 Chrome/Chromium)、`cdpEndpoint`(string,显式给出时取代自动探测)、`profileDir`(string)、`viewportWidth` / `viewportHeight`(number,声明面已给 min/max)、`idleTimeoutSeconds`(number)、`concurrency`(number,**声明面 min = max = 1**:同一 profile 起两个实例会互锁)。🔴 **未知字段一律拒(`400 invalid_payload`),绝不静默忽略** —— 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
**错误码(本插件在 (b) 通用表之上追加的,宿主只透传「码 + 文案 + HTTP 状态」)**:
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|---|---|---|---|
| `400` | `invalid_payload` | 载荷不是对象 / 缺 `values` / `values` 不是对象 / **未知字段** / 字段类型错 | 显示文案原文;**不要**当成「保存成功」 |
| `409` | `browser_unavailable` | 本机**没有**可用浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)⇒ `plugin.shootPage` / `plugin.checkFingerprint` 做不了 | 提示「先去配置里填可执行文件或 CDP 端点并保存」;面板可以配 ⇒ 不是死路 |
| `409` | `browser_session_not_open` | `plugin.shootPage` 时浏览器**没有打开的页面** | 提示「先让她打开一个页面(或在有头窗口里人工接管)」—— **面板刻意不替模型开页**(否则它就成了「任意 URL 的截图器」) |
| `409` | `config_rejected` | 这次保存**没能生效**(新配置会让工具不可用),**已按字节回滚**到保存前那一份 | 显示原因原文;**盘上没有变**,不必再手动改回 |
| `500` | `config_unreadable` | 面板配置存在但**读不出来**(损坏 / 权限)⇒ 本次**没有**改动它 | 显示「配置读不出来」,指向服务端日志;**不要**重试保存 |
| `500` | `config_write_failed` | 配置写不进去(磁盘 / 权限);⚠️ **回滚也失败**时也用这个码,文案明说「盘上的文件可能已经变了」 | **不假装改动没发生**;提示可能需要人工检查盘上文件 |
| `502` | `browser_action_failed` | 浏览器动作本身执行失败(截图 / 自检) | 详细原因只进后端日志;显示「执行失败」+ 文案 |
> **`applied:false` + `reason` 是正常结论,不是错误**:保存前**本来就没有**可用工具时(例如第一次配一个还没起来的容器端点),文件留下、`200` + `applied:false`。客户端**不要**把 `applied:false` 渲染成失败 —— 要说的是「已保存,但这次没有东西可换/没生效」+ `reason`。
**模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(**闭集**,v0.82):
| 取值 | 含义 |
|---|---|
| `queued` | 截图已入**本会话的待送附件箱**,将在本回合的下一次模型调用里作为附件送出(主模型收不了图时以视觉/OCR 的文字形态) |
| `no-session` | 本次调用拿不到会话 id(子代理 / 未接编排器的调用),**未入箱** ⇒ 由模型自己按 `path` 去读 |
| `outbox-full` | 该会话的箱子已满(**每会话最多 4 张**、TTL 10 分钟),**丢掉了最旧的那张**(如实报,不静默) |
| `disabled` | 本工具没有附件箱(`New(nil)` 这类夹具),未入箱 |
配套的 `hint` 字段是一句**如实**的下一步提示(`queued` 时说「已经作为图片附件送出」;其余档说「图没进上下文,按路径读」)。**「入箱」到「进上下文」靠 `loop.Options.TransformContext` 上挂的钩子**(`cmd/cyrene/wire_browser_context.go`)—— 它**取走即消费**(同一张图不会被送两次),也因此**不会**进 checkpoint、不会进后台记忆提取的输入面。⚠️ **该挂点在 v0.82 登记时仍在工作树、未提交**;缺了它,`turnAttachment` 仍会照报 `queued` 而图**一张都不会进上下文**(**零症状**的坏法,因此有一条源码级用例盯着 `wire_runtime.go` 那一行)。
---
### 4.15 记忆整理「应用面」(B20–B24;**v0.83 新增,切片 TIDY-1**)
> 依据 `design/23`(§7 人工在环 / §8 软动作与回滚 / §10 status 列 / §11 管理面)。**背景**:v0.75 起只有「试算」(B14/B15 —— 只回建议、**零副作用**)⇒ 主人的实测口径是「候选 77 条**全 pending**、两天**一条没应用**」。本版把**执行面**补上:候选能列、能应用、能回滚,整理能手动真跑。
#### (a) 端点族一览
| 端点 | 语义 | 关键参数 | 审计 |
|---|---|---|---|
| B20 `GET …/memories/candidates` | 列候选(含 `id`/`runId`/证据) | `status`(省略 = 全部)、`limit`(缺省 100 / 钳 500 / 非法 400) | 无(只读) |
| B21 `POST …/candidates/{id}/apply` | 应用**一条** | 无体 | `memory.tidy.apply` |
| B22 `POST …/candidates/apply` | **批量**应用 | `runId`(省略 = **全部待定**)、`limit`(缺省 100 / 钳 500 / 非法 400) | `memory.tidy.apply` |
| B23 `POST …/candidates/{id}/revert` | 回滚**一条**已应用 | 无体 | `memory.tidy.revert` |
| B24 `POST …/memories/tidy/run` | 手动**真跑**一轮(写候选 + 留痕,**不自动应用**) | 无体 | 领域侧记 `memory.tidy.run`(一次点击一条) |
形状逐字段见 §2.2 的 B20–B24 行。**门一律 `writeAuth`**(owner\|admin)—— 候选正文含隐私,与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档。⚠️ `design/23 §11` 表格把**回滚**标为 owner-only,而实现用了 `writeAuth`:**口径差异待裁决**(若以设计为准,`routes.go` 里把那两行换成 `ownerAuth` 即可,一行一处;与 §2.2 B17 的同类差异是同一件事)。
#### (b) 三条硬约束(客户端与面板都按这个编程)
1. **幂等不是错误**:执行闸是**候选状态的原子条件更新**(`WHERE id=? AND status='pending'` + `RowsAffected`),记忆行侧还有第二道闸(`AND status='active'`)。因此并发重复点击**只有一个赢家**,其余拿到 `200` + `status:"skipped"` —— 面板**不得**把它渲染成失败,也**不得**据此提示「重试」。
2. **超限必须如实**:批量回执的 `total` 与 `applied+skipped+failed` **口径不同**(前者 = 命中筛选的**真**待定数,后者只覆盖 `limit` 之内),差集由 **`truncated:true`** 表达。🔴 这是「不许把本轮 100 / 共 342 画成全部完成」的契约级要求。
3. **软动作、永不物理删除**:merge = 被吸收行 `status='merged'` + `merged_into`;archive = `status='archived'`;**`content` 一个字不改、行一行不删** ⇒ `revert`(B23)必然无损可逆。同时,**软删行必须真的从读侧消失**(v0.83 行为修复):记忆的四条列表读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)只回 `status='active'`,因此被软合并/归档的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。🔴 **只有两处刻意不过闸**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕本身没有 status 概念,且它是软删行的查看入口 —— 加闸等于审计自我抹除)。
#### (c) 调度:到点即跑 + catch-up(对客户端零形状影响)
原实现是「`interval=24h` 的 ticker 从**进程启动**算起」⇒ 后端两天重启十几次,**24h 永远走不完**(`memory_tidy_runs` 只有 1 行,这就是「两天没跑」的一半根因)。现在以 **`memory_tidy_runs` 的留痕行**作为「上次到底跑过没有」的**持久事实**,启动时与心跳到点都判一次(三条判据),**重启不饿死**。B24 是它的手动入口(panic button),与心跳**同一套规则、同一份配置**。
#### (d) 诚实边界(**别当已验证**)
- 🔴 **生产库全程零动作**:1191 行记忆与 77 条待定候选**都没有被应用或清理**。本版交付的是**能力**,不是「已经清过了」—— 真跑与批量应用要主人拍板后执行(可分批、可 revert)。
- **面板上的真机端到端未跑**:本版登记时**前端操作面还没落地**(前端切片并行在做),因此本版证据是**存储层单测 + 网关端到端测试 + 变异自证**,不含「面板点一下真生效」。
- **旧候选的 `run_id` 是空串**(77 条在生产库里都是这样)⇒ 「**按 run 批量**」(B22 带 `runId`)**找不到它们**;要清它们必须走**全部待定**那一档。
- 候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore` 的 `ensureTidySchema`),**未**同步进迁移 `0019`;组合根每次启动都会跑到前滚,因此生产库拿到该列没问题,但「迁移是不是唯一真源」这条口径**有欠**。
- **`memory.recall` 的语义召回在本机不可复现**(远端 embedder 对同一文本返回不同向量,TIDY-2 诊断发现)—— 它**不影响**本族的规则层判定与状态机,但**影响「整理候选是否可复现」**,登记在此以免被当成实现缺陷。
#### (e) v0.85:「不再只产建议」+ `link` 停产 + 按动作批量拒绝(B25)
**① 轮末自动落地(默认生效)**:整理跑完一轮后会**自动**把两类**规则层已判定为高置信**的候选落地,不再等人点:
- `merge`:**字面重复**(两侧正文逐字相同)**或**「事实集合等价 ∧ 相似度 ≥ **0.95**」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:习惯类会因正文里带钟点被误判成一次性事件,**归档它是错的**)。
🔴 **不自动**(别指望它替你干这些):`reclassify`、**低置信** merge、跨分区 / 跨角色、**任何改正文**、**受限来源(群聊 / 陌生人)**。
**六条护栏**:软动作(只改 `status` / `merged_*`,**正文不改、行不删**)· **每轮上限 50**(硬顶 500,超限**如实** `truncated` + 真总数)· 审计 `memory.tidy.apply`(`auto=true`,**不带正文**)· **可 revert**(回滚路径与人工应用**同一条**)· 幂等(候选状态条件更新)· **一键关回只产建议**:写 `$DATA_DIR/config/memory-tidy.json` → `{"autoApply": false}`(调上限同文件 `autoApplyPerRound`;**启动期读一次,改了要重启**;**零新 env 键**)。
**② `link` 候选已停产**:`link` 在 `design/23 §14` 里是**未细化项**(既无存储落点、也无数据模型),而 `applyable()` 只认 `merge/archive/reclassify` ⇒ **应用一条 `link` 只会拿到 `skipped`**。⇒ 规则层不再产出它(判 `keep`)。`TidyLink` 常量保留**只为读历史行**与「不可应用」判定。
**③ 存量清理走 B25**(幂等、只改候选状态、不碰记忆):
```bash
BASE=http://127.0.0.1:10942
# ① 先看现状(默认只看 pending;limit 缺省 100、钳 500)
curl -s -H "X-API-Key: $KEY" "$BASE/api/v1/admin/memories/candidates?status=pending&limit=500"
# ② 一次性拒绝全部 link 候选(幂等;limit 缺省 1000、硬顶 10000)
curl -s -X POST -H "X-API-Key: $KEY" \
"$BASE/api/v1/admin/memories/candidates/reject?action=link&limit=1000"
# 期望 {"data":{"action":"link","limit":1000,"total":501,"rejected":501,"truncated":false}}
# ③ 复核:同一调用再来一次 ⇒ total:0 / rejected:0(**不是错误**)
```
🔴 **只该整类拒 `link`(必要时 `keep`)** —— `merge/archive/reclassify` **有执行语义**,交给自动档或人工,别整类拒掉。
⚠️ 本条的 `total/rejected/truncated` 口径与 B22(批量应用)**同构**:`total` 是真命中数,处理数只覆盖 `limit` 之内。
### 4.16 助手文本的段落标签约束(**v0.84 新增,产出侧硬约束**)
**约束(一句话)**:助手文本在**落库、对外读接口、IM 镜像**里**不得出现未配对的段落标签** —— 不许有落单的 `` 或 `
`。语义上:**一个 `` = 一段,段落不嵌套**。
**为什么写进契约**(这曾是真实纠纷):安卓端从 `GET /api/v1/sessions/web/messages` 读到过一条 `content` 形如
```
满意地点点头
真乖♪ 这可是今天最让人家放心的一句。
那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?
```
他们的解析契约 `P_BLOCK_RE = ]*)?>([\s\S]*?)
` 是**非贪婪**的 ⇒ 第三段必然匹配成「外层 `` 开、内容 = `
正文`、`
` 收」⇒ **用户直接看到字面的 ``**。这不是他们解析器的口味问题:**任何按该契约定实现的消费方(Web 面板 / IM 桥 / 第三方)都会踩**。
**实测根因(两半都在产出侧,已修 `8051906`)**:
1. 规整器**原样放过**该形状(分块正则非贪婪 ⇒ `
X
` 被当成「一个已完整包裹的 `` 块」逐字保留;更糟的是旧的覆盖检查用**同一非贪婪正则**,护栏本身是盲的);
2. 规整器还会**亲手制造**它:段首 `
` 未闭合(模型末段漏 `
`)时**无条件再套一层** —— 实证:模型吐 `那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**;
3. 顺带修掉一个**幂等漏洞**:落单 `
` 旧行为 ⇒ `正文。
`,再跑一次又变 ⇒ 越跑越多。
**修后的处置口径(产出侧,逐条有测试)**:`X
` ⇒ `X
`;`X`(漏闭)⇒ 补 `
`(**补闭不补开**);`正文
` ⇒ `
正文
`(合并不吞字);`A
` ⇒ `
A
`(丢空开标签、不造空气泡);`` ⇒ 整对丢;落单 `` ⇒ 丢标签、**正文不动**。
**🔴 客户端要做 / 不用做的**:
- **不用做**:不必为这个畸形写特例解析器(产出侧已保证不再产出)。
- **仍要做**:**保留**你现有的「剥落单标签」容错 —— ① **历史行不回填**(库里已经有畸形的行);② 产出链**不止一条**(渠道镜像 / 主动推送 / 后台思考各有包装点),不能假设全都干净。
- **护栏(不许退化)**:`1 < 2`、`a`、``)、代码围栏内的 `
`、`` 块内的 ``、自闭合 `
` —— 这些**都不是**段落标签,一律原样保留(产出侧与消费侧**同一条口径**)。
**一个保守处置(待设计裁决,生产库 0 例)**:`A\n\n
B
`(未闭合段落之间夹正文)产出侧选择**合并** ⇒ `A\n\nB
`,**不**做「插入式隐式收尾」(那要猜作者意图)。客户端不必为此适配。
### 4.17 记忆整理调优键的读写面(B26 / B27;**v0.92 新增,切片 M4**)
| 项 | 值 |
|---|---|
| 方法 / 路径 | `GET` 与 `PUT` `/api/v1/admin/memories/tidy/tuning` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档 |
| 审计 | GET **不审计**;PUT 落 **`memory.tidy.tuning`**(`result` ∈ `ok\|error`) |
| 落盘 | `$DATA_DIR/config/memory-tidy.json`(原子写:temp + rename;**值一个都没变时不写盘**) |
它救的是「这些键只能改文件 + 重启后端」——面板从此是配置的一等来源(`.env` 只做兜底)。
#### 管的 7 个键
| key | kind | unit | 取值 | 兜底来源 |
|---|---|---|---|---|
| `intervalSec` | int | sec | ≥ 1 | `env:MEMORY_TIDY_INTERVAL_SEC` |
| `quietIntervalSec` | int | sec | ≥ 1 | `panel:intervalSec` |
| `quietStartHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_START_HOUR` |
| `quietEndHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_END_HOUR` |
| `ingestGate` | bool | — | — | `code-default` |
| `autoApply` | bool | — | — | `code-default` |
| `tempTtlSec` | int | sec | ≥ 1 | `code-default` |
🔴 **`specs` 是取值规则唯一真源**:面板据此渲染与就地校验 ⇒ 不会出现两套规则。`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
#### 响应(GET 与 PUT 的 200 同形状)
```json
{
"path": "config/memory-tidy.json",
"present": true,
"hot": false,
"hotError": "…(仅 hot=false 时出现)",
"specs": ["…见上表,每项 {key,kind,unit?,min?,max?,fallback}…"],
"values": {"intervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": false},
"envValues": {"intervalSec": 86400, "quietStartHour": 23, "quietEndHour": 7}
}
```
🔴 **`values` 的缺位语义**:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键;同文件里**不受管**的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**,但 PUT 时**原样保留**。
🔴 **`envValues`**:键缺位时的**下一档(`.env`)进程生效值**(与 `/admin/config-facts` 同源 `s.cfg`,网关**绝不 fopen `.env`**);某键连 `.env` 档也没有值 ⇒ 该键不出现(例:`intervalSec ≤ 0`、安静时段两端都为 0 = 未填)。
#### 写侧三态(硬要求,逐字)
| 请求体里 | 结果 |
|---|---|
| 键**缺位** | 不改动 |
| **合法值** | 采用 |
| **非法值** | `400 invalid_payload`,且**盘上一个字节不动** |
| **未知键**(含拼错) | 归「非法」⇒ `400`,**绝不静默忽略** |
| 值 `null` | **清除该键**(回到下一档兜底) |
- 请求体 = **扁平 JSON 对象**(值类型 int 秒/钟点 或 bool)。
- **PUT 的 200 是「写完重新读盘」的真值**,不是回显请求体。
#### 错误码
| code | 何时 |
|---|---|
| `400 invalid_payload` | 值非法 / 未知键 / 体不是对象 / 体是 `null` |
| `409 tuning_corrupt` | 盘上那份**运行期读不走** ⇒ **拒绝覆盖**(文案说明此刻正回落 `.env`/代码默认) |
| `500 tuning_unavailable` | 数据目录未接线 |
| `500 tuning_write_failed` | 落盘失败(盘上未改动) |
#### 🔴 `hot` 的诚实口径(客户端按此渲染)
- `hot:true` = 值**已叠到运行期**:整理档会 `Stop→写→Start` **重排心跳**(否则新周期要等旧周期那一觉睡完 —— 主人把 24h 改成 3h 就「等于没改」);审查档重算 `ingestGate` / `tempTtlSec`。
- `hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「已保存到面板配置文件,**重启后端后生效**」。
- **绝不把「落盘了」说成「生效了」。**
#### `recallMode` 的渲染(前端,承接 v0.91)
试算卡片(B14)的 `recallMode` 现在四态如实渲染:`embedding` ⇒「召回来源:语义向量」|`lexical` ⇒「召回来源:词项余弦(未接向量,已降级)」|`mixed` ⇒「召回来源:部分分区降级(其余走向量)」|`""` ⇒「召回来源:无候选可比(本轮未做召回)」——**不再渲染成空标签**。未登记取值 ⇒「…(未登记取值)」(不吞信息)。「尝试过但向量源不可用」走**错误路径**(`error.code === "tidy_recall_unavailable"`),面板额外渲染一句解释,**不套在别的错误码上**。
## 5. env 与模型配置(与契约相关)
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `/models.json`(默认 `./data/models.json`);「渠道管理」页写 `/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
| 键 | 默认 | 与契约的关系 |
|---|---|---|
| `PORT` | `8080` | 网关唯一端口(含 REST/WS/admin) |
| `HOST` | `0.0.0.0` | 监听地址 |
| `ADMIN_PATH` | `admin` | 管理面板**前端路由前缀**(`/`+值)。**不是 REST 契约**:`/api/v1/admin/*` 一律不藏。非法值回退 `/admin`;路径形如面板入口但前缀不符 → `404` |
| `CHAT_IMAGE_MAX_BYTES` | `5242880`(5 MiB) | `chat.image` **字符串长度**上限的换算源(**单位是字节**,按 base64 4/3 膨胀换成字符:`ceil(n/3)*4 + 64`,默认 **6990572 字符**;只作用于 **base64 / `data:` 形态**,URL 形态不走这道检查)。`≤0` = 用默认;**只能收紧**,钳到 vision 硬顶(见 §4.5) |
| `CHAT_DOCUMENT_MAX_BYTES` | `20971520`(20 MiB) | `POST /documents` 单文件上限(`≤0` 用默认;与图片上限**刻意解耦**) |
| `AUTH_REFRESH_TTL` | `720h` | refresh token 有效期(access 固定 24h) |
| `AUTH_REFRESH_ROTATE_DISABLED` | `false` | `true` = 关闭 refresh 单次轮换 |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 占位 | 登录凭据(`POST /auth/login`);必填 |
| `JWT_SECRET` | 占位 | 必填、≥32 字符(签名 JWT) |
| `DATA_DIR` | `./data` | 数据目录;接受保留字 `platform`(大小写不敏感)→ 平台标准目录 |
| `DB_SQLITE_PATH` | `./data/cyrene.db` | SQLite 路径 |
| `DB_PG_URL` | 空 | pgvector **可选增强**;配置了必须真实走语义/向量路径。是否生效另由 `GET /admin/system/storage` 的 `vector.{enabled,ready}` 如实报告 |
| `ALLOWED_ORIGINS` | 示例值 | CORS + WS Origin 白名单(同 host 与回环别名额外放行) |
| `WS_MAX_CONNECTIONS` | `1000` | WS 连接上限(配置项,**未确证**是否在网关强制生效 —— 代码只见解析,未见到接线点) |
| `LLM_MULTIMODAL` | `false` | **主模型自身能否收图**的 `.env` 等价物(只作用于 `.env` 合成组;有有效 `models.json` 时以该组 `multimodal` 为准)。`true` = 图片直进主模型;`false`/留空 = 图片交 vision/OCR 工具。**非法值启动即报错** |
| `LLM_VISION_MODEL` / `LLM_OCR_MODEL` | 空 | **只供视觉/OCR 工具**调用;**不代表主模型能收图** |
| `LLM_CONTEXT_WINDOW` | 空 → 131072 | 上下文窗口(预算推导依据);负数启动报错 |
| `CONTEXT_MAX_TOKENS` | 空 → 按窗口推导 | `>0` 显式覆盖;`0`/负 = 关闭压缩。⚠️ 若 `.env` 里还留着 `32768`,它就是显式覆盖、推导不生效 |
| `CONTEXT_BUDGET_RATIO` | `2/3` | 预算占窗口比例(接受 `2/3` 或 `0.6667`) |
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
| `LLM_USAGE_RETENTION` | `720h` | `llm_usage` 保留期(`0`/负 = 关闭清理) |
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值)。`THINK_INTERVAL_SEC`(`900`)/ **`THINK_QUIET_INTERVAL_SEC`(`3600`,v0.75 新增)**:落在安静窗口内时,**周期性心跳**改用后者降频、窗外回落前者;**`≤0` = 关闭降频**(回落普通间隔,**不是**「半夜不思考」)。⚠️ 它**只降周期性心跳**:事件驱动(安全域/急性事件)照旧立刻触发、照旧穿透安静时段 |
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆**。**v0.75 起**:关闭时试算端点(B14)也**拒绝作答**(`409 tidy_disabled`),不绕过这个选择 |
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7`。⚠️ 窗口**只管心跳调度**:试算端点(B14)**不受它约束**(否则「现在就想看」正是它存在的理由) |
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定)。试算端点(B14)用**同一个**值 |
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / **`200`** | 每分区每轮的扫描上限 / **候选**上限(⚠️ 两者都是**每分区**的量,不是全库)。候选上限的默认值 **v0.77 由 `20` 抬到 `200`**(首轮真跑四个分区同时打满旧值 ⇒ 候选数由上限而不是规则决定);命中它时响应 `truncated` 带 `<分区>(candidates)`。试算端点(B14)用**同一对**上限(不另造一套) |
| `MEMORY_TIDY_COMPARE_LIMIT` | `2000` | **v0.77 新增**:每分区每轮**真正参与两两比对**的条目上限(性能护栏,两两相似度是 O(n²·dim))。此前**写死为 `400` 且没有 env 键**,实测把 459 条的分区截到 400 ⇒ `compared(705) < scanned(784)`(观测口径被上限决定)⇒ v0.77 抬到 `2000` 并开放为 env。命中它时响应 `truncated` 带 `<分区>(compare)` + 日志 WARN。`≤0` = 用默认 |
| `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) |
| `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
| `MEMORY_REVIEW_TIMEOUT_SEC` | **`90`**(v0.93 起;v0.77–v0.92 为 `20`) | 单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN。🔴 **默认值的唯一真源在 memory 包**(`internal/runtime/memory` 的 `defaultReviewTimeout`)—— 该 env 键**不设自带默认值**,未配置读出 `0` = 「没配」,由 `normalizeReviewConfig` 单点落地;**显式写正数才压过它**(不做「保底抬到 90」的加码)。⚠️ `20s` 已被实测证伪:批量 8 时一条完整召回链墙钟 **21.5–28.2s** ⇒ 20s 预算会让审查**整条失败且不写留痕**(见 §6 v0.93) |
| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) |
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记,已接线**(E4 `afe9afb`;`.env.example` 同步于 `f9c5b56`):`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记,已接线**(E4 `afe9afb`):渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
| `FLOW_GRAPH_RETENTION` | `168h` | **v0.73 新增,已接线**(切片 F2):`flow_graphs` 轮次图保留期;`≤0` = 关闭 GC(显式选择,「配了却不生效」不存在)。过期轮次从 `turns[]` 消失(见 §4.11(b)-5 / §7.4-20) |
| `FLOW_GRAPH_GC_INTERVAL` | `1h` | **v0.73 新增,已接线**(切片 F2):链路图保留期清扫间隔(与 `CHANNEL_EVENT_GC_INTERVAL` / `DOCUMENT_GC_INTERVAL` 同范式;启动期扫一次 + 定时 + 失败不退出) |
| `WEB_SEARCH_*`(`WEB_SEARCH_PROVIDER` / `_FALLBACK` / `_MAX_RESULTS` / `_ENDPOINT` / `_TIMEOUT` / 各家 `_API_KEY` / `_BASE_URL`) | 见模板 | **v0.78 起语义收窄(切片 PLG-4,运维视角的行为变更)**:web-search 的**可变源**改为 `$DATA_DIR/config/plugins/web-search.json`(0600、文件名 = 插件 id)。组合根**先读可变源、文件缺失才回落 `.env` 播种**(即 `.env` 只作**引导面**):**文件一旦落盘,改 `.env` 不再影响运行中的部署**。🔴 **不新增任何 env 键**(`.env` 已冻结);客户端一律走插件动作端点(§4.14(c)),**不直连该文件** |
> ⚠️ **v0.78 的「`.env` 只播种」口径**(切片 PLG-4):这与 §5 开头「**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层」是**同一条原则**在搜索 provider 上的落地 —— 此前 web-search 是纯 `.env` 面,v0.78 起它也有了面板/文件配置面。**无 env 键新增/删除**;模板里既有 `WEB_SEARCH_*` 键**保持现状**。
**与契约相关但「配了不生效」的键**:
- **`CHAT_MAX_PARALLEL_TURNS`:未接线**。编排器有构造期旋钮 `Deps.MaxParallelTurns`,但**组合根未从任何 env 读它** ⇒ **生产恒为默认 2**(`.env` 里配了不生效)。**本文档不把它登记为可用旋钮**。
- **`ENABLE_BACKGROUND_THINKING` / `THINK_SILENCE_TIMEOUT_SEC`:未接线**(全仓无消费者;`DevDocs/audit/07-backthink-automation-iot.md` §1-17,是否接线待主人拍板)。`ENABLE_BACKGROUND_THINKING` 自 v0.75 起被列进 B12 的只读事实面 —— 那是为了**让面板看得见这个死开关**,**不是**「它已生效」的声称:`facts[].value` 只是解析值,配 `false` 也不会关掉后台思考。`THINK_SILENCE_TIMEOUT_SEC` **未列入**事实面。
- 旧 `MCP_*` 扁平键:生产装配只读 `MCP_SERVERS_JSON`。
- `GETUI_*`:SDK 未接入,预留占位、当前不生效。
---
## 6. 变更记录
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.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/`**(插件只给动作名、不给 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": "", "transport": "stdio|sse|http" }`。**宿主内置能力整键缺席**(不是空对象)—— 「没有 `origin`」正是「宿主自己提供」的表达。
- 🔴 **归属只来自注册那一刻的元数据,绝不解析工具名字前缀**:MCP server 的工具名由它自己给,宿主不加前缀;实测一台 `halo` server 的工具分属 4 个不同前缀族 ⇒ 按前缀猜归属必错。
- **这是呈现隔离,不是权限隔离**:`tool_policies` 没有 server 维度,`mcp:` 策略行是惰性的 ⇒ **本版不提供「按 server 开关工具」,也不给假开关**。
### v0.93
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
- 🔴 **`MEMORY_REVIEW_TIMEOUT_SEC` 的默认值 `20` → `90`**,并把默认值**收到唯一真源**:它**不再自带 env tag 默认值**,未配置读出 `0` = 「没配」,由 memory 包 `normalizeReviewConfig` 单点落地 `defaultReviewTimeout`。
- **未配置 ⇒ `90s`**;**显式写正数 ⇒ 原样生效**(**不做**「保底抬到 90」的加码);**写 `0` / 负数 ⇒ `90s`**。
- 🔴 **它修的是一个「看起来改了、其实没改」的事故**:v0.92 那批切片把 memory 包的 `defaultReviewTimeout` 从 `20s` 改成 `90s`,但 config 包的 env tag `default:"20"` **永远产出非零的 20** ⇒ `normalizeReviewConfig` 的「`≤ 0` 才填默认」分支**永远不走** ⇒ **90s 是死代码**,线上启动行仍打印 `timeout=20s`。
- **为什么必须修**:批量上限抬到 8 之后,一条完整召回链实测 **21.5–28.2s** ⇒ 20s 预算下审查**整条失败、且不写留痕** ⇒ 召回降级、灰区恒空、T1 发不出调用 ⇒ 「重复记忆收不动」这条链**只修了一半**。
- **为什么旧断言拦不住**:既有跨包断言比的是 **memory 包的常量**(「我打算用多少」),而病害发生在**另一个包**(config 的 tag),单包自洽、跨包漂移看不见。本版新增一条**走真实组装路径**的断言(`LoadMemoryReviewConfig` → `ReviewConfig` → `normalizeReviewConfig` → **生效值**,再断言它 > 实测最坏链长 + T1 子超时),并在注释里写明「**比常量 vs 比生效值**」的区别。
- **兼容性**:**纯行为修复,无 API 形状变化**;`.env` / `.env.example` 文件内容**一字未动**(该键从未进过模板,符合 `.env` 冻结口径);受影响的只有「没显式配这个键」的部署 —— 它们的单条审查预算由 20s 变 90s。
- **诚实边界**:若某部署**显式**写了 `MEMORY_REVIEW_TIMEOUT_SEC=20`,本版**不会**替你抬到 90(那是显式配置,尊重它)。另有两处**散文副本**仍写着旧值(`internal/runtime/memory/preinsert_t1.go` 的一处注释),已 file finding、不在本版改动面内。
### v0.92
**「记忆整理调优键可写」版 —— 管理面新增 2 条 `writeAuth` 端点,把面板从「只能改文件 + 重启」救出来**(依据:主人 2026-10-13 口径「`.env` 只做兜底…这些键优先从面板配置上取」「频率必须调…白天间隔短一点、静默期长一点」;实现切片 M4)
- **新增 `GET /api/v1/admin/memories/tidy/tuning`(B26)** 与 **`PUT` 同路径(B27)**。管 7 个键:`intervalSec` / `quietIntervalSec` / `quietStartHour` / `quietEndHour` / `ingestGate` / `autoApply` / `tempTtlSec`。门 = `writeAuth`(owner|admin;`viewer` → 403,未认证 → 401),与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档;GET 只读**不审计**,PUT 落审计 **`memory.tidy.tuning`**(`result` ∈ `ok|error`)。
- 🔴 **响应三块的分工(客户端按此编程,别自己算默认值)**:
- `specs[]` 是**取值规则唯一真源**(`kind` / `unit` / `min` / `max` / `fallback`),面板据此渲染与就地校验 ⇒ 不会出现两套规则;`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
- `values` 是**盘上**的值:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键,同文件里不受管的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**。
- `envValues` 是键缺位时**下一档(`.env`)**的进程生效值(与 `/admin/config-facts` 同源,网关**绝不 fopen `.env`**);连 `.env` 档也没有值 ⇒ 该键不出现。
- 🔴 **写侧三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**。**未知键(含拼错)也归「非法」,绝不静默忽略**。值 `null` = **清除该键**(回到下一档兜底)。错误码:`400 invalid_payload` | `409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)| `500 tuning_unavailable`(数据目录未接线)| `500 tuning_write_failed`(落盘失败,盘上未改动)。
- **两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。PUT 的 200 与 GET **同形状**,但值是**写完重新读盘**的真值。
- 🔴 **`hot` 语义(绝不把「落盘了」说成「生效了」)**:`hot:true` = 值已叠到运行期(整理档会 `Stop→写→Start` **重排心跳**,否则新周期要等旧周期那一觉睡完;审查档重算 `ingestGate`/`tempTtlSec`);`hot:false` = 已落盘但运行期没接上,`hotError` 给原因,面板文案必须是「**重启后端后生效**」。GET 的 `hot` **恒为 false**(读不改变任何东西)——那是如实占位,**别读成能力声明**。
- **兼容性**:**纯增量**(新端点;既有端点/帧一字未改),老客户端零影响。
- **计数**:`writeAuth` 段 25 → **27**;管理路由总数 79 → **81**(`14 + 27 + 40`)。字段级说明见 **§4.17**。
### v0.91
**「召回来源如实上报」版 —— `recallMode` 取值域扩两个 + 语义收紧**(实现 `5967272`,切片 M9;起因 = M6 诊断发现旧实现把「全量降级」静默报成 `embedding`,见 `DevDocs/audit/去重失效诊断-2026-10-13.md`)
- `recallMode` 现在**如实上报本轮实际选用的召回路径**,取值域:`"embedding"` | `"lexical"` | **`"mixed"`(部分分区降级、其余走向量 —— v0.91 新增)** | **`""`(没有候选可比 ⇒ 本轮未做召回 —— v0.91 新增;它不是降级,也不该被显示成空标签)**。
- 🔴 **行为变更(客户端须跟进)**:旧实现报的是**配置态**(进程接没接 embedder),因此「向量路径实际失败、回退到词项」会被显示成 `embedding` —— 面板**看不出降级**。现改为实际态。**试算端点(B14)与整理轮日志/审计同源**;整理轮另新增 `recall_degraded_partitions` 计数,用于回答「哪几个分区降级了」。
- **兼容性**:按 `embedding|lexical` 二分读的老客户端需自行兜底新值(`mixed` → 「部分降级」;`""` → 「无候选可比」)。前端渲染由后续切片修正(`MemoryTidyDryRunCard`)。
- **附带的稳定性修复(对客户端透明,无契约影响)**:embedding 批上限改为按「单次请求预算 = 客户端超时的一半」**推演**得出,并在 embedder 内部强制切片(任何调用方给多大的批都安全);**客户端 5s 超时值本身一字未改**。它修的是「客户端先放弃、服务端继续算 ⇒ 队列堆积级联」这条真机制(M9 实测:空闲态单条 ~0.03s(ASCII)/ ~0.16s(40 汉字),饱和态可放大 70~90 倍)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。字段级说明见 **§4.12** 的 `recallMode` 行。
### v0.90
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
- **三态闭集(口径落在适配器自身,网关只做映射)**:
| 状态 | 含义 | 线上投影 |
|---|---|---|
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
### v0.89
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
- 🔴 **背景(实测)**:她输出的**不是 Markdown**(最近 12 条 assistant 消息里 `**` 记号 0 个、列表行 0 个),而是**有限的一小组 HTML 标签**:`` 96 次、`` 34 次、**`` 6 次**。`` / `` 各端早已处理,**`` 此前没有任何一端处理 ⇒ 用户直接看到裸标签**。
- **新增渲染口径(跨端一致)**:助手消息正文里,以下**行内标签**应被渲染成对应样式,而**不是**按字面显示:
`b` / `strong`(粗)· `i` / `em`(斜)· `code`(等宽)· `u`(下划线)。
⚠️ **白名单最终定为上面六项** —— 主人 2026-10-07 明确「**下划线确实可以保留**」⇒ `u` 加回;`s`(删除线)**仍不在其内**(她 100 条输出里 0 命中);`br` **不是行内标签**,见下一节(它是分隔符)。
- **🔴 `
` 是「气泡/消息分隔符」,不是行内标签(同日追加,主人口径)**:主人原话「**对消息内的 `
` 始终处理为多条消息隔断**」。
- **语义与 `` 同级**:`
` 两边的文本**必须成为两条独立的消息**(IM 侧)/ **两个独立的气泡**(Web 侧),**而不是**同一条消息里的一个换行。
- `
` / `
` / `` 三种写法**等价**(HTML5 口径)。
- **空气泡照旧丢弃**(与既有 `
` 空气泡口径一致);`
` 两侧都是空白 ⇒ 不产生多余空气泡。
- ⚠️ **影响面(各端须知)**:一条含 `
` 的回复在 IM 上会变成**多条消息**(与 `
` 一样受既有的气泡间隔节流约束)。
- ⚠️ **实现状态**:本条发出时**实现跟进中**(Web 侧 `2dae469` 当时把 `
` 当行内换行,正在改;IM 出站侧当时连行内标签都还没剥,正在改)。**以本契约为准,不以当日实现为准。**
- **🆕 非 chat 模式(单卡正文)的口径(同日追加)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置 等**单卡正文**界面**不是气泡模型** ⇒ 那些模式里:**`
` ⇒ 一个真换行(不切分)**、**行内白名单照常出样式**、白名单外仍逐字原样。⚠️ **这些模式里也不许出现字面标签**。各模式**既有的较强排版约定优先**(例:工作模式的按行切清单、代码模式的 `
` 等宽块 —— 通用规则不得覆盖它们)。
- **🔴 `
` 在哪**不**分隔(同日追加,跨端口径)**:`
` **只在「普通文本层」分隔**。以下两种位置**不分隔**:① **角色注记内部**(`` / `` 里)—— 否则注记正文会被切成聊天气泡、把本该隐藏的旁白发给对方;② **白名单行内元素内部**(尤其 ``)—— 它是**成对**元素,从中间切开必然制造不平衡、触发「整段降级」,反而把裸标签露给用户。
- **🔴 三条硬口径(各端必须照此实现,否则跨端显示不一致)**:
1. **白名单外一律按纯文本原样显示** —— `