Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@

## [Unreleased]

## 🆕 新增

- **压缩边缘双落点(issue #249 N3)**:上下文即将被宿主压缩前抢救「正在做什么」,新增
opt-in 键 `continuityRescueEnabled`(注入父开关 `autoInject` 的子项,默认关;`lightMode`
强制关)。**必须是双落点**:①落一条连续性提案到新表 `continuity_proposals`(脱离对话
独立存活);②把同一份快照追加成序列末尾的插件消息——宿主的压缩摘要器**只看对话里的
内容**,只放系统提示段等于白写。触发不自定阈值,直接订阅宿主真的压缩
(`compaction/start|summary|end`):压缩插件在自己那一步先压缩再 `return next()`,所以
我们在 `agent/pre-step` 拿到结果时边缘已落库,而返回的 `decision.messages` 由宿主以
`surfaceOp: "append"` 追加,晚于压缩的 `replace`——落点天然在压缩之后。三字段
(`current_work` / `next_step` / `open_questions`)用确定性抽取、**全程不调模型**:
最近一条真实 `user/message` 取头部 200 字符、最近一条 `assistant/message` 取尾部 200
字符(下一步活在末尾那句里),`open_questions` 判不出就留白(注入文本里如实标 `none`,
不编造)。提案行按 `(session_id, kind)` 唯一键落「同一会话同一类只留一条」:再次触发是
刷新而不是新增,`status`/`edge_seq` 就地支撑 #249 §8 要求的触发率与采纳率统计;**队列满
则弃新**(200 条,不淘汰旧行——旧行是别的会话还没转正的工作状态)。注入按「统一前缀 +
全文」判重,同文本不追加第二次(没有 in-memory 改写钩子,这是形态上限)。宿主若无压缩前
时机则**降级为持久规则**(写进 `memory_save` 描述交给 agent 自判),不算失败、不要求宿主
加接口。新增回归 14 条(`test/continuity.test.js`),新文档见 [docs/CONTINUITY.md](docs/CONTINUITY.md)。

## [0.8.6] - 2026-09-23

## 🆕 新增
Expand Down
124 changes: 124 additions & 0 deletions dsh-mneme/docs/CONTINUITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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. 触发

**不自定阈值**,就是订阅宿主真的压缩:`compaction/start` / `compaction/summary` /
`compaction/end`(都在宿主的事件白名单里)。理由:「压缩边缘」由宿主定义,我们再校准
一份阈值参数只会与之漂移,还多一份没人维护的旋钮。

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 后不再动作、
默认关 + 父关不生效 + 轻量档压掉 + 降级规则只在开启时进描述、门控不调模型(静态锁)、
存量库重开即建表。
6 changes: 5 additions & 1 deletion dsh-mneme/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ window.__ModuleLoader__.load({
"memory.features.autoInject.hint": "每轮对话自动携带相关记忆",
"memory.features.injectGuidanceEnabled": "能力说明",
"memory.features.injectGuidanceEnabled.hint": "在工具描述与一次性提示段里说明怎么用记忆(何时查、何时写、拿不准就不做)",
"memory.features.continuityRescueEnabled": "压缩边缘抢救",
"memory.features.continuityRescueEnabled.hint": "上下文即将被精简前,落一条连续性快照(正在做什么 / 下一步 / 未决问题)并追加到对话末尾,让它活过这次压缩",
"memory.features.parentOff": "父开关关闭时不生效",
"memory.features.autoSummarize": "自动总结",
"memory.features.autoSummarize.hint": "对话结束自动提炼记忆条目",
Expand Down Expand Up @@ -769,6 +771,8 @@ window.__ModuleLoader__.load({
"memory.features.autoInject.hint": "Carry relevant memories into every turn",
"memory.features.injectGuidanceEnabled": "Capability guide",
"memory.features.injectGuidanceEnabled.hint": "Explain how to use memory (when to search, when to save, when to do nothing) in tool descriptions plus a one-time prompt section",
"memory.features.continuityRescueEnabled": "Compaction-edge rescue",
"memory.features.continuityRescueEnabled.hint": "Before context compaction, record a continuity snapshot (current work / next step / open questions) and append it near the end of the conversation so it survives the compaction",
"memory.features.parentOff": "Inactive while auto injection is off",
"memory.features.autoSummarize": "Auto summarization",
"memory.features.autoSummarize.hint": "Distill memory entries when a conversation ends",
Expand Down Expand Up @@ -1931,7 +1935,7 @@ window.__ModuleLoader__.load({
// 「重置用户配置」,子项自己勾着的值要留着,也应该能提前设好。同一份关系在
// 后端 src/config.js 的 INJECT_CHILD_FLAGS(运行时闸门),两侧漂移由
// test/inject-parent-gate.test.js 钉住。
const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled"] };
const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"] };
const FEATURE_GROUPS = [
{ key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "memoryQualityFilter.enabled", "llmAudit.enabled"] },
{ key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled"] },
Expand Down
25 changes: 23 additions & 2 deletions dsh-mneme/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ export const Config = z.object({
// 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
// feature_flags 的值永远优先于这里的默认值。
injectGuidanceEnabled: z.boolean().default(true),
// #249 N3(压缩边缘双落点):上下文即将大幅精简前抢救「正在做什么」。默认关。
// 触发靠宿主自己的压缩事件(`compaction/start|summary|end`,都在事件白名单里),
// 不自定一套阈值参数——「压缩边缘」由宿主定义,我们再校准一份只会与之漂移。
// 为什么是「新时机 + 新表面」因而默认关:它往对话里**追加消息**(新的注入表面,
// 参照实现里最容易累积成一堆历史的那类),并多写一张提案表。按 §10 判据,引入
// 新时机/新表面/新成本的子项独立成键、默认关;只修正既有块的(基础内容分池、
// 库可见性行)才随父开关默认开。`pinnedInjectBudget` 与它同批,但属前者之外:
// 那是既有块内的预算,不是新表面。
// 双落点是硬要求,不能只留一半:宿主的压缩摘要器**只看对话里的内容**,只落库不
// 注入,等于在摘要重建里什么都没留下;只注入不落库,则压缩一过就随旧消息一起
// 消失。两者都做,且落库先于注入(注入失败不该丢提案)。
// 祖先:`autoInject`(父关则本项不生效,闸门见 injectChildEnabled)。轻量档默认
// 置关(见 LIGHT_MODE_OFF):轻量档多一份注入物是反的,与 injectGuidanceEnabled
// 同一取舍。注意预设只是默认值而非强制——装配时用户显式开关在它之后展开,勾了就赢
// (合并顺序见 index.js 装配处)。
continuityRescueEnabled: z.boolean().default(false),
// #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
// 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
// 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
Expand Down Expand Up @@ -633,7 +649,12 @@ const LIGHT_MODE_OFF = [
// 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
"documentMemoryEnabled",
// 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。
"heatEnabled"
"heatEnabled",
// #249 N3:轻量档默认不开压缩边缘双落点——它往对话里追加消息(新的注入表面),
// 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制:
// 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同
// injectGuidanceEnabled)。
"continuityRescueEnabled"
];

/**
Expand Down Expand Up @@ -670,7 +691,7 @@ export function applyLightModePreset(cfg) {
* 是扁平键,不受这条限制。
*/
export const INJECT_CHILD_FLAGS = Object.freeze({
autoInject: Object.freeze(["injectGuidanceEnabled"])
autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"])
});

/** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
Expand Down
Loading
Loading