@modusensus/dsh-mneme 0.8.14 → 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. package/test/write-admission.test.js +668 -668
@@ -1,1383 +1,1385 @@
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 } 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.startsWith("id=")) {
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
- // --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
909
-
910
- test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
911
- const { store, service } = dreamSetup();
912
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
913
- let systemText = "";
914
- const ctx = {
915
- logger: { warn: () => {} },
916
- llm: {
917
- async *stream(options) {
918
- // 只捕获第一次调用(consolidation)的 system 文本:空数组现在走 no-op 成功、
919
- // summary 会照常跑并覆盖 systemText,所以最后一次调用捕获到的是 SUMMARY_PROMPT。
920
- if (!systemText) systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
921
- yield { type: "text-delta", text: "[]" };
922
- yield { type: "finish", reason: { kind: "ok" } };
923
- }
924
- }
925
- };
926
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
927
- await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
928
- // 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
929
- assert.match(systemText, /"action"/, "prompt names the action field");
930
- assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
931
- // conflict winner/loser 是单个 id 字符串而非数组
932
- assert.match(systemText, /"winner"/, "prompt names the winner field");
933
- assert.match(systemText, /"loser"/, "prompt names the loser field");
934
- assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
935
- assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
936
- // 同一 id 不可被多个决策重复 claim
937
- assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
938
- // 决策 JSON 示例块
939
- assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
940
- store.close();
941
- });
942
-
943
- // --- Bug8: llm_audit_logs trail ----------------------------------------------
944
-
945
- test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
946
- const { store, service } = dreamSetup();
947
- service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
948
- service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
949
- const ctx = mockCtx({
950
- onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
951
- });
952
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
953
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
954
- assert.equal(result.ok, true, "run succeeds");
955
- const rows = store.listLlmAudits();
956
- assert.equal(rows.length, 2, "consolidation + summary both audited");
957
- assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
958
- assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
959
- const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
960
- assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
961
- const summarize = rows.find((r) => r.operation_type === "dream_summarize");
962
- assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
963
- for (const row of rows) {
964
- assert.equal(row.status, "success");
965
- assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
966
- assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
967
- assert.equal(row.input_tokens, 0);
968
- assert.equal(row.output_tokens, 0);
969
- }
970
- store.close();
971
- });
972
-
973
- test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
974
- const { store, service } = dreamSetup();
975
- service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
976
- const ctx = {
977
- logger: { warn: () => {} },
978
- llm: {
979
- stream: async function* () {
980
- yield { type: "finish", reason: { kind: "error" } };
981
- }
982
- }
983
- };
984
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
985
- const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
986
- assert.equal(result.ok, false, "failed run reported");
987
- const rows = store.listLlmAudits();
988
- assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
989
- assert.equal(rows[0].operation_type, "dream_consolidate");
990
- assert.equal(rows[0].status, "error", "LLM failure status=error");
991
- assert.ok(rows[0].error_message, "error message recorded");
992
- store.close();
993
- });
994
-
995
- // --- Issue #89 回归修复:v0.6.9(Issue #26)的 skipInvalid 宽容路径在 v0.7.11
996
- // 重写中丢失,弱模型(如 qwen3.8-flash)单条非法决策导致整单拒绝、合法子集
997
- // 全部白烧。以下单测自 v0.6.9 测试原样移植,锁定恢复后的行为。-------------
998
-
999
- test("validateDecisions skipInvalid: a single invalid decision is skipped, valid subset survives", () => {
1000
- const snap = new Map([
1001
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1002
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1003
- ["a", { id: "a", type: "project", title: "旧A", content: "过时A", importance: 3, archived: false, forgotten: false }],
1004
- ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1005
- ]);
1006
- const decisions = [
1007
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1008
- { action: "archive", ids: ["a"], reason: "stale" },
1009
- { action: "archive", ids: ["b"], reason: "stale" }
1010
- ];
1011
- const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1012
- assert.equal(ok, true, `valid subset should survive, got: ${errors.join("; ")}`);
1013
- assert.equal(skipped.length, 1, "cross-type merge recorded as skipped");
1014
- assert.equal(skipped[0].index, 0, "the skipped one is the cross-type merge");
1015
- assert.match(skipped[0].error, /multiple types/, "skip reason mentions types");
1016
- // invalid merge spliced out of the caller's array; valid archives + implicit
1017
- // keeps for p/j survive (p/j were left unclaimed by the skipped merge)
1018
- assert.deepEqual(decisions.map((d) => d.action), ["archive", "archive", "keep", "keep"]);
1019
- assert.deepEqual(decisions[0].ids, ["a"]);
1020
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("p")), "p auto-kept");
1021
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
1022
- });
1023
-
1024
- test("validateDecisions skipInvalid: an all-invalid batch still rejects (zero-survivor guard, #104 方向 1 重审后保留)", () => {
1025
- const snap = new Map([
1026
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1027
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1028
- ["d1", { id: "d1", type: "decision", title: "决定", content: "内容D", importance: 3, archived: false, forgotten: false }],
1029
- ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1030
- ]);
1031
- // both decisions are cross-type merges → both skipped → valid claims = 0
1032
- const decisions = [
1033
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1034
- { action: "merge", ids: ["d1", "b"], keepSource: "d1", title: "跨类型2", content: "不应合并", importance: 4 }
1035
- ];
1036
- const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1037
- assert.equal(ok, false, "no valid decisions left → whole batch rejected");
1038
- assert.equal(skipped.length, 2);
1039
- assert.ok(errors.some((e) => e.includes("nothing valid survived")), "zero-survivor guard error present");
1040
- assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
1041
- });
1042
-
1043
- test("validateDecisions skipInvalid: runaway update count still rejects the whole batch (global cap)", () => {
1044
- const snap = new Map([
1045
- ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1046
- ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1047
- ["c", { id: "c", type: "project", title: "C", content: "旧C", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }]
1048
- ]);
1049
- const decisions = [
1050
- { action: "update", ids: ["a"], content: "新A" },
1051
- { action: "update", ids: ["b"], content: "新B" },
1052
- { action: "update", ids: ["c"], content: "新C" }
1053
- ];
1054
- const { ok, errors } = validateDecisions(decisions, snap, { skipInvalid: true });
1055
- assert.equal(ok, false, "3 updates > default cap 2 → still rejects");
1056
- assert.ok(errors.some((e) => e.includes("too many update decisions")), "global cap error present");
1057
- });
1058
-
1059
- test("validateDecisions allowCrossTypeMerge: cross-type merge is allowed when the flag is on", () => {
1060
- const snap = new Map([
1061
- ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1062
- ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
1063
- ]);
1064
- const decisions = [
1065
- { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
1066
- ];
1067
- const { ok, errors } = validateDecisions(decisions, snap, { allowCrossTypeMerge: true });
1068
- assert.equal(ok, true, `cross-type merge allowed with the flag, got: ${errors.join("; ")}`);
1069
- assert.equal(decisions.length, 1, "no keep appended (both snapshot ids claimed)");
1070
- });
1071
-
1072
- test("validateDecisions default (no options) stays strict — sleep passes unchanged", () => {
1073
- const snap = new Map([
1074
- ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false }],
1075
- ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false }]
1076
- ]);
1077
- const { ok, skipped } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
1078
- assert.equal(ok, false, "strict rejection without skipInvalid");
1079
- assert.deepEqual(skipped, [], "no skipped bookkeeping in strict mode");
1080
- });
1081
-
1082
- test("issue#89: dream run with a skipped invalid decision lands the valid subset and is marked degraded", async () => {
1083
- const { store, service } = dreamSetup();
1084
- const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1085
- const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1086
- const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1087
- const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1088
- const warnings = [];
1089
- const ctx = {
1090
- logger: { warn: (m) => warnings.push(String(m)) },
1091
- llm: {
1092
- async *stream(options) {
1093
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1094
- if (userText.startsWith("id=")) {
1095
- // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
1096
- // 高于 50% 显式覆盖率下限)
1097
- yield { type: "text-delta", index: 0, text: JSON.stringify([
1098
- { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1099
- { action: "archive", ids: [b.id], reason: "stale" },
1100
- { action: "archive", ids: [c.id], reason: "stale" }
1101
- ]) };
1102
- } else {
1103
- yield { type: "text-delta", index: 0, text: "记忆库总览:弱模型的个别非法决策不再拖垮整轮巩固。" };
1104
- }
1105
- yield { type: "finish", reason: { kind: "stop" } };
1106
- }
1107
- }
1108
- };
1109
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1110
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1111
- assert.equal(result.ok, true, "valid subset absorbed (ok for the baseline)");
1112
- assert.equal(result.status, "degraded", "run marked degraded, not faked ok");
1113
- assert.equal(store.getById(b.id).archived, true, "valid archive applied");
1114
- assert.equal(store.getById(pref.id).archived, false, "invalid merge did not touch its targets");
1115
- assert.ok(warnings.some((w) => w.includes("skipped") && w.includes("multiple types")), "skip reason logged");
1116
- store.close();
1117
- });
1118
-
1119
- // Issue #104(跟进 #137 失败路径):degraded(合法子集已应用)轮的跳过明细此前只进
1120
- // logger.warn,splice 之后就地从 decisions 里消失,落库的已是幸存列表——离线回放
1121
- // dream_runs 无法判断被跳的是跨类型 merge、update 保护期还是 unknown id。这里断言
1122
- // 明细进了独立的 skipped 列(而非内联进 decisions),且携带可定位的逐条原因。
1123
- test("issue#104: degraded run persists the skipped decision detail into dream_runs.skipped", async () => {
1124
- const { store, service } = dreamSetup();
1125
- const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1126
- const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1127
- const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1128
- const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1129
- const ctx = {
1130
- logger: { warn: () => {}, info: () => {} },
1131
- llm: {
1132
- async *stream(options) {
1133
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1134
- if (userText.startsWith("id=")) {
1135
- // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(显式覆盖 2/4,过 50% 下限)
1136
- yield { type: "text-delta", index: 0, text: JSON.stringify([
1137
- { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1138
- { action: "archive", ids: [b.id], reason: "stale" },
1139
- { action: "archive", ids: [c.id], reason: "stale" }
1140
- ]) };
1141
- } else {
1142
- yield { type: "text-delta", index: 0, text: "记忆库总览:degraded 轮的跳过明细可从审计行回放。" };
1143
- }
1144
- yield { type: "finish", reason: { kind: "stop" } };
1145
- }
1146
- }
1147
- };
1148
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1149
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1150
- assert.equal(result.status, "degraded", "run degrades on the skipped decision");
1151
- // 落库验证:skipped 列不再是 NULL,逐条带 index/action/error,事后无需重跑即可定位成因。
1152
- const run = store.getDreamRun(result.runId);
1153
- assert.ok(Array.isArray(run?.skipped) && run.skipped.length === 1, "skipped detail persisted in dream_runs.skipped");
1154
- assert.equal(run.skipped[0].index, 0, "the skipped entry is the cross-type merge");
1155
- assert.equal(run.skipped[0].action, "merge");
1156
- assert.match(run.skipped[0].error, /multiple types/, "per-item skip reason is kept");
1157
- // 独立字段,不污染按「决策数组」消费 decisions 的下游读者。
1158
- assert.ok(!run.decisions.some((d) => d && d._skipped !== undefined), "decisions stays free of inline markers");
1159
- store.close();
1160
- });
1161
-
1162
- test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection", async () => {
1163
- const { store, service } = dreamSetup();
1164
- const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1165
- const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1166
- const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1167
- const ctx = {
1168
- logger: { warn: () => {} },
1169
- llm: {
1170
- async *stream(options) {
1171
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1172
- if (userText.startsWith("id=")) {
1173
- yield { type: "text-delta", index: 0, text: JSON.stringify([
1174
- { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1175
- { action: "archive", ids: [b.id], reason: "stale" }
1176
- ]) };
1177
- } else {
1178
- yield { type: "text-delta", index: 0, text: "记忆库总览。" };
1179
- }
1180
- yield { type: "finish", reason: { kind: "stop" } };
1181
- }
1182
- }
1183
- };
1184
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1185
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSkipInvalid: false });
1186
- assert.equal(result.ok, false, "strict mode rejects the batch");
1187
- assert.equal(result.error, "invalid decisions: 1 errors");
1188
- assert.equal(store.getById(b.id).archived, false, "nothing applied under strict rejection");
1189
- store.close();
1190
- });
1191
-
1192
- test("issue#89: minIntervalMs throttles re-triggering regardless of run outcome", async () => {
1193
- const { store, service } = dreamSetup();
1194
- let runs = 0;
1195
- const dream = createDreamScheduler({
1196
- onRun: async () => { runs++; return { ok: false, error: "llm failed" }; },
1197
- thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 80,
1198
- logger: { warn: () => {} }
1199
- });
1200
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1201
- assert.equal(dream.maybeSchedule(service), true, "first trigger scheduled");
1202
- await new Promise((r) => setTimeout(r, 20)); // delayMs 0 → run already dispatched and finished
1203
- assert.equal(runs, 1, "ran once");
1204
- assert.equal(dream.maybeSchedule(service), false, "inside the min interval, even after a failed run");
1205
- await new Promise((r) => setTimeout(r, 90));
1206
- assert.equal(dream.maybeSchedule(service), true, "interval elapsed → eligible again (baseline unmoved by failure)");
1207
- store.close();
1208
- });
1209
-
1210
- // --- Issue #126:新动作(supersede / differentiate)在 dream 路径也必须产 receipt ---
1211
- // buildRecordReceipts 此前只认 merge/conflict/update,新动作会静默不产 receipt,让
1212
- // receipt_chain 留洞。dream 的 consolidation prompt 虽然不含这两个动作,但校验器
1213
- // 的 ACTIONS 是共用的——模型自由发挥(或将来把动作集扩到 dream)时审计不能丢。
1214
-
1215
- test("issue#126: a supersede decision on the dream path produces a supersede receipt", async () => {
1216
- const { store, service } = dreamSetup();
1217
- const oldMem = service.saveWithDedupe({ type: "project", title: "方案 v1", content: "用 pm2 常驻" }).memory;
1218
- const newMem = service.saveWithDedupe({ type: "project", title: "方案 v2", content: "改用 systemd" }).memory;
1219
- const ctx = mockCtx({
1220
- onConsolidation: () => JSON.stringify([
1221
- { action: "supersede", winner: newMem.id, loser: oldMem.id, reason: "版本演进" }
1222
- ])
1223
- });
1224
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1225
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1226
- assert.equal(result.status, "ok", "supersede applied and the summary landed");
1227
- assert.equal(store.getById(oldMem.id).archived, true, "older entry archived");
1228
- assert.equal(store.getById(newMem.id).content, "改用 systemd", "winner body untouched");
1229
- const receipts = store.listReceipts();
1230
- const supersedeReceipt = receipts.find((r) => r.kind === "supersede");
1231
- assert.ok(supersedeReceipt, "supersede receipt lands in receipt_chain");
1232
- assert.equal(supersedeReceipt.record_id, oldMem.id, "receipt records the superseded side");
1233
- assert.equal(supersedeReceipt.winner_id, newMem.id);
1234
- assert.equal(supersedeReceipt.loser_id, oldMem.id);
1235
- store.close();
1236
- });
1237
-
1238
- test("issue#126: a differentiate decision on the dream path produces one receipt per entry", async () => {
1239
- const { store, service } = dreamSetup();
1240
- const a = service.saveWithDedupe({ type: "project", title: "内网端口", content: "内网走 22" }).memory;
1241
- const b = service.saveWithDedupe({ type: "project", title: "外网端口", content: "外网映射 2222" }).memory;
1242
- const ctx = mockCtx({
1243
- onConsolidation: () => JSON.stringify([
1244
- { action: "differentiate", ids: [a.id, b.id], distinctions: ["内网直连", "外网映射"] }
1245
- ])
1246
- });
1247
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1248
- const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1249
- assert.equal(result.status, "ok", "differentiate applied and the summary landed");
1250
- assert.equal(store.getById(a.id).archived, false, "neither side is archived");
1251
- assert.equal(store.getById(b.id).archived, false);
1252
- const receipts = store.listReceipts().filter((r) => r.kind === "differentiate");
1253
- assert.equal(receipts.length, 2, "one receipt per differentiated entry");
1254
- assert.deepEqual(receipts.map((r) => r.record_id).sort(), [a.id, b.id].sort());
1255
- assert.ok(receipts.every((r) => r.count_before === 1 && r.count_after === 1), "no count change for differentiate");
1256
- store.close();
1257
- });
1258
-
1259
- // --- Issue #258: dream_summarize 独立路由 + 输入硬上限 ------------------------
1260
-
1261
- test("issue#258: summarize uses dreamSummaryProvider/dreamSummaryModel when configured, falls back to dream route otherwise", async () => {
1262
- const { store, service } = dreamSetup();
1263
- service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1264
- const seen = [];
1265
- const ctx = mockCtx({});
1266
- const origStream = ctx.llm.stream.bind(ctx.llm);
1267
- ctx.llm.stream = async function* (options) {
1268
- seen.push(`${options.provider}:${options.model}`);
1269
- yield* origStream(options);
1270
- };
1271
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1272
- await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryProvider: "openai", dreamSummaryModel: "big-ctx" });
1273
- assert.ok(seen.includes("mock:mock-model"), "consolidation keeps the dream route");
1274
- assert.ok(seen.includes("openai:big-ctx"), "summary takes the dedicated route");
1275
- const summarize = store.listLlmAudits().find((r) => r.operation_type === "dream_summarize");
1276
- assert.equal(summarize.model_id, "openai:big-ctx", "audit trail names the dedicated route");
1277
- store.close();
1278
- });
1279
-
1280
- test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 = whole library)", async () => {
1281
- const { store, service } = dreamSetup();
1282
- const titles = ["旧一", "旧二", "旧三", "新四", "新五"];
1283
- const ids = titles.map((t, i) => service.saveWithDedupe({ type: "project", title: t, content: `内容${i}` }).memory.id);
1284
- // 同毫秒保存会让 updated_at 并列、排序退化到 id(UUID 随机)——回填保证严格递增。
1285
- ids.forEach((id, i) => {
1286
- const at = `2026-01-01T0${i + 1}:00:00.000Z`;
1287
- store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(at, id);
1288
- });
1289
- const seenUserText = [];
1290
- const ctx = mockCtx({});
1291
- const origStream = ctx.llm.stream.bind(ctx.llm);
1292
- ctx.llm.stream = async function* (options) {
1293
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1294
- if (!userText.startsWith("id=")) seenUserText.push(userText);
1295
- yield* origStream(options);
1296
- };
1297
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1298
- await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryMaxInputs: 3 });
1299
- const summaryText = seenUserText.at(-1);
1300
- assert.ok(summaryText.includes("新四") && summaryText.includes("新五") && summaryText.includes("旧三"), "newest three survive the cap");
1301
- assert.ok(!summaryText.includes("旧一") && !summaryText.includes("旧二"), "oldest two are cut");
1302
- const overview = () => service.all().find((m) => m.type === "summary");
1303
- assert.match(overview().content, /整理后 3 条/, "口径 footer counts the capped snapshot");
1304
- // 未配置 = 0 = 全库,历史行为不变
1305
- await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1306
- assert.match(overview().content, /整理后 5 条/, "uncapped run covers the whole library");
1307
- store.close();
1308
- });
1309
-
1310
- // --- issue #339 / E8 考卷:merge 护栏(dreamMergeGuard,opt-in)---------------
1311
- // E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——
1312
- // archive 护栏只挡 archive 不挡 merge。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES
1313
- // 的 merge 决策走与 archive 护栏同款通道:skipInvalid 时 skipped、严格时整单拒绝。
1314
-
1315
- test("validateDecisions mergeGuard: guarded-type merge is skipped under skipInvalid", () => {
1316
- const snap = new Map([
1317
- ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1318
- ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }],
1319
- ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
1320
- ["h2", { id: "h2", type: "history", title: "旧事(近似)", content: "内容2", importance: 3, archived: false, forgotten: false }]
1321
- ]);
1322
- const decisions = [
1323
- { action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "$12,400 上下", importance: 4 },
1324
- { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
1325
- ];
1326
- const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1327
- assert.equal(ok, true, "history merge survives, guarded merge skipped");
1328
- assert.equal(skipped.length, 1);
1329
- assert.match(skipped[0].error, /dreamMergeGuard/, "skip reason names the guard");
1330
- assert.match(skipped[0].error, /constraint/, "skip reason names the guarded type");
1331
- // guarded merge 被 splice 掉,history merge 与两条隐式 keep 存活。
1332
- assert.deepEqual(decisions.map((d) => d.action), ["merge", "keep", "keep"]);
1333
- assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("c1")), "c1 auto-kept");
1334
- });
1335
-
1336
- test("validateDecisions mergeGuard: strict mode rejects the whole batch", () => {
1337
- const guardedSnap = new Map([
1338
- ["g1", { id: "g1", type: "pitfall", title: "坑", content: "内容", importance: 3, archived: false, forgotten: false }],
1339
- ["g2", { id: "g2", type: "pitfall", title: "坑2", content: "内容2", importance: 3, archived: false, forgotten: false }]
1340
- ]);
1341
- const strict = validateDecisions(
1342
- [{ action: "merge", ids: ["g1", "g2"], keepSource: "g1", title: "t", content: "c" }],
1343
- guardedSnap,
1344
- { skipInvalid: false, mergeGuard: true }
1345
- );
1346
- assert.equal(strict.ok, false, "strict mode rejects guarded merge");
1347
- assert.ok(strict.errors.some((e) => /dreamMergeGuard/.test(e)));
1348
- });
1349
-
1350
- test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = legacy behavior", () => {
1351
- const snap = new Map([
1352
- ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1353
- ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }]
1354
- ]);
1355
- const decisions = [{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "合并", importance: 4 }];
1356
- // guard 关 = 现行为(merge 照常通过)。
1357
- assert.equal(validateDecisions(decisions, snap, { skipInvalid: true }).ok, true);
1358
- // guard 开但类型非 guarded(history 用 snapshot 默认 type=project 亦非 guarded)。
1359
- const plainSnap = snapshot(["x", "y"]);
1360
- assert.equal(
1361
- validateDecisions(
1362
- [{ action: "merge", ids: ["x", "y"], keepSource: "x", title: "t", content: "c" }],
1363
- plainSnap,
1364
- { skipInvalid: true, mergeGuard: true }
1365
- ).ok,
1366
- true,
1367
- "non-guarded merge passes under guard"
1368
- );
1369
- });
1370
-
1371
- test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => {
1372
- const snap = new Map([
1373
- ["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1374
- ["p", { id: "p", type: "project", title: "项目", content: "内容", importance: 3, archived: false, forgotten: false }]
1375
- ]);
1376
- const { ok, skipped } = validateDecisions([
1377
- { action: "archive", ids: ["c"], reason: "keeps the store tidy" },
1378
- { action: "archive", ids: ["p"], reason: "outdated" }
1379
- ], snap, { skipInvalid: true });
1380
- assert.equal(ok, true, "project archive with stale rationale survives");
1381
- assert.equal(skipped.length, 1, "constraint archive without rationale skipped");
1382
- assert.match(skipped[0].error, /long-retention type/);
1383
- });
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 } 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.startsWith("id=")) {
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
+ // --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
909
+
910
+ test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
911
+ const { store, service } = dreamSetup();
912
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
913
+ let systemText = "";
914
+ const ctx = {
915
+ logger: { warn: () => {} },
916
+ llm: {
917
+ async *stream(options) {
918
+ // 只捕获第一次调用(consolidation)的 system 文本:空数组现在走 no-op 成功、
919
+ // summary 会照常跑并覆盖 systemText,所以最后一次调用捕获到的是 SUMMARY_PROMPT。
920
+ if (!systemText) systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
921
+ yield { type: "text-delta", text: "[]" };
922
+ yield { type: "finish", reason: { kind: "ok" } };
923
+ }
924
+ }
925
+ };
926
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
927
+ await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
928
+ // 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
929
+ assert.match(systemText, /"action"/, "prompt names the action field");
930
+ assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
931
+ // conflict winner/loser 是单个 id 字符串而非数组
932
+ assert.match(systemText, /"winner"/, "prompt names the winner field");
933
+ assert.match(systemText, /"loser"/, "prompt names the loser field");
934
+ assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
935
+ assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
936
+ // 同一 id 不可被多个决策重复 claim
937
+ assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
938
+ // 决策 JSON 示例块
939
+ assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
940
+ store.close();
941
+ });
942
+
943
+ // --- Bug8: llm_audit_logs trail ----------------------------------------------
944
+
945
+ test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
946
+ const { store, service } = dreamSetup();
947
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
948
+ service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
949
+ const ctx = mockCtx({
950
+ onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
951
+ });
952
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
953
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
954
+ assert.equal(result.ok, true, "run succeeds");
955
+ const rows = store.listLlmAudits();
956
+ assert.equal(rows.length, 2, "consolidation + summary both audited");
957
+ assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
958
+ assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
959
+ const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
960
+ assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
961
+ const summarize = rows.find((r) => r.operation_type === "dream_summarize");
962
+ assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
963
+ for (const row of rows) {
964
+ assert.equal(row.status, "success");
965
+ assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
966
+ assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
967
+ assert.equal(row.input_tokens, 0);
968
+ assert.equal(row.output_tokens, 0);
969
+ }
970
+ store.close();
971
+ });
972
+
973
+ test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
974
+ const { store, service } = dreamSetup();
975
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
976
+ const ctx = {
977
+ logger: { warn: () => {} },
978
+ llm: {
979
+ stream: async function* () {
980
+ yield { type: "finish", reason: { kind: "error" } };
981
+ }
982
+ }
983
+ };
984
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
985
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
986
+ assert.equal(result.ok, false, "failed run reported");
987
+ const rows = store.listLlmAudits();
988
+ assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
989
+ assert.equal(rows[0].operation_type, "dream_consolidate");
990
+ assert.equal(rows[0].status, "error", "LLM failure status=error");
991
+ assert.ok(rows[0].error_message, "error message recorded");
992
+ store.close();
993
+ });
994
+
995
+ // --- Issue #89 回归修复:v0.6.9(Issue #26)的 skipInvalid 宽容路径在 v0.7.11
996
+ // 重写中丢失,弱模型(如 qwen3.8-flash)单条非法决策导致整单拒绝、合法子集
997
+ // 全部白烧。以下单测自 v0.6.9 测试原样移植,锁定恢复后的行为。-------------
998
+
999
+ test("validateDecisions skipInvalid: a single invalid decision is skipped, valid subset survives", () => {
1000
+ const snap = new Map([
1001
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1002
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1003
+ ["a", { id: "a", type: "project", title: "旧A", content: "过时A", importance: 3, archived: false, forgotten: false }],
1004
+ ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1005
+ ]);
1006
+ const decisions = [
1007
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1008
+ { action: "archive", ids: ["a"], reason: "stale" },
1009
+ { action: "archive", ids: ["b"], reason: "stale" }
1010
+ ];
1011
+ const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1012
+ assert.equal(ok, true, `valid subset should survive, got: ${errors.join("; ")}`);
1013
+ assert.equal(skipped.length, 1, "cross-type merge recorded as skipped");
1014
+ assert.equal(skipped[0].index, 0, "the skipped one is the cross-type merge");
1015
+ assert.match(skipped[0].error, /multiple types/, "skip reason mentions types");
1016
+ // invalid merge spliced out of the caller's array; valid archives + implicit
1017
+ // keeps for p/j survive (p/j were left unclaimed by the skipped merge)
1018
+ assert.deepEqual(decisions.map((d) => d.action), ["archive", "archive", "keep", "keep"]);
1019
+ assert.deepEqual(decisions[0].ids, ["a"]);
1020
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("p")), "p auto-kept");
1021
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
1022
+ });
1023
+
1024
+ test("validateDecisions skipInvalid: an all-invalid batch still rejects (zero-survivor guard, #104 方向 1 重审后保留)", () => {
1025
+ const snap = new Map([
1026
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1027
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
1028
+ ["d1", { id: "d1", type: "decision", title: "决定", content: "内容D", importance: 3, archived: false, forgotten: false }],
1029
+ ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
1030
+ ]);
1031
+ // both decisions are cross-type merges → both skipped → valid claims = 0
1032
+ const decisions = [
1033
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
1034
+ { action: "merge", ids: ["d1", "b"], keepSource: "d1", title: "跨类型2", content: "不应合并", importance: 4 }
1035
+ ];
1036
+ const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
1037
+ assert.equal(ok, false, "no valid decisions left → whole batch rejected");
1038
+ assert.equal(skipped.length, 2);
1039
+ assert.ok(errors.some((e) => e.includes("nothing valid survived")), "zero-survivor guard error present");
1040
+ assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
1041
+ });
1042
+
1043
+ test("validateDecisions skipInvalid: runaway update count still rejects the whole batch (global cap)", () => {
1044
+ const snap = new Map([
1045
+ ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1046
+ ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
1047
+ ["c", { id: "c", type: "project", title: "C", content: "旧C", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }]
1048
+ ]);
1049
+ const decisions = [
1050
+ { action: "update", ids: ["a"], content: "新A" },
1051
+ { action: "update", ids: ["b"], content: "新B" },
1052
+ { action: "update", ids: ["c"], content: "新C" }
1053
+ ];
1054
+ const { ok, errors } = validateDecisions(decisions, snap, { skipInvalid: true });
1055
+ assert.equal(ok, false, "3 updates > default cap 2 → still rejects");
1056
+ assert.ok(errors.some((e) => e.includes("too many update decisions")), "global cap error present");
1057
+ });
1058
+
1059
+ test("validateDecisions allowCrossTypeMerge: cross-type merge is allowed when the flag is on", () => {
1060
+ const snap = new Map([
1061
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
1062
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
1063
+ ]);
1064
+ const decisions = [
1065
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
1066
+ ];
1067
+ const { ok, errors } = validateDecisions(decisions, snap, { allowCrossTypeMerge: true });
1068
+ assert.equal(ok, true, `cross-type merge allowed with the flag, got: ${errors.join("; ")}`);
1069
+ assert.equal(decisions.length, 1, "no keep appended (both snapshot ids claimed)");
1070
+ });
1071
+
1072
+ test("validateDecisions default (no options) stays strict — sleep passes unchanged", () => {
1073
+ const snap = new Map([
1074
+ ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false }],
1075
+ ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false }]
1076
+ ]);
1077
+ const { ok, skipped } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
1078
+ assert.equal(ok, false, "strict rejection without skipInvalid");
1079
+ assert.deepEqual(skipped, [], "no skipped bookkeeping in strict mode");
1080
+ });
1081
+
1082
+ test("issue#89: dream run with a skipped invalid decision lands the valid subset and is marked degraded", async () => {
1083
+ const { store, service } = dreamSetup();
1084
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1085
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1086
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1087
+ const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1088
+ const warnings = [];
1089
+ const ctx = {
1090
+ logger: { warn: (m) => warnings.push(String(m)) },
1091
+ llm: {
1092
+ async *stream(options) {
1093
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1094
+ if (userText.startsWith("id=")) {
1095
+ // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
1096
+ // 高于 50% 显式覆盖率下限)
1097
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1098
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1099
+ { action: "archive", ids: [b.id], reason: "stale" },
1100
+ { action: "archive", ids: [c.id], reason: "stale" }
1101
+ ]) };
1102
+ } else {
1103
+ yield { type: "text-delta", index: 0, text: "记忆库总览:弱模型的个别非法决策不再拖垮整轮巩固。" };
1104
+ }
1105
+ yield { type: "finish", reason: { kind: "stop" } };
1106
+ }
1107
+ }
1108
+ };
1109
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1110
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1111
+ assert.equal(result.ok, true, "valid subset absorbed (ok for the baseline)");
1112
+ assert.equal(result.status, "degraded", "run marked degraded, not faked ok");
1113
+ assert.equal(store.getById(b.id).archived, true, "valid archive applied");
1114
+ assert.equal(store.getById(pref.id).archived, false, "invalid merge did not touch its targets");
1115
+ assert.ok(warnings.some((w) => w.includes("skipped") && w.includes("multiple types")), "skip reason logged");
1116
+ store.close();
1117
+ });
1118
+
1119
+ // Issue #104(跟进 #137 失败路径):degraded(合法子集已应用)轮的跳过明细此前只进
1120
+ // logger.warn,splice 之后就地从 decisions 里消失,落库的已是幸存列表——离线回放
1121
+ // dream_runs 无法判断被跳的是跨类型 merge、update 保护期还是 unknown id。这里断言
1122
+ // 明细进了独立的 skipped 列(而非内联进 decisions),且携带可定位的逐条原因。
1123
+ test("issue#104: degraded run persists the skipped decision detail into dream_runs.skipped", async () => {
1124
+ const { store, service } = dreamSetup();
1125
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1126
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1127
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1128
+ const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
1129
+ const ctx = {
1130
+ logger: { warn: () => {}, info: () => {} },
1131
+ llm: {
1132
+ async *stream(options) {
1133
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1134
+ if (userText.startsWith("id=")) {
1135
+ // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(显式覆盖 2/4,过 50% 下限)
1136
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1137
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1138
+ { action: "archive", ids: [b.id], reason: "stale" },
1139
+ { action: "archive", ids: [c.id], reason: "stale" }
1140
+ ]) };
1141
+ } else {
1142
+ yield { type: "text-delta", index: 0, text: "记忆库总览:degraded 轮的跳过明细可从审计行回放。" };
1143
+ }
1144
+ yield { type: "finish", reason: { kind: "stop" } };
1145
+ }
1146
+ }
1147
+ };
1148
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1149
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1150
+ assert.equal(result.status, "degraded", "run degrades on the skipped decision");
1151
+ // 落库验证:skipped 列不再是 NULL,逐条带 index/action/error,事后无需重跑即可定位成因。
1152
+ const run = store.getDreamRun(result.runId);
1153
+ assert.ok(Array.isArray(run?.skipped) && run.skipped.length === 1, "skipped detail persisted in dream_runs.skipped");
1154
+ assert.equal(run.skipped[0].index, 0, "the skipped entry is the cross-type merge");
1155
+ assert.equal(run.skipped[0].action, "merge");
1156
+ assert.match(run.skipped[0].error, /multiple types/, "per-item skip reason is kept");
1157
+ // 独立字段,不污染按「决策数组」消费 decisions 的下游读者。
1158
+ assert.ok(!run.decisions.some((d) => d && d._skipped !== undefined), "decisions stays free of inline markers");
1159
+ store.close();
1160
+ });
1161
+
1162
+ test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection", async () => {
1163
+ const { store, service } = dreamSetup();
1164
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1165
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1166
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1167
+ const ctx = {
1168
+ logger: { warn: () => {} },
1169
+ llm: {
1170
+ async *stream(options) {
1171
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1172
+ if (userText.startsWith("id=")) {
1173
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1174
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1175
+ { action: "archive", ids: [b.id], reason: "stale" }
1176
+ ]) };
1177
+ } else {
1178
+ yield { type: "text-delta", index: 0, text: "记忆库总览。" };
1179
+ }
1180
+ yield { type: "finish", reason: { kind: "stop" } };
1181
+ }
1182
+ }
1183
+ };
1184
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1185
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSkipInvalid: false });
1186
+ assert.equal(result.ok, false, "strict mode rejects the batch");
1187
+ assert.equal(result.error, "invalid decisions: 1 errors");
1188
+ assert.equal(store.getById(b.id).archived, false, "nothing applied under strict rejection");
1189
+ store.close();
1190
+ });
1191
+
1192
+ test("issue#89: minIntervalMs throttles re-triggering regardless of run outcome", async () => {
1193
+ const { store, service } = dreamSetup();
1194
+ let runs = 0;
1195
+ const dream = createDreamScheduler({
1196
+ onRun: async () => { runs++; return { ok: false, error: "llm failed" }; },
1197
+ thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 80,
1198
+ logger: { warn: () => {} }
1199
+ });
1200
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1201
+ assert.equal(dream.maybeSchedule(service), true, "first trigger scheduled");
1202
+ await new Promise((r) => setTimeout(r, 20)); // delayMs 0 → run already dispatched and finished
1203
+ assert.equal(runs, 1, "ran once");
1204
+ assert.equal(dream.maybeSchedule(service), false, "inside the min interval, even after a failed run");
1205
+ await new Promise((r) => setTimeout(r, 90));
1206
+ assert.equal(dream.maybeSchedule(service), true, "interval elapsed → eligible again (baseline unmoved by failure)");
1207
+ store.close();
1208
+ });
1209
+
1210
+ // --- Issue #126:新动作(supersede / differentiate)在 dream 路径也必须产 receipt ---
1211
+ // buildRecordReceipts 此前只认 merge/conflict/update,新动作会静默不产 receipt,让
1212
+ // receipt_chain 留洞。dream 的 consolidation prompt 虽然不含这两个动作,但校验器
1213
+ // 的 ACTIONS 是共用的——模型自由发挥(或将来把动作集扩到 dream)时审计不能丢。
1214
+
1215
+ test("issue#126: a supersede decision on the dream path produces a supersede receipt", async () => {
1216
+ const { store, service } = dreamSetup();
1217
+ const oldMem = service.saveWithDedupe({ type: "project", title: "方案 v1", content: "用 pm2 常驻" }).memory;
1218
+ const newMem = service.saveWithDedupe({ type: "project", title: "方案 v2", content: "改用 systemd" }).memory;
1219
+ const ctx = mockCtx({
1220
+ onConsolidation: () => JSON.stringify([
1221
+ { action: "supersede", winner: newMem.id, loser: oldMem.id, reason: "版本演进" }
1222
+ ])
1223
+ });
1224
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1225
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1226
+ assert.equal(result.status, "ok", "supersede applied and the summary landed");
1227
+ assert.equal(store.getById(oldMem.id).archived, true, "older entry archived");
1228
+ assert.equal(store.getById(newMem.id).content, "改用 systemd", "winner body untouched");
1229
+ const receipts = store.listReceipts();
1230
+ const supersedeReceipt = receipts.find((r) => r.kind === "supersede");
1231
+ assert.ok(supersedeReceipt, "supersede receipt lands in receipt_chain");
1232
+ assert.equal(supersedeReceipt.record_id, oldMem.id, "receipt records the superseded side");
1233
+ assert.equal(supersedeReceipt.winner_id, newMem.id);
1234
+ assert.equal(supersedeReceipt.loser_id, oldMem.id);
1235
+ store.close();
1236
+ });
1237
+
1238
+ test("issue#126: a differentiate decision on the dream path produces one receipt per entry", async () => {
1239
+ const { store, service } = dreamSetup();
1240
+ const a = service.saveWithDedupe({ type: "project", title: "内网端口", content: "内网走 22" }).memory;
1241
+ const b = service.saveWithDedupe({ type: "project", title: "外网端口", content: "外网映射 2222" }).memory;
1242
+ const ctx = mockCtx({
1243
+ onConsolidation: () => JSON.stringify([
1244
+ { action: "differentiate", ids: [a.id, b.id], distinctions: ["内网直连", "外网映射"] }
1245
+ ])
1246
+ });
1247
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1248
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1249
+ assert.equal(result.status, "ok", "differentiate applied and the summary landed");
1250
+ assert.equal(store.getById(a.id).archived, false, "neither side is archived");
1251
+ assert.equal(store.getById(b.id).archived, false);
1252
+ const receipts = store.listReceipts().filter((r) => r.kind === "differentiate");
1253
+ assert.equal(receipts.length, 2, "one receipt per differentiated entry");
1254
+ assert.deepEqual(receipts.map((r) => r.record_id).sort(), [a.id, b.id].sort());
1255
+ assert.ok(receipts.every((r) => r.count_before === 1 && r.count_after === 1), "no count change for differentiate");
1256
+ store.close();
1257
+ });
1258
+
1259
+ // --- Issue #258: dream_summarize 独立路由 + 输入硬上限 ------------------------
1260
+
1261
+ test("issue#258: summarize uses dreamSummaryProvider/dreamSummaryModel when configured, falls back to dream route otherwise", async () => {
1262
+ const { store, service } = dreamSetup();
1263
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1264
+ const seen = [];
1265
+ const ctx = mockCtx({});
1266
+ const origStream = ctx.llm.stream.bind(ctx.llm);
1267
+ ctx.llm.stream = async function* (options) {
1268
+ seen.push(`${options.provider}:${options.model}`);
1269
+ yield* origStream(options);
1270
+ };
1271
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1272
+ await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryProvider: "openai", dreamSummaryModel: "big-ctx" });
1273
+ assert.ok(seen.includes("mock:mock-model"), "consolidation keeps the dream route");
1274
+ assert.ok(seen.includes("openai:big-ctx"), "summary takes the dedicated route");
1275
+ const summarize = store.listLlmAudits().find((r) => r.operation_type === "dream_summarize");
1276
+ assert.equal(summarize.model_id, "openai:big-ctx", "audit trail names the dedicated route");
1277
+ store.close();
1278
+ });
1279
+
1280
+ test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 = whole library)", async () => {
1281
+ const { store, service } = dreamSetup();
1282
+ const titles = ["旧一", "旧二", "旧三", "新四", "新五"];
1283
+ const ids = titles.map((t, i) => service.saveWithDedupe({ type: "project", title: t, content: `内容${i}` }).memory.id);
1284
+ // 同毫秒保存会让 updated_at 并列、排序退化到 id(UUID 随机)——回填保证严格递增。
1285
+ ids.forEach((id, i) => {
1286
+ const at = `2026-01-01T0${i + 1}:00:00.000Z`;
1287
+ store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(at, id);
1288
+ });
1289
+ const seenUserText = [];
1290
+ const ctx = mockCtx({});
1291
+ const origStream = ctx.llm.stream.bind(ctx.llm);
1292
+ ctx.llm.stream = async function* (options) {
1293
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1294
+ if (!userText.startsWith("id=")) seenUserText.push(userText);
1295
+ yield* origStream(options);
1296
+ };
1297
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1298
+ await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSummaryMaxInputs: 3 });
1299
+ const summaryText = seenUserText.at(-1);
1300
+ assert.ok(summaryText.includes("新四") && summaryText.includes("新五") && summaryText.includes("旧三"), "newest three survive the cap");
1301
+ assert.ok(!summaryText.includes("旧一") && !summaryText.includes("旧二"), "oldest two are cut");
1302
+ const overview = () => service.all().find((m) => m.type === "summary");
1303
+ assert.match(overview().content, /整理后 3 条/, "口径 footer counts the capped snapshot");
1304
+ // 未配置 = 0 = 全库,历史行为不变
1305
+ await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1306
+ assert.match(overview().content, /整理后 5 条/, "uncapped run covers the whole library");
1307
+ store.close();
1308
+ });
1309
+
1310
+ // --- issue #339 / E8 考卷:merge 护栏(dreamMergeGuard,默认开)----------------
1311
+ // E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——
1312
+ // archive 护栏只挡 archive 不挡 merge。默认开(config.js);被合并对象命中
1313
+ // ARCHIVE_GUARDED_TYPES 的 merge 决策走与 archive 护栏同款通道:skipInvalid
1314
+ // 时 skipped、严格时整单拒绝。单元层直接传 options,不经 config。
1315
+
1316
+ test("validateDecisions mergeGuard: guarded-type merge is skipped under skipInvalid", () => {
1317
+ const snap = new Map([
1318
+ ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1319
+ ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }],
1320
+ ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
1321
+ ["h2", { id: "h2", type: "history", title: "旧事(近似)", content: "内容2", importance: 3, archived: false, forgotten: false }]
1322
+ ]);
1323
+ const decisions = [
1324
+ { action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "$12,400 上下", importance: 4 },
1325
+ { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
1326
+ ];
1327
+ const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
1328
+ assert.equal(ok, true, "history merge survives, guarded merge skipped");
1329
+ assert.equal(skipped.length, 1);
1330
+ assert.match(skipped[0].error, /dreamMergeGuard/, "skip reason names the guard");
1331
+ assert.match(skipped[0].error, /constraint/, "skip reason names the guarded type");
1332
+ // guarded merge 被 splice 掉,history merge 与两条隐式 keep 存活。
1333
+ assert.deepEqual(decisions.map((d) => d.action), ["merge", "keep", "keep"]);
1334
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("c1")), "c1 auto-kept");
1335
+ });
1336
+
1337
+ test("validateDecisions mergeGuard: strict mode rejects the whole batch", () => {
1338
+ const guardedSnap = new Map([
1339
+ ["g1", { id: "g1", type: "pitfall", title: "坑", content: "内容", importance: 3, archived: false, forgotten: false }],
1340
+ ["g2", { id: "g2", type: "pitfall", title: "坑2", content: "内容2", importance: 3, archived: false, forgotten: false }]
1341
+ ]);
1342
+ const strict = validateDecisions(
1343
+ [{ action: "merge", ids: ["g1", "g2"], keepSource: "g1", title: "t", content: "c" }],
1344
+ guardedSnap,
1345
+ { skipInvalid: false, mergeGuard: true }
1346
+ );
1347
+ assert.equal(strict.ok, false, "strict mode rejects guarded merge");
1348
+ assert.ok(strict.errors.some((e) => /dreamMergeGuard/.test(e)));
1349
+ });
1350
+
1351
+ test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = legacy behavior", () => {
1352
+ const snap = new Map([
1353
+ ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1354
+ ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }]
1355
+ ]);
1356
+ const decisions = [{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "合并", importance: 4 }];
1357
+ // guard 选项缺省/false = 旧行为(merge 照常通过);默认值由 config.js 供给,
1358
+ // 本单元层直接传 options,锁的是「不显式开就不拦」这条回退路径。
1359
+ assert.equal(validateDecisions(decisions, snap, { skipInvalid: true }).ok, true);
1360
+ // guard 开但类型非 guarded(history 用 snapshot 默认 type=project 亦非 guarded)。
1361
+ const plainSnap = snapshot(["x", "y"]);
1362
+ assert.equal(
1363
+ validateDecisions(
1364
+ [{ action: "merge", ids: ["x", "y"], keepSource: "x", title: "t", content: "c" }],
1365
+ plainSnap,
1366
+ { skipInvalid: true, mergeGuard: true }
1367
+ ).ok,
1368
+ true,
1369
+ "non-guarded merge passes under guard"
1370
+ );
1371
+ });
1372
+
1373
+ test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => {
1374
+ const snap = new Map([
1375
+ ["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
1376
+ ["p", { id: "p", type: "project", title: "项目", content: "内容", importance: 3, archived: false, forgotten: false }]
1377
+ ]);
1378
+ const { ok, skipped } = validateDecisions([
1379
+ { action: "archive", ids: ["c"], reason: "keeps the store tidy" },
1380
+ { action: "archive", ids: ["p"], reason: "outdated" }
1381
+ ], snap, { skipInvalid: true });
1382
+ assert.equal(ok, true, "project archive with stale rationale survives");
1383
+ assert.equal(skipped.length, 1, "constraint archive without rationale skipped");
1384
+ assert.match(skipped[0].error, /long-retention type/);
1385
+ });