@modusensus/dsh-mneme 0.13.0 → 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 -828
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -254
  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 -402
  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 -3394
  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 -13436
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1317
  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 -907
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -1887
  54. package/lib/dream/triage.js +139 -0
  55. package/lib/dream.js +1879 -1764
  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 -107
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -822
  74. package/lib/inject.js +656 -639
  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 -584
  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 -3196
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -802
  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 -5662
  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 -0
  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 -474
  142. package/lib/wiki/render.js +261 -187
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -463
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -502
  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 +15 -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 -204
  162. package/scripts/release-notes.mjs +164 -161
  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 -3394
  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 -1317
  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 -907
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -1887
  196. package/src/dream/triage.js +139 -0
  197. package/src/dream.js +1879 -1764
  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 -107
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -822
  216. package/src/inject.js +656 -639
  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 -584
  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 -3196
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -802
  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 -5662
  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 -0
  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 -474
  284. package/src/wiki/render.js +261 -187
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -463
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -502
  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 -2988
  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 -4184
  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 -0
  318. package/test/conflict-ledger.test.js +242 -0
  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 -320
  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 -0
  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 -209
  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 -211
  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 -106
  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 -494
  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 -0
  407. package/test/panel-tokens.test.js +245 -0
  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 -136
  453. package/test/release-notes.test.js +265 -240
  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 -178
  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 -1418
  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 -627
  529. package/test/wiki-claims.test.js +194 -0
  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 -0
  535. package/test/wiki-queries.test.js +398 -277
  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 -380
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -235
  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,1528 +1,1528 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { validateDecisions, applyDecisions, createDreamScheduler } from "../src/dream.js";
4
- import { createStore } from "../src/store.js";
5
- import { createService } from "../src/service.js";
6
- import { mockCtx, sessionDecisions } from "./helpers/dream-mock.js";
7
-
8
- function snapshot(ids, type = "project") {
9
- return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
10
- }
11
-
12
- test("valid decision list passes", () => {
13
- const snap = snapshot(["a", "b", "c"]);
14
- const decisions = [
15
- { action: "keep", ids: ["a"], reason: "ok" },
16
- { action: "merge", ids: ["b", "c"], title: "bc", content: "merged", importance: 4, keepSource: "b" }
17
- ];
18
- const { ok, errors } = validateDecisions(decisions, snap);
19
- assert.equal(ok, true);
20
- assert.deepEqual(errors, []);
21
- });
22
-
23
- test("unknown id rejects whole list", () => {
24
- const snap = snapshot(["a"]);
25
- const { ok, errors } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
26
- assert.equal(ok, false);
27
- assert.ok(errors.some((e) => e.includes("zzz")));
28
- });
29
-
30
- // --- Issue #135: 唯一前缀解析 -------------------------------------------------
31
- // 模型常把 36 位 UUID 回填成前缀(报告实测 78/78 个 8 位前缀都能唯一对应窗口内
32
- // 真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写)。校验前的唯一
33
- // 前缀解析把它们还原成完整 id,而不是逐条 unknown id 后被覆盖率闸整单拒绝。
34
-
35
- test("Issue #135: unique short prefixes resolve to full ids (ids + keepSource)", () => {
36
- const idA = "f77a0d17-2586-41fe-a789-d69f4d1cee83";
37
- const idB = "bc17c9ed-e84c-4b9f-bc36-48f8e9a6b3e0";
38
- const snap = snapshot([idA, idB]);
39
- const decisions = [
40
- { action: "merge", ids: ["f77a0d17", "bc17c9ed"], title: "合并", content: "合并后的内容", importance: 4, keepSource: "f77a0d17" }
41
- ];
42
- const { ok, errors, resolvedShortIds } = validateDecisions(decisions, snap);
43
- assert.equal(ok, true, JSON.stringify(errors));
44
- assert.equal(resolvedShortIds, 3, "two ids + keepSource resolved");
45
- assert.deepEqual(decisions[0].ids, [idA, idB], "ids rewritten in place");
46
- assert.equal(decisions[0].keepSource, idA, "keepSource rewritten");
47
- });
48
-
49
- test("Issue #135: conflict winner/loser short prefixes resolve", () => {
50
- const idA = "f77a0d17-2586-41fe-a789-d69f4d1cee83";
51
- const idB = "bc17c9ed-e84c-4b9f-bc36-48f8e9a6b3e0";
52
- const snap = snapshot([idA, idB]);
53
- const decisions = [{ action: "conflict", winner: "f77a0d17", loser: "bc17c9ed", reason: "内容矛盾" }];
54
- const { ok, errors, resolvedShortIds } = validateDecisions(decisions, snap);
55
- assert.equal(ok, true, JSON.stringify(errors));
56
- assert.equal(resolvedShortIds, 2);
57
- assert.equal(decisions[0].winner, idA, "winner rewritten");
58
- assert.equal(decisions[0].loser, idB, "loser rewritten");
59
- });
60
-
61
- test("Issue #135: ambiguous prefix stays unresolved and reports unknown id", () => {
62
- const snap = snapshot([
63
- "aaaaaaaa-1111-4111-8111-111111111111",
64
- "aaaaaaaa-2222-4222-8222-222222222222"
65
- ]);
66
- const { ok, errors, resolvedShortIds } = validateDecisions([{ action: "archive", ids: ["aaaaaaaa"] }], snap);
67
- assert.equal(ok, false);
68
- assert.equal(resolvedShortIds, 0, "ambiguous prefix is never guessed");
69
- assert.ok(errors.some((e) => e.includes("unknown id")), JSON.stringify(errors));
70
- });
71
-
72
- test("Issue #135: unmatched prefix is left for the validator to report", () => {
73
- const snap = snapshot(["f77a0d17-2586-41fe-a789-d69f4d1cee83"]);
74
- const { ok, errors, resolvedShortIds } = validateDecisions([{ action: "archive", ids: ["deadbeef"] }], snap);
75
- assert.equal(ok, false);
76
- assert.equal(resolvedShortIds, 0, "no match means no rewrite");
77
- assert.ok(errors.some((e) => e.includes("deadbeef")), JSON.stringify(errors));
78
- });
79
-
80
- test("invalid action rejects", () => {
81
- const snap = snapshot(["a"]);
82
- const { ok } = validateDecisions([{ action: "explode", ids: ["a"] }], snap);
83
- assert.equal(ok, false);
84
- });
85
-
86
- test("merge keepSource must be in ids", () => {
87
- const snap = snapshot(["a", "b"]);
88
- const { ok } = validateDecisions([{ action: "merge", ids: ["a"], keepSource: "b", title: "t", content: "c" }], snap);
89
- assert.equal(ok, false);
90
- });
91
-
92
- test("conflict winner and loser must exist and differ", () => {
93
- const snap = snapshot(["a", "b"]);
94
- const { ok } = validateDecisions([{ action: "conflict", winner: "a", loser: "a" }], snap);
95
- assert.equal(ok, false);
96
- const { ok: ok2 } = validateDecisions([{ action: "conflict", winner: "a", loser: "zzz" }], snap);
97
- assert.equal(ok2, false);
98
- });
99
-
100
- test("duplicate primary ids across decisions reject", () => {
101
- const snap = snapshot(["a", "b"]);
102
- const { ok } = validateDecisions([
103
- { action: "archive", ids: ["a"] },
104
- { action: "keep", ids: ["a"] }
105
- ], snap);
106
- assert.equal(ok, false, "a claimed twice");
107
- });
108
-
109
- test("archived or summary entries cannot be decision targets", () => {
110
- const snap = new Map([["arch", { id: "arch", type: "project", title: "t", content: "c", importance: 3, archived: true, forgotten: false }]]);
111
- const { ok } = validateDecisions([{ action: "archive", ids: ["arch"] }], snap);
112
- assert.equal(ok, false);
113
- });
114
-
115
- test("empty decision list is a no-op success (model: nothing to consolidate)", () => {
116
- // 合法 JSON [] 是模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 允许空输出),
117
- // 不是空体/截断——显式短路 ok,避免隐式 keep 的覆盖率检查误判 0% 为失败。
118
- const { ok, errors, skipped } = validateDecisions([], snapshot(["a"]));
119
- assert.equal(ok, true);
120
- assert.deepEqual(errors, []);
121
- assert.deepEqual(skipped, []);
122
- });
123
-
124
- test("empty ids rejects", () => {
125
- const { ok } = validateDecisions([{ action: "archive", ids: [] }], snapshot(["a"]));
126
- assert.equal(ok, false);
127
- });
128
-
129
- test("merge requires non-empty title and content", () => {
130
- const snap = snapshot(["a"]);
131
- const base = { action: "merge", ids: ["a"], keepSource: "a" };
132
- for (const [title, content] of [["", "x"], ["t", ""], [undefined, "x"], ["t", undefined]]) {
133
- const { ok } = validateDecisions([{ ...base, title, content }], snap);
134
- assert.equal(ok, false, `title=${JSON.stringify(title)} content=${JSON.stringify(content)}`);
135
- }
136
- });
137
-
138
- test("summary entries cannot be decision targets", () => {
139
- const snap = new Map([["s", { id: "s", type: "summary", title: "t", content: "c", importance: 3, archived: false, forgotten: false }]]);
140
- const { ok } = validateDecisions([{ action: "archive", ids: ["s"] }], snap);
141
- assert.equal(ok, false);
142
- });
143
-
144
- test("uncovered snapshot memories are auto-filled with keep (implicit keep, v0.4.4)", () => {
145
- const snap = snapshot(["a", "b"]);
146
- const decisions = [{ action: "keep", ids: ["a"] }];
147
- const { ok, errors } = validateDecisions(decisions, snap);
148
- assert.equal(ok, true, errors.join("; "));
149
- assert.equal(decisions.length, 2, "keep appended for the uncovered snapshot id");
150
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("b")), "b auto-kept");
151
- });
152
-
153
- test("dreamImplicitKeep=false keeps the strict full-coverage validation", () => {
154
- const snap = snapshot(["a", "b"]);
155
- const decisions = [{ action: "keep", ids: ["a"] }];
156
- const { ok, errors } = validateDecisions(decisions, snap, { dreamImplicitKeep: false });
157
- assert.equal(ok, false);
158
- assert.ok(errors.some((e) => e.includes("missing from decisions")));
159
- });
160
-
161
- test("duplicate ids within one decision reject", () => {
162
- const { ok } = validateDecisions([{ action: "keep", ids: ["a", "a"] }], snapshot(["a"]));
163
- assert.equal(ok, false);
164
- });
165
-
166
- test("merge importance out of range rejects", () => {
167
- const snap = snapshot(["a", "b"]);
168
- for (const importance of [0, 6, 99, 1.5, "4"]) {
169
- const { ok, errors } = validateDecisions([
170
- { action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance }
171
- ], snap);
172
- assert.equal(ok, false, `importance=${JSON.stringify(importance)} rejected`);
173
- assert.ok(errors.some((e) => e.includes("importance")), `importance error present for ${JSON.stringify(importance)}`);
174
- }
175
- const { ok } = validateDecisions([
176
- { action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance: 5 }
177
- ], snap);
178
- assert.equal(ok, true, "importance 5 accepted");
179
- });
180
-
181
- test("merge across types rejects", () => {
182
- const snap = new Map([
183
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
184
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
185
- ]);
186
- const { ok, errors } = validateDecisions([
187
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
188
- ], snap);
189
- assert.equal(ok, false, "cross-type merge rejected");
190
- assert.ok(errors.some((e) => e.includes("multiple types")), "multi-type error present");
191
- });
192
-
193
- function dreamSetup() {
194
- const store = createStore(":memory:");
195
- const service = createService({ store, mirror: null, config: {} });
196
- return { store, service };
197
- }
198
-
199
- test("applyDecisions merges: keepSource updated, others archived", () => {
200
- const { store, service } = dreamSetup();
201
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
202
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
203
- const { applied } = applyDecisions([
204
- { action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
205
- ], service);
206
- assert.equal(applied, 1);
207
- const keeper = store.getById(b.id);
208
- assert.equal(keeper.content, "合并内容");
209
- assert.equal(keeper.title, "插件总览");
210
- assert.equal(keeper.importance, 5);
211
- assert.equal(store.getById(a.id).archived, true, "source archived");
212
- });
213
-
214
- test("applyDecisions conflict: winner kept, loser archived with provenance", () => {
215
- const { store, service } = dreamSetup();
216
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
217
- const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
218
- applyDecisions([{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }], service);
219
- assert.equal(store.getById(l.id).archived, true);
220
- const winner = store.getById(w.id);
221
- assert.ok(winner.content.includes("8月20日"), "winner content intact");
222
- assert.ok(winner.content.includes("已否决旧信息"), "provenance note appended");
223
- });
224
-
225
- test("applyDecisions archive and keep", () => {
226
- const { store, service } = dreamSetup();
227
- const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
228
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "废弃", content: "过时" });
229
- applyDecisions([
230
- { action: "keep", ids: [k.id] },
231
- { action: "archive", ids: [a.id], reason: "过时" }
232
- ], service);
233
- assert.equal(store.getById(k.id).archived, false);
234
- assert.equal(store.getById(a.id).archived, true);
235
- });
236
-
237
- test("applyDecisions returns count and never throws on unknown id (skip)", () => {
238
- const { store, service } = dreamSetup();
239
- const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
240
- const { applied } = applyDecisions([{ action: "archive", ids: ["ghost"], reason: "x" }], service);
241
- assert.equal(applied, 0);
242
- assert.equal(store.getById(k.id).archived, false);
243
- });
244
-
245
- test("applyDecisions catch path: throwing decision is skipped, logged, and later decisions still apply", () => {
246
- const { store, service } = dreamSetup();
247
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "会炸", content: "x" });
248
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "正常", content: "y" });
249
- const originalSetArchived = service.setArchived;
250
- let calls = 0;
251
- service.setArchived = (id, archived) => {
252
- calls++;
253
- if (calls === 1) throw new Error("boom");
254
- return originalSetArchived.call(service, id, archived);
255
- };
256
- const warnings = [];
257
- const logger = { warn: (msg) => warnings.push(msg) };
258
- const { applied, failures } = applyDecisions([
259
- { action: "archive", ids: [a.id], reason: "x" },
260
- { action: "archive", ids: [b.id], reason: "y" }
261
- ], service, logger);
262
- assert.equal(applied, 1, "throwing decision not counted, surviving decision counted");
263
- assert.equal(failures.length, 1, "thrown decision reported as a failure");
264
- assert.equal(store.getById(a.id).archived, false, "throwing decision left no partial effect");
265
- assert.equal(store.getById(b.id).archived, true, "later decision still applied");
266
- assert.equal(warnings.length, 1, "logger called once");
267
- assert.match(warnings[0], /failed to apply archive at index 0: boom/);
268
- });
269
-
270
- test("applyDecisions conflict with missing loser skips cleanly", () => {
271
- const { store, service } = dreamSetup();
272
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
273
- const { applied } = applyDecisions([{ action: "conflict", winner: w.id, loser: "ghost", reason: "x" }], service);
274
- assert.equal(applied, 0);
275
- assert.equal(store.getById(w.id).archived, false, "winner untouched");
276
- assert.ok(!store.getById(w.id).content.includes("已否决"), "no provenance note appended");
277
- });
278
-
279
- test("applyDecisions merge with missing keeper skips cleanly", () => {
280
- const { store, service } = dreamSetup();
281
- const { applied } = applyDecisions([
282
- { action: "merge", ids: ["ghost"], keepSource: "ghost", title: "t", content: "c" }
283
- ], service);
284
- assert.equal(applied, 0);
285
- assert.equal(store.getById("ghost"), undefined);
286
- });
287
-
288
- test("applyDecisions merge without importance falls back to max source importance", () => {
289
- const { store, service } = dreamSetup();
290
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
291
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
292
- const { applied } = applyDecisions([
293
- { action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", keepSource: b.id }
294
- ], service);
295
- assert.equal(applied, 1);
296
- assert.equal(store.getById(b.id).importance, 4, "keeper keeps max of source importances");
297
- assert.equal(store.getById(a.id).archived, true, "source archived");
298
- });
299
-
300
- test("maybeSchedule triggers when count exceeds threshold", () => {
301
- const { store, service } = dreamSetup();
302
- let runs = 0;
303
- const dream = createDreamScheduler({
304
- onRun: async () => { runs++; },
305
- thresholdCount: 3,
306
- thresholdChars: 5000,
307
- delayMs: 0
308
- });
309
- for (let i = 0; i < 3; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(100) });
310
- const pending = dream.maybeSchedule(service);
311
- assert.equal(pending, true, "scheduled");
312
- assert.equal(runs, 0, "not run yet (async)");
313
- store.close();
314
- });
315
-
316
- test("maybeSchedule does not trigger below threshold", () => {
317
- const { store, service } = dreamSetup();
318
- const dream = createDreamScheduler({ onRun: async () => {}, thresholdCount: 10, thresholdChars: 5000, delayMs: 0 });
319
- service.saveWithDedupe({ type: "project", title: "only", content: "x" });
320
- assert.equal(dream.maybeSchedule(service), false);
321
- store.close();
322
- });
323
-
324
- test("scheduler fires async and resets baseline", async () => {
325
- const { store, service } = dreamSetup();
326
- let runs = 0;
327
- const dream = createDreamScheduler({
328
- onRun: async () => { runs++; return { ok: true }; },
329
- thresholdCount: 2, thresholdChars: 5000, delayMs: 5
330
- });
331
- for (let i = 0; i < 2; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(50) });
332
- dream.maybeSchedule(service);
333
- await new Promise((r) => setTimeout(r, 50));
334
- assert.equal(runs, 1, "ran once");
335
- // still above threshold but baseline reset → no immediate re-trigger
336
- assert.equal(dream.maybeSchedule(service), false, "baseline prevents loop");
337
- store.close();
338
- });
339
-
340
- test("failed run does not refresh baseline: next write re-triggers", async () => {
341
- const { store, service } = dreamSetup();
342
- let calls = 0;
343
- const dream = createDreamScheduler({
344
- onRun: async () => {
345
- calls++;
346
- return { ok: false, error: "llm failed" };
347
- },
348
- thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
349
- logger: { warn: () => {} }
350
- });
351
- service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
352
- service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
353
- assert.equal(dream.maybeSchedule(service), true, "scheduled");
354
- await new Promise((r) => setTimeout(r, 50));
355
- assert.equal(calls, 1, "run attempted once");
356
- // baseline NOT refreshed on failure → the same write volume still triggers
357
- assert.equal(dream.maybeSchedule(service), true, "failed run keeps baseline, re-schedules");
358
- await new Promise((r) => setTimeout(r, 50));
359
- assert.equal(calls, 2, "retried after failure");
360
- store.close();
361
- });
362
-
363
- test("noop run (nothing changed) does not refresh baseline: next write re-triggers", async () => {
364
- const { store, service } = dreamSetup();
365
- let calls = 0;
366
- const dream = createDreamScheduler({
367
- onRun: async () => { calls++; return { ok: false, status: "noop", applied: 0, summary: false }; },
368
- thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
369
- logger: { warn: () => {} }
370
- });
371
- service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
372
- service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
373
- assert.equal(dream.maybeSchedule(service), true, "scheduled");
374
- await new Promise((r) => setTimeout(r, 50));
375
- assert.equal(calls, 1, "run attempted once");
376
- // a noop is not a success: the baseline stays put so the accumulated writes
377
- // are still owed and the next write re-schedules instead of being absorbed
378
- assert.equal(dream.maybeSchedule(service), true, "noop keeps baseline, re-schedules");
379
- await new Promise((r) => setTimeout(r, 50));
380
- assert.equal(calls, 2, "retried after noop");
381
- store.close();
382
- });
383
-
384
- test("throwing run does not refresh baseline and is logged", async () => {
385
- const { store, service } = dreamSetup();
386
- const warnings = [];
387
- let calls = 0;
388
- const dream = createDreamScheduler({
389
- onRun: async () => {
390
- calls++;
391
- throw new Error("boom");
392
- },
393
- thresholdCount: 1, thresholdChars: 0, delayMs: 5,
394
- logger: { warn: (msg) => warnings.push(msg) }
395
- });
396
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
397
- assert.equal(dream.maybeSchedule(service), true, "scheduled");
398
- await new Promise((r) => setTimeout(r, 30));
399
- assert.equal(calls, 1, "run attempted once");
400
- assert.ok(warnings.some((m) => m.includes("run failed")), "throw logged");
401
- assert.equal(dream.maybeSchedule(service), true, "throw keeps baseline, re-schedules");
402
- store.close();
403
- });
404
-
405
- test("maybeSchedule returns false while a run is in flight", async () => {
406
- const { store, service } = dreamSetup();
407
- let release;
408
- const gate = new Promise((r) => { release = r; });
409
- let entered = false;
410
- const dream = createDreamScheduler({
411
- onRun: async () => { entered = true; await gate; },
412
- thresholdCount: 1, thresholdChars: 0, delayMs: 0,
413
- logger: { warn: () => {} }
414
- });
415
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
416
- assert.equal(dream.maybeSchedule(service), true, "first schedule accepted");
417
- await new Promise((r) => setTimeout(r, 20));
418
- assert.equal(entered, true, "run started");
419
- assert.equal(dream.maybeSchedule(service), false, "no schedule while running");
420
- release();
421
- await new Promise((r) => setTimeout(r, 10)); // let the run finish + baseline refresh
422
- store.close();
423
- });
424
-
425
- test("dispose clears pending timer and blocks future scheduling", async () => {
426
- const { store, service } = dreamSetup();
427
- let runs = 0;
428
- const dream = createDreamScheduler({
429
- onRun: async () => { runs++; },
430
- thresholdCount: 1, thresholdChars: 0, delayMs: 5,
431
- logger: { warn: () => {} }
432
- });
433
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
434
- assert.equal(dream.maybeSchedule(service), true, "scheduled");
435
- dream.dispose();
436
- await new Promise((r) => setTimeout(r, 30));
437
- assert.equal(runs, 0, "pending run cancelled by dispose");
438
- assert.equal(dream.maybeSchedule(service), false, "disposed scheduler never schedules");
439
- store.close();
440
- });
441
-
442
- test("dispose awaits an in-flight run so the store can close safely", async () => {
443
- const { store, service } = dreamSetup();
444
- let release;
445
- const gate = new Promise((r) => { release = r; });
446
- let entered = false;
447
- let finished = false;
448
- const dream = createDreamScheduler({
449
- onRun: async () => { entered = true; await gate; finished = true; },
450
- thresholdCount: 1, thresholdChars: 0, delayMs: 0,
451
- logger: { warn: () => {} }
452
- });
453
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
454
- assert.equal(dream.maybeSchedule(service), true, "scheduled");
455
- await new Promise((r) => setTimeout(r, 20));
456
- assert.equal(entered, true, "run started");
457
-
458
- const disposeP = dream.dispose();
459
- let settled = false;
460
- disposeP.then(() => { settled = true; });
461
- await new Promise((r) => setTimeout(r, 10));
462
- assert.equal(settled, false, "dispose must not resolve while a run is in flight");
463
- assert.equal(finished, false, "run still pending");
464
-
465
- release();
466
- await disposeP;
467
- assert.equal(finished, true, "run completed before dispose resolved");
468
- store.close();
469
- });
470
-
471
- test("runDream stores summary and applies decisions", async () => {
472
- const { store, service } = dreamSetup();
473
- // seed 2 memories so snapshot is non-empty and decisions cover them
474
- const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
475
- const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
476
- let calls = 0;
477
- const ctx = {
478
- llm: {
479
- stream: async function* () {
480
- calls++;
481
- if (calls === 1) {
482
- const text = JSON.stringify([
483
- { action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }
484
- ]);
485
- yield { type: "text-delta", text };
486
- } else {
487
- yield { type: "text-delta", text: "记忆库总览摘要文本" };
488
- }
489
- yield { type: "finish", reason: { kind: "ok" } };
490
- }
491
- },
492
- logger: { warn: () => {} }
493
- };
494
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
495
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
496
- assert.equal(result.ok, true);
497
- assert.equal(result.applied, 1, "merge decision applied");
498
- assert.equal(result.summary, true, "summary stored");
499
- const keeper = store.getById(a.memory.id);
500
- assert.equal(keeper.title, "合并标题", "keeper title updated");
501
- assert.equal(keeper.content, "合并后的内容", "keeper content updated");
502
- assert.equal(keeper.importance, 4, "keeper importance updated");
503
- assert.equal(store.getById(b.memory.id).archived, true, "merged source archived");
504
- const summary = store.all().find((m) => m.type === "summary");
505
- assert.ok(summary, "summary created");
506
- assert.equal(summary.title, "记忆库总览");
507
- // 常驻状态条(#164 对齐):叙述正文 + 快照口径脚注
508
- assert.ok(summary.content.startsWith("记忆库总览摘要文本"));
509
- assert.ok(summary.content.includes("〔口径:基于整理后"), "scope footer present");
510
- assert.ok(summary.content.includes("条记忆快照"), "scope footer carries input count");
511
- store.close();
512
- });
513
-
514
- test("runDream fails safe on invalid decisions", async () => {
515
- const { store, service } = dreamSetup();
516
- const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
517
- let calls = 0;
518
- const warnings = [];
519
- const ctx = {
520
- llm: {
521
- stream: async function* () {
522
- calls++;
523
- yield { type: "text-delta", text: calls === 1 ? "not json at all" : "summary" };
524
- yield { type: "finish", reason: { kind: "ok" } };
525
- }
526
- },
527
- logger: { warn: (msg) => warnings.push(msg) }
528
- };
529
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
530
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
531
- assert.equal(result.ok, false, "invalid decisions rejected");
532
- assert.equal(result.summary, false, "no summary flag on failure");
533
- assert.equal(store.all().filter((m) => m.type === "summary").length, 0, "no summary on failure");
534
- const lang = store.getById(saved.memory.id);
535
- assert.ok(lang, "original memory still present");
536
- assert.equal(lang.archived, false, "original memory not archived");
537
- assert.equal(lang.content, "中文", "original memory content untouched");
538
- assert.ok(warnings.length >= 1, "failure was logged");
539
- store.close();
540
- });
541
-
542
- test("Issue #135: failed validation persists { _validationFailed, errors, skipped } into the run row", async () => {
543
- const { store, service } = dreamSetup();
544
- const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
545
- let calls = 0;
546
- const ctx = {
547
- llm: {
548
- stream: async function* () {
549
- calls++;
550
- yield { type: "text-delta", text: calls === 1 ? JSON.stringify([{ action: "archive", ids: ["deadbeef"] }]) : "summary" };
551
- yield { type: "finish", reason: { kind: "ok" } };
552
- }
553
- },
554
- logger: { warn: () => {}, info: () => {} }
555
- };
556
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
557
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
558
- assert.equal(result.ok, false, "coverage gate rejects (0% explicit coverage)");
559
- assert.ok(Array.isArray(result.decisions) && result.decisions[0]?._validationFailed === true,
560
- "failure result carries the validation detail");
561
- assert.ok(result.decisions[0].skipped.some((s) => s.error.includes("deadbeef")),
562
- "skipped keeps the per-item reason");
563
- // 落库验证:dream_runs.decisions 不再是 NULL,事后可离线定位失败原因。
564
- const run = store.getDreamRun(result.runId);
565
- assert.ok(run?.decisions?.[0]?._validationFailed === true, "detail persisted in dream_runs.decisions");
566
- assert.ok(run.decisions[0].skipped.length >= 1);
567
- const lang = store.getById(saved.memory.id);
568
- assert.equal(lang.archived, false, "unknown-id decision must not touch real memories");
569
- store.close();
570
- });
571
-
572
- // --- item ①: CAS guard against concurrent edits ----------------------------
573
-
574
- test("applyDecisions CAS: merge onto a concurrently-edited target is skipped as a conflict, not applied", () => {
575
- const { store, service } = dreamSetup();
576
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
577
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
578
- // snapshot captured before the "LLM call"; a concurrent edit lands meanwhile
579
- const snapshot = new Map([a.id, b.id].map((id) => [id, store.getById(id)]));
580
- service.update(a.id, { content: "并发编辑" });
581
- const { applied, conflicts, committed } = applyDecisions([
582
- { action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
583
- ], service, null, snapshot);
584
- assert.equal(applied, 0, "decision skipped entirely");
585
- assert.equal(conflicts.length, 1, "recorded as a CAS conflict");
586
- assert.equal(committed.length, 0, "nothing committed");
587
- assert.equal(store.getById(a.id).content, "并发编辑", "concurrent edit preserved");
588
- assert.equal(store.getById(b.id).archived, false, "source not archived");
589
- assert.equal(store.getById(b.id).title, "插件2", "keeper untouched");
590
- store.close();
591
- });
592
-
593
- test("applyDecisions CAS: update to a concurrently-edited memory is skipped as a conflict", () => {
594
- const { store, service } = dreamSetup();
595
- const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "喜欢 Python" });
596
- const snapshot = new Map([[m.id, store.getById(m.id)]]);
597
- service.update(m.id, { content: "并发改动" });
598
- const { applied, conflicts } = applyDecisions(
599
- [{ action: "update", ids: [m.id], content: "喜欢 Rust" }],
600
- service, null, snapshot
601
- );
602
- assert.equal(applied, 0);
603
- assert.equal(conflicts.length, 1);
604
- assert.equal(store.getById(m.id).content, "并发改动", "concurrent edit wins");
605
- store.close();
606
- });
607
-
608
- test("applyDecisions without a snapshot skips the CAS guard (replay path unchanged)", () => {
609
- const { store, service } = dreamSetup();
610
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
611
- service.update(a.id, { content: "并发编辑" }); // concurrent edit
612
- const { applied, conflicts } = applyDecisions([
613
- { action: "archive", ids: [a.id], reason: "x" }
614
- ], service);
615
- assert.equal(applied, 1, "snapshotless replay applies (no CAS guard)");
616
- assert.equal(conflicts.length, 0);
617
- assert.equal(store.getById(a.id).archived, true);
618
- store.close();
619
- });
620
-
621
- // --- item ②: per-decision transaction atomicity ----------------------------
622
-
623
- test("applyDecisions merge is atomic: a throwing archive step rolls back the keeper update too", () => {
624
- const { store, service } = dreamSetup();
625
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "甲", content: "旧甲", importance: 3 });
626
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "乙", content: "旧乙", importance: 4 });
627
- const originalSetArchived = service.setArchived;
628
- service.setArchived = (id, archived) => {
629
- if (id === a.id) throw new Error("archive boom");
630
- return originalSetArchived.call(service, id, archived);
631
- };
632
- const warnings = [];
633
- const { applied, failures, committed } = applyDecisions([
634
- { action: "merge", ids: [a.id, b.id], title: "甲乙", content: "合并", importance: 4, keepSource: b.id }
635
- ], service, { warn: (m) => warnings.push(m) });
636
- assert.equal(applied, 0, "merge not committed");
637
- assert.equal(failures.length, 1, "reported as a failure");
638
- assert.equal(committed.length, 0, "outcome must not claim a merge that rolled back");
639
- assert.equal(store.getById(b.id).title, "乙", "keeper title untouched by the rolled-back update");
640
- assert.equal(store.getById(b.id).content, "旧乙", "keeper content untouched");
641
- assert.equal(store.getById(a.id).archived, false, "source not archived");
642
- assert.ok(warnings.length >= 1, "failure logged");
643
- store.close();
644
- });
645
-
646
- // --- conflict freeze: manual review instead of auto-adjudication ----------
647
-
648
- function freezeCtx({ conflicts, includes = [], summaryText = "记忆库总览摘要" }) {
649
- let calls = 0;
650
- const warnings = [];
651
- const ctx = {
652
- warnings,
653
- llm: {
654
- stream: async function* () {
655
- calls++;
656
- const list = [...includes, ...conflicts];
657
- yield { type: "text-delta", text: calls === 1 ? JSON.stringify(list) : summaryText };
658
- yield { type: "finish", reason: { kind: "ok" } };
659
- }
660
- },
661
- logger: { warn: (m) => warnings.push(m) }
662
- };
663
- return ctx;
664
- }
665
-
666
- test("runDream with conflictFreezeEnabled parks conflicts instead of adjudicating", async () => {
667
- const { store, service } = dreamSetup();
668
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
669
- const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
670
- const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新,候选取新" }] });
671
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
672
- const result = await dream.runDream(ctx, service, {
673
- dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
674
- });
675
- assert.equal(result.ok, true);
676
- assert.equal(result.applied, 0, "no conflict applied");
677
- assert.equal(result.frozen, 1, "one conflict frozen");
678
- // pending row recorded for human review
679
- const pending = store.listConflictPending();
680
- assert.equal(pending.length, 1);
681
- assert.equal(pending[0].reason, "日期更新,候选取新");
682
- // neither side was auto-adjudicated
683
- assert.equal(store.getById(l.id).archived, false, "loser NOT archived");
684
- assert.equal(store.getById(w.id).archived, false, "winner NOT archived");
685
- assert.ok(!store.getById(w.id).content.includes("已否决旧信息"), "no provenance note appended");
686
- // audit outcome marks both sides pending
687
- const run = store.listDreamRuns()[0];
688
- assert.equal(run.outcome.byId[w.id], "conflict-pending");
689
- assert.equal(run.outcome.byId[l.id], "conflict-pending");
690
- assert.equal(run.status, "ok", "summary stored + freeze landed → ok");
691
- assert.equal(store.listReceipts().length, 0, "no conflict receipt for a frozen (unapplied) conflict");
692
- store.close();
693
- });
694
-
695
- test("runDream freeze keeps auto-adjudication when disabled (default)", async () => {
696
- const { store, service } = dreamSetup();
697
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
698
- const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
699
- const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }] });
700
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
701
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
702
- assert.equal(result.ok, true);
703
- assert.equal(result.applied, 1, "conflict auto-adjudicated when freeze is off");
704
- assert.equal(result.frozen, 0);
705
- assert.equal(store.getById(l.id).archived, true, "loser archived");
706
- assert.ok(store.getById(w.id).content.includes("已否决旧信息"), "provenance note appended");
707
- assert.equal(store.listConflictPending().length, 0, "no pending rows in auto mode");
708
- store.close();
709
- });
710
-
711
- test("runDream freeze applies non-conflict decisions while parking conflicts", async () => {
712
- const { store, service } = dreamSetup();
713
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
714
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
715
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
716
- const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
717
- const ctx = freezeCtx({
718
- includes: [{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }],
719
- conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新" }]
720
- });
721
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
722
- const result = await dream.runDream(ctx, service, {
723
- dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
724
- });
725
- assert.equal(result.ok, true);
726
- assert.equal(result.applied, 1, "merge applied normally");
727
- assert.equal(result.frozen, 1, "conflict frozen");
728
- assert.equal(store.getById(b.id).title, "插件总览", "merge keeper updated");
729
- assert.equal(store.getById(a.id).archived, true, "merge source archived");
730
- assert.equal(store.getById(l.id).archived, false, "conflict loser untouched by the merge run");
731
- const pending = store.listConflictPending();
732
- assert.equal(pending.length, 1);
733
- assert.ok(pending[0].reason.includes("日期更新"));
734
- store.close();
735
- });
736
-
737
- test("runDream freeze respects conflictFreezeMaxPending cap and skips overflow", async () => {
738
- const { store, service } = dreamSetup();
739
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
740
- const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
741
- const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
742
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
743
- const result = await dream.runDream(ctx, service, {
744
- dreamProvider: "deepseek", dreamModel: "deepseek-chat",
745
- conflictFreezeEnabled: true, conflictFreezeMaxPending: 0
746
- });
747
- assert.equal(result.frozen, 0, "nothing frozen at capacity");
748
- assert.equal(store.listConflictPending().length, 0, "no pending rows");
749
- assert.ok(ctx.warnings.some((m) => m.includes("freeze queue full")), "capacity warning logged");
750
- store.close();
751
- });
752
-
753
- test("runDream freeze store failure never blocks the run (fail-safe)", async () => {
754
- const { store, service } = dreamSetup();
755
- const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
756
- const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
757
- service.saveConflictPending = () => { throw new Error("pending store boom"); };
758
- service.countConflictPending = () => { throw new Error("count boom"); };
759
- const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
760
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
761
- const result = await dream.runDream(ctx, service, {
762
- dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
763
- });
764
- assert.equal(result.ok, true, "run completes despite freeze store failure");
765
- assert.equal(result.frozen, 0, "nothing frozen");
766
- assert.ok(ctx.warnings.length >= 1, "freeze failure logged");
767
- assert.equal(store.getById(l.id).archived, false, "no side effects on memories");
768
- assert.equal(store.getById(w.id).content, "8月20日", "winner untouched");
769
- store.close();
770
- });
771
-
772
- // --- v0.4.4: 大记忆量 autoDream(滑动窗口 + 隐式 keep)回归 -----------------
773
-
774
- test("autoDream with 650 memories: sliding window truncates snapshot + implicit keep fills uncovered ids", async () => {
775
- const { store, service } = dreamSetup();
776
- // seed 650 memories — the size that used to produce 677 "missing from
777
- // decisions" errors and applied=0 under the strict full-coverage check
778
- for (let i = 0; i < 650; i++) {
779
- service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
780
- }
781
- // mock LLM claims only a small subset (20 archives) and never emits keeps
782
- // for the rest — implicit keep must auto-fill the uncovered snapshot ids
783
- const ctx = mockCtx({
784
- onConsolidation: (listText) => {
785
- const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
786
- .map((m) => m[1]);
787
- return JSON.stringify(ids.slice(0, 20).map((id) => ({ action: "archive", ids: [id], reason: "stale" })));
788
- }
789
- });
790
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
791
- const result = await dream.runDream(ctx, service, {
792
- dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 200,
793
- // mock only claims 20/200 = 10%; the implicit-keep coverage floor must be
794
- // lowered so this deliberate "tiny explicit claim" scenario still passes
795
- // (it exercises the window + keep-fill, not the coverage guard). Same for
796
- // the archive cap: 20 mock archives exceed the default 8 — #104 方向 2.
797
- dreamMinExplicitCoverage: 0.1,
798
- dreamMaxArchivePerRun: 20
799
- });
800
- assert.equal(result.ok, true, "run succeeds instead of 677-error rejection");
801
- assert.ok(result.applied > 0, "archive decisions applied");
802
- // snapshot capped at the sliding window
803
- const run = store.listDreamRuns()[0];
804
- assert.equal(run.input_count, 200, "snapshot truncated to dreamMaxSnapshotSize");
805
- // decisions cover exactly the snapshot window, with implicit keeps
806
- assert.equal(result.decisions.length, 200, "decisions count = snapshot count");
807
- assert.equal(result.decisions.filter((d) => d.action === "archive").length, 20, "claimed subset present");
808
- assert.equal(result.decisions.filter((d) => d.action === "keep").length, 180, "uncovered ids auto-kept");
809
- assert.equal(store.getById(result.decisions.find((d) => d.action === "archive").ids[0]).archived, true, "an archive landed");
810
- store.close();
811
- });
812
-
813
- // --- v0.4.4 fix: 残缺输出防洗白(显式覆盖率下限) + 严格模式透传 --------------
814
-
815
- test("validateDecisions degrades a low-coverage valid output (Issue #104 方向 1)", () => {
816
- const snap = snapshot(["a", "b", "c", "d"]);
817
- const decisions = [{ action: "keep", ids: ["a"] }]; // claims 1/4 = 25%
818
- const { ok, errors, coverageShortfall } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
819
- assert.equal(ok, true, "valid subset applies — coverage shortfall degrades instead of rejecting");
820
- assert.deepEqual(errors, [], "shortfall is not a per-decision error");
821
- assert.match(
822
- String(coverageShortfall),
823
- /explicit decision coverage 25% < minimum 50%/,
824
- `coverage reason surfaced, got: ${coverageShortfall}`
825
- );
826
- assert.equal(decisions.length, 4, "implicit keeps fill the unclaimed ids");
827
- });
828
-
829
- test("validateDecisions covers the whole snapshot when explicit coverage meets the floor", () => {
830
- const snap = snapshot(["a", "b", "c", "d"]);
831
- const decisions = [{ action: "archive", ids: ["a", "b"], reason: "stale" }]; // claims 2/4 = 50%
832
- const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
833
- assert.equal(ok, true, errors.join("; "));
834
- assert.equal(decisions.length, 3, "archive + 2 implicit keeps for c/d");
835
- assert.equal(decisions.filter((d) => d.action === "keep").length, 2);
836
- });
837
-
838
- test("runDream with dreamImplicitKeep=false rejects a partial mock output (missing from decisions)", async () => {
839
- const { store, service } = dreamSetup();
840
- for (let i = 0; i < 3; i++) {
841
- service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
842
- }
843
- // mock only claims the first snapshot id, misses the rest — strict mode must
844
- // reject the whole run instead of auto-keeping the uncovered ids
845
- const warnings = [];
846
- const ctx = {
847
- warnings,
848
- logger: { warn: (m) => warnings.push(m) },
849
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "stress-model" }) },
850
- llm: {
851
- async *stream(options) {
852
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
853
- if (userText.includes("| type=")) {
854
- const ids = [...userText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)].map((m) => m[1]);
855
- yield { type: "text-delta", index: 0, text: JSON.stringify(ids.slice(0, 1).map((id) => ({ action: "archive", ids: [id], reason: "stale" }))) };
856
- } else {
857
- yield { type: "text-delta", index: 0, text: "记忆库总览摘要" };
858
- }
859
- yield { type: "finish", reason: { kind: "stop" } };
860
- }
861
- }
862
- };
863
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
864
- const result = await dream.runDream(ctx, service, {
865
- dreamProvider: "deepseek", dreamModel: "deepseek-chat",
866
- dreamImplicitKeep: false
867
- });
868
- assert.equal(result.ok, false, "strict mode rejects the partial output");
869
- assert.match(result.error, /invalid decisions: \d+ errors/);
870
- assert.ok(warnings.some((m) => m.includes("missing from decisions")), "warned which ids are missing");
871
- assert.equal(store.listDreamRuns()[0].status, "failed", "run audited as failed");
872
- store.close();
873
- });
874
-
875
- test("runDream sliding window keeps the newest N memories and excludes the oldest", async () => {
876
- const { store, service } = dreamSetup();
877
- // seed 5 memories, backdate updated_at so i=0 is oldest (5h ago), i=4 newest (1h ago)
878
- const ids = [];
879
- for (let i = 0; i < 5; i++) {
880
- const { memory } = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
881
- ids.push(memory.id);
882
- }
883
- for (let i = 0; i < 5; i++) {
884
- const old = new Date(Date.now() - (5 - i) * 3600000).toISOString();
885
- store.db.prepare("UPDATE memories SET updated_at = ?, created_at = ? WHERE id = ?").run(old, old, ids[i]);
886
- }
887
- const ctx = mockCtx({
888
- onConsolidation: (listText) => {
889
- const inWindow = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
890
- .map((m) => m[1]);
891
- return JSON.stringify(inWindow.map((id) => ({ action: "keep", ids: [id] })));
892
- }
893
- });
894
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
895
- const result = await dream.runDream(ctx, service, {
896
- dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 3
897
- });
898
- const run = store.listDreamRuns()[0];
899
- assert.equal(run.input_count, 3, "window capped at dreamMaxSnapshotSize");
900
- const windowIds = run.input.map((m) => m.id).sort();
901
- const expected = [ids[2], ids[3], ids[4]].sort(); // newest 3 by updated_at
902
- assert.deepEqual(windowIds, expected, "window contains the newest 3 memories");
903
- assert.ok(!windowIds.includes(ids[0]), "oldest memory excluded from the window");
904
- assert.equal(result.decisions.length, 3, "decisions cover exactly the window");
905
- store.close();
906
- });
907
-
908
- // --- issue #557:dream 审计行输入快照瘦身(dreamInputSummaryEnabled)-----------
909
- //
910
- // dream_runs.input 单行体积随记忆量涨(#557 实测平均 40-79 KB/轮,大头是
911
- // content),轮数又随天数涨,超线性。本组用例锁三件事:
912
- // ① 默认关 = 审计行逐字节一致(content 原样落库);
913
- // ② 开启后 auto 轮只落骨架(id/type/title/importance/updated_at),且
914
- // input_count / snapshot_hash / receipt / decisions 都不变——瘦身的只是
915
- // 落库副本,LLM 输入与 receipt 完整性不受影响;
916
- // ③ organize 轮的重放载荷不经过 dream.js 的快照构造(结构性分叉),这里锁
917
- // auto 路径的形状,organize 侧由 organize.test.js 的 apply 重放用例兜住。
918
-
919
- test("issue#557: 默认关 = 审计行快照带 content(行为与改动前一致)", async () => {
920
- const { store, service } = dreamSetup();
921
- const content = "很长的正文,瘦身前应原样落库。".repeat(20);
922
- for (let i = 0; i < 3; i++) {
923
- service.saveWithDedupe({ type: "project", title: `主题${i}`, content, importance: 3 });
924
- }
925
- const ctx = mockCtx({ onConsolidation: sessionDecisions });
926
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
927
- await dream.runDream(ctx, service, {
928
- dreamProvider: "deepseek", dreamModel: "deepseek-chat"
929
- });
930
- const run = store.listDreamRuns()[0];
931
- assert.equal(run.input_count, 3);
932
- assert.deepEqual(
933
- Object.keys(run.input[0]).sort(),
934
- ["content", "id", "importance", "title", "type", "updated_at"],
935
- "默认快照含 content"
936
- );
937
- assert.equal(run.input[0].content, content, "content 原样落库");
938
- store.close();
939
- });
940
-
941
- test("issue#557: dreamInputSummaryEnabled 开启后快照只留骨架,判定与回执不变", async () => {
942
- const { store, service } = dreamSetup();
943
- for (let i = 0; i < 3; i++) {
944
- service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `很长的正文${i}`.repeat(20), importance: 3 });
945
- }
946
- const ctx = mockCtx({ onConsolidation: sessionDecisions });
947
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
948
- const result = await dream.runDream(ctx, service, {
949
- dreamProvider: "deepseek", dreamModel: "deepseek-chat",
950
- dreamInputSummaryEnabled: true
951
- });
952
- const run = store.listDreamRuns()[0];
953
- assert.equal(run.input_count, 3, "input_count 语义不变");
954
- for (const m of run.input) {
955
- assert.deepEqual(
956
- Object.keys(m).sort(),
957
- ["id", "importance", "title", "type", "updated_at"],
958
- "瘦身快照不含 content"
959
- );
960
- }
961
- assert.deepEqual(run.input.map((m) => m.title).sort(), ["主题0", "主题1", "主题2"], "骨架字段带出标题");
962
- assert.ok(run.snapshot_hash && run.snapshot_hash.length === 64, "snapshot_hash 形状不变(内存里对含 content 快照计算)");
963
- assert.match(run.receipt, /^dsh-mneme:run:/, "receipt 形状不变");
964
- assert.ok(Array.isArray(run.decisions) && run.decisions.length > 0, "决策不受影响");
965
- assert.ok(JSON.stringify(run.input).length < 2048, "骨架行确实小(content 不在)");
966
- store.close();
967
- });
968
-
969
- // --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
970
-
971
- test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
972
- const { store, service } = dreamSetup();
973
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
974
- let systemText = "";
975
- const ctx = {
976
- logger: { warn: () => {} },
977
- llm: {
978
- async *stream(options) {
979
- // 只捕获第一次调用(consolidation)的 system 文本:空数组现在走 no-op 成功、
980
- // summary 会照常跑并覆盖 systemText,所以最后一次调用捕获到的是 SUMMARY_PROMPT。
981
- if (!systemText) systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
982
- yield { type: "text-delta", text: "[]" };
983
- yield { type: "finish", reason: { kind: "ok" } };
984
- }
985
- }
986
- };
987
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
988
- await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
989
- // 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
990
- assert.match(systemText, /"action"/, "prompt names the action field");
991
- assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
992
- // conflict winner/loser 是单个 id 字符串而非数组
993
- assert.match(systemText, /"winner"/, "prompt names the winner field");
994
- assert.match(systemText, /"loser"/, "prompt names the loser field");
995
- assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
996
- assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
997
- // 同一 id 不可被多个决策重复 claim
998
- assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
999
- // 决策 JSON 示例块
1000
- assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
1001
- store.close();
1002
- });
1003
-
1004
- // --- Bug8: llm_audit_logs trail ----------------------------------------------
1005
-
1006
- test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
1007
- const { store, service } = dreamSetup();
1008
- service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
1009
- service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
1010
- const ctx = mockCtx({
1011
- onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
1012
- });
1013
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1014
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
1015
- assert.equal(result.ok, true, "run succeeds");
1016
- const rows = store.listLlmAudits();
1017
- assert.equal(rows.length, 2, "consolidation + summary both audited");
1018
- assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
1019
- assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
1020
- const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
1021
- assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
1022
- const summarize = rows.find((r) => r.operation_type === "dream_summarize");
1023
- assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
1024
- for (const row of rows) {
1025
- assert.equal(row.status, "success");
1026
- assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
1027
- assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
1028
- assert.equal(row.input_tokens, 0);
1029
- assert.equal(row.output_tokens, 0);
1030
- }
1031
- store.close();
1032
- });
1033
-
1034
- test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
1035
- const { store, service } = dreamSetup();
1036
- service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
1037
- const ctx = {
1038
- logger: { warn: () => {} },
1039
- llm: {
1040
- stream: async function* () {
1041
- yield { type: "finish", reason: { kind: "error" } };
1042
- }
1043
- }
1044
- };
1045
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1046
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
1047
- assert.equal(result.ok, false, "failed run reported");
1048
- const rows = store.listLlmAudits();
1049
- assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
1050
- assert.equal(rows[0].operation_type, "dream_consolidate");
1051
- assert.equal(rows[0].status, "error", "LLM failure status=error");
1052
- assert.ok(rows[0].error_message, "error message recorded");
1053
- store.close();
1054
- });
1055
-
1056
- // --- Issue #89 回归修复:v0.6.9(Issue #26)的 skipInvalid 宽容路径在 v0.7.11
1057
- // 重写中丢失,弱模型(如 qwen3.8-flash)单条非法决策导致整单拒绝、合法子集
1058
- // 全部白烧。以下单测自 v0.6.9 测试原样移植,锁定恢复后的行为。-------------
1059
-
1060
- test("validateDecisions skipInvalid: a single invalid decision is skipped, valid subset survives", () => {
1061
- const snap = new Map([
1062
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1063
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1064
- ["a", { id: "a", type: "project", title: "旧A", content: "过时A", importance: 3, archived: false, forgotten: false }],
1065
- ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1066
- ]);
1067
- const decisions = [
1068
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1069
- { action: "archive", ids: ["a"], reason: "stale" },
1070
- { action: "archive", ids: ["b"], reason: "stale" }
1071
- ];
1072
- const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1073
- assert.equal(ok, true, `valid subset should survive, got: ${errors.join("; ")}`);
1074
- assert.equal(skipped.length, 1, "cross-type merge recorded as skipped");
1075
- assert.equal(skipped[0].index, 0, "the skipped one is the cross-type merge");
1076
- assert.match(skipped[0].error, /multiple types/, "skip reason mentions types");
1077
- // invalid merge spliced out of the caller's array; valid archives + implicit
1078
- // keeps for p/j survive (p/j were left unclaimed by the skipped merge)
1079
- assert.deepEqual(decisions.map((d) => d.action), ["archive", "archive", "keep", "keep"]);
1080
- assert.deepEqual(decisions[0].ids, ["a"]);
1081
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("p")), "p auto-kept");
1082
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
1083
- });
1084
-
1085
- test("validateDecisions skipInvalid: an all-invalid batch still rejects (zero-survivor guard, #104 方向 1 重审后保留)", () => {
1086
- const snap = new Map([
1087
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1088
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1089
- ["d1", { id: "d1", type: "decision", title: "决定", content: "内容D", importance: 3, archived: false, forgotten: false }],
1090
- ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1091
- ]);
1092
- // both decisions are cross-type merges → both skipped → valid claims = 0
1093
- const decisions = [
1094
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1095
- { action: "merge", ids: ["d1", "b"], keepSource: "d1", title: "跨类型2", content: "不应合并", importance: 4 }
1096
- ];
1097
- const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1098
- assert.equal(ok, false, "no valid decisions left → whole batch rejected");
1099
- assert.equal(skipped.length, 2);
1100
- assert.ok(errors.some((e) => e.includes("nothing valid survived")), "zero-survivor guard error present");
1101
- assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
1102
- });
1103
-
1104
- test("validateDecisions skipInvalid: runaway update count still rejects the whole batch (global cap)", () => {
1105
- const snap = new Map([
1106
- ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1107
- ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1108
- ["c", { id: "c", type: "project", title: "C", content: "旧C", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }]
1109
- ]);
1110
- const decisions = [
1111
- { action: "update", ids: ["a"], content: "新A" },
1112
- { action: "update", ids: ["b"], content: "新B" },
1113
- { action: "update", ids: ["c"], content: "新C" }
1114
- ];
1115
- const { ok, errors } = validateDecisions(decisions, snap, { skipInvalid: true });
1116
- assert.equal(ok, false, "3 updates > default cap 2 → still rejects");
1117
- assert.ok(errors.some((e) => e.includes("too many update decisions")), "global cap error present");
1118
- });
1119
-
1120
- test("validateDecisions allowCrossTypeMerge: cross-type merge is allowed when the flag is on", () => {
1121
- const snap = new Map([
1122
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1123
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
1124
- ]);
1125
- const decisions = [
1126
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
1127
- ];
1128
- const { ok, errors } = validateDecisions(decisions, snap, { allowCrossTypeMerge: true });
1129
- assert.equal(ok, true, `cross-type merge allowed with the flag, got: ${errors.join("; ")}`);
1130
- assert.equal(decisions.length, 1, "no keep appended (both snapshot ids claimed)");
1131
- });
1132
-
1133
- test("validateDecisions default (no options) stays strict — sleep passes unchanged", () => {
1134
- const snap = new Map([
1135
- ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false }],
1136
- ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false }]
1137
- ]);
1138
- const { ok, skipped } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
1139
- assert.equal(ok, false, "strict rejection without skipInvalid");
1140
- assert.deepEqual(skipped, [], "no skipped bookkeeping in strict mode");
1141
- });
1142
-
1143
- test("issue#89: dream run with a skipped invalid decision lands the valid subset and is marked degraded", async () => {
1144
- const { store, service } = dreamSetup();
1145
- const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1146
- const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1147
- const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1148
- const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1149
- const warnings = [];
1150
- const ctx = {
1151
- logger: { warn: (m) => warnings.push(String(m)) },
1152
- llm: {
1153
- async *stream(options) {
1154
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1155
- if (userText.includes("| type=")) {
1156
- // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
1157
- // 高于 50% 显式覆盖率下限)
1158
- yield { type: "text-delta", index: 0, text: JSON.stringify([
1159
- { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1160
- { action: "archive", ids: [b.id], reason: "stale" },
1161
- { action: "archive", ids: [c.id], reason: "stale" }
1162
- ]) };
1163
- } else {
1164
- yield { type: "text-delta", index: 0, text: "记忆库总览:弱模型的个别非法决策不再拖垮整轮巩固。" };
1165
- }
1166
- yield { type: "finish", reason: { kind: "stop" } };
1167
- }
1168
- }
1169
- };
1170
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1171
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1172
- assert.equal(result.ok, true, "valid subset absorbed (ok for the baseline)");
1173
- assert.equal(result.status, "degraded", "run marked degraded, not faked ok");
1174
- assert.equal(store.getById(b.id).archived, true, "valid archive applied");
1175
- assert.equal(store.getById(pref.id).archived, false, "invalid merge did not touch its targets");
1176
- assert.ok(warnings.some((w) => w.includes("skipped") && w.includes("multiple types")), "skip reason logged");
1177
- store.close();
1178
- });
1179
-
1180
- // Issue #104(跟进 #137 失败路径):degraded(合法子集已应用)轮的跳过明细此前只进
1181
- // logger.warn,splice 之后就地从 decisions 里消失,落库的已是幸存列表——离线回放
1182
- // dream_runs 无法判断被跳的是跨类型 merge、update 保护期还是 unknown id。这里断言
1183
- // 明细进了独立的 skipped 列(而非内联进 decisions),且携带可定位的逐条原因。
1184
- test("issue#104: degraded run persists the skipped decision detail into dream_runs.skipped", async () => {
1185
- const { store, service } = dreamSetup();
1186
- const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1187
- const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1188
- const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1189
- const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1190
- const ctx = {
1191
- logger: { warn: () => {}, info: () => {} },
1192
- llm: {
1193
- async *stream(options) {
1194
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1195
- if (userText.includes("| type=")) {
1196
- // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(显式覆盖 2/4,过 50% 下限)
1197
- yield { type: "text-delta", index: 0, text: JSON.stringify([
1198
- { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1199
- { action: "archive", ids: [b.id], reason: "stale" },
1200
- { action: "archive", ids: [c.id], reason: "stale" }
1201
- ]) };
1202
- } else {
1203
- yield { type: "text-delta", index: 0, text: "记忆库总览:degraded 轮的跳过明细可从审计行回放。" };
1204
- }
1205
- yield { type: "finish", reason: { kind: "stop" } };
1206
- }
1207
- }
1208
- };
1209
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1210
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1211
- assert.equal(result.status, "degraded", "run degrades on the skipped decision");
1212
- // 落库验证:skipped 列不再是 NULL,逐条带 index/action/error,事后无需重跑即可定位成因。
1213
- const run = store.getDreamRun(result.runId);
1214
- assert.ok(Array.isArray(run?.skipped) && run.skipped.length === 1, "skipped detail persisted in dream_runs.skipped");
1215
- assert.equal(run.skipped[0].index, 0, "the skipped entry is the cross-type merge");
1216
- assert.equal(run.skipped[0].action, "merge");
1217
- assert.match(run.skipped[0].error, /multiple types/, "per-item skip reason is kept");
1218
- // 独立字段,不污染按「决策数组」消费 decisions 的下游读者。
1219
- assert.ok(!run.decisions.some((d) => d && d._skipped !== undefined), "decisions stays free of inline markers");
1220
- store.close();
1221
- });
1222
-
1223
- test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection", async () => {
1224
- const { store, service } = dreamSetup();
1225
- const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1226
- const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1227
- const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1228
- const ctx = {
1229
- logger: { warn: () => {} },
1230
- llm: {
1231
- async *stream(options) {
1232
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1233
- if (userText.includes("| type=")) {
1234
- yield { type: "text-delta", index: 0, text: JSON.stringify([
1235
- { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1236
- { action: "archive", ids: [b.id], reason: "stale" }
1237
- ]) };
1238
- } else {
1239
- yield { type: "text-delta", index: 0, text: "记忆库总览。" };
1240
- }
1241
- yield { type: "finish", reason: { kind: "stop" } };
1242
- }
1243
- }
1244
- };
1245
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1246
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSkipInvalid: false });
1247
- assert.equal(result.ok, false, "strict mode rejects the batch");
1248
- assert.equal(result.error, "invalid decisions: 1 errors");
1249
- assert.equal(store.getById(b.id).archived, false, "nothing applied under strict rejection");
1250
- store.close();
1251
- });
1252
-
1253
- test("issue#89: minIntervalMs throttles re-triggering regardless of run outcome", async () => {
1254
- const { store, service } = dreamSetup();
1255
- let runs = 0;
1256
- const dream = createDreamScheduler({
1257
- onRun: async () => { runs++; return { ok: false, error: "llm failed" }; },
1258
- thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 80,
1259
- logger: { warn: () => {} }
1260
- });
1261
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1262
- assert.equal(dream.maybeSchedule(service), true, "first trigger scheduled");
1263
- await new Promise((r) => setTimeout(r, 20)); // delayMs 0 → run already dispatched and finished
1264
- assert.equal(runs, 1, "ran once");
1265
- assert.equal(dream.maybeSchedule(service), false, "inside the min interval, even after a failed run");
1266
- await new Promise((r) => setTimeout(r, 90));
1267
- assert.equal(dream.maybeSchedule(service), true, "interval elapsed → eligible again (baseline unmoved by failure)");
1268
- store.close();
1269
- });
1270
-
1271
- // --- Issue #126:新动作(supersede / differentiate)在 dream 路径也必须产 receipt ---
1272
- // buildRecordReceipts 此前只认 merge/conflict/update,新动作会静默不产 receipt,让
1273
- // receipt_chain 留洞。dream 的 consolidation prompt 虽然不含这两个动作,但校验器
1274
- // 的 ACTIONS 是共用的——模型自由发挥(或将来把动作集扩到 dream)时审计不能丢。
1275
-
1276
- test("issue#126: a supersede decision on the dream path produces a supersede receipt", async () => {
1277
- const { store, service } = dreamSetup();
1278
- const oldMem = service.saveWithDedupe({ type: "project", title: "方案 v1", content: "用 pm2 常驻" }).memory;
1279
- const newMem = service.saveWithDedupe({ type: "project", title: "方案 v2", content: "改用 systemd" }).memory;
1280
- const ctx = mockCtx({
1281
- onConsolidation: () => JSON.stringify([
1282
- { action: "supersede", winner: newMem.id, loser: oldMem.id, reason: "版本演进" }
1283
- ])
1284
- });
1285
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1286
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1287
- assert.equal(result.status, "ok", "supersede applied and the summary landed");
1288
- assert.equal(store.getById(oldMem.id).archived, true, "older entry archived");
1289
- assert.equal(store.getById(newMem.id).content, "改用 systemd", "winner body untouched");
1290
- const receipts = store.listReceipts();
1291
- const supersedeReceipt = receipts.find((r) => r.kind === "supersede");
1292
- assert.ok(supersedeReceipt, "supersede receipt lands in receipt_chain");
1293
- assert.equal(supersedeReceipt.record_id, oldMem.id, "receipt records the superseded side");
1294
- assert.equal(supersedeReceipt.winner_id, newMem.id);
1295
- assert.equal(supersedeReceipt.loser_id, oldMem.id);
1296
- store.close();
1297
- });
1298
-
1299
- test("issue#126: a differentiate decision on the dream path produces one receipt per entry", async () => {
1300
- const { store, service } = dreamSetup();
1301
- const a = service.saveWithDedupe({ type: "project", title: "内网端口", content: "内网走 22" }).memory;
1302
- const b = service.saveWithDedupe({ type: "project", title: "外网端口", content: "外网映射 2222" }).memory;
1303
- const ctx = mockCtx({
1304
- onConsolidation: () => JSON.stringify([
1305
- { action: "differentiate", ids: [a.id, b.id], distinctions: ["内网直连", "外网映射"] }
1306
- ])
1307
- });
1308
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1309
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1310
- assert.equal(result.status, "ok", "differentiate applied and the summary landed");
1311
- assert.equal(store.getById(a.id).archived, false, "neither side is archived");
1312
- assert.equal(store.getById(b.id).archived, false);
1313
- const receipts = store.listReceipts().filter((r) => r.kind === "differentiate");
1314
- assert.equal(receipts.length, 2, "one receipt per differentiated entry");
1315
- assert.deepEqual(receipts.map((r) => r.record_id).sort(), [a.id, b.id].sort());
1316
- assert.ok(receipts.every((r) => r.count_before === 1 && r.count_after === 1), "no count change for differentiate");
1317
- store.close();
1318
- });
1319
-
1320
- // --- Issue #258: dream_summarize 独立路由 + 输入硬上限 ------------------------
1321
-
1322
- test("issue#258: summarize uses dreamSummaryProvider/dreamSummaryModel when configured, falls back to dream route otherwise", async () => {
1323
- const { store, service } = dreamSetup();
1324
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1325
- const seen = [];
1326
- const ctx = mockCtx({});
1327
- const origStream = ctx.llm.stream.bind(ctx.llm);
1328
- ctx.llm.stream = async function* (options) {
1329
- seen.push(`${options.provider}:${options.model}`);
1330
- yield* origStream(options);
1331
- };
1332
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1333
- await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryProvider: "openai", dreamSummaryModel: "big-ctx" });
1334
- assert.ok(seen.includes("mock:mock-model"), "consolidation keeps the dream route");
1335
- assert.ok(seen.includes("openai:big-ctx"), "summary takes the dedicated route");
1336
- const summarize = store.listLlmAudits().find((r) => r.operation_type === "dream_summarize");
1337
- assert.equal(summarize.model_id, "openai:big-ctx", "audit trail names the dedicated route");
1338
- store.close();
1339
- });
1340
-
1341
- test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 = whole library)", async () => {
1342
- const { store, service } = dreamSetup();
1343
- const titles = ["旧一", "旧二", "旧三", "新四", "新五"];
1344
- const ids = titles.map((t, i) => service.saveWithDedupe({ type: "project", title: t, content: `内容${i}` }).memory.id);
1345
- // 同毫秒保存会让 updated_at 并列、排序退化到 id(UUID 随机)——回填保证严格递增。
1346
- ids.forEach((id, i) => {
1347
- const at = `2026-01-01T0${i + 1}:00:00.000Z`;
1348
- store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(at, id);
1349
- });
1350
- const seenUserText = [];
1351
- const ctx = mockCtx({});
1352
- const origStream = ctx.llm.stream.bind(ctx.llm);
1353
- ctx.llm.stream = async function* (options) {
1354
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1355
- if (!userText.includes("| type=")) seenUserText.push(userText);
1356
- yield* origStream(options);
1357
- };
1358
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1359
- await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryMaxInputs: 3 });
1360
- const summaryText = seenUserText.at(-1);
1361
- assert.ok(summaryText.includes("新四") && summaryText.includes("新五") && summaryText.includes("旧三"), "newest three survive the cap");
1362
- assert.ok(!summaryText.includes("旧一") && !summaryText.includes("旧二"), "oldest two are cut");
1363
- const overview = () => service.all().find((m) => m.type === "summary");
1364
- assert.match(overview().content, /整理后 3 条/, "口径 footer counts the capped snapshot");
1365
- // 未配置 = 0 = 全库,历史行为不变
1366
- await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1367
- assert.match(overview().content, /整理后 5 条/, "uncapped run covers the whole library");
1368
- store.close();
1369
- });
1370
-
1371
- // --- issue #339 / E8 考卷:merge 护栏(dreamMergeGuard,默认开)----------------
1372
- // E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——
1373
- // archive 护栏只挡 archive 不挡 merge。默认开(config.js);被合并对象命中
1374
- // ARCHIVE_GUARDED_TYPES 的 merge 决策走与 archive 护栏同款通道:skipInvalid
1375
- // 时 skipped、严格时整单拒绝。单元层直接传 options,不经 config。
1376
-
1377
- test("validateDecisions mergeGuard: guarded-type merge is skipped under skipInvalid", () => {
1378
- const snap = new Map([
1379
- ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1380
- ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }],
1381
- ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
1382
- ["h2", { id: "h2", type: "history", title: "旧事(近似)", content: "内容2", importance: 3, archived: false, forgotten: false }]
1383
- ]);
1384
- const decisions = [
1385
- { action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "$12,400 上下", importance: 4 },
1386
- { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
1387
- ];
1388
- const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1389
- assert.equal(ok, true, "history merge survives, guarded merge skipped");
1390
- assert.equal(skipped.length, 1);
1391
- assert.match(skipped[0].error, /dreamMergeGuard/, "skip reason names the guard");
1392
- assert.match(skipped[0].error, /constraint/, "skip reason names the guarded type");
1393
- // guarded merge 被 splice 掉,history merge 与两条隐式 keep 存活。
1394
- assert.deepEqual(decisions.map((d) => d.action), ["merge", "keep", "keep"]);
1395
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("c1")), "c1 auto-kept");
1396
- });
1397
-
1398
- test("validateDecisions mergeGuard: strict mode rejects the whole batch", () => {
1399
- const guardedSnap = new Map([
1400
- ["g1", { id: "g1", type: "pitfall", title: "坑", content: "内容", importance: 3, archived: false, forgotten: false }],
1401
- ["g2", { id: "g2", type: "pitfall", title: "坑2", content: "内容2", importance: 3, archived: false, forgotten: false }]
1402
- ]);
1403
- const strict = validateDecisions(
1404
- [{ action: "merge", ids: ["g1", "g2"], keepSource: "g1", title: "t", content: "c" }],
1405
- guardedSnap,
1406
- { skipInvalid: false, mergeGuard: true }
1407
- );
1408
- assert.equal(strict.ok, false, "strict mode rejects guarded merge");
1409
- assert.ok(strict.errors.some((e) => /dreamMergeGuard/.test(e)));
1410
- });
1411
-
1412
- test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = legacy behavior", () => {
1413
- const snap = new Map([
1414
- ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1415
- ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }]
1416
- ]);
1417
- const decisions = [{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "合并", importance: 4 }];
1418
- // guard 选项缺省/false = 旧行为(merge 照常通过);默认值由 config.js 供给,
1419
- // 本单元层直接传 options,锁的是「不显式开就不拦」这条回退路径。
1420
- assert.equal(validateDecisions(decisions, snap, { skipInvalid: true }).ok, true);
1421
- // guard 开但类型非 guarded(history 用 snapshot 默认 type=project 亦非 guarded)。
1422
- const plainSnap = snapshot(["x", "y"]);
1423
- assert.equal(
1424
- validateDecisions(
1425
- [{ action: "merge", ids: ["x", "y"], keepSource: "x", title: "t", content: "c" }],
1426
- plainSnap,
1427
- { skipInvalid: true, mergeGuard: true }
1428
- ).ok,
1429
- true,
1430
- "non-guarded merge passes under guard"
1431
- );
1432
- });
1433
-
1434
- // Issue #521:merge 护栏与 archive 护栏对齐——真重复/真过时的长保留类型必须
1435
- // 能合,否则提示词催着模型合、护栏一律拦,形成「每轮重提同一簇、满载 token 空
1436
- // 转、merge 恒为 0」的自锁。锁三件事:①同类型 + 重复理由放行;②只有理由没有
1437
- // 同类型不放行(逃生门不跟着放宽跨类型);③同类型但理由只是「主题相近」不放行
1438
- // (相近 ≠ 重复,这条是故意的)。
1439
- test("validateDecisions mergeGuard: genuine duplicate of a long-retention type passes (issue #521)", () => {
1440
- const snap = new Map([
1441
- ["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
1442
- ["p2", { id: "p2", type: "preference", title: "偏好深色主题", content: "偏好深色界面", importance: 3, archived: false, forgotten: false }]
1443
- ]);
1444
- const decisions = [
1445
- { action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "喜欢深色主题", content: "偏好深色界面", importance: 3, reason: "两条重复" }
1446
- ];
1447
- const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1448
- assert.equal(ok, true, "duplicate-rationale merge of guarded type survives the guard");
1449
- assert.equal(skipped.length, 0, "nothing skipped when the escape hatch opens");
1450
- assert.equal(decisions.filter((d) => d.action === "merge").length, 1, "merge decision is applied, not dropped");
1451
- });
1452
-
1453
- test("validateDecisions mergeGuard: same type but only 'topically similar' rationale still blocked (issue #521)", () => {
1454
- const snap = new Map([
1455
- ["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
1456
- ["p2", { id: "p2", type: "preference", title: "喜欢等宽字体", content: "偏好等宽", importance: 3, archived: false, forgotten: false }]
1457
- ]);
1458
- const decisions = [
1459
- { action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "界面偏好", content: "深色 + 等宽", importance: 3, reason: "主题相近" }
1460
- ];
1461
- // 单条决策被跳过后批次整体不成立(覆盖率下限),所以配一条合法的非 guarded
1462
- // merge 让批次存活,单独观察这条被拦的理由。
1463
- decisions.push({ action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 });
1464
- snap.set("h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false });
1465
- snap.set("h2", { id: "h2", type: "history", title: "旧事2", content: "内容2", importance: 3, archived: false, forgotten: false });
1466
- const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1467
- assert.equal(ok, true, "skipInvalid keeps the batch alive");
1468
- assert.equal(skipped.length, 1, "topically-similar rationale is not a duplicate rationale");
1469
- assert.match(skipped[0].error, /dreamMergeGuard/);
1470
- assert.match(skipped[0].error, /重复\/过时/, "skip reason tells the model what would unblock it");
1471
- });
1472
-
1473
- test("validateDecisions mergeGuard: duplicate rationale does not unlock cross-type merge (issue #521)", () => {
1474
- const snap = new Map([
1475
- ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1476
- ["p1", { id: "p1", type: "preference", title: "预算偏好", content: "$12,400", importance: 3, archived: false, forgotten: false }]
1477
- ]);
1478
- const decisions = [
1479
- { action: "merge", ids: ["c1", "p1"], keepSource: "c1", title: "预算", content: "$12,400", importance: 4, reason: "重复" }
1480
- ];
1481
- // 显式放开 allowCrossTypeMerge:跨类型本身不再报错,逃生门仍必须拦住它——
1482
- // 否则「重复」理由会把约束和偏好揉成一条,正是 #339 要防的失真。
1483
- const { skipped } = validateDecisions(decisions, snap, {
1484
- skipInvalid: true,
1485
- mergeGuard: true,
1486
- allowCrossTypeMerge: true
1487
- });
1488
- assert.equal(skipped.length, 1, "escape hatch requires all ids share one type");
1489
- assert.match(skipped[0].error, /dreamMergeGuard/);
1490
- });
1491
-
1492
- // #522 评审:逃生门的词表只认关键词、不认否定。「并不重复,只是主题相近」与
1493
- // "not a duplicate" 都含「重复」/"duplicate" 子串,放它们过闸等于模型用一句否认
1494
- // 换来合并许可——恰好把 #339 要防的「两条独立表述被揉成一段糊话」放回来。
1495
- test("validateDecisions mergeGuard: a negated duplicate rationale does not open the escape hatch (#522)", () => {
1496
- const makeSnap = () => new Map([
1497
- ["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
1498
- ["p2", { id: "p2", type: "preference", title: "喜欢等宽字体", content: "偏好等宽", importance: 3, archived: false, forgotten: false }],
1499
- ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
1500
- ["h2", { id: "h2", type: "history", title: "旧事2", content: "内容2", importance: 3, archived: false, forgotten: false }]
1501
- ]);
1502
- // 中英各一条否定表述:都要被拦。配一条合法的非 guarded merge 让批次存活
1503
- // (单条决策被跳过后覆盖率下限会让整批不成立),单独观察被拦的理由。
1504
- // 第二轮评审:词表里「过时 / stale / outdated」也是钥匙,同样要挡。
1505
- for (const reason of ["并不重复,只是主题相近", "not a duplicate, just similar", "并不过时,只是主题相近", "not outdated, merely similar", "not stale, just similar"]) {
1506
- const decisions = [
1507
- { action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "界面偏好", content: "深色 + 等宽", importance: 3, reason },
1508
- { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
1509
- ];
1510
- const { ok, skipped } = validateDecisions(decisions, makeSnap(), { skipInvalid: true, mergeGuard: true });
1511
- assert.equal(ok, true, "skipInvalid keeps the batch alive");
1512
- assert.equal(skipped.length, 1, `negated rationale must not open the escape hatch: ${reason}`);
1513
- assert.match(skipped[0].error, /dreamMergeGuard/);
1514
- }
1515
- });
1516
- test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => {
1517
- const snap = new Map([
1518
- ["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1519
- ["p", { id: "p", type: "project", title: "项目", content: "内容", importance: 3, archived: false, forgotten: false }]
1520
- ]);
1521
- const { ok, skipped } = validateDecisions([
1522
- { action: "archive", ids: ["c"], reason: "keeps the store tidy" },
1523
- { action: "archive", ids: ["p"], reason: "outdated" }
1524
- ], snap, { skipInvalid: true });
1525
- assert.equal(ok, true, "project archive with stale rationale survives");
1526
- assert.equal(skipped.length, 1, "constraint archive without rationale skipped");
1527
- assert.match(skipped[0].error, /long-retention type/);
1528
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateDecisions, applyDecisions, createDreamScheduler } from "../src/dream.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { mockCtx, sessionDecisions } from "./helpers/dream-mock.js";
7
+
8
+ function snapshot(ids, type = "project") {
9
+ return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
10
+ }
11
+
12
+ test("valid decision list passes", () => {
13
+ const snap = snapshot(["a", "b", "c"]);
14
+ const decisions = [
15
+ { action: "keep", ids: ["a"], reason: "ok" },
16
+ { action: "merge", ids: ["b", "c"], title: "bc", content: "merged", importance: 4, keepSource: "b" }
17
+ ];
18
+ const { ok, errors } = validateDecisions(decisions, snap);
19
+ assert.equal(ok, true);
20
+ assert.deepEqual(errors, []);
21
+ });
22
+
23
+ test("unknown id rejects whole list", () => {
24
+ const snap = snapshot(["a"]);
25
+ const { ok, errors } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
26
+ assert.equal(ok, false);
27
+ assert.ok(errors.some((e) => e.includes("zzz")));
28
+ });
29
+
30
+ // --- Issue #135: 唯一前缀解析 -------------------------------------------------
31
+ // 模型常把 36 位 UUID 回填成前缀(报告实测 78/78 个 8 位前缀都能唯一对应窗口内
32
+ // 真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写)。校验前的唯一
33
+ // 前缀解析把它们还原成完整 id,而不是逐条 unknown id 后被覆盖率闸整单拒绝。
34
+
35
+ test("Issue #135: unique short prefixes resolve to full ids (ids + keepSource)", () => {
36
+ const idA = "f77a0d17-2586-41fe-a789-d69f4d1cee83";
37
+ const idB = "bc17c9ed-e84c-4b9f-bc36-48f8e9a6b3e0";
38
+ const snap = snapshot([idA, idB]);
39
+ const decisions = [
40
+ { action: "merge", ids: ["f77a0d17", "bc17c9ed"], title: "合并", content: "合并后的内容", importance: 4, keepSource: "f77a0d17" }
41
+ ];
42
+ const { ok, errors, resolvedShortIds } = validateDecisions(decisions, snap);
43
+ assert.equal(ok, true, JSON.stringify(errors));
44
+ assert.equal(resolvedShortIds, 3, "two ids + keepSource resolved");
45
+ assert.deepEqual(decisions[0].ids, [idA, idB], "ids rewritten in place");
46
+ assert.equal(decisions[0].keepSource, idA, "keepSource rewritten");
47
+ });
48
+
49
+ test("Issue #135: conflict winner/loser short prefixes resolve", () => {
50
+ const idA = "f77a0d17-2586-41fe-a789-d69f4d1cee83";
51
+ const idB = "bc17c9ed-e84c-4b9f-bc36-48f8e9a6b3e0";
52
+ const snap = snapshot([idA, idB]);
53
+ const decisions = [{ action: "conflict", winner: "f77a0d17", loser: "bc17c9ed", reason: "内容矛盾" }];
54
+ const { ok, errors, resolvedShortIds } = validateDecisions(decisions, snap);
55
+ assert.equal(ok, true, JSON.stringify(errors));
56
+ assert.equal(resolvedShortIds, 2);
57
+ assert.equal(decisions[0].winner, idA, "winner rewritten");
58
+ assert.equal(decisions[0].loser, idB, "loser rewritten");
59
+ });
60
+
61
+ test("Issue #135: ambiguous prefix stays unresolved and reports unknown id", () => {
62
+ const snap = snapshot([
63
+ "aaaaaaaa-1111-4111-8111-111111111111",
64
+ "aaaaaaaa-2222-4222-8222-222222222222"
65
+ ]);
66
+ const { ok, errors, resolvedShortIds } = validateDecisions([{ action: "archive", ids: ["aaaaaaaa"] }], snap);
67
+ assert.equal(ok, false);
68
+ assert.equal(resolvedShortIds, 0, "ambiguous prefix is never guessed");
69
+ assert.ok(errors.some((e) => e.includes("unknown id")), JSON.stringify(errors));
70
+ });
71
+
72
+ test("Issue #135: unmatched prefix is left for the validator to report", () => {
73
+ const snap = snapshot(["f77a0d17-2586-41fe-a789-d69f4d1cee83"]);
74
+ const { ok, errors, resolvedShortIds } = validateDecisions([{ action: "archive", ids: ["deadbeef"] }], snap);
75
+ assert.equal(ok, false);
76
+ assert.equal(resolvedShortIds, 0, "no match means no rewrite");
77
+ assert.ok(errors.some((e) => e.includes("deadbeef")), JSON.stringify(errors));
78
+ });
79
+
80
+ test("invalid action rejects", () => {
81
+ const snap = snapshot(["a"]);
82
+ const { ok } = validateDecisions([{ action: "explode", ids: ["a"] }], snap);
83
+ assert.equal(ok, false);
84
+ });
85
+
86
+ test("merge keepSource must be in ids", () => {
87
+ const snap = snapshot(["a", "b"]);
88
+ const { ok } = validateDecisions([{ action: "merge", ids: ["a"], keepSource: "b", title: "t", content: "c" }], snap);
89
+ assert.equal(ok, false);
90
+ });
91
+
92
+ test("conflict winner and loser must exist and differ", () => {
93
+ const snap = snapshot(["a", "b"]);
94
+ const { ok } = validateDecisions([{ action: "conflict", winner: "a", loser: "a" }], snap);
95
+ assert.equal(ok, false);
96
+ const { ok: ok2 } = validateDecisions([{ action: "conflict", winner: "a", loser: "zzz" }], snap);
97
+ assert.equal(ok2, false);
98
+ });
99
+
100
+ test("duplicate primary ids across decisions reject", () => {
101
+ const snap = snapshot(["a", "b"]);
102
+ const { ok } = validateDecisions([
103
+ { action: "archive", ids: ["a"] },
104
+ { action: "keep", ids: ["a"] }
105
+ ], snap);
106
+ assert.equal(ok, false, "a claimed twice");
107
+ });
108
+
109
+ test("archived or summary entries cannot be decision targets", () => {
110
+ const snap = new Map([["arch", { id: "arch", type: "project", title: "t", content: "c", importance: 3, archived: true, forgotten: false }]]);
111
+ const { ok } = validateDecisions([{ action: "archive", ids: ["arch"] }], snap);
112
+ assert.equal(ok, false);
113
+ });
114
+
115
+ test("empty decision list is a no-op success (model: nothing to consolidate)", () => {
116
+ // 合法 JSON [] 是模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 允许空输出),
117
+ // 不是空体/截断——显式短路 ok,避免隐式 keep 的覆盖率检查误判 0% 为失败。
118
+ const { ok, errors, skipped } = validateDecisions([], snapshot(["a"]));
119
+ assert.equal(ok, true);
120
+ assert.deepEqual(errors, []);
121
+ assert.deepEqual(skipped, []);
122
+ });
123
+
124
+ test("empty ids rejects", () => {
125
+ const { ok } = validateDecisions([{ action: "archive", ids: [] }], snapshot(["a"]));
126
+ assert.equal(ok, false);
127
+ });
128
+
129
+ test("merge requires non-empty title and content", () => {
130
+ const snap = snapshot(["a"]);
131
+ const base = { action: "merge", ids: ["a"], keepSource: "a" };
132
+ for (const [title, content] of [["", "x"], ["t", ""], [undefined, "x"], ["t", undefined]]) {
133
+ const { ok } = validateDecisions([{ ...base, title, content }], snap);
134
+ assert.equal(ok, false, `title=${JSON.stringify(title)} content=${JSON.stringify(content)}`);
135
+ }
136
+ });
137
+
138
+ test("summary entries cannot be decision targets", () => {
139
+ const snap = new Map([["s", { id: "s", type: "summary", title: "t", content: "c", importance: 3, archived: false, forgotten: false }]]);
140
+ const { ok } = validateDecisions([{ action: "archive", ids: ["s"] }], snap);
141
+ assert.equal(ok, false);
142
+ });
143
+
144
+ test("uncovered snapshot memories are auto-filled with keep (implicit keep, v0.4.4)", () => {
145
+ const snap = snapshot(["a", "b"]);
146
+ const decisions = [{ action: "keep", ids: ["a"] }];
147
+ const { ok, errors } = validateDecisions(decisions, snap);
148
+ assert.equal(ok, true, errors.join("; "));
149
+ assert.equal(decisions.length, 2, "keep appended for the uncovered snapshot id");
150
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("b")), "b auto-kept");
151
+ });
152
+
153
+ test("dreamImplicitKeep=false keeps the strict full-coverage validation", () => {
154
+ const snap = snapshot(["a", "b"]);
155
+ const decisions = [{ action: "keep", ids: ["a"] }];
156
+ const { ok, errors } = validateDecisions(decisions, snap, { dreamImplicitKeep: false });
157
+ assert.equal(ok, false);
158
+ assert.ok(errors.some((e) => e.includes("missing from decisions")));
159
+ });
160
+
161
+ test("duplicate ids within one decision reject", () => {
162
+ const { ok } = validateDecisions([{ action: "keep", ids: ["a", "a"] }], snapshot(["a"]));
163
+ assert.equal(ok, false);
164
+ });
165
+
166
+ test("merge importance out of range rejects", () => {
167
+ const snap = snapshot(["a", "b"]);
168
+ for (const importance of [0, 6, 99, 1.5, "4"]) {
169
+ const { ok, errors } = validateDecisions([
170
+ { action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance }
171
+ ], snap);
172
+ assert.equal(ok, false, `importance=${JSON.stringify(importance)} rejected`);
173
+ assert.ok(errors.some((e) => e.includes("importance")), `importance error present for ${JSON.stringify(importance)}`);
174
+ }
175
+ const { ok } = validateDecisions([
176
+ { action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance: 5 }
177
+ ], snap);
178
+ assert.equal(ok, true, "importance 5 accepted");
179
+ });
180
+
181
+ test("merge across types rejects", () => {
182
+ const snap = new Map([
183
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
184
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
185
+ ]);
186
+ const { ok, errors } = validateDecisions([
187
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
188
+ ], snap);
189
+ assert.equal(ok, false, "cross-type merge rejected");
190
+ assert.ok(errors.some((e) => e.includes("multiple types")), "multi-type error present");
191
+ });
192
+
193
+ function dreamSetup() {
194
+ const store = createStore(":memory:");
195
+ const service = createService({ store, mirror: null, config: {} });
196
+ return { store, service };
197
+ }
198
+
199
+ test("applyDecisions merges: keepSource updated, others archived", () => {
200
+ const { store, service } = dreamSetup();
201
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
202
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
203
+ const { applied } = applyDecisions([
204
+ { action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
205
+ ], service);
206
+ assert.equal(applied, 1);
207
+ const keeper = store.getById(b.id);
208
+ assert.equal(keeper.content, "合并内容");
209
+ assert.equal(keeper.title, "插件总览");
210
+ assert.equal(keeper.importance, 5);
211
+ assert.equal(store.getById(a.id).archived, true, "source archived");
212
+ });
213
+
214
+ test("applyDecisions conflict: winner kept, loser archived with provenance", () => {
215
+ const { store, service } = dreamSetup();
216
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
217
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
218
+ applyDecisions([{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }], service);
219
+ assert.equal(store.getById(l.id).archived, true);
220
+ const winner = store.getById(w.id);
221
+ assert.ok(winner.content.includes("8月20日"), "winner content intact");
222
+ assert.ok(winner.content.includes("已否决旧信息"), "provenance note appended");
223
+ });
224
+
225
+ test("applyDecisions archive and keep", () => {
226
+ const { store, service } = dreamSetup();
227
+ const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
228
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "废弃", content: "过时" });
229
+ applyDecisions([
230
+ { action: "keep", ids: [k.id] },
231
+ { action: "archive", ids: [a.id], reason: "过时" }
232
+ ], service);
233
+ assert.equal(store.getById(k.id).archived, false);
234
+ assert.equal(store.getById(a.id).archived, true);
235
+ });
236
+
237
+ test("applyDecisions returns count and never throws on unknown id (skip)", () => {
238
+ const { store, service } = dreamSetup();
239
+ const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
240
+ const { applied } = applyDecisions([{ action: "archive", ids: ["ghost"], reason: "x" }], service);
241
+ assert.equal(applied, 0);
242
+ assert.equal(store.getById(k.id).archived, false);
243
+ });
244
+
245
+ test("applyDecisions catch path: throwing decision is skipped, logged, and later decisions still apply", () => {
246
+ const { store, service } = dreamSetup();
247
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "会炸", content: "x" });
248
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "正常", content: "y" });
249
+ const originalSetArchived = service.setArchived;
250
+ let calls = 0;
251
+ service.setArchived = (id, archived) => {
252
+ calls++;
253
+ if (calls === 1) throw new Error("boom");
254
+ return originalSetArchived.call(service, id, archived);
255
+ };
256
+ const warnings = [];
257
+ const logger = { warn: (msg) => warnings.push(msg) };
258
+ const { applied, failures } = applyDecisions([
259
+ { action: "archive", ids: [a.id], reason: "x" },
260
+ { action: "archive", ids: [b.id], reason: "y" }
261
+ ], service, logger);
262
+ assert.equal(applied, 1, "throwing decision not counted, surviving decision counted");
263
+ assert.equal(failures.length, 1, "thrown decision reported as a failure");
264
+ assert.equal(store.getById(a.id).archived, false, "throwing decision left no partial effect");
265
+ assert.equal(store.getById(b.id).archived, true, "later decision still applied");
266
+ assert.equal(warnings.length, 1, "logger called once");
267
+ assert.match(warnings[0], /failed to apply archive at index 0: boom/);
268
+ });
269
+
270
+ test("applyDecisions conflict with missing loser skips cleanly", () => {
271
+ const { store, service } = dreamSetup();
272
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
273
+ const { applied } = applyDecisions([{ action: "conflict", winner: w.id, loser: "ghost", reason: "x" }], service);
274
+ assert.equal(applied, 0);
275
+ assert.equal(store.getById(w.id).archived, false, "winner untouched");
276
+ assert.ok(!store.getById(w.id).content.includes("已否决"), "no provenance note appended");
277
+ });
278
+
279
+ test("applyDecisions merge with missing keeper skips cleanly", () => {
280
+ const { store, service } = dreamSetup();
281
+ const { applied } = applyDecisions([
282
+ { action: "merge", ids: ["ghost"], keepSource: "ghost", title: "t", content: "c" }
283
+ ], service);
284
+ assert.equal(applied, 0);
285
+ assert.equal(store.getById("ghost"), undefined);
286
+ });
287
+
288
+ test("applyDecisions merge without importance falls back to max source importance", () => {
289
+ const { store, service } = dreamSetup();
290
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
291
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
292
+ const { applied } = applyDecisions([
293
+ { action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", keepSource: b.id }
294
+ ], service);
295
+ assert.equal(applied, 1);
296
+ assert.equal(store.getById(b.id).importance, 4, "keeper keeps max of source importances");
297
+ assert.equal(store.getById(a.id).archived, true, "source archived");
298
+ });
299
+
300
+ test("maybeSchedule triggers when count exceeds threshold", () => {
301
+ const { store, service } = dreamSetup();
302
+ let runs = 0;
303
+ const dream = createDreamScheduler({
304
+ onRun: async () => { runs++; },
305
+ thresholdCount: 3,
306
+ thresholdChars: 5000,
307
+ delayMs: 0
308
+ });
309
+ for (let i = 0; i < 3; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(100) });
310
+ const pending = dream.maybeSchedule(service);
311
+ assert.equal(pending, true, "scheduled");
312
+ assert.equal(runs, 0, "not run yet (async)");
313
+ store.close();
314
+ });
315
+
316
+ test("maybeSchedule does not trigger below threshold", () => {
317
+ const { store, service } = dreamSetup();
318
+ const dream = createDreamScheduler({ onRun: async () => {}, thresholdCount: 10, thresholdChars: 5000, delayMs: 0 });
319
+ service.saveWithDedupe({ type: "project", title: "only", content: "x" });
320
+ assert.equal(dream.maybeSchedule(service), false);
321
+ store.close();
322
+ });
323
+
324
+ test("scheduler fires async and resets baseline", async () => {
325
+ const { store, service } = dreamSetup();
326
+ let runs = 0;
327
+ const dream = createDreamScheduler({
328
+ onRun: async () => { runs++; return { ok: true }; },
329
+ thresholdCount: 2, thresholdChars: 5000, delayMs: 5
330
+ });
331
+ for (let i = 0; i < 2; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(50) });
332
+ dream.maybeSchedule(service);
333
+ await new Promise((r) => setTimeout(r, 50));
334
+ assert.equal(runs, 1, "ran once");
335
+ // still above threshold but baseline reset → no immediate re-trigger
336
+ assert.equal(dream.maybeSchedule(service), false, "baseline prevents loop");
337
+ store.close();
338
+ });
339
+
340
+ test("failed run does not refresh baseline: next write re-triggers", async () => {
341
+ const { store, service } = dreamSetup();
342
+ let calls = 0;
343
+ const dream = createDreamScheduler({
344
+ onRun: async () => {
345
+ calls++;
346
+ return { ok: false, error: "llm failed" };
347
+ },
348
+ thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
349
+ logger: { warn: () => {} }
350
+ });
351
+ service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
352
+ service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
353
+ assert.equal(dream.maybeSchedule(service), true, "scheduled");
354
+ await new Promise((r) => setTimeout(r, 50));
355
+ assert.equal(calls, 1, "run attempted once");
356
+ // baseline NOT refreshed on failure → the same write volume still triggers
357
+ assert.equal(dream.maybeSchedule(service), true, "failed run keeps baseline, re-schedules");
358
+ await new Promise((r) => setTimeout(r, 50));
359
+ assert.equal(calls, 2, "retried after failure");
360
+ store.close();
361
+ });
362
+
363
+ test("noop run (nothing changed) does not refresh baseline: next write re-triggers", async () => {
364
+ const { store, service } = dreamSetup();
365
+ let calls = 0;
366
+ const dream = createDreamScheduler({
367
+ onRun: async () => { calls++; return { ok: false, status: "noop", applied: 0, summary: false }; },
368
+ thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
369
+ logger: { warn: () => {} }
370
+ });
371
+ service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
372
+ service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
373
+ assert.equal(dream.maybeSchedule(service), true, "scheduled");
374
+ await new Promise((r) => setTimeout(r, 50));
375
+ assert.equal(calls, 1, "run attempted once");
376
+ // a noop is not a success: the baseline stays put so the accumulated writes
377
+ // are still owed and the next write re-schedules instead of being absorbed
378
+ assert.equal(dream.maybeSchedule(service), true, "noop keeps baseline, re-schedules");
379
+ await new Promise((r) => setTimeout(r, 50));
380
+ assert.equal(calls, 2, "retried after noop");
381
+ store.close();
382
+ });
383
+
384
+ test("throwing run does not refresh baseline and is logged", async () => {
385
+ const { store, service } = dreamSetup();
386
+ const warnings = [];
387
+ let calls = 0;
388
+ const dream = createDreamScheduler({
389
+ onRun: async () => {
390
+ calls++;
391
+ throw new Error("boom");
392
+ },
393
+ thresholdCount: 1, thresholdChars: 0, delayMs: 5,
394
+ logger: { warn: (msg) => warnings.push(msg) }
395
+ });
396
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
397
+ assert.equal(dream.maybeSchedule(service), true, "scheduled");
398
+ await new Promise((r) => setTimeout(r, 30));
399
+ assert.equal(calls, 1, "run attempted once");
400
+ assert.ok(warnings.some((m) => m.includes("run failed")), "throw logged");
401
+ assert.equal(dream.maybeSchedule(service), true, "throw keeps baseline, re-schedules");
402
+ store.close();
403
+ });
404
+
405
+ test("maybeSchedule returns false while a run is in flight", async () => {
406
+ const { store, service } = dreamSetup();
407
+ let release;
408
+ const gate = new Promise((r) => { release = r; });
409
+ let entered = false;
410
+ const dream = createDreamScheduler({
411
+ onRun: async () => { entered = true; await gate; },
412
+ thresholdCount: 1, thresholdChars: 0, delayMs: 0,
413
+ logger: { warn: () => {} }
414
+ });
415
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
416
+ assert.equal(dream.maybeSchedule(service), true, "first schedule accepted");
417
+ await new Promise((r) => setTimeout(r, 20));
418
+ assert.equal(entered, true, "run started");
419
+ assert.equal(dream.maybeSchedule(service), false, "no schedule while running");
420
+ release();
421
+ await new Promise((r) => setTimeout(r, 10)); // let the run finish + baseline refresh
422
+ store.close();
423
+ });
424
+
425
+ test("dispose clears pending timer and blocks future scheduling", async () => {
426
+ const { store, service } = dreamSetup();
427
+ let runs = 0;
428
+ const dream = createDreamScheduler({
429
+ onRun: async () => { runs++; },
430
+ thresholdCount: 1, thresholdChars: 0, delayMs: 5,
431
+ logger: { warn: () => {} }
432
+ });
433
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
434
+ assert.equal(dream.maybeSchedule(service), true, "scheduled");
435
+ dream.dispose();
436
+ await new Promise((r) => setTimeout(r, 30));
437
+ assert.equal(runs, 0, "pending run cancelled by dispose");
438
+ assert.equal(dream.maybeSchedule(service), false, "disposed scheduler never schedules");
439
+ store.close();
440
+ });
441
+
442
+ test("dispose awaits an in-flight run so the store can close safely", async () => {
443
+ const { store, service } = dreamSetup();
444
+ let release;
445
+ const gate = new Promise((r) => { release = r; });
446
+ let entered = false;
447
+ let finished = false;
448
+ const dream = createDreamScheduler({
449
+ onRun: async () => { entered = true; await gate; finished = true; },
450
+ thresholdCount: 1, thresholdChars: 0, delayMs: 0,
451
+ logger: { warn: () => {} }
452
+ });
453
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
454
+ assert.equal(dream.maybeSchedule(service), true, "scheduled");
455
+ await new Promise((r) => setTimeout(r, 20));
456
+ assert.equal(entered, true, "run started");
457
+
458
+ const disposeP = dream.dispose();
459
+ let settled = false;
460
+ disposeP.then(() => { settled = true; });
461
+ await new Promise((r) => setTimeout(r, 10));
462
+ assert.equal(settled, false, "dispose must not resolve while a run is in flight");
463
+ assert.equal(finished, false, "run still pending");
464
+
465
+ release();
466
+ await disposeP;
467
+ assert.equal(finished, true, "run completed before dispose resolved");
468
+ store.close();
469
+ });
470
+
471
+ test("runDream stores summary and applies decisions", async () => {
472
+ const { store, service } = dreamSetup();
473
+ // seed 2 memories so snapshot is non-empty and decisions cover them
474
+ const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
475
+ const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
476
+ let calls = 0;
477
+ const ctx = {
478
+ llm: {
479
+ stream: async function* () {
480
+ calls++;
481
+ if (calls === 1) {
482
+ const text = JSON.stringify([
483
+ { action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }
484
+ ]);
485
+ yield { type: "text-delta", text };
486
+ } else {
487
+ yield { type: "text-delta", text: "记忆库总览摘要文本" };
488
+ }
489
+ yield { type: "finish", reason: { kind: "ok" } };
490
+ }
491
+ },
492
+ logger: { warn: () => {} }
493
+ };
494
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
495
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
496
+ assert.equal(result.ok, true);
497
+ assert.equal(result.applied, 1, "merge decision applied");
498
+ assert.equal(result.summary, true, "summary stored");
499
+ const keeper = store.getById(a.memory.id);
500
+ assert.equal(keeper.title, "合并标题", "keeper title updated");
501
+ assert.equal(keeper.content, "合并后的内容", "keeper content updated");
502
+ assert.equal(keeper.importance, 4, "keeper importance updated");
503
+ assert.equal(store.getById(b.memory.id).archived, true, "merged source archived");
504
+ const summary = store.all().find((m) => m.type === "summary");
505
+ assert.ok(summary, "summary created");
506
+ assert.equal(summary.title, "记忆库总览");
507
+ // 常驻状态条(#164 对齐):叙述正文 + 快照口径脚注
508
+ assert.ok(summary.content.startsWith("记忆库总览摘要文本"));
509
+ assert.ok(summary.content.includes("〔口径:基于整理后"), "scope footer present");
510
+ assert.ok(summary.content.includes("条记忆快照"), "scope footer carries input count");
511
+ store.close();
512
+ });
513
+
514
+ test("runDream fails safe on invalid decisions", async () => {
515
+ const { store, service } = dreamSetup();
516
+ const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
517
+ let calls = 0;
518
+ const warnings = [];
519
+ const ctx = {
520
+ llm: {
521
+ stream: async function* () {
522
+ calls++;
523
+ yield { type: "text-delta", text: calls === 1 ? "not json at all" : "summary" };
524
+ yield { type: "finish", reason: { kind: "ok" } };
525
+ }
526
+ },
527
+ logger: { warn: (msg) => warnings.push(msg) }
528
+ };
529
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
530
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
531
+ assert.equal(result.ok, false, "invalid decisions rejected");
532
+ assert.equal(result.summary, false, "no summary flag on failure");
533
+ assert.equal(store.all().filter((m) => m.type === "summary").length, 0, "no summary on failure");
534
+ const lang = store.getById(saved.memory.id);
535
+ assert.ok(lang, "original memory still present");
536
+ assert.equal(lang.archived, false, "original memory not archived");
537
+ assert.equal(lang.content, "中文", "original memory content untouched");
538
+ assert.ok(warnings.length >= 1, "failure was logged");
539
+ store.close();
540
+ });
541
+
542
+ test("Issue #135: failed validation persists { _validationFailed, errors, skipped } into the run row", async () => {
543
+ const { store, service } = dreamSetup();
544
+ const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
545
+ let calls = 0;
546
+ const ctx = {
547
+ llm: {
548
+ stream: async function* () {
549
+ calls++;
550
+ yield { type: "text-delta", text: calls === 1 ? JSON.stringify([{ action: "archive", ids: ["deadbeef"] }]) : "summary" };
551
+ yield { type: "finish", reason: { kind: "ok" } };
552
+ }
553
+ },
554
+ logger: { warn: () => {}, info: () => {} }
555
+ };
556
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
557
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
558
+ assert.equal(result.ok, false, "coverage gate rejects (0% explicit coverage)");
559
+ assert.ok(Array.isArray(result.decisions) && result.decisions[0]?._validationFailed === true,
560
+ "failure result carries the validation detail");
561
+ assert.ok(result.decisions[0].skipped.some((s) => s.error.includes("deadbeef")),
562
+ "skipped keeps the per-item reason");
563
+ // 落库验证:dream_runs.decisions 不再是 NULL,事后可离线定位失败原因。
564
+ const run = store.getDreamRun(result.runId);
565
+ assert.ok(run?.decisions?.[0]?._validationFailed === true, "detail persisted in dream_runs.decisions");
566
+ assert.ok(run.decisions[0].skipped.length >= 1);
567
+ const lang = store.getById(saved.memory.id);
568
+ assert.equal(lang.archived, false, "unknown-id decision must not touch real memories");
569
+ store.close();
570
+ });
571
+
572
+ // --- item ①: CAS guard against concurrent edits ----------------------------
573
+
574
+ test("applyDecisions CAS: merge onto a concurrently-edited target is skipped as a conflict, not applied", () => {
575
+ const { store, service } = dreamSetup();
576
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
577
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
578
+ // snapshot captured before the "LLM call"; a concurrent edit lands meanwhile
579
+ const snapshot = new Map([a.id, b.id].map((id) => [id, store.getById(id)]));
580
+ service.update(a.id, { content: "并发编辑" });
581
+ const { applied, conflicts, committed } = applyDecisions([
582
+ { action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
583
+ ], service, null, snapshot);
584
+ assert.equal(applied, 0, "decision skipped entirely");
585
+ assert.equal(conflicts.length, 1, "recorded as a CAS conflict");
586
+ assert.equal(committed.length, 0, "nothing committed");
587
+ assert.equal(store.getById(a.id).content, "并发编辑", "concurrent edit preserved");
588
+ assert.equal(store.getById(b.id).archived, false, "source not archived");
589
+ assert.equal(store.getById(b.id).title, "插件2", "keeper untouched");
590
+ store.close();
591
+ });
592
+
593
+ test("applyDecisions CAS: update to a concurrently-edited memory is skipped as a conflict", () => {
594
+ const { store, service } = dreamSetup();
595
+ const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "喜欢 Python" });
596
+ const snapshot = new Map([[m.id, store.getById(m.id)]]);
597
+ service.update(m.id, { content: "并发改动" });
598
+ const { applied, conflicts } = applyDecisions(
599
+ [{ action: "update", ids: [m.id], content: "喜欢 Rust" }],
600
+ service, null, snapshot
601
+ );
602
+ assert.equal(applied, 0);
603
+ assert.equal(conflicts.length, 1);
604
+ assert.equal(store.getById(m.id).content, "并发改动", "concurrent edit wins");
605
+ store.close();
606
+ });
607
+
608
+ test("applyDecisions without a snapshot skips the CAS guard (replay path unchanged)", () => {
609
+ const { store, service } = dreamSetup();
610
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
611
+ service.update(a.id, { content: "并发编辑" }); // concurrent edit
612
+ const { applied, conflicts } = applyDecisions([
613
+ { action: "archive", ids: [a.id], reason: "x" }
614
+ ], service);
615
+ assert.equal(applied, 1, "snapshotless replay applies (no CAS guard)");
616
+ assert.equal(conflicts.length, 0);
617
+ assert.equal(store.getById(a.id).archived, true);
618
+ store.close();
619
+ });
620
+
621
+ // --- item ②: per-decision transaction atomicity ----------------------------
622
+
623
+ test("applyDecisions merge is atomic: a throwing archive step rolls back the keeper update too", () => {
624
+ const { store, service } = dreamSetup();
625
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "甲", content: "旧甲", importance: 3 });
626
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "乙", content: "旧乙", importance: 4 });
627
+ const originalSetArchived = service.setArchived;
628
+ service.setArchived = (id, archived) => {
629
+ if (id === a.id) throw new Error("archive boom");
630
+ return originalSetArchived.call(service, id, archived);
631
+ };
632
+ const warnings = [];
633
+ const { applied, failures, committed } = applyDecisions([
634
+ { action: "merge", ids: [a.id, b.id], title: "甲乙", content: "合并", importance: 4, keepSource: b.id }
635
+ ], service, { warn: (m) => warnings.push(m) });
636
+ assert.equal(applied, 0, "merge not committed");
637
+ assert.equal(failures.length, 1, "reported as a failure");
638
+ assert.equal(committed.length, 0, "outcome must not claim a merge that rolled back");
639
+ assert.equal(store.getById(b.id).title, "乙", "keeper title untouched by the rolled-back update");
640
+ assert.equal(store.getById(b.id).content, "旧乙", "keeper content untouched");
641
+ assert.equal(store.getById(a.id).archived, false, "source not archived");
642
+ assert.ok(warnings.length >= 1, "failure logged");
643
+ store.close();
644
+ });
645
+
646
+ // --- conflict freeze: manual review instead of auto-adjudication ----------
647
+
648
+ function freezeCtx({ conflicts, includes = [], summaryText = "记忆库总览摘要" }) {
649
+ let calls = 0;
650
+ const warnings = [];
651
+ const ctx = {
652
+ warnings,
653
+ llm: {
654
+ stream: async function* () {
655
+ calls++;
656
+ const list = [...includes, ...conflicts];
657
+ yield { type: "text-delta", text: calls === 1 ? JSON.stringify(list) : summaryText };
658
+ yield { type: "finish", reason: { kind: "ok" } };
659
+ }
660
+ },
661
+ logger: { warn: (m) => warnings.push(m) }
662
+ };
663
+ return ctx;
664
+ }
665
+
666
+ test("runDream with conflictFreezeEnabled parks conflicts instead of adjudicating", async () => {
667
+ const { store, service } = dreamSetup();
668
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
669
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
670
+ const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新,候选取新" }] });
671
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
672
+ const result = await dream.runDream(ctx, service, {
673
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
674
+ });
675
+ assert.equal(result.ok, true);
676
+ assert.equal(result.applied, 0, "no conflict applied");
677
+ assert.equal(result.frozen, 1, "one conflict frozen");
678
+ // pending row recorded for human review
679
+ const pending = store.listConflictPending();
680
+ assert.equal(pending.length, 1);
681
+ assert.equal(pending[0].reason, "日期更新,候选取新");
682
+ // neither side was auto-adjudicated
683
+ assert.equal(store.getById(l.id).archived, false, "loser NOT archived");
684
+ assert.equal(store.getById(w.id).archived, false, "winner NOT archived");
685
+ assert.ok(!store.getById(w.id).content.includes("已否决旧信息"), "no provenance note appended");
686
+ // audit outcome marks both sides pending
687
+ const run = store.listDreamRuns()[0];
688
+ assert.equal(run.outcome.byId[w.id], "conflict-pending");
689
+ assert.equal(run.outcome.byId[l.id], "conflict-pending");
690
+ assert.equal(run.status, "ok", "summary stored + freeze landed → ok");
691
+ assert.equal(store.listReceipts().length, 0, "no conflict receipt for a frozen (unapplied) conflict");
692
+ store.close();
693
+ });
694
+
695
+ test("runDream freeze keeps auto-adjudication when disabled (default)", async () => {
696
+ const { store, service } = dreamSetup();
697
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
698
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
699
+ const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }] });
700
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
701
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
702
+ assert.equal(result.ok, true);
703
+ assert.equal(result.applied, 1, "conflict auto-adjudicated when freeze is off");
704
+ assert.equal(result.frozen, 0);
705
+ assert.equal(store.getById(l.id).archived, true, "loser archived");
706
+ assert.ok(store.getById(w.id).content.includes("已否决旧信息"), "provenance note appended");
707
+ assert.equal(store.listConflictPending().length, 0, "no pending rows in auto mode");
708
+ store.close();
709
+ });
710
+
711
+ test("runDream freeze applies non-conflict decisions while parking conflicts", async () => {
712
+ const { store, service } = dreamSetup();
713
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
714
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
715
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
716
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
717
+ const ctx = freezeCtx({
718
+ includes: [{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }],
719
+ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新" }]
720
+ });
721
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
722
+ const result = await dream.runDream(ctx, service, {
723
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
724
+ });
725
+ assert.equal(result.ok, true);
726
+ assert.equal(result.applied, 1, "merge applied normally");
727
+ assert.equal(result.frozen, 1, "conflict frozen");
728
+ assert.equal(store.getById(b.id).title, "插件总览", "merge keeper updated");
729
+ assert.equal(store.getById(a.id).archived, true, "merge source archived");
730
+ assert.equal(store.getById(l.id).archived, false, "conflict loser untouched by the merge run");
731
+ const pending = store.listConflictPending();
732
+ assert.equal(pending.length, 1);
733
+ assert.ok(pending[0].reason.includes("日期更新"));
734
+ store.close();
735
+ });
736
+
737
+ test("runDream freeze respects conflictFreezeMaxPending cap and skips overflow", async () => {
738
+ const { store, service } = dreamSetup();
739
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
740
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
741
+ const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
742
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
743
+ const result = await dream.runDream(ctx, service, {
744
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat",
745
+ conflictFreezeEnabled: true, conflictFreezeMaxPending: 0
746
+ });
747
+ assert.equal(result.frozen, 0, "nothing frozen at capacity");
748
+ assert.equal(store.listConflictPending().length, 0, "no pending rows");
749
+ assert.ok(ctx.warnings.some((m) => m.includes("freeze queue full")), "capacity warning logged");
750
+ store.close();
751
+ });
752
+
753
+ test("runDream freeze store failure never blocks the run (fail-safe)", async () => {
754
+ const { store, service } = dreamSetup();
755
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
756
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
757
+ service.saveConflictPending = () => { throw new Error("pending store boom"); };
758
+ service.countConflictPending = () => { throw new Error("count boom"); };
759
+ const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
760
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
761
+ const result = await dream.runDream(ctx, service, {
762
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
763
+ });
764
+ assert.equal(result.ok, true, "run completes despite freeze store failure");
765
+ assert.equal(result.frozen, 0, "nothing frozen");
766
+ assert.ok(ctx.warnings.length >= 1, "freeze failure logged");
767
+ assert.equal(store.getById(l.id).archived, false, "no side effects on memories");
768
+ assert.equal(store.getById(w.id).content, "8月20日", "winner untouched");
769
+ store.close();
770
+ });
771
+
772
+ // --- v0.4.4: 大记忆量 autoDream(滑动窗口 + 隐式 keep)回归 -----------------
773
+
774
+ test("autoDream with 650 memories: sliding window truncates snapshot + implicit keep fills uncovered ids", async () => {
775
+ const { store, service } = dreamSetup();
776
+ // seed 650 memories — the size that used to produce 677 "missing from
777
+ // decisions" errors and applied=0 under the strict full-coverage check
778
+ for (let i = 0; i < 650; i++) {
779
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
780
+ }
781
+ // mock LLM claims only a small subset (20 archives) and never emits keeps
782
+ // for the rest — implicit keep must auto-fill the uncovered snapshot ids
783
+ const ctx = mockCtx({
784
+ onConsolidation: (listText) => {
785
+ const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
786
+ .map((m) => m[1]);
787
+ return JSON.stringify(ids.slice(0, 20).map((id) => ({ action: "archive", ids: [id], reason: "stale" })));
788
+ }
789
+ });
790
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
791
+ const result = await dream.runDream(ctx, service, {
792
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 200,
793
+ // mock only claims 20/200 = 10%; the implicit-keep coverage floor must be
794
+ // lowered so this deliberate "tiny explicit claim" scenario still passes
795
+ // (it exercises the window + keep-fill, not the coverage guard). Same for
796
+ // the archive cap: 20 mock archives exceed the default 8 — #104 方向 2.
797
+ dreamMinExplicitCoverage: 0.1,
798
+ dreamMaxArchivePerRun: 20
799
+ });
800
+ assert.equal(result.ok, true, "run succeeds instead of 677-error rejection");
801
+ assert.ok(result.applied > 0, "archive decisions applied");
802
+ // snapshot capped at the sliding window
803
+ const run = store.listDreamRuns()[0];
804
+ assert.equal(run.input_count, 200, "snapshot truncated to dreamMaxSnapshotSize");
805
+ // decisions cover exactly the snapshot window, with implicit keeps
806
+ assert.equal(result.decisions.length, 200, "decisions count = snapshot count");
807
+ assert.equal(result.decisions.filter((d) => d.action === "archive").length, 20, "claimed subset present");
808
+ assert.equal(result.decisions.filter((d) => d.action === "keep").length, 180, "uncovered ids auto-kept");
809
+ assert.equal(store.getById(result.decisions.find((d) => d.action === "archive").ids[0]).archived, true, "an archive landed");
810
+ store.close();
811
+ });
812
+
813
+ // --- v0.4.4 fix: 残缺输出防洗白(显式覆盖率下限) + 严格模式透传 --------------
814
+
815
+ test("validateDecisions degrades a low-coverage valid output (Issue #104 方向 1)", () => {
816
+ const snap = snapshot(["a", "b", "c", "d"]);
817
+ const decisions = [{ action: "keep", ids: ["a"] }]; // claims 1/4 = 25%
818
+ const { ok, errors, coverageShortfall } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
819
+ assert.equal(ok, true, "valid subset applies — coverage shortfall degrades instead of rejecting");
820
+ assert.deepEqual(errors, [], "shortfall is not a per-decision error");
821
+ assert.match(
822
+ String(coverageShortfall),
823
+ /explicit decision coverage 25% < minimum 50%/,
824
+ `coverage reason surfaced, got: ${coverageShortfall}`
825
+ );
826
+ assert.equal(decisions.length, 4, "implicit keeps fill the unclaimed ids");
827
+ });
828
+
829
+ test("validateDecisions covers the whole snapshot when explicit coverage meets the floor", () => {
830
+ const snap = snapshot(["a", "b", "c", "d"]);
831
+ const decisions = [{ action: "archive", ids: ["a", "b"], reason: "stale" }]; // claims 2/4 = 50%
832
+ const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
833
+ assert.equal(ok, true, errors.join("; "));
834
+ assert.equal(decisions.length, 3, "archive + 2 implicit keeps for c/d");
835
+ assert.equal(decisions.filter((d) => d.action === "keep").length, 2);
836
+ });
837
+
838
+ test("runDream with dreamImplicitKeep=false rejects a partial mock output (missing from decisions)", async () => {
839
+ const { store, service } = dreamSetup();
840
+ for (let i = 0; i < 3; i++) {
841
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
842
+ }
843
+ // mock only claims the first snapshot id, misses the rest — strict mode must
844
+ // reject the whole run instead of auto-keeping the uncovered ids
845
+ const warnings = [];
846
+ const ctx = {
847
+ warnings,
848
+ logger: { warn: (m) => warnings.push(m) },
849
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "stress-model" }) },
850
+ llm: {
851
+ async *stream(options) {
852
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
853
+ if (userText.includes("| type=")) {
854
+ const ids = [...userText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)].map((m) => m[1]);
855
+ yield { type: "text-delta", index: 0, text: JSON.stringify(ids.slice(0, 1).map((id) => ({ action: "archive", ids: [id], reason: "stale" }))) };
856
+ } else {
857
+ yield { type: "text-delta", index: 0, text: "记忆库总览摘要" };
858
+ }
859
+ yield { type: "finish", reason: { kind: "stop" } };
860
+ }
861
+ }
862
+ };
863
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
864
+ const result = await dream.runDream(ctx, service, {
865
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat",
866
+ dreamImplicitKeep: false
867
+ });
868
+ assert.equal(result.ok, false, "strict mode rejects the partial output");
869
+ assert.match(result.error, /invalid decisions: \d+ errors/);
870
+ assert.ok(warnings.some((m) => m.includes("missing from decisions")), "warned which ids are missing");
871
+ assert.equal(store.listDreamRuns()[0].status, "failed", "run audited as failed");
872
+ store.close();
873
+ });
874
+
875
+ test("runDream sliding window keeps the newest N memories and excludes the oldest", async () => {
876
+ const { store, service } = dreamSetup();
877
+ // seed 5 memories, backdate updated_at so i=0 is oldest (5h ago), i=4 newest (1h ago)
878
+ const ids = [];
879
+ for (let i = 0; i < 5; i++) {
880
+ const { memory } = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
881
+ ids.push(memory.id);
882
+ }
883
+ for (let i = 0; i < 5; i++) {
884
+ const old = new Date(Date.now() - (5 - i) * 3600000).toISOString();
885
+ store.db.prepare("UPDATE memories SET updated_at = ?, created_at = ? WHERE id = ?").run(old, old, ids[i]);
886
+ }
887
+ const ctx = mockCtx({
888
+ onConsolidation: (listText) => {
889
+ const inWindow = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
890
+ .map((m) => m[1]);
891
+ return JSON.stringify(inWindow.map((id) => ({ action: "keep", ids: [id] })));
892
+ }
893
+ });
894
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
895
+ const result = await dream.runDream(ctx, service, {
896
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 3
897
+ });
898
+ const run = store.listDreamRuns()[0];
899
+ assert.equal(run.input_count, 3, "window capped at dreamMaxSnapshotSize");
900
+ const windowIds = run.input.map((m) => m.id).sort();
901
+ const expected = [ids[2], ids[3], ids[4]].sort(); // newest 3 by updated_at
902
+ assert.deepEqual(windowIds, expected, "window contains the newest 3 memories");
903
+ assert.ok(!windowIds.includes(ids[0]), "oldest memory excluded from the window");
904
+ assert.equal(result.decisions.length, 3, "decisions cover exactly the window");
905
+ store.close();
906
+ });
907
+
908
+ // --- issue #557:dream 审计行输入快照瘦身(dreamInputSummaryEnabled)-----------
909
+ //
910
+ // dream_runs.input 单行体积随记忆量涨(#557 实测平均 40-79 KB/轮,大头是
911
+ // content),轮数又随天数涨,超线性。本组用例锁三件事:
912
+ // ① 默认关 = 审计行逐字节一致(content 原样落库);
913
+ // ② 开启后 auto 轮只落骨架(id/type/title/importance/updated_at),且
914
+ // input_count / snapshot_hash / receipt / decisions 都不变——瘦身的只是
915
+ // 落库副本,LLM 输入与 receipt 完整性不受影响;
916
+ // ③ organize 轮的重放载荷不经过 dream.js 的快照构造(结构性分叉),这里锁
917
+ // auto 路径的形状,organize 侧由 organize.test.js 的 apply 重放用例兜住。
918
+
919
+ test("issue#557: 默认关 = 审计行快照带 content(行为与改动前一致)", async () => {
920
+ const { store, service } = dreamSetup();
921
+ const content = "很长的正文,瘦身前应原样落库。".repeat(20);
922
+ for (let i = 0; i < 3; i++) {
923
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content, importance: 3 });
924
+ }
925
+ const ctx = mockCtx({ onConsolidation: sessionDecisions });
926
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
927
+ await dream.runDream(ctx, service, {
928
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat"
929
+ });
930
+ const run = store.listDreamRuns()[0];
931
+ assert.equal(run.input_count, 3);
932
+ assert.deepEqual(
933
+ Object.keys(run.input[0]).sort(),
934
+ ["content", "id", "importance", "title", "type", "updated_at"],
935
+ "默认快照含 content"
936
+ );
937
+ assert.equal(run.input[0].content, content, "content 原样落库");
938
+ store.close();
939
+ });
940
+
941
+ test("issue#557: dreamInputSummaryEnabled 开启后快照只留骨架,判定与回执不变", async () => {
942
+ const { store, service } = dreamSetup();
943
+ for (let i = 0; i < 3; i++) {
944
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `很长的正文${i}`.repeat(20), importance: 3 });
945
+ }
946
+ const ctx = mockCtx({ onConsolidation: sessionDecisions });
947
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
948
+ const result = await dream.runDream(ctx, service, {
949
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat",
950
+ dreamInputSummaryEnabled: true
951
+ });
952
+ const run = store.listDreamRuns()[0];
953
+ assert.equal(run.input_count, 3, "input_count 语义不变");
954
+ for (const m of run.input) {
955
+ assert.deepEqual(
956
+ Object.keys(m).sort(),
957
+ ["id", "importance", "title", "type", "updated_at"],
958
+ "瘦身快照不含 content"
959
+ );
960
+ }
961
+ assert.deepEqual(run.input.map((m) => m.title).sort(), ["主题0", "主题1", "主题2"], "骨架字段带出标题");
962
+ assert.ok(run.snapshot_hash && run.snapshot_hash.length === 64, "snapshot_hash 形状不变(内存里对含 content 快照计算)");
963
+ assert.match(run.receipt, /^dsh-mneme:run:/, "receipt 形状不变");
964
+ assert.ok(Array.isArray(run.decisions) && run.decisions.length > 0, "决策不受影响");
965
+ assert.ok(JSON.stringify(run.input).length < 2048, "骨架行确实小(content 不在)");
966
+ store.close();
967
+ });
968
+
969
+ // --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
970
+
971
+ test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
972
+ const { store, service } = dreamSetup();
973
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
974
+ let systemText = "";
975
+ const ctx = {
976
+ logger: { warn: () => {} },
977
+ llm: {
978
+ async *stream(options) {
979
+ // 只捕获第一次调用(consolidation)的 system 文本:空数组现在走 no-op 成功、
980
+ // summary 会照常跑并覆盖 systemText,所以最后一次调用捕获到的是 SUMMARY_PROMPT。
981
+ if (!systemText) systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
982
+ yield { type: "text-delta", text: "[]" };
983
+ yield { type: "finish", reason: { kind: "ok" } };
984
+ }
985
+ }
986
+ };
987
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
988
+ await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
989
+ // 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
990
+ assert.match(systemText, /"action"/, "prompt names the action field");
991
+ assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
992
+ // conflict winner/loser 是单个 id 字符串而非数组
993
+ assert.match(systemText, /"winner"/, "prompt names the winner field");
994
+ assert.match(systemText, /"loser"/, "prompt names the loser field");
995
+ assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
996
+ assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
997
+ // 同一 id 不可被多个决策重复 claim
998
+ assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
999
+ // 决策 JSON 示例块
1000
+ assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
1001
+ store.close();
1002
+ });
1003
+
1004
+ // --- Bug8: llm_audit_logs trail ----------------------------------------------
1005
+
1006
+ test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
1007
+ const { store, service } = dreamSetup();
1008
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
1009
+ service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
1010
+ const ctx = mockCtx({
1011
+ onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
1012
+ });
1013
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1014
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
1015
+ assert.equal(result.ok, true, "run succeeds");
1016
+ const rows = store.listLlmAudits();
1017
+ assert.equal(rows.length, 2, "consolidation + summary both audited");
1018
+ assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
1019
+ assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
1020
+ const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
1021
+ assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
1022
+ const summarize = rows.find((r) => r.operation_type === "dream_summarize");
1023
+ assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
1024
+ for (const row of rows) {
1025
+ assert.equal(row.status, "success");
1026
+ assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
1027
+ assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
1028
+ assert.equal(row.input_tokens, 0);
1029
+ assert.equal(row.output_tokens, 0);
1030
+ }
1031
+ store.close();
1032
+ });
1033
+
1034
+ test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
1035
+ const { store, service } = dreamSetup();
1036
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
1037
+ const ctx = {
1038
+ logger: { warn: () => {} },
1039
+ llm: {
1040
+ stream: async function* () {
1041
+ yield { type: "finish", reason: { kind: "error" } };
1042
+ }
1043
+ }
1044
+ };
1045
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1046
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
1047
+ assert.equal(result.ok, false, "failed run reported");
1048
+ const rows = store.listLlmAudits();
1049
+ assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
1050
+ assert.equal(rows[0].operation_type, "dream_consolidate");
1051
+ assert.equal(rows[0].status, "error", "LLM failure status=error");
1052
+ assert.ok(rows[0].error_message, "error message recorded");
1053
+ store.close();
1054
+ });
1055
+
1056
+ // --- Issue #89 回归修复:v0.6.9(Issue #26)的 skipInvalid 宽容路径在 v0.7.11
1057
+ // 重写中丢失,弱模型(如 qwen3.8-flash)单条非法决策导致整单拒绝、合法子集
1058
+ // 全部白烧。以下单测自 v0.6.9 测试原样移植,锁定恢复后的行为。-------------
1059
+
1060
+ test("validateDecisions skipInvalid: a single invalid decision is skipped, valid subset survives", () => {
1061
+ const snap = new Map([
1062
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1063
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1064
+ ["a", { id: "a", type: "project", title: "旧A", content: "过时A", importance: 3, archived: false, forgotten: false }],
1065
+ ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1066
+ ]);
1067
+ const decisions = [
1068
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1069
+ { action: "archive", ids: ["a"], reason: "stale" },
1070
+ { action: "archive", ids: ["b"], reason: "stale" }
1071
+ ];
1072
+ const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1073
+ assert.equal(ok, true, `valid subset should survive, got: ${errors.join("; ")}`);
1074
+ assert.equal(skipped.length, 1, "cross-type merge recorded as skipped");
1075
+ assert.equal(skipped[0].index, 0, "the skipped one is the cross-type merge");
1076
+ assert.match(skipped[0].error, /multiple types/, "skip reason mentions types");
1077
+ // invalid merge spliced out of the caller's array; valid archives + implicit
1078
+ // keeps for p/j survive (p/j were left unclaimed by the skipped merge)
1079
+ assert.deepEqual(decisions.map((d) => d.action), ["archive", "archive", "keep", "keep"]);
1080
+ assert.deepEqual(decisions[0].ids, ["a"]);
1081
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("p")), "p auto-kept");
1082
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
1083
+ });
1084
+
1085
+ test("validateDecisions skipInvalid: an all-invalid batch still rejects (zero-survivor guard, #104 方向 1 重审后保留)", () => {
1086
+ const snap = new Map([
1087
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1088
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1089
+ ["d1", { id: "d1", type: "decision", title: "决定", content: "内容D", importance: 3, archived: false, forgotten: false }],
1090
+ ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1091
+ ]);
1092
+ // both decisions are cross-type merges → both skipped → valid claims = 0
1093
+ const decisions = [
1094
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1095
+ { action: "merge", ids: ["d1", "b"], keepSource: "d1", title: "跨类型2", content: "不应合并", importance: 4 }
1096
+ ];
1097
+ const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1098
+ assert.equal(ok, false, "no valid decisions left → whole batch rejected");
1099
+ assert.equal(skipped.length, 2);
1100
+ assert.ok(errors.some((e) => e.includes("nothing valid survived")), "zero-survivor guard error present");
1101
+ assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
1102
+ });
1103
+
1104
+ test("validateDecisions skipInvalid: runaway update count still rejects the whole batch (global cap)", () => {
1105
+ const snap = new Map([
1106
+ ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1107
+ ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1108
+ ["c", { id: "c", type: "project", title: "C", content: "旧C", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }]
1109
+ ]);
1110
+ const decisions = [
1111
+ { action: "update", ids: ["a"], content: "新A" },
1112
+ { action: "update", ids: ["b"], content: "新B" },
1113
+ { action: "update", ids: ["c"], content: "新C" }
1114
+ ];
1115
+ const { ok, errors } = validateDecisions(decisions, snap, { skipInvalid: true });
1116
+ assert.equal(ok, false, "3 updates > default cap 2 → still rejects");
1117
+ assert.ok(errors.some((e) => e.includes("too many update decisions")), "global cap error present");
1118
+ });
1119
+
1120
+ test("validateDecisions allowCrossTypeMerge: cross-type merge is allowed when the flag is on", () => {
1121
+ const snap = new Map([
1122
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1123
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
1124
+ ]);
1125
+ const decisions = [
1126
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
1127
+ ];
1128
+ const { ok, errors } = validateDecisions(decisions, snap, { allowCrossTypeMerge: true });
1129
+ assert.equal(ok, true, `cross-type merge allowed with the flag, got: ${errors.join("; ")}`);
1130
+ assert.equal(decisions.length, 1, "no keep appended (both snapshot ids claimed)");
1131
+ });
1132
+
1133
+ test("validateDecisions default (no options) stays strict — sleep passes unchanged", () => {
1134
+ const snap = new Map([
1135
+ ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false }],
1136
+ ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false }]
1137
+ ]);
1138
+ const { ok, skipped } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
1139
+ assert.equal(ok, false, "strict rejection without skipInvalid");
1140
+ assert.deepEqual(skipped, [], "no skipped bookkeeping in strict mode");
1141
+ });
1142
+
1143
+ test("issue#89: dream run with a skipped invalid decision lands the valid subset and is marked degraded", async () => {
1144
+ const { store, service } = dreamSetup();
1145
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1146
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1147
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1148
+ const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1149
+ const warnings = [];
1150
+ const ctx = {
1151
+ logger: { warn: (m) => warnings.push(String(m)) },
1152
+ llm: {
1153
+ async *stream(options) {
1154
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1155
+ if (userText.includes("| type=")) {
1156
+ // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
1157
+ // 高于 50% 显式覆盖率下限)
1158
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1159
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1160
+ { action: "archive", ids: [b.id], reason: "stale" },
1161
+ { action: "archive", ids: [c.id], reason: "stale" }
1162
+ ]) };
1163
+ } else {
1164
+ yield { type: "text-delta", index: 0, text: "记忆库总览:弱模型的个别非法决策不再拖垮整轮巩固。" };
1165
+ }
1166
+ yield { type: "finish", reason: { kind: "stop" } };
1167
+ }
1168
+ }
1169
+ };
1170
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1171
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1172
+ assert.equal(result.ok, true, "valid subset absorbed (ok for the baseline)");
1173
+ assert.equal(result.status, "degraded", "run marked degraded, not faked ok");
1174
+ assert.equal(store.getById(b.id).archived, true, "valid archive applied");
1175
+ assert.equal(store.getById(pref.id).archived, false, "invalid merge did not touch its targets");
1176
+ assert.ok(warnings.some((w) => w.includes("skipped") && w.includes("multiple types")), "skip reason logged");
1177
+ store.close();
1178
+ });
1179
+
1180
+ // Issue #104(跟进 #137 失败路径):degraded(合法子集已应用)轮的跳过明细此前只进
1181
+ // logger.warn,splice 之后就地从 decisions 里消失,落库的已是幸存列表——离线回放
1182
+ // dream_runs 无法判断被跳的是跨类型 merge、update 保护期还是 unknown id。这里断言
1183
+ // 明细进了独立的 skipped 列(而非内联进 decisions),且携带可定位的逐条原因。
1184
+ test("issue#104: degraded run persists the skipped decision detail into dream_runs.skipped", async () => {
1185
+ const { store, service } = dreamSetup();
1186
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1187
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1188
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1189
+ const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1190
+ const ctx = {
1191
+ logger: { warn: () => {}, info: () => {} },
1192
+ llm: {
1193
+ async *stream(options) {
1194
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1195
+ if (userText.includes("| type=")) {
1196
+ // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(显式覆盖 2/4,过 50% 下限)
1197
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1198
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1199
+ { action: "archive", ids: [b.id], reason: "stale" },
1200
+ { action: "archive", ids: [c.id], reason: "stale" }
1201
+ ]) };
1202
+ } else {
1203
+ yield { type: "text-delta", index: 0, text: "记忆库总览:degraded 轮的跳过明细可从审计行回放。" };
1204
+ }
1205
+ yield { type: "finish", reason: { kind: "stop" } };
1206
+ }
1207
+ }
1208
+ };
1209
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1210
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1211
+ assert.equal(result.status, "degraded", "run degrades on the skipped decision");
1212
+ // 落库验证:skipped 列不再是 NULL,逐条带 index/action/error,事后无需重跑即可定位成因。
1213
+ const run = store.getDreamRun(result.runId);
1214
+ assert.ok(Array.isArray(run?.skipped) && run.skipped.length === 1, "skipped detail persisted in dream_runs.skipped");
1215
+ assert.equal(run.skipped[0].index, 0, "the skipped entry is the cross-type merge");
1216
+ assert.equal(run.skipped[0].action, "merge");
1217
+ assert.match(run.skipped[0].error, /multiple types/, "per-item skip reason is kept");
1218
+ // 独立字段,不污染按「决策数组」消费 decisions 的下游读者。
1219
+ assert.ok(!run.decisions.some((d) => d && d._skipped !== undefined), "decisions stays free of inline markers");
1220
+ store.close();
1221
+ });
1222
+
1223
+ test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection", async () => {
1224
+ const { store, service } = dreamSetup();
1225
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1226
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1227
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1228
+ const ctx = {
1229
+ logger: { warn: () => {} },
1230
+ llm: {
1231
+ async *stream(options) {
1232
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1233
+ if (userText.includes("| type=")) {
1234
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1235
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1236
+ { action: "archive", ids: [b.id], reason: "stale" }
1237
+ ]) };
1238
+ } else {
1239
+ yield { type: "text-delta", index: 0, text: "记忆库总览。" };
1240
+ }
1241
+ yield { type: "finish", reason: { kind: "stop" } };
1242
+ }
1243
+ }
1244
+ };
1245
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1246
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSkipInvalid: false });
1247
+ assert.equal(result.ok, false, "strict mode rejects the batch");
1248
+ assert.equal(result.error, "invalid decisions: 1 errors");
1249
+ assert.equal(store.getById(b.id).archived, false, "nothing applied under strict rejection");
1250
+ store.close();
1251
+ });
1252
+
1253
+ test("issue#89: minIntervalMs throttles re-triggering regardless of run outcome", async () => {
1254
+ const { store, service } = dreamSetup();
1255
+ let runs = 0;
1256
+ const dream = createDreamScheduler({
1257
+ onRun: async () => { runs++; return { ok: false, error: "llm failed" }; },
1258
+ thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 80,
1259
+ logger: { warn: () => {} }
1260
+ });
1261
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1262
+ assert.equal(dream.maybeSchedule(service), true, "first trigger scheduled");
1263
+ await new Promise((r) => setTimeout(r, 20)); // delayMs 0 → run already dispatched and finished
1264
+ assert.equal(runs, 1, "ran once");
1265
+ assert.equal(dream.maybeSchedule(service), false, "inside the min interval, even after a failed run");
1266
+ await new Promise((r) => setTimeout(r, 90));
1267
+ assert.equal(dream.maybeSchedule(service), true, "interval elapsed → eligible again (baseline unmoved by failure)");
1268
+ store.close();
1269
+ });
1270
+
1271
+ // --- Issue #126:新动作(supersede / differentiate)在 dream 路径也必须产 receipt ---
1272
+ // buildRecordReceipts 此前只认 merge/conflict/update,新动作会静默不产 receipt,让
1273
+ // receipt_chain 留洞。dream 的 consolidation prompt 虽然不含这两个动作,但校验器
1274
+ // 的 ACTIONS 是共用的——模型自由发挥(或将来把动作集扩到 dream)时审计不能丢。
1275
+
1276
+ test("issue#126: a supersede decision on the dream path produces a supersede receipt", async () => {
1277
+ const { store, service } = dreamSetup();
1278
+ const oldMem = service.saveWithDedupe({ type: "project", title: "方案 v1", content: "用 pm2 常驻" }).memory;
1279
+ const newMem = service.saveWithDedupe({ type: "project", title: "方案 v2", content: "改用 systemd" }).memory;
1280
+ const ctx = mockCtx({
1281
+ onConsolidation: () => JSON.stringify([
1282
+ { action: "supersede", winner: newMem.id, loser: oldMem.id, reason: "版本演进" }
1283
+ ])
1284
+ });
1285
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1286
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1287
+ assert.equal(result.status, "ok", "supersede applied and the summary landed");
1288
+ assert.equal(store.getById(oldMem.id).archived, true, "older entry archived");
1289
+ assert.equal(store.getById(newMem.id).content, "改用 systemd", "winner body untouched");
1290
+ const receipts = store.listReceipts();
1291
+ const supersedeReceipt = receipts.find((r) => r.kind === "supersede");
1292
+ assert.ok(supersedeReceipt, "supersede receipt lands in receipt_chain");
1293
+ assert.equal(supersedeReceipt.record_id, oldMem.id, "receipt records the superseded side");
1294
+ assert.equal(supersedeReceipt.winner_id, newMem.id);
1295
+ assert.equal(supersedeReceipt.loser_id, oldMem.id);
1296
+ store.close();
1297
+ });
1298
+
1299
+ test("issue#126: a differentiate decision on the dream path produces one receipt per entry", async () => {
1300
+ const { store, service } = dreamSetup();
1301
+ const a = service.saveWithDedupe({ type: "project", title: "内网端口", content: "内网走 22" }).memory;
1302
+ const b = service.saveWithDedupe({ type: "project", title: "外网端口", content: "外网映射 2222" }).memory;
1303
+ const ctx = mockCtx({
1304
+ onConsolidation: () => JSON.stringify([
1305
+ { action: "differentiate", ids: [a.id, b.id], distinctions: ["内网直连", "外网映射"] }
1306
+ ])
1307
+ });
1308
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1309
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1310
+ assert.equal(result.status, "ok", "differentiate applied and the summary landed");
1311
+ assert.equal(store.getById(a.id).archived, false, "neither side is archived");
1312
+ assert.equal(store.getById(b.id).archived, false);
1313
+ const receipts = store.listReceipts().filter((r) => r.kind === "differentiate");
1314
+ assert.equal(receipts.length, 2, "one receipt per differentiated entry");
1315
+ assert.deepEqual(receipts.map((r) => r.record_id).sort(), [a.id, b.id].sort());
1316
+ assert.ok(receipts.every((r) => r.count_before === 1 && r.count_after === 1), "no count change for differentiate");
1317
+ store.close();
1318
+ });
1319
+
1320
+ // --- Issue #258: dream_summarize 独立路由 + 输入硬上限 ------------------------
1321
+
1322
+ test("issue#258: summarize uses dreamSummaryProvider/dreamSummaryModel when configured, falls back to dream route otherwise", async () => {
1323
+ const { store, service } = dreamSetup();
1324
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1325
+ const seen = [];
1326
+ const ctx = mockCtx({});
1327
+ const origStream = ctx.llm.stream.bind(ctx.llm);
1328
+ ctx.llm.stream = async function* (options) {
1329
+ seen.push(`${options.provider}:${options.model}`);
1330
+ yield* origStream(options);
1331
+ };
1332
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1333
+ await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryProvider: "openai", dreamSummaryModel: "big-ctx" });
1334
+ assert.ok(seen.includes("mock:mock-model"), "consolidation keeps the dream route");
1335
+ assert.ok(seen.includes("openai:big-ctx"), "summary takes the dedicated route");
1336
+ const summarize = store.listLlmAudits().find((r) => r.operation_type === "dream_summarize");
1337
+ assert.equal(summarize.model_id, "openai:big-ctx", "audit trail names the dedicated route");
1338
+ store.close();
1339
+ });
1340
+
1341
+ test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 = whole library)", async () => {
1342
+ const { store, service } = dreamSetup();
1343
+ const titles = ["旧一", "旧二", "旧三", "新四", "新五"];
1344
+ const ids = titles.map((t, i) => service.saveWithDedupe({ type: "project", title: t, content: `内容${i}` }).memory.id);
1345
+ // 同毫秒保存会让 updated_at 并列、排序退化到 id(UUID 随机)——回填保证严格递增。
1346
+ ids.forEach((id, i) => {
1347
+ const at = `2026-01-01T0${i + 1}:00:00.000Z`;
1348
+ store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(at, id);
1349
+ });
1350
+ const seenUserText = [];
1351
+ const ctx = mockCtx({});
1352
+ const origStream = ctx.llm.stream.bind(ctx.llm);
1353
+ ctx.llm.stream = async function* (options) {
1354
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1355
+ if (!userText.includes("| type=")) seenUserText.push(userText);
1356
+ yield* origStream(options);
1357
+ };
1358
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1359
+ await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryMaxInputs: 3 });
1360
+ const summaryText = seenUserText.at(-1);
1361
+ assert.ok(summaryText.includes("新四") && summaryText.includes("新五") && summaryText.includes("旧三"), "newest three survive the cap");
1362
+ assert.ok(!summaryText.includes("旧一") && !summaryText.includes("旧二"), "oldest two are cut");
1363
+ const overview = () => service.all().find((m) => m.type === "summary");
1364
+ assert.match(overview().content, /整理后 3 条/, "口径 footer counts the capped snapshot");
1365
+ // 未配置 = 0 = 全库,历史行为不变
1366
+ await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1367
+ assert.match(overview().content, /整理后 5 条/, "uncapped run covers the whole library");
1368
+ store.close();
1369
+ });
1370
+
1371
+ // --- issue #339 / E8 考卷:merge 护栏(dreamMergeGuard,默认开)----------------
1372
+ // E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——
1373
+ // archive 护栏只挡 archive 不挡 merge。默认开(config.js);被合并对象命中
1374
+ // ARCHIVE_GUARDED_TYPES 的 merge 决策走与 archive 护栏同款通道:skipInvalid
1375
+ // 时 skipped、严格时整单拒绝。单元层直接传 options,不经 config。
1376
+
1377
+ test("validateDecisions mergeGuard: guarded-type merge is skipped under skipInvalid", () => {
1378
+ const snap = new Map([
1379
+ ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1380
+ ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }],
1381
+ ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
1382
+ ["h2", { id: "h2", type: "history", title: "旧事(近似)", content: "内容2", importance: 3, archived: false, forgotten: false }]
1383
+ ]);
1384
+ const decisions = [
1385
+ { action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "$12,400 上下", importance: 4 },
1386
+ { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
1387
+ ];
1388
+ const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1389
+ assert.equal(ok, true, "history merge survives, guarded merge skipped");
1390
+ assert.equal(skipped.length, 1);
1391
+ assert.match(skipped[0].error, /dreamMergeGuard/, "skip reason names the guard");
1392
+ assert.match(skipped[0].error, /constraint/, "skip reason names the guarded type");
1393
+ // guarded merge 被 splice 掉,history merge 与两条隐式 keep 存活。
1394
+ assert.deepEqual(decisions.map((d) => d.action), ["merge", "keep", "keep"]);
1395
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("c1")), "c1 auto-kept");
1396
+ });
1397
+
1398
+ test("validateDecisions mergeGuard: strict mode rejects the whole batch", () => {
1399
+ const guardedSnap = new Map([
1400
+ ["g1", { id: "g1", type: "pitfall", title: "坑", content: "内容", importance: 3, archived: false, forgotten: false }],
1401
+ ["g2", { id: "g2", type: "pitfall", title: "坑2", content: "内容2", importance: 3, archived: false, forgotten: false }]
1402
+ ]);
1403
+ const strict = validateDecisions(
1404
+ [{ action: "merge", ids: ["g1", "g2"], keepSource: "g1", title: "t", content: "c" }],
1405
+ guardedSnap,
1406
+ { skipInvalid: false, mergeGuard: true }
1407
+ );
1408
+ assert.equal(strict.ok, false, "strict mode rejects guarded merge");
1409
+ assert.ok(strict.errors.some((e) => /dreamMergeGuard/.test(e)));
1410
+ });
1411
+
1412
+ test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = legacy behavior", () => {
1413
+ const snap = new Map([
1414
+ ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1415
+ ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }]
1416
+ ]);
1417
+ const decisions = [{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "合并", importance: 4 }];
1418
+ // guard 选项缺省/false = 旧行为(merge 照常通过);默认值由 config.js 供给,
1419
+ // 本单元层直接传 options,锁的是「不显式开就不拦」这条回退路径。
1420
+ assert.equal(validateDecisions(decisions, snap, { skipInvalid: true }).ok, true);
1421
+ // guard 开但类型非 guarded(history 用 snapshot 默认 type=project 亦非 guarded)。
1422
+ const plainSnap = snapshot(["x", "y"]);
1423
+ assert.equal(
1424
+ validateDecisions(
1425
+ [{ action: "merge", ids: ["x", "y"], keepSource: "x", title: "t", content: "c" }],
1426
+ plainSnap,
1427
+ { skipInvalid: true, mergeGuard: true }
1428
+ ).ok,
1429
+ true,
1430
+ "non-guarded merge passes under guard"
1431
+ );
1432
+ });
1433
+
1434
+ // Issue #521:merge 护栏与 archive 护栏对齐——真重复/真过时的长保留类型必须
1435
+ // 能合,否则提示词催着模型合、护栏一律拦,形成「每轮重提同一簇、满载 token 空
1436
+ // 转、merge 恒为 0」的自锁。锁三件事:①同类型 + 重复理由放行;②只有理由没有
1437
+ // 同类型不放行(逃生门不跟着放宽跨类型);③同类型但理由只是「主题相近」不放行
1438
+ // (相近 ≠ 重复,这条是故意的)。
1439
+ test("validateDecisions mergeGuard: genuine duplicate of a long-retention type passes (issue #521)", () => {
1440
+ const snap = new Map([
1441
+ ["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
1442
+ ["p2", { id: "p2", type: "preference", title: "偏好深色主题", content: "偏好深色界面", importance: 3, archived: false, forgotten: false }]
1443
+ ]);
1444
+ const decisions = [
1445
+ { action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "喜欢深色主题", content: "偏好深色界面", importance: 3, reason: "两条重复" }
1446
+ ];
1447
+ const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1448
+ assert.equal(ok, true, "duplicate-rationale merge of guarded type survives the guard");
1449
+ assert.equal(skipped.length, 0, "nothing skipped when the escape hatch opens");
1450
+ assert.equal(decisions.filter((d) => d.action === "merge").length, 1, "merge decision is applied, not dropped");
1451
+ });
1452
+
1453
+ test("validateDecisions mergeGuard: same type but only 'topically similar' rationale still blocked (issue #521)", () => {
1454
+ const snap = new Map([
1455
+ ["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
1456
+ ["p2", { id: "p2", type: "preference", title: "喜欢等宽字体", content: "偏好等宽", importance: 3, archived: false, forgotten: false }]
1457
+ ]);
1458
+ const decisions = [
1459
+ { action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "界面偏好", content: "深色 + 等宽", importance: 3, reason: "主题相近" }
1460
+ ];
1461
+ // 单条决策被跳过后批次整体不成立(覆盖率下限),所以配一条合法的非 guarded
1462
+ // merge 让批次存活,单独观察这条被拦的理由。
1463
+ decisions.push({ action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 });
1464
+ snap.set("h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false });
1465
+ snap.set("h2", { id: "h2", type: "history", title: "旧事2", content: "内容2", importance: 3, archived: false, forgotten: false });
1466
+ const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1467
+ assert.equal(ok, true, "skipInvalid keeps the batch alive");
1468
+ assert.equal(skipped.length, 1, "topically-similar rationale is not a duplicate rationale");
1469
+ assert.match(skipped[0].error, /dreamMergeGuard/);
1470
+ assert.match(skipped[0].error, /重复\/过时/, "skip reason tells the model what would unblock it");
1471
+ });
1472
+
1473
+ test("validateDecisions mergeGuard: duplicate rationale does not unlock cross-type merge (issue #521)", () => {
1474
+ const snap = new Map([
1475
+ ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1476
+ ["p1", { id: "p1", type: "preference", title: "预算偏好", content: "$12,400", importance: 3, archived: false, forgotten: false }]
1477
+ ]);
1478
+ const decisions = [
1479
+ { action: "merge", ids: ["c1", "p1"], keepSource: "c1", title: "预算", content: "$12,400", importance: 4, reason: "重复" }
1480
+ ];
1481
+ // 显式放开 allowCrossTypeMerge:跨类型本身不再报错,逃生门仍必须拦住它——
1482
+ // 否则「重复」理由会把约束和偏好揉成一条,正是 #339 要防的失真。
1483
+ const { skipped } = validateDecisions(decisions, snap, {
1484
+ skipInvalid: true,
1485
+ mergeGuard: true,
1486
+ allowCrossTypeMerge: true
1487
+ });
1488
+ assert.equal(skipped.length, 1, "escape hatch requires all ids share one type");
1489
+ assert.match(skipped[0].error, /dreamMergeGuard/);
1490
+ });
1491
+
1492
+ // #522 评审:逃生门的词表只认关键词、不认否定。「并不重复,只是主题相近」与
1493
+ // "not a duplicate" 都含「重复」/"duplicate" 子串,放它们过闸等于模型用一句否认
1494
+ // 换来合并许可——恰好把 #339 要防的「两条独立表述被揉成一段糊话」放回来。
1495
+ test("validateDecisions mergeGuard: a negated duplicate rationale does not open the escape hatch (#522)", () => {
1496
+ const makeSnap = () => new Map([
1497
+ ["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
1498
+ ["p2", { id: "p2", type: "preference", title: "喜欢等宽字体", content: "偏好等宽", importance: 3, archived: false, forgotten: false }],
1499
+ ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
1500
+ ["h2", { id: "h2", type: "history", title: "旧事2", content: "内容2", importance: 3, archived: false, forgotten: false }]
1501
+ ]);
1502
+ // 中英各一条否定表述:都要被拦。配一条合法的非 guarded merge 让批次存活
1503
+ // (单条决策被跳过后覆盖率下限会让整批不成立),单独观察被拦的理由。
1504
+ // 第二轮评审:词表里「过时 / stale / outdated」也是钥匙,同样要挡。
1505
+ for (const reason of ["并不重复,只是主题相近", "not a duplicate, just similar", "并不过时,只是主题相近", "not outdated, merely similar", "not stale, just similar"]) {
1506
+ const decisions = [
1507
+ { action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "界面偏好", content: "深色 + 等宽", importance: 3, reason },
1508
+ { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
1509
+ ];
1510
+ const { ok, skipped } = validateDecisions(decisions, makeSnap(), { skipInvalid: true, mergeGuard: true });
1511
+ assert.equal(ok, true, "skipInvalid keeps the batch alive");
1512
+ assert.equal(skipped.length, 1, `negated rationale must not open the escape hatch: ${reason}`);
1513
+ assert.match(skipped[0].error, /dreamMergeGuard/);
1514
+ }
1515
+ });
1516
+ test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => {
1517
+ const snap = new Map([
1518
+ ["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1519
+ ["p", { id: "p", type: "project", title: "项目", content: "内容", importance: 3, archived: false, forgotten: false }]
1520
+ ]);
1521
+ const { ok, skipped } = validateDecisions([
1522
+ { action: "archive", ids: ["c"], reason: "keeps the store tidy" },
1523
+ { action: "archive", ids: ["p"], reason: "outdated" }
1524
+ ], snap, { skipInvalid: true });
1525
+ assert.equal(ok, true, "project archive with stale rationale survives");
1526
+ assert.equal(skipped.length, 1, "constraint archive without rationale skipped");
1527
+ assert.match(skipped[0].error, /long-retention type/);
1528
+ });