From de281f209d0e0c704a2e94c938da268356570159 Mon Sep 17 00:00:00 2001 From: David Wong Date: Fri, 25 Sep 2026 09:06:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(continuity):=20=E5=8E=8B=E7=BC=A9=E8=BE=B9?= =?UTF-8?q?=E7=BC=98=E5=8F=8C=E8=90=BD=E7=82=B9=EF=BC=88#249=20N3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上下文即将被宿主压缩前抢救「正在做什么」,让它活过这次压缩。**必须双落点**: 落一条提案到新表,再把同一份快照追加成序列末尾的插件消息——宿主的压缩摘要器 只看对话里的内容,只放系统提示段等于白写(#249 §4.4)。 - 触发不自定阈值,直接订阅宿主真的压缩(compaction/start|summary|end):压缩插件 在自己那一步先压缩再 return next(),所以我们在 agent/pre-step 拿到结果时边缘已 落库;返回的 decision.messages 由宿主以 surfaceOp:append 追加,晚于压缩的 replace——落点天然在压缩之后。 - 新表 continuity_proposals:(session_id, kind) 唯一键,再次触发是刷新而不是新增; status/edge_seq 就地支撑 §8 要求的触发率与采纳率统计;队列满则弃新(200 条, 不淘汰旧行——旧行是别的会话还没转正的工作状态),丢弃会留痕。 - 三字段确定性抽取(最近一条真实 user/message 取头部 200 字符、最近一条 assistant/message 取尾部 200 字符、open_questions 判不出就留白),全程不调模型。 - 注入按「统一前缀 + 全文」在**当前表面**上判重,同文本不追加第二次(DSH 没有 in-memory 消息改写钩子,这是形态上限);按表面而不是全量日志判重,是因为被 压缩折叠出表面的旧注入仍在 append-only 日志里,照日志判重会让该补的一次静默跳过。 注入物用 source.kind=plugin,不会被下一轮蒸馏成记忆。 - 写侧两条不变量:监听器绝不把宿主的这一步带崩(落库失败只告警、注入照做,注入不 依赖库);落库是单条 UPSERT,多进程共库 WAL 下由唯一键裁决,先查后插会撞约束。 - 新开关 continuityRescueEnabled:autoInject 的子项、默认关(新注入表面), lightMode 默认置关(用户显式开关仍优先);落点 config.js schema + settings.js 白名单 + 面板 FEATURE_CHILDREN 与双语文案,跨文件关系由 test/inject-parent-gate.test.js 钉住。 - 降级路径:宿主无压缩前时机时把规则写进 memory_save 描述交给 agent 自判,不算失败 (本批不做宿主能力探测,子开关打开即常驻)。 - 口径修正:注释与文档把 lightMode 从「强制关」改成「默认置关、用户开关仍优先」; 降级规则的 memory_save 文本补上固定 title 与「同 type + title 命中是追加合并」。 - 回归 14 条(test/continuity.test.js)、新文档 docs/CONTINUITY.md、CHANGELOG。 --- dsh-mneme/CHANGELOG.md | 20 ++ dsh-mneme/docs/CONTINUITY.md | 124 +++++++++++ dsh-mneme/lib/client.js | 6 +- dsh-mneme/lib/config.js | 25 ++- dsh-mneme/lib/continuity.js | 220 ++++++++++++++++++++ dsh-mneme/lib/guide.js | 18 ++ dsh-mneme/lib/index.js | 10 +- dsh-mneme/lib/settings.js | 3 + dsh-mneme/lib/store.js | 87 ++++++++ dsh-mneme/lib/tools.js | 14 +- dsh-mneme/src/config.js | 25 ++- dsh-mneme/src/continuity.js | 220 ++++++++++++++++++++ dsh-mneme/src/guide.js | 18 ++ dsh-mneme/src/index.js | 10 +- dsh-mneme/src/settings.js | 3 + dsh-mneme/src/store.js | 87 ++++++++ dsh-mneme/src/tools.js | 14 +- dsh-mneme/test/api.test.js | 5 +- dsh-mneme/test/continuity.test.js | 333 ++++++++++++++++++++++++++++++ 19 files changed, 1227 insertions(+), 15 deletions(-) create mode 100644 dsh-mneme/docs/CONTINUITY.md create mode 100644 dsh-mneme/lib/continuity.js create mode 100644 dsh-mneme/src/continuity.js create mode 100644 dsh-mneme/test/continuity.test.js diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 9a4c628..463f487 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -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 ## 🆕 新增 diff --git a/dsh-mneme/docs/CONTINUITY.md b/dsh-mneme/docs/CONTINUITY.md new file mode 100644 index 0000000..84c88a2 --- /dev/null +++ b/dsh-mneme/docs/CONTINUITY.md @@ -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 后不再动作、 +默认关 + 父关不生效 + 轻量档压掉 + 降级规则只在开启时进描述、门控不调模型(静态锁)、 +存量库重开即建表。 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 7c04f8c..3cb3858 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -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": "对话结束自动提炼记忆条目", @@ -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", @@ -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"] }, diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 281048a..2f9f96f 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -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%),故这两类不进相关性竞争、不参与跨轮轮换、 @@ -633,7 +649,12 @@ const LIGHT_MODE_OFF = [ // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。 "documentMemoryEnabled", // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。 - "heatEnabled" + "heatEnabled", + // #249 N3:轻量档默认不开压缩边缘双落点——它往对话里追加消息(新的注入表面), + // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制: + // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同 + // injectGuidanceEnabled)。 + "continuityRescueEnabled" ]; /** @@ -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)时恒不生效。 */ diff --git a/dsh-mneme/lib/continuity.js b/dsh-mneme/lib/continuity.js new file mode 100644 index 0000000..8ed8f7a --- /dev/null +++ b/dsh-mneme/lib/continuity.js @@ -0,0 +1,220 @@ +import { createUserMessage } from "@deepseek-ai/dsh-llm"; + +// #249 N3(压缩边缘双落点):上下文即将大幅精简前,抢救「正在做什么」。 +// +// 为什么是这个形状(#249 §4.4 / §6.2 / §11;维护者 2026-09-24 拍板「都同意,开工」): +// +// 1. **必须双落点**。宿主的压缩摘要器只看对话里的内容:压缩时宿主按固定字段模板 +// 重建对话,只放系统提示段的东西不进这次重建,等于白写。所以除了落库,还要把同 +// 一条连续性快照追加成靠近序列末尾的消息。DSH 没有 in-memory 消息改写钩子 +// (§7 已核),因此这是「注入 + 序号引用寻址」,不是改写历史。 +// 2. **落库落成提案,不落成记忆行**。边缘产出直接进 memories,长会话会攒出第 N 条 +// 同主题条目(#275 记的失败形态);转正通道与 #254 的二次确认共用一套、阶段二才 +// 打开,本批只写 pending 行。 +// 3. **全程不调模型**。三个字段是确定性抽取 + 截断(口径见 deriveContinuity),判定 +// 也只用本地字符串比较;门控成本为零。 +// 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与 +// `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件 +// (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里 +// `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没 +// 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。 +// +// 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先 +// `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果 +// 时压缩事件已经落库;而我们返回的 `decision.messages` 由宿主在本步末尾以 +// `surfaceOp: "append"` 追加(`@deepseek-ai/dsh-agent-loop/lib/index.js:1046`),晚于 +// 压缩的 `surfaceOp: "replace"`(`dsh-compaction-basic/lib/index.js:649`)——落点天然 +// 在压缩之后、靠近序列末尾。两条注册顺序都成立(谁先谁后,我们的 `await next()` 都在 +// 压缩工作之后返回)。 + +/** 提案的 kind(唯一键的一半):一次会话里"压缩边缘"这一类只留一条。 */ +export const CONTINUITY_KIND = "compaction-edge"; +/** 注入消息的统一前缀:机械校验与「不变不重复」判重都靠它(#249 §6.3)。 */ +export const CONTINUITY_NOTICE_PREFIX = "[dsh-mneme continuity]"; +/** 单字段字符上限:抽取是截断,不是摘要(改口径不需要动调用方)。 */ +export const CONTINUITY_FIELD_MAX = 200; +/** 整条注入消息的字符上限。比 §6.3 的 160B 单行提醒宽:它是结构化快照,不是一句话提醒。 */ +export const CONTINUITY_NOTICE_MAX = 900; +/** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */ +const MAX_TRACKED_EDGES = 50; + +const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]); + +// 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理 +// 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部 +// 细节,两边取文本的理由不同。 +function textOf(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => (typeof block === "string" ? block : (block?.type === "text" && typeof block.text === "string" ? block.text : ""))) + .filter((s) => s) + .join("\n"); +} + +// DSH 0.1.2-rc.1 起 Session 用 snapshotEvents(),兼容旧版 .events。这是**全量日志** +// (append-only,含已被 surfaceOp:"replace" 折叠出表面的旧事件)——抽取要的正是全量: +// 被压缩折叠掉的最后一条真实用户消息,也曾是"正在做什么"。 +function sessionEvents(session) { + let events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : session?.events; + // 与 summarize.js 的同名兼容层同形:snapshotEvents 不可用或返回非数组时退回 .events。 + if (!Array.isArray(events)) events = session?.events; + return Array.isArray(events) ? events : []; +} + +// 「当前表面」= `session.surface.nodes`(只存活节点的 seq,事件体在上面那份日志里)。 +// 判重必须按表面而不是全量日志:旧注入还在日志里、但已被折叠出表面,照日志判重会让 +// 这一次压缩后该补的注入被静默跳过——双落点的 ② 就空了一次,而日志里看不出发生过。 +// 宿主没有表面投影(旧版)时退回全量:宁可少追加一次,也不在表面里重复追加同一份文本。 +function surfaceEvents(session) { + const events = sessionEvents(session); + let nodes; + // 宿主的 `surface.nodes` 是会抛的 getter(它自己用过、之后又被移除或替换的 message + // projection 会让宿主内部的 `_assertProjections` 抛)。判重是锦上添花的一步:读不到就 + // 退回全量,别让一个可选的抢救功能因为读宿主状态失败而把 agent 的这一步变失败。 + try { + nodes = session?.surface?.nodes; + } catch { + return events; + } + if (!Array.isArray(nodes)) return events; + const inSurface = new Set(nodes); + return events.filter((event) => inSurface.has(event?.seq)); +} + +/** + * 抽取连续性三字段(纯函数、无 LLM、无副作用,供测试直接钉住口径): + * - `currentWork`:最近一条**真实** user/message(插件注入与子代理上报不算用户指令), + * 取头部 CONTINUITY_FIELD_MAX 字符——"正在做什么"就是最后那条指令。 + * - `nextStep`:最近一条 assistant/message 的**尾部**同长度——"下一步"活在末尾那 + * 一段/最后一句里,取头会把开场白带进来。 + * - `openQuestions`:恒 null。确定性抽取判不出「哪些问题还没解决」,硬用反引号/问号 + * 猜会给出似是而非的字段;这个字段留给转正通道与人工补(不是所有字段都必须自动 + * 填满——宁可空着,也不编造)。空字段在注入文本里如实标 (none)。 + */ +export function deriveContinuity(session, fieldMax = CONTINUITY_FIELD_MAX) { + let currentWork = null; + let nextStep = null; + for (const event of sessionEvents(session)) { + const data = event?.data ?? {}; + if (event?.type === "user/message") { + // 只认用户自己写的:source.kind 缺省(旧事件)或 "user"。插件消息(我们自己的 + // 注入、其它插件)与子代理上报都不是"当前指令"。 + const kind = data?.source?.kind; + if (kind !== undefined && kind !== "user") continue; + const text = textOf(data.content).trim(); + if (text) currentWork = text.slice(0, fieldMax); + } else if (event?.type === "assistant/message") { + const text = textOf(data?.message?.content).trim(); + if (text) nextStep = text.slice(-fieldMax); + } + } + return { currentWork, nextStep, openQuestions: null }; +} + +/** 单行化:字段里的换行会把「靠近末尾的一条消息」摊成多行,也破坏 §6.3 的单行约定。 */ +function oneLine(text) { + return typeof text === "string" ? text.replace(/\s*\n\s*/g, " ").trim() : ""; +} + +/** 渲染注入文本(纯函数):统一前缀 + 三个固定字段(#249 §6.2)。 */ +export function renderContinuityNotice(fields, noticeMax = CONTINUITY_NOTICE_MAX) { + const body = [ + `${CONTINUITY_NOTICE_PREFIX} Context compaction happened in this session; here is the continuity snapshot ` + + "recorded so it survives outside the transcript (a proposal — not yet in the memory store):", + `current_work: ${oneLine(fields?.currentWork) || "(none)"}`, + `next_step: ${oneLine(fields?.nextStep) || "(none)"}`, + `open_questions: ${oneLine(fields?.openQuestions) || "(none)"}` + ].join("\n"); + return body.length > noticeMax ? body.slice(0, noticeMax) : body; +} + +/** 表面上是否已经有同一条注入(「不变不重复」,#249 §6.5):按前缀 + 全文比较, + * 不做语义判定——没有改写钩子,能做的只有"同一份文本不追加第二次"。 */ +function noticeAlreadyInSurface(session, text) { + return surfaceEvents(session).some((event) => { + if (event?.type !== "user/message") return false; + if (event?.data?.source?.plugin !== "dsh-mneme") return false; + const content = textOf(event?.data?.content); + return content === text; + }); +} + +/** + * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。 + * + * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。 + * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定, + * 保持"挂上就是开"的单一口径。 + */ +export function createContinuityRescue(ctx, store) { + // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后 + // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply + // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有 + // pre-step 是常见路径,这些条目永远走不到消费路径上。 + const edge = new Map(); + + const unsubscribe = ctx.on("session/event", (session, event) => { + if (!EDGE_EVENT_TYPES.has(event?.type)) return; + if (!session?.id) return; + if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) { + // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。 + edge.delete(edge.keys().next().value); + } + // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有 + // 信息——summary 带摘要事件 seq),序号即触发证据。 + edge.set(session.id, typeof event.seq === "number" ? event.seq : null); + }); + + const offStep = ctx.on("agent/pre-step", async (payload, next) => { + const decision = await next(); + // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。 + if (decision?.kind !== "enter") return decision; + const session = payload?.agent?.session; + const sessionId = session?.id; + if (!sessionId) return decision; + if (!edge.has(sessionId)) return decision; + const edgeSeq = edge.get(sessionId); + edge.delete(sessionId); + + // 抽取就地用 payload 上的这个 session(与置位时同一个 id),不从边缘表里握对象引用。 + const fields = deriveContinuity(session); + // 落库先于注入:提案是"脱离对话独立存活"的那一半。写不进去不算宿主这一步的失败—— + // 可选插件的可选能力把 agent 的一步带崩,比丢一次抢救严重得多(本函数的不变量)。 + try { + const saved = store.saveContinuityProposal({ + sessionId, + kind: CONTINUITY_KIND, + currentWork: fields.currentWork, + nextStep: fields.nextStep, + openQuestions: fields.openQuestions, + edgeSeq + }); + // 弃新是"静默失效"的那一类:不报出来,运维与 §8 的触发率口径都会把它当成没触发。 + if (saved?.dropped) { + ctx.logger?.warn?.(`[dsh-mneme] continuity: pending queue full, dropped the edge for session ${sessionId}`); + } + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] continuity proposal not recorded: ${String(error)}`); + } + + const text = renderContinuityNotice(fields); + if (noticeAlreadyInSurface(session, text)) return decision; + const messages = Array.isArray(decision.messages) ? decision.messages : []; + return { + ...decision, + messages: [ + ...messages, + createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin", plugin: "dsh-mneme" } }) + ] + }; + }); + + return { + dispose() { + unsubscribe?.(); + offStep?.(); + edge.clear(); + } + }; +} diff --git a/dsh-mneme/lib/guide.js b/dsh-mneme/lib/guide.js index 2a56b9b..8bfd616 100644 --- a/dsh-mneme/lib/guide.js +++ b/dsh-mneme/lib/guide.js @@ -42,3 +42,21 @@ export const TOOL_GUIDE = { " Save only durable, cross-session value (a preference, a decision with its rationale, an engineering constraint, " + "a pitfall with its root cause). Trivial single-turn work does not belong here, and when unsure, do not save." }; + +/** + * #249 N3 的降级路径:宿主若不提供可挂钩的压缩前时机,双落点里的「注入」那一半就没 + * 有触发者。此时不把功能算作失败,而是把规则交给 agent 自判压力(§4.4 点名的降级 + * 形态)。落点是工具描述——常驻文本、不进每轮上下文,零注入成本。 + * + * 英文单一正本,理由同 TOOL_GUIDE(见文件头)。字段名用存储与注入那一侧的口径 + * (`current_work` / `next_step` / open question,#249 §6.2);规格 §4.4 里的 + * Current Work / Next Step / Critical Context 是**宿主压缩模板**的字段名,不是这里的。 + */ +export const CONTINUITY_TOOL_RULE = { + memory_save: + " If this session's context is about to be compacted, first save a continuity note with this same tool " + + "(type: project, title: one stable title for this line of work, e.g. \"continuity: \"): current_work (what you " + + "are doing now), next_step, and any open question. Keep it to those fields, and reuse that same title for later " + + "updates: the store merges rows of the same type + title (the merge appends to the note instead of replacing it), " + + "so a fresh title each time just leaves a trail of near-duplicate project rows." +}; diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index 908d98a..aef528a 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -7,6 +7,7 @@ import { createService } from "./service.js"; import { createWriteAdmission } from "./write-admission.js"; import { createTools } from "./tools.js"; import { createInjector } from "./inject.js"; +import { createContinuityRescue } from "./continuity.js"; import { createSummarizer } from "./summarize.js"; import { createDreamScheduler } from "./dream.js"; import { createSleepScheduler, runSleep } from "./dream/sleep.js"; @@ -20,7 +21,7 @@ import { createEmbedder } from "./embedding.js"; import { createEmbedderByProvider } from "./local-embedder.js"; import { LocalReranker } from "./reranker.js"; import { createVectorIndex } from "./vector-index.js"; -import { Config, applyLightModePreset } from "./config.js"; +import { Config, applyLightModePreset, injectChildEnabled } from "./config.js"; import { langOf } from "./lang.js"; import { extractEntities } from "./entities/extractor.js"; import { mkdirSync } from "node:fs"; @@ -559,6 +560,13 @@ export const apply = (ctx, config) => { if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg)); }); + // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加), + // 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子 + // 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。 + if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) { + disposers.push(createContinuityRescue(ctx, store)); + } + ctx.inject(["tools"], (toolsCtx) => { disposers.push(createTools(toolsCtx, service, cfg, embedder)); }); diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index b9ffb88..9c1423b 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -48,6 +48,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是 // 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。 "injectGuidanceEnabled", + // Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往 + // 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。 + "continuityRescueEnabled", "entityExtractionEnabled", // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index fc51449..b8d2b92 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -276,6 +276,28 @@ CREATE TABLE IF NOT EXISTS mirror_state ( applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次 type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}} ); + +-- #249 N3(压缩边缘双落点):连续性提案。压缩边缘只落**提案**行,不进 memories +-- ——边缘产出若直接进记忆库,一个长会话就会攒出第 N 条同主题条目,正是 #275 记的 +-- 失败形态;转正通道与 #254 的二次确认共用一套,阶段二才打开,本批只写 pending。 +-- 唯一键 (session_id, kind) 就是形态约定里的「同一会话同一类只留一条」:再次触发 +-- 是刷新同一行,不是新增一行。status 为转正通道预留(pending → promoted/discarded): +-- 「实际触发率」= 提案行数、「采纳率」= promoted/总数,都能就地统计(#249 §8)。 +-- 时间戳是 ISO(与 memories 同款),created_at 在刷新时不动,updated_at 记最近边缘。 +CREATE TABLE IF NOT EXISTS continuity_proposals ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + kind TEXT NOT NULL, + current_work TEXT, + next_step TEXT, + open_questions TEXT, + status TEXT NOT NULL DEFAULT 'pending', + edge_seq INTEGER, -- 触发本次刷新的压缩事件 seq(证据/复现用) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_continuity_session_kind ON continuity_proposals(session_id, kind); +CREATE INDEX IF NOT EXISTS idx_continuity_status ON continuity_proposals(status, updated_at); `; // Exported for API-layer type validation (standalone API POST /memories and @@ -286,6 +308,12 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s // (registerDocument)——saveWithDedupe/updateMemory 另有守卫拒绝旁路铸造。 "document"]); +// #249 N3:连续性提案的 pending 队列上限。「队列满则弃新」是形态约定的一半——满时丢 +// 掉本次触发,**不是**淘汰旧行:旧行是别的会话还没转正的活状态,用"更近的边缘"把它挤 +// 掉,等于让长会话的噪音吃掉短会话的真实工作状态。这个数是存储侧策略,与抽取/注入的 +// 截断上限(continuity.js)是两件事。 +const MAX_CONTINUITY_PENDING = 200; + // Epistemic status: what kind of evidence a memory rests on. Defaults to // 'subjective' so legacy rows (and rows without any signal) stay compatible. const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]); @@ -2645,6 +2673,60 @@ export function createStore(path) { * Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation * bump commit together: a crash between them can never leave a mutated store * with generation == applied (audit peer blocker 1, "crash window"). */ + // --- #249 N3:压缩边缘的连续性提案(唯一写入口 = 压缩边缘监听器) ------------- + /** Upsert one continuity proposal. (session_id, kind) is the形态约定「同一会话同一类 + * 只留一条」:命中即刷新(created_at 不动、updated_at 记最近边缘),不新增行。 + * Queue full → drop the new one and say so (dropped: true) instead of throwing: an edge + * that cannot be recorded must not break the host's step. Returns {id, created, dropped}. */ + function saveContinuityProposal({ + sessionId, kind, currentWork = null, nextStep = null, openQuestions = null, edgeSeq = null, + maxPending = MAX_CONTINUITY_PENDING + } = {}) { + if (!sessionId || !kind) throw new TypeError("continuity proposal needs sessionId and kind"); + const now = new Date().toISOString(); + const existing = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind); + if (!existing) { + const pending = db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = 'pending'").get().c; + if (pending >= maxPending) return { id: null, created: false, dropped: true }; + } + // 写入本身是一条 UPSERT:唯一索引 (session_id, kind) 是并发的裁决者。两个宿主共用 + // memoryDir 时(AGENTS.md 的多进程 WAL 场景)先查后插会有一个撞 SQLITE_CONSTRAINT, + // 而这里只该有一条语句决定成不成功。上面的 SELECT 只用于「队列满」与 created 标记。 + // created_at 与 status 刻意不在 SET 里:命中只刷新内容与最近边缘时刻,不把已转正的行打回 pending。 + const id = randomUUID(); + db.prepare(` + INSERT INTO continuity_proposals (id, session_id, kind, current_work, next_step, open_questions, status, edge_seq, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(session_id, kind) DO UPDATE SET + current_work = excluded.current_work, + next_step = excluded.next_step, + open_questions = excluded.open_questions, + edge_seq = excluded.edge_seq, + updated_at = excluded.updated_at + `).run(id, sessionId, kind, currentWork, nextStep, openQuestions, edgeSeq, now, now); + const row = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind); + return { id: row?.id ?? id, created: !existing, dropped: false }; + } + + /** The single proposal for (session, kind), or null. */ + function getContinuityProposal(sessionId, kind) { + return db.prepare("SELECT * FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind) ?? null; + } + + /** Newest-first proposals, optionally filtered by status (转正通道的读侧预置)。 */ + function listContinuityProposals({ status, limit = 50 } = {}) { + const bounded = Number.isSafeInteger(limit) && limit > 0 ? limit : 50; + return status + ? db.prepare("SELECT * FROM continuity_proposals WHERE status = ? ORDER BY updated_at DESC, id LIMIT ?").all(status, bounded) + : db.prepare("SELECT * FROM continuity_proposals ORDER BY updated_at DESC, id LIMIT ?").all(bounded); + } + + function countContinuityProposals({ status } = {}) { + return status + ? db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = ?").get(status).c + : db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals").get().c; + } + function runAtomically(fn) { if (db.isTransaction) return fn(); db.exec("BEGIN"); @@ -2769,6 +2851,11 @@ export function createStore(path) { setTypeStatus, getTypeStatus, incrementGeneration, + // #249 N3:压缩边缘的连续性提案(落提案、转正才进 memories)。 + saveContinuityProposal, + getContinuityProposal, + listContinuityProposals, + countContinuityProposals, close() { db.close(); } diff --git a/dsh-mneme/lib/tools.js b/dsh-mneme/lib/tools.js index 0ef8c6b..48a0d8a 100644 --- a/dsh-mneme/lib/tools.js +++ b/dsh-mneme/lib/tools.js @@ -5,7 +5,7 @@ import { defaultRuntimeDir } from "./runtime/layout.js"; import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js"; import { matchesPlatform } from "./runtime/closure.js"; import { verifyPayload } from "./runtime/verify.js"; -import { TOOL_GUIDE } from "./guide.js"; +import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js"; import { injectChildEnabled } from "./config.js"; const TEXT_OUTPUT = (text) => [{ type: "text", text }]; @@ -90,8 +90,16 @@ export function createTools(ctx, service, config, embedder) { // 关闭时描述逐字节不变,也不动其他工具的文案。 // 它是注入父开关的子项(#249 第二批):`autoInject` 关掉时这里同样不生效—— // 工具描述是这个子项唯一住在注入器之外的落点,不在闸门内就漏了。 - const withToolGuide = (name, description) => - injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description; + // #249 N3 降级路径(continuityRescueEnabled):宿主若不提供可挂钩的压缩前时机, + // 双落点里的注入那一半就没有触发者,这时把规则交给 agent 自判(见 CONTINUITY_TOOL_RULE)。 + // 与能力说明各自独立:任一开关关闭时描述逐字节不变。 + // 本批不做宿主能力探测(没有可靠的探测口),所以子开关打开时这条规则常驻,与自动双落点并存。 + const withToolGuide = (name, description) => { + let text = description; + if (injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name]) text += TOOL_GUIDE[name]; + if (injectChildEnabled(config, "continuityRescueEnabled") && CONTINUITY_TOOL_RULE[name]) text += CONTINUITY_TOOL_RULE[name]; + return text; + }; // 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按 // 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 281048a..2f9f96f 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -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%),故这两类不进相关性竞争、不参与跨轮轮换、 @@ -633,7 +649,12 @@ const LIGHT_MODE_OFF = [ // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。 "documentMemoryEnabled", // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。 - "heatEnabled" + "heatEnabled", + // #249 N3:轻量档默认不开压缩边缘双落点——它往对话里追加消息(新的注入表面), + // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制: + // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同 + // injectGuidanceEnabled)。 + "continuityRescueEnabled" ]; /** @@ -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)时恒不生效。 */ diff --git a/dsh-mneme/src/continuity.js b/dsh-mneme/src/continuity.js new file mode 100644 index 0000000..8ed8f7a --- /dev/null +++ b/dsh-mneme/src/continuity.js @@ -0,0 +1,220 @@ +import { createUserMessage } from "@deepseek-ai/dsh-llm"; + +// #249 N3(压缩边缘双落点):上下文即将大幅精简前,抢救「正在做什么」。 +// +// 为什么是这个形状(#249 §4.4 / §6.2 / §11;维护者 2026-09-24 拍板「都同意,开工」): +// +// 1. **必须双落点**。宿主的压缩摘要器只看对话里的内容:压缩时宿主按固定字段模板 +// 重建对话,只放系统提示段的东西不进这次重建,等于白写。所以除了落库,还要把同 +// 一条连续性快照追加成靠近序列末尾的消息。DSH 没有 in-memory 消息改写钩子 +// (§7 已核),因此这是「注入 + 序号引用寻址」,不是改写历史。 +// 2. **落库落成提案,不落成记忆行**。边缘产出直接进 memories,长会话会攒出第 N 条 +// 同主题条目(#275 记的失败形态);转正通道与 #254 的二次确认共用一套、阶段二才 +// 打开,本批只写 pending 行。 +// 3. **全程不调模型**。三个字段是确定性抽取 + 截断(口径见 deriveContinuity),判定 +// 也只用本地字符串比较;门控成本为零。 +// 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与 +// `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件 +// (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里 +// `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没 +// 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。 +// +// 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先 +// `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果 +// 时压缩事件已经落库;而我们返回的 `decision.messages` 由宿主在本步末尾以 +// `surfaceOp: "append"` 追加(`@deepseek-ai/dsh-agent-loop/lib/index.js:1046`),晚于 +// 压缩的 `surfaceOp: "replace"`(`dsh-compaction-basic/lib/index.js:649`)——落点天然 +// 在压缩之后、靠近序列末尾。两条注册顺序都成立(谁先谁后,我们的 `await next()` 都在 +// 压缩工作之后返回)。 + +/** 提案的 kind(唯一键的一半):一次会话里"压缩边缘"这一类只留一条。 */ +export const CONTINUITY_KIND = "compaction-edge"; +/** 注入消息的统一前缀:机械校验与「不变不重复」判重都靠它(#249 §6.3)。 */ +export const CONTINUITY_NOTICE_PREFIX = "[dsh-mneme continuity]"; +/** 单字段字符上限:抽取是截断,不是摘要(改口径不需要动调用方)。 */ +export const CONTINUITY_FIELD_MAX = 200; +/** 整条注入消息的字符上限。比 §6.3 的 160B 单行提醒宽:它是结构化快照,不是一句话提醒。 */ +export const CONTINUITY_NOTICE_MAX = 900; +/** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */ +const MAX_TRACKED_EDGES = 50; + +const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]); + +// 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理 +// 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部 +// 细节,两边取文本的理由不同。 +function textOf(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => (typeof block === "string" ? block : (block?.type === "text" && typeof block.text === "string" ? block.text : ""))) + .filter((s) => s) + .join("\n"); +} + +// DSH 0.1.2-rc.1 起 Session 用 snapshotEvents(),兼容旧版 .events。这是**全量日志** +// (append-only,含已被 surfaceOp:"replace" 折叠出表面的旧事件)——抽取要的正是全量: +// 被压缩折叠掉的最后一条真实用户消息,也曾是"正在做什么"。 +function sessionEvents(session) { + let events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : session?.events; + // 与 summarize.js 的同名兼容层同形:snapshotEvents 不可用或返回非数组时退回 .events。 + if (!Array.isArray(events)) events = session?.events; + return Array.isArray(events) ? events : []; +} + +// 「当前表面」= `session.surface.nodes`(只存活节点的 seq,事件体在上面那份日志里)。 +// 判重必须按表面而不是全量日志:旧注入还在日志里、但已被折叠出表面,照日志判重会让 +// 这一次压缩后该补的注入被静默跳过——双落点的 ② 就空了一次,而日志里看不出发生过。 +// 宿主没有表面投影(旧版)时退回全量:宁可少追加一次,也不在表面里重复追加同一份文本。 +function surfaceEvents(session) { + const events = sessionEvents(session); + let nodes; + // 宿主的 `surface.nodes` 是会抛的 getter(它自己用过、之后又被移除或替换的 message + // projection 会让宿主内部的 `_assertProjections` 抛)。判重是锦上添花的一步:读不到就 + // 退回全量,别让一个可选的抢救功能因为读宿主状态失败而把 agent 的这一步变失败。 + try { + nodes = session?.surface?.nodes; + } catch { + return events; + } + if (!Array.isArray(nodes)) return events; + const inSurface = new Set(nodes); + return events.filter((event) => inSurface.has(event?.seq)); +} + +/** + * 抽取连续性三字段(纯函数、无 LLM、无副作用,供测试直接钉住口径): + * - `currentWork`:最近一条**真实** user/message(插件注入与子代理上报不算用户指令), + * 取头部 CONTINUITY_FIELD_MAX 字符——"正在做什么"就是最后那条指令。 + * - `nextStep`:最近一条 assistant/message 的**尾部**同长度——"下一步"活在末尾那 + * 一段/最后一句里,取头会把开场白带进来。 + * - `openQuestions`:恒 null。确定性抽取判不出「哪些问题还没解决」,硬用反引号/问号 + * 猜会给出似是而非的字段;这个字段留给转正通道与人工补(不是所有字段都必须自动 + * 填满——宁可空着,也不编造)。空字段在注入文本里如实标 (none)。 + */ +export function deriveContinuity(session, fieldMax = CONTINUITY_FIELD_MAX) { + let currentWork = null; + let nextStep = null; + for (const event of sessionEvents(session)) { + const data = event?.data ?? {}; + if (event?.type === "user/message") { + // 只认用户自己写的:source.kind 缺省(旧事件)或 "user"。插件消息(我们自己的 + // 注入、其它插件)与子代理上报都不是"当前指令"。 + const kind = data?.source?.kind; + if (kind !== undefined && kind !== "user") continue; + const text = textOf(data.content).trim(); + if (text) currentWork = text.slice(0, fieldMax); + } else if (event?.type === "assistant/message") { + const text = textOf(data?.message?.content).trim(); + if (text) nextStep = text.slice(-fieldMax); + } + } + return { currentWork, nextStep, openQuestions: null }; +} + +/** 单行化:字段里的换行会把「靠近末尾的一条消息」摊成多行,也破坏 §6.3 的单行约定。 */ +function oneLine(text) { + return typeof text === "string" ? text.replace(/\s*\n\s*/g, " ").trim() : ""; +} + +/** 渲染注入文本(纯函数):统一前缀 + 三个固定字段(#249 §6.2)。 */ +export function renderContinuityNotice(fields, noticeMax = CONTINUITY_NOTICE_MAX) { + const body = [ + `${CONTINUITY_NOTICE_PREFIX} Context compaction happened in this session; here is the continuity snapshot ` + + "recorded so it survives outside the transcript (a proposal — not yet in the memory store):", + `current_work: ${oneLine(fields?.currentWork) || "(none)"}`, + `next_step: ${oneLine(fields?.nextStep) || "(none)"}`, + `open_questions: ${oneLine(fields?.openQuestions) || "(none)"}` + ].join("\n"); + return body.length > noticeMax ? body.slice(0, noticeMax) : body; +} + +/** 表面上是否已经有同一条注入(「不变不重复」,#249 §6.5):按前缀 + 全文比较, + * 不做语义判定——没有改写钩子,能做的只有"同一份文本不追加第二次"。 */ +function noticeAlreadyInSurface(session, text) { + return surfaceEvents(session).some((event) => { + if (event?.type !== "user/message") return false; + if (event?.data?.source?.plugin !== "dsh-mneme") return false; + const content = textOf(event?.data?.content); + return content === text; + }); +} + +/** + * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。 + * + * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。 + * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定, + * 保持"挂上就是开"的单一口径。 + */ +export function createContinuityRescue(ctx, store) { + // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后 + // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply + // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有 + // pre-step 是常见路径,这些条目永远走不到消费路径上。 + const edge = new Map(); + + const unsubscribe = ctx.on("session/event", (session, event) => { + if (!EDGE_EVENT_TYPES.has(event?.type)) return; + if (!session?.id) return; + if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) { + // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。 + edge.delete(edge.keys().next().value); + } + // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有 + // 信息——summary 带摘要事件 seq),序号即触发证据。 + edge.set(session.id, typeof event.seq === "number" ? event.seq : null); + }); + + const offStep = ctx.on("agent/pre-step", async (payload, next) => { + const decision = await next(); + // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。 + if (decision?.kind !== "enter") return decision; + const session = payload?.agent?.session; + const sessionId = session?.id; + if (!sessionId) return decision; + if (!edge.has(sessionId)) return decision; + const edgeSeq = edge.get(sessionId); + edge.delete(sessionId); + + // 抽取就地用 payload 上的这个 session(与置位时同一个 id),不从边缘表里握对象引用。 + const fields = deriveContinuity(session); + // 落库先于注入:提案是"脱离对话独立存活"的那一半。写不进去不算宿主这一步的失败—— + // 可选插件的可选能力把 agent 的一步带崩,比丢一次抢救严重得多(本函数的不变量)。 + try { + const saved = store.saveContinuityProposal({ + sessionId, + kind: CONTINUITY_KIND, + currentWork: fields.currentWork, + nextStep: fields.nextStep, + openQuestions: fields.openQuestions, + edgeSeq + }); + // 弃新是"静默失效"的那一类:不报出来,运维与 §8 的触发率口径都会把它当成没触发。 + if (saved?.dropped) { + ctx.logger?.warn?.(`[dsh-mneme] continuity: pending queue full, dropped the edge for session ${sessionId}`); + } + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] continuity proposal not recorded: ${String(error)}`); + } + + const text = renderContinuityNotice(fields); + if (noticeAlreadyInSurface(session, text)) return decision; + const messages = Array.isArray(decision.messages) ? decision.messages : []; + return { + ...decision, + messages: [ + ...messages, + createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin", plugin: "dsh-mneme" } }) + ] + }; + }); + + return { + dispose() { + unsubscribe?.(); + offStep?.(); + edge.clear(); + } + }; +} diff --git a/dsh-mneme/src/guide.js b/dsh-mneme/src/guide.js index 2a56b9b..8bfd616 100644 --- a/dsh-mneme/src/guide.js +++ b/dsh-mneme/src/guide.js @@ -42,3 +42,21 @@ export const TOOL_GUIDE = { " Save only durable, cross-session value (a preference, a decision with its rationale, an engineering constraint, " + "a pitfall with its root cause). Trivial single-turn work does not belong here, and when unsure, do not save." }; + +/** + * #249 N3 的降级路径:宿主若不提供可挂钩的压缩前时机,双落点里的「注入」那一半就没 + * 有触发者。此时不把功能算作失败,而是把规则交给 agent 自判压力(§4.4 点名的降级 + * 形态)。落点是工具描述——常驻文本、不进每轮上下文,零注入成本。 + * + * 英文单一正本,理由同 TOOL_GUIDE(见文件头)。字段名用存储与注入那一侧的口径 + * (`current_work` / `next_step` / open question,#249 §6.2);规格 §4.4 里的 + * Current Work / Next Step / Critical Context 是**宿主压缩模板**的字段名,不是这里的。 + */ +export const CONTINUITY_TOOL_RULE = { + memory_save: + " If this session's context is about to be compacted, first save a continuity note with this same tool " + + "(type: project, title: one stable title for this line of work, e.g. \"continuity: \"): current_work (what you " + + "are doing now), next_step, and any open question. Keep it to those fields, and reuse that same title for later " + + "updates: the store merges rows of the same type + title (the merge appends to the note instead of replacing it), " + + "so a fresh title each time just leaves a trail of near-duplicate project rows." +}; diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index 908d98a..aef528a 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -7,6 +7,7 @@ import { createService } from "./service.js"; import { createWriteAdmission } from "./write-admission.js"; import { createTools } from "./tools.js"; import { createInjector } from "./inject.js"; +import { createContinuityRescue } from "./continuity.js"; import { createSummarizer } from "./summarize.js"; import { createDreamScheduler } from "./dream.js"; import { createSleepScheduler, runSleep } from "./dream/sleep.js"; @@ -20,7 +21,7 @@ import { createEmbedder } from "./embedding.js"; import { createEmbedderByProvider } from "./local-embedder.js"; import { LocalReranker } from "./reranker.js"; import { createVectorIndex } from "./vector-index.js"; -import { Config, applyLightModePreset } from "./config.js"; +import { Config, applyLightModePreset, injectChildEnabled } from "./config.js"; import { langOf } from "./lang.js"; import { extractEntities } from "./entities/extractor.js"; import { mkdirSync } from "node:fs"; @@ -559,6 +560,13 @@ export const apply = (ctx, config) => { if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg)); }); + // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加), + // 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子 + // 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。 + if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) { + disposers.push(createContinuityRescue(ctx, store)); + } + ctx.inject(["tools"], (toolsCtx) => { disposers.push(createTools(toolsCtx, service, cfg, embedder)); }); diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index b9ffb88..9c1423b 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -48,6 +48,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是 // 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。 "injectGuidanceEnabled", + // Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往 + // 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。 + "continuityRescueEnabled", "entityExtractionEnabled", // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index fc51449..b8d2b92 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -276,6 +276,28 @@ CREATE TABLE IF NOT EXISTS mirror_state ( applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次 type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}} ); + +-- #249 N3(压缩边缘双落点):连续性提案。压缩边缘只落**提案**行,不进 memories +-- ——边缘产出若直接进记忆库,一个长会话就会攒出第 N 条同主题条目,正是 #275 记的 +-- 失败形态;转正通道与 #254 的二次确认共用一套,阶段二才打开,本批只写 pending。 +-- 唯一键 (session_id, kind) 就是形态约定里的「同一会话同一类只留一条」:再次触发 +-- 是刷新同一行,不是新增一行。status 为转正通道预留(pending → promoted/discarded): +-- 「实际触发率」= 提案行数、「采纳率」= promoted/总数,都能就地统计(#249 §8)。 +-- 时间戳是 ISO(与 memories 同款),created_at 在刷新时不动,updated_at 记最近边缘。 +CREATE TABLE IF NOT EXISTS continuity_proposals ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + kind TEXT NOT NULL, + current_work TEXT, + next_step TEXT, + open_questions TEXT, + status TEXT NOT NULL DEFAULT 'pending', + edge_seq INTEGER, -- 触发本次刷新的压缩事件 seq(证据/复现用) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_continuity_session_kind ON continuity_proposals(session_id, kind); +CREATE INDEX IF NOT EXISTS idx_continuity_status ON continuity_proposals(status, updated_at); `; // Exported for API-layer type validation (standalone API POST /memories and @@ -286,6 +308,12 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s // (registerDocument)——saveWithDedupe/updateMemory 另有守卫拒绝旁路铸造。 "document"]); +// #249 N3:连续性提案的 pending 队列上限。「队列满则弃新」是形态约定的一半——满时丢 +// 掉本次触发,**不是**淘汰旧行:旧行是别的会话还没转正的活状态,用"更近的边缘"把它挤 +// 掉,等于让长会话的噪音吃掉短会话的真实工作状态。这个数是存储侧策略,与抽取/注入的 +// 截断上限(continuity.js)是两件事。 +const MAX_CONTINUITY_PENDING = 200; + // Epistemic status: what kind of evidence a memory rests on. Defaults to // 'subjective' so legacy rows (and rows without any signal) stay compatible. const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]); @@ -2645,6 +2673,60 @@ export function createStore(path) { * Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation * bump commit together: a crash between them can never leave a mutated store * with generation == applied (audit peer blocker 1, "crash window"). */ + // --- #249 N3:压缩边缘的连续性提案(唯一写入口 = 压缩边缘监听器) ------------- + /** Upsert one continuity proposal. (session_id, kind) is the形态约定「同一会话同一类 + * 只留一条」:命中即刷新(created_at 不动、updated_at 记最近边缘),不新增行。 + * Queue full → drop the new one and say so (dropped: true) instead of throwing: an edge + * that cannot be recorded must not break the host's step. Returns {id, created, dropped}. */ + function saveContinuityProposal({ + sessionId, kind, currentWork = null, nextStep = null, openQuestions = null, edgeSeq = null, + maxPending = MAX_CONTINUITY_PENDING + } = {}) { + if (!sessionId || !kind) throw new TypeError("continuity proposal needs sessionId and kind"); + const now = new Date().toISOString(); + const existing = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind); + if (!existing) { + const pending = db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = 'pending'").get().c; + if (pending >= maxPending) return { id: null, created: false, dropped: true }; + } + // 写入本身是一条 UPSERT:唯一索引 (session_id, kind) 是并发的裁决者。两个宿主共用 + // memoryDir 时(AGENTS.md 的多进程 WAL 场景)先查后插会有一个撞 SQLITE_CONSTRAINT, + // 而这里只该有一条语句决定成不成功。上面的 SELECT 只用于「队列满」与 created 标记。 + // created_at 与 status 刻意不在 SET 里:命中只刷新内容与最近边缘时刻,不把已转正的行打回 pending。 + const id = randomUUID(); + db.prepare(` + INSERT INTO continuity_proposals (id, session_id, kind, current_work, next_step, open_questions, status, edge_seq, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(session_id, kind) DO UPDATE SET + current_work = excluded.current_work, + next_step = excluded.next_step, + open_questions = excluded.open_questions, + edge_seq = excluded.edge_seq, + updated_at = excluded.updated_at + `).run(id, sessionId, kind, currentWork, nextStep, openQuestions, edgeSeq, now, now); + const row = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind); + return { id: row?.id ?? id, created: !existing, dropped: false }; + } + + /** The single proposal for (session, kind), or null. */ + function getContinuityProposal(sessionId, kind) { + return db.prepare("SELECT * FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind) ?? null; + } + + /** Newest-first proposals, optionally filtered by status (转正通道的读侧预置)。 */ + function listContinuityProposals({ status, limit = 50 } = {}) { + const bounded = Number.isSafeInteger(limit) && limit > 0 ? limit : 50; + return status + ? db.prepare("SELECT * FROM continuity_proposals WHERE status = ? ORDER BY updated_at DESC, id LIMIT ?").all(status, bounded) + : db.prepare("SELECT * FROM continuity_proposals ORDER BY updated_at DESC, id LIMIT ?").all(bounded); + } + + function countContinuityProposals({ status } = {}) { + return status + ? db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = ?").get(status).c + : db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals").get().c; + } + function runAtomically(fn) { if (db.isTransaction) return fn(); db.exec("BEGIN"); @@ -2769,6 +2851,11 @@ export function createStore(path) { setTypeStatus, getTypeStatus, incrementGeneration, + // #249 N3:压缩边缘的连续性提案(落提案、转正才进 memories)。 + saveContinuityProposal, + getContinuityProposal, + listContinuityProposals, + countContinuityProposals, close() { db.close(); } diff --git a/dsh-mneme/src/tools.js b/dsh-mneme/src/tools.js index 0ef8c6b..48a0d8a 100644 --- a/dsh-mneme/src/tools.js +++ b/dsh-mneme/src/tools.js @@ -5,7 +5,7 @@ import { defaultRuntimeDir } from "./runtime/layout.js"; import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js"; import { matchesPlatform } from "./runtime/closure.js"; import { verifyPayload } from "./runtime/verify.js"; -import { TOOL_GUIDE } from "./guide.js"; +import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js"; import { injectChildEnabled } from "./config.js"; const TEXT_OUTPUT = (text) => [{ type: "text", text }]; @@ -90,8 +90,16 @@ export function createTools(ctx, service, config, embedder) { // 关闭时描述逐字节不变,也不动其他工具的文案。 // 它是注入父开关的子项(#249 第二批):`autoInject` 关掉时这里同样不生效—— // 工具描述是这个子项唯一住在注入器之外的落点,不在闸门内就漏了。 - const withToolGuide = (name, description) => - injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description; + // #249 N3 降级路径(continuityRescueEnabled):宿主若不提供可挂钩的压缩前时机, + // 双落点里的注入那一半就没有触发者,这时把规则交给 agent 自判(见 CONTINUITY_TOOL_RULE)。 + // 与能力说明各自独立:任一开关关闭时描述逐字节不变。 + // 本批不做宿主能力探测(没有可靠的探测口),所以子开关打开时这条规则常驻,与自动双落点并存。 + const withToolGuide = (name, description) => { + let text = description; + if (injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name]) text += TOOL_GUIDE[name]; + if (injectChildEnabled(config, "continuityRescueEnabled") && CONTINUITY_TOOL_RULE[name]) text += CONTINUITY_TOOL_RULE[name]; + return text; + }; // 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按 // 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 72d665e..e0e79cc 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -663,9 +663,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #257 新增 sleepMaxTokens、issue #258 新增总览路由两键与 // dreamSummaryMaxInputs、issue #230 新增 documentMemoryEnabled/ // documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/ - // pinnedInjectBudget,v0.8.5 新增 disableMemorySearch/disableMemoryArchive, + // pinnedInjectBudget、issue #249 N3 新增 continuityRescueEnabled, + // v0.8.5 新增 disableMemorySearch/disableMemoryArchive, // 本地嵌入池化新增 localEmbedPooling) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1); + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMinIntervalMinutes, 0); diff --git a/dsh-mneme/test/continuity.test.js b/dsh-mneme/test/continuity.test.js new file mode 100644 index 0000000..d863018 --- /dev/null +++ b/dsh-mneme/test/continuity.test.js @@ -0,0 +1,333 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createTools } from "../src/tools.js"; +import { Config, applyLightModePreset, injectChildEnabled } from "../src/config.js"; +import { + CONTINUITY_KIND, + CONTINUITY_NOTICE_PREFIX, + CONTINUITY_FIELD_MAX, + deriveContinuity, + renderContinuityNotice, + createContinuityRescue +} from "../src/continuity.js"; + +// 回归(issue #249 N3):压缩边缘双落点。 +// +// 这个文件锁的是「形态」而不是「文案」:宿主的压缩摘要器只看对话里的内容,所以 +// 双落点(落提案 + 末尾追加)缺一半功能就等于没做——每条用例都对应一处会静默退化 +// 的形态,而不是某段文本正好长什么样。 +// +// 事件形状照 summarize.js 的 collectMessages 口径:user/message 的文本在 +// `data.content`、来源在 `data.source`,assistant/message 的在 `data.message.content`。 + +const KIND = CONTINUITY_KIND; + +function userEvent(seq, text, source = { kind: "user" }) { + return { type: "user/message", seq, data: { content: [{ type: "text", text }], source } }; +} + +function assistantEvent(seq, text) { + return { type: "assistant/message", seq, data: { message: { content: [{ type: "text", text }] } } }; +} + +/** surfaceSeqs 给了才挂表面投影(`session.surface.nodes` 只存活节点的 seq):判重按表面、 + * 抽取按全量日志,这两条口径必须能分开测。 */ +function fakeSession(id, events, surfaceSeqs) { + const session = { id, snapshotEvents: () => events, append() {} }; + if (surfaceSeqs) session.surface = { nodes: surfaceSeqs }; + return session; +} + +/** 捕获 handler 的 ctx:fire 直接调被注册的函数,返回它的返回值(含 Promise)。 */ +function mockCtx() { + const handlers = new Map(); + const warnings = []; + return { + logger: { warn: (message) => warnings.push(String(message)) }, + warnings, + on(name, fn) { + handlers.set(name, fn); + return () => handlers.delete(name); + }, + fire(name, ...args) { + return handlers.get(name)?.(...args); + } + }; +} + +function toolDescriptions(config) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const registered = []; + const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } }; + createTools(ctx, service, config, null); + return new Map(registered.map((t) => [t.name, t.description])); +} + +test("#249 N3:抽取口径——最后一句话的指令取头、最后一段回复取尾", () => { + const long = "x".repeat(CONTINUITY_FIELD_MAX * 2); + const fields = deriveContinuity( + fakeSession("s1", [ + userEvent(1, "早期的指令"), + assistantEvent(2, "早期的回复"), + userEvent(4, `最新指令 ${long}`), + assistantEvent(5, `最新回复 ${long}`), + // 插件消息(我们自己的注入或别的插件)不是"用户当前指令",必须跳过:否则注入物会被 + // 当成用户诉求、下一轮又抄进快照(自我强化)。**刻意放在最后一条 user/message**: + // 夹在中间时"后写覆盖"会让这条断言恒绿,钉不住这层过滤(审查发现)。 + userEvent(6, "插件注入的一行", { kind: "plugin", plugin: "dsh-mneme" }) + ]) + ); + assert.equal(fields.currentWork, `最新指令 ${long}`.slice(0, CONTINUITY_FIELD_MAX), "取最近一条真实用户消息的头部"); + assert.equal(fields.nextStep, `最新回复 ${long}`.slice(-CONTINUITY_FIELD_MAX), "下一步活在回复末尾,故取尾部"); + assert.equal(fields.openQuestions, null, "确定性抽取判不出未决问题,字段留空而不编造"); +}); + +test("#249 N3:渲染——统一前缀 + 三字段单行 + 空字段如实标 none", () => { + const text = renderContinuityNotice({ currentWork: "改 注入\n第二行", nextStep: null, openQuestions: "要不要开 PR?" }); + assert.ok(text.startsWith(CONTINUITY_NOTICE_PREFIX), "统一前缀是剥离与判重的锚"); + assert.ok(!text.slice(CONTINUITY_NOTICE_PREFIX.length).includes("\n\n"), "字段内部换行被压平,一条消息仍是结构化的三行"); + assert.ok(text.includes("current_work: 改 注入 第二行"), "字段内换行压成空格"); + assert.ok(text.includes("next_step: (none)"), "空字段如实标 none"); + assert.ok(text.includes("open_questions: 要不要开 PR?")); + assert.equal(renderContinuityNotice({ currentWork: "y".repeat(500) }, 40).length, 40, "整条有硬上限"); +}); + +test("#249 N3:提案落库——同一会话同一类只一条(刷新而不是新增)", () => { + const store = createStore(":memory:"); + const first = store.saveContinuityProposal({ sessionId: "s1", kind: KIND, currentWork: "第一版", edgeSeq: 7 }); + const created = store.getContinuityProposal("s1", KIND); + assert.equal(first.created, true); + const again = store.saveContinuityProposal({ sessionId: "s1", kind: KIND, currentWork: "第二版", edgeSeq: 9 }); + assert.equal(again.created, false, "同一 (session, kind) 是刷新"); + assert.equal(again.id, first.id); + const row = store.getContinuityProposal("s1", KIND); + assert.equal(row.current_work, "第二版", "刷新覆盖内容(最新的边缘赢)"); + assert.equal(row.created_at, created.created_at, "created_at 记首次落库,刷新不动它"); + assert.equal(row.edge_seq, 9, "edge_seq 记最近一次触发,是「实际触发率」的证据"); + assert.equal(store.countContinuityProposals({ status: "pending" }), 1); + // 唯一键是「会话 + 类型」这一对:换任一维都是另一条提案。 + store.saveContinuityProposal({ sessionId: "s2", kind: KIND, currentWork: "别的会话" }); + store.saveContinuityProposal({ sessionId: "s1", kind: "other-kind", currentWork: "别的类" }); + assert.equal(store.countContinuityProposals(), 3); + assert.equal(store.listContinuityProposals().length, 3, "读侧按 updated_at 倒序返回"); +}); + +test("#249 N3:队列满则弃新——但不是淘汰旧行,且不影响刷新", () => { + const store = createStore(":memory:"); + store.saveContinuityProposal({ sessionId: "s1", kind: KIND, currentWork: "旧会话的活状态" }); + const dropped = store.saveContinuityProposal({ sessionId: "s2", kind: KIND, currentWork: "新的", maxPending: 1 }); + assert.deepEqual(dropped, { id: null, created: false, dropped: true }, "达上限丢掉本次触发"); + assert.equal(store.countContinuityProposals(), 1, "旧行仍在——弃新不是淘汰旧,旧行是别的会话还没转正的工作状态"); + const refreshed = store.saveContinuityProposal({ sessionId: "s1", kind: KIND, currentWork: "刷新", maxPending: 1 }); + assert.equal(refreshed.created, false, "满队列不影响已有行的刷新"); + assert.equal(store.getContinuityProposal("s1", KIND).current_work, "刷新"); +}); + +test("#249 N3:pre-step 双落点——压缩边缘落提案并往末尾追加一条", async () => { + const store = createStore(":memory:"); + const ctx = mockCtx(); + const rescue = createContinuityRescue(ctx, store); + const events = [userEvent(1, "把 N3 实现完"), assistantEvent(2, "接着写回归测试")]; + const nodes = [1, 2]; + const session = fakeSession("s1", events, nodes); + + const step = () => ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + + // 没有边缘时不追加:本功能只在宿主真的压缩之后动作,不自定阈值。 + assert.deepEqual((await step()).messages, [], "无压缩事件 → 不做任何事"); + + ctx.fire("session/event", session, { type: "compaction/end", seq: 9 }); + const decision = await step(); + assert.equal(decision.messages.length, 1, "压缩边缘 → 追加一条"); + const message = decision.messages[0]; + assert.equal(message.source.plugin, "dsh-mneme"); + assert.equal(message.source.kind, "plugin", "插件来源会被蒸馏管线跳过,注入物不会变成记忆(也不会自我蒸馏)"); + assert.ok(message.content[0].text.startsWith(CONTINUITY_NOTICE_PREFIX)); + assert.ok(message.content[0].text.includes("current_work: 把 N3 实现完")); + const proposal = store.getContinuityProposal("s1", KIND); + assert.equal(proposal.current_work, "把 N3 实现完", "落库先于注入:提案是脱离对话存活的那一半"); + assert.equal(proposal.edge_seq, 9); + + // 同一个边缘只消费一次(无新边缘 → 不再追加)。 + assert.deepEqual((await step()).messages, [], "边缘一次性消费,不会每步都追加"); + + // 「不变不重复」:宿主已经把这条注入落进会话表面后,同文本不再追加第二次。 + events.push({ type: "user/message", seq: 11, data: { content: message.content, source: message.source } }); + nodes.push(11); // 宿主把这条注入落进了当前表面 + ctx.fire("session/event", session, { type: "compaction/end", seq: 12 }); + const sameText = await step(); + assert.deepEqual(sameText.messages, [], "同文本已在表面里 → 不重复追加(没有改写钩子,只能做到这一层)"); + assert.equal(store.getContinuityProposal("s1", KIND).edge_seq, 12, "但提案仍被刷新——两半各自独立"); + + // 内容变了就允许再追加一次:判重按全文比较,不是"注入过就再不注入"。 + events.push(assistantEvent(13, "内容变了")); + ctx.fire("session/event", session, { type: "compaction/end", seq: 14 }); + assert.equal((await step()).messages.length, 1, "快照内容变化 → 追加新的一份"); + + // dispose 只能按行为断言:mockCtx 的 handler 是块体箭头函数、恒返回 undefined,拿 fire 的 + // 返回值断言"已摘除"是恒真的(审查发现的假断言)。pre-step 监听器活着时返回 decision、 + // 摘除后 fire 拿不到 handler,两者才是可分辨的。 + rescue.dispose(); + ctx.fire("session/event", session, { type: "compaction/end", seq: 15 }); + const afterDispose = await step(); + assert.equal(afterDispose, undefined, "dispose 后不再有 pre-step 监听器"); + assert.equal(store.getContinuityProposal("s1", KIND).edge_seq, 14, "dispose 后不再落库(edge_seq 停在最后一条边缘)"); +}); + +test("#249 N3:宿主表面读取抛错时退回全量判重,也不打断宿主的一步", async () => { + const ctx = mockCtx(); + const stub = { saveContinuityProposal: () => ({ id: "x", created: true, dropped: false }) }; + const rescue = createContinuityRescue(ctx, stub); + const session = fakeSession("s1", [userEvent(1, "指令")]); + // 宿主的 `surface.nodes` 是会抛的 getter(它自己内部的 projection 断言)。判重读不到就退回 + // 全量:可选的抢救功能绝不能因为读宿主状态失败而把 agent 的这一步变失败。 + Object.defineProperty(session, "surface", { + get() { + throw new Error('session message projection "X" was removed or replaced'); + } + }); + ctx.fire("session/event", session, { type: "compaction/end", seq: 3 }); + const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + assert.equal(decision.messages.length, 1, "读不到表面不该 reject 这一步,注入照做"); + rescue.dispose(); +}); + +test("#249 N3:压缩事件不带 seq 时仍要注入(「有没有边缘」不等于「序号的真假」)", async () => { + const store = createStore(":memory:"); + const ctx = mockCtx(); + const rescue = createContinuityRescue(ctx, store); + const session = fakeSession("s1", [userEvent(1, "指令")]); + // 边缘只记「有没有」这一件事,序号允许为 null。把「取到的值」当真假判断(`if (!edge.get(id))` + // 那种写法)会把这条边缘当成不存在:既不注入,也不清理。 + ctx.fire("session/event", session, { type: "compaction/end" }); + const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + assert.equal(decision.messages.length, 1, "序号缺失不影响注入"); + assert.equal(store.getContinuityProposal("s1", KIND).edge_seq, null, "序号缺失就如实记 null,不编一个数"); + rescue.dispose(); +}); + +test("#249 N3:判重按当前表面——被折叠出表面的旧注入不算「已注入」", async () => { + const store = createStore(":memory:"); + const ctx = mockCtx(); + const rescue = createContinuityRescue(ctx, store); + const old = renderContinuityNotice({ currentWork: "指令", nextStep: "回复" }); + // seq 0 是上一次注入的快照:还在 append-only 日志里,但已被一次压缩 replace 折叠出表面。 + const events = [ + { type: "user/message", seq: 0, data: { content: [{ type: "text", text: old }], source: { kind: "plugin", plugin: "dsh-mneme" } } }, + userEvent(1, "指令"), + assistantEvent(2, "回复") + ]; + const session = fakeSession("s1", events, [1, 2]); + ctx.fire("session/event", session, { type: "compaction/end", seq: 9 }); + const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + // 按日志判重会在这里静默跳过(同文本命中已折叠的旧事件),注入那一半就空了一次; + // 按表面判重才补得上——这正是双落点的 ② 存在的理由。 + assert.equal(decision.messages.length, 1, "日志里有同文本、表面里没有 → 这一次仍要补"); + rescue.dispose(); +}); + +test("#249 N3:落库失败不打断宿主的一步(本函数的不变量)", async () => { + const ctx = mockCtx(); + const broken = { saveContinuityProposal() { throw new Error("boom"); } }; + const rescue = createContinuityRescue(ctx, broken); + const session = fakeSession("s1", [userEvent(1, "指令")]); + ctx.fire("session/event", session, { type: "compaction/end", seq: 3 }); + const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + // 写不进去也要把注入那一半做出来:追加不依赖库;更关键的是可选插件的故障绝不该把 + // agent 的这一步变成失败(磁盘满 / busy 超时 / 库损坏都会走到这里)。 + assert.equal(decision.messages.length, 1, "库写不进去,注入那一半照做"); + assert.equal(ctx.warnings.length, 1, "失败要留痕,不静默"); + assert.match(ctx.warnings[0], /not recorded/); + rescue.dispose(); +}); + +test("#249 N3:队列满的丢弃必须留痕(§8 触发率口径)", async () => { + const store = createStore(":memory:"); + const ctx = mockCtx(); + const rescue = createContinuityRescue(ctx, store); + for (let i = 0; i < 200; i += 1) { + store.saveContinuityProposal({ sessionId: `filler-${i}`, kind: KIND, currentWork: "占位" }); + } + const session = fakeSession("s1", [userEvent(1, "指令")]); + ctx.fire("session/event", session, { type: "compaction/end", seq: 3 }); + const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + assert.equal(store.getContinuityProposal("s1", KIND), null, "满队列弃新:本次触发不落库"); + assert.equal(decision.messages.length, 1, "但注入那一半照做(两半各自独立)"); + assert.ok(ctx.warnings.some((w) => w.includes("queue full")), "丢弃是静默失效的那一类,必须报出来"); + rescue.dispose(); +}); + +test("#249 N3:门控不调模型(静态锁)与挂载点的父开关在前", () => { + // §11「门控不调 LLM」:本模块只 import 消息工厂。静态锁而不是运行时断言——真去调模型的 + // 回归不会从返回值露出来,只会让这一步变慢、变贵。 + const src = readFileSync(new URL("../src/continuity.js", import.meta.url), "utf8"); + assert.deepEqual(src.match(/^import .*$/gm), ['import { createUserMessage } from "@deepseek-ai/dsh-llm";']); + // 挂载点(`src/index.js`)是本功能唯一的生产入口:父开关必须在前、子项在后,否则「默认关」 + // 只剩 Config 里的默认值撑着,接线漏一次没人发现。 + const index = readFileSync(new URL("../src/index.js", import.meta.url), "utf8"); + assert.match(index, /if \(cfg\.autoInject && injectChildEnabled\(cfg, "continuityRescueEnabled"\)\) \{/); + assert.match(index, /disposers\.push\(createContinuityRescue\(ctx, store\)\)/); +}); + +test("#249 N3:reject 放行且不消费边缘;缺 session 不抛", async () => { + const store = createStore(":memory:"); + const ctx = mockCtx(); + const rescue = createContinuityRescue(ctx, store); + const session = fakeSession("s1", [userEvent(1, "指令")]); + ctx.fire("session/event", session, { type: "compaction/end", seq: 3 }); + + const rejected = { kind: "reject", feedback: "no" }; + const viaReject = await ctx.fire("agent/pre-step", { agent: { session } }, async () => rejected); + assert.equal(viaReject, rejected, "reject 原样返回——这一步不该发生,塞消息没有意义"); + assert.equal(store.countContinuityProposals(), 0, "边缘没被消费"); + const after = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] })); + assert.equal(after.messages.length, 1, "边缘留给下一次真正的步骤"); + + ctx.fire("session/event", fakeSession("s2", []), { type: "compaction/end", seq: 4 }); + const noAgent = await ctx.fire("agent/pre-step", {}, async () => ({ kind: "enter", messages: [] })); + assert.deepEqual(noAgent.messages, [], "拿不到 session 就什么都不做"); + rescue.dispose(); +}); + +test("#249 N3:契约——默认关、父关不生效、轻量档压掉、降级规则只在开启时进描述", () => { + assert.equal(Config({}).continuityRescueEnabled, false, "新增注入表面 → opt-in 默认关"); + assert.equal(applyLightModePreset({ lightMode: true }).continuityRescueEnabled, false, "轻量档压掉(同 injectGuidanceEnabled)"); + assert.equal(injectChildEnabled({ autoInject: false, continuityRescueEnabled: true }, "continuityRescueEnabled"), false, "父关则不生效"); + + const plain = toolDescriptions({ continuityRescueEnabled: false }); + const on = toolDescriptions({ autoInject: true, continuityRescueEnabled: true }); + const gated = toolDescriptions({ autoInject: false, continuityRescueEnabled: true }); + assert.notEqual(on.get("memory_save"), plain.get("memory_save"), "开启时 memory_save 描述补降级规则"); + assert.equal(gated.get("memory_save"), plain.get("memory_save"), "父关 → 描述逐字节回到未开状态"); + assert.equal(on.get("memory_search"), plain.get("memory_search"), "其它工具的描述不受牵连"); + // 降级规则本身是可读的持久规则(宿主无压缩前时机时由 agent 自判),不是一句口号。 + for (const field of ["current_work", "next_step"]) { + assert.ok(on.get("memory_save").includes(field), `降级规则要点名 ${field}`); + } +}); + +test("#249 N3:存量库启动即建表(幂等迁移),重开后提案还在", () => { + const dir = mkdtempSync(join(tmpdir(), "mneme-continuity-")); + const path = join(dir, "memory.db"); + let store; + let reopened; + try { + store = createStore(path); + store.saveContinuityProposal({ sessionId: "s1", kind: KIND, currentWork: "重启前" }); + assert.equal(store.getContinuityProposal("s1", KIND).current_work, "重启前"); + reopened = createStore(path); + assert.equal(reopened.getContinuityProposal("s1", KIND).current_work, "重启前", "SCHEMA 的 IF NOT EXISTS 让存量库重开也不用迁移"); + assert.equal(reopened.countContinuityProposals({ status: "pending" }), 1); + } finally { + // 先关句柄再删目录:断言失败时若还开着库,Windows 的 rmSync 会抛 EPERM 盖掉真因。 + try { reopened?.close(); } catch { /* 关闭失败不该掩盖断言差异 */ } + try { store?.close(); } catch { /* 同上 */ } + rmSync(dir, { recursive: true, force: true }); + } +});