@modusensus/dsh-mneme 0.8.15 → 0.9.0

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.
Files changed (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
package/lib/config.js CHANGED
@@ -91,7 +91,8 @@ export const Config = z.object({
91
91
  // feature_flags 的值永远优先于这里的默认值。
92
92
  injectGuidanceEnabled: z.boolean().default(true),
93
93
  // #249 N3(压缩边缘双落点):上下文即将大幅精简前抢救「正在做什么」。默认关。
94
- // 触发靠宿主自己的压缩事件(`compaction/start|summary|end`,都在事件白名单里),
94
+ // 触发只认「历史真的被折叠了」的压缩事件(summary / prune / 不带 error 的 end;
95
+ // 判定见 continuity.js 的 isCompactionEdge —— start 只是「试过」,带 error 的 end 是失败收尾),
95
96
  // 不自定一套阈值参数——「压缩边缘」由宿主定义,我们再校准一份只会与之漂移。
96
97
  // 为什么是「新时机 + 新表面」因而默认关:它往对话里**追加消息**(新的注入表面,
97
98
  // 参照实现里最容易累积成一堆历史的那类),并多写一张提案表。按 §10 判据,引入
@@ -189,6 +190,42 @@ export const Config = z.object({
189
190
  // 模型的推理与正文共享该预算,effort 未压低时 32768 恰好被推理烧光 → 空体
190
191
  // ("no json array in llm output",报告实测 146 轮因此失败)。默认抬到上限,
191
192
  // 让默认配置也留足正文预算;流式计费按实际用量,不按上限。
193
+ //
194
+ // 成本审计(2026-10-12,本机库只读探针)复验了「默认值该不该降回去」这个问题,
195
+ // 结论是**不该降**,故默认维持 131072。三条实测证据:
196
+ // 1. 这个键是**上限**,不是计费口径——流式按实际用量计费(与 sleepMaxTokens
197
+ // 的 description 同口径)。压上限只在**真撞顶**时生效:近 10 天 88 个成功
198
+ // 轮 output 中位 24022、73.9% 超过 16384,因此把默认压到 16384 会让近四分之三
199
+ // 的正常轮撞顶。
200
+ // 2. 撞顶的后果不是「少烧一点」,而是**整轮失败**:max_tokens 截断 → 流上没有
201
+ // 完整 JSON → extractJsonArray 返回 null → finish({ok:false,
202
+ // error:"no json array in llm output"})(src/dream.js:1167-1171);而调度器
203
+ // **只有 result.ok 才刷新 baseline**(src/dream.js:905-915,注释明写这是刻意
204
+ // 设计「失败必须能立刻重试」)→ 下一次写入用**同一批输入**重跑一遍。按 133 个
205
+ // 有真实用量的成功轮逐轮折算:cap=16384 撞顶 78.9%、净总量 +54.1%;
206
+ // cap=24576 +43.3%;cap=32768 +15.9%;cap=49152 +6.5%;98304 起为 0。
207
+ // 即**调低这个值会把 token 花得更多**,而不是更少。
208
+ // 3. 真正的大头它治不到:把「时间就近」配上的 133 次成功调用(同一次配对里取
209
+ // 分子与分母)的决策正文(merge/update 的 content+title,剥离代码自动补的隐式
210
+ // keep)按中英分估(中文 ≈1.5 字符/token、英文 ≈4)折 419,487 token,而同一批
211
+ // 调用输出合计 3,543,111 token —— 正文约占 11.8%(换算方法敏感性区间
212
+ // 8.2%~22.0%,逐轮中位 12.1%),其余约 88% 是推理与 JSON 骨架。
213
+ // 注意别用「全部有 decisions 的 run」当分子配「配对子集」的分母(会得 13.9%
214
+ // 这种偏高的数)——跨口径比较先校量纲、分子分母必须同集合。
215
+ // 病根是推理与正文共享 max_tokens 单一池子。宿主(DSH)侧确有分离机制:
216
+ // DEFAULT_THINKING_BUDGETS = { minimal:1024, low:2048, medium:8192, high:16384 }、
217
+ // adjustMaxTokensForThinking()(正文预算 + 推理预算相加后卡模型上限)、
218
+ // clampThinkingBudgetToAnswerRoom()(MIN_ANSWER_TOKENS=1024,保证正文至少留
219
+ // 1024 token)——但它只对 Anthropic Claude 路径默认生效;chat-completions 网关
220
+ // (volcano-engine / deepseek / glm 均在此列)须由路由 profile 开
221
+ // compat.supportsThinkingTokenBudget 并指定 compat.thinkingTokenBudgetField
222
+ // (vLLM/Qwen/llama.cpp 风格顶层字段,上游端点还必须认它),asar 注释明写
223
+ // "Off by default"。两者都是 **profile 级**配置,插件 stream 调用无法
224
+ // per-request 传递(ctx.llm.stream 唯一的推理旋钮是 reasoningEffort)——故本插件
225
+ // 在调用面无法单独约束推理预算:抬高这个上限是当前路由下唯一可用的止损手段,
226
+ // 治标不治本;治本是换非思考模型,或走能分离预算的路由 profile。
227
+ // 显式调小仍是允许的(min 256):适合「宁可整轮失败也要压单轮上限」的场景,但请
228
+ // 知悉上面第 2 条的净成本方向。需要旧行为(无上限感)的库显式设回 131072。
192
229
  dreamMaxTokens: z.natural().min(256).max(131072).default(131072),
193
230
  // Pass-through reasoning effort for dream's LLM calls (Issue #135 建议 4/5).
194
231
  // Unset (no value) resolves to the LOWEST effort the model declares before
@@ -216,6 +253,17 @@ export const Config = z.object({
216
253
  // 被批量归档,其中 8 条疑似误伤长保留类型)是失控信号,与 update 上限同类的
217
254
  // 全局闸门:skipInvalid 也不豁免,超限整单拒绝。正常清理可调高。
218
255
  dreamMaxArchivePerRun: z.natural().min(1).max(200).default(8),
256
+ // #135 成本审计派生:单轮 merge 决策上限。merge 是唯一**回吐正文**的动作(每条
257
+ // 要求模型重新写出合并后的 content,实测平均 550 字符/条),其余动作都不带正文,
258
+ // 所以这是「输出里正文占比」唯一可控的杠杆。与 update/archive 上限同类:全局
259
+ // 闸门,skipInvalid 也不豁免,超限整单拒绝(errors 由 decisions.js 的数量闸段
260
+ // 统一汇总,run 记 failed)。默认 40 的校准依据(只读探针 246 轮 / 近 10 天 88 轮):
261
+ // 每轮 merge 数全库 p50 7 / p90 15 / p99 28 / max 39,近 10 天 p99 39 / max 39
262
+ // ——40 即观测分布上沿(历史命中 0/246),与 archive 闸默认 8 恰为其 p90=max 是
263
+ // 同一校准法。刻意保守(不取 p90=15 之类):超限代价不对称,整单拒绝会让调度器
264
+ // 不刷新 baseline、下轮同输入重跑,一次误伤约等于该轮成本 ×2,而探针里 merge≥15
265
+ // 的大轮正文占比正常(10~22%),没有失控特征。想拿覆盖换成本的用户可调低。
266
+ dreamMaxMergePerRun: z.natural().min(1).max(200).default(40),
219
267
  // Issue #125:候选集构造方式。"window"(默认,等同现状)只取最近
220
268
  // dreamMaxSnapshotSize 条;"hybrid" 在此基础上并入**向量翻出的高相似组**——
221
269
  // 本机实测 45 对「双方活跃且 sim≥0.85」里 0 对能同时进窗口(窗口覆盖率 11.7%),
@@ -434,6 +482,13 @@ export const Config = z.object({
434
482
  reflectionFailureTracking: z.boolean().default(true),
435
483
  reflectionUpdateMaxPerRun: z.natural().min(0).max(5).default(2),
436
484
  reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24),
485
+ // failure_memories 的启动期保留期(天)。此前在 index.js 里写死 90,是这一族
486
+ // 四张启动期清理表里唯一没有键可陈述的一条(#275 伞下的欠账)。形态对齐
487
+ // recallRetentionDays:顶层扁平键、只从配置文件改;暂不进 settings.js 白名单,
488
+ // 面板可调是后续的事(那要同时补 api.js 的 NESTED 表与 client.js 的面板行)。
489
+ // 下限取 1 而非 0:这张表的用途是「挖掘重复出现的纠正模式」,窗口太短就只剩
490
+ // 最近一次;要彻底停掉清理应当显式关掉 reflectionFailureTracking。
491
+ reflectionFailureRetentionDays: z.natural().min(1).max(3650).default(90),
437
492
 
438
493
  // --- conflict freeze: manual review for conflicting memories (v0.2.1) ---
439
494
  // Opt-in by default: when true, conflicting memories are not auto-merged
@@ -630,6 +685,22 @@ export const Config = z.object({
630
685
  retentionDays: z.natural().min(1).max(3650).default(90)
631
686
  }).default({}),
632
687
 
688
+ // --- distill records + provenance (#399) ------------------------------------
689
+ // 蒸馏把窗口压成记忆后,原文默认不留——于是「这条记忆出自哪段对话」事后无法
690
+ // 复核(arXiv 2609.36059 §3 Eq.1:旁路索引不丢信息,替换才丢)。开启后蒸馏时
691
+ // 顺带把**公开**正文原样落盘(distill_records),并给写出的记忆挂上窗口链接
692
+ // (memory_provenance)。默认关:这是一份对话原文副本,价值与暴露面都取决于
693
+ // 用户是否愿意留档,不该由升级默认打开。
694
+ //
695
+ // retentionDays 只裁 distill_records(启动即清,挂 llmAudit purge 旁);链接
696
+ // 保留指向空区间,面板/CLI 显示「来源已过期」——删原文不等于篡改历史。落盘
697
+ // 文本不受 distillMaxChars / trim(args,300) / trim(out,500) 约束(记录是凭证,
698
+ // 不是喂给 LLM 的预算);工具调用入参与结果、code 输出不落盘(推理亦然)。
699
+ distillRecords: z.object({
700
+ enabled: z.boolean().default(false),
701
+ retentionDays: z.natural().min(1).max(3650).default(90)
702
+ }).default({}),
703
+
633
704
  // --- write admission (#254) ------------------------------------------------
634
705
  // 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
635
706
  // 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
@@ -765,10 +836,15 @@ export const Config = z.object({
765
836
 
766
837
  // --- scope: v0.8.0 A1 存储层(issue #17)--------------------------------
767
838
  // 总开关默认关:关闭时写入不标注 scope、去重维持 (type, title) 现状,行为
768
- // 逐字节不变。开启后 memory_save 写入 agent_scope(session header 的
769
- // agentPreset)与 workspace_scope(registry 反查 canonical path,header.cwd
770
- // 兜底,取不到 NULL——解析绝不阻塞写入),去重键扩展为 (type, title,
771
- // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。
839
+ // 逐字节不变。开启后**两条写入路径都自动盖章**(*_scope_source='auto'):
840
+ // ① 工具调用(tools.js:memory_save / memory_update,显式参数改标 'explicit')
841
+ // ② 机器提炼(summarize.js:会话蒸馏;合并进既有行时不盖章,作用域属于那一行
842
+ // 原来被提炼出来的语境——v0.9.0 补,此前只有工具路径盖章,于是「项目记忆」
843
+ // 在库里几乎不存在)。
844
+ // 两路的值同源:agent 维取 session header 的 agentPreset,workspace 维取
845
+ // registry 反查的 canonical path(header.cwd 兜底,取不到 NULL——解析绝不阻塞
846
+ // 写入),去重键扩展为 (type, title, agent_scope, workspace_scope, sensitivity)。
847
+ // 检索侧加权/过滤在 A2/A3 落地。
772
848
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
773
849
  scopeEnabled: z.boolean().default(false),
774
850
  // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见,
@@ -779,6 +855,19 @@ export const Config = z.object({
779
855
  // 打开才有意义(没有写入标注就无可过滤维度),但独立成键:可先开标注积累
780
856
  // 数据、观察 A2 加权质量后再切硬过滤。
781
857
  strictScope: z.boolean().default(false),
858
+
859
+ // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--------------------------
860
+ // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.;
861
+ // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句
862
+ // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。
863
+ // 这是客户端只读视图(做法图谱 + 对话行结算卡)的门控。API 的 effective 取值只认
864
+ // config 顶层/嵌套路径,schema 里没有这个键 → effective 静默少键 → 客户端读
865
+ // 不到就一个座位都不注册(#254 漏登记 NESTED_FLAG_PATHS 是同一个坑)。
866
+ // 默认关:beta 功能必须 opt-in,老用户行为逐字节不变。不进 LIGHT_MODE_OFF——
867
+ // 它只是只读视图、不引入任何重型后台路径,轻量档用户显式勾选仍然赢(预设
868
+ // 该关的是重型能力,不该替用户关掉一个纯 UI 入口)。
869
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
870
+ actionPipelineEnabled: z.boolean().default(false)
782
871
  });
783
872
 
784
873
  // Fields forced to false by the light-mode preset. Everything not listed here
@@ -821,7 +910,11 @@ const LIGHT_MODE_OFF = [
821
910
  // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制:
822
911
  // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同
823
912
  // injectGuidanceEnabled)。
824
- "continuityRescueEnabled"
913
+ "continuityRescueEnabled",
914
+ // #399:轻量档不开蒸馏原文留存——它是一份对话原文副本(磁盘 + 暴露面),
915
+ // 与轻量档「小模型 / 小上下文 / 少留痕」的取向相反。默认关,这里只是
916
+ // 轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。
917
+ "distillRecords.enabled"
825
918
  ];
826
919
 
827
920
  /**
package/lib/continuity.js CHANGED
@@ -15,9 +15,11 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
15
15
  // 也只用本地字符串比较;门控成本为零。
16
16
  // 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与
17
17
  // `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件
18
- // (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里
18
+ // (`compaction/*` 一族,都在 @deepseek-ai/dsh-session 的事件白名单里
19
19
  // `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没
20
20
  // 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。
21
+ // **但发过压缩事件不等于这段历史真的被折叠了** —— 压缩插件在每一步都会试一次,
22
+ // 失败时没有任何东西需要抢救。记账口径见 isCompactionEdge。
21
23
  //
22
24
  // 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先
23
25
  // `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果
@@ -38,7 +40,34 @@ export const CONTINUITY_NOTICE_MAX = 900;
38
40
  /** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */
39
41
  const MAX_TRACKED_EDGES = 50;
40
42
 
41
- const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]);
43
+ /**
44
+ * 什么算「一个真的边缘」= 这段历史**真的被折叠了**。
45
+ *
46
+ * 这跟「有人发过压缩事件」不是同一个问题。压缩插件在**每一步**都会试一次压缩
47
+ * (`agent/pre-step` 里的 `compactIfNeeded`),所以 `compaction/start` 只在说
48
+ * 「有人试过」;尝试失败时没有任何东西需要抢救。按「试过就救」记账会得到一个
49
+ * **每步都触发**的触发器 —— 实测(2026-10-14,本机两个长会话、12,000+ 事件,
50
+ * 代理层把 dsh 原生压缩 100% 拒掉):
51
+ *
52
+ * · 按 `start|summary|end` 记账:1,287 次置位 / 359 次注入(4c28eaaa:2,895 / 625)
53
+ * · 按「真落地」记账: 39 次置位 / 32 次注入(4c28eaaa: 161 / 74)→ 降 91% / 88%
54
+ *
55
+ * 三种「真落地」:
56
+ * - `compaction/summary`:宿主压缩成功才落(dsh-compaction-basic 的
57
+ * `commitCompactionBody`,与成功路同步提交;失败路不落)。
58
+ * - `compaction/prune`:宿主把旧事件折叠出表面(dsh-compaction-tool-result-pruner),
59
+ * 是「表面真的变短了」最直接的证据。
60
+ * - `compaction/end`:**成败两条路都发**(同一份源码:失败路
61
+ * `append("compaction/end", { ...lifecycle, error: errorChain(error) })`),
62
+ * 所以只有**不带 error** 的那条才算落地。
63
+ */
64
+ function isCompactionEdge(event) {
65
+ const type = event?.type;
66
+ if (type === "compaction/summary" || type === "compaction/prune") return true;
67
+ if (type !== "compaction/end") return false;
68
+ const error = event?.data?.error;
69
+ return error === undefined || error === null || error === "";
70
+ }
42
71
 
43
72
  // 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理
44
73
  // 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部
@@ -148,21 +177,21 @@ function noticeAlreadyInSurface(session, text) {
148
177
  * 保持"挂上就是开"的单一口径。
149
178
  */
150
179
  export function createContinuityRescue(ctx, store) {
151
- // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后
180
+ // 待处理的边缘:sessionId → 压缩事件序号。**真落地**的压缩事件到达时置位,下一次 pre-step 消费后
152
181
  // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
153
182
  // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
154
183
  // pre-step 是常见路径,这些条目永远走不到消费路径上。
155
184
  const edge = new Map();
156
185
 
157
186
  const unsubscribe = ctx.on("session/event", (session, event) => {
158
- if (!EDGE_EVENT_TYPES.has(event?.type)) return;
187
+ if (!isCompactionEdge(event)) return;
159
188
  if (!session?.id) return;
160
189
  if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
161
190
  // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。
162
191
  edge.delete(edge.keys().next().value);
163
192
  }
164
- // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有
165
- // 信息——summary 带摘要事件 seq),序号即触发证据。
193
+ // 同一轮可能出现多条落地事件(prune 与 end 并存),后到的覆盖前一个:留着最后一个,
194
+ // 序号即触发证据。
166
195
  edge.set(session.id, typeof event.seq === "number" ? event.seq : null);
167
196
  });
168
197
 
@@ -264,6 +264,26 @@ export function validateDecisions(decisions, snapshot, options = {}) {
264
264
  if (archiveCount > maxArchivePerRun) {
265
265
  errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
266
266
  }
267
+ // merge 批量上限(#135 成本审计的派生护栏)。merge 是唯一**回吐正文**的动作:
268
+ // 每条 merge 都要求模型重新写出合并后的 content(实测 2113 条 merge 平均 550
269
+ // 字符/条),而 archive/keep/conflict 都不带正文——所以单轮 merge 数是「输出
270
+ // 里正文占比」唯一可控的杠杆(实测 dream_consolidate 的输出 token 只有约 11.8%
271
+ // 是决策正文,其余是推理与 JSON 骨架,那部分由 effort 决定、与本闸无关)。
272
+ // 与 update/create/archive 三道闸同类:全局闸门,skipInvalid 也不豁免,超限整单
273
+ // 拒绝。默认值取 40 的校准依据(只读探针,全库 246 轮 / 近 10 天 88 轮):
274
+ // 每轮 merge 数 全库 p50 7 / p90 15 / p99 28 / max 39,近 10 天 p50 9 / p90 20 /
275
+ // p99 39 / max 39 —— 40 是观测分布的上沿(历史命中 0/246),与 archive 闸默认 8
276
+ // 恰为其 p90=max 同一校准法:这是面向未来的尾险护栏,不是成本削减工具(现有三
277
+ // 道闸在实测分布下命中率均为 0.0%,同一性质)。刻意不取更小的值:超限的代价
278
+ // 不对称——整单拒绝 = run 记 failed = 调度器不刷新 baseline(src/dream.js:905-915)
279
+ // = 下一次写入用同一批输入重跑,一次误伤约等于该轮成本 ×2;而探针里 merge≥15 的
280
+ // 大轮正文/显式决策比例都正常(10~22%),没有失控特征。想用覆盖换成本的用户可
281
+ // 自行调低 dreamMaxMergePerRun。
282
+ const mergeCount = survivors.filter((d) => d.action === "merge").length;
283
+ const maxMergePerRun = options.maxMergePerRun ?? 40;
284
+ if (mergeCount > maxMergePerRun) {
285
+ errors.push(`too many merge decisions: ${mergeCount} > ${maxMergePerRun}`);
286
+ }
267
287
  // Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
268
288
  // 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
269
289
  // 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
@@ -375,6 +395,12 @@ function casGuard(service, snapshot, ids) {
375
395
  * provided, every decision target is CAS-checked against it and a decision
376
396
  * computed from stale state is skipped and reported as a conflict instead of
377
397
  * overwriting concurrent writes (item ①).
398
+ * @param config - pipeline config; `config.mintSource` (optional string) is the
399
+ * provenance stamped on memories minted by `create` decisions. It is
400
+ * read from the CALLER, never from the decision object: the LLM authors
401
+ * `create` payloads and validateDecisions does not strip unknown fields, so
402
+ * trusting `d.source` would let a model label its own output as human-written.
403
+ * Absent/blank → saveWithDedupe's existing "manual" fallback (unchanged).
378
404
  * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
379
405
  * applied - number of decisions/memories actually committed (archive counts
380
406
  * each archived memory as one, merge/conflict/update count one).
@@ -464,7 +490,17 @@ function applyCreate(d, service, config = {}) {
464
490
  const body = evidence.length > 0
465
491
  ? STR.evidenceSuffix[language](content, evidence)
466
492
  : content;
467
- const created = service.saveWithDedupe({ type, title, content: body, importance });
493
+ // mint 来源由调用方(管道)给,不从决策对象取——LLM 能自己写 d.source,
494
+ // 信它就等于让模型给自造条目盖「人写的」戳(见 applyDecisions 的 @param config)。
495
+ // 取不到就一个字段都不传,落回 saveWithDedupe 原有的兜底,行为与改前一致。
496
+ const mintSource = typeof config?.mintSource === "string" ? config.mintSource.trim() : "";
497
+ const created = service.saveWithDedupe({
498
+ type,
499
+ title,
500
+ content: body,
501
+ importance,
502
+ ...(mintSource ? { source: mintSource } : {})
503
+ });
468
504
  const memory = created?.memory;
469
505
  if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
470
506
  return { applied: 1, committed: { action: "create", id: memory.id, type } };
@@ -381,7 +381,11 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
381
381
  allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
382
382
  });
383
383
  if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
384
- const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
384
+ // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
385
+ // LEGACY_ACTIONS 含 create,理论上可 mint)。
386
+ const { applied, failures, conflicts } = applyDecisions(
387
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
388
+ );
385
389
  // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
386
390
  // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
387
391
  // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
@@ -525,7 +529,11 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
525
529
  maxCreatePerRun: maxPatterns
526
530
  });
527
531
  if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
528
- const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
532
+ // 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
533
+ // applyDecisions 的 @param config。
534
+ const { applied, failures, conflicts } = applyDecisions(
535
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
536
+ );
529
537
  return {
530
538
  status: applied > 0 ? "ok" : "noop",
531
539
  scanned: memories.length,
package/lib/dream.js CHANGED
@@ -1173,6 +1173,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1173
1173
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
1174
1174
  // Issue #104 方向 2:archive 批量上限(全局闸门,超限整单拒绝)。
1175
1175
  maxArchivePerRun: config.dreamMaxArchivePerRun,
1176
+ // #135 成本审计派生:merge 批量上限(merge 是唯一回吐正文的动作,同款全局闸门)。
1177
+ maxMergePerRun: config.dreamMaxMergePerRun,
1176
1178
  minAgeHours: config.reflectionUpdateMinAgeHours,
1177
1179
  // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
1178
1180
  // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
@@ -1307,7 +1309,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1307
1309
  // a target changed during the LLM call is skipped and reported as a
1308
1310
  // conflict instead of being overwritten (item ①). Frozen conflicts are
1309
1311
  // excluded from this list (they are parked, not applied).
1310
- const { applied, conflicts, failures, committed } = applyDecisions(applyList, service, logger, snapshot, config);
1312
+ // mint 来源:主链路的 create 落 source=dream。来源从管道给而非从决策对象取——
1313
+ // 见 applyDecisions 的 @param config。
1314
+ const { applied, conflicts, failures, committed } = applyDecisions(
1315
+ applyList, service, logger, snapshot, { ...config, mintSource: "dream" }
1316
+ );
1311
1317
  // Per-record receipt chain: one row per actually-committed merge/conflict/
1312
1318
  // update verdict, stamped with the decision-basis digest + idempotency
1313
1319
  // counters (count_before → count_after). Written here, before the run audit
@@ -163,6 +163,10 @@ export async function extractEntities(memory, { store, config, callLLM, logger }
163
163
  if (config.entityExtractionModel) options.model = config.entityExtractionModel;
164
164
  const reasoning = config.entityExtractionReasoning;
165
165
  if (reasoning && reasoning !== "none") options.reasoningEffort = reasoning;
166
+ // #405: 审计行要能归因到具体记忆。extractor 只把 content 写进 prompt,id 这里顺
167
+ // options 交给 index.js 的 adapter(route 映射只认 provider/model,多余键不会进
168
+ // llm.stream)。拿不到 id 就不写这个键,adapter 侧照旧留空数组,不造虚账。
169
+ if (memory.id) options.memoryId = memory.id;
166
170
  const llmResponse = await callLLM(messages, options);
167
171
 
168
172
  if (!llmResponse) {
package/lib/hot-memory.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { safeSlice } from "./safe-slice.js";
2
+
1
3
  // Session-scoped hot memory (v0.5.0 召回率优化 1.3): a short-term buffer of
2
4
  // the latest dialogue rounds, kept strictly apart from the long-term memory
3
5
  // store. The injector renders it ahead of the long-term recall block so the
@@ -44,3 +46,63 @@ export function createHotMemory({ maxRounds = 5, maxTokens = 2000 } = {}) {
44
46
  clear() { buffer.length = 0; }
45
47
  };
46
48
  }
49
+
50
+ // Issue:rewind(surfaceOp replace)/compact 遮蔽过的事件仍留在 append-only 事件
51
+ // 流里——热记忆若直接遍历原始流,被撤回的轮次会回流进 prompt(用户实测:一次
52
+ // 撤回 3+ 条后模型仍能复述大部分内容)。宿主在 session.surface.nodes 维护
53
+ // 「模型可见事件 seq」列表(dsh-rewind 插件即据此计算替换区间);调用方把可见
54
+ // 集合传入本函数,只统计可见事件,热记忆与模型看到的对话因此同源。visibleSeqs
55
+ // 传 null 表示宿主未提供 surface(老版本 DSH),保持旧行为——宁可持续旧偏差,
56
+ // 绝不误删仍可见的轮次。
57
+ //
58
+ // 配对规则与 inject.js 原实现一致:user/message(source.kind 缺省或 "user")开启
59
+ // 新一轮;下一条 assistant 起源的事件关闭该轮。tool/system 等携带正文的事件不得
60
+ // 冒充模型回复;rewind 标记(source.kind = "dsh-rewind")不是用户提问。
61
+ export function roundsFromEvents(events, maxRounds, visibleSeqs = null) {
62
+ const rounds = [];
63
+ let pendingQuery = null;
64
+ const textOf = (event) => {
65
+ // user/message 的正文就在 data 上,而 assistant/message(以及 tool/result)的消息体
66
+ // 嵌在 data.message 下 —— DSH 侧由 @deepseek-ai/dsh-session 的
67
+ // assertMessageEventShape 一行写死:const message = type === "user/message" ? record : record?.["message"]。
68
+ // 只读 data.content 会让每一轮的 response 恒为空(Issue #129)。
69
+ // 这里用 ?? 兜底而不是直接改成 data.message.content:user 消息与旧的扁平形状都照旧可用。
70
+ const parts = event?.data?.content ?? event?.data?.message?.content;
71
+ if (!Array.isArray(parts)) return "";
72
+ // 只取正文 part(type === "text" 或字符串),跳过 reasoning(issue #162):
73
+ // reasoning 里出现 Go template / Vue / Handlebars / 正则 / 日志原文的概率远高于
74
+ // 正文,把它带进 hot memory 会让 `{{...}}` 模板片段注入 prompt。旧的扁平形状
75
+ // (字符串 part)照旧取用,无 type 字段的兜底视为正文。
76
+ return parts
77
+ .map((p) => {
78
+ if (typeof p === "string") return p;
79
+ if (p?.type && p.type !== "text") return "";
80
+ return p?.text ?? "";
81
+ })
82
+ .filter(Boolean)
83
+ .join("\n")
84
+ .trim();
85
+ };
86
+ for (const event of events) {
87
+ // surface 投影:不在「模型可见面」上的事件(被 rewind 撤回、被 compact 替换)
88
+ // 不参与配对。
89
+ if (visibleSeqs && !visibleSeqs.has(event?.seq)) continue;
90
+ const kind = event?.data?.source?.kind;
91
+ const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
92
+ if (isUser) {
93
+ if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
94
+ pendingQuery = safeSlice(textOf(event), 500);
95
+ continue;
96
+ }
97
+ // Only assistant-originated events close a round; tool/system events
98
+ // carrying text must not be mistaken for the model's reply.
99
+ const isAssistant = typeof event?.type === "string" && event.type.includes("assistant")
100
+ || kind === "assistant";
101
+ const body = isAssistant ? textOf(event) : "";
102
+ if (!body || !pendingQuery) continue;
103
+ rounds.push({ query: pendingQuery, response: safeSlice(body, 800) });
104
+ pendingQuery = null;
105
+ }
106
+ if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
107
+ return rounds.slice(-maxRounds);
108
+ }
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@ import { createInjector } from "./inject.js";
14
14
  import { createPreInjectGate } from "./pre-inject-gate.js";
15
15
  import { createContinuityRescue } from "./continuity.js";
16
16
  import { createSummarizer } from "./summarize.js";
17
- import { createDreamScheduler } from "./dream.js";
17
+ import { createDreamScheduler, describeStreamFailure } from "./dream.js";
18
18
  import { createSleepScheduler, runSleep } from "./dream/sleep.js";
19
19
  import { createApi } from "./api.js";
20
20
  import { createStandaloneApi } from "./api-standalone.js";
@@ -78,6 +78,11 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
78
78
  } catch { /* fall through to whatever route we already have */ }
79
79
  }
80
80
  const effort = options.reasoningEffort;
81
+ // #405: 审计行的 related_memory_ids 此前恒空、error_message 又被压成一句常量,
82
+ // 于是「哪条记忆没抽到实体、为什么」在库里查不出来(主库 735 条 error 行全是
83
+ // 同一句话)。id 由 extractor 顺 options 传下来——它只认 memory.content,不认
84
+ // id。口径同 write-admission:350:拿不到 id 就留空,绝不挂不存在的 id 造虚账。
85
+ const relatedMemoryIds = options.memoryId ? [options.memoryId] : [];
81
86
  // 没有解析出 provider/model 就没有可归属的模型——与 dream 的 resolveRoute
82
87
  // 无路由早退同口径,那种情况不写审计行。
83
88
  const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
@@ -89,7 +94,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
89
94
  const timestamp = new Date(startedAt).toISOString();
90
95
  // 记账是 best-effort:写审计行失败只 warn,绝不反噬抽取本身(CONTRIBUTING
91
96
  // 的 fail-safe 硬约定)。
92
- const writeAudit = (status, errorMessage) => {
97
+ const writeAudit = (status, errorMessage, relatedMemoryIds = []) => {
93
98
  if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
94
99
  // #372: modelId 为空 = 路由解析失败——此前整个 writeAudit 静默 return,
95
100
  // llm.stream 抛错也留不下任何行,正是 #108 要消灭的「零实体零日志」同款盲区。
@@ -111,7 +116,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
111
116
  duration_ms: Date.now() - startedAt,
112
117
  status,
113
118
  error_message: errorMessage,
114
- related_memory_ids: []
119
+ related_memory_ids: relatedMemoryIds
115
120
  });
116
121
  } catch (auditError) {
117
122
  logger?.warn?.(`dsh-mneme: entity extraction llm audit write failed: ${String(auditError)}`);
@@ -136,7 +141,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
136
141
  if (Number.isFinite(o)) outputTokens = o;
137
142
  }
138
143
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
139
- writeAudit("error", "llm stream aborted or errored");
144
+ writeAudit("error", describeStreamFailure(chunk.reason) || "llm stream aborted or errored", relatedMemoryIds);
140
145
  return undefined;
141
146
  }
142
147
  }
@@ -144,7 +149,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
144
149
  return text;
145
150
  })().catch((err) => {
146
151
  logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
147
- writeAudit("error", String(err?.message ?? err));
152
+ writeAudit("error", String(err?.message ?? err), relatedMemoryIds);
148
153
  return undefined;
149
154
  });
150
155
  };
@@ -183,11 +188,6 @@ export const apply = (ctx, config) => {
183
188
  }
184
189
 
185
190
  const store = createStore(join(memoryDir, "memory.db"));
186
- // Prune reflection failure rows older than 90 days on boot (best-effort, so
187
- // the failure table never grows unbounded).
188
- try {
189
- store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
190
- } catch { /* non-fatal */ }
191
191
 
192
192
  // User-configurable settings (profile, rules, panel mode, standalone API
193
193
  // token) share the same SQLite file in dedicated tables, isolated from
@@ -226,6 +226,21 @@ export const apply = (ctx, config) => {
226
226
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
227
227
  }
228
228
 
229
+ // 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
230
+ // 90 天)。保留期现在是显式配置键 reflectionFailureRetentionDays;等它进面板
231
+ // 白名单的那天,这里读的就是装配后的值,不用再改第二处。best-effort 语义不变:
232
+ // 清理失败不挡启动,否则一次锁表就能让插件起不来——但「不挡启动」不等于「可以
233
+ // 悄悄失败」:这一族的 recall_runs 清理当年就是静默蒸发,表涨到两万余行无人
234
+ // 察觉,所以本处与 llm_audit / distill_records / recall_runs 三处一样出声。
235
+ try {
236
+ const failureRetentionDays = Number.isInteger(cfg.reflectionFailureRetentionDays)
237
+ ? cfg.reflectionFailureRetentionDays
238
+ : 90;
239
+ store.deleteOldFailures(new Date(Date.now() - failureRetentionDays * 86400000).toISOString());
240
+ } catch (err) {
241
+ ctx.logger?.warn?.(`[dsh-mneme] failure-table retention purge failed: ${String(err)}`);
242
+ }
243
+
229
244
  // Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
230
245
  // cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
231
246
  // rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
@@ -236,7 +251,43 @@ export const apply = (ctx, config) => {
236
251
  const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
237
252
  store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
238
253
  }
239
- } catch { /* non-fatal */ }
254
+ } catch (err) {
255
+ ctx.logger?.warn?.(`[dsh-mneme] llm_audit retention purge failed: ${String(err)}`);
256
+ }
257
+
258
+ // #399 的启动期裁剪:与上面同款(读装配后的 cfg,理由同上——面板写的保留期必须
259
+ // 与写入侧取到同一个值)。不同的一点:蒸馏原文留存默认关,而「默认关」在写入侧
260
+ // 的含义就是零写入,所以这里也只在 enabled === true 时才动手——否则一张空表被
261
+ // 每次启动扫一遍,白付一次全表 DELETE 的代价。只裁 distill_records;链接
262
+ // (memory_provenance)保留,指向空区间即为「来源已过期」。
263
+ try {
264
+ if (cfg.distillRecords?.enabled === true) {
265
+ const retentionMs = Number.isInteger(cfg.distillRecords?.retentionDays)
266
+ ? cfg.distillRecords.retentionDays
267
+ : 90;
268
+ store.clearDistillRecords(new Date(Date.now() - retentionMs * 86400000).toISOString());
269
+ }
270
+ } catch (err) {
271
+ ctx.logger?.warn?.(`[dsh-mneme] distill_records retention purge failed: ${String(err)}`);
272
+ }
273
+
274
+ // recall_runs 的滚动清理(v0.7.0 原设计,2026-10-14 找回):与 llm_audit 同策略,
275
+ // 按 cfg.recallRetentionDays(默认 90 天)启动时清一次。读装配后的 cfg,理由与上面
276
+ // 两处一致 —— 写入侧取的是 cfg,清理侧若取 rawCfg,面板改的保留期就不生效。
277
+ // 这一对曾在 dd0ed78(v0.7.12 发布提交)里被整块弄丢,配置键与文档却一直留着,
278
+ // 实测 2026-10-13 那天从 16,041 涨到 20,510 行(一天四千余行,只增不减;
279
+ // 单给一个静态行数会随表腐坏,故带日期的区间才是可核对的说法)。注入行开始写真实 query 之后,这条从「表膨胀」
280
+ // 升级为「原话无限期留存」,所以必须与那次改动同批交付。
281
+ // 保留期语义是「天」(函数内部乘 86400000),清理失败只 warn,绝不挡插件启动。
282
+ try {
283
+ const recallRetentionDays = Number.isInteger(cfg.recallRetentionDays) ? cfg.recallRetentionDays : 90;
284
+ store.purgeRecallRunsOlderThan(recallRetentionDays);
285
+ } catch (err) {
286
+ // 这里必须真的出声。这一对当初是**静默**蒸发的:函数与调用一起没了、没人发现,
287
+ // 表就这么涨到两万余行。清理再失败一次而仍然不出声,同样的回归照样看不见——
288
+ // 「只 warn」不是修辞,是这族清理唯一的事后线索。
289
+ ctx.logger?.warn?.(`[dsh-mneme] recall_runs retention purge failed: ${String(err)}`);
290
+ }
240
291
 
241
292
  // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
242
293
  // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
@@ -483,6 +534,15 @@ export const apply = (ctx, config) => {
483
534
  const summarizer = createSummarizer(ctx, service, cfg);
484
535
  disposers.push(summarizer.dispose);
485
536
 
537
+ // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
538
+ // 这一步是不可逆的内容丢弃,只由人显式触发(面板的维护卡、`dsh-mneme reclaim`)。
539
+ // 实例建在插件进程内,是为了让 CLI 也能走这条路:VACUUM 要排他锁,跟宿主抢锁的
540
+ // 那条路走不通。
541
+ // 位置在 webServer 块之前:面板的 in-process API(createApi)与 standalone 数据面
542
+ // 都要用它,而 webServer 是可选的——headless 宿主不注册面板路由,构造点若留在块后
543
+ // 就永远晚于 createApi(#386 第 4 条)。构造期无副作用(只返回 { reclaim }),前移安全。
544
+ const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
545
+
486
546
  // webServer 可选依赖:cordis 4 的 ctx 是 Proxy,直接访问未在 inject 声明的
487
547
  // 属性会抛 "cannot get property without inject"(不会返回 undefined),所以
488
548
  // 不能用 if (ctx.webServer) 守卫。ctx.reflect.get 是 cordis 提供的免 inject
@@ -495,16 +555,10 @@ export const apply = (ctx, config) => {
495
555
  add: () => { throw new Error("commands unavailable"); },
496
556
  remove: () => false,
497
557
  list: () => []
498
- }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg);
558
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance);
499
559
  disposers.push(api.dispose);
500
560
  }
501
561
 
502
- // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
503
- // 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
504
- // 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
505
- // 抢锁的那条路走不通)。
506
- const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
507
-
508
562
  // Standalone external API (v0.7.12): plain node:http server for ecosystem
509
563
  // integrations outside the DSH host. Persisted external_api settings win
510
564
  // over the bundle config (enabled/port); the Bearer token lives in the same