@modusensus/dsh-mneme 0.8.7 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-1370%20passed-success)](https://github.com/slow-stack/mneme)
9
- [![tests](https://img.shields.io/badge/tests-1370%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-1404%20passed-success)](https://github.com/slow-stack/mneme)
10
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
11
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
12
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -348,6 +347,8 @@ dsh web
348
347
  | `dreamSummaryProvider` / `dreamSummaryModel` | 空 | 总览(dream_summarize)专用模型路由(留空 = 沿用 `dreamProvider`/`dreamModel`)。consolidate 有窗口(`dreamMaxSnapshotSize`)而总览输入随库增长,ctx 需求差数倍——用小 ctx 模型跑巩固时把总览指到大 ctx 模型(issue #258) |
349
348
  | `dreamSummaryMaxInputs` | `0` | 总览输入条数硬上限(0 = 不设上限):超过时按 `updated_at` 倒序只保留最新 N 条进总览,防小 ctx 模型被全库输入撑爆;总览口径脚注的条数随实际输入变化 |
350
349
  | `dreamMinIntervalMinutes` | `0` | autoDream 最小触发间隔(0-10080,0=不限):失败/degraded run 也占用 |
350
+ | `dreamPeakHours` | 空 | 巩固侧高峰时段(本地时间,与 `summarizePeakHours` **同一份语法**):空=关;命中时不调模型、baseline 不刷新(阈值继续累积,留到非高峰一次性巩固)、登记 `skipped`/`peak-hours` 审计并顺延到最近的高峰结束时刻。适合「白天要留算力给交互、巩固挪到夜里」的场景(#239 第 4 项镜像到巩固) |
351
+ | `dreamPeakMaxDeferMinutes` | `120` | 巩固侧高峰顺延上限(0-1440 分钟,0=不设上限):到点仍处高峰就照常跑,避免长高峰把巩固饿死(#239) |
351
352
  | `dreamNarrativeEnabled` | `false` | 叙述条总开关(#164 对齐,v0.8.4):按共享 tag 主题簇合成叙述 + evidence 证据链,注入候选排除(按需检索,常驻位只留 dream 总览);也走 feature_flags 白名单,lightMode 强制关 |
352
353
  | `dreamNarrativeMinCluster` | `3` | 主题簇合成叙述的最小成员数(2-20,v0.8.4) |
353
354
  | `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer <apiToken>` |
@@ -582,8 +583,8 @@ src/
582
583
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
583
584
  └── index.js # 插件接线
584
585
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
585
- test/ # 1342 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
586
- test/ # 1342 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
586
+ test/ # 1404 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
587
+ test/ # 1404 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
587
588
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
588
589
  ```
589
590
 
@@ -592,8 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
592
593
  ```bash
593
594
  cd dsh-mneme
594
595
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
595
- npm test # 运行 1342 个测试
596
- npm test # 运行 1342 个测试
596
+ npm test # 运行 1404 个测试
597
597
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
598
598
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
599
599
  ```
@@ -606,6 +606,7 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动
606
606
 
607
607
  > 设计文档位于仓库根 `docs/`,链接以 `../docs/` 相对路径指向(GitHub 上从本目录打开可正常跳转)。
608
608
 
609
+ - [配置说明(全键参考)](docs/CONFIGURATION.md)
609
610
  - [实体结构化记忆设计](docs/ENTITIES.md)
610
611
  - [语义增强架构](docs/SEMANTIC.md)
611
612
  - [本地模型部署指南](docs/LOCAL_MODEL.md)
package/lib/client.js CHANGED
@@ -381,6 +381,8 @@ window.__ModuleLoader__.load({
381
381
  "memory.features.autoInject.hint": "每轮对话自动携带相关记忆",
382
382
  "memory.features.injectGuidanceEnabled": "能力说明",
383
383
  "memory.features.injectGuidanceEnabled.hint": "在工具描述与一次性提示段里说明怎么用记忆(何时查、何时写、拿不准就不做)",
384
+ "memory.features.continuityRescueEnabled": "压缩边缘抢救",
385
+ "memory.features.continuityRescueEnabled.hint": "上下文即将被精简前,落一条连续性快照(正在做什么 / 下一步 / 未决问题)并追加到对话末尾,让它活过这次压缩",
384
386
  "memory.features.parentOff": "父开关关闭时不生效",
385
387
  "memory.features.autoSummarize": "自动总结",
386
388
  "memory.features.autoSummarize.hint": "对话结束自动提炼记忆条目",
@@ -395,6 +397,13 @@ window.__ModuleLoader__.load({
395
397
  "memory.features.entityExtractionReasoning.low": "低",
396
398
  "memory.features.entityExtractionReasoning.medium": "中",
397
399
  "memory.features.entityExtractionReasoning.high": "高",
400
+ "memory.features.summarizeReasoningEffort": "蒸馏思考强度",
401
+ "memory.features.summarizeReasoningEffort.off": "关闭思考",
402
+ "memory.features.summarizeReasoningEffort.none": "跟随默认",
403
+ "memory.features.summarizeReasoningEffort.low": "低",
404
+ "memory.features.summarizeReasoningEffort.medium": "中",
405
+ "memory.features.summarizeReasoningEffort.high": "高",
406
+ "memory.features.summarizeReasoningEffort.hint": "思考型模型建议选低档或关闭思考,避免推理烧光输出预算导致总结失败;改后重启 DSH 生效",
398
407
  "memory.features.entityExtractionModelHint": "Provider/模型留空 = 跟随主对话模型;思考强度 none = 服务商默认",
399
408
  "memory.features.codingRetrospect": "编码记忆蒸馏",
400
409
  "memory.features.codingRetrospect.hint": "用完整转录(含工具调用与报错)提炼踩坑、约束与被否决方案",
@@ -456,6 +465,10 @@ window.__ModuleLoader__.load({
456
465
  "memory.features.dreamProvider": "巩固模型 Provider",
457
466
  "memory.features.dreamModel": "巩固用模型名",
458
467
  "memory.features.dreamModelHint": "留空 = 跟随主对话模型;只影响记忆巩固(autoDream)用的模型",
468
+ "memory.features.dreamPeakHours": "高峰时段(不做梦)",
469
+ "memory.features.dreamPeakHours.hint": "空 = 关闭。逗号分隔、可带星期前缀、支持跨零点,如 09:00-18:00 或 mon-fri 08:00-12:00,14:00-18:00。命中时段不调模型,顺延到最近的高峰结束时刻补跑(最多顺延 dreamPeakMaxDeferMinutes 分钟,避免长高峰把巩固饿死)",
470
+ "memory.features.summarizePeakHours": "高峰时段(不蒸馏)",
471
+ "memory.features.summarizePeakHours.hint": "空 = 关闭。与上方巩固侧同一份时段语法;命中时蒸馏顺延到非高峰、窗口累积后一次蒸",
459
472
  "memory.features.sleepProvider": "睡眠 Provider",
460
473
  "memory.features.sleepModel": "睡眠模型",
461
474
  "memory.features.sleepModelHint": "留空 = 用巩固模型或当前模型;建议选非思考模型",
@@ -769,6 +782,8 @@ window.__ModuleLoader__.load({
769
782
  "memory.features.autoInject.hint": "Carry relevant memories into every turn",
770
783
  "memory.features.injectGuidanceEnabled": "Capability guide",
771
784
  "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",
785
+ "memory.features.continuityRescueEnabled": "Compaction-edge rescue",
786
+ "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",
772
787
  "memory.features.parentOff": "Inactive while auto injection is off",
773
788
  "memory.features.autoSummarize": "Auto summarization",
774
789
  "memory.features.autoSummarize.hint": "Distill memory entries when a conversation ends",
@@ -783,6 +798,13 @@ window.__ModuleLoader__.load({
783
798
  "memory.features.entityExtractionReasoning.low": "Low",
784
799
  "memory.features.entityExtractionReasoning.medium": "Medium",
785
800
  "memory.features.entityExtractionReasoning.high": "High",
801
+ "memory.features.summarizeReasoningEffort": "Distill reasoning effort",
802
+ "memory.features.summarizeReasoningEffort.off": "No reasoning",
803
+ "memory.features.summarizeReasoningEffort.none": "Follow default",
804
+ "memory.features.summarizeReasoningEffort.low": "Low",
805
+ "memory.features.summarizeReasoningEffort.medium": "Medium",
806
+ "memory.features.summarizeReasoningEffort.high": "High",
807
+ "memory.features.summarizeReasoningEffort.hint": "Prefer low or No reasoning for thinking models so reasoning cannot drain the output budget; takes effect after a DSH restart",
786
808
  "memory.features.entityExtractionModelHint": "Provider / model empty = follow the main conversation model; reasoning none = provider default",
787
809
  "memory.features.codingRetrospect": "Coding retrospection",
788
810
  "memory.features.codingRetrospect.hint": "Distill pitfalls, constraints and rejected solutions from full transcripts (tools and errors included)",
@@ -844,6 +866,10 @@ window.__ModuleLoader__.load({
844
866
  "memory.features.dreamProvider": "Consolidation provider",
845
867
  "memory.features.dreamModel": "Consolidation model",
846
868
  "memory.features.dreamModelHint": "Leave empty to follow the main conversation model; only affects autoDream consolidation",
869
+ "memory.features.dreamPeakHours": "Peak hours (no dreaming)",
870
+ "memory.features.dreamPeakHours.hint": "Empty = off. Comma-separated windows, optional weekday prefix, midnight-crossing allowed — e.g. 09:00-18:00 or mon-fri 08:00-12:00,14:00-18:00. Inside these windows no LLM call is made; the run is deferred to the nearest peak-end (capped by dreamPeakMaxDeferMinutes so an all-day peak cannot starve consolidation)",
871
+ "memory.features.summarizePeakHours": "Peak hours (no distillation)",
872
+ "memory.features.summarizePeakHours.hint": "Empty = off. Same window syntax as the consolidation side above; inside a peak, distillation is deferred and the window accumulates for one bigger run",
847
873
  "memory.features.sleepProvider": "Sleep provider",
848
874
  "memory.features.sleepModel": "Sleep model",
849
875
  "memory.features.sleepModelHint": "Leave empty to reuse the consolidation model; a non-reasoning model is recommended",
@@ -1931,7 +1957,7 @@ window.__ModuleLoader__.load({
1931
1957
  // 「重置用户配置」,子项自己勾着的值要留着,也应该能提前设好。同一份关系在
1932
1958
  // 后端 src/config.js 的 INJECT_CHILD_FLAGS(运行时闸门),两侧漂移由
1933
1959
  // test/inject-parent-gate.test.js 钉住。
1934
- const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled"] };
1960
+ const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"] };
1935
1961
  const FEATURE_GROUPS = [
1936
1962
  { key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "memoryQualityFilter.enabled", "llmAudit.enabled"] },
1937
1963
  { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled"] },
@@ -1942,10 +1968,16 @@ window.__ModuleLoader__.load({
1942
1968
  const FEATURE_ADVANCED_BOOLS = ["hybridInject", "selectiveInjectEnabled", "adaptiveThresholdEnabled", "reflectionUpdateEnabled", "reflectionFailureTracking", "conflictFreezeEnabled", "trustEpistemicWeighting"];
1943
1969
  // 字符串键(blur/Enter 提交,空串合法 = 跟随默认):巩固模型与语义
1944
1970
  // 检索路线。embedProvider 是枚举,用下拉单独渲染。
1945
- const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel"];
1971
+ const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel",
1972
+ // Issue #239 第 4 项:错峰时段串(巩固侧与蒸馏侧)。此前只有后端白名单、
1973
+ // 面板调不到——两个错峰键一个能调一个不能比都不给更让人困惑。
1974
+ "dreamPeakHours", "summarizePeakHours"];
1946
1975
  const EMBED_PROVIDERS = ["openai", "local", "ollama"];
1947
1976
  // 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。
1948
1977
  const ENTITY_REASONING = ["none", "low", "medium", "high"];
1978
+ // 蒸馏思考强度(issue #315):比 entity 多一个 off(显式关思考,思考型模型
1979
+ // 蒸馏防推理烧预算)。后端枚举含 off,面板必须能选到,否则操作者用不上。
1980
+ const SUMMARIZE_REASONING = ["off", "none", "low", "medium", "high"];
1949
1981
 
1950
1982
  function FeatureRow({ name, hint, on, disabled, onToggle, sub }) {
1951
1983
  return h("div", { className: "mneme-featrow", style: sub ? { paddingLeft: 18, opacity: 0.86 } : undefined },
@@ -2201,7 +2233,11 @@ window.__ModuleLoader__.load({
2201
2233
  Array.isArray(routes)
2202
2234
  ? routeSelects("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort")
2203
2235
  : h(react.Fragment, null, strRow("dreamProvider"), strRow("dreamModel")),
2204
- h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamModelHint"))
2236
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamModelHint")),
2237
+ // Issue #239 第 4 项镜像到巩固:高峰时段串(空 = 关闭)。放在 autoDream
2238
+ // 子块内——它是巩固的排程,开关关掉时不该还在界面上留着可编辑的输入框。
2239
+ strRow("dreamPeakHours"),
2240
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamPeakHours.hint"))
2205
2241
  );
2206
2242
 
2207
2243
  // 睡眠模型:sleepModeEnabled 开着才展开(sleepProvider/sleepModel 随本版
@@ -2231,6 +2267,27 @@ window.__ModuleLoader__.load({
2231
2267
  h("div", { className: "mneme-featsubhint" }, t("memory.features.entityExtractionModelHint"))
2232
2268
  );
2233
2269
 
2270
+ // 蒸馏思考强度(issue #315):autoSummarize 开着才展开,档位与实体抽取
2271
+ // 同款枚举下拉、即时提交。蒸馏没有独立 provider/model 路由键(跟随会话
2272
+ // 头或 config 文件的 summarizeProvider/summarizeModel),不上连通性测试。
2273
+ const summarizeSub = eff.autoSummarize && h("div", { className: "mneme-featsub" },
2274
+ h("div", { className: "mneme-featnum" },
2275
+ h("span", { className: "mneme-featnumlabel" }, t("memory.features.summarizeReasoningEffort")),
2276
+ h("select", {
2277
+ className: "mneme-select",
2278
+ value: eff.summarizeReasoningEffort || "none",
2279
+ disabled: busy,
2280
+ onChange: (e) => put({ summarizeReasoningEffort: e.target.value })
2281
+ },
2282
+ SUMMARIZE_REASONING.map((r) => h("option", { key: r, value: r }, t(`memory.features.summarizeReasoningEffort.${r}`))))
2283
+ ),
2284
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizeReasoningEffort.hint")),
2285
+ // Issue #239 第 4 项:蒸馏侧错峰时段串(空 = 关闭)。与思考强度同处一个
2286
+ // autoSummarize 子块——两处错峰开关都能在面板上调(巩固侧见 dreamSub)。
2287
+ strRow("summarizePeakHours"),
2288
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizePeakHours.hint"))
2289
+ );
2290
+
2234
2291
  if (error && !state) return h("section", { className: "mneme-set-card" },
2235
2292
  h("div", { className: "mneme-set-title" }, t("memory.features.title")),
2236
2293
  h("div", { className: "mneme-set-hint" }, error));
@@ -2248,6 +2305,7 @@ window.__ModuleLoader__.load({
2248
2305
  FEATURE_GROUPS.map((g) => h(react.Fragment, { key: g.key },
2249
2306
  h("div", { className: "mneme-featgroup" }, t(`memory.features.${g.key}`)),
2250
2307
  g.items.map(flagRow),
2308
+ g.key === "group.core" && h(react.Fragment, null, summarizeSub),
2251
2309
  g.key === "group.enhance" && h(react.Fragment, null, embedSub, entitySub),
2252
2310
  g.key === "group.dream" && h(react.Fragment, null, dreamSub, sleepSub)
2253
2311
  )),
package/lib/config.js CHANGED
@@ -87,6 +87,22 @@ export const Config = z.object({
87
87
  // 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
88
88
  // feature_flags 的值永远优先于这里的默认值。
89
89
  injectGuidanceEnabled: z.boolean().default(true),
90
+ // #249 N3(压缩边缘双落点):上下文即将大幅精简前抢救「正在做什么」。默认关。
91
+ // 触发靠宿主自己的压缩事件(`compaction/start|summary|end`,都在事件白名单里),
92
+ // 不自定一套阈值参数——「压缩边缘」由宿主定义,我们再校准一份只会与之漂移。
93
+ // 为什么是「新时机 + 新表面」因而默认关:它往对话里**追加消息**(新的注入表面,
94
+ // 参照实现里最容易累积成一堆历史的那类),并多写一张提案表。按 §10 判据,引入
95
+ // 新时机/新表面/新成本的子项独立成键、默认关;只修正既有块的(基础内容分池、
96
+ // 库可见性行)才随父开关默认开。`pinnedInjectBudget` 与它同批,但属前者之外:
97
+ // 那是既有块内的预算,不是新表面。
98
+ // 双落点是硬要求,不能只留一半:宿主的压缩摘要器**只看对话里的内容**,只落库不
99
+ // 注入,等于在摘要重建里什么都没留下;只注入不落库,则压缩一过就随旧消息一起
100
+ // 消失。两者都做,且落库先于注入(注入失败不该丢提案)。
101
+ // 祖先:`autoInject`(父关则本项不生效,闸门见 injectChildEnabled)。轻量档默认
102
+ // 置关(见 LIGHT_MODE_OFF):轻量档多一份注入物是反的,与 injectGuidanceEnabled
103
+ // 同一取舍。注意预设只是默认值而非强制——装配时用户显式开关在它之后展开,勾了就赢
104
+ // (合并顺序见 index.js 装配处)。
105
+ continuityRescueEnabled: z.boolean().default(false),
90
106
  // #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
91
107
  // 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
92
108
  // 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
@@ -129,6 +145,31 @@ export const Config = z.object({
129
145
  // 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
130
146
  // 写入事件会重新评估。
131
147
  dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
148
+ // Issue #292(#135 派生):autoDream 连续失败退避(opt-in,默认关 = 行为与
149
+ // 现状逐字节一致)。开启后调度器对连续失败做指数退避:有效最小间隔 =
150
+ // dreamMinIntervalMinutes × 2^连续失败数(成功一次清零恢复),封顶 30 分钟。
151
+ // #89 的最小间隔闸失败 run 也占用,但间隔恒定——恒定失败的模型(#135 空体
152
+ // 面)会按固定节奏连发刷爆配额;退避把下次重试按失败次数指数推远。基数取
153
+ // dreamMinIntervalMinutes:基数为 0 时无闸可翻倍,本键不自己产生间隔(先配
154
+ // dreamMinIntervalMinutes 再开本键)。与 dreamPeakHours / dreamMinIntervalMinutes
155
+ // 同族(节流阀,不新增任何 LLM 调用),故不进 LIGHT_MODE_OFF。
156
+ autoDreamFailureBackoff: z.boolean().default(false),
157
+ // Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与
158
+ // summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec /
159
+ // isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持
160
+ // 跨零点。空串 = 关闭,行为与现状逐字节一致。
161
+ // "09:00-18:00" 每天 09:00-18:00
162
+ // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(按高峰计费的供应商即此形态)
163
+ // 为什么巩固比蒸馏更该有这道闸:单次巩固的输入是整窗快照(dreamMaxSnapshotSize
164
+ // 条),实测一次 run 的 LLM 时长可达数分钟量级,撞上高峰时既贵又慢;而它由写入
165
+ // 事件触发、没有天然的「等到空闲再跑」路径。命中高峰时:不调 LLM、不刷新
166
+ // baseline(阈值继续累积,留到非高峰一次性巩固),登记一行 status='skipped' /
167
+ // error_message='peak-hours' 审计,并按下面的上限择时补跑。任一写法非法则整串
168
+ // 按「未配置」处理——排程是省钱手段,绝不该因为写错格式把巩固停掉。
169
+ dreamPeakHours: z.string().default(""),
170
+ // 高峰顺延上限(分钟,0 = 不设上限):到点仍处高峰就照常跑,避免整天高峰把巩固
171
+ // 饿死。默认 120,与 summarizePeakMaxDeferMinutes 对齐。仅在时段串非空时生效。
172
+ dreamPeakMaxDeferMinutes: z.natural().min(0).max(1440).default(120),
132
173
  // 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel):
133
174
  // dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first,
134
175
  // Issue #25)。模型分类声明:
@@ -476,6 +517,23 @@ export const Config = z.object({
476
517
  z.const("high"),
477
518
  z.const("none")
478
519
  ]).description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,未配置 = 自动取模型支持的最低档;显式 'none' = 不发送字段、用服务商自带默认。"),
520
+ // Pass-through reasoning effort for the distill (summarize) LLM call
521
+ // (issue #315). Mirrors entityExtractionReasoning rather than dream: the
522
+ // default 'none' omits the field entirely (provider default), so current
523
+ // behavior is unchanged until the user opts in — no auto-lowest resolution
524
+ // here, distill failures are retried at the window level anyway. off/low/
525
+ // medium/high are forwarded verbatim; a provider that rejects the effort
526
+ // retries once without it (withEffortFallback), so opting in is safe to
527
+ // experiment with. The failure being addressed is the #9 shape on the
528
+ // distill path: a thinking model drains the output budget on reasoning and
529
+ // the summary comes back empty/truncated.
530
+ summarizeReasoningEffort: z.union([
531
+ z.const("off"),
532
+ z.const("low"),
533
+ z.const("medium"),
534
+ z.const("high"),
535
+ z.const("none")
536
+ ]).default("none").description("蒸馏(会话总结提炼)LLM 的推理档位:默认 'none' = 不发送字段、用服务商自带默认;off/low/medium/high 原样传递,被模型拒收时自动去掉字段重试一次(与巩固/睡眠同款降级,#315)。思考型模型建议 off/low,避免推理烧光输出预算。"),
479
537
  // Issue #257:sleep 冲突/模式两阶段的输出预算(原硬编码 2048)。实测默认档
480
538
  // 每对裁决约 90 token、24 对 2097——2048 恰好压在边界(53 次运行 48 败);
481
539
  // full 档六分支实测约 290 token/对、24 对 6967,2048 必然截断。默认 8192
@@ -633,7 +691,12 @@ const LIGHT_MODE_OFF = [
633
691
  // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
634
692
  "documentMemoryEnabled",
635
693
  // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。
636
- "heatEnabled"
694
+ "heatEnabled",
695
+ // #249 N3:轻量档默认不开压缩边缘双落点——它往对话里追加消息(新的注入表面),
696
+ // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制:
697
+ // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同
698
+ // injectGuidanceEnabled)。
699
+ "continuityRescueEnabled"
637
700
  ];
638
701
 
639
702
  /**
@@ -670,7 +733,7 @@ export function applyLightModePreset(cfg) {
670
733
  * 是扁平键,不受这条限制。
671
734
  */
672
735
  export const INJECT_CHILD_FLAGS = Object.freeze({
673
- autoInject: Object.freeze(["injectGuidanceEnabled"])
736
+ autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"])
674
737
  });
675
738
 
676
739
  /** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
@@ -0,0 +1,220 @@
1
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
+
3
+ // #249 N3(压缩边缘双落点):上下文即将大幅精简前,抢救「正在做什么」。
4
+ //
5
+ // 为什么是这个形状(#249 §4.4 / §6.2 / §11;维护者 2026-09-24 拍板「都同意,开工」):
6
+ //
7
+ // 1. **必须双落点**。宿主的压缩摘要器只看对话里的内容:压缩时宿主按固定字段模板
8
+ // 重建对话,只放系统提示段的东西不进这次重建,等于白写。所以除了落库,还要把同
9
+ // 一条连续性快照追加成靠近序列末尾的消息。DSH 没有 in-memory 消息改写钩子
10
+ // (§7 已核),因此这是「注入 + 序号引用寻址」,不是改写历史。
11
+ // 2. **落库落成提案,不落成记忆行**。边缘产出直接进 memories,长会话会攒出第 N 条
12
+ // 同主题条目(#275 记的失败形态);转正通道与 #254 的二次确认共用一套、阶段二才
13
+ // 打开,本批只写 pending 行。
14
+ // 3. **全程不调模型**。三个字段是确定性抽取 + 截断(口径见 deriveContinuity),判定
15
+ // 也只用本地字符串比较;门控成本为零。
16
+ // 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与
17
+ // `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件
18
+ // (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里
19
+ // `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没
20
+ // 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。
21
+ //
22
+ // 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先
23
+ // `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果
24
+ // 时压缩事件已经落库;而我们返回的 `decision.messages` 由宿主在本步末尾以
25
+ // `surfaceOp: "append"` 追加(`@deepseek-ai/dsh-agent-loop/lib/index.js:1046`),晚于
26
+ // 压缩的 `surfaceOp: "replace"`(`dsh-compaction-basic/lib/index.js:649`)——落点天然
27
+ // 在压缩之后、靠近序列末尾。两条注册顺序都成立(谁先谁后,我们的 `await next()` 都在
28
+ // 压缩工作之后返回)。
29
+
30
+ /** 提案的 kind(唯一键的一半):一次会话里"压缩边缘"这一类只留一条。 */
31
+ export const CONTINUITY_KIND = "compaction-edge";
32
+ /** 注入消息的统一前缀:机械校验与「不变不重复」判重都靠它(#249 §6.3)。 */
33
+ export const CONTINUITY_NOTICE_PREFIX = "[dsh-mneme continuity]";
34
+ /** 单字段字符上限:抽取是截断,不是摘要(改口径不需要动调用方)。 */
35
+ export const CONTINUITY_FIELD_MAX = 200;
36
+ /** 整条注入消息的字符上限。比 §6.3 的 160B 单行提醒宽:它是结构化快照,不是一句话提醒。 */
37
+ export const CONTINUITY_NOTICE_MAX = 900;
38
+ /** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */
39
+ const MAX_TRACKED_EDGES = 50;
40
+
41
+ const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]);
42
+
43
+ // 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理
44
+ // 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部
45
+ // 细节,两边取文本的理由不同。
46
+ function textOf(content) {
47
+ if (typeof content === "string") return content;
48
+ if (!Array.isArray(content)) return "";
49
+ return content
50
+ .map((block) => (typeof block === "string" ? block : (block?.type === "text" && typeof block.text === "string" ? block.text : "")))
51
+ .filter((s) => s)
52
+ .join("\n");
53
+ }
54
+
55
+ // DSH 0.1.2-rc.1 起 Session 用 snapshotEvents(),兼容旧版 .events。这是**全量日志**
56
+ // (append-only,含已被 surfaceOp:"replace" 折叠出表面的旧事件)——抽取要的正是全量:
57
+ // 被压缩折叠掉的最后一条真实用户消息,也曾是"正在做什么"。
58
+ function sessionEvents(session) {
59
+ let events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : session?.events;
60
+ // 与 summarize.js 的同名兼容层同形:snapshotEvents 不可用或返回非数组时退回 .events。
61
+ if (!Array.isArray(events)) events = session?.events;
62
+ return Array.isArray(events) ? events : [];
63
+ }
64
+
65
+ // 「当前表面」= `session.surface.nodes`(只存活节点的 seq,事件体在上面那份日志里)。
66
+ // 判重必须按表面而不是全量日志:旧注入还在日志里、但已被折叠出表面,照日志判重会让
67
+ // 这一次压缩后该补的注入被静默跳过——双落点的 ② 就空了一次,而日志里看不出发生过。
68
+ // 宿主没有表面投影(旧版)时退回全量:宁可少追加一次,也不在表面里重复追加同一份文本。
69
+ function surfaceEvents(session) {
70
+ const events = sessionEvents(session);
71
+ let nodes;
72
+ // 宿主的 `surface.nodes` 是会抛的 getter(它自己用过、之后又被移除或替换的 message
73
+ // projection 会让宿主内部的 `_assertProjections` 抛)。判重是锦上添花的一步:读不到就
74
+ // 退回全量,别让一个可选的抢救功能因为读宿主状态失败而把 agent 的这一步变失败。
75
+ try {
76
+ nodes = session?.surface?.nodes;
77
+ } catch {
78
+ return events;
79
+ }
80
+ if (!Array.isArray(nodes)) return events;
81
+ const inSurface = new Set(nodes);
82
+ return events.filter((event) => inSurface.has(event?.seq));
83
+ }
84
+
85
+ /**
86
+ * 抽取连续性三字段(纯函数、无 LLM、无副作用,供测试直接钉住口径):
87
+ * - `currentWork`:最近一条**真实** user/message(插件注入与子代理上报不算用户指令),
88
+ * 取头部 CONTINUITY_FIELD_MAX 字符——"正在做什么"就是最后那条指令。
89
+ * - `nextStep`:最近一条 assistant/message 的**尾部**同长度——"下一步"活在末尾那
90
+ * 一段/最后一句里,取头会把开场白带进来。
91
+ * - `openQuestions`:恒 null。确定性抽取判不出「哪些问题还没解决」,硬用反引号/问号
92
+ * 猜会给出似是而非的字段;这个字段留给转正通道与人工补(不是所有字段都必须自动
93
+ * 填满——宁可空着,也不编造)。空字段在注入文本里如实标 (none)。
94
+ */
95
+ export function deriveContinuity(session, fieldMax = CONTINUITY_FIELD_MAX) {
96
+ let currentWork = null;
97
+ let nextStep = null;
98
+ for (const event of sessionEvents(session)) {
99
+ const data = event?.data ?? {};
100
+ if (event?.type === "user/message") {
101
+ // 只认用户自己写的:source.kind 缺省(旧事件)或 "user"。插件消息(我们自己的
102
+ // 注入、其它插件)与子代理上报都不是"当前指令"。
103
+ const kind = data?.source?.kind;
104
+ if (kind !== undefined && kind !== "user") continue;
105
+ const text = textOf(data.content).trim();
106
+ if (text) currentWork = text.slice(0, fieldMax);
107
+ } else if (event?.type === "assistant/message") {
108
+ const text = textOf(data?.message?.content).trim();
109
+ if (text) nextStep = text.slice(-fieldMax);
110
+ }
111
+ }
112
+ return { currentWork, nextStep, openQuestions: null };
113
+ }
114
+
115
+ /** 单行化:字段里的换行会把「靠近末尾的一条消息」摊成多行,也破坏 §6.3 的单行约定。 */
116
+ function oneLine(text) {
117
+ return typeof text === "string" ? text.replace(/\s*\n\s*/g, " ").trim() : "";
118
+ }
119
+
120
+ /** 渲染注入文本(纯函数):统一前缀 + 三个固定字段(#249 §6.2)。 */
121
+ export function renderContinuityNotice(fields, noticeMax = CONTINUITY_NOTICE_MAX) {
122
+ const body = [
123
+ `${CONTINUITY_NOTICE_PREFIX} Context compaction happened in this session; here is the continuity snapshot ` +
124
+ "recorded so it survives outside the transcript (a proposal — not yet in the memory store):",
125
+ `current_work: ${oneLine(fields?.currentWork) || "(none)"}`,
126
+ `next_step: ${oneLine(fields?.nextStep) || "(none)"}`,
127
+ `open_questions: ${oneLine(fields?.openQuestions) || "(none)"}`
128
+ ].join("\n");
129
+ return body.length > noticeMax ? body.slice(0, noticeMax) : body;
130
+ }
131
+
132
+ /** 表面上是否已经有同一条注入(「不变不重复」,#249 §6.5):按前缀 + 全文比较,
133
+ * 不做语义判定——没有改写钩子,能做的只有"同一份文本不追加第二次"。 */
134
+ function noticeAlreadyInSurface(session, text) {
135
+ return surfaceEvents(session).some((event) => {
136
+ if (event?.type !== "user/message") return false;
137
+ if (event?.data?.source?.plugin !== "dsh-mneme") return false;
138
+ const content = textOf(event?.data?.content);
139
+ return content === text;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。
145
+ *
146
+ * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。
147
+ * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定,
148
+ * 保持"挂上就是开"的单一口径。
149
+ */
150
+ export function createContinuityRescue(ctx, store) {
151
+ // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后
152
+ // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
153
+ // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
154
+ // pre-step 是常见路径,这些条目永远走不到消费路径上。
155
+ const edge = new Map();
156
+
157
+ const unsubscribe = ctx.on("session/event", (session, event) => {
158
+ if (!EDGE_EVENT_TYPES.has(event?.type)) return;
159
+ if (!session?.id) return;
160
+ if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
161
+ // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。
162
+ edge.delete(edge.keys().next().value);
163
+ }
164
+ // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有
165
+ // 信息——summary 带摘要事件 seq),序号即触发证据。
166
+ edge.set(session.id, typeof event.seq === "number" ? event.seq : null);
167
+ });
168
+
169
+ const offStep = ctx.on("agent/pre-step", async (payload, next) => {
170
+ const decision = await next();
171
+ // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。
172
+ if (decision?.kind !== "enter") return decision;
173
+ const session = payload?.agent?.session;
174
+ const sessionId = session?.id;
175
+ if (!sessionId) return decision;
176
+ if (!edge.has(sessionId)) return decision;
177
+ const edgeSeq = edge.get(sessionId);
178
+ edge.delete(sessionId);
179
+
180
+ // 抽取就地用 payload 上的这个 session(与置位时同一个 id),不从边缘表里握对象引用。
181
+ const fields = deriveContinuity(session);
182
+ // 落库先于注入:提案是"脱离对话独立存活"的那一半。写不进去不算宿主这一步的失败——
183
+ // 可选插件的可选能力把 agent 的一步带崩,比丢一次抢救严重得多(本函数的不变量)。
184
+ try {
185
+ const saved = store.saveContinuityProposal({
186
+ sessionId,
187
+ kind: CONTINUITY_KIND,
188
+ currentWork: fields.currentWork,
189
+ nextStep: fields.nextStep,
190
+ openQuestions: fields.openQuestions,
191
+ edgeSeq
192
+ });
193
+ // 弃新是"静默失效"的那一类:不报出来,运维与 §8 的触发率口径都会把它当成没触发。
194
+ if (saved?.dropped) {
195
+ ctx.logger?.warn?.(`[dsh-mneme] continuity: pending queue full, dropped the edge for session ${sessionId}`);
196
+ }
197
+ } catch (error) {
198
+ ctx.logger?.warn?.(`[dsh-mneme] continuity proposal not recorded: ${String(error)}`);
199
+ }
200
+
201
+ const text = renderContinuityNotice(fields);
202
+ if (noticeAlreadyInSurface(session, text)) return decision;
203
+ const messages = Array.isArray(decision.messages) ? decision.messages : [];
204
+ return {
205
+ ...decision,
206
+ messages: [
207
+ ...messages,
208
+ createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin", plugin: "dsh-mneme" } })
209
+ ]
210
+ };
211
+ });
212
+
213
+ return {
214
+ dispose() {
215
+ unsubscribe?.();
216
+ offStep?.();
217
+ edge.clear();
218
+ }
219
+ };
220
+ }