# dsh-mneme 压缩边缘抢救（issue #249 N3）

- **日期**：2026-09-24
- **状态**：✅ 已实现（opt-in，默认关）
- **范围**：上下文即将被宿主压缩前，抢救「正在做什么」，让它活过这次压缩
- **相关文档**：[存储与回收](STORAGE.md)（同批的 #275 第一批） · [语义增强](SEMANTIC.md)

## 1. 问题

宿主的压缩摘要器只看对话里的内容：压缩时它按固定字段模板重建对话（Current Work /
Next Step / Critical Context 等），**系统提示段里的东西不进这次重建**。所以任何"只放在
提示段里的工作状态"，一过压缩就等于没写过。

## 2. 形态：必须双落点

| 落点 | 去处 | 为什么不能省 |
|---|---|---|
| ① 落提案 | `continuity_proposals` 表（`src/store.js`） | 脱离对话独立存活；注入失败/被跳过也不丢工作状态 |
| ② 追加消息 | 序列末尾一条 `user/message`（`source: {kind: "plugin", plugin: "dsh-mneme"}`） | 这是唯一能进宿主摘要器重建范围的落点 |

落库先于注入。落点顺序由宿主契约保证：压缩插件在自己那一步先压缩、再 `return next()`，
所以我们在 `agent/pre-step` 拿到 `next()` 结果时压缩事件已落库；而我们返回的
`decision.messages` 由宿主在本步末尾以 `surfaceOp: "append"` 追加，晚于压缩的
`surfaceOp: "replace"`——追加物天然在压缩之后、靠近序列末尾。

注入文本是**结构化快照**（统一前缀 + 三行固定字段，整条 ≤900 字符），不是 §6.3 那种
≤160B 的单行提醒——那条上限约束的是"一句话提醒"，不约束固定字段的快照；字段内部的换行
仍会被压平成空格，保证它仍是一条消息。**判重按当前表面**（`session.surface.nodes`），
不按全量日志：被一次压缩折叠出表面的旧快照还在 append-only 日志里，照日志判重会让这次
该补的注入静默跳过。

## 3. 触发

**不自定阈值**，就是订阅宿主真的压缩（都在宿主的事件白名单里）。理由：「压缩边缘」由宿主
定义，我们再校准一份阈值参数只会与之漂移，还多一份没人维护的旋钮。

**但「发过压缩事件」不等于「这段历史真的被折叠了」**：宿主的压缩插件在**每一步**都会试一次
（`agent/pre-step` 里的 `compactIfNeeded`），而代理层（如 billion-context）会拒掉原生压缩。
一次失败的尝试什么都没折叠，不需要抢救 —— 按「试过就救」记账，触发器就退化成「每步一次」。
所以只认**内容真的被移除过**的三种事件（判定在 `src/continuity.js` 的 `isCompactionEdge`）：

| 事件 | 算不算边缘 | 为什么 |
|---|---|---|
| `compaction/summary` | ✅ | 宿主压缩成功才落（`dsh-compaction-basic` 的 `commitCompactionBody`） |
| `compaction/prune` | ✅ | 宿主把超预算的旧 tool/result 折叠出表面 —— 「表面真的变短了」最直接的证据 |
| `compaction/end` **不带 error** | ✅ | 成功路的收尾 |
| `compaction/end` **带 error** | ❌ | 成败两条路都发这个事件，失败路额外带 `error`；整笔事务失败 |
| `compaction/start` | ❌ | 只是开锁标记（「有人试过」） |

本机实测（2026-10-14，两个长会话、12,000+ 事件，原生压缩被 100% 拒绝）：按 `start|summary|end`
记账置位 1,287 / 2,895 次，改成「真落地」后降到 39 / 161 次。

DSH 没有 in-memory 消息改写钩子，所以本机制一律是「注入 + 序号引用寻址」，**不改写
历史消息本体**。

## 4. 字段与抽取口径

固定三字段（不用自由散文），全程不调模型、纯确定性抽取（`src/continuity.js`）：

| 字段 | 来源 | 截断 |
|---|---|---|
| `current_work` | 最近一条**真实** `user/message`（插件注入与子代理上报不算用户指令） | 头部 200 字符 |
| `next_step` | 最近一条 `assistant/message` | **尾部** 200 字符（下一步活在末尾那句里） |
| `open_questions` | 恒 `null` | —— |

「哪些问题还没解决」判不出就是判不出，硬猜会给出似是而非的字段；空字段在注入文本里
如实标 `(none)`。这个字段留给转正通道与人工补。

字段名以 #249 §6.2 为准（`current_work` / `next_step` / `open_questions`）。§4.4 里的
Current Work / Next Step / Critical Context 是**宿主压缩模板**的字段名，不是这张表的列名
（规格里两处用词不同，实现取 §6.2）。

## 5. 存储与生命周期

```
continuity_proposals(id, session_id, kind, current_work, next_step, open_questions,
                     status, edge_seq, created_at, updated_at)
UNIQUE (session_id, kind)   -- 「同一会话同一类只留一条」：再次触发是刷新，不是新增
```

- `kind`：`compaction-edge`（一次会话里这一类只留一条）。
- `status`：`pending`（本批唯一写入态）→ 未来 `promoted` / `discarded`（转正通道与
  #254 的二次确认共用一套，阶段二才打开）。
- **队列满则弃新**（`MAX_CONTINUITY_PENDING = 200`）：达上限丢掉本次触发，而不是淘汰
  旧行——旧行是别的会话还没转正的工作状态，用「更近的边缘」把它挤掉是反的。满队列
  不影响已有行的刷新。
- 丢弃会**留痕**（`agent/pre-step` 监听器 `logger.warn`）：弃新属于"静默失效"，不报出来
  的话 §8 的触发率口径会把它算成"没触发"。
- **本批没有回收路径**：`status` 只写 `pending`，`maintenance`/`reclaim` 不碰这张表，
  所以 200 是上限——满了之后新会话不再落行，直到转正通道打开（阶段二）。判定是进程内的
  （先查计数再写），两个宿主共用同一 `memoryDir` 时可能各自都看到未满，实际越过 200 条。
- `edge_seq` 记最近一次触发的压缩事件序号，是「实际触发率」的证据。
- 落库失败只告警、不抛：可选插件的故障不该把宿主的这一步变成失败，而注入那一半不依赖
  库，照做。

指标口径（#249 §8 要求触发率/采纳率可统计）：实际触发率 = 提案行数；采纳率 =
`promoted` / 全部。两者都是就地读表，不需要额外打点。

## 6. 开关

| 层级 | 键 | 默认 |
|---|---|---|
| 父 | `autoInject` | 开（既有总闸） |
| 子 | `continuityRescueEnabled` | **关**（引入新注入表面，按 #249 §10 判据独立成键） |

父关则子不生效（`injectChildEnabled`）；轻量档默认置关（`applyLightModePreset`
把它算进 `LIGHT_MODE_OFF`），但那只是默认值——装配时按「用户开关 > 轻量预设 >
bundle 配置」合并，用户在面板里显式勾选仍然赢。面板落点在 `lib/client.js`
的 `FEATURE_CHILDREN` / `FEATURE_GROUPS`（该文件无 src 对应物），跨文件关系由
`test/inject-parent-gate.test.js` 逐个断言钉住。

## 7. 降级路径（不要求宿主加接口）

宿主若不提供可挂钩的压缩前时机，双落点里的 ② 就没有触发者。这时**不算失败**：把规则
写成持久规则交给 agent 自判（`src/guide.js` 的 `CONTINUITY_TOOL_RULE`，挂在
`memory_save` 的工具描述尾部——常驻文本、零注入成本，只在子开关开启时出现）。

本批**不做宿主能力探测**（没有可靠的探测口，宿主也没有暴露"有没有压缩前时机"这个事实），
所以子开关打开时这条规则就常驻，与 ② 并存——两者不是互斥的备选，而是同一个开关下的两层：
② 在宿主真的压缩时自动生效，持久规则在 agent 自己判断"上下文要炸了"时兜底。

## 8. 已知坑

- **判重只能做到「同一份文本不追加第二次」**。没有改写钩子，就不能删掉表面里已经存在
  的那条旧快照；内容一变就是新的一份（旧的那份随宿主压缩自然消失）。这是形态上限，
  不是实现偷懒。
- **注入物不得变成记忆**：追加消息用 `source.kind = "plugin"`，`summarize.js` 的
  `collectMessages` 会跳过它——否则注入物会被下一轮蒸馏成记忆，形成自我强化。
- **`open_questions` 恒 null** 是刻意留白，不是没实现。

## 9. 验收锚点

`test/continuity.test.js` 逐条锁形态（不是锁文案）：抽取口径（头/尾方向、插件消息跳过、
插件消息是最后一条 `user/message` 时仍被跳过）、渲染（统一前缀、单行、`none`、整条上限）、
唯一键刷新（`created_at` 不动、幂等 UPSERT）、队列满弃新且不影响刷新、丢弃留痕、
pre-step 双落点与一次性消费、同文本不重复追加（按当前表面）、被折叠出表面的旧注入要补、
落库失败不打断宿主的一步、`reject` 放行且不消费边缘、缺 session 不抛、dispose 后不再动作、
默认关 + 父关不生效 + 轻量档压掉 + 降级规则只在开启时进描述、门控不调模型（静态锁）、
存量库重开即建表。
