@modusensus/dsh-mneme 0.8.16 → 0.9.1

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 (135) hide show
  1. package/README.md +91 -16
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +8 -2
  4. package/docs/CONFIGURATION.md +27 -1
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/SEMANTIC.md +3 -3
  7. package/docs/WORKFLOWS.md +299 -0
  8. package/lib/api-standalone.js +78 -12
  9. package/lib/api.js +403 -62
  10. package/lib/candidates.js +454 -0
  11. package/lib/capabilities.js +819 -0
  12. package/lib/client.js +2294 -423
  13. package/lib/config.js +63 -6
  14. package/lib/continuity.js +35 -6
  15. package/lib/dream/decisions.js +17 -1
  16. package/lib/dream/sleep.js +10 -2
  17. package/lib/dream.js +5 -1
  18. package/lib/embedding.js +116 -15
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/index.js +81 -12
  21. package/lib/inject.js +40 -4
  22. package/lib/lang.js +124 -0
  23. package/lib/pipeline-activities.js +178 -0
  24. package/lib/pipeline-attribution.js +165 -0
  25. package/lib/pipeline-command.js +279 -0
  26. package/lib/pipeline-confirm.js +142 -0
  27. package/lib/pipeline-exec.js +174 -0
  28. package/lib/pipeline-nudge.js +1281 -0
  29. package/lib/pipeline-observations.js +181 -0
  30. package/lib/pipeline-observe.js +133 -0
  31. package/lib/pipeline-runner.js +346 -0
  32. package/lib/pipeline-runs.js +442 -0
  33. package/lib/pipeline.js +655 -0
  34. package/lib/scope.js +16 -4
  35. package/lib/semantic.js +66 -10
  36. package/lib/serve.js +1 -1
  37. package/lib/service.js +59 -18
  38. package/lib/session-usage.js +350 -0
  39. package/lib/settings.js +138 -1
  40. package/lib/store.js +132 -6
  41. package/lib/summarize.js +30 -0
  42. package/lib/tools.js +11 -2
  43. package/package.json +3 -3
  44. package/scripts/changelog-prep.mjs +62 -7
  45. package/scripts/check-sync.js +8 -6
  46. package/scripts/publish-gate.mjs +197 -0
  47. package/scripts/release-docs.mjs +204 -0
  48. package/scripts/release-notes.mjs +162 -0
  49. package/src/api-standalone.js +78 -12
  50. package/src/api.js +403 -62
  51. package/src/candidates.js +454 -0
  52. package/src/capabilities.js +819 -0
  53. package/src/config.js +63 -6
  54. package/src/continuity.js +35 -6
  55. package/src/dream/decisions.js +17 -1
  56. package/src/dream/sleep.js +10 -2
  57. package/src/dream.js +5 -1
  58. package/src/embedding.js +116 -15
  59. package/src/entities/extractor.js +4 -0
  60. package/src/index.js +81 -12
  61. package/src/inject.js +40 -4
  62. package/src/lang.js +124 -0
  63. package/src/pipeline-activities.js +178 -0
  64. package/src/pipeline-attribution.js +165 -0
  65. package/src/pipeline-command.js +279 -0
  66. package/src/pipeline-confirm.js +142 -0
  67. package/src/pipeline-exec.js +174 -0
  68. package/src/pipeline-nudge.js +1281 -0
  69. package/src/pipeline-observations.js +181 -0
  70. package/src/pipeline-observe.js +133 -0
  71. package/src/pipeline-runner.js +346 -0
  72. package/src/pipeline-runs.js +442 -0
  73. package/src/pipeline.js +655 -0
  74. package/src/scope.js +16 -4
  75. package/src/semantic.js +66 -10
  76. package/src/serve.js +1 -1
  77. package/src/service.js +59 -18
  78. package/src/session-usage.js +350 -0
  79. package/src/settings.js +138 -1
  80. package/src/store.js +132 -6
  81. package/src/summarize.js +30 -0
  82. package/src/tools.js +11 -2
  83. package/test/api-hardening.test.js +173 -0
  84. package/test/api.test.js +244 -7
  85. package/test/candidates.test.js +345 -0
  86. package/test/capabilities.test.js +577 -0
  87. package/test/changelog-prep.test.js +74 -8
  88. package/test/cli-enums.test.js +37 -0
  89. package/test/client.test.js +510 -2
  90. package/test/continuity.test.js +84 -0
  91. package/test/docs-config-keys.test.js +65 -0
  92. package/test/embed-ready-poll.test.js +94 -0
  93. package/test/entities.test.js +13 -2
  94. package/test/failure-query.test.js +147 -0
  95. package/test/failure-retention.test.js +105 -0
  96. package/test/graph-api.test.js +36 -2
  97. package/test/helpers/write-admission-samples.js +6 -2
  98. package/test/inject-recall.test.js +32 -4
  99. package/test/lib-smoke.test.js +20 -0
  100. package/test/llm-audit.test.js +92 -1
  101. package/test/mint-source.test.js +202 -0
  102. package/test/persona-api.test.js +172 -0
  103. package/test/persona-flag.test.js +36 -0
  104. package/test/persona-inject.test.js +145 -0
  105. package/test/persona-settings.test.js +145 -0
  106. package/test/pipeline-activities.test.js +155 -0
  107. package/test/pipeline-attribution.test.js +175 -0
  108. package/test/pipeline-command.test.js +243 -0
  109. package/test/pipeline-confirm.test.js +148 -0
  110. package/test/pipeline-exec.test.js +172 -0
  111. package/test/pipeline-nudge.test.js +1036 -0
  112. package/test/pipeline-observations.test.js +157 -0
  113. package/test/pipeline-observe.test.js +127 -0
  114. package/test/pipeline-r1r2.test.js +149 -0
  115. package/test/pipeline-runner.test.js +442 -0
  116. package/test/pipeline-runs.test.js +767 -0
  117. package/test/pipeline.test.js +636 -0
  118. package/test/placement.test.js +1 -1
  119. package/test/publish-gate.test.js +272 -0
  120. package/test/recall-retention.test.js +128 -0
  121. package/test/reindex-backfill.test.js +83 -4
  122. package/test/release-docs.test.js +130 -0
  123. package/test/release-notes.test.js +240 -0
  124. package/test/release-workflow.test.js +93 -0
  125. package/test/route-policy.test.js +113 -0
  126. package/test/scope-distill-stamp.test.js +135 -0
  127. package/test/security-doc.test.js +139 -0
  128. package/test/sensitive-scan.test.js +44 -0
  129. package/test/serve.test.js +1 -1
  130. package/test/session-usage.test.js +308 -0
  131. package/test/store-write-path-columns.test.js +191 -0
  132. package/test/summarize.test.js +176 -0
  133. package/test/supersede-state-tracking.test.js +4 -4
  134. package/test/tools.test.js +16 -1
  135. package/test/workflow-guards.test.js +156 -0
package/src/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 判据,引入
@@ -414,6 +415,13 @@ export const Config = z.object({
414
415
  // in the background after a short delay, rate-limited in batches. On by
415
416
  // default; set false to keep the backfill manual only.
416
417
  autoReindexOnBoot: z.boolean().default(true),
418
+ // Backfill pacing (previously hardcoded 500 / 10 / 200 in semantic.js). A
419
+ // fixed 500-per-boot cap meant a 4k-row backlog needed nine restarts to
420
+ // clear, and nothing reported that the run had been capped.
421
+ // reindexMaxPerBoot = 0 means "no cap, drain the whole backlog this run".
422
+ reindexMaxPerBoot: z.natural().default(500),
423
+ reindexBatchSize: z.natural().min(1).max(64).default(10),
424
+ reindexRateLimitMs: z.natural().default(200),
417
425
  // Semantic-first injection (Bug4): when enabled, injectCandidates with a
418
426
  // non-empty query recalls via the vector index first and falls back to the
419
427
  // rule-based pick to fill/dedupe. Empty query / no vector → legacy behavior.
@@ -481,6 +489,13 @@ export const Config = z.object({
481
489
  reflectionFailureTracking: z.boolean().default(true),
482
490
  reflectionUpdateMaxPerRun: z.natural().min(0).max(5).default(2),
483
491
  reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24),
492
+ // failure_memories 的启动期保留期(天)。此前在 index.js 里写死 90,是这一族
493
+ // 四张启动期清理表里唯一没有键可陈述的一条(#275 伞下的欠账)。形态对齐
494
+ // recallRetentionDays:顶层扁平键、只从配置文件改;暂不进 settings.js 白名单,
495
+ // 面板可调是后续的事(那要同时补 api.js 的 NESTED 表与 client.js 的面板行)。
496
+ // 下限取 1 而非 0:这张表的用途是「挖掘重复出现的纠正模式」,窗口太短就只剩
497
+ // 最近一次;要彻底停掉清理应当显式关掉 reflectionFailureTracking。
498
+ reflectionFailureRetentionDays: z.natural().min(1).max(3650).default(90),
484
499
 
485
500
  // --- conflict freeze: manual review for conflicting memories (v0.2.1) ---
486
501
  // Opt-in by default: when true, conflicting memories are not auto-merged
@@ -828,10 +843,15 @@ export const Config = z.object({
828
843
 
829
844
  // --- scope: v0.8.0 A1 存储层(issue #17)--------------------------------
830
845
  // 总开关默认关:关闭时写入不标注 scope、去重维持 (type, title) 现状,行为
831
- // 逐字节不变。开启后 memory_save 写入 agent_scope(session header 的
832
- // agentPreset)与 workspace_scope(registry 反查 canonical path,header.cwd
833
- // 兜底,取不到 NULL——解析绝不阻塞写入),去重键扩展为 (type, title,
834
- // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。
846
+ // 逐字节不变。开启后**两条写入路径都自动盖章**(*_scope_source='auto'):
847
+ // ① 工具调用(tools.js:memory_save / memory_update,显式参数改标 'explicit')
848
+ // ② 机器提炼(summarize.js:会话蒸馏;合并进既有行时不盖章,作用域属于那一行
849
+ // 原来被提炼出来的语境——v0.9.0 补,此前只有工具路径盖章,于是「项目记忆」
850
+ // 在库里几乎不存在)。
851
+ // 两路的值同源:agent 维取 session header 的 agentPreset,workspace 维取
852
+ // registry 反查的 canonical path(header.cwd 兜底,取不到 NULL——解析绝不阻塞
853
+ // 写入),去重键扩展为 (type, title, agent_scope, workspace_scope, sensitivity)。
854
+ // 检索侧加权/过滤在 A2/A3 落地。
835
855
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
836
856
  scopeEnabled: z.boolean().default(false),
837
857
  // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见,
@@ -842,6 +862,39 @@ export const Config = z.object({
842
862
  // 打开才有意义(没有写入标注就无可过滤维度),但独立成键:可先开标注积累
843
863
  // 数据、观察 A2 加权质量后再切硬过滤。
844
864
  strictScope: z.boolean().default(false),
865
+
866
+ // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--------------------------
867
+ // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.;
868
+ // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句
869
+ // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。
870
+ // 这是客户端只读视图(做法图谱 + 对话行结算卡)的门控。API 的 effective 取值只认
871
+ // config 顶层/嵌套路径,schema 里没有这个键 → effective 静默少键 → 客户端读
872
+ // 不到就一个座位都不注册(#254 漏登记 NESTED_FLAG_PATHS 是同一个坑)。
873
+ // 默认关:beta 功能必须 opt-in,老用户行为逐字节不变。不进 LIGHT_MODE_OFF——
874
+ // 它只是只读视图、不引入任何重型后台路径,轻量档用户显式勾选仍然赢(预设
875
+ // 该关的是重型能力,不该替用户关掉一个纯 UI 入口)。
876
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
877
+ actionPipelineEnabled: z.boolean().default(false),
878
+
879
+ // M.O. 执行记账(v0.9.0 第二批):`/mo-run` 显式开一次执行窗口,窗口内的命令执行
880
+ // 才可能被记成一条 run。与上面那个键**分开**:上面管「看图的座位」,这个管
881
+ // 「往账本里写」——看图和写账是两件事,混成一个键会让「只想看图」的人不知不觉
882
+ // 获得写入能力。
883
+ //
884
+ // 默认关(beta 必须 opt-in)。它挂在热路径上(每个会话事件都过一次监听),
885
+ // 所以轻量档要关掉:`LIGHT_MODE_OFF` 里有它。
886
+ actionRunEnabled: z.boolean().default(false),
887
+
888
+ // --- 人格定义页(beta)---------------------------------------------------
889
+ // 注入器渲染人格段的顶层总闸(双闸门的 config 半边;user_settings "persona"
890
+ // 键内的 persona.enabled 是 per-agent 半边,两闸同真才渲染)。API 的 effective
891
+ // 必须含这个键——schema 里没有它 effective 就静默少键,面板开关形同虚设
892
+ // (#254 漏登记 NESTED_FLAG_PATHS 是同一个坑,计数锁 test/api.test.js 钉住)。
893
+ // 默认关:beta 功能必须 opt-in,老用户行为逐字节不变。不进 LIGHT_MODE_OFF——
894
+ // 人格段是用户自写的一小段轻量常驻文本,不引入任何重型后台路径,轻量档用户
895
+ // 显式勾选仍然赢(同 actionPipelineEnabled 的定性)。
896
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
897
+ personaEnabled: z.boolean().default(false)
845
898
  });
846
899
 
847
900
  // Fields forced to false by the light-mode preset. Everything not listed here
@@ -888,7 +941,11 @@ const LIGHT_MODE_OFF = [
888
941
  // #399:轻量档不开蒸馏原文留存——它是一份对话原文副本(磁盘 + 暴露面),
889
942
  // 与轻量档「小模型 / 小上下文 / 少留痕」的取向相反。默认关,这里只是
890
943
  // 轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。
891
- "distillRecords.enabled"
944
+ "distillRecords.enabled",
945
+ // M.O. 执行记账:它挂在**热路径**上(每个会话事件都过一次监听)。轻量档的取向
946
+ // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。
947
+ // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。
948
+ "actionRunEnabled"
892
949
  ];
893
950
 
894
951
  /**
package/src/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
 
@@ -395,6 +395,12 @@ function casGuard(service, snapshot, ids) {
395
395
  * provided, every decision target is CAS-checked against it and a decision
396
396
  * computed from stale state is skipped and reported as a conflict instead of
397
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).
398
404
  * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
399
405
  * applied - number of decisions/memories actually committed (archive counts
400
406
  * each archived memory as one, merge/conflict/update count one).
@@ -484,7 +490,17 @@ function applyCreate(d, service, config = {}) {
484
490
  const body = evidence.length > 0
485
491
  ? STR.evidenceSuffix[language](content, evidence)
486
492
  : content;
487
- 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
+ });
488
504
  const memory = created?.memory;
489
505
  if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
490
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/src/dream.js CHANGED
@@ -1309,7 +1309,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1309
1309
  // a target changed during the LLM call is skipped and reported as a
1310
1310
  // conflict instead of being overwritten (item ①). Frozen conflicts are
1311
1311
  // excluded from this list (they are parked, not applied).
1312
- 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
+ );
1313
1317
  // Per-record receipt chain: one row per actually-committed merge/conflict/
1314
1318
  // update verdict, stamped with the decision-basis digest + idempotency
1315
1319
  // counters (count_before → count_after). Written here, before the run audit
package/src/embedding.js CHANGED
@@ -69,9 +69,51 @@ export async function embedText({ baseUrl, apiKey, model }, text) {
69
69
  * successful embed the model that produced the vectors is recorded, so the
70
70
  * index can detect drift and the auto-reindex backfill knows what to rebuild.
71
71
  */
72
+ /**
73
+ * Bounded receipt ring for embedding attempts.
74
+ *
75
+ * Why a ring instead of llm_audit_logs: a full backfill is thousands of
76
+ * attempts, and one audit row each would bury the LLM calls that table exists
77
+ * to trace. What the user actually needs in order to answer "is the vector
78
+ * layer silently dead right now?" is the recent window plus running totals —
79
+ * which costs nothing and cannot flood.
80
+ */
81
+ const RECEIPT_LIMIT = 200;
82
+
83
+ function createReceiptLedger() {
84
+ const recent = [];
85
+ const totals = { ok: 0, failed: 0, skipped: 0 };
86
+ let lastOkAt = null;
87
+ let lastFailAt = null;
88
+ let lastFailReason = null;
89
+ return {
90
+ record(entry) {
91
+ recent.push(entry);
92
+ if (recent.length > RECEIPT_LIMIT) recent.shift();
93
+ if (entry.status in totals) totals[entry.status]++;
94
+ if (entry.status === "ok") lastOkAt = entry.at;
95
+ else if (entry.status === "failed") {
96
+ lastFailAt = entry.at;
97
+ lastFailReason = entry.reason ?? null;
98
+ }
99
+ },
100
+ snapshot() {
101
+ return {
102
+ totals: { ...totals },
103
+ lastOkAt,
104
+ lastFailAt,
105
+ lastFailReason,
106
+ recent: recent.slice(-RECEIPT_LIMIT)
107
+ };
108
+ }
109
+ };
110
+ }
111
+
72
112
  export function createEmbedder({ store, settings, logger, vectorIndex }) {
73
113
  // Dimension of the most recent successful embed, exposed for fingerprinting.
74
114
  let _dimension = 0;
115
+ // Per-write embed receipts (see createReceiptLedger above).
116
+ const ledger = createReceiptLedger();
75
117
 
76
118
  /** Record the producing model fingerprint in vector_meta (best-effort). */
77
119
  function markModel(cfg, dimension) {
@@ -85,19 +127,52 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
85
127
  // ready/configured 两个 getter 共用,避免以后改一处漏一处。
86
128
  const configComplete = (cfg) => !!(cfg?.enabled && cfg.baseUrl && cfg.apiKey && cfg.model);
87
129
 
130
+ /**
131
+ * Embed one memory and store the vector. Never throws — a flaky endpoint
132
+ * must not break memory writes — but it now REPORTS what happened instead
133
+ * of returning undefined on both success and failure. Callers that ignore
134
+ * the result are unaffected; callers that care get a receipt.
135
+ *
136
+ * @returns {Promise<{status:"ok"|"failed"|"skipped", id:string,
137
+ * at:string, reason?:string, dim?:number, model?:string}>}
138
+ */
88
139
  async function embedFor(id, title, content) {
140
+ const at = new Date().toISOString();
89
141
  const cfg = settings.getVectorConfig();
90
- if (!configComplete(cfg)) return;
142
+ if (!configComplete(cfg)) {
143
+ const entry = { status: "skipped", id, at, reason: "vector-config-incomplete" };
144
+ ledger.record(entry);
145
+ return entry;
146
+ }
91
147
  const text = [title, content].filter(Boolean).join("\n");
92
- const vector = await embedText(cfg, text);
93
- if (vector) {
94
- store.setEmbedding(id, vector);
95
- _dimension = vector.length;
96
- // Bug3: record which model produced the current vectors so the index can
97
- // detect drift and skip a redundant backfill when nothing changed.
98
- markModel(cfg, vector.length);
99
- logger?.info?.(`[dsh-mneme] embedded memory ${id} (dim=${vector.length})`);
148
+ let vector = null;
149
+ try {
150
+ vector = await embedText(cfg, text);
151
+ } catch (error) {
152
+ const entry = { status: "failed", id, at, reason: String(error) };
153
+ ledger.record(entry);
154
+ logger?.warn?.(`[dsh-mneme] embed memory ${id} failed: ${String(error)}`);
155
+ return entry;
156
+ }
157
+ if (!vector) {
158
+ // embedText swallows the HTTP detail (404 / timeout / malformed body all
159
+ // resolve to null). The receipt still has to say "no vector", which is
160
+ // exactly the fact the write path never used to report.
161
+ const entry = { status: "failed", id, at, reason: "embedding-request-returned-no-vector" };
162
+ ledger.record(entry);
163
+ logger?.warn?.(`[dsh-mneme] embed memory ${id} produced no vector`);
164
+ return entry;
100
165
  }
166
+ const model = modelHashOf(cfg.model);
167
+ store.setEmbedding(id, vector, model);
168
+ _dimension = vector.length;
169
+ // Bug3: record which model produced the current vectors so the index can
170
+ // detect drift and skip a redundant backfill when nothing changed.
171
+ markModel(cfg, vector.length);
172
+ const entry = { status: "ok", id, at, dim: vector.length, model };
173
+ ledger.record(entry);
174
+ logger?.info?.(`[dsh-mneme] embedded memory ${id} (dim=${vector.length})`);
175
+ return entry;
101
176
  }
102
177
 
103
178
  return {
@@ -120,10 +195,26 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
120
195
  get configured() {
121
196
  return configComplete(settings.getVectorConfig());
122
197
  },
123
- /** Fire-and-forget re-embed of a memory after any write. */
198
+ /**
199
+ * Re-embed a memory after any write. Still non-blocking — the caller does
200
+ * not have to await it — but it now RETURNS the receipt promise so a
201
+ * caller that wants to know can. Previously this returned undefined and
202
+ * swallowed the rejection, so "did this write get a vector?" had no answer
203
+ * anywhere in the process. Swallowing still applies to the rejection
204
+ * itself: a vector failure must never break the memory write.
205
+ */
124
206
  schedule(memory) {
125
- if (!memory?.id) return;
126
- embedFor(memory.id, memory.title, memory.content).catch(() => {});
207
+ if (!memory?.id) {
208
+ return Promise.resolve({ status: "skipped", id: null, at: new Date().toISOString(), reason: "no-id" });
209
+ }
210
+ return embedFor(memory.id, memory.title, memory.content).catch((error) => ({
211
+ status: "failed", id: memory.id, at: new Date().toISOString(), reason: String(error)
212
+ }));
213
+ },
214
+
215
+ /** Recent embed receipts + running totals (see createReceiptLedger). */
216
+ embedReceipts() {
217
+ return ledger.snapshot();
127
218
  },
128
219
 
129
220
  /** Embed one text and return its vector (null on failure/disabled). */
@@ -155,17 +246,27 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
155
246
  return _dimension || undefined;
156
247
  },
157
248
 
158
- /** Batch re-index rows still missing an embedding. */
249
+ /**
250
+ * Batch re-index rows whose vector is missing OR came from another model.
251
+ * Uses needsEmbeddingFor (not needsEmbedding) so a model switch actually
252
+ * re-embeds the old vectors instead of leaving them eligible for "has a
253
+ * vector" forever while they poison similarity ranking.
254
+ */
159
255
  async reindexMissing(limit = 50) {
160
256
  const cfg = settings.getVectorConfig();
161
257
  if (!cfg?.enabled) return { indexed: 0, skipped: 0 };
162
- const rows = store.needsEmbedding(limit);
258
+ const model = modelHashOf(cfg.model);
259
+ // Fall back to the plain selector for stores that predate the
260
+ // provenance column (older hosts, test doubles).
261
+ const rows = typeof store.needsEmbeddingFor === "function"
262
+ ? store.needsEmbeddingFor(model, limit)
263
+ : store.needsEmbedding(limit);
163
264
  let indexed = 0;
164
265
  for (const row of rows) {
165
266
  const text = [row.title, row.content].filter(Boolean).join("\n");
166
267
  const vector = await embedText(cfg, text);
167
268
  if (vector) {
168
- store.setEmbedding(row.id, vector);
269
+ store.setEmbedding(row.id, vector, model);
169
270
  _dimension = vector.length;
170
271
  indexed++;
171
272
  }
@@ -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/src/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";
@@ -22,6 +22,8 @@ import { createStandaloneApi } from "./api-standalone.js";
22
22
  import { createMaintenance } from "./maintenance.js";
23
23
  import { createSettings } from "./settings.js";
24
24
  import { createCommandManager } from "./commands.js";
25
+ import { createExecutionRecorder } from "./pipeline-runner.js";
26
+ import { registerMoRunCommand } from "./pipeline-command.js";
25
27
  import { createVectorIndex } from "./vector-index.js";
26
28
  // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与
27
29
  // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。
@@ -78,6 +80,11 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
78
80
  } catch { /* fall through to whatever route we already have */ }
79
81
  }
80
82
  const effort = options.reasoningEffort;
83
+ // #405: 审计行的 related_memory_ids 此前恒空、error_message 又被压成一句常量,
84
+ // 于是「哪条记忆没抽到实体、为什么」在库里查不出来(主库 735 条 error 行全是
85
+ // 同一句话)。id 由 extractor 顺 options 传下来——它只认 memory.content,不认
86
+ // id。口径同 write-admission:350:拿不到 id 就留空,绝不挂不存在的 id 造虚账。
87
+ const relatedMemoryIds = options.memoryId ? [options.memoryId] : [];
81
88
  // 没有解析出 provider/model 就没有可归属的模型——与 dream 的 resolveRoute
82
89
  // 无路由早退同口径,那种情况不写审计行。
83
90
  const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
@@ -89,7 +96,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
89
96
  const timestamp = new Date(startedAt).toISOString();
90
97
  // 记账是 best-effort:写审计行失败只 warn,绝不反噬抽取本身(CONTRIBUTING
91
98
  // 的 fail-safe 硬约定)。
92
- const writeAudit = (status, errorMessage) => {
99
+ const writeAudit = (status, errorMessage, relatedMemoryIds = []) => {
93
100
  if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
94
101
  // #372: modelId 为空 = 路由解析失败——此前整个 writeAudit 静默 return,
95
102
  // llm.stream 抛错也留不下任何行,正是 #108 要消灭的「零实体零日志」同款盲区。
@@ -111,7 +118,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
111
118
  duration_ms: Date.now() - startedAt,
112
119
  status,
113
120
  error_message: errorMessage,
114
- related_memory_ids: []
121
+ related_memory_ids: relatedMemoryIds
115
122
  });
116
123
  } catch (auditError) {
117
124
  logger?.warn?.(`dsh-mneme: entity extraction llm audit write failed: ${String(auditError)}`);
@@ -136,7 +143,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
136
143
  if (Number.isFinite(o)) outputTokens = o;
137
144
  }
138
145
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
139
- writeAudit("error", "llm stream aborted or errored");
146
+ writeAudit("error", describeStreamFailure(chunk.reason) || "llm stream aborted or errored", relatedMemoryIds);
140
147
  return undefined;
141
148
  }
142
149
  }
@@ -144,7 +151,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
144
151
  return text;
145
152
  })().catch((err) => {
146
153
  logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
147
- writeAudit("error", String(err?.message ?? err));
154
+ writeAudit("error", String(err?.message ?? err), relatedMemoryIds);
148
155
  return undefined;
149
156
  });
150
157
  };
@@ -183,11 +190,6 @@ export const apply = (ctx, config) => {
183
190
  }
184
191
 
185
192
  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
193
 
192
194
  // User-configurable settings (profile, rules, panel mode, standalone API
193
195
  // token) share the same SQLite file in dedicated tables, isolated from
@@ -226,6 +228,21 @@ export const apply = (ctx, config) => {
226
228
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
227
229
  }
228
230
 
231
+ // 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
232
+ // 90 天)。保留期现在是显式配置键 reflectionFailureRetentionDays;等它进面板
233
+ // 白名单的那天,这里读的就是装配后的值,不用再改第二处。best-effort 语义不变:
234
+ // 清理失败不挡启动,否则一次锁表就能让插件起不来——但「不挡启动」不等于「可以
235
+ // 悄悄失败」:这一族的 recall_runs 清理当年就是静默蒸发,表涨到两万余行无人
236
+ // 察觉,所以本处与 llm_audit / distill_records / recall_runs 三处一样出声。
237
+ try {
238
+ const failureRetentionDays = Number.isInteger(cfg.reflectionFailureRetentionDays)
239
+ ? cfg.reflectionFailureRetentionDays
240
+ : 90;
241
+ store.deleteOldFailures(new Date(Date.now() - failureRetentionDays * 86400000).toISOString());
242
+ } catch (err) {
243
+ ctx.logger?.warn?.(`[dsh-mneme] failure-table retention purge failed: ${String(err)}`);
244
+ }
245
+
229
246
  // Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
230
247
  // cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
231
248
  // rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
@@ -236,7 +253,9 @@ export const apply = (ctx, config) => {
236
253
  const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
237
254
  store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
238
255
  }
239
- } catch { /* non-fatal */ }
256
+ } catch (err) {
257
+ ctx.logger?.warn?.(`[dsh-mneme] llm_audit retention purge failed: ${String(err)}`);
258
+ }
240
259
 
241
260
  // #399 的启动期裁剪:与上面同款(读装配后的 cfg,理由同上——面板写的保留期必须
242
261
  // 与写入侧取到同一个值)。不同的一点:蒸馏原文留存默认关,而「默认关」在写入侧
@@ -250,7 +269,27 @@ export const apply = (ctx, config) => {
250
269
  : 90;
251
270
  store.clearDistillRecords(new Date(Date.now() - retentionMs * 86400000).toISOString());
252
271
  }
253
- } catch { /* non-fatal */ }
272
+ } catch (err) {
273
+ ctx.logger?.warn?.(`[dsh-mneme] distill_records retention purge failed: ${String(err)}`);
274
+ }
275
+
276
+ // recall_runs 的滚动清理(v0.7.0 原设计,2026-10-14 找回):与 llm_audit 同策略,
277
+ // 按 cfg.recallRetentionDays(默认 90 天)启动时清一次。读装配后的 cfg,理由与上面
278
+ // 两处一致 —— 写入侧取的是 cfg,清理侧若取 rawCfg,面板改的保留期就不生效。
279
+ // 这一对曾在 dd0ed78(v0.7.12 发布提交)里被整块弄丢,配置键与文档却一直留着,
280
+ // 实测 2026-10-13 那天从 16,041 涨到 20,510 行(一天四千余行,只增不减;
281
+ // 单给一个静态行数会随表腐坏,故带日期的区间才是可核对的说法)。注入行开始写真实 query 之后,这条从「表膨胀」
282
+ // 升级为「原话无限期留存」,所以必须与那次改动同批交付。
283
+ // 保留期语义是「天」(函数内部乘 86400000),清理失败只 warn,绝不挡插件启动。
284
+ try {
285
+ const recallRetentionDays = Number.isInteger(cfg.recallRetentionDays) ? cfg.recallRetentionDays : 90;
286
+ store.purgeRecallRunsOlderThan(recallRetentionDays);
287
+ } catch (err) {
288
+ // 这里必须真的出声。这一对当初是**静默**蒸发的:函数与调用一起没了、没人发现,
289
+ // 表就这么涨到两万余行。清理再失败一次而仍然不出声,同样的回归照样看不见——
290
+ // 「只 warn」不是修辞,是这族清理唯一的事后线索。
291
+ ctx.logger?.warn?.(`[dsh-mneme] recall_runs retention purge failed: ${String(err)}`);
292
+ }
254
293
 
255
294
  // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
256
295
  // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
@@ -351,11 +390,40 @@ export const apply = (ctx, config) => {
351
390
  // Custom commands: register persisted commands into the DSH command registry
352
391
  // on boot; add/remove re-register live through the API.
353
392
  let commands = null;
393
+ // M.O. 执行记账器:默认 null(旗标关),开了才装配。
394
+ let recorder = null;
354
395
  if (ctx.commands) {
355
396
  commands = createCommandManager({ ctx, settings, logger: ctx.logger, language: langOf(cfg) });
356
397
  commands.sync();
357
398
  }
358
399
 
400
+ // M.O. 执行记账(v0.9.0 第二批):`/mo-run` 显式开一次执行窗口,窗口内的命令执行
401
+ // 才可能被记成一条 run。挂的是 `session/event`——本仓已有三处消费者(continuity /
402
+ // summarize / api),不是新引入的宿主依赖。
403
+ //
404
+ // 为什么必须显式开窗:工作流的 trigger 是人话,拿它去猜「用户这句话是不是要开始走
405
+ // 某条流程」只能猜错,而猜错会把一条从没跑过的流程记成跑过。所以窗口边界由人的
406
+ // 显式动作划,**没开窗时一个字都不记**。
407
+ //
408
+ // 门控:默认关(actionRunEnabled),并且它挂热路径,轻量档也关。
409
+ if (cfg.actionRunEnabled === true) {
410
+ recorder = createExecutionRecorder({ ctx, logger: ctx.logger });
411
+ const disposeMoRun = registerMoRunCommand({
412
+ ctx,
413
+ recorder,
414
+ enabled: true,
415
+ language: () => langOf(cfg)
416
+ });
417
+ if (disposeMoRun) {
418
+ disposers.push(disposeMoRun);
419
+ } else {
420
+ // 注册不上(宿主没有命令注册表)时别留一个只开着窗口却没人能操作的记录器。
421
+ recorder.dispose();
422
+ recorder = null;
423
+ ctx.logger?.warn?.("[dsh-mneme] actionRunEnabled=true 但宿主没有命令注册表,M.O. 记账未启用");
424
+ }
425
+ }
426
+
359
427
  // Dream scheduler: automatic consolidation + summary runs, triggered by
360
428
  // store growth. Writes through the service fire the dream hook, which asks
361
429
  // the scheduler to (re)schedule a run once absolute and since-last-run
@@ -545,6 +613,7 @@ export const apply = (ctx, config) => {
545
613
  if (typeof dispose === "function") dispose();
546
614
  }
547
615
  commands?.dispose();
616
+ recorder?.dispose();
548
617
  if (dream) await dream.dispose();
549
618
  if (sleep) sleep.dispose();
550
619
  store.close();