@modusensus/dsh-mneme 0.8.14 → 0.8.16

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 (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. package/test/write-admission.test.js +668 -668
@@ -1,674 +1,694 @@
1
- import { STR, langOf } from "../lang.js";
2
-
3
- // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
4
- // 旧版)与 differentiate(互补:两条都留、各追加差异注记)。两者的校验与执行都
5
- // 复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
6
- // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
7
- export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
8
-
9
- // Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
10
- // keepSource and conflict winners prefer the higher-trust memory. Higher value
11
- // = preferred. observation (measured) > inferred (derived) > subjective (guess).
12
- const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
13
-
14
- // Issue #104 方向 2:archive 类型护栏的长保留类型集合——这些类型的存在意义
15
- // 就是长期保存(踩坑记录、约束、用户偏好、挖掘出的模式),LLM 以「价值判断」
16
- // 类理由批量归档它们时误伤损失最大。conflict 裁决的败者归档不经过 archive
17
- // action(走 applyDecisions 的 conflict 分支),不受此护栏影响。
18
- const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_solution", "constraint", "pitfall"]);
19
- // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
20
- // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
21
- const ARCHIVE_RATIONALE_RE = /重复|过时|陈旧|过期|已被取代|被取代|冗余|不再使用|旧版|废弃|stale|outdated|obsolete|dup(?:licate|licat)?|superseded|redundant|deprecated|expired|replaced/i;
22
-
23
- /**
24
- * Validate a dream decision list against a snapshot of eligible memories.
25
- * @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
26
- * entries are spliced out of this array in place (the caller reuses the same
27
- * reference downstream for apply/audit); implicit keeps are appended here too.
28
- * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
29
- * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>,
30
- * resolvedShortIds?: number}}
31
- */
32
- export function validateDecisions(decisions, snapshot, options = {}) {
33
- const errors = [];
34
- // Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
35
- // 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
36
- // 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
37
- // degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
38
- // 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
39
- const skipped = [];
40
- const survivors = [];
41
- const skipInvalid = options.skipInvalid === true;
42
- const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
43
- const minAgeHours = options.minAgeHours ?? 24;
44
- // Issue #126 review(Copilot):档位白名单。sleepActionSet 默认档只换 prompt 是
45
- // 不够的——ACTIONS 是两个模块共用的,模型自发出 supersede / differentiate 时校验器
46
- // 照样放行,"opt-in 零行为变化"就成了空话。省略 / null = 不限制(沿用全局 ACTIONS)。
47
- const allowedActions = Array.isArray(options.allowedActions) ? new Set(options.allowedActions) : null;
48
- if (!Array.isArray(decisions)) {
49
- return { ok: false, errors: ["decision list must be an array"], resolvedShortIds: 0 };
50
- }
51
- // 空决策 = 模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 明确允许"无问题的
52
- // 条目无需输出")。合法 JSON [] 不是空体(那是无输出/截断),也不是"残缺输出"
53
- // ——显式短路直接 ok,避免隐式 keep 的覆盖率检查把 0% 误判为模型坏了。与
54
- // sleep 的空模式(skipped no-op)语义对齐:下游 applied=0、audit 记 ok。
55
- if (decisions.length === 0) {
56
- return { ok: true, errors: [], skipped: [], resolvedShortIds: 0 };
57
- }
58
- // Issue #135:模型常把 36 位 UUID 回填成前缀(实测 78/78 个 8 位前缀都能唯一
59
- // 对应窗口内真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写),而
60
- // 下方校验用 snapshot.get(id) 整串精确匹配 → 全部 unknown id → claimed=0 →
61
- // 覆盖率闸整单拒绝,一整轮语义成果被丢。校验前先把「唯一前缀」解析回完整 id
62
- // (git 短哈希式):仅在 snapshot 内解析,有歧义或匹配不到就原样保留,仍由下
63
- // 方 unknown id 校验如实报错。就地改写 ids / keepSource / winner / loser,下
64
- // 游 applyDecisions 与审计拿到的同样是完整 id。
65
- let resolvedShortIds = 0;
66
- const resolveShortId = (value) => {
67
- if (typeof value !== "string" || snapshot.has(value)) return value;
68
- // 太短不猜(歧义概率高);长度 ≥36 说明不是前缀缩写,交给校验如实报错。
69
- if (value.length < 8 || value.length >= 36) return value;
70
- let hit = null;
71
- let matches = 0;
72
- for (const id of snapshot.keys()) {
73
- if (id.startsWith(value)) {
74
- hit = id;
75
- if (++matches > 1) return value; // 有歧义,不解析
76
- }
77
- }
78
- if (matches === 1) {
79
- resolvedShortIds++;
80
- return hit;
81
- }
82
- return value;
83
- };
84
- for (const d of decisions) {
85
- if (!d || typeof d !== "object") continue;
86
- if (Array.isArray(d.ids)) d.ids = d.ids.map(resolveShortId);
87
- if (typeof d.keepSource === "string") d.keepSource = resolveShortId(d.keepSource);
88
- if (typeof d.winner === "string") d.winner = resolveShortId(d.winner);
89
- if (typeof d.loser === "string") d.loser = resolveShortId(d.loser);
90
- }
91
- const claimed = new Set();
92
- for (const [index, d] of decisions.entries()) {
93
- const at = `decision[${index}]`;
94
- // 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
95
- // 全局 errors(沿用 v0.6.9 的双轨结构)。
96
- const local = [];
97
- // Issue #126:supersede 与 conflict 一样用 winner/loser 定位双方(沿用同一套
98
- // 字段,校验与 receipt 逻辑因此可以直接复用)。
99
- const ids = d && (d.action === "conflict" || d.action === "supersede") ? [d.winner, d.loser] : (d?.ids ?? []);
100
- if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
101
- local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
102
- } else if (allowedActions && !allowedActions.has(d.action)) {
103
- // 动作本身合法,但不在当前档位允许的集合里(例如默认档收到 supersede)。
104
- local.push(`${at}: action ${JSON.stringify(d.action)} not allowed in this mode`);
105
- } else if (d.action === "conflict" || d.action === "supersede") {
106
- if (!d.winner || !d.loser || d.winner === d.loser) {
107
- local.push(`${at}: ${d.action} needs distinct winner and loser`);
108
- }
109
- } else if (d.action === "differentiate") {
110
- // Issue #126:互补型——两条都留下、各追加差异注记。ids 必须 ≥2(随后走下方
111
- // 通用 id 校验:unknown / archived / summary / 重复 claim),distinctions 必须
112
- // 非空,否则会写出空注记(写了等于没写,还会污染正文)。
113
- if (!Array.isArray(d.ids) || d.ids.length < 2) {
114
- local.push(`${at}: differentiate needs at least two ids`);
115
- } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
116
- local.push(`${at}: differentiate needs a non-empty distinctions array`);
117
- }
118
- } else if (d.action === "create") {
119
- // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
120
- // so it skips the claiming loop below; evidence is optional provenance
121
- // (already filtered to real ids by the caller) and is stored in content.
122
- if (typeof d.title !== "string" || !d.title.trim()) {
123
- local.push(`${at}: create needs non-empty title`);
124
- }
125
- if (typeof d.content !== "string" || !d.content.trim()) {
126
- local.push(`${at}: create needs non-empty content`);
127
- }
128
- if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
129
- local.push(`${at}: create importance must be an integer 1-5 when provided`);
130
- }
131
- if (typeof d.type !== "string" || !d.type.trim()) {
132
- local.push(`${at}: create needs non-empty type`);
133
- }
134
- } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
135
- local.push(`${at}: ${d.action} needs non-empty ids`);
136
- }
137
- // update-specific field validation runs BEFORE claiming ids, so a failing
138
- // update never pollutes the claimed set (which drives the "every id must
139
- // appear in a decision" check below).
140
- // Issue #104 方向 2:archive 类型护栏。长保留类型的 archive 决策必须携带
141
- // 「重复 / 过时」类理由(提示词对 archive 的定义本就如此),「价值判断」类
142
- // 理由(保持整洁、参考价值低……)不放行——单条跳过记 skipped(run 记
143
- // degraded,明细进 dream_runs.skipped),绝不整单拒绝。未知 id 在下方通用
144
- // 校验里另行报错,这里 snapshot.get 为 undefined 时跳过护栏判断。
145
- if (local.length === 0 && d?.action === "archive") {
146
- const guardedTypes = new Set();
147
- for (const id of d.ids) {
148
- const mem = snapshot.get(id);
149
- if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
150
- }
151
- 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)}`);
153
- }
154
- }
155
- if (local.length === 0 && d?.action === "update") {
156
- // 只能更新单条
157
- if (!Array.isArray(d.ids) || d.ids.length !== 1) {
158
- local.push(`${at}: update must target exactly one id`);
159
- } else {
160
- // 必须产生实际变化
161
- const mem = snapshot.get(d.ids[0]);
162
- const hasChange = (d.title !== undefined && d.title !== mem?.title)
163
- || (d.content !== undefined && d.content !== mem?.content)
164
- || (d.importance !== undefined && d.importance !== mem?.importance);
165
- if (!hasChange) {
166
- local.push(`${at}: update must change at least one field`);
167
- } else if (mem?.type === "summary") {
168
- // 不能更新 summary
169
- local.push(`${at}: cannot update summary via update action`);
170
- } else {
171
- // 保护期:新建记忆不可立即被 update(可配置)
172
- const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
173
- if (ageHours < minAgeHours) {
174
- local.push(`${at}: memory too young (< ${minAgeHours}h)`);
175
- }
176
- }
177
- }
178
- }
179
- if (local.length === 0 && d && d.action !== "create") {
180
- // seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
181
- // (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
182
- const seen = new Set();
183
- for (const id of ids) {
184
- const mem = snapshot.get(id);
185
- if (!mem) {
186
- local.push(`${at}: unknown id ${JSON.stringify(id)}`);
187
- } else if (mem.archived || mem.type === "summary") {
188
- local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
189
- }
190
- if (claimed.has(id) || seen.has(id)) {
191
- local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
192
- }
193
- seen.add(id);
194
- }
195
- if (local.length === 0 && d.action === "merge") {
196
- if (!d.keepSource || !d.ids.includes(d.keepSource)) {
197
- local.push(`${at}: merge keepSource must be one of ids`);
198
- }
199
- if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
200
- local.push(`${at}: merge needs non-empty title and content`);
201
- }
202
- if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
203
- local.push(`${at}: merge importance must be an integer 1-5 when provided`);
204
- }
205
- // Merging across types would blur preference/project/decision boundaries
206
- // in the injected context; the snapshot carries each entry's type.
207
- // Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
208
- // 后放宽该检查,类型边界由用户自行承担。
209
- const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
210
- if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
211
- local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
212
- }
213
- // issue #339 / E8 考卷:merge 护栏(opt-in,dreamMergeGuard)。巩固损耗
214
- // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive
215
- // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是
216
- // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的
217
- // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped
218
- // 明细、严格模式整单拒绝)。
219
- if (local.length === 0 && options.mergeGuard === true) {
220
- const guardedTypes = new Set();
221
- for (const id of d.ids) {
222
- const mem = snapshot.get(id);
223
- if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
224
- }
225
- if (guardedTypes.size > 0) {
226
- local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`);
227
- }
228
- }
229
- }
230
- }
231
- if (local.length > 0) {
232
- if (skipInvalid) {
233
- // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
234
- // keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
235
- // errors,否则会误触发下方的整单拒绝。
236
- skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
237
- } else {
238
- errors.push(...local);
239
- }
240
- continue;
241
- }
242
- if (d.action !== "create") {
243
- for (const id of ids) claimed.add(id);
244
- }
245
- survivors.push(d);
246
- }
247
- // Cap update churn: too many edits in one cycle signals a runaway model.
248
- // 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
249
- const updateCount = survivors.filter((d) => d.action === "update").length;
250
- if (updateCount > maxUpdatePerRun) {
251
- errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
252
- }
253
- // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
254
- const createCount = survivors.filter((d) => d.action === "create").length;
255
- const maxCreatePerRun = options.maxCreatePerRun ?? 5;
256
- if (createCount > maxCreatePerRun) {
257
- errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
258
- }
259
- // Issue #104 方向 2:archive 批量上限——一次性大扫除(12 条互不相关主题被
260
- // 批量归档)是失控信号,与 update 上限同一类全局闸门:skipInvalid 也不豁免,
261
- // 超限整单拒绝;正常的大规模清理由用户调高 dreamMaxArchivePerRun。
262
- const archiveCount = survivors.filter((d) => d.action === "archive").length;
263
- const maxArchivePerRun = options.maxArchivePerRun ?? 8;
264
- if (archiveCount > maxArchivePerRun) {
265
- errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
266
- }
267
- // Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
268
- // 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
269
- // 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
270
- // 「有合法子集但覆盖率不足」的良性挑重点(见下方 coverageShortfall)。
271
- if (options.skipInvalid === true && skipped.length > 0 && survivors.length === 0 && errors.length === 0) {
272
- errors.push(`all ${skipped.length} decision(s) invalid — nothing valid survived validation`);
273
- return { ok: false, errors, skipped, resolvedShortIds };
274
- }
275
- // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
276
- // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
277
- // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
278
- // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
279
- // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
280
- //
281
- // v0.4.4 fix(残缺输出防洗白)→ Issue #104 方向 1 重审:显式覆盖率护栏的
282
- // 初衷是拦「输出截断 → 只 claim 少量 → 其余被隐式 keep 洗白」,但它无法区分
283
- // 恶性截断与良性挑重点——而提示词硬性规则本身要求「无问题的条目无需输出」,
284
- // 挑重点正是要求的行为。官方路由 + 强模型的实测(heptaspirit:81 轮 failed
285
- // 全是 coverage,LLM 调用本身成功、applied=0)证明这是常规路径而非边角。
286
- // 处置:覆盖率不足不再 ok:false 整单拒绝,改为返回 coverageShortfall 理由、
287
- // 照常补齐隐式 keep——调用方把 run 记 degraded(合法子集已应用),护栏以
288
- // 状态暴露而非一票否决,与 skipInvalid 同一宽容哲学。明确异常信号(create
289
- // 上限等硬错误)保持整单拒绝。要恢复旧行为可设 dreamImplicitKeep:false
290
- // (严格模式,缺失即 error)或 dreamMinExplicitCoverage: 0(关护栏)。
291
- // 非覆盖类硬错误(create 上限溢出、严格模式缺失 id)仍然先整单拒绝——
292
- // #104 方向 1 只豁免 coverage 这一个信号,防洗白语义对它们原样保留。
293
- if (errors.length > 0) {
294
- return { ok: false, errors, skipped, resolvedShortIds };
295
- }
296
- const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
297
- let coverageShortfall = null;
298
- if (options.dreamImplicitKeep !== false) {
299
- const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
300
- if (coverage < minCoverage) {
301
- coverageShortfall = `explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`;
302
- }
303
- for (const id of snapshot.keys()) {
304
- if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
305
- }
306
- } else {
307
- for (const id of snapshot.keys()) {
308
- if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
309
- }
310
- if (errors.length > 0) return { ok: false, errors, skipped, resolvedShortIds };
311
- }
312
- // 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
313
- // 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
314
- // 不能以 survivors.length !== decisions.length 作为是否 splice 的判据:
315
- // 当"被跳过的非法决策数 == 隐式补齐的 keep 数"时长度回到相等但内容已变,
316
- // 被跳过的决策会残留进 apply/audit。一律无条件 splice 最安全。
317
- decisions.splice(0, decisions.length, ...survivors);
318
- return { ok: true, errors, skipped, resolvedShortIds, coverageShortfall };
319
- }
320
-
321
- /** Marker thrown when a decision target changed since the run snapshot. */
322
- export class CasConflictError extends Error {
323
- constructor(action, ids, reason) {
324
- super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
325
- this.name = "CasConflictError";
326
- this.action = action;
327
- this.ids = ids;
328
- }
329
- }
330
-
331
- function decisionIds(d) {
332
- return d.action === "conflict" || d.action === "supersede" ? [d.winner, d.loser] : (d.ids ?? []);
333
- }
334
-
335
- /**
336
- * CAS guard (item ①): every target memory must still match what the run
337
- * snapshot captured — otherwise the decision was computed against stale state
338
- * and applying it would overwrite a concurrent edit. Snapshotless replays skip
339
- * the guard entirely (per-action idempotency checks handle those). Throws
340
- * CasConflictError on the first mismatch; the caller's transaction rolls back.
341
- */
342
- function casGuard(service, snapshot, ids) {
343
- if (!snapshot) return;
344
- for (const id of ids) {
345
- const expect = snapshot.get(id);
346
- if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
347
- const current = service.getById(id);
348
- if (!current) {
349
- throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
350
- }
351
- const changed = expect.updated_at !== undefined
352
- ? current.updated_at !== expect.updated_at
353
- : current.content !== expect.content || current.title !== expect.title;
354
- if (changed) {
355
- throw new CasConflictError(
356
- "changed",
357
- [id],
358
- `memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
359
- );
360
- }
361
- }
362
- }
363
-
364
- /**
365
- * Apply a validated decision list to the service. Caller must validate first.
366
- * Each decision runs inside its own SQLite transaction (item ②): the multi-step
367
- * mutation of a decision is atomic, so a merge can never leave "keeper updated
368
- * but source not archived" or vice versa — a throwing sub-step rolls the whole
369
- * decision back.
370
- *
371
- * @param decisions - validated decision list.
372
- * @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
373
- * @param logger - optional logger ({ warn }); per-decision failures are logged.
374
- * @param snapshot - optional Map<id, memory> captured before the LLM call; when
375
- * provided, every decision target is CAS-checked against it and a decision
376
- * computed from stale state is skipped and reported as a conflict instead of
377
- * overwriting concurrent writes (item ①).
378
- * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
379
- * applied - number of decisions/memories actually committed (archive counts
380
- * each archived memory as one, merge/conflict/update count one).
381
- * conflicts - decisions skipped because a target changed since the snapshot.
382
- * failures - decisions that threw mid-transaction (fully rolled back).
383
- * committed - the decisions that actually landed, for outcome/receipt based
384
- * on real committed sub-steps rather than the raw LLM list.
385
- */
386
- export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
387
- let applied = 0;
388
- const conflicts = [];
389
- const failures = [];
390
- const committed = [];
391
- for (const [i, d] of decisions.entries()) {
392
- try {
393
- // keep is a confirmed no-op: it commits nothing but still records the
394
- // per-id disposition so the outcome covers every snapshot memory.
395
- if (d.action === "keep") {
396
- committed.push({ action: "keep", ids: d.ids });
397
- continue;
398
- }
399
- const outcome = applyOne(d, service, snapshot, config);
400
- if (outcome === "skipped") continue;
401
- applied += outcome.applied;
402
- committed.push(outcome.committed);
403
- } catch (error) {
404
- if (error instanceof CasConflictError) {
405
- conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
406
- logger?.warn?.(`dsh-mneme dream: ${error.message}`);
407
- } else {
408
- failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
409
- logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
410
- }
411
- }
412
- }
413
- return { applied, conflicts, failures, committed };
414
- }
415
-
416
- function applyOne(d, service, snapshot, config = {}) {
417
- switch (d.action) {
418
- case "archive": return applyArchive(d, service, snapshot);
419
- case "merge": return applyMerge(d, service, snapshot, config);
420
- case "conflict": return applyConflict(d, service, snapshot, config);
421
- case "supersede": return applySupersede(d, service, snapshot, config);
422
- case "differentiate": return applyDifferentiate(d, service, snapshot, config);
423
- case "create": return applyCreate(d, service, config);
424
- default: return applyUpdate(d, service, snapshot, config);
425
- }
426
- }
427
-
428
- /**
429
- * Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
430
- * `preferred`). Archived memories are never eligible keepers — promoting one
431
- * would demote the real keepSource to a source and then hit the archived-keeper
432
- * guard in applyMerge, silently skipping the whole merge. When `preferred`
433
- * itself is archived (or missing), fall back to any unarchived candidate.
434
- */
435
- function pickBestKeeper(ids, preferred, service) {
436
- let best = null;
437
- let bestP = -1;
438
- for (const id of ids) {
439
- const mem = service.getById(id);
440
- if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
441
- const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
442
- if (p > bestP || (p === bestP && id === preferred)) {
443
- bestP = p;
444
- best = id;
445
- }
446
- }
447
- return best ?? preferred;
448
- }
449
-
450
- /**
451
- * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
452
- * Evidence ids ride in the content so a pattern stays traceable to its source
453
- * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
454
- */
455
- function applyCreate(d, service, config = {}) {
456
- const language = langOf(config);
457
- const title = String(d.title ?? "").trim();
458
- const content = String(d.content ?? "").trim();
459
- const importance = Number.isInteger(d.importance) ? d.importance : 3;
460
- const type = typeof d.type === "string" ? d.type : "pattern";
461
- const evidence = Array.isArray(d.evidence)
462
- ? d.evidence.filter((id) => typeof id === "string")
463
- : [];
464
- const body = evidence.length > 0
465
- ? STR.evidenceSuffix[language](content, evidence)
466
- : content;
467
- const created = service.saveWithDedupe({ type, title, content: body, importance });
468
- const memory = created?.memory;
469
- if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
470
- return { applied: 1, committed: { action: "create", id: memory.id, type } };
471
- }
472
-
473
- function applyArchive(d, service, snapshot) {
474
- const targets = d.ids.filter((id) => {
475
- const mem = service.getById(id);
476
- return mem && !mem.archived; // existing, not-yet-archived rows only
477
- });
478
- if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
479
- service.transaction(() => {
480
- casGuard(service, snapshot, d.ids);
481
- for (const id of d.ids) {
482
- const mem = service.getById(id);
483
- if (mem && !mem.archived) service.setArchived(id, true);
484
- }
485
- });
486
- return { applied: targets.length, committed: { action: "archive", ids: targets } };
487
- }
488
-
489
- function applyMerge(d, service, snapshot, config = {}) {
490
- // Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
491
- // subjective/inferred one. Mutating the decision keeps the receipt + committed
492
- // record aligned with the actual keeper.
493
- if (config.trustEpistemicWeighting === true) {
494
- const best = pickBestKeeper(d.ids, d.keepSource, service);
495
- if (best && best !== d.keepSource) d.keepSource = best;
496
- }
497
- const sources = d.ids.filter((id) => id !== d.keepSource);
498
- // Idempotent replay: if every other source is already archived, this merge
499
- // already landed — skip so a replayed/concurrent decision never double-counts
500
- // or re-applies (guard against duplicate merges).
501
- if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
502
- service.transaction(() => {
503
- casGuard(service, snapshot, d.ids);
504
- const keeper = service.getById(d.keepSource);
505
- if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
506
- service.update(d.keepSource, {
507
- title: d.title,
508
- content: d.content,
509
- importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
510
- });
511
- for (const id of sources) {
512
- const mem = service.getById(id);
513
- if (mem && !mem.archived) service.setArchived(id, true);
514
- }
515
- // 4.3.2 迁移实体关联(opt-in):将 loser(source)记忆关联的 entity_attrs 的
516
- // memory_id 迁移到 keeper;keeper 已有同 entity+key 的当前属性时 loser 行被
517
- // 失效。单个 source 迁移失败只告警,绝不能导致整个 merge 事务回滚(fail-safe)。
518
- if (config.entityExtractionEnabled && typeof service.migrateAttrsToMemory === "function") {
519
- for (const id of sources) {
520
- try {
521
- service.migrateAttrsToMemory(id, d.keepSource, new Date().toISOString());
522
- } catch (error) {
523
- logger?.warn?.(`dsh-mneme dream: failed to migrate attrs from ${id} to ${d.keepSource}: ${error.message}`);
524
- }
525
- }
526
- }
527
- });
528
- return {
529
- applied: 1,
530
- committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance, count_before: d.ids.length, count_after: 1 }
531
- };
532
- }
533
-
534
- function applyConflict(d, service, snapshot, config = {}) {
535
- const language = langOf(config);
536
- // Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
537
- // is preferred as winner over a subjective/inferred one.
538
- if (config.trustEpistemicWeighting === true) {
539
- const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
540
- const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
541
- if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
542
- }
543
- const winner = service.getById(d.winner);
544
- const loser = service.getById(d.loser);
545
- if (!winner || !loser) return "skipped";
546
- // Idempotent replay: an already-archived loser means the conflict was already
547
- // adjudicated — skip so the provenance note is never re-appended and the loser
548
- // is not re-archived.
549
- if (loser.archived) return "skipped";
550
- service.transaction(() => {
551
- casGuard(service, snapshot, [d.winner, d.loser]);
552
- const winnerNow = service.getById(d.winner);
553
- const loserNow = service.getById(d.loser);
554
- if (!winnerNow || !loserNow || loserNow.archived) return;
555
- service.update(d.winner, {
556
- content: STR.supersededSuffix[language](winnerNow.content, [...loserNow.content].slice(0, 100).join(""))
557
- });
558
- service.setArchived(d.loser, true);
559
- });
560
- return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
561
- }
562
-
563
- /**
564
- * supersede(Issue #126,sleepActionSet="full"):演进型重复——新版本取代旧版本。
565
- * 与 conflict 的关键差别:**注记追加到 loser(被取代方)而不是 winner**。conflict
566
- * 把"已否决旧信息"写进赢家正文,赢家因此被污染——这正是 #126 的原始抱怨;
567
- * supersede 把可追溯性留在被归档的那一侧,赢家正文一字不动。
568
- *
569
- * 幂等:loser 已归档即视为该决策已落地(重放/并发不会重复追加注记或重复归档)。
570
- */
571
- function applySupersede(d, service, snapshot, config = {}) {
572
- const language = langOf(config);
573
- // Epistemic trust(与 conflict 同款):可信度更高的一侧优先作为取代方。
574
- if (config.trustEpistemicWeighting === true) {
575
- const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
576
- const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
577
- if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
578
- }
579
- const winner = service.getById(d.winner);
580
- const loser = service.getById(d.loser);
581
- if (!winner || !loser) return "skipped";
582
- if (loser.archived) return "skipped"; // 幂等重放:已经取代过了
583
- service.transaction(() => {
584
- casGuard(service, snapshot, [d.winner, d.loser]);
585
- const winnerNow = service.getById(d.winner);
586
- const loserNow = service.getById(d.loser);
587
- if (!winnerNow || !loserNow || loserNow.archived) return;
588
- service.update(d.loser, {
589
- content: STR.supersededBySuffix[language](loserNow.content, winnerNow.title)
590
- });
591
- service.setArchived(d.loser, true);
592
- });
593
- return { applied: 1, committed: { action: "supersede", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
594
- }
595
-
596
- /**
597
- * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
598
- * 覆盖不同侧面,不该判输赢。双方都保留(不归档),各追加同一段差异注记;注记进
599
- * 正文 → 进注入与 embedding → 下一轮不会再被判成重复(这是本动作存在的意义)。
600
- *
601
- * 注记由插件按 distinctions 渲染,**不让模型整段重写 content**(重写有篡改/丢失
602
- * 风险)。幂等:注记已在正文里就不重复追加,否则同一决策重放会不断堆叠。
603
- */
604
- function applyDifferentiate(d, service, snapshot, config = {}) {
605
- const language = langOf(config);
606
- const ids = Array.isArray(d.ids) ? d.ids : [];
607
- const notes = (Array.isArray(d.distinctions) ? d.distinctions : [])
608
- .filter((s) => typeof s === "string" && s.trim())
609
- .map((s) => s.trim());
610
- const marker = STR.differentiatedMarker[language](notes);
611
- const alive = ids.filter((id) => {
612
- const m = service.getById(id);
613
- return m && !m.archived;
614
- });
615
- if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
616
- if (alive.every((id) => String(service.getById(id)?.content ?? "").includes(marker))) {
617
- return "skipped"; // 同一条决策重放:注记已存在
618
- }
619
- service.transaction(() => {
620
- casGuard(service, snapshot, ids);
621
- for (const id of ids) {
622
- const m = service.getById(id);
623
- if (!m || m.archived) continue;
624
- if (String(m.content).includes(marker)) continue; // 单条已注记过
625
- service.update(id, { content: `${m.content}${marker}` });
626
- }
627
- });
628
- return {
629
- applied: 1,
630
- committed: { action: "differentiate", ids, count_before: ids.length, count_after: ids.length }
631
- };
632
- }
633
-
634
- function applyUpdate(d, service, snapshot, config = {}) {
635
- const id = d.ids[0];
636
- const mem = service.getById(id);
637
- if (!mem || mem.archived) return "skipped";
638
- // 幂等检查:如果字段已与目标一致则跳过
639
- const same = (d.title === undefined || d.title === mem.title)
640
- && (d.content === undefined || d.content === mem.content)
641
- && (d.importance === undefined || d.importance === mem.importance);
642
- if (same) return "skipped";
643
- service.transaction(() => {
644
- casGuard(service, snapshot, [id]);
645
- const cur = service.getById(id);
646
- if (!cur || cur.archived) return;
647
- service.update(id, {
648
- title: d.title ?? cur.title,
649
- content: d.content ?? cur.content,
650
- importance: d.importance ?? cur.importance
651
- });
652
- });
653
- // 4.3.1 supersedes 关系(opt-in):事务提交成功后,为该记忆关联的每条实体属性
654
- // 建立自引用 supersedes 关系,表示"此属性版本已被替代"。仅记录、绝不阻断主流程
655
- // (fail-safe):记录失败只告警,update 本身照常生效。
656
- if (config.entityExtractionEnabled && typeof service.saveRelation === "function" && typeof service.getAttrsByMemory === "function") {
657
- try {
658
- const oldAttrs = service.getAttrsByMemory(id);
659
- for (const attr of oldAttrs) {
660
- if (!attr.entity_id) continue;
661
- service.saveRelation({
662
- from_entity: attr.entity_id,
663
- to_entity: attr.entity_id,
664
- relation_type: "supersedes",
665
- memory_id: id,
666
- metadata: JSON.stringify({ attr_key: attr.attr_key, old_value: attr.attr_value })
667
- });
668
- }
669
- } catch (error) {
670
- logger?.warn?.(`dsh-mneme dream: failed to record supersedes relations for ${id}: ${error.message}`);
671
- }
672
- }
673
- return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
674
- }
1
+ import { STR, langOf } from "../lang.js";
2
+
3
+ // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
4
+ // 旧版)与 differentiate(互补:两条都留、各追加差异注记)。两者的校验与执行都
5
+ // 复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
6
+ // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
7
+ export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
8
+
9
+ // Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
10
+ // keepSource and conflict winners prefer the higher-trust memory. Higher value
11
+ // = preferred. observation (measured) > inferred (derived) > subjective (guess).
12
+ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
13
+
14
+ // Issue #104 方向 2:archive 类型护栏的长保留类型集合——这些类型的存在意义
15
+ // 就是长期保存(踩坑记录、约束、用户偏好、挖掘出的模式),LLM 以「价值判断」
16
+ // 类理由批量归档它们时误伤损失最大。conflict 裁决的败者归档不经过 archive
17
+ // action(走 applyDecisions 的 conflict 分支),不受此护栏影响。
18
+ const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_solution", "constraint", "pitfall"]);
19
+ // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
20
+ // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
21
+ const ARCHIVE_RATIONALE_RE = /重复|过时|陈旧|过期|已被取代|被取代|冗余|不再使用|旧版|废弃|stale|outdated|obsolete|dup(?:licate|licat)?|superseded|redundant|deprecated|expired|replaced/i;
22
+
23
+ /**
24
+ * Validate a dream decision list against a snapshot of eligible memories.
25
+ * @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
26
+ * entries are spliced out of this array in place (the caller reuses the same
27
+ * reference downstream for apply/audit); implicit keeps are appended here too.
28
+ * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
29
+ * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>,
30
+ * resolvedShortIds?: number}}
31
+ */
32
+ export function validateDecisions(decisions, snapshot, options = {}) {
33
+ const errors = [];
34
+ // Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
35
+ // 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
36
+ // 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
37
+ // degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
38
+ // 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
39
+ const skipped = [];
40
+ const survivors = [];
41
+ const skipInvalid = options.skipInvalid === true;
42
+ const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
43
+ const minAgeHours = options.minAgeHours ?? 24;
44
+ // Issue #126 review(Copilot):档位白名单。sleepActionSet 默认档只换 prompt 是
45
+ // 不够的——ACTIONS 是两个模块共用的,模型自发出 supersede / differentiate 时校验器
46
+ // 照样放行,"opt-in 零行为变化"就成了空话。省略 / null = 不限制(沿用全局 ACTIONS)。
47
+ const allowedActions = Array.isArray(options.allowedActions) ? new Set(options.allowedActions) : null;
48
+ if (!Array.isArray(decisions)) {
49
+ return { ok: false, errors: ["decision list must be an array"], resolvedShortIds: 0 };
50
+ }
51
+ // 空决策 = 模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 明确允许"无问题的
52
+ // 条目无需输出")。合法 JSON [] 不是空体(那是无输出/截断),也不是"残缺输出"
53
+ // ——显式短路直接 ok,避免隐式 keep 的覆盖率检查把 0% 误判为模型坏了。与
54
+ // sleep 的空模式(skipped no-op)语义对齐:下游 applied=0、audit 记 ok。
55
+ if (decisions.length === 0) {
56
+ return { ok: true, errors: [], skipped: [], resolvedShortIds: 0 };
57
+ }
58
+ // Issue #135:模型常把 36 位 UUID 回填成前缀(实测 78/78 个 8 位前缀都能唯一
59
+ // 对应窗口内真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写),而
60
+ // 下方校验用 snapshot.get(id) 整串精确匹配 → 全部 unknown id → claimed=0 →
61
+ // 覆盖率闸整单拒绝,一整轮语义成果被丢。校验前先把「唯一前缀」解析回完整 id
62
+ // (git 短哈希式):仅在 snapshot 内解析,有歧义或匹配不到就原样保留,仍由下
63
+ // 方 unknown id 校验如实报错。就地改写 ids / keepSource / winner / loser,下
64
+ // 游 applyDecisions 与审计拿到的同样是完整 id。
65
+ let resolvedShortIds = 0;
66
+ const resolveShortId = (value) => {
67
+ if (typeof value !== "string" || snapshot.has(value)) return value;
68
+ // 太短不猜(歧义概率高);长度 ≥36 说明不是前缀缩写,交给校验如实报错。
69
+ if (value.length < 8 || value.length >= 36) return value;
70
+ let hit = null;
71
+ let matches = 0;
72
+ for (const id of snapshot.keys()) {
73
+ if (id.startsWith(value)) {
74
+ hit = id;
75
+ if (++matches > 1) return value; // 有歧义,不解析
76
+ }
77
+ }
78
+ if (matches === 1) {
79
+ resolvedShortIds++;
80
+ return hit;
81
+ }
82
+ return value;
83
+ };
84
+ for (const d of decisions) {
85
+ if (!d || typeof d !== "object") continue;
86
+ if (Array.isArray(d.ids)) d.ids = d.ids.map(resolveShortId);
87
+ if (typeof d.keepSource === "string") d.keepSource = resolveShortId(d.keepSource);
88
+ if (typeof d.winner === "string") d.winner = resolveShortId(d.winner);
89
+ if (typeof d.loser === "string") d.loser = resolveShortId(d.loser);
90
+ }
91
+ const claimed = new Set();
92
+ for (const [index, d] of decisions.entries()) {
93
+ const at = `decision[${index}]`;
94
+ // 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
95
+ // 全局 errors(沿用 v0.6.9 的双轨结构)。
96
+ const local = [];
97
+ // Issue #126:supersede 与 conflict 一样用 winner/loser 定位双方(沿用同一套
98
+ // 字段,校验与 receipt 逻辑因此可以直接复用)。
99
+ const ids = d && (d.action === "conflict" || d.action === "supersede") ? [d.winner, d.loser] : (d?.ids ?? []);
100
+ if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
101
+ local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
102
+ } else if (allowedActions && !allowedActions.has(d.action)) {
103
+ // 动作本身合法,但不在当前档位允许的集合里(例如默认档收到 supersede)。
104
+ local.push(`${at}: action ${JSON.stringify(d.action)} not allowed in this mode`);
105
+ } else if (d.action === "conflict" || d.action === "supersede") {
106
+ if (!d.winner || !d.loser || d.winner === d.loser) {
107
+ local.push(`${at}: ${d.action} needs distinct winner and loser`);
108
+ }
109
+ } else if (d.action === "differentiate") {
110
+ // Issue #126:互补型——两条都留下、各追加差异注记。ids 必须 ≥2(随后走下方
111
+ // 通用 id 校验:unknown / archived / summary / 重复 claim),distinctions 必须
112
+ // 非空,否则会写出空注记(写了等于没写,还会污染正文)。
113
+ if (!Array.isArray(d.ids) || d.ids.length < 2) {
114
+ local.push(`${at}: differentiate needs at least two ids`);
115
+ } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
116
+ local.push(`${at}: differentiate needs a non-empty distinctions array`);
117
+ }
118
+ } else if (d.action === "create") {
119
+ // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
120
+ // so it skips the claiming loop below; evidence is optional provenance
121
+ // (already filtered to real ids by the caller) and is stored in content.
122
+ if (typeof d.title !== "string" || !d.title.trim()) {
123
+ local.push(`${at}: create needs non-empty title`);
124
+ }
125
+ if (typeof d.content !== "string" || !d.content.trim()) {
126
+ local.push(`${at}: create needs non-empty content`);
127
+ }
128
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
129
+ local.push(`${at}: create importance must be an integer 1-5 when provided`);
130
+ }
131
+ if (typeof d.type !== "string" || !d.type.trim()) {
132
+ local.push(`${at}: create needs non-empty type`);
133
+ }
134
+ } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
135
+ local.push(`${at}: ${d.action} needs non-empty ids`);
136
+ }
137
+ // update-specific field validation runs BEFORE claiming ids, so a failing
138
+ // update never pollutes the claimed set (which drives the "every id must
139
+ // appear in a decision" check below).
140
+ // Issue #104 方向 2:archive 类型护栏。长保留类型的 archive 决策必须携带
141
+ // 「重复 / 过时」类理由(提示词对 archive 的定义本就如此),「价值判断」类
142
+ // 理由(保持整洁、参考价值低……)不放行——单条跳过记 skipped(run 记
143
+ // degraded,明细进 dream_runs.skipped),绝不整单拒绝。未知 id 在下方通用
144
+ // 校验里另行报错,这里 snapshot.get 为 undefined 时跳过护栏判断。
145
+ if (local.length === 0 && d?.action === "archive") {
146
+ const guardedTypes = new Set();
147
+ for (const id of d.ids) {
148
+ const mem = snapshot.get(id);
149
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
150
+ }
151
+ 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)}`);
153
+ }
154
+ }
155
+ if (local.length === 0 && d?.action === "update") {
156
+ // 只能更新单条
157
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
158
+ local.push(`${at}: update must target exactly one id`);
159
+ } else {
160
+ // 必须产生实际变化
161
+ const mem = snapshot.get(d.ids[0]);
162
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
163
+ || (d.content !== undefined && d.content !== mem?.content)
164
+ || (d.importance !== undefined && d.importance !== mem?.importance);
165
+ if (!hasChange) {
166
+ local.push(`${at}: update must change at least one field`);
167
+ } else if (mem?.type === "summary") {
168
+ // 不能更新 summary
169
+ local.push(`${at}: cannot update summary via update action`);
170
+ } else {
171
+ // 保护期:新建记忆不可立即被 update(可配置)
172
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
173
+ if (ageHours < minAgeHours) {
174
+ local.push(`${at}: memory too young (< ${minAgeHours}h)`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ if (local.length === 0 && d && d.action !== "create") {
180
+ // seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
181
+ // (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
182
+ const seen = new Set();
183
+ for (const id of ids) {
184
+ const mem = snapshot.get(id);
185
+ if (!mem) {
186
+ local.push(`${at}: unknown id ${JSON.stringify(id)}`);
187
+ } else if (mem.archived || mem.type === "summary") {
188
+ local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
189
+ }
190
+ if (claimed.has(id) || seen.has(id)) {
191
+ local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
192
+ }
193
+ seen.add(id);
194
+ }
195
+ if (local.length === 0 && d.action === "merge") {
196
+ if (!d.keepSource || !d.ids.includes(d.keepSource)) {
197
+ local.push(`${at}: merge keepSource must be one of ids`);
198
+ }
199
+ if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
200
+ local.push(`${at}: merge needs non-empty title and content`);
201
+ }
202
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
203
+ local.push(`${at}: merge importance must be an integer 1-5 when provided`);
204
+ }
205
+ // Merging across types would blur preference/project/decision boundaries
206
+ // in the injected context; the snapshot carries each entry's type.
207
+ // Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
208
+ // 后放宽该检查,类型边界由用户自行承担。
209
+ const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
210
+ if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
211
+ local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
212
+ }
213
+ // issue #339 / E8 考卷:merge 护栏(默认开,dreamMergeGuard)。巩固损耗
214
+ // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive
215
+ // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是
216
+ // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的
217
+ // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped
218
+ // 明细、严格模式整单拒绝)。
219
+ if (local.length === 0 && options.mergeGuard === true) {
220
+ const guardedTypes = new Set();
221
+ for (const id of d.ids) {
222
+ const mem = snapshot.get(id);
223
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
224
+ }
225
+ if (guardedTypes.size > 0) {
226
+ local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ if (local.length > 0) {
232
+ if (skipInvalid) {
233
+ // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
234
+ // keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
235
+ // errors,否则会误触发下方的整单拒绝。
236
+ skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
237
+ } else {
238
+ errors.push(...local);
239
+ }
240
+ continue;
241
+ }
242
+ if (d.action !== "create") {
243
+ for (const id of ids) claimed.add(id);
244
+ }
245
+ survivors.push(d);
246
+ }
247
+ // Cap update churn: too many edits in one cycle signals a runaway model.
248
+ // 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
249
+ const updateCount = survivors.filter((d) => d.action === "update").length;
250
+ if (updateCount > maxUpdatePerRun) {
251
+ errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
252
+ }
253
+ // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
254
+ const createCount = survivors.filter((d) => d.action === "create").length;
255
+ const maxCreatePerRun = options.maxCreatePerRun ?? 5;
256
+ if (createCount > maxCreatePerRun) {
257
+ errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
258
+ }
259
+ // Issue #104 方向 2:archive 批量上限——一次性大扫除(12 条互不相关主题被
260
+ // 批量归档)是失控信号,与 update 上限同一类全局闸门:skipInvalid 也不豁免,
261
+ // 超限整单拒绝;正常的大规模清理由用户调高 dreamMaxArchivePerRun。
262
+ const archiveCount = survivors.filter((d) => d.action === "archive").length;
263
+ const maxArchivePerRun = options.maxArchivePerRun ?? 8;
264
+ if (archiveCount > maxArchivePerRun) {
265
+ errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
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
+ }
287
+ // Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
288
+ // 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
289
+ // 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
290
+ // 「有合法子集但覆盖率不足」的良性挑重点(见下方 coverageShortfall)。
291
+ if (options.skipInvalid === true && skipped.length > 0 && survivors.length === 0 && errors.length === 0) {
292
+ errors.push(`all ${skipped.length} decision(s) invalid — nothing valid survived validation`);
293
+ return { ok: false, errors, skipped, resolvedShortIds };
294
+ }
295
+ // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
296
+ // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
297
+ // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
298
+ // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
299
+ // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
300
+ //
301
+ // v0.4.4 fix(残缺输出防洗白)→ Issue #104 方向 1 重审:显式覆盖率护栏的
302
+ // 初衷是拦「输出截断 → 只 claim 少量 → 其余被隐式 keep 洗白」,但它无法区分
303
+ // 恶性截断与良性挑重点——而提示词硬性规则本身要求「无问题的条目无需输出」,
304
+ // 挑重点正是要求的行为。官方路由 + 强模型的实测(heptaspirit:81 轮 failed
305
+ // 全是 coverage,LLM 调用本身成功、applied=0)证明这是常规路径而非边角。
306
+ // 处置:覆盖率不足不再 ok:false 整单拒绝,改为返回 coverageShortfall 理由、
307
+ // 照常补齐隐式 keep——调用方把 run 记 degraded(合法子集已应用),护栏以
308
+ // 状态暴露而非一票否决,与 skipInvalid 同一宽容哲学。明确异常信号(create
309
+ // 上限等硬错误)保持整单拒绝。要恢复旧行为可设 dreamImplicitKeep:false
310
+ // (严格模式,缺失即 error)或 dreamMinExplicitCoverage: 0(关护栏)。
311
+ // 非覆盖类硬错误(create 上限溢出、严格模式缺失 id)仍然先整单拒绝——
312
+ // #104 方向 1 只豁免 coverage 这一个信号,防洗白语义对它们原样保留。
313
+ if (errors.length > 0) {
314
+ return { ok: false, errors, skipped, resolvedShortIds };
315
+ }
316
+ const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
317
+ let coverageShortfall = null;
318
+ if (options.dreamImplicitKeep !== false) {
319
+ const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
320
+ if (coverage < minCoverage) {
321
+ coverageShortfall = `explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`;
322
+ }
323
+ for (const id of snapshot.keys()) {
324
+ if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
325
+ }
326
+ } else {
327
+ for (const id of snapshot.keys()) {
328
+ if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
329
+ }
330
+ if (errors.length > 0) return { ok: false, errors, skipped, resolvedShortIds };
331
+ }
332
+ // 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
333
+ // 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
334
+ // 不能以 survivors.length !== decisions.length 作为是否 splice 的判据:
335
+ // 当"被跳过的非法决策数 == 隐式补齐的 keep 数"时长度回到相等但内容已变,
336
+ // 被跳过的决策会残留进 apply/audit。一律无条件 splice 最安全。
337
+ decisions.splice(0, decisions.length, ...survivors);
338
+ return { ok: true, errors, skipped, resolvedShortIds, coverageShortfall };
339
+ }
340
+
341
+ /** Marker thrown when a decision target changed since the run snapshot. */
342
+ export class CasConflictError extends Error {
343
+ constructor(action, ids, reason) {
344
+ super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
345
+ this.name = "CasConflictError";
346
+ this.action = action;
347
+ this.ids = ids;
348
+ }
349
+ }
350
+
351
+ function decisionIds(d) {
352
+ return d.action === "conflict" || d.action === "supersede" ? [d.winner, d.loser] : (d.ids ?? []);
353
+ }
354
+
355
+ /**
356
+ * CAS guard (item ①): every target memory must still match what the run
357
+ * snapshot captured — otherwise the decision was computed against stale state
358
+ * and applying it would overwrite a concurrent edit. Snapshotless replays skip
359
+ * the guard entirely (per-action idempotency checks handle those). Throws
360
+ * CasConflictError on the first mismatch; the caller's transaction rolls back.
361
+ */
362
+ function casGuard(service, snapshot, ids) {
363
+ if (!snapshot) return;
364
+ for (const id of ids) {
365
+ const expect = snapshot.get(id);
366
+ if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
367
+ const current = service.getById(id);
368
+ if (!current) {
369
+ throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
370
+ }
371
+ const changed = expect.updated_at !== undefined
372
+ ? current.updated_at !== expect.updated_at
373
+ : current.content !== expect.content || current.title !== expect.title;
374
+ if (changed) {
375
+ throw new CasConflictError(
376
+ "changed",
377
+ [id],
378
+ `memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
379
+ );
380
+ }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Apply a validated decision list to the service. Caller must validate first.
386
+ * Each decision runs inside its own SQLite transaction (item ②): the multi-step
387
+ * mutation of a decision is atomic, so a merge can never leave "keeper updated
388
+ * but source not archived" or vice versa — a throwing sub-step rolls the whole
389
+ * decision back.
390
+ *
391
+ * @param decisions - validated decision list.
392
+ * @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
393
+ * @param logger - optional logger ({ warn }); per-decision failures are logged.
394
+ * @param snapshot - optional Map<id, memory> captured before the LLM call; when
395
+ * provided, every decision target is CAS-checked against it and a decision
396
+ * computed from stale state is skipped and reported as a conflict instead of
397
+ * overwriting concurrent writes (item ①).
398
+ * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
399
+ * applied - number of decisions/memories actually committed (archive counts
400
+ * each archived memory as one, merge/conflict/update count one).
401
+ * conflicts - decisions skipped because a target changed since the snapshot.
402
+ * failures - decisions that threw mid-transaction (fully rolled back).
403
+ * committed - the decisions that actually landed, for outcome/receipt based
404
+ * on real committed sub-steps rather than the raw LLM list.
405
+ */
406
+ export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
407
+ let applied = 0;
408
+ const conflicts = [];
409
+ const failures = [];
410
+ const committed = [];
411
+ for (const [i, d] of decisions.entries()) {
412
+ try {
413
+ // keep is a confirmed no-op: it commits nothing but still records the
414
+ // per-id disposition so the outcome covers every snapshot memory.
415
+ if (d.action === "keep") {
416
+ committed.push({ action: "keep", ids: d.ids });
417
+ continue;
418
+ }
419
+ const outcome = applyOne(d, service, snapshot, config);
420
+ if (outcome === "skipped") continue;
421
+ applied += outcome.applied;
422
+ committed.push(outcome.committed);
423
+ } catch (error) {
424
+ if (error instanceof CasConflictError) {
425
+ conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
426
+ logger?.warn?.(`dsh-mneme dream: ${error.message}`);
427
+ } else {
428
+ failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
429
+ logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
430
+ }
431
+ }
432
+ }
433
+ return { applied, conflicts, failures, committed };
434
+ }
435
+
436
+ function applyOne(d, service, snapshot, config = {}) {
437
+ switch (d.action) {
438
+ case "archive": return applyArchive(d, service, snapshot);
439
+ case "merge": return applyMerge(d, service, snapshot, config);
440
+ case "conflict": return applyConflict(d, service, snapshot, config);
441
+ case "supersede": return applySupersede(d, service, snapshot, config);
442
+ case "differentiate": return applyDifferentiate(d, service, snapshot, config);
443
+ case "create": return applyCreate(d, service, config);
444
+ default: return applyUpdate(d, service, snapshot, config);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
450
+ * `preferred`). Archived memories are never eligible keepers — promoting one
451
+ * would demote the real keepSource to a source and then hit the archived-keeper
452
+ * guard in applyMerge, silently skipping the whole merge. When `preferred`
453
+ * itself is archived (or missing), fall back to any unarchived candidate.
454
+ */
455
+ function pickBestKeeper(ids, preferred, service) {
456
+ let best = null;
457
+ let bestP = -1;
458
+ for (const id of ids) {
459
+ const mem = service.getById(id);
460
+ if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
461
+ const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
462
+ if (p > bestP || (p === bestP && id === preferred)) {
463
+ bestP = p;
464
+ best = id;
465
+ }
466
+ }
467
+ return best ?? preferred;
468
+ }
469
+
470
+ /**
471
+ * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
472
+ * Evidence ids ride in the content so a pattern stays traceable to its source
473
+ * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
474
+ */
475
+ function applyCreate(d, service, config = {}) {
476
+ const language = langOf(config);
477
+ const title = String(d.title ?? "").trim();
478
+ const content = String(d.content ?? "").trim();
479
+ const importance = Number.isInteger(d.importance) ? d.importance : 3;
480
+ const type = typeof d.type === "string" ? d.type : "pattern";
481
+ const evidence = Array.isArray(d.evidence)
482
+ ? d.evidence.filter((id) => typeof id === "string")
483
+ : [];
484
+ const body = evidence.length > 0
485
+ ? STR.evidenceSuffix[language](content, evidence)
486
+ : content;
487
+ const created = service.saveWithDedupe({ type, title, content: body, importance });
488
+ const memory = created?.memory;
489
+ if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
490
+ return { applied: 1, committed: { action: "create", id: memory.id, type } };
491
+ }
492
+
493
+ function applyArchive(d, service, snapshot) {
494
+ const targets = d.ids.filter((id) => {
495
+ const mem = service.getById(id);
496
+ return mem && !mem.archived; // existing, not-yet-archived rows only
497
+ });
498
+ if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
499
+ service.transaction(() => {
500
+ casGuard(service, snapshot, d.ids);
501
+ for (const id of d.ids) {
502
+ const mem = service.getById(id);
503
+ if (mem && !mem.archived) service.setArchived(id, true);
504
+ }
505
+ });
506
+ return { applied: targets.length, committed: { action: "archive", ids: targets } };
507
+ }
508
+
509
+ function applyMerge(d, service, snapshot, config = {}) {
510
+ // Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
511
+ // subjective/inferred one. Mutating the decision keeps the receipt + committed
512
+ // record aligned with the actual keeper.
513
+ if (config.trustEpistemicWeighting === true) {
514
+ const best = pickBestKeeper(d.ids, d.keepSource, service);
515
+ if (best && best !== d.keepSource) d.keepSource = best;
516
+ }
517
+ const sources = d.ids.filter((id) => id !== d.keepSource);
518
+ // Idempotent replay: if every other source is already archived, this merge
519
+ // already landed — skip so a replayed/concurrent decision never double-counts
520
+ // or re-applies (guard against duplicate merges).
521
+ if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
522
+ service.transaction(() => {
523
+ casGuard(service, snapshot, d.ids);
524
+ const keeper = service.getById(d.keepSource);
525
+ if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
526
+ service.update(d.keepSource, {
527
+ title: d.title,
528
+ content: d.content,
529
+ importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
530
+ });
531
+ for (const id of sources) {
532
+ const mem = service.getById(id);
533
+ if (mem && !mem.archived) service.setArchived(id, true);
534
+ }
535
+ // 4.3.2 迁移实体关联(opt-in):将 loser(source)记忆关联的 entity_attrs 的
536
+ // memory_id 迁移到 keeper;keeper 已有同 entity+key 的当前属性时 loser 行被
537
+ // 失效。单个 source 迁移失败只告警,绝不能导致整个 merge 事务回滚(fail-safe)。
538
+ if (config.entityExtractionEnabled && typeof service.migrateAttrsToMemory === "function") {
539
+ for (const id of sources) {
540
+ try {
541
+ service.migrateAttrsToMemory(id, d.keepSource, new Date().toISOString());
542
+ } catch (error) {
543
+ logger?.warn?.(`dsh-mneme dream: failed to migrate attrs from ${id} to ${d.keepSource}: ${error.message}`);
544
+ }
545
+ }
546
+ }
547
+ });
548
+ return {
549
+ applied: 1,
550
+ committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance, count_before: d.ids.length, count_after: 1 }
551
+ };
552
+ }
553
+
554
+ function applyConflict(d, service, snapshot, config = {}) {
555
+ const language = langOf(config);
556
+ // Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
557
+ // is preferred as winner over a subjective/inferred one.
558
+ if (config.trustEpistemicWeighting === true) {
559
+ const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
560
+ const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
561
+ if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
562
+ }
563
+ const winner = service.getById(d.winner);
564
+ const loser = service.getById(d.loser);
565
+ if (!winner || !loser) return "skipped";
566
+ // Idempotent replay: an already-archived loser means the conflict was already
567
+ // adjudicated — skip so the provenance note is never re-appended and the loser
568
+ // is not re-archived.
569
+ if (loser.archived) return "skipped";
570
+ service.transaction(() => {
571
+ casGuard(service, snapshot, [d.winner, d.loser]);
572
+ const winnerNow = service.getById(d.winner);
573
+ const loserNow = service.getById(d.loser);
574
+ if (!winnerNow || !loserNow || loserNow.archived) return;
575
+ service.update(d.winner, {
576
+ content: STR.supersededSuffix[language](winnerNow.content, [...loserNow.content].slice(0, 100).join(""))
577
+ });
578
+ service.setArchived(d.loser, true);
579
+ });
580
+ return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
581
+ }
582
+
583
+ /**
584
+ * supersede(Issue #126,sleepActionSet="full"):演进型重复——新版本取代旧版本。
585
+ * 与 conflict 的关键差别:**注记追加到 loser(被取代方)而不是 winner**。conflict
586
+ * 把"已否决旧信息"写进赢家正文,赢家因此被污染——这正是 #126 的原始抱怨;
587
+ * supersede 把可追溯性留在被归档的那一侧,赢家正文一字不动。
588
+ *
589
+ * 幂等:loser 已归档即视为该决策已落地(重放/并发不会重复追加注记或重复归档)。
590
+ */
591
+ function applySupersede(d, service, snapshot, config = {}) {
592
+ const language = langOf(config);
593
+ // Epistemic trust(与 conflict 同款):可信度更高的一侧优先作为取代方。
594
+ if (config.trustEpistemicWeighting === true) {
595
+ const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
596
+ const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
597
+ if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
598
+ }
599
+ const winner = service.getById(d.winner);
600
+ const loser = service.getById(d.loser);
601
+ if (!winner || !loser) return "skipped";
602
+ if (loser.archived) return "skipped"; // 幂等重放:已经取代过了
603
+ service.transaction(() => {
604
+ casGuard(service, snapshot, [d.winner, d.loser]);
605
+ const winnerNow = service.getById(d.winner);
606
+ const loserNow = service.getById(d.loser);
607
+ if (!winnerNow || !loserNow || loserNow.archived) return;
608
+ service.update(d.loser, {
609
+ content: STR.supersededBySuffix[language](loserNow.content, winnerNow.title)
610
+ });
611
+ service.setArchived(d.loser, true);
612
+ });
613
+ return { applied: 1, committed: { action: "supersede", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
614
+ }
615
+
616
+ /**
617
+ * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
618
+ * 覆盖不同侧面,不该判输赢。双方都保留(不归档),各追加同一段差异注记;注记进
619
+ * 正文 → 进注入与 embedding → 下一轮不会再被判成重复(这是本动作存在的意义)。
620
+ *
621
+ * 注记由插件按 distinctions 渲染,**不让模型整段重写 content**(重写有篡改/丢失
622
+ * 风险)。幂等:注记已在正文里就不重复追加,否则同一决策重放会不断堆叠。
623
+ */
624
+ function applyDifferentiate(d, service, snapshot, config = {}) {
625
+ const language = langOf(config);
626
+ const ids = Array.isArray(d.ids) ? d.ids : [];
627
+ const notes = (Array.isArray(d.distinctions) ? d.distinctions : [])
628
+ .filter((s) => typeof s === "string" && s.trim())
629
+ .map((s) => s.trim());
630
+ const marker = STR.differentiatedMarker[language](notes);
631
+ const alive = ids.filter((id) => {
632
+ const m = service.getById(id);
633
+ return m && !m.archived;
634
+ });
635
+ if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
636
+ if (alive.every((id) => String(service.getById(id)?.content ?? "").includes(marker))) {
637
+ return "skipped"; // 同一条决策重放:注记已存在
638
+ }
639
+ service.transaction(() => {
640
+ casGuard(service, snapshot, ids);
641
+ for (const id of ids) {
642
+ const m = service.getById(id);
643
+ if (!m || m.archived) continue;
644
+ if (String(m.content).includes(marker)) continue; // 单条已注记过
645
+ service.update(id, { content: `${m.content}${marker}` });
646
+ }
647
+ });
648
+ return {
649
+ applied: 1,
650
+ committed: { action: "differentiate", ids, count_before: ids.length, count_after: ids.length }
651
+ };
652
+ }
653
+
654
+ function applyUpdate(d, service, snapshot, config = {}) {
655
+ const id = d.ids[0];
656
+ const mem = service.getById(id);
657
+ if (!mem || mem.archived) return "skipped";
658
+ // 幂等检查:如果字段已与目标一致则跳过
659
+ const same = (d.title === undefined || d.title === mem.title)
660
+ && (d.content === undefined || d.content === mem.content)
661
+ && (d.importance === undefined || d.importance === mem.importance);
662
+ if (same) return "skipped";
663
+ service.transaction(() => {
664
+ casGuard(service, snapshot, [id]);
665
+ const cur = service.getById(id);
666
+ if (!cur || cur.archived) return;
667
+ service.update(id, {
668
+ title: d.title ?? cur.title,
669
+ content: d.content ?? cur.content,
670
+ importance: d.importance ?? cur.importance
671
+ });
672
+ });
673
+ // 4.3.1 supersedes 关系(opt-in):事务提交成功后,为该记忆关联的每条实体属性
674
+ // 建立自引用 supersedes 关系,表示"此属性版本已被替代"。仅记录、绝不阻断主流程
675
+ // (fail-safe):记录失败只告警,update 本身照常生效。
676
+ if (config.entityExtractionEnabled && typeof service.saveRelation === "function" && typeof service.getAttrsByMemory === "function") {
677
+ try {
678
+ const oldAttrs = service.getAttrsByMemory(id);
679
+ for (const attr of oldAttrs) {
680
+ if (!attr.entity_id) continue;
681
+ service.saveRelation({
682
+ from_entity: attr.entity_id,
683
+ to_entity: attr.entity_id,
684
+ relation_type: "supersedes",
685
+ memory_id: id,
686
+ metadata: JSON.stringify({ attr_key: attr.attr_key, old_value: attr.attr_value })
687
+ });
688
+ }
689
+ } catch (error) {
690
+ logger?.warn?.(`dsh-mneme dream: failed to record supersedes relations for ${id}: ${error.message}`);
691
+ }
692
+ }
693
+ return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
694
+ }