@modusensus/dsh-mneme 0.14.0-beta.1 → 0.14.0-beta.2

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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -830
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -262
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -404
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3600
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -14322
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1344
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -945
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -2027
  54. package/lib/dream/triage.js +139 -139
  55. package/lib/dream.js +1879 -1879
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -119
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -852
  74. package/lib/inject.js +656 -651
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -595
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3268
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -817
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5895
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -267
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -640
  142. package/lib/wiki/render.js +261 -261
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -500
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -609
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +1 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -210
  162. package/scripts/release-notes.mjs +164 -164
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3600
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1344
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -945
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -2027
  196. package/src/dream/triage.js +139 -139
  197. package/src/dream.js +1879 -1879
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -119
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -852
  216. package/src/inject.js +656 -651
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -595
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3268
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -817
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5895
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -267
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -640
  284. package/src/wiki/render.js +261 -261
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -500
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -609
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2998
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4425
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -452
  318. package/test/conflict-ledger.test.js +242 -242
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -432
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -174
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -333
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -259
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -109
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -553
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -90
  407. package/test/panel-tokens.test.js +245 -245
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -156
  453. package/test/release-notes.test.js +265 -265
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -188
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1455
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -734
  529. package/test/wiki-claims.test.js +194 -194
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -202
  535. package/test/wiki-queries.test.js +398 -398
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -456
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -460
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
@@ -1,945 +1,945 @@
1
- import { STR, langOf } from "../lang.js";
2
- import { contentHashOf } from "../content-hash.js";
3
-
4
- // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
5
- // 旧版)与 differentiate(互补:两条都留,各自保留原本的正文)。两者的校验与执行
6
- // 都复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
7
- // Issue #533 起 differentiate 不再改写正文,只落裁决账本——见 applyDifferentiate。
8
- // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
9
- export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
10
-
11
- // Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
12
- // keepSource and conflict winners prefer the higher-trust memory. Higher value
13
- // = preferred. observation (measured) > inferred (derived) > subjective (guess).
14
- export const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
15
-
16
- // Issue #104 方向 2:archive 类型护栏的长保留类型集合——这些类型的存在意义
17
- // 就是长期保存(踩坑记录、约束、用户偏好、挖掘出的模式),LLM 以「价值判断」
18
- // 类理由批量归档它们时误伤损失最大。conflict 裁决的败者归档不经过 archive
19
- // action(走 applyDecisions 的 conflict 分支),不受此护栏影响。
20
- const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_solution", "constraint", "pitfall"]);
21
- // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
22
- // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
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
- }
96
-
97
- /**
98
- * Validate a dream decision list against a snapshot of eligible memories.
99
- * @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
100
- * entries are spliced out of this array in place (the caller reuses the same
101
- * reference downstream for apply/audit); implicit keeps are appended here too.
102
- * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
103
- * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>,
104
- * resolvedShortIds?: number}}
105
- */
106
- export function validateDecisions(decisions, snapshot, options = {}) {
107
- const errors = [];
108
- // Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
109
- // 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
110
- // 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
111
- // degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
112
- // 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
113
- const skipped = [];
114
- const survivors = [];
115
- const skipInvalid = options.skipInvalid === true;
116
- const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
117
- const minAgeHours = options.minAgeHours ?? 24;
118
- // Issue #126 review(Copilot):档位白名单。sleepActionSet 默认档只换 prompt 是
119
- // 不够的——ACTIONS 是两个模块共用的,模型自发出 supersede / differentiate 时校验器
120
- // 照样放行,"opt-in 零行为变化"就成了空话。省略 / null = 不限制(沿用全局 ACTIONS)。
121
- const allowedActions = Array.isArray(options.allowedActions) ? new Set(options.allowedActions) : null;
122
- // reflectionUpdateEnabled=false:update 在本次调用里不算合法动作。走与
123
- // allowedActions 同一条单条跳过的通路(不是整单拒绝)——用户关掉的是「改写已有
124
- // 记忆」这一种动作,模型多输出了几条 update 不该连带丢掉同批的 merge/archive。
125
- // 与 prompt 侧后缀双闸:prompt 让模型别写,这里兜住模型仍然写了的情况。
126
- const allowUpdate = options.allowUpdate !== false;
127
- // 被用户开关关掉的动作单独计数:它们进 skipped(要留痕),但不该触发下面的
128
- // 「全部跳过 = 模型坏了」整单拒绝——那是防幻觉 id / 截断输出的护栏,而这里是
129
- // 用户自己关掉的动作。混在一起会让「只输出了 update」的一轮被判 failed、调度器
130
- // 拿同一批输入重跑同一条必被拒的输出,无限循环。
131
- let policySkipped = 0;
132
- if (!Array.isArray(decisions)) {
133
- return { ok: false, errors: ["decision list must be an array"], resolvedShortIds: 0 };
134
- }
135
- // 空决策 = 模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 明确允许"无问题的
136
- // 条目无需输出")。合法 JSON [] 不是空体(那是无输出/截断),也不是"残缺输出"
137
- // ——显式短路直接 ok,避免隐式 keep 的覆盖率检查把 0% 误判为模型坏了。与
138
- // sleep 的空模式(skipped no-op)语义对齐:下游 applied=0、audit 记 ok。
139
- if (decisions.length === 0) {
140
- return { ok: true, errors: [], skipped: [], resolvedShortIds: 0 };
141
- }
142
- // Issue #135:模型常把 36 位 UUID 回填成前缀(实测 78/78 个 8 位前缀都能唯一
143
- // 对应窗口内真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写),而
144
- // 下方校验用 snapshot.get(id) 整串精确匹配 → 全部 unknown id → claimed=0 →
145
- // 覆盖率闸整单拒绝,一整轮语义成果被丢。校验前先把「唯一前缀」解析回完整 id
146
- // (git 短哈希式):仅在 snapshot 内解析,有歧义或匹配不到就原样保留,仍由下
147
- // 方 unknown id 校验如实报错。就地改写 ids / keepSource / winner / loser,下
148
- // 游 applyDecisions 与审计拿到的同样是完整 id。
149
- let resolvedShortIds = 0;
150
- const resolveShortId = (value) => {
151
- if (typeof value !== "string" || snapshot.has(value)) return value;
152
- // 太短不猜(歧义概率高);长度 ≥36 说明不是前缀缩写,交给校验如实报错。
153
- if (value.length < 8 || value.length >= 36) return value;
154
- let hit = null;
155
- let matches = 0;
156
- for (const id of snapshot.keys()) {
157
- if (id.startsWith(value)) {
158
- hit = id;
159
- if (++matches > 1) return value; // 有歧义,不解析
160
- }
161
- }
162
- if (matches === 1) {
163
- resolvedShortIds++;
164
- return hit;
165
- }
166
- return value;
167
- };
168
- for (const d of decisions) {
169
- if (!d || typeof d !== "object") continue;
170
- if (Array.isArray(d.ids)) d.ids = d.ids.map(resolveShortId);
171
- if (typeof d.keepSource === "string") d.keepSource = resolveShortId(d.keepSource);
172
- if (typeof d.winner === "string") d.winner = resolveShortId(d.winner);
173
- if (typeof d.loser === "string") d.loser = resolveShortId(d.loser);
174
- }
175
- const claimed = new Set();
176
- for (const [index, d] of decisions.entries()) {
177
- const at = `decision[${index}]`;
178
- // 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
179
- // 全局 errors(沿用 v0.6.9 的双轨结构)。
180
- const local = [];
181
- let policyDisabled = false;
182
- // Issue #126:supersede 与 conflict 一样用 winner/loser 定位双方(沿用同一套
183
- // 字段,校验与 receipt 逻辑因此可以直接复用)。
184
- const ids = d && (d.action === "conflict" || d.action === "supersede") ? [d.winner, d.loser] : (d?.ids ?? []);
185
- if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
186
- local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
187
- } else if (allowedActions && !allowedActions.has(d.action)) {
188
- // 动作本身合法,但不在当前档位允许的集合里(例如默认档收到 supersede)。
189
- local.push(`${at}: action ${JSON.stringify(d.action)} not allowed in this mode`);
190
- } else if (d.action === "update" && !allowUpdate) {
191
- // reflectionUpdateEnabled=false:旧记忆永不被自动改写。这条要排在
192
- // allowedActions 之后、update 字段校验之前——先判「这个动作本次不许用」,
193
- // 再谈字段合不合规,否则同一条会同时报两个错、日志里读不出真实原因。
194
- local.push(`${at}: update action is disabled (reflectionUpdateEnabled=false)`);
195
- policyDisabled = true;
196
- } else if (d.action === "conflict" || d.action === "supersede") {
197
- if (!d.winner || !d.loser || d.winner === d.loser) {
198
- local.push(`${at}: ${d.action} needs distinct winner and loser`);
199
- }
200
- } else if (d.action === "differentiate") {
201
- // Issue #126:互补型——两条都留下。ids 必须 ≥2(随后走下方通用 id 校验:
202
- // unknown / archived / summary / 重复 claim)。distinctions 仍然必填:#533 之后
203
- // 它不再写进正文,但仍是「模型确实做过分类」的唯一凭据,落进裁决账本供审计
204
- // (空数组意味着模型只是偷懒输出了一条动作名,不该被记成已裁决)。
205
- if (!Array.isArray(d.ids) || d.ids.length < 2) {
206
- local.push(`${at}: differentiate needs at least two ids`);
207
- } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
208
- local.push(`${at}: differentiate needs a non-empty distinctions array`);
209
- }
210
- } else if (d.action === "create") {
211
- // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
212
- // so it skips the claiming loop below; evidence is optional provenance
213
- // (already filtered to real ids by the caller) and is stored in content.
214
- if (typeof d.title !== "string" || !d.title.trim()) {
215
- local.push(`${at}: create needs non-empty title`);
216
- }
217
- if (typeof d.content !== "string" || !d.content.trim()) {
218
- local.push(`${at}: create needs non-empty content`);
219
- }
220
- if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
221
- local.push(`${at}: create importance must be an integer 1-5 when provided`);
222
- }
223
- if (typeof d.type !== "string" || !d.type.trim()) {
224
- local.push(`${at}: create needs non-empty type`);
225
- }
226
- } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
227
- local.push(`${at}: ${d.action} needs non-empty ids`);
228
- }
229
- // update-specific field validation runs BEFORE claiming ids, so a failing
230
- // update never pollutes the claimed set (which drives the "every id must
231
- // appear in a decision" check below).
232
- // Issue #104 方向 2:archive 类型护栏。长保留类型的 archive 决策必须携带
233
- // 「重复 / 过时」类理由(提示词对 archive 的定义本就如此),「价值判断」类
234
- // 理由(保持整洁、参考价值低……)不放行——单条跳过记 skipped(run 记
235
- // degraded,明细进 dream_runs.skipped),绝不整单拒绝。未知 id 在下方通用
236
- // 校验里另行报错,这里 snapshot.get 为 undefined 时跳过护栏判断。
237
- if (local.length === 0 && d?.action === "archive") {
238
- const guardedTypes = new Set();
239
- for (const id of d.ids) {
240
- const mem = snapshot.get(id);
241
- if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
242
- }
243
- if (guardedTypes.size > 0 && !ARCHIVE_RATIONALE_RE.test(String(d.reason ?? ""))) {
244
- 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))}`);
245
- }
246
- }
247
- if (local.length === 0 && d?.action === "update") {
248
- // 只能更新单条
249
- if (!Array.isArray(d.ids) || d.ids.length !== 1) {
250
- local.push(`${at}: update must target exactly one id`);
251
- } else {
252
- // 必须产生实际变化
253
- const mem = snapshot.get(d.ids[0]);
254
- const hasChange = (d.title !== undefined && d.title !== mem?.title)
255
- || (d.content !== undefined && d.content !== mem?.content)
256
- || (d.importance !== undefined && d.importance !== mem?.importance);
257
- if (!hasChange) {
258
- local.push(`${at}: update must change at least one field`);
259
- } else if (mem?.type === "summary") {
260
- // 不能更新 summary
261
- local.push(`${at}: cannot update summary via update action`);
262
- } else {
263
- // 保护期:新建记忆不可立即被 update(可配置)
264
- const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
265
- if (ageHours < minAgeHours) {
266
- local.push(`${at}: memory too young (< ${minAgeHours}h)`);
267
- }
268
- }
269
- }
270
- }
271
- if (local.length === 0 && d && d.action !== "create") {
272
- // seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
273
- // (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
274
- const seen = new Set();
275
- for (const id of ids) {
276
- const mem = snapshot.get(id);
277
- if (!mem) {
278
- local.push(`${at}: unknown id ${JSON.stringify(id)}`);
279
- } else if (mem.archived || mem.type === "summary") {
280
- local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
281
- }
282
- if (claimed.has(id) || seen.has(id)) {
283
- local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
284
- }
285
- seen.add(id);
286
- }
287
- if (local.length === 0 && d.action === "merge") {
288
- if (!d.keepSource || !d.ids.includes(d.keepSource)) {
289
- local.push(`${at}: merge keepSource must be one of ids`);
290
- }
291
- if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
292
- local.push(`${at}: merge needs non-empty title and content`);
293
- }
294
- if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
295
- local.push(`${at}: merge importance must be an integer 1-5 when provided`);
296
- }
297
- // Merging across types would blur preference/project/decision boundaries
298
- // in the injected context; the snapshot carries each entry's type.
299
- // Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
300
- // 后放宽该检查,类型边界由用户自行承担。
301
- const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
302
- if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
303
- local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
304
- }
305
- // issue #339 / E8 考卷:merge 护栏(默认开,dreamMergeGuard)。巩固损耗
306
- // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive
307
- // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是
308
- // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的
309
- // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped
310
- // 明细、严格模式整单拒绝)。
311
- if (local.length === 0 && options.mergeGuard === true) {
312
- const guardedTypes = new Set();
313
- for (const id of d.ids) {
314
- const mem = snapshot.get(id);
315
- if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
316
- }
317
- // Issue #521:与上方 archive 护栏对齐——archive 早就允许「带重复/过时
318
- // 理由」的长保留类型通过,merge 却一律拦死,于是一份真重复的
319
- // preference 永远合不掉,而提示词还在催模型合并它们。实测(#521)46 轮
320
- // 巩固里 280 条 merge 决策全被这条拦下、merge 恒为 0,模型每轮把同一
321
- // 簇条目再提一次,满载 token 空转。逃生门条件与 archive 护栏同一条词表
322
- // 且要求同类型(跨类型合并本就另受 allowCrossTypeMerge 约束,逃生门
323
- // 不跟着放宽)。reason 只写「主题相近」不会命中词表——这是故意的:
324
- // 相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。
325
- const sameType = new Set(d.ids.map((id) => snapshot.get(id)?.type)).size === 1;
326
- const reasonText = String(d.reason ?? "");
327
- // #522 评审:词表命中还不够——「并不重复」「not a duplicate」也含关键词,
328
- // 放它们过闸等于让模型用一句否认换来合并许可。否定表述直接判不重复。
329
- const duplicated = !NEGATED_RATIONALE_RE.test(reasonText) && ARCHIVE_RATIONALE_RE.test(reasonText);
330
- if (guardedTypes.size > 0 && !(sameType && duplicated)) {
331
- local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard (needs a duplicate/outdated rationale, 重复/过时)`);
332
- }
333
- }
334
- }
335
- }
336
- if (local.length > 0) {
337
- if (skipInvalid) {
338
- // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
339
- // keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
340
- // errors,否则会误触发下方的整单拒绝。
341
- //
342
- // #534:除四键骨架外补三个「模型到底给没给」的布尔,把 skipped 从
343
- // 「error 字符串」变成可计数的形状。作者实测 19 轮 17 轮整轮无效,但
344
- // skipped 里只有一句错误串,事后无法判断模型是漏字段(merge 缺
345
- // title/content)、还是字段给了但值不合规(has_* 为 true 仍报错)。
346
- // 只加布尔不加原文:原文进 llm_audit_logs.metadata 与 dream_runs.raw_output,
347
- // 这里保持每条记录足够小(skipped 会按轮累积进一行 JSON)。
348
- if (policyDisabled) policySkipped++;
349
- skipped.push({
350
- index,
351
- action: d?.action,
352
- ids,
353
- error: local.join("; "),
354
- // true = 用户开关关掉了这个动作(不是模型输出有问题)。调用方据此区分
355
- // 「这轮白跑」与「这轮输出坏了」。
356
- ...(policyDisabled ? { policyDisabled: true } : {}),
357
- has_keepSource: d?.keepSource !== undefined && d?.keepSource !== null,
358
- has_title: typeof d?.title === "string" && d.title.trim().length > 0,
359
- has_content: typeof d?.content === "string" && d.content.trim().length > 0
360
- });
361
- } else {
362
- errors.push(...local);
363
- }
364
- continue;
365
- }
366
- if (d.action !== "create") {
367
- for (const id of ids) claimed.add(id);
368
- }
369
- survivors.push(d);
370
- }
371
- // Cap update churn: too many edits in one cycle signals a runaway model.
372
- // 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
373
- const updateCount = survivors.filter((d) => d.action === "update").length;
374
- if (updateCount > maxUpdatePerRun) {
375
- errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
376
- }
377
- // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
378
- const createCount = survivors.filter((d) => d.action === "create").length;
379
- const maxCreatePerRun = options.maxCreatePerRun ?? 5;
380
- if (createCount > maxCreatePerRun) {
381
- errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
382
- }
383
- // Issue #104 方向 2:archive 批量上限——一次性大扫除(12 条互不相关主题被
384
- // 批量归档)是失控信号,与 update 上限同一类全局闸门:skipInvalid 也不豁免,
385
- // 超限整单拒绝;正常的大规模清理由用户调高 dreamMaxArchivePerRun。
386
- const archiveCount = survivors.filter((d) => d.action === "archive").length;
387
- const maxArchivePerRun = options.maxArchivePerRun ?? 8;
388
- if (archiveCount > maxArchivePerRun) {
389
- errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
390
- }
391
- // merge 批量上限(#135 成本审计的派生护栏)。merge 是唯一**回吐正文**的动作:
392
- // 每条 merge 都要求模型重新写出合并后的 content(实测 2113 条 merge 平均 550
393
- // 字符/条),而 archive/keep/conflict 都不带正文——所以单轮 merge 数是「输出
394
- // 里正文占比」唯一可控的杠杆(实测 dream_consolidate 的输出 token 只有约 11.8%
395
- // 是决策正文,其余是推理与 JSON 骨架,那部分由 effort 决定、与本闸无关)。
396
- // 与 update/create/archive 三道闸同类:全局闸门,skipInvalid 也不豁免,超限整单
397
- // 拒绝。默认值取 40 的校准依据(只读探针,全库 246 轮 / 近 10 天 88 轮):
398
- // 每轮 merge 数 全库 p50 7 / p90 15 / p99 28 / max 39,近 10 天 p50 9 / p90 20 /
399
- // p99 39 / max 39 —— 40 是观测分布的上沿(历史命中 0/246),与 archive 闸默认 8
400
- // 恰为其 p90=max 同一校准法:这是面向未来的尾险护栏,不是成本削减工具(现有三
401
- // 道闸在实测分布下命中率均为 0.0%,同一性质)。刻意不取更小的值:超限的代价
402
- // 不对称——整单拒绝 = run 记 failed = 调度器不刷新 baseline(src/dream.js:905-915)
403
- // = 下一次写入用同一批输入重跑,一次误伤约等于该轮成本 ×2;而探针里 merge≥15 的
404
- // 大轮正文/显式决策比例都正常(10~22%),没有失控特征。想用覆盖换成本的用户可
405
- // 自行调低 dreamMaxMergePerRun。
406
- const mergeCount = survivors.filter((d) => d.action === "merge").length;
407
- const maxMergePerRun = options.maxMergePerRun ?? 40;
408
- if (mergeCount > maxMergePerRun) {
409
- errors.push(`too many merge decisions: ${mergeCount} > ${maxMergePerRun}`);
410
- }
411
- // Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
412
- // 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
413
- // 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
414
- // 「有合法子集但覆盖率不足」的良性挑重点(见下方 coverageShortfall)。
415
- // 只把「非策略性跳过」算进这条:policySkipped 是用户关掉的动作,不是模型故障。
416
- const modelSkipped = skipped.length - policySkipped;
417
- if (options.skipInvalid === true && modelSkipped > 0 && survivors.length === 0 && errors.length === 0) {
418
- errors.push(`all ${skipped.length} decision(s) invalid — nothing valid survived validation`);
419
- return { ok: false, errors, skipped, resolvedShortIds };
420
- }
421
- // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
422
- // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
423
- // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
424
- // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
425
- // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
426
- //
427
- // v0.4.4 fix(残缺输出防洗白)→ Issue #104 方向 1 重审:显式覆盖率护栏的
428
- // 初衷是拦「输出截断 → 只 claim 少量 → 其余被隐式 keep 洗白」,但它无法区分
429
- // 恶性截断与良性挑重点——而提示词硬性规则本身要求「无问题的条目无需输出」,
430
- // 挑重点正是要求的行为。官方路由 + 强模型的实测(heptaspirit:81 轮 failed
431
- // 全是 coverage,LLM 调用本身成功、applied=0)证明这是常规路径而非边角。
432
- // 处置:覆盖率不足不再 ok:false 整单拒绝,改为返回 coverageShortfall 理由、
433
- // 照常补齐隐式 keep——调用方把 run 记 degraded(合法子集已应用),护栏以
434
- // 状态暴露而非一票否决,与 skipInvalid 同一宽容哲学。明确异常信号(create
435
- // 上限等硬错误)保持整单拒绝。要恢复旧行为可设 dreamImplicitKeep:false
436
- // (严格模式,缺失即 error)或 dreamMinExplicitCoverage: 0(关护栏)。
437
- // 非覆盖类硬错误(create 上限溢出、严格模式缺失 id)仍然先整单拒绝——
438
- // #104 方向 1 只豁免 coverage 这一个信号,防洗白语义对它们原样保留。
439
- if (errors.length > 0) {
440
- return { ok: false, errors, skipped, resolvedShortIds };
441
- }
442
- const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
443
- let coverageShortfall = null;
444
- if (options.dreamImplicitKeep !== false) {
445
- const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
446
- if (coverage < minCoverage) {
447
- coverageShortfall = `explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`;
448
- }
449
- for (const id of snapshot.keys()) {
450
- if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
451
- }
452
- } else {
453
- for (const id of snapshot.keys()) {
454
- if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
455
- }
456
- if (errors.length > 0) return { ok: false, errors, skipped, resolvedShortIds };
457
- }
458
- // 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
459
- // 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
460
- // 不能以 survivors.length !== decisions.length 作为是否 splice 的判据:
461
- // 当"被跳过的非法决策数 == 隐式补齐的 keep 数"时长度回到相等但内容已变,
462
- // 被跳过的决策会残留进 apply/audit。一律无条件 splice 最安全。
463
- decisions.splice(0, decisions.length, ...survivors);
464
- return { ok: true, errors, skipped, resolvedShortIds, coverageShortfall };
465
- }
466
-
467
- /** Marker thrown when a decision target changed since the run snapshot. */
468
- export class CasConflictError extends Error {
469
- constructor(action, ids, reason) {
470
- super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
471
- this.name = "CasConflictError";
472
- this.action = action;
473
- this.ids = ids;
474
- }
475
- }
476
-
477
- function decisionIds(d) {
478
- return d.action === "conflict" || d.action === "supersede" ? [d.winner, d.loser] : (d.ids ?? []);
479
- }
480
-
481
- /**
482
- * CAS guard (item ①): every target memory must still match what the run
483
- * snapshot captured — otherwise the decision was computed against stale state
484
- * and applying it would overwrite a concurrent edit. Snapshotless replays skip
485
- * the guard entirely (per-action idempotency checks handle those). Throws
486
- * CasConflictError on the first mismatch; the caller's transaction rolls back.
487
- */
488
- function casGuard(service, snapshot, ids) {
489
- if (!snapshot) return;
490
- for (const id of ids) {
491
- const expect = snapshot.get(id);
492
- if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
493
- const current = service.getById(id);
494
- if (!current) {
495
- throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
496
- }
497
- const changed = expect.updated_at !== undefined
498
- ? current.updated_at !== expect.updated_at
499
- : current.content !== expect.content || current.title !== expect.title;
500
- if (changed) {
501
- throw new CasConflictError(
502
- "changed",
503
- [id],
504
- `memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
505
- );
506
- }
507
- }
508
- }
509
-
510
- /**
511
- * Apply a validated decision list to the service. Caller must validate first.
512
- * Each decision runs inside its own SQLite transaction (item ②): the multi-step
513
- * mutation of a decision is atomic, so a merge can never leave "keeper updated
514
- * but source not archived" or vice versa — a throwing sub-step rolls the whole
515
- * decision back.
516
- *
517
- * @param decisions - validated decision list.
518
- * @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
519
- * @param logger - optional logger ({ warn }); per-decision failures are logged.
520
- * @param snapshot - optional Map<id, memory> captured before the LLM call; when
521
- * provided, every decision target is CAS-checked against it and a decision
522
- * computed from stale state is skipped and reported as a conflict instead of
523
- * overwriting concurrent writes (item ①).
524
- * @param config - pipeline config; `config.mintSource` (optional string) is the
525
- * provenance stamped on memories minted by `create` decisions. It is
526
- * read from the CALLER, never from the decision object: the LLM authors
527
- * `create` payloads and validateDecisions does not strip unknown fields, so
528
- * trusting `d.source` would let a model label its own output as human-written.
529
- * Absent/blank → saveWithDedupe's existing "manual" fallback (unchanged).
530
- * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
531
- * applied - number of decisions/memories actually committed (archive counts
532
- * each archived memory as one, merge/conflict/update count one).
533
- * conflicts - decisions skipped because a target changed since the snapshot.
534
- * failures - decisions that threw mid-transaction (fully rolled back).
535
- * committed - the decisions that actually landed, for outcome/receipt based
536
- * on real committed sub-steps rather than the raw LLM list.
537
- */
538
- export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
539
- let applied = 0;
540
- let merged = 0;
541
- const conflicts = [];
542
- const failures = [];
543
- const committed = [];
544
- for (const [i, d] of decisions.entries()) {
545
- try {
546
- // keep is a confirmed no-op: it commits nothing but still records the
547
- // per-id disposition so the outcome covers every snapshot memory.
548
- if (d.action === "keep") {
549
- committed.push({ action: "keep", ids: d.ids });
550
- continue;
551
- }
552
- const outcome = applyOne(d, service, snapshot, config, logger);
553
- if (outcome === "skipped") continue;
554
- applied += outcome.applied;
555
- // `merged` counts creates that hit a same-title row instead of minting a
556
- // new one. Kept apart from `applied` on purpose: a create that vanished
557
- // into an existing row is not a new conclusion, and folding it in is how
558
- // two different dispositions ended up looking like two minted patterns
559
- // (#619 audit round 5).
560
- if (outcome.merged) merged += outcome.merged;
561
- committed.push(outcome.committed);
562
- } catch (error) {
563
- if (error instanceof CasConflictError) {
564
- conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
565
- logger?.warn?.(`dsh-mneme dream: ${error.message}`);
566
- } else {
567
- failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
568
- logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
569
- }
570
- }
571
- }
572
- return { applied, merged, conflicts, failures, committed };
573
- }
574
-
575
- function applyOne(d, service, snapshot, config = {}, logger = null) {
576
- switch (d.action) {
577
- case "archive": return applyArchive(d, service, snapshot);
578
- case "merge": return applyMerge(d, service, snapshot, config);
579
- case "conflict": return applyConflict(d, service, snapshot, config);
580
- case "supersede": return applySupersede(d, service, snapshot, config);
581
- case "differentiate": return applyDifferentiate(d, service, snapshot, config);
582
- case "create": return applyCreate(d, service, config, logger);
583
- default: return applyUpdate(d, service, snapshot, config);
584
- }
585
- }
586
-
587
- /**
588
- * Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
589
- * `preferred`). Archived memories are never eligible keepers — promoting one
590
- * would demote the real keepSource to a source and then hit the archived-keeper
591
- * guard in applyMerge, silently skipping the whole merge. When `preferred`
592
- * itself is archived (or missing), fall back to any unarchived candidate.
593
- */
594
- function pickBestKeeper(ids, preferred, service) {
595
- let best = null;
596
- let bestP = -1;
597
- for (const id of ids) {
598
- const mem = service.getById(id);
599
- if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
600
- const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
601
- if (p > bestP || (p === bestP && id === preferred)) {
602
- bestP = p;
603
- best = id;
604
- }
605
- }
606
- return best ?? preferred;
607
- }
608
-
609
- /**
610
- * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
611
- * Evidence ids ride in the content so a pattern stays traceable to its source
612
- * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
613
- */
614
- function applyCreate(d, service, config = {}, logger = null) {
615
- const language = langOf(config);
616
- const title = String(d.title ?? "").trim();
617
- const content = String(d.content ?? "").trim();
618
- const importance = Number.isInteger(d.importance) ? d.importance : 3;
619
- const type = typeof d.type === "string" ? d.type : "pattern";
620
- const evidence = Array.isArray(d.evidence)
621
- ? d.evidence.filter((id) => typeof id === "string")
622
- : [];
623
- const body = evidence.length > 0
624
- ? STR.evidenceSuffix[language](content, evidence)
625
- : content;
626
- // mint 来源由调用方(管道)给,不从决策对象取——LLM 能自己写 d.source,
627
- // 信它就等于让模型给自造条目盖「人写的」戳(见 applyDecisions 的 @param config)。
628
- // 取不到就一个字段都不传,落回 saveWithDedupe 原有的兜底,行为与改前一致。
629
- const mintSource = typeof config?.mintSource === "string" ? config.mintSource.trim() : "";
630
- // pattern 是「整句重写」而不是「增量补充」:同一 (人, 对象, 情绪) 的标题天然
631
- // 相同,saveWithDedupe 命中后若走 appendContent,重放一轮就把同一段结论再粘
632
- // 一遍(实测 161 → 355 → 549 字,外加一串重复的 [证据: …] 尾巴),而 pattern
633
- // 正是注入面消费的东西。`_overwrite` 是既有逃生口(bootstrap / dream summary /
634
- // narrative 三处先例):命中同题行时原地刷新而不是追加,旧文进 content_history。
635
- // 只对 pattern 放开——其他类型被 create 撞上同题行时仍按合并语义走,那多半是
636
- // 模型自造了不该出现的类型,把既有行整段覆盖掉比追加更危险。
637
- const overwrite = type === "pattern";
638
- const created = service.saveWithDedupe({
639
- type,
640
- title,
641
- content: body,
642
- importance,
643
- ...(mintSource ? { source: mintSource } : {}),
644
- ...(overwrite ? { _overwrite: true } : {})
645
- });
646
- const memory = created?.memory;
647
- if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
648
- // 命中同题活跃行 ⇒ saveWithDedupe 返回 action:"merged"。它确实动过库(刷新了
649
- // 那一行),但**没有铸出新条目**:把它算进 applied 会让「一条真实结论被同题行
650
- // 吃掉了」这种情况看起来像成功(#619 审计第五轮:两个不同对象铸出同一条
651
- // pattern,applied 照样报 2)。两类结局分开报:applied 只数新铸,merged 数刷新。
652
- const refreshed = created?.action !== "created";
653
- // #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
654
- // 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
655
- //
656
- // #619 / Ephor 第七轮 ③:`refreshed` 也在这条规矩里。原地刷新没有铸新条目,
657
- // 它改的是既有行——同一条记忆不该有两份「出生记录」。此前 create_merged 这条
658
- // 新路没被上面那句规矩覆盖,于是一个 pattern 跑两轮,`memory_mints` 就有两行、
659
- // run_id 各不相同,面板上显示成「这条记忆出生过两次」(`mintsFor` 由
660
- // provenance-http.js 读取)。live 库当时还没显形,是因为同题刷新刚上线。
661
- //
662
- // 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
663
- try {
664
- if (!refreshed && typeof service?.saveMemoryMint === "function" && config?.runId) {
665
- service.saveMemoryMint({
666
- memoryId: memory.id,
667
- runId: config.runId,
668
- runType: config.mintSource === "sleep" ? "sleep" : "dream",
669
- triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
670
- createdAt: new Date().toISOString()
671
- });
672
- }
673
- } catch (mintError) {
674
- logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
675
- }
676
- return { applied: refreshed ? 0 : 1, merged: refreshed ? 1 : 0, committed: { action: refreshed ? "create_merged" : "create", id: memory.id, type } };
677
- }
678
-
679
- function applyArchive(d, service, snapshot) {
680
- const targets = d.ids.filter((id) => {
681
- const mem = service.getById(id);
682
- return mem && !mem.archived; // existing, not-yet-archived rows only
683
- });
684
- if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
685
- service.transaction(() => {
686
- casGuard(service, snapshot, d.ids);
687
- for (const id of d.ids) {
688
- const mem = service.getById(id);
689
- if (mem && !mem.archived) service.setArchived(id, true);
690
- }
691
- });
692
- return { applied: targets.length, committed: { action: "archive", ids: targets } };
693
- }
694
-
695
- function applyMerge(d, service, snapshot, config = {}) {
696
- // Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
697
- // subjective/inferred one. Mutating the decision keeps the receipt + committed
698
- // record aligned with the actual keeper.
699
- if (config.trustEpistemicWeighting === true) {
700
- const best = pickBestKeeper(d.ids, d.keepSource, service);
701
- if (best && best !== d.keepSource) d.keepSource = best;
702
- }
703
- const sources = d.ids.filter((id) => id !== d.keepSource);
704
- // Idempotent replay: if every other source is already archived, this merge
705
- // already landed — skip so a replayed/concurrent decision never double-counts
706
- // or re-applies (guard against duplicate merges).
707
- if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
708
- service.transaction(() => {
709
- casGuard(service, snapshot, d.ids);
710
- const keeper = service.getById(d.keepSource);
711
- if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
712
- service.update(d.keepSource, {
713
- title: d.title,
714
- content: d.content,
715
- importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
716
- });
717
- for (const id of sources) {
718
- const mem = service.getById(id);
719
- if (mem && !mem.archived) service.setArchived(id, true);
720
- }
721
- // 4.3.2 迁移实体关联(opt-in):将 loser(source)记忆关联的 entity_attrs 的
722
- // memory_id 迁移到 keeper;keeper 已有同 entity+key 的当前属性时 loser 行被
723
- // 失效。单个 source 迁移失败只告警,绝不能导致整个 merge 事务回滚(fail-safe)。
724
- if (config.entityExtractionEnabled && typeof service.migrateAttrsToMemory === "function") {
725
- for (const id of sources) {
726
- try {
727
- service.migrateAttrsToMemory(id, d.keepSource, new Date().toISOString());
728
- } catch (error) {
729
- logger?.warn?.(`dsh-mneme dream: failed to migrate attrs from ${id} to ${d.keepSource}: ${error.message}`);
730
- }
731
- }
732
- }
733
- });
734
- return {
735
- applied: 1,
736
- 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 }
737
- };
738
- }
739
-
740
- function applyConflict(d, service, snapshot, config = {}) {
741
- const language = langOf(config);
742
- // Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
743
- // is preferred as winner over a subjective/inferred one.
744
- if (config.trustEpistemicWeighting === true) {
745
- const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
746
- const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
747
- if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
748
- }
749
- const winner = service.getById(d.winner);
750
- const loser = service.getById(d.loser);
751
- if (!winner || !loser) return "skipped";
752
- // Idempotent replay: an already-archived loser means the conflict was already
753
- // adjudicated — skip so the provenance note is never re-appended and the loser
754
- // is not re-archived.
755
- if (loser.archived) return "skipped";
756
- service.transaction(() => {
757
- casGuard(service, snapshot, [d.winner, d.loser]);
758
- const winnerNow = service.getById(d.winner);
759
- const loserNow = service.getById(d.loser);
760
- if (!winnerNow || !loserNow || loserNow.archived) return;
761
- service.update(d.winner, {
762
- content: STR.supersededSuffix[language](winnerNow.content, [...loserNow.content].slice(0, 100).join(""))
763
- });
764
- service.setArchived(d.loser, true);
765
- });
766
- return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
767
- }
768
-
769
- /**
770
- * 确定性信息量优先 supersede(issue #641 enforce 档的 T1 处置,供 sleep/dream
771
- * 两条链路共用)。与冻结分支自动裁决档(#603 第 4 层,sleep.js 内联实现)同一
772
- * 条契约,本函数是它的共享出口:
773
- * · 保留**信息量更大**的那条,不是更新的——#616 实测 55-57% 的新条反而更短
774
- * (蒸馏压缩过的一版常常更新),留新条等于把更完整的那条归档掉;
775
- * · 长度相同才退回 updated_at,再退回 id——三级兜底保证同一对每次判出同一个
776
- * 赢家(重放不翻面);
777
- * · trustEpistemicWeighting 显式关:自动处置的承诺必须是确定的,可信度换位
778
- * 不让结果随 epistemic_status 数据漂移;
779
- * · 赢家正文一字不动——supersede 的可追溯注记落在被归档侧(#126 原设计)。
780
- * E13b 证明 conflict 动作的「败者前 100 字追加进赢家」是值级泄漏通道,
781
- * 确定性处置一律走 supersede、不走 conflict。
782
- * CAS 快照 + 事务 + 幂等(败者已归档 → applied 0)全部沿用 applyDecisions,
783
- * 不另写第二份裁决路径。
784
- *
785
- * @param {{a: object, b: object}} p 候选对(完整记忆行:id/title/content/updated_at)
786
- * @returns {{applied: boolean, winnerId: string, loserId: string, winner: object, loser: object}}
787
- */
788
- export function supersedeInfoFirstPair(p, service, logger = null, config = {}) {
789
- const an = p.a.content?.length ?? 0;
790
- const bn = p.b.content?.length ?? 0;
791
- const [winner, loser] = an !== bn
792
- ? (an > bn ? [p.a, p.b] : [p.b, p.a])
793
- : (p.a.updated_at ?? "") !== (p.b.updated_at ?? "")
794
- ? ((p.a.updated_at ?? "") > (p.b.updated_at ?? "") ? [p.a, p.b] : [p.b, p.a])
795
- : (p.a.id > p.b.id ? [p.a, p.b] : [p.b, p.a]);
796
- const snapshot = new Map();
797
- for (const m of [winner, loser]) {
798
- snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
799
- }
800
- const { applied } = applyDecisions(
801
- [{ action: "supersede", winner: winner.id, loser: loser.id }],
802
- service, logger, snapshot, { ...config, trustEpistemicWeighting: false }
803
- );
804
- return { applied: applied > 0, winnerId: winner.id, loserId: loser.id, winner, loser };
805
- }
806
-
807
- /**
808
- * supersede(Issue #126,sleepActionSet="full"):演进型重复——新版本取代旧版本。
809
- * 与 conflict 的关键差别:**注记追加到 loser(被取代方)而不是 winner**。conflict
810
- * 把"已否决旧信息"写进赢家正文,赢家因此被污染——这正是 #126 的原始抱怨;
811
- * supersede 把可追溯性留在被归档的那一侧,赢家正文一字不动。
812
- *
813
- * 幂等:loser 已归档即视为该决策已落地(重放/并发不会重复追加注记或重复归档)。
814
- */
815
- function applySupersede(d, service, snapshot, config = {}) {
816
- const language = langOf(config);
817
- // Epistemic trust(与 conflict 同款):可信度更高的一侧优先作为取代方。
818
- if (config.trustEpistemicWeighting === true) {
819
- const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
820
- const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
821
- if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
822
- }
823
- const winner = service.getById(d.winner);
824
- const loser = service.getById(d.loser);
825
- if (!winner || !loser) return "skipped";
826
- if (loser.archived) return "skipped"; // 幂等重放:已经取代过了
827
- service.transaction(() => {
828
- casGuard(service, snapshot, [d.winner, d.loser]);
829
- const winnerNow = service.getById(d.winner);
830
- const loserNow = service.getById(d.loser);
831
- if (!winnerNow || !loserNow || loserNow.archived) return;
832
- service.update(d.loser, {
833
- content: STR.supersededBySuffix[language](loserNow.content, winnerNow.title)
834
- });
835
- service.setArchived(d.loser, true);
836
- });
837
- return { applied: 1, committed: { action: "supersede", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
838
- }
839
-
840
- /**
841
- * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
842
- * 覆盖不同侧面,不该判输赢。双方都保留(不归档),**正文一个字节都不动**。
843
- *
844
- * 为什么不动正文(Issue #533):#126 的原设计是「双方各追加同一段差异注记,注记
845
- * 进正文 → 进注入与 embedding → 下一轮就不会再被判成重复」。离线复现证伪了这个
846
- * 假设——两侧共享同一段后缀会让两条向量**同向靠拢**,余弦一轮比一轮高(0.876 →
847
- * 0.960 只用了 5 轮,把注记截到 0 字照样往上爬,因为推高相似度的是共享前缀本身而
848
- * 不是注记长短),于是同一对下一轮又被判成重复、又追加一段,实测单条堆到 19 段。
849
- * 现在的裁决落在 differentiated_pairs 账本(src/store.js)里:候选构造阶段读它,
850
- * 已裁决且两侧正文都没改过就不再送模型,正文保持干净。
851
- *
852
- * 幂等三层,从便宜到贵:
853
- * 1. 一侧已归档 → skipped(缺一条就没什么可区分的);
854
- * 2. 存量兼容:两侧正文都还带着旧版注记块 → skipped(升级不清账);
855
- * 3. 账本命中且两侧 content hash 与记账时一致 → skipped(真正的幂等重放)。
856
- * 任一侧后来被改写 → hash 对不上 → 重新裁决,宁可再判一次也不静音真的演化。
857
- */
858
- function applyDifferentiate(d, service, snapshot, config = {}) {
859
- const ids = Array.isArray(d.ids) ? d.ids : [];
860
- const alive = ids.filter((id) => {
861
- const m = service.getById(id);
862
- return m && !m.archived;
863
- });
864
- if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
865
- const hashes = new Map(alive.map((id) => [id, contentHashOf(service.getById(id))]));
866
- const [a, b] = alive.slice(0, 2);
867
- const record = typeof service.findDifferentiatedPair === "function"
868
- ? service.findDifferentiatedPair(a, b)
869
- : undefined;
870
- if (record) {
871
- // 记账时 a/b 是按 id 排序存的,hash 也跟着换位,这里同样按排序取值比对。
872
- const [ha, hb] = a <= b ? [hashes.get(a), hashes.get(b)] : [hashes.get(b), hashes.get(a)];
873
- if (record.hash_a === ha && record.hash_b === hb) return "skipped";
874
- } else {
875
- // 存量库兼容:旧版本把注记写进了正文,那些行没有账本行。两侧都还带着注记块
876
- // 才认定「已裁决过」——只有一侧有,说明另一侧后来被改写,应当重判。
877
- const noted = alive.filter((id) => STR.differentiatedNoteRe.test(String(service.getById(id)?.content ?? "")));
878
- if (noted.length >= 2) return "skipped";
879
- }
880
- // 账本缺席(人工确认路径的裸 service)就什么都不写:正文一字不动本来就是本动作
881
- // 的全部副作用,没有账本就只剩「这条决策无处落地」,不是错误,也不是 applied。
882
- if (typeof service.saveDifferentiatedPair !== "function") return "skipped";
883
- service.transaction(() => {
884
- casGuard(service, snapshot, ids);
885
- const [ia, ib] = alive.slice(0, 2);
886
- service.saveDifferentiatedPair({
887
- run_id: config.runId,
888
- memory_a: ia,
889
- memory_b: ib,
890
- // hash 取**执行时**的正文(不是送审时),与账本里下一次比对的口径同源。
891
- hash_a: hashes.get(ia),
892
- hash_b: hashes.get(ib),
893
- // 模型给的差异点也落账:正文不再写注记,这些就是「当初为什么判互补」的唯一凭据。
894
- distinctions: (Array.isArray(d.distinctions) ? d.distinctions : [])
895
- .filter((x) => typeof x === "string" && x.trim())
896
- .map((x) => x.trim())
897
- });
898
- });
899
- return {
900
- applied: 1,
901
- committed: { action: "differentiate", ids, count_before: ids.length, count_after: ids.length }
902
- };
903
- }
904
-
905
- function applyUpdate(d, service, snapshot, config = {}) {
906
- const id = d.ids[0];
907
- const mem = service.getById(id);
908
- if (!mem || mem.archived) return "skipped";
909
- // 幂等检查:如果字段已与目标一致则跳过
910
- const same = (d.title === undefined || d.title === mem.title)
911
- && (d.content === undefined || d.content === mem.content)
912
- && (d.importance === undefined || d.importance === mem.importance);
913
- if (same) return "skipped";
914
- service.transaction(() => {
915
- casGuard(service, snapshot, [id]);
916
- const cur = service.getById(id);
917
- if (!cur || cur.archived) return;
918
- service.update(id, {
919
- title: d.title ?? cur.title,
920
- content: d.content ?? cur.content,
921
- importance: d.importance ?? cur.importance
922
- });
923
- });
924
- // 4.3.1 supersedes 关系(opt-in):事务提交成功后,为该记忆关联的每条实体属性
925
- // 建立自引用 supersedes 关系,表示"此属性版本已被替代"。仅记录、绝不阻断主流程
926
- // (fail-safe):记录失败只告警,update 本身照常生效。
927
- if (config.entityExtractionEnabled && typeof service.saveRelation === "function" && typeof service.getAttrsByMemory === "function") {
928
- try {
929
- const oldAttrs = service.getAttrsByMemory(id);
930
- for (const attr of oldAttrs) {
931
- if (!attr.entity_id) continue;
932
- service.saveRelation({
933
- from_entity: attr.entity_id,
934
- to_entity: attr.entity_id,
935
- relation_type: "supersedes",
936
- memory_id: id,
937
- metadata: JSON.stringify({ attr_key: attr.attr_key, old_value: attr.attr_value })
938
- });
939
- }
940
- } catch (error) {
941
- logger?.warn?.(`dsh-mneme dream: failed to record supersedes relations for ${id}: ${error.message}`);
942
- }
943
- }
944
- return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
945
- }
1
+ import { STR, langOf } from "../lang.js";
2
+ import { contentHashOf } from "../content-hash.js";
3
+
4
+ // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
5
+ // 旧版)与 differentiate(互补:两条都留,各自保留原本的正文)。两者的校验与执行
6
+ // 都复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
7
+ // Issue #533 起 differentiate 不再改写正文,只落裁决账本——见 applyDifferentiate。
8
+ // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
9
+ export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
10
+
11
+ // Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
12
+ // keepSource and conflict winners prefer the higher-trust memory. Higher value
13
+ // = preferred. observation (measured) > inferred (derived) > subjective (guess).
14
+ export const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
15
+
16
+ // Issue #104 方向 2:archive 类型护栏的长保留类型集合——这些类型的存在意义
17
+ // 就是长期保存(踩坑记录、约束、用户偏好、挖掘出的模式),LLM 以「价值判断」
18
+ // 类理由批量归档它们时误伤损失最大。conflict 裁决的败者归档不经过 archive
19
+ // action(走 applyDecisions 的 conflict 分支),不受此护栏影响。
20
+ const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_solution", "constraint", "pitfall"]);
21
+ // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
22
+ // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
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
+ }
96
+
97
+ /**
98
+ * Validate a dream decision list against a snapshot of eligible memories.
99
+ * @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
100
+ * entries are spliced out of this array in place (the caller reuses the same
101
+ * reference downstream for apply/audit); implicit keeps are appended here too.
102
+ * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
103
+ * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>,
104
+ * resolvedShortIds?: number}}
105
+ */
106
+ export function validateDecisions(decisions, snapshot, options = {}) {
107
+ const errors = [];
108
+ // Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
109
+ // 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
110
+ // 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
111
+ // degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
112
+ // 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
113
+ const skipped = [];
114
+ const survivors = [];
115
+ const skipInvalid = options.skipInvalid === true;
116
+ const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
117
+ const minAgeHours = options.minAgeHours ?? 24;
118
+ // Issue #126 review(Copilot):档位白名单。sleepActionSet 默认档只换 prompt 是
119
+ // 不够的——ACTIONS 是两个模块共用的,模型自发出 supersede / differentiate 时校验器
120
+ // 照样放行,"opt-in 零行为变化"就成了空话。省略 / null = 不限制(沿用全局 ACTIONS)。
121
+ const allowedActions = Array.isArray(options.allowedActions) ? new Set(options.allowedActions) : null;
122
+ // reflectionUpdateEnabled=false:update 在本次调用里不算合法动作。走与
123
+ // allowedActions 同一条单条跳过的通路(不是整单拒绝)——用户关掉的是「改写已有
124
+ // 记忆」这一种动作,模型多输出了几条 update 不该连带丢掉同批的 merge/archive。
125
+ // 与 prompt 侧后缀双闸:prompt 让模型别写,这里兜住模型仍然写了的情况。
126
+ const allowUpdate = options.allowUpdate !== false;
127
+ // 被用户开关关掉的动作单独计数:它们进 skipped(要留痕),但不该触发下面的
128
+ // 「全部跳过 = 模型坏了」整单拒绝——那是防幻觉 id / 截断输出的护栏,而这里是
129
+ // 用户自己关掉的动作。混在一起会让「只输出了 update」的一轮被判 failed、调度器
130
+ // 拿同一批输入重跑同一条必被拒的输出,无限循环。
131
+ let policySkipped = 0;
132
+ if (!Array.isArray(decisions)) {
133
+ return { ok: false, errors: ["decision list must be an array"], resolvedShortIds: 0 };
134
+ }
135
+ // 空决策 = 模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 明确允许"无问题的
136
+ // 条目无需输出")。合法 JSON [] 不是空体(那是无输出/截断),也不是"残缺输出"
137
+ // ——显式短路直接 ok,避免隐式 keep 的覆盖率检查把 0% 误判为模型坏了。与
138
+ // sleep 的空模式(skipped no-op)语义对齐:下游 applied=0、audit 记 ok。
139
+ if (decisions.length === 0) {
140
+ return { ok: true, errors: [], skipped: [], resolvedShortIds: 0 };
141
+ }
142
+ // Issue #135:模型常把 36 位 UUID 回填成前缀(实测 78/78 个 8 位前缀都能唯一
143
+ // 对应窗口内真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写),而
144
+ // 下方校验用 snapshot.get(id) 整串精确匹配 → 全部 unknown id → claimed=0 →
145
+ // 覆盖率闸整单拒绝,一整轮语义成果被丢。校验前先把「唯一前缀」解析回完整 id
146
+ // (git 短哈希式):仅在 snapshot 内解析,有歧义或匹配不到就原样保留,仍由下
147
+ // 方 unknown id 校验如实报错。就地改写 ids / keepSource / winner / loser,下
148
+ // 游 applyDecisions 与审计拿到的同样是完整 id。
149
+ let resolvedShortIds = 0;
150
+ const resolveShortId = (value) => {
151
+ if (typeof value !== "string" || snapshot.has(value)) return value;
152
+ // 太短不猜(歧义概率高);长度 ≥36 说明不是前缀缩写,交给校验如实报错。
153
+ if (value.length < 8 || value.length >= 36) return value;
154
+ let hit = null;
155
+ let matches = 0;
156
+ for (const id of snapshot.keys()) {
157
+ if (id.startsWith(value)) {
158
+ hit = id;
159
+ if (++matches > 1) return value; // 有歧义,不解析
160
+ }
161
+ }
162
+ if (matches === 1) {
163
+ resolvedShortIds++;
164
+ return hit;
165
+ }
166
+ return value;
167
+ };
168
+ for (const d of decisions) {
169
+ if (!d || typeof d !== "object") continue;
170
+ if (Array.isArray(d.ids)) d.ids = d.ids.map(resolveShortId);
171
+ if (typeof d.keepSource === "string") d.keepSource = resolveShortId(d.keepSource);
172
+ if (typeof d.winner === "string") d.winner = resolveShortId(d.winner);
173
+ if (typeof d.loser === "string") d.loser = resolveShortId(d.loser);
174
+ }
175
+ const claimed = new Set();
176
+ for (const [index, d] of decisions.entries()) {
177
+ const at = `decision[${index}]`;
178
+ // 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
179
+ // 全局 errors(沿用 v0.6.9 的双轨结构)。
180
+ const local = [];
181
+ let policyDisabled = false;
182
+ // Issue #126:supersede 与 conflict 一样用 winner/loser 定位双方(沿用同一套
183
+ // 字段,校验与 receipt 逻辑因此可以直接复用)。
184
+ const ids = d && (d.action === "conflict" || d.action === "supersede") ? [d.winner, d.loser] : (d?.ids ?? []);
185
+ if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
186
+ local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
187
+ } else if (allowedActions && !allowedActions.has(d.action)) {
188
+ // 动作本身合法,但不在当前档位允许的集合里(例如默认档收到 supersede)。
189
+ local.push(`${at}: action ${JSON.stringify(d.action)} not allowed in this mode`);
190
+ } else if (d.action === "update" && !allowUpdate) {
191
+ // reflectionUpdateEnabled=false:旧记忆永不被自动改写。这条要排在
192
+ // allowedActions 之后、update 字段校验之前——先判「这个动作本次不许用」,
193
+ // 再谈字段合不合规,否则同一条会同时报两个错、日志里读不出真实原因。
194
+ local.push(`${at}: update action is disabled (reflectionUpdateEnabled=false)`);
195
+ policyDisabled = true;
196
+ } else if (d.action === "conflict" || d.action === "supersede") {
197
+ if (!d.winner || !d.loser || d.winner === d.loser) {
198
+ local.push(`${at}: ${d.action} needs distinct winner and loser`);
199
+ }
200
+ } else if (d.action === "differentiate") {
201
+ // Issue #126:互补型——两条都留下。ids 必须 ≥2(随后走下方通用 id 校验:
202
+ // unknown / archived / summary / 重复 claim)。distinctions 仍然必填:#533 之后
203
+ // 它不再写进正文,但仍是「模型确实做过分类」的唯一凭据,落进裁决账本供审计
204
+ // (空数组意味着模型只是偷懒输出了一条动作名,不该被记成已裁决)。
205
+ if (!Array.isArray(d.ids) || d.ids.length < 2) {
206
+ local.push(`${at}: differentiate needs at least two ids`);
207
+ } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
208
+ local.push(`${at}: differentiate needs a non-empty distinctions array`);
209
+ }
210
+ } else if (d.action === "create") {
211
+ // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
212
+ // so it skips the claiming loop below; evidence is optional provenance
213
+ // (already filtered to real ids by the caller) and is stored in content.
214
+ if (typeof d.title !== "string" || !d.title.trim()) {
215
+ local.push(`${at}: create needs non-empty title`);
216
+ }
217
+ if (typeof d.content !== "string" || !d.content.trim()) {
218
+ local.push(`${at}: create needs non-empty content`);
219
+ }
220
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
221
+ local.push(`${at}: create importance must be an integer 1-5 when provided`);
222
+ }
223
+ if (typeof d.type !== "string" || !d.type.trim()) {
224
+ local.push(`${at}: create needs non-empty type`);
225
+ }
226
+ } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
227
+ local.push(`${at}: ${d.action} needs non-empty ids`);
228
+ }
229
+ // update-specific field validation runs BEFORE claiming ids, so a failing
230
+ // update never pollutes the claimed set (which drives the "every id must
231
+ // appear in a decision" check below).
232
+ // Issue #104 方向 2:archive 类型护栏。长保留类型的 archive 决策必须携带
233
+ // 「重复 / 过时」类理由(提示词对 archive 的定义本就如此),「价值判断」类
234
+ // 理由(保持整洁、参考价值低……)不放行——单条跳过记 skipped(run 记
235
+ // degraded,明细进 dream_runs.skipped),绝不整单拒绝。未知 id 在下方通用
236
+ // 校验里另行报错,这里 snapshot.get 为 undefined 时跳过护栏判断。
237
+ if (local.length === 0 && d?.action === "archive") {
238
+ const guardedTypes = new Set();
239
+ for (const id of d.ids) {
240
+ const mem = snapshot.get(id);
241
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
242
+ }
243
+ if (guardedTypes.size > 0 && !ARCHIVE_RATIONALE_RE.test(String(d.reason ?? ""))) {
244
+ 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))}`);
245
+ }
246
+ }
247
+ if (local.length === 0 && d?.action === "update") {
248
+ // 只能更新单条
249
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
250
+ local.push(`${at}: update must target exactly one id`);
251
+ } else {
252
+ // 必须产生实际变化
253
+ const mem = snapshot.get(d.ids[0]);
254
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
255
+ || (d.content !== undefined && d.content !== mem?.content)
256
+ || (d.importance !== undefined && d.importance !== mem?.importance);
257
+ if (!hasChange) {
258
+ local.push(`${at}: update must change at least one field`);
259
+ } else if (mem?.type === "summary") {
260
+ // 不能更新 summary
261
+ local.push(`${at}: cannot update summary via update action`);
262
+ } else {
263
+ // 保护期:新建记忆不可立即被 update(可配置)
264
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
265
+ if (ageHours < minAgeHours) {
266
+ local.push(`${at}: memory too young (< ${minAgeHours}h)`);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ if (local.length === 0 && d && d.action !== "create") {
272
+ // seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
273
+ // (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
274
+ const seen = new Set();
275
+ for (const id of ids) {
276
+ const mem = snapshot.get(id);
277
+ if (!mem) {
278
+ local.push(`${at}: unknown id ${JSON.stringify(id)}`);
279
+ } else if (mem.archived || mem.type === "summary") {
280
+ local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
281
+ }
282
+ if (claimed.has(id) || seen.has(id)) {
283
+ local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
284
+ }
285
+ seen.add(id);
286
+ }
287
+ if (local.length === 0 && d.action === "merge") {
288
+ if (!d.keepSource || !d.ids.includes(d.keepSource)) {
289
+ local.push(`${at}: merge keepSource must be one of ids`);
290
+ }
291
+ if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
292
+ local.push(`${at}: merge needs non-empty title and content`);
293
+ }
294
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
295
+ local.push(`${at}: merge importance must be an integer 1-5 when provided`);
296
+ }
297
+ // Merging across types would blur preference/project/decision boundaries
298
+ // in the injected context; the snapshot carries each entry's type.
299
+ // Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
300
+ // 后放宽该检查,类型边界由用户自行承担。
301
+ const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
302
+ if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
303
+ local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
304
+ }
305
+ // issue #339 / E8 考卷:merge 护栏(默认开,dreamMergeGuard)。巩固损耗
306
+ // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive
307
+ // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是
308
+ // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的
309
+ // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped
310
+ // 明细、严格模式整单拒绝)。
311
+ if (local.length === 0 && options.mergeGuard === true) {
312
+ const guardedTypes = new Set();
313
+ for (const id of d.ids) {
314
+ const mem = snapshot.get(id);
315
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
316
+ }
317
+ // Issue #521:与上方 archive 护栏对齐——archive 早就允许「带重复/过时
318
+ // 理由」的长保留类型通过,merge 却一律拦死,于是一份真重复的
319
+ // preference 永远合不掉,而提示词还在催模型合并它们。实测(#521)46 轮
320
+ // 巩固里 280 条 merge 决策全被这条拦下、merge 恒为 0,模型每轮把同一
321
+ // 簇条目再提一次,满载 token 空转。逃生门条件与 archive 护栏同一条词表
322
+ // 且要求同类型(跨类型合并本就另受 allowCrossTypeMerge 约束,逃生门
323
+ // 不跟着放宽)。reason 只写「主题相近」不会命中词表——这是故意的:
324
+ // 相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。
325
+ const sameType = new Set(d.ids.map((id) => snapshot.get(id)?.type)).size === 1;
326
+ const reasonText = String(d.reason ?? "");
327
+ // #522 评审:词表命中还不够——「并不重复」「not a duplicate」也含关键词,
328
+ // 放它们过闸等于让模型用一句否认换来合并许可。否定表述直接判不重复。
329
+ const duplicated = !NEGATED_RATIONALE_RE.test(reasonText) && ARCHIVE_RATIONALE_RE.test(reasonText);
330
+ if (guardedTypes.size > 0 && !(sameType && duplicated)) {
331
+ local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard (needs a duplicate/outdated rationale, 重复/过时)`);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ if (local.length > 0) {
337
+ if (skipInvalid) {
338
+ // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
339
+ // keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
340
+ // errors,否则会误触发下方的整单拒绝。
341
+ //
342
+ // #534:除四键骨架外补三个「模型到底给没给」的布尔,把 skipped 从
343
+ // 「error 字符串」变成可计数的形状。作者实测 19 轮 17 轮整轮无效,但
344
+ // skipped 里只有一句错误串,事后无法判断模型是漏字段(merge 缺
345
+ // title/content)、还是字段给了但值不合规(has_* 为 true 仍报错)。
346
+ // 只加布尔不加原文:原文进 llm_audit_logs.metadata 与 dream_runs.raw_output,
347
+ // 这里保持每条记录足够小(skipped 会按轮累积进一行 JSON)。
348
+ if (policyDisabled) policySkipped++;
349
+ skipped.push({
350
+ index,
351
+ action: d?.action,
352
+ ids,
353
+ error: local.join("; "),
354
+ // true = 用户开关关掉了这个动作(不是模型输出有问题)。调用方据此区分
355
+ // 「这轮白跑」与「这轮输出坏了」。
356
+ ...(policyDisabled ? { policyDisabled: true } : {}),
357
+ has_keepSource: d?.keepSource !== undefined && d?.keepSource !== null,
358
+ has_title: typeof d?.title === "string" && d.title.trim().length > 0,
359
+ has_content: typeof d?.content === "string" && d.content.trim().length > 0
360
+ });
361
+ } else {
362
+ errors.push(...local);
363
+ }
364
+ continue;
365
+ }
366
+ if (d.action !== "create") {
367
+ for (const id of ids) claimed.add(id);
368
+ }
369
+ survivors.push(d);
370
+ }
371
+ // Cap update churn: too many edits in one cycle signals a runaway model.
372
+ // 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
373
+ const updateCount = survivors.filter((d) => d.action === "update").length;
374
+ if (updateCount > maxUpdatePerRun) {
375
+ errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
376
+ }
377
+ // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
378
+ const createCount = survivors.filter((d) => d.action === "create").length;
379
+ const maxCreatePerRun = options.maxCreatePerRun ?? 5;
380
+ if (createCount > maxCreatePerRun) {
381
+ errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
382
+ }
383
+ // Issue #104 方向 2:archive 批量上限——一次性大扫除(12 条互不相关主题被
384
+ // 批量归档)是失控信号,与 update 上限同一类全局闸门:skipInvalid 也不豁免,
385
+ // 超限整单拒绝;正常的大规模清理由用户调高 dreamMaxArchivePerRun。
386
+ const archiveCount = survivors.filter((d) => d.action === "archive").length;
387
+ const maxArchivePerRun = options.maxArchivePerRun ?? 8;
388
+ if (archiveCount > maxArchivePerRun) {
389
+ errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
390
+ }
391
+ // merge 批量上限(#135 成本审计的派生护栏)。merge 是唯一**回吐正文**的动作:
392
+ // 每条 merge 都要求模型重新写出合并后的 content(实测 2113 条 merge 平均 550
393
+ // 字符/条),而 archive/keep/conflict 都不带正文——所以单轮 merge 数是「输出
394
+ // 里正文占比」唯一可控的杠杆(实测 dream_consolidate 的输出 token 只有约 11.8%
395
+ // 是决策正文,其余是推理与 JSON 骨架,那部分由 effort 决定、与本闸无关)。
396
+ // 与 update/create/archive 三道闸同类:全局闸门,skipInvalid 也不豁免,超限整单
397
+ // 拒绝。默认值取 40 的校准依据(只读探针,全库 246 轮 / 近 10 天 88 轮):
398
+ // 每轮 merge 数 全库 p50 7 / p90 15 / p99 28 / max 39,近 10 天 p50 9 / p90 20 /
399
+ // p99 39 / max 39 —— 40 是观测分布的上沿(历史命中 0/246),与 archive 闸默认 8
400
+ // 恰为其 p90=max 同一校准法:这是面向未来的尾险护栏,不是成本削减工具(现有三
401
+ // 道闸在实测分布下命中率均为 0.0%,同一性质)。刻意不取更小的值:超限的代价
402
+ // 不对称——整单拒绝 = run 记 failed = 调度器不刷新 baseline(src/dream.js:905-915)
403
+ // = 下一次写入用同一批输入重跑,一次误伤约等于该轮成本 ×2;而探针里 merge≥15 的
404
+ // 大轮正文/显式决策比例都正常(10~22%),没有失控特征。想用覆盖换成本的用户可
405
+ // 自行调低 dreamMaxMergePerRun。
406
+ const mergeCount = survivors.filter((d) => d.action === "merge").length;
407
+ const maxMergePerRun = options.maxMergePerRun ?? 40;
408
+ if (mergeCount > maxMergePerRun) {
409
+ errors.push(`too many merge decisions: ${mergeCount} > ${maxMergePerRun}`);
410
+ }
411
+ // Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
412
+ // 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
413
+ // 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
414
+ // 「有合法子集但覆盖率不足」的良性挑重点(见下方 coverageShortfall)。
415
+ // 只把「非策略性跳过」算进这条:policySkipped 是用户关掉的动作,不是模型故障。
416
+ const modelSkipped = skipped.length - policySkipped;
417
+ if (options.skipInvalid === true && modelSkipped > 0 && survivors.length === 0 && errors.length === 0) {
418
+ errors.push(`all ${skipped.length} decision(s) invalid — nothing valid survived validation`);
419
+ return { ok: false, errors, skipped, resolvedShortIds };
420
+ }
421
+ // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
422
+ // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
423
+ // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
424
+ // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
425
+ // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
426
+ //
427
+ // v0.4.4 fix(残缺输出防洗白)→ Issue #104 方向 1 重审:显式覆盖率护栏的
428
+ // 初衷是拦「输出截断 → 只 claim 少量 → 其余被隐式 keep 洗白」,但它无法区分
429
+ // 恶性截断与良性挑重点——而提示词硬性规则本身要求「无问题的条目无需输出」,
430
+ // 挑重点正是要求的行为。官方路由 + 强模型的实测(heptaspirit:81 轮 failed
431
+ // 全是 coverage,LLM 调用本身成功、applied=0)证明这是常规路径而非边角。
432
+ // 处置:覆盖率不足不再 ok:false 整单拒绝,改为返回 coverageShortfall 理由、
433
+ // 照常补齐隐式 keep——调用方把 run 记 degraded(合法子集已应用),护栏以
434
+ // 状态暴露而非一票否决,与 skipInvalid 同一宽容哲学。明确异常信号(create
435
+ // 上限等硬错误)保持整单拒绝。要恢复旧行为可设 dreamImplicitKeep:false
436
+ // (严格模式,缺失即 error)或 dreamMinExplicitCoverage: 0(关护栏)。
437
+ // 非覆盖类硬错误(create 上限溢出、严格模式缺失 id)仍然先整单拒绝——
438
+ // #104 方向 1 只豁免 coverage 这一个信号,防洗白语义对它们原样保留。
439
+ if (errors.length > 0) {
440
+ return { ok: false, errors, skipped, resolvedShortIds };
441
+ }
442
+ const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
443
+ let coverageShortfall = null;
444
+ if (options.dreamImplicitKeep !== false) {
445
+ const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
446
+ if (coverage < minCoverage) {
447
+ coverageShortfall = `explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`;
448
+ }
449
+ for (const id of snapshot.keys()) {
450
+ if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
451
+ }
452
+ } else {
453
+ for (const id of snapshot.keys()) {
454
+ if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
455
+ }
456
+ if (errors.length > 0) return { ok: false, errors, skipped, resolvedShortIds };
457
+ }
458
+ // 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
459
+ // 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
460
+ // 不能以 survivors.length !== decisions.length 作为是否 splice 的判据:
461
+ // 当"被跳过的非法决策数 == 隐式补齐的 keep 数"时长度回到相等但内容已变,
462
+ // 被跳过的决策会残留进 apply/audit。一律无条件 splice 最安全。
463
+ decisions.splice(0, decisions.length, ...survivors);
464
+ return { ok: true, errors, skipped, resolvedShortIds, coverageShortfall };
465
+ }
466
+
467
+ /** Marker thrown when a decision target changed since the run snapshot. */
468
+ export class CasConflictError extends Error {
469
+ constructor(action, ids, reason) {
470
+ super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
471
+ this.name = "CasConflictError";
472
+ this.action = action;
473
+ this.ids = ids;
474
+ }
475
+ }
476
+
477
+ function decisionIds(d) {
478
+ return d.action === "conflict" || d.action === "supersede" ? [d.winner, d.loser] : (d.ids ?? []);
479
+ }
480
+
481
+ /**
482
+ * CAS guard (item ①): every target memory must still match what the run
483
+ * snapshot captured — otherwise the decision was computed against stale state
484
+ * and applying it would overwrite a concurrent edit. Snapshotless replays skip
485
+ * the guard entirely (per-action idempotency checks handle those). Throws
486
+ * CasConflictError on the first mismatch; the caller's transaction rolls back.
487
+ */
488
+ function casGuard(service, snapshot, ids) {
489
+ if (!snapshot) return;
490
+ for (const id of ids) {
491
+ const expect = snapshot.get(id);
492
+ if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
493
+ const current = service.getById(id);
494
+ if (!current) {
495
+ throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
496
+ }
497
+ const changed = expect.updated_at !== undefined
498
+ ? current.updated_at !== expect.updated_at
499
+ : current.content !== expect.content || current.title !== expect.title;
500
+ if (changed) {
501
+ throw new CasConflictError(
502
+ "changed",
503
+ [id],
504
+ `memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
505
+ );
506
+ }
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Apply a validated decision list to the service. Caller must validate first.
512
+ * Each decision runs inside its own SQLite transaction (item ②): the multi-step
513
+ * mutation of a decision is atomic, so a merge can never leave "keeper updated
514
+ * but source not archived" or vice versa — a throwing sub-step rolls the whole
515
+ * decision back.
516
+ *
517
+ * @param decisions - validated decision list.
518
+ * @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
519
+ * @param logger - optional logger ({ warn }); per-decision failures are logged.
520
+ * @param snapshot - optional Map<id, memory> captured before the LLM call; when
521
+ * provided, every decision target is CAS-checked against it and a decision
522
+ * computed from stale state is skipped and reported as a conflict instead of
523
+ * overwriting concurrent writes (item ①).
524
+ * @param config - pipeline config; `config.mintSource` (optional string) is the
525
+ * provenance stamped on memories minted by `create` decisions. It is
526
+ * read from the CALLER, never from the decision object: the LLM authors
527
+ * `create` payloads and validateDecisions does not strip unknown fields, so
528
+ * trusting `d.source` would let a model label its own output as human-written.
529
+ * Absent/blank → saveWithDedupe's existing "manual" fallback (unchanged).
530
+ * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
531
+ * applied - number of decisions/memories actually committed (archive counts
532
+ * each archived memory as one, merge/conflict/update count one).
533
+ * conflicts - decisions skipped because a target changed since the snapshot.
534
+ * failures - decisions that threw mid-transaction (fully rolled back).
535
+ * committed - the decisions that actually landed, for outcome/receipt based
536
+ * on real committed sub-steps rather than the raw LLM list.
537
+ */
538
+ export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
539
+ let applied = 0;
540
+ let merged = 0;
541
+ const conflicts = [];
542
+ const failures = [];
543
+ const committed = [];
544
+ for (const [i, d] of decisions.entries()) {
545
+ try {
546
+ // keep is a confirmed no-op: it commits nothing but still records the
547
+ // per-id disposition so the outcome covers every snapshot memory.
548
+ if (d.action === "keep") {
549
+ committed.push({ action: "keep", ids: d.ids });
550
+ continue;
551
+ }
552
+ const outcome = applyOne(d, service, snapshot, config, logger);
553
+ if (outcome === "skipped") continue;
554
+ applied += outcome.applied;
555
+ // `merged` counts creates that hit a same-title row instead of minting a
556
+ // new one. Kept apart from `applied` on purpose: a create that vanished
557
+ // into an existing row is not a new conclusion, and folding it in is how
558
+ // two different dispositions ended up looking like two minted patterns
559
+ // (#619 audit round 5).
560
+ if (outcome.merged) merged += outcome.merged;
561
+ committed.push(outcome.committed);
562
+ } catch (error) {
563
+ if (error instanceof CasConflictError) {
564
+ conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
565
+ logger?.warn?.(`dsh-mneme dream: ${error.message}`);
566
+ } else {
567
+ failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
568
+ logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
569
+ }
570
+ }
571
+ }
572
+ return { applied, merged, conflicts, failures, committed };
573
+ }
574
+
575
+ function applyOne(d, service, snapshot, config = {}, logger = null) {
576
+ switch (d.action) {
577
+ case "archive": return applyArchive(d, service, snapshot);
578
+ case "merge": return applyMerge(d, service, snapshot, config);
579
+ case "conflict": return applyConflict(d, service, snapshot, config);
580
+ case "supersede": return applySupersede(d, service, snapshot, config);
581
+ case "differentiate": return applyDifferentiate(d, service, snapshot, config);
582
+ case "create": return applyCreate(d, service, config, logger);
583
+ default: return applyUpdate(d, service, snapshot, config);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
589
+ * `preferred`). Archived memories are never eligible keepers — promoting one
590
+ * would demote the real keepSource to a source and then hit the archived-keeper
591
+ * guard in applyMerge, silently skipping the whole merge. When `preferred`
592
+ * itself is archived (or missing), fall back to any unarchived candidate.
593
+ */
594
+ function pickBestKeeper(ids, preferred, service) {
595
+ let best = null;
596
+ let bestP = -1;
597
+ for (const id of ids) {
598
+ const mem = service.getById(id);
599
+ if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
600
+ const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
601
+ if (p > bestP || (p === bestP && id === preferred)) {
602
+ bestP = p;
603
+ best = id;
604
+ }
605
+ }
606
+ return best ?? preferred;
607
+ }
608
+
609
+ /**
610
+ * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
611
+ * Evidence ids ride in the content so a pattern stays traceable to its source
612
+ * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
613
+ */
614
+ function applyCreate(d, service, config = {}, logger = null) {
615
+ const language = langOf(config);
616
+ const title = String(d.title ?? "").trim();
617
+ const content = String(d.content ?? "").trim();
618
+ const importance = Number.isInteger(d.importance) ? d.importance : 3;
619
+ const type = typeof d.type === "string" ? d.type : "pattern";
620
+ const evidence = Array.isArray(d.evidence)
621
+ ? d.evidence.filter((id) => typeof id === "string")
622
+ : [];
623
+ const body = evidence.length > 0
624
+ ? STR.evidenceSuffix[language](content, evidence)
625
+ : content;
626
+ // mint 来源由调用方(管道)给,不从决策对象取——LLM 能自己写 d.source,
627
+ // 信它就等于让模型给自造条目盖「人写的」戳(见 applyDecisions 的 @param config)。
628
+ // 取不到就一个字段都不传,落回 saveWithDedupe 原有的兜底,行为与改前一致。
629
+ const mintSource = typeof config?.mintSource === "string" ? config.mintSource.trim() : "";
630
+ // pattern 是「整句重写」而不是「增量补充」:同一 (人, 对象, 情绪) 的标题天然
631
+ // 相同,saveWithDedupe 命中后若走 appendContent,重放一轮就把同一段结论再粘
632
+ // 一遍(实测 161 → 355 → 549 字,外加一串重复的 [证据: …] 尾巴),而 pattern
633
+ // 正是注入面消费的东西。`_overwrite` 是既有逃生口(bootstrap / dream summary /
634
+ // narrative 三处先例):命中同题行时原地刷新而不是追加,旧文进 content_history。
635
+ // 只对 pattern 放开——其他类型被 create 撞上同题行时仍按合并语义走,那多半是
636
+ // 模型自造了不该出现的类型,把既有行整段覆盖掉比追加更危险。
637
+ const overwrite = type === "pattern";
638
+ const created = service.saveWithDedupe({
639
+ type,
640
+ title,
641
+ content: body,
642
+ importance,
643
+ ...(mintSource ? { source: mintSource } : {}),
644
+ ...(overwrite ? { _overwrite: true } : {})
645
+ });
646
+ const memory = created?.memory;
647
+ if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
648
+ // 命中同题活跃行 ⇒ saveWithDedupe 返回 action:"merged"。它确实动过库(刷新了
649
+ // 那一行),但**没有铸出新条目**:把它算进 applied 会让「一条真实结论被同题行
650
+ // 吃掉了」这种情况看起来像成功(#619 审计第五轮:两个不同对象铸出同一条
651
+ // pattern,applied 照样报 2)。两类结局分开报:applied 只数新铸,merged 数刷新。
652
+ const refreshed = created?.action !== "created";
653
+ // #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
654
+ // 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
655
+ //
656
+ // #619 / Ephor 第七轮 ③:`refreshed` 也在这条规矩里。原地刷新没有铸新条目,
657
+ // 它改的是既有行——同一条记忆不该有两份「出生记录」。此前 create_merged 这条
658
+ // 新路没被上面那句规矩覆盖,于是一个 pattern 跑两轮,`memory_mints` 就有两行、
659
+ // run_id 各不相同,面板上显示成「这条记忆出生过两次」(`mintsFor` 由
660
+ // provenance-http.js 读取)。live 库当时还没显形,是因为同题刷新刚上线。
661
+ //
662
+ // 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
663
+ try {
664
+ if (!refreshed && typeof service?.saveMemoryMint === "function" && config?.runId) {
665
+ service.saveMemoryMint({
666
+ memoryId: memory.id,
667
+ runId: config.runId,
668
+ runType: config.mintSource === "sleep" ? "sleep" : "dream",
669
+ triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
670
+ createdAt: new Date().toISOString()
671
+ });
672
+ }
673
+ } catch (mintError) {
674
+ logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
675
+ }
676
+ return { applied: refreshed ? 0 : 1, merged: refreshed ? 1 : 0, committed: { action: refreshed ? "create_merged" : "create", id: memory.id, type } };
677
+ }
678
+
679
+ function applyArchive(d, service, snapshot) {
680
+ const targets = d.ids.filter((id) => {
681
+ const mem = service.getById(id);
682
+ return mem && !mem.archived; // existing, not-yet-archived rows only
683
+ });
684
+ if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
685
+ service.transaction(() => {
686
+ casGuard(service, snapshot, d.ids);
687
+ for (const id of d.ids) {
688
+ const mem = service.getById(id);
689
+ if (mem && !mem.archived) service.setArchived(id, true);
690
+ }
691
+ });
692
+ return { applied: targets.length, committed: { action: "archive", ids: targets } };
693
+ }
694
+
695
+ function applyMerge(d, service, snapshot, config = {}) {
696
+ // Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
697
+ // subjective/inferred one. Mutating the decision keeps the receipt + committed
698
+ // record aligned with the actual keeper.
699
+ if (config.trustEpistemicWeighting === true) {
700
+ const best = pickBestKeeper(d.ids, d.keepSource, service);
701
+ if (best && best !== d.keepSource) d.keepSource = best;
702
+ }
703
+ const sources = d.ids.filter((id) => id !== d.keepSource);
704
+ // Idempotent replay: if every other source is already archived, this merge
705
+ // already landed — skip so a replayed/concurrent decision never double-counts
706
+ // or re-applies (guard against duplicate merges).
707
+ if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
708
+ service.transaction(() => {
709
+ casGuard(service, snapshot, d.ids);
710
+ const keeper = service.getById(d.keepSource);
711
+ if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
712
+ service.update(d.keepSource, {
713
+ title: d.title,
714
+ content: d.content,
715
+ importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
716
+ });
717
+ for (const id of sources) {
718
+ const mem = service.getById(id);
719
+ if (mem && !mem.archived) service.setArchived(id, true);
720
+ }
721
+ // 4.3.2 迁移实体关联(opt-in):将 loser(source)记忆关联的 entity_attrs 的
722
+ // memory_id 迁移到 keeper;keeper 已有同 entity+key 的当前属性时 loser 行被
723
+ // 失效。单个 source 迁移失败只告警,绝不能导致整个 merge 事务回滚(fail-safe)。
724
+ if (config.entityExtractionEnabled && typeof service.migrateAttrsToMemory === "function") {
725
+ for (const id of sources) {
726
+ try {
727
+ service.migrateAttrsToMemory(id, d.keepSource, new Date().toISOString());
728
+ } catch (error) {
729
+ logger?.warn?.(`dsh-mneme dream: failed to migrate attrs from ${id} to ${d.keepSource}: ${error.message}`);
730
+ }
731
+ }
732
+ }
733
+ });
734
+ return {
735
+ applied: 1,
736
+ 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 }
737
+ };
738
+ }
739
+
740
+ function applyConflict(d, service, snapshot, config = {}) {
741
+ const language = langOf(config);
742
+ // Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
743
+ // is preferred as winner over a subjective/inferred one.
744
+ if (config.trustEpistemicWeighting === true) {
745
+ const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
746
+ const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
747
+ if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
748
+ }
749
+ const winner = service.getById(d.winner);
750
+ const loser = service.getById(d.loser);
751
+ if (!winner || !loser) return "skipped";
752
+ // Idempotent replay: an already-archived loser means the conflict was already
753
+ // adjudicated — skip so the provenance note is never re-appended and the loser
754
+ // is not re-archived.
755
+ if (loser.archived) return "skipped";
756
+ service.transaction(() => {
757
+ casGuard(service, snapshot, [d.winner, d.loser]);
758
+ const winnerNow = service.getById(d.winner);
759
+ const loserNow = service.getById(d.loser);
760
+ if (!winnerNow || !loserNow || loserNow.archived) return;
761
+ service.update(d.winner, {
762
+ content: STR.supersededSuffix[language](winnerNow.content, [...loserNow.content].slice(0, 100).join(""))
763
+ });
764
+ service.setArchived(d.loser, true);
765
+ });
766
+ return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
767
+ }
768
+
769
+ /**
770
+ * 确定性信息量优先 supersede(issue #641 enforce 档的 T1 处置,供 sleep/dream
771
+ * 两条链路共用)。与冻结分支自动裁决档(#603 第 4 层,sleep.js 内联实现)同一
772
+ * 条契约,本函数是它的共享出口:
773
+ * · 保留**信息量更大**的那条,不是更新的——#616 实测 55-57% 的新条反而更短
774
+ * (蒸馏压缩过的一版常常更新),留新条等于把更完整的那条归档掉;
775
+ * · 长度相同才退回 updated_at,再退回 id——三级兜底保证同一对每次判出同一个
776
+ * 赢家(重放不翻面);
777
+ * · trustEpistemicWeighting 显式关:自动处置的承诺必须是确定的,可信度换位
778
+ * 不让结果随 epistemic_status 数据漂移;
779
+ * · 赢家正文一字不动——supersede 的可追溯注记落在被归档侧(#126 原设计)。
780
+ * E13b 证明 conflict 动作的「败者前 100 字追加进赢家」是值级泄漏通道,
781
+ * 确定性处置一律走 supersede、不走 conflict。
782
+ * CAS 快照 + 事务 + 幂等(败者已归档 → applied 0)全部沿用 applyDecisions,
783
+ * 不另写第二份裁决路径。
784
+ *
785
+ * @param {{a: object, b: object}} p 候选对(完整记忆行:id/title/content/updated_at)
786
+ * @returns {{applied: boolean, winnerId: string, loserId: string, winner: object, loser: object}}
787
+ */
788
+ export function supersedeInfoFirstPair(p, service, logger = null, config = {}) {
789
+ const an = p.a.content?.length ?? 0;
790
+ const bn = p.b.content?.length ?? 0;
791
+ const [winner, loser] = an !== bn
792
+ ? (an > bn ? [p.a, p.b] : [p.b, p.a])
793
+ : (p.a.updated_at ?? "") !== (p.b.updated_at ?? "")
794
+ ? ((p.a.updated_at ?? "") > (p.b.updated_at ?? "") ? [p.a, p.b] : [p.b, p.a])
795
+ : (p.a.id > p.b.id ? [p.a, p.b] : [p.b, p.a]);
796
+ const snapshot = new Map();
797
+ for (const m of [winner, loser]) {
798
+ snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
799
+ }
800
+ const { applied } = applyDecisions(
801
+ [{ action: "supersede", winner: winner.id, loser: loser.id }],
802
+ service, logger, snapshot, { ...config, trustEpistemicWeighting: false }
803
+ );
804
+ return { applied: applied > 0, winnerId: winner.id, loserId: loser.id, winner, loser };
805
+ }
806
+
807
+ /**
808
+ * supersede(Issue #126,sleepActionSet="full"):演进型重复——新版本取代旧版本。
809
+ * 与 conflict 的关键差别:**注记追加到 loser(被取代方)而不是 winner**。conflict
810
+ * 把"已否决旧信息"写进赢家正文,赢家因此被污染——这正是 #126 的原始抱怨;
811
+ * supersede 把可追溯性留在被归档的那一侧,赢家正文一字不动。
812
+ *
813
+ * 幂等:loser 已归档即视为该决策已落地(重放/并发不会重复追加注记或重复归档)。
814
+ */
815
+ function applySupersede(d, service, snapshot, config = {}) {
816
+ const language = langOf(config);
817
+ // Epistemic trust(与 conflict 同款):可信度更高的一侧优先作为取代方。
818
+ if (config.trustEpistemicWeighting === true) {
819
+ const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
820
+ const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
821
+ if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
822
+ }
823
+ const winner = service.getById(d.winner);
824
+ const loser = service.getById(d.loser);
825
+ if (!winner || !loser) return "skipped";
826
+ if (loser.archived) return "skipped"; // 幂等重放:已经取代过了
827
+ service.transaction(() => {
828
+ casGuard(service, snapshot, [d.winner, d.loser]);
829
+ const winnerNow = service.getById(d.winner);
830
+ const loserNow = service.getById(d.loser);
831
+ if (!winnerNow || !loserNow || loserNow.archived) return;
832
+ service.update(d.loser, {
833
+ content: STR.supersededBySuffix[language](loserNow.content, winnerNow.title)
834
+ });
835
+ service.setArchived(d.loser, true);
836
+ });
837
+ return { applied: 1, committed: { action: "supersede", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
838
+ }
839
+
840
+ /**
841
+ * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
842
+ * 覆盖不同侧面,不该判输赢。双方都保留(不归档),**正文一个字节都不动**。
843
+ *
844
+ * 为什么不动正文(Issue #533):#126 的原设计是「双方各追加同一段差异注记,注记
845
+ * 进正文 → 进注入与 embedding → 下一轮就不会再被判成重复」。离线复现证伪了这个
846
+ * 假设——两侧共享同一段后缀会让两条向量**同向靠拢**,余弦一轮比一轮高(0.876 →
847
+ * 0.960 只用了 5 轮,把注记截到 0 字照样往上爬,因为推高相似度的是共享前缀本身而
848
+ * 不是注记长短),于是同一对下一轮又被判成重复、又追加一段,实测单条堆到 19 段。
849
+ * 现在的裁决落在 differentiated_pairs 账本(src/store.js)里:候选构造阶段读它,
850
+ * 已裁决且两侧正文都没改过就不再送模型,正文保持干净。
851
+ *
852
+ * 幂等三层,从便宜到贵:
853
+ * 1. 一侧已归档 → skipped(缺一条就没什么可区分的);
854
+ * 2. 存量兼容:两侧正文都还带着旧版注记块 → skipped(升级不清账);
855
+ * 3. 账本命中且两侧 content hash 与记账时一致 → skipped(真正的幂等重放)。
856
+ * 任一侧后来被改写 → hash 对不上 → 重新裁决,宁可再判一次也不静音真的演化。
857
+ */
858
+ function applyDifferentiate(d, service, snapshot, config = {}) {
859
+ const ids = Array.isArray(d.ids) ? d.ids : [];
860
+ const alive = ids.filter((id) => {
861
+ const m = service.getById(id);
862
+ return m && !m.archived;
863
+ });
864
+ if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
865
+ const hashes = new Map(alive.map((id) => [id, contentHashOf(service.getById(id))]));
866
+ const [a, b] = alive.slice(0, 2);
867
+ const record = typeof service.findDifferentiatedPair === "function"
868
+ ? service.findDifferentiatedPair(a, b)
869
+ : undefined;
870
+ if (record) {
871
+ // 记账时 a/b 是按 id 排序存的,hash 也跟着换位,这里同样按排序取值比对。
872
+ const [ha, hb] = a <= b ? [hashes.get(a), hashes.get(b)] : [hashes.get(b), hashes.get(a)];
873
+ if (record.hash_a === ha && record.hash_b === hb) return "skipped";
874
+ } else {
875
+ // 存量库兼容:旧版本把注记写进了正文,那些行没有账本行。两侧都还带着注记块
876
+ // 才认定「已裁决过」——只有一侧有,说明另一侧后来被改写,应当重判。
877
+ const noted = alive.filter((id) => STR.differentiatedNoteRe.test(String(service.getById(id)?.content ?? "")));
878
+ if (noted.length >= 2) return "skipped";
879
+ }
880
+ // 账本缺席(人工确认路径的裸 service)就什么都不写:正文一字不动本来就是本动作
881
+ // 的全部副作用,没有账本就只剩「这条决策无处落地」,不是错误,也不是 applied。
882
+ if (typeof service.saveDifferentiatedPair !== "function") return "skipped";
883
+ service.transaction(() => {
884
+ casGuard(service, snapshot, ids);
885
+ const [ia, ib] = alive.slice(0, 2);
886
+ service.saveDifferentiatedPair({
887
+ run_id: config.runId,
888
+ memory_a: ia,
889
+ memory_b: ib,
890
+ // hash 取**执行时**的正文(不是送审时),与账本里下一次比对的口径同源。
891
+ hash_a: hashes.get(ia),
892
+ hash_b: hashes.get(ib),
893
+ // 模型给的差异点也落账:正文不再写注记,这些就是「当初为什么判互补」的唯一凭据。
894
+ distinctions: (Array.isArray(d.distinctions) ? d.distinctions : [])
895
+ .filter((x) => typeof x === "string" && x.trim())
896
+ .map((x) => x.trim())
897
+ });
898
+ });
899
+ return {
900
+ applied: 1,
901
+ committed: { action: "differentiate", ids, count_before: ids.length, count_after: ids.length }
902
+ };
903
+ }
904
+
905
+ function applyUpdate(d, service, snapshot, config = {}) {
906
+ const id = d.ids[0];
907
+ const mem = service.getById(id);
908
+ if (!mem || mem.archived) return "skipped";
909
+ // 幂等检查:如果字段已与目标一致则跳过
910
+ const same = (d.title === undefined || d.title === mem.title)
911
+ && (d.content === undefined || d.content === mem.content)
912
+ && (d.importance === undefined || d.importance === mem.importance);
913
+ if (same) return "skipped";
914
+ service.transaction(() => {
915
+ casGuard(service, snapshot, [id]);
916
+ const cur = service.getById(id);
917
+ if (!cur || cur.archived) return;
918
+ service.update(id, {
919
+ title: d.title ?? cur.title,
920
+ content: d.content ?? cur.content,
921
+ importance: d.importance ?? cur.importance
922
+ });
923
+ });
924
+ // 4.3.1 supersedes 关系(opt-in):事务提交成功后,为该记忆关联的每条实体属性
925
+ // 建立自引用 supersedes 关系,表示"此属性版本已被替代"。仅记录、绝不阻断主流程
926
+ // (fail-safe):记录失败只告警,update 本身照常生效。
927
+ if (config.entityExtractionEnabled && typeof service.saveRelation === "function" && typeof service.getAttrsByMemory === "function") {
928
+ try {
929
+ const oldAttrs = service.getAttrsByMemory(id);
930
+ for (const attr of oldAttrs) {
931
+ if (!attr.entity_id) continue;
932
+ service.saveRelation({
933
+ from_entity: attr.entity_id,
934
+ to_entity: attr.entity_id,
935
+ relation_type: "supersedes",
936
+ memory_id: id,
937
+ metadata: JSON.stringify({ attr_key: attr.attr_key, old_value: attr.attr_value })
938
+ });
939
+ }
940
+ } catch (error) {
941
+ logger?.warn?.(`dsh-mneme dream: failed to record supersedes relations for ${id}: ${error.message}`);
942
+ }
943
+ }
944
+ return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
945
+ }