@modusensus/dsh-mneme 0.9.4 → 0.10.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 (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
package/lib/config.js CHANGED
@@ -586,6 +586,16 @@ export const Config = z.object({
586
586
  // Minimum gap between two sleep runs (hours) — a second idle window within
587
587
  // this interval does not retrigger.
588
588
  sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
589
+ // Issue #529(C 项的替代实现):sleep 连续失败退避(默认关 = 行为逐字节不变)。
590
+ // 基数是 sleepMinIntervalHours,有效间隔 = 基数 × 2^连续失败数(成功清零),
591
+ // 封顶 max(基数, 24 小时)——不是硬 24 小时:这个键允许配到 168,用户配 48 就是
592
+ // 明说「两天跑一次」,退避只能放宽间隔、不该把用户自己的基数压小。
593
+ // 为什么不按 issue 原案用 snapshot_hash 判据:sleep 的
594
+ // snapshot_hash 是**跑完之后**对 phases 结果算的哈希(dream/sleep.js 的
595
+ // createHash(JSON.stringify(phases))),不是输入快照;同一批输入跑两次结果不同
596
+ // 就会得到不同哈希,用它当「同一输入又在失败」的判据不成立。退避与 #292 的
597
+ // autoDreamFailureBackoff 同构:只放大既有的最小间隔闸,不新增状态面。
598
+ sleepFailureBackoff: z.boolean().default(false),
589
599
  // Conflict adjudication strictness:
590
600
  // gentle only high-confidence conflicts (threshold 0.92) are resolved
591
601
  // normal standard dream-level (threshold 0.85)
@@ -653,6 +663,22 @@ export const Config = z.object({
653
663
  // 增长);流式计费按实际用量,不按上限。
654
664
  sleepMaxTokens: z.natural().min(256).max(131072).default(8192).description("sleep 冲突消解与模式发现阶段的 LLM 输出预算上限(token)。原为硬编码 2048,sleepActionSet=full 实测需约 7000 导致裁决被截断而整轮失败;流式计费按实际用量,调大不增加成本。"),
655
665
 
666
+ // Issue #529:冲突阶段此前没有任何输入侧预算——同 scope 的全部相似对被一次性
667
+ // 拼进一个 prompt,实测单请求 163,054 输入 token(服务端 --ctx-size 163,840,
668
+ // 占 99.5%),449 对的池子必然撞墙;而失败的轮次不落 pairs、判 keep 又是
669
+ // no-op,下一轮冷却结束再送**完全相同的输入**,于是 23 轮连续 llm failed。
670
+ // 输出侧早有 sleepMaxTokens(#257),输入侧这一半一直是空的。
671
+ //
672
+ // 每批上限:0 = 不设上限(严格回退到旧的一次性全送,用于排查"是不是分批
673
+ // 引入的差异")。取 12 是因为实测单对约 112~168 输入 token、12 对落在
674
+ // 1.5K~2K 区间,远低于任何服务商的上下文窗口,也不至于让 LLM 在长清单里
675
+ // 走神。
676
+ sleepConflictMaxPairs: z.natural().min(0).max(500).default(12).description("sleep 冲突阶段每批送给 LLM 的候选对上限(0 = 不设上限,回退旧的一次性全送)。实测单对约 112~168 输入 token,默认 12 对约 1.5K~2K。"),
677
+ // 每轮总上限:分批之后仍要有个总闸,否则一个 449 对的池子会拆成 38 批、
678
+ // 每批一次 LLM 往返,单轮跑上几小时(本身也没比撞墙好)。0 = 不设上限。
679
+ // 未入选的对不是"被丢弃"——它们留在 conflict_pending 与下一轮的候选池里。
680
+ sleepConflictMaxPairsPerRun: z.natural().min(0).max(5000).default(60).description("sleep 冲突阶段单轮累计裁定的候选对上限(0 = 不设上限)。默认 60 对 ≈ 5 批,约 10K 输入 token。"),
681
+
656
682
  // --- epistemic trust: memory source credibility (v0.4.5) -----------------
657
683
  // Distinguish memories by source: observation (measured / witnessed),
658
684
  // subjective (opinion / guess) and inferred (derived from other evidence).
@@ -966,7 +992,13 @@ export const Config = z.object({
966
992
  // 显式保存,画像正文必须是用户手写的原文,不给模型改写权。
967
993
  // 进 LIGHT_MODE_OFF:一次调用要读上百行记忆 + 一轮模型生成,与低资源档互斥(同
968
994
  // preInjectGate.enabled 的定性);是预设不是强制,用户显式勾选仍然赢。
969
- profileExtractEnabled: z.boolean().default(false)
995
+ profileExtractEnabled: z.boolean().default(false),
996
+
997
+ // #539 逐会话无痕:总开关默认关,保持旧用户行为;子代理选择器默认开,但总开关
998
+ // 未开时不生效。手动名单是精确的 session id 匹配。
999
+ sessionGateEnabled: z.boolean().default(false),
1000
+ sessionGateSubagents: z.boolean().default(true),
1001
+ sessionGateSessions: z.array(z.string()).default([])
970
1002
  });
971
1003
 
972
1004
  // Fields forced to false by the light-mode preset. Everything not listed here
package/lib/continuity.js CHANGED
@@ -173,10 +173,10 @@ function noticeAlreadyInSurface(session, text) {
173
173
  * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。
174
174
  *
175
175
  * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。
176
- * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定,
177
- * 保持"挂上就是开"的单一口径。
176
+ * 调用方负责父/子开关(`autoInject` + `continuityRescueEnabled`),本函数另检查逐会话
177
+ * 无痕闸门;保持"挂上就是开、命中会话就不处理"的单一口径。
178
178
  */
179
- export function createContinuityRescue(ctx, store) {
179
+ export function createContinuityRescue(ctx, store, sessionGate = null) {
180
180
  // 待处理的边缘:sessionId → 压缩事件序号。**真落地**的压缩事件到达时置位,下一次 pre-step 消费后
181
181
  // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
182
182
  // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
@@ -184,6 +184,10 @@ export function createContinuityRescue(ctx, store) {
184
184
  const edge = new Map();
185
185
 
186
186
  const unsubscribe = ctx.on("session/event", (session, event) => {
187
+ if (sessionGate?.isOff(session)) {
188
+ if (session?.id) edge.delete(session.id);
189
+ return;
190
+ }
187
191
  if (!isCompactionEdge(event)) return;
188
192
  if (!session?.id) return;
189
193
  if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
@@ -200,6 +204,10 @@ export function createContinuityRescue(ctx, store) {
200
204
  // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。
201
205
  if (decision?.kind !== "enter") return decision;
202
206
  const session = payload?.agent?.session;
207
+ if (sessionGate?.isOff(session)) {
208
+ if (session?.id) edge.delete(session.id);
209
+ return decision;
210
+ }
203
211
  const sessionId = session?.id;
204
212
  if (!sessionId) return decision;
205
213
  if (!edge.has(sessionId)) return decision;
package/lib/document.js CHANGED
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
16
16
  import { isAbsolute, resolve, sep } from "node:path";
17
17
  import { scopeKeyOf } from "./scope.js";
18
18
  import { cosineSimilarity } from "./dream/clustering.js";
19
+ import { isImportedRow } from "./memory-source.js";
19
20
 
20
21
  // C2 vector 档阈值(#164 拍板 0.92,与 findSessionDuplicate 的 vector 档同源)。
21
22
  // 刻意不做 24h 时间窗:那个窗是会话去重的「近邻界」(findSessionDuplicate 注释),
@@ -82,7 +83,7 @@ const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
82
83
  *
83
84
  * @returns {(payload: object, opts?: object) => Promise<object>}
84
85
  * `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
85
- * evidence_dropped, evidence_archived, degraded }`
86
+ * evidence_dropped, evidence_archived, evidence_skipped, degraded }`
86
87
  * @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
87
88
  * 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
88
89
  */
@@ -262,6 +263,10 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
262
263
  let superseded;
263
264
  const loser = explicitTarget ? store.getById(explicitTarget.id) : null;
264
265
  if (loser && !loser.archived) {
266
+ // 这里不挂 isImportedRow:loser 只会是 document 行(scopeMatched 全来自
267
+ // store.list({ type: "document" })),而 document 行永远不可能是外来行——
268
+ // 分享包格式排除 document,存储层的 document 只由 saveDocument 从这里铸造。
269
+ // 加谓词是空转,还会让下一个读者误以为 document 行有外来形态。
265
270
  store.update(loser.id, {
266
271
  content: `${loser.content}${supersededByPointer(created.id)}`,
267
272
  content_history: pushContentHistory(loser, "superseded")
@@ -276,18 +281,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
276
281
  // ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
277
282
  // (可恢复),opt-out 走 archiveEvidence。
278
283
  // pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
284
+ // 外来行不吸收(spec §15 一,车道 C8):这条旁路不经任何 service.all() 行池,
285
+ // 车道 B 的九池静态锁看不见它;证据 id 由调用方给,循环里的 setArchived 是
286
+ // 机器发起的批量归档——正是「本库模型不改写别人的记忆」要禁的动作类。
287
+ // 引用本身照旧落库(文档合法地为它背书),只是不替别人撤下活跃面,
288
+ // 跳过条数走 evidence_skipped 报出来,不做静默跳过。
279
289
  let evidenceArchived = 0;
290
+ let evidenceSkipped = 0;
280
291
  if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
281
292
  for (const id of kept) {
282
293
  // 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
283
294
  const row = store.getById(id);
284
295
  if (!row || row.archived || row.forgotten) continue;
296
+ if (isImportedRow(row)) { evidenceSkipped += 1; continue; }
285
297
  if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
286
298
  store.setArchived(id, true);
287
299
  evidenceArchived += 1;
288
300
  }
289
301
  }
290
- return { created, superseded, evidenceArchived };
302
+ return { created, superseded, evidenceArchived, evidenceSkipped };
291
303
  });
292
304
  finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
293
305
  return {
@@ -297,6 +309,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
297
309
  evidence_kept: kept.length,
298
310
  evidence_dropped: dropped.length,
299
311
  evidence_archived: result.evidenceArchived,
312
+ evidence_skipped: result.evidenceSkipped,
300
313
  degraded: dropped.length > 0
301
314
  };
302
315
  };
@@ -1,8 +1,10 @@
1
1
  import { STR, langOf } from "../lang.js";
2
+ import { contentHashOf } from "../content-hash.js";
2
3
 
3
4
  // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
4
- // 旧版)与 differentiate(互补:两条都留、各追加差异注记)。两者的校验与执行都
5
- // 复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
5
+ // 旧版)与 differentiate(互补:两条都留,各自保留原本的正文)。两者的校验与执行
6
+ // 都复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
7
+ // Issue #533 起 differentiate 不再改写正文,只落裁决账本——见 applyDifferentiate。
6
8
  // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
7
9
  export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
8
10
 
@@ -19,6 +21,78 @@ const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_soluti
19
21
  // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
20
22
  // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
21
23
  const ARCHIVE_RATIONALE_RE = /重复|过时|陈旧|过期|已被取代|被取代|冗余|不再使用|旧版|废弃|stale|outdated|obsolete|dup(?:licate|licat)?|superseded|redundant|deprecated|expired|replaced/i;
24
+ // Issue #522 评审:上面那张词表只认关键词,不认否定——「并不重复,只是主题相近」
25
+ // 与 "not a duplicate" 都会命中「重复」/"duplicate" 子串。archive 护栏从 #104 起
26
+ // 就带着这个洞(既有行为,改它会扩大范围,先记在这里),但 #521 给 merge 加的
27
+ // 逃生门是本 PR 新开的口子,必须自己堵上:否定表述一票否决。第二轮评审又指出
28
+ // 词表里「过时 / stale / outdated」同样是钥匙,只挡「重复」等于留了半边门——
29
+ // 「并不过时,只是主题相近」照样能换到合并许可,所以否定词按整张词表铺开。
30
+ const NEGATED_RATIONALE_RE = /(?:不|并不|不是|非)\s*(?:重复|过时|陈旧|过期|已被取代|被取代|冗余|旧版|废弃)|\b(?:not|no|isn't|is not|never)\s+(?:a\s+|an\s+)?(?:duplicat\w*|stale|outdated|obsolete|superseded|redundant|deprecated|expired|replaced)/i;
31
+
32
+ // Issue #521:把 skipped[].error 归一化成可分组的原因。原样字符串不能直接
33
+ // GROUP BY——前缀带 decision[N]: 序号,同一种原因会被拆成 N 组(#521 实测 280
34
+ // 条被拆成 20 组),面板上看不出「一整类动作 100% 失败」。这里只剥三样东西:
35
+ // 序号前缀、模型可控的引号文本、具体类型集合,其余措辞保持原样,好让人一眼
36
+ // 认出是哪个判据。
37
+ export function normalizeSkipReason(error) {
38
+ // 一条决策可能攒下多条错误,skipped[].error 用 "; " 把它们拼成一句(见下方
39
+ // skipped.push)。所以先按 decision[N]: 的边界切开、逐段归一化再拼回去——
40
+ // 原先的正则 ^ 锚定,只剥得掉第一个前缀,后面的 decision[1]: 会留在字符串
41
+ // 中间,聚合时又被拆成多组(#522 评审指出)。
42
+ // 抹引号必须排在切分之前:模型写的理由原文里可能正好含 "; decision[0]: "
43
+ // (引文、邮箱都算),先切分就会把这段自由文本切成独立的一段,而那一段不带
44
+ // 任何可识别的边界,后面对它无从下手。先抹掉引号里的内容,切分就只看得到
45
+ // 我们自己拼进去的分隔符。
46
+ return maskModelText(String(error ?? ""))
47
+ .split(/;\s*(?=decision\[\d+\]:\s*)/)
48
+ .map(normalizeSkipSegment)
49
+ .join("; ")
50
+ .trim();
51
+ }
52
+
53
+ // 引号内的内容一律是模型可控的:id、action 名、理由原文都由 JSON.stringify
54
+ // 加引号,所以「引号内 = 不可信」是这里唯一的判据。成对的那段整段抹成占位符;
55
+ // 万一剩下一个孤立引号(旧版把理由截到 60 字再拼进 error,可能正好断在字符串
56
+ // 中间),从那里砍到结尾——宁可少一句尾巴,也不让记忆正文随 open 路由出网关。
57
+ // 刻意不按「, got: 」这类分隔符猜边界(#522 三轮评审):分隔符本身可能出现在
58
+ // id 里(unknown id "a@b.c, got: x"),猜错就会把引号截断,脱敏反而失效。
59
+ function maskModelText(text) {
60
+ return String(text ?? "")
61
+ .replace(/"(?:[^"\\]|\\.)*"/g, "\u0000")
62
+ .replace(/"(?:[\s\S])*$/, "\u0000")
63
+ .replace(/\u0000/g, '"…"');
64
+ }
65
+
66
+ // 单段归一化:剥序号前缀、丢掉已经抹空的自由文本尾巴、把具体类型集合抹成占位符。
67
+ function normalizeSkipSegment(segment) {
68
+ return String(segment ?? "")
69
+ .replace(/^decision\[\d+\]:\s*/, "")
70
+ // 抹完之后「, got: "…"」是个固定字面量(maskModelText 的产物),可以整段
71
+ // 砍掉:它只说明「校验器当时回了一句理由」,内容已经不在字符串里了。
72
+ .replace(/,\s*got:\s*"…"$/, "")
73
+ // 类型集合同理,但单个类型名保留——「被拦的是哪一类」本身是有效信息
74
+ .replace(/\(([a-z_]+(?:,\s*[a-z_]+)+)\)/g, "(…)")
75
+ .trim();
76
+ }
77
+
78
+ // Issue #521:按归一化原因聚合 skipped,供面板直接显示。按 (action, reason)
79
+ // 二元组计数——同一句原因在 merge 和 archive 上是两回事,混在一起会误导。
80
+ export function summarizeSkipped(skipped) {
81
+ const buckets = new Map();
82
+ for (const item of Array.isArray(skipped) ? skipped : []) {
83
+ const reason = normalizeSkipReason(item?.error);
84
+ if (!reason) continue;
85
+ // #522 评审:action 也走白名单。这条聚合结果会随 open 路由出网关,放任意字符串
86
+ // 过去等于让上游的脏数据(或将来某个新字段)自己决定响应形状;不在枚举里的一律
87
+ // 归成空串,面板少显示一个动作标签,比显示一段来路不明的文本安全。
88
+ const action = ACTIONS.has(item?.action) ? item.action : "";
89
+ const key = action + "\u0000" + reason;
90
+ const bucket = buckets.get(key);
91
+ if (bucket) bucket.count += 1;
92
+ else buckets.set(key, { action, reason, count: 1 });
93
+ }
94
+ return [...buckets.values()].sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
95
+ }
22
96
 
23
97
  /**
24
98
  * Validate a dream decision list against a snapshot of eligible memories.
@@ -107,9 +181,10 @@ export function validateDecisions(decisions, snapshot, options = {}) {
107
181
  local.push(`${at}: ${d.action} needs distinct winner and loser`);
108
182
  }
109
183
  } else if (d.action === "differentiate") {
110
- // Issue #126:互补型——两条都留下、各追加差异注记。ids 必须 ≥2(随后走下方
111
- // 通用 id 校验:unknown / archived / summary / 重复 claim),distinctions 必须
112
- // 非空,否则会写出空注记(写了等于没写,还会污染正文)。
184
+ // Issue #126:互补型——两条都留下。ids 必须 ≥2(随后走下方通用 id 校验:
185
+ // unknown / archived / summary / 重复 claim)。distinctions 仍然必填:#533 之后
186
+ // 它不再写进正文,但仍是「模型确实做过分类」的唯一凭据,落进裁决账本供审计
187
+ // (空数组意味着模型只是偷懒输出了一条动作名,不该被记成已裁决)。
113
188
  if (!Array.isArray(d.ids) || d.ids.length < 2) {
114
189
  local.push(`${at}: differentiate needs at least two ids`);
115
190
  } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
@@ -149,7 +224,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
149
224
  if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
150
225
  }
151
226
  if (guardedTypes.size > 0 && !ARCHIVE_RATIONALE_RE.test(String(d.reason ?? ""))) {
152
- local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "")).slice(0, 60)}`);
227
+ local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "").slice(0, 60))}`);
153
228
  }
154
229
  }
155
230
  if (local.length === 0 && d?.action === "update") {
@@ -222,8 +297,21 @@ export function validateDecisions(decisions, snapshot, options = {}) {
222
297
  const mem = snapshot.get(id);
223
298
  if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
224
299
  }
225
- if (guardedTypes.size > 0) {
226
- local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`);
300
+ // Issue #521:与上方 archive 护栏对齐——archive 早就允许「带重复/过时
301
+ // 理由」的长保留类型通过,merge 却一律拦死,于是一份真重复的
302
+ // preference 永远合不掉,而提示词还在催模型合并它们。实测(#521)46 轮
303
+ // 巩固里 280 条 merge 决策全被这条拦下、merge 恒为 0,模型每轮把同一
304
+ // 簇条目再提一次,满载 token 空转。逃生门条件与 archive 护栏同一条词表
305
+ // 且要求同类型(跨类型合并本就另受 allowCrossTypeMerge 约束,逃生门
306
+ // 不跟着放宽)。reason 只写「主题相近」不会命中词表——这是故意的:
307
+ // 相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。
308
+ const sameType = new Set(d.ids.map((id) => snapshot.get(id)?.type)).size === 1;
309
+ const reasonText = String(d.reason ?? "");
310
+ // #522 评审:词表命中还不够——「并不重复」「not a duplicate」也含关键词,
311
+ // 放它们过闸等于让模型用一句否认换来合并许可。否定表述直接判不重复。
312
+ const duplicated = !NEGATED_RATIONALE_RE.test(reasonText) && ARCHIVE_RATIONALE_RE.test(reasonText);
313
+ if (guardedTypes.size > 0 && !(sameType && duplicated)) {
314
+ local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard (needs a duplicate/outdated rationale, 重复/过时)`);
227
315
  }
228
316
  }
229
317
  }
@@ -233,7 +321,22 @@ export function validateDecisions(decisions, snapshot, options = {}) {
233
321
  // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
234
322
  // keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
235
323
  // errors,否则会误触发下方的整单拒绝。
236
- skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
324
+ //
325
+ // #534:除四键骨架外补三个「模型到底给没给」的布尔,把 skipped 从
326
+ // 「error 字符串」变成可计数的形状。作者实测 19 轮 17 轮整轮无效,但
327
+ // skipped 里只有一句错误串,事后无法判断模型是漏字段(merge 缺
328
+ // title/content)、还是字段给了但值不合规(has_* 为 true 仍报错)。
329
+ // 只加布尔不加原文:原文进 llm_audit_logs.metadata 与 dream_runs.raw_output,
330
+ // 这里保持每条记录足够小(skipped 会按轮累积进一行 JSON)。
331
+ skipped.push({
332
+ index,
333
+ action: d?.action,
334
+ ids,
335
+ error: local.join("; "),
336
+ has_keepSource: d?.keepSource !== undefined && d?.keepSource !== null,
337
+ has_title: typeof d?.title === "string" && d.title.trim().length > 0,
338
+ has_content: typeof d?.content === "string" && d.content.trim().length > 0
339
+ });
237
340
  } else {
238
341
  errors.push(...local);
239
342
  }
@@ -422,7 +525,7 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
422
525
  committed.push({ action: "keep", ids: d.ids });
423
526
  continue;
424
527
  }
425
- const outcome = applyOne(d, service, snapshot, config);
528
+ const outcome = applyOne(d, service, snapshot, config, logger);
426
529
  if (outcome === "skipped") continue;
427
530
  applied += outcome.applied;
428
531
  committed.push(outcome.committed);
@@ -439,14 +542,14 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
439
542
  return { applied, conflicts, failures, committed };
440
543
  }
441
544
 
442
- function applyOne(d, service, snapshot, config = {}) {
545
+ function applyOne(d, service, snapshot, config = {}, logger = null) {
443
546
  switch (d.action) {
444
547
  case "archive": return applyArchive(d, service, snapshot);
445
548
  case "merge": return applyMerge(d, service, snapshot, config);
446
549
  case "conflict": return applyConflict(d, service, snapshot, config);
447
550
  case "supersede": return applySupersede(d, service, snapshot, config);
448
551
  case "differentiate": return applyDifferentiate(d, service, snapshot, config);
449
- case "create": return applyCreate(d, service, config);
552
+ case "create": return applyCreate(d, service, config, logger);
450
553
  default: return applyUpdate(d, service, snapshot, config);
451
554
  }
452
555
  }
@@ -478,7 +581,7 @@ function pickBestKeeper(ids, preferred, service) {
478
581
  * Evidence ids ride in the content so a pattern stays traceable to its source
479
582
  * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
480
583
  */
481
- function applyCreate(d, service, config = {}) {
584
+ function applyCreate(d, service, config = {}, logger = null) {
482
585
  const language = langOf(config);
483
586
  const title = String(d.title ?? "").trim();
484
587
  const content = String(d.content ?? "").trim();
@@ -503,6 +606,22 @@ function applyCreate(d, service, config = {}) {
503
606
  });
504
607
  const memory = created?.memory;
505
608
  if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
609
+ // #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
610
+ // 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
611
+ // 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
612
+ try {
613
+ if (typeof service?.saveMemoryMint === "function" && config?.runId) {
614
+ service.saveMemoryMint({
615
+ memoryId: memory.id,
616
+ runId: config.runId,
617
+ runType: config.mintSource === "sleep" ? "sleep" : "dream",
618
+ triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
619
+ createdAt: new Date().toISOString()
620
+ });
621
+ }
622
+ } catch (mintError) {
623
+ logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
624
+ }
506
625
  return { applied: 1, committed: { action: "create", id: memory.id, type } };
507
626
  }
508
627
 
@@ -631,35 +750,62 @@ function applySupersede(d, service, snapshot, config = {}) {
631
750
 
632
751
  /**
633
752
  * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
634
- * 覆盖不同侧面,不该判输赢。双方都保留(不归档),各追加同一段差异注记;注记进
635
- * 正文 → 进注入与 embedding → 下一轮不会再被判成重复(这是本动作存在的意义)。
753
+ * 覆盖不同侧面,不该判输赢。双方都保留(不归档),**正文一个字节都不动**。
754
+ *
755
+ * 为什么不动正文(Issue #533):#126 的原设计是「双方各追加同一段差异注记,注记
756
+ * 进正文 → 进注入与 embedding → 下一轮就不会再被判成重复」。离线复现证伪了这个
757
+ * 假设——两侧共享同一段后缀会让两条向量**同向靠拢**,余弦一轮比一轮高(0.876 →
758
+ * 0.960 只用了 5 轮,把注记截到 0 字照样往上爬,因为推高相似度的是共享前缀本身而
759
+ * 不是注记长短),于是同一对下一轮又被判成重复、又追加一段,实测单条堆到 19 段。
760
+ * 现在的裁决落在 differentiated_pairs 账本(src/store.js)里:候选构造阶段读它,
761
+ * 已裁决且两侧正文都没改过就不再送模型,正文保持干净。
636
762
  *
637
- * 注记由插件按 distinctions 渲染,**不让模型整段重写 content**(重写有篡改/丢失
638
- * 风险)。幂等:注记已在正文里就不重复追加,否则同一决策重放会不断堆叠。
763
+ * 幂等三层,从便宜到贵:
764
+ * 1. 一侧已归档 → skipped(缺一条就没什么可区分的);
765
+ * 2. 存量兼容:两侧正文都还带着旧版注记块 → skipped(升级不清账);
766
+ * 3. 账本命中且两侧 content hash 与记账时一致 → skipped(真正的幂等重放)。
767
+ * 任一侧后来被改写 → hash 对不上 → 重新裁决,宁可再判一次也不静音真的演化。
639
768
  */
640
769
  function applyDifferentiate(d, service, snapshot, config = {}) {
641
- const language = langOf(config);
642
770
  const ids = Array.isArray(d.ids) ? d.ids : [];
643
- const notes = (Array.isArray(d.distinctions) ? d.distinctions : [])
644
- .filter((s) => typeof s === "string" && s.trim())
645
- .map((s) => s.trim());
646
- const marker = STR.differentiatedMarker[language](notes);
647
771
  const alive = ids.filter((id) => {
648
772
  const m = service.getById(id);
649
773
  return m && !m.archived;
650
774
  });
651
775
  if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
652
- if (alive.every((id) => String(service.getById(id)?.content ?? "").includes(marker))) {
653
- return "skipped"; // 同一条决策重放:注记已存在
776
+ const hashes = new Map(alive.map((id) => [id, contentHashOf(service.getById(id))]));
777
+ const [a, b] = alive.slice(0, 2);
778
+ const record = typeof service.findDifferentiatedPair === "function"
779
+ ? service.findDifferentiatedPair(a, b)
780
+ : undefined;
781
+ if (record) {
782
+ // 记账时 a/b 是按 id 排序存的,hash 也跟着换位,这里同样按排序取值比对。
783
+ const [ha, hb] = a <= b ? [hashes.get(a), hashes.get(b)] : [hashes.get(b), hashes.get(a)];
784
+ if (record.hash_a === ha && record.hash_b === hb) return "skipped";
785
+ } else {
786
+ // 存量库兼容:旧版本把注记写进了正文,那些行没有账本行。两侧都还带着注记块
787
+ // 才认定「已裁决过」——只有一侧有,说明另一侧后来被改写,应当重判。
788
+ const noted = alive.filter((id) => STR.differentiatedNoteRe.test(String(service.getById(id)?.content ?? "")));
789
+ if (noted.length >= 2) return "skipped";
654
790
  }
791
+ // 账本缺席(人工确认路径的裸 service)就什么都不写:正文一字不动本来就是本动作
792
+ // 的全部副作用,没有账本就只剩「这条决策无处落地」,不是错误,也不是 applied。
793
+ if (typeof service.saveDifferentiatedPair !== "function") return "skipped";
655
794
  service.transaction(() => {
656
795
  casGuard(service, snapshot, ids);
657
- for (const id of ids) {
658
- const m = service.getById(id);
659
- if (!m || m.archived) continue;
660
- if (String(m.content).includes(marker)) continue; // 单条已注记过
661
- service.update(id, { content: `${m.content}${marker}` });
662
- }
796
+ const [ia, ib] = alive.slice(0, 2);
797
+ service.saveDifferentiatedPair({
798
+ run_id: config.runId,
799
+ memory_a: ia,
800
+ memory_b: ib,
801
+ // hash 取**执行时**的正文(不是送审时),与账本里下一次比对的口径同源。
802
+ hash_a: hashes.get(ia),
803
+ hash_b: hashes.get(ib),
804
+ // 模型给的差异点也落账:正文不再写注记,这些就是「当初为什么判互补」的唯一凭据。
805
+ distinctions: (Array.isArray(d.distinctions) ? d.distinctions : [])
806
+ .filter((x) => typeof x === "string" && x.trim())
807
+ .map((x) => x.trim())
808
+ });
663
809
  });
664
810
  return {
665
811
  applied: 1,