@modusensus/dsh-mneme 0.8.13 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,671 +1,671 @@
1
- // #230:document 型记忆——注册校验 / 摘要+doc_path 落库 / C2 去重 / supersede
2
- // 记账 / 注入档位与预算 / 写入权分离守卫。口径基线 = #164 设计稿评审线
3
- // (discussioncomment-18495098 + 维护者对账 18502150)。
4
-
5
- import test from "node:test";
6
- import assert from "node:assert/strict";
7
- import { mkdtemp, writeFile, rm } from "node:fs/promises";
8
- import os from "node:os";
9
- import path from "node:path";
10
- import { createStore } from "../src/store.js";
11
- import { createService, PINNED_MEMORY_TYPES } from "../src/service.js";
12
- import { createDocumentRegistrar } from "../src/document.js";
13
- import { MEMORY_ITEM_SCHEMA } from "../src/tools.js";
14
- import { TYPE_DECAY_DEFAULTS } from "../src/heat.js";
15
-
16
- function makeService(config = {}) {
17
- const store = createStore(":memory:");
18
- const service = createService({ store, mirror: null, config });
19
- return { store, service, close: () => store.close() };
20
- }
21
-
22
- async function makeDocDir() {
23
- const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-mneme-doc-"));
24
- const writeDoc = async (name, content = "# Doc\n\nbody text") => {
25
- const p = path.join(dir, name);
26
- await writeFile(p, content, "utf8");
27
- return p;
28
- };
29
- return { dir, writeDoc, cleanup: () => rm(dir, { recursive: true, force: true }) };
30
- }
31
-
32
- /** 单元级 registrar:注入假 embedQuery,隔离 vector 档行为。 */
33
- function makeRegistrar(store, config = {}, embedQuery = async () => null, { pinnedTypes = PINNED_MEMORY_TYPES } = {}) {
34
- const finalized = [];
35
- const register = createDocumentRegistrar({
36
- store,
37
- config,
38
- embedQuery,
39
- // 与 service.js 的 pushContentHistory 同形(FIFO cap 20)
40
- pushContentHistory: (existing, source) => [
41
- { content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
42
- ...(Array.isArray(existing?.content_history) ? existing.content_history : [])
43
- ].slice(0, 20),
44
- transaction: (fn) => fn(),
45
- finalize: (rows) => finalized.push(...rows),
46
- // #275 拍板 5:service.js 注入的 #249 逐字保真池(这里同款注入,保持行为一致)
47
- pinnedTypes
48
- });
49
- return { register, finalized };
50
- }
51
-
52
- // ============================================================ 注册校验(DoD 1)
53
-
54
- test("registration is refused while the feature flag is off", async () => {
55
- const { service, close } = makeService({});
56
- const { writeDoc, cleanup } = await makeDocDir();
57
- try {
58
- const p = await writeDoc("a.md");
59
- await assert.rejects(
60
- () => service.registerDocument({ path: p, title: "T", summary: "S" }),
61
- /documentMemoryEnabled/
62
- );
63
- } finally {
64
- cleanup();
65
- close();
66
- }
67
- });
68
-
69
- test("path validation: required / absolute-only / existing non-empty regular file", async () => {
70
- const { service, close } = makeService({ documentMemoryEnabled: true });
71
- const { dir, writeDoc, cleanup } = await makeDocDir();
72
- try {
73
- await assert.rejects(
74
- () => service.registerDocument({ title: "T", summary: "S" }),
75
- /path is required/
76
- );
77
- await assert.rejects(
78
- () => service.registerDocument({ path: "rel.md", title: "T", summary: "S" }),
79
- /must be absolute/
80
- );
81
- await assert.rejects(
82
- () => service.registerDocument({ path: path.join(dir, "missing.md"), title: "T", summary: "S" }),
83
- /not found or empty/
84
- );
85
- await assert.rejects(
86
- () => service.registerDocument({ path: dir, title: "T", summary: "S" }),
87
- /not found or empty/
88
- );
89
- const empty = await writeDoc("empty.md", "");
90
- await assert.rejects(
91
- () => service.registerDocument({ path: empty, title: "T", summary: "S" }),
92
- /not found or empty/
93
- );
94
- } finally {
95
- cleanup();
96
- close();
97
- }
98
- });
99
-
100
- test("~ paths expand to the real home and register", async () => {
101
- const { service, close } = makeService({ documentMemoryEnabled: true });
102
- const { writeDoc, cleanup } = await makeDocDir();
103
- try {
104
- const p = await writeDoc("tilde.md");
105
- const rel = path.relative(os.homedir(), p);
106
- const res = await service.registerDocument({
107
- path: `~/${rel.split(path.sep).join("/")}`,
108
- title: "TildeDoc",
109
- summary: "S"
110
- });
111
- assert.equal(res.action, "created");
112
- assert.equal(res.memory.doc_path, p);
113
- } finally {
114
- cleanup();
115
- close();
116
- }
117
- });
118
-
119
- test("evidence intersection: valid subset kept, unknown dropped + degraded tag, all-fabricated rejected", async () => {
120
- const { service, close } = makeService({ documentMemoryEnabled: true });
121
- const { writeDoc, cleanup } = await makeDocDir();
122
- try {
123
- const a = service.saveWithDedupe({ type: "preference", title: "P1", content: "c1" }).memory;
124
- const b = service.saveWithDedupe({ type: "decision", title: "D1", content: "c2" }).memory;
125
- const p = await writeDoc("r.md");
126
-
127
- const partial = await service.registerDocument({
128
- path: p, title: "R", summary: "S", evidence: [a.id, "nope-1", b.id]
129
- });
130
- assert.equal(partial.action, "created");
131
- assert.deepEqual(partial.memory.evidence, [a.id, b.id]);
132
- assert.equal(partial.evidence_kept, 2);
133
- assert.equal(partial.evidence_dropped, 1);
134
- assert.equal(partial.degraded, true);
135
- assert.ok(partial.memory.tags.includes("evidence_degraded"));
136
-
137
- await assert.rejects(
138
- () => service.registerDocument({ path: p, title: "T2", summary: "S", evidence: ["nope-2"] }),
139
- /fabricated evidence is rejected/
140
- );
141
-
142
- const clean = await service.registerDocument({ path: await writeDoc("clean.md"), title: "T3", summary: "S" });
143
- assert.equal(clean.degraded, false);
144
- assert.deepEqual(clean.memory.evidence, []);
145
- assert.ok(!clean.memory.tags.includes("evidence_degraded"));
146
- } finally {
147
- cleanup();
148
- close();
149
- }
150
- });
151
-
152
- // ============================ 升格吸收的 evidence 归档(#275 拍板 5,同事务)
153
-
154
- // 拍板原文:registerDocument 成功后同一事务把 absorbed 的 evidence 行翻 archived
155
- // (可恢复、审计全留),带 opt-out;pinned(constraint / preference)永不自动归档;
156
- // supersede 的 loser 行同样翻 archived(后者本来就是本模块既有行为,这里一并锁住)。
157
-
158
- test("absorbed evidence rows are archived with the document; pinned rows are exempt", async () => {
159
- const { store, service, close } = makeService({ documentMemoryEnabled: true });
160
- const { writeDoc, cleanup } = await makeDocDir();
161
- try {
162
- const pinned = service.saveWithDedupe({ type: "constraint", title: "C", content: "边界条件" }).memory;
163
- const decision = service.saveWithDedupe({ type: "decision", title: "D", content: "一个决定" }).memory;
164
- const p = await writeDoc("absorb.md");
165
-
166
- const res = await service.registerDocument({
167
- path: p, title: "Absorb", summary: "summary", evidence: [pinned.id, decision.id]
168
- });
169
-
170
- assert.equal(res.evidence_kept, 2, "引用本身照旧全留下:doc 行的 evidence 数组是正向链");
171
- assert.equal(res.evidence_archived, 1, "只有非 pinned 的那条被动");
172
- const after = service.getById(decision.id);
173
- assert.equal(after.archived, true, "被吸收的行退出活跃面(「21 行不是 1 行」)");
174
- assert.equal(after.content, "一个决定", "只翻标志位:内容与审计全留(可恢复)");
175
- assert.equal(service.getById(pinned.id).archived, false, "#249 的逐字保真池,升格不能绕过");
176
- assert.equal(store.list().some((m) => m.id === decision.id), false, "默认面不再列出吸收行");
177
- } finally {
178
- cleanup();
179
- close();
180
- }
181
- });
182
-
183
- test("archiveEvidence: false(工具侧 keep_evidence_active)保留吸收行的活跃面", async () => {
184
- const { service, close } = makeService({ documentMemoryEnabled: true });
185
- const { writeDoc, cleanup } = await makeDocDir();
186
- try {
187
- const d = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
188
- const res = await service.registerDocument(
189
- { path: await writeDoc("optout.md"), title: "OptOut", summary: "s", evidence: [d.id] },
190
- { archiveEvidence: false }
191
- );
192
- assert.equal(res.evidence_kept, 1);
193
- assert.equal(res.evidence_archived, 0, "opt-out 时不归档");
194
- assert.equal(service.getById(d.id).archived, false);
195
- } finally {
196
- cleanup();
197
- close();
198
- }
199
- });
200
-
201
- test("re-registering a document whose evidence was absorbed keeps the references", async () => {
202
- // 这条防的是「补丁把常规路径打坏」:吸收行一旦归档,重注册同一份文档(出新版)
203
- // 时同一批 evidence 会全落 dropped,进而撞上捏造判据——最常规的路径反而报错。
204
- const { service, close } = makeService({ documentMemoryEnabled: true });
205
- const { writeDoc, cleanup } = await makeDocDir();
206
- try {
207
- const d = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
208
- const p = await writeDoc("ver.md");
209
- const first = await service.registerDocument({ path: p, title: "Ver", summary: "v1", evidence: [d.id] });
210
- assert.equal(first.evidence_archived, 1);
211
-
212
- const second = await service.registerDocument({ path: p, title: "Ver", summary: "v2", evidence: [d.id] });
213
- assert.equal(second.action, "superseded");
214
- assert.deepEqual(second.memory.evidence, [d.id], "吸收行仍为它所属的那份文档背书");
215
- assert.equal(second.evidence_kept, 1);
216
- assert.equal(second.degraded, false, "不误报成捏造证据");
217
- // 认回是窄口径的:已归档行只为**吸收它的那份**文档背书。别的文档(新注册、无
218
- // 同名目标)拿它当证据,照旧走既有的捏造判据——不因为这条补丁放宽。
219
- const other = await writeDoc("other.md");
220
- await assert.rejects(
221
- () => service.registerDocument({ path: other, title: "Other", summary: "s", evidence: [d.id] }),
222
- /fabricated evidence is rejected/
223
- );
224
- } finally {
225
- cleanup();
226
- close();
227
- }
228
- });
229
-
230
- test("registrar without the pinned pool wiring archives nothing (fail-safe)", async () => {
231
- // 拿不到 pinned 集合就不做这一步:宁可少做,也不能把保真池当普通行收走。
232
- const store = createStore(":memory:");
233
- const { writeDoc, cleanup } = await makeDocDir();
234
- try {
235
- const d = store.save({ type: "decision", title: "D", content: "c" });
236
- const { register } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => null, { pinnedTypes: null });
237
- const res = await register({ path: await writeDoc("noset.md"), title: "NoSet", summary: "s", evidence: [d.id] });
238
- assert.equal(res.evidence_archived, 0);
239
- assert.equal(store.getById(d.id).archived, false);
240
- } finally {
241
- cleanup();
242
- store.close();
243
- }
244
- });
245
-
246
- test("自有生命周期的类型不被吸收:document 行保持活跃,同 path 的 supersede 链不断", async () => {
247
- // 回归点(单盲审查 H1):把另一份 document 行当 evidence 时,若照普通行吸收,它会
248
- // 被静默归档——走不到 loser 分支(没有 [superseded by] 指针与 content_history),
249
- // 而 supersede 探测只看活跃行,于是重注册同 path 会再铸一行、同一个文件留下两行。
250
- const { store, service, close } = makeService({ documentMemoryEnabled: true });
251
- const { writeDoc, cleanup } = await makeDocDir();
252
- try {
253
- const pA = await writeDoc("a.md");
254
- const docA = (await service.registerDocument({ path: pA, title: "DocA", summary: "v1" })).memory;
255
- const docB = await service.registerDocument({
256
- path: await writeDoc("b.md"), title: "DocB", summary: "grounded in A", evidence: [docA.id]
257
- });
258
- assert.equal(docB.evidence_archived, 0, "document 行不参与吸收");
259
- assert.equal(service.getById(docA.id).archived, false, "被引用不改变它的活跃状态");
260
-
261
- const again = await service.registerDocument({ path: pA, title: "DocA", summary: "v2" });
262
- assert.equal(again.action, "superseded", "指针行仍能被 supersede 探测看到");
263
- assert.equal(again.superseded.id, docA.id);
264
- assert.ok(again.superseded.content.includes(again.memory.id), "旧行拿到 [superseded by] 指针");
265
- assert.equal(store.all().filter((m) => m.doc_path === pA).length, 2, "同一 path 一行新版 + 一行带指针的旧版,没有多余的第三行");
266
- } finally {
267
- cleanup();
268
- close();
269
- }
270
- });
271
-
272
- test("自有生命周期的类型不被吸收:summary(dream 总览)保持活跃,不会被当成不存在重铸", async () => {
273
- // 回归点:summary 按 source 身份去重(dream 总览)且按注入档位常驻。被吸收归档后
274
- // 去重候选集(store.list 默认排除归档)看不到它 → 下一次做梦会再铸一行同源总览。
275
- const { service, close } = makeService({ documentMemoryEnabled: true });
276
- const { writeDoc, cleanup } = await makeDocDir();
277
- try {
278
- const digest = service.saveWithDedupe({
279
- type: "summary", title: "当前项目状态", content: "resident digest", source: "dream", importance: 5
280
- }).memory;
281
- const res = await service.registerDocument({
282
- path: await writeDoc("with-digest.md"), title: "DocW", summary: "s", evidence: [digest.id]
283
- });
284
- assert.equal(res.evidence_archived, 0, "summary 不参与吸收");
285
- assert.equal(service.getById(digest.id).archived, false);
286
-
287
- const again = service.saveWithDedupe({
288
- type: "summary", title: "当前项目状态", content: "resident digest v2", source: "dream", importance: 5
289
- });
290
- assert.equal(again.action, "merged", "下一次做梦仍认得出这行,不会铸出第二行同源总览");
291
- assert.equal(again.memory.id, digest.id);
292
- } finally {
293
- cleanup();
294
- close();
295
- }
296
- });
297
-
298
- // ============================================================ supersede 记账(DoD 2)
299
-
300
- test("re-registering the same path supersedes: old row archived + traceable, file untouched", async () => {
301
- const { service, close } = makeService({ documentMemoryEnabled: true });
302
- const { writeDoc, cleanup } = await makeDocDir();
303
- try {
304
- const p = await writeDoc("v.md");
305
- const v1 = await service.registerDocument({ path: p, title: "T1", summary: "summary v1", tags: ["doc"] });
306
- const v2 = await service.registerDocument({ path: p, title: "T2", summary: "summary v2" });
307
-
308
- assert.equal(v2.action, "superseded");
309
- assert.equal(v2.superseded.id, v1.memory.id);
310
- assert.equal(v2.memory.doc_path, p);
311
-
312
- const old = service.getById(v1.memory.id);
313
- assert.equal(old.archived, true, "old row archived (no physical delete)");
314
- assert.ok(old.content.includes(`[superseded by ${v2.memory.id}]`), "loser carries the pointer note");
315
- assert.equal(old.content_history[0].source, "superseded");
316
- assert.equal(old.content_history[0].content, "summary v1", "old summary traceable via content_history");
317
- assert.equal(old.doc_path, p, "old pointer preserved");
318
-
319
- const fresh = service.getById(v2.memory.id);
320
- assert.equal(fresh.archived, false);
321
- assert.equal(fresh.content, "summary v2");
322
- } finally {
323
- cleanup();
324
- close();
325
- }
326
- });
327
-
328
- test("same title on a different path also supersedes (new version of the moved doc)", async () => {
329
- const { service, close } = makeService({ documentMemoryEnabled: true });
330
- const { writeDoc, cleanup } = await makeDocDir();
331
- try {
332
- const p1 = await writeDoc("old.md");
333
- const p2 = await writeDoc("new.md");
334
- const v1 = await service.registerDocument({ path: p1, title: "Same", summary: "s1" });
335
- const v2 = await service.registerDocument({ path: p2, title: "Same", summary: "s2" });
336
- assert.equal(v2.action, "superseded");
337
- assert.equal(v2.superseded.id, v1.memory.id);
338
- assert.equal(service.getById(v1.memory.id).archived, true);
339
- assert.equal(service.getById(v2.memory.id).doc_path, p2);
340
- } finally {
341
- cleanup();
342
- close();
343
- }
344
- });
345
-
346
- // ============================================================ C2 去重(DoD 6 前半)
347
-
348
- test("vector near-duplicate on a different path/title is rejected (agent decides, no silent supersede)", async () => {
349
- const { writeDoc, cleanup } = await makeDocDir();
350
- const store = createStore(":memory:");
351
- try {
352
- store.saveDocument({
353
- type: "document", title: "Alpha", content: "existing summary",
354
- doc_path: "X:\\elsewhere\\a.md", embedding: [1, 0, 0], source: "tool"
355
- });
356
- const b = await writeDoc("b.md");
357
- const c = await writeDoc("c.md");
358
- const { register } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => [1, 0, 0]);
359
- await assert.rejects(
360
- () => register({ path: b, title: "Beta", summary: "similar summary" }),
361
- /near duplicate/
362
- );
363
- // 正交向量 = 无近重复 → 正常创建。
364
- const { register: register2 } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => [0, 1, 0]);
365
- const res = await register2({ path: c, title: "Gamma", summary: "unrelated summary" });
366
- assert.equal(res.action, "created");
367
- } finally {
368
- cleanup();
369
- store.close();
370
- }
371
- });
372
-
373
- test("embedder unavailable: dedupe degrades to path/title tiers, writes still land", async () => {
374
- const { service, close } = makeService({ documentMemoryEnabled: true });
375
- const { writeDoc, cleanup } = await makeDocDir();
376
- try {
377
- const p1 = await writeDoc("d1.md");
378
- const p2 = await writeDoc("d2.md");
379
- const r1 = await service.registerDocument({ path: p1, title: "A", summary: "identical summary text" });
380
- const r2 = await service.registerDocument({ path: p2, title: "B", summary: "identical summary text" });
381
- assert.equal(r1.action, "created");
382
- assert.equal(r2.action, "created", "no vector signal = no near-dup verdict, write is never blocked");
383
- } finally {
384
- cleanup();
385
- close();
386
- }
387
- });
388
-
389
- // ============================================================ 写入权分离守卫(DoD 1/3 旁路防护)
390
-
391
- test("saveWithDedupe refuses to mint document rows", () => {
392
- const { service, close } = makeService({});
393
- try {
394
- assert.throws(
395
- () => service.saveWithDedupe({ type: "document", title: "x", content: "y" }),
396
- /registerDocument/
397
- );
398
- } finally {
399
- close();
400
- }
401
- });
402
-
403
- test("updateMemory cannot retype a row into document, but document summary repair passes", async () => {
404
- const { service, close } = makeService({ documentMemoryEnabled: true });
405
- const { writeDoc, cleanup } = await makeDocDir();
406
- try {
407
- const pref = service.saveWithDedupe({ type: "preference", title: "P", content: "c" }).memory;
408
- assert.throws(() => service.update(pref.id, { type: "document" }), /registerDocument/);
409
-
410
- const p = await writeDoc("fix.md");
411
- const atomic = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
412
- const doc = (await service.registerDocument({
413
- path: p, title: "Doc", summary: "s", evidence: [atomic.id, "nope"]
414
- })).memory;
415
- const updated = service.update(doc.id, { content: "repaired summary", tags: ["user-tag"] });
416
- assert.equal(updated.content, "repaired summary");
417
- assert.equal(updated.doc_path, p, "pointer untouched by summary repair");
418
- assert.equal(updated.archived, false);
419
- assert.ok(
420
- updated.tags.includes("evidence_degraded") && updated.tags.includes("user-tag"),
421
- "system signal tag survives a tags-replacing update"
422
- );
423
- } finally {
424
- cleanup();
425
- close();
426
- }
427
- });
428
-
429
- // ============================================================ 注入档位与预算(DoD 5)
430
-
431
- function seedInjectionFixtures(service, store) {
432
- service.saveWithDedupe({ type: "summary", title: "当前项目状态", content: "resident digest", source: "dream", importance: 5, _overwrite: true });
433
- service.saveWithDedupe({ type: "summary", title: "叙述:topic", content: "narrative bar", source: "narrative", tags: ["topic-n"], importance: 3 });
434
- // document 行铸造口唯一:测试种子也走 store.saveDocument(saveWithDedupe
435
- // 有 service 守卫、store.save 有存储层守卫)。
436
- for (let i = 1; i <= 3; i++) {
437
- store.saveDocument({ type: "document", title: `Doc${i}`, content: `d${i}`, source: "tool", doc_path: `X:\\${i}.md`, importance: 3 });
438
- }
439
- }
440
-
441
- test("injection: document rows at the next-priority tier, capped by documentInjectBudget; narrative tier re-enabled", () => {
442
- const { store, service, close } = makeService({ documentMemoryEnabled: true, documentInjectBudget: 2, dreamNarrativeEnabled: true });
443
- try {
444
- seedInjectionFixtures(service, store);
445
- const picked = service.injectCandidates({});
446
- assert.equal(picked[0].type, "summary");
447
- assert.equal(picked[0].source, "dream", "resident digest keeps tier 0");
448
- assert.equal(picked.filter((m) => m.type === "document").length, 2, "document budget caps at 2 of 3");
449
- assert.ok(picked.some((m) => m.source === "narrative"), "narrative rows inject at the next-priority tier (#230 拍板)");
450
- } finally {
451
- close();
452
- }
453
- });
454
-
455
- test("injection: flags off → document and narrative rows stay out, behavior unchanged", () => {
456
- const { store, service, close } = makeService({});
457
- try {
458
- seedInjectionFixtures(service, store);
459
- const picked = service.injectCandidates({});
460
- assert.ok(picked.every((m) => m.type !== "document"), "document rows excluded while flag off");
461
- assert.ok(picked.every((m) => m.source !== "narrative"), "narrative rows excluded while dreamNarrativeEnabled off");
462
- assert.ok(picked.some((m) => m.type === "summary" && m.source === "dream"), "dream digest unaffected");
463
- } finally {
464
- close();
465
- }
466
- });
467
-
468
- // ============================================================ DTO / 扩展面(DoD 3)
469
-
470
- test("toApiList exposes doc_path on pointer rows only, within MEMORY_ITEM_SCHEMA", async () => {
471
- const { service, close } = makeService({ documentMemoryEnabled: true });
472
- const { writeDoc, cleanup } = await makeDocDir();
473
- try {
474
- const pref = service.saveWithDedupe({ type: "preference", title: "P", content: "c" }).memory;
475
- const p = await writeDoc("dto.md");
476
- const doc = (await service.registerDocument({ path: p, title: "D", summary: "s" })).memory;
477
-
478
- const prefDto = service.toApiList([pref])[0];
479
- assert.ok(!("doc_path" in prefDto), "non-pointer rows keep the pre-#230 DTO shape");
480
- const docDto = service.toApiList([doc])[0];
481
- assert.equal(docDto.doc_path, p);
482
- for (const key of Object.keys(docDto)) {
483
- assert.ok(key in MEMORY_ITEM_SCHEMA.properties, `DTO key "${key}" declared in MEMORY_ITEM_SCHEMA`);
484
- }
485
- } finally {
486
- cleanup();
487
- close();
488
- }
489
- });
490
-
491
- test("document rows are heat-immune (pointer rows do not decay)", () => {
492
- assert.equal(TYPE_DECAY_DEFAULTS.document, 0);
493
- });
494
-
495
- // ============================================================ CodeRabbit 修复回归(PR #256 review)
496
-
497
- test("path normalization: equivalent absolute spellings supersede instead of duplicating", async () => {
498
- const { service, close } = makeService({ documentMemoryEnabled: true });
499
- const { dir, writeDoc, cleanup } = await makeDocDir();
500
- try {
501
- const p = await writeDoc("norm.md");
502
- const first = await service.registerDocument({ path: p, title: "V1", summary: "s1" });
503
- // path.join 会把 "." 段吃掉,手工拼出等价但未归一化的写法。
504
- const dotted = `${dir}${path.sep}.${path.sep}norm.md`;
505
- const second = await service.registerDocument({ path: dotted, title: "V2", summary: "s2" });
506
- assert.equal(second.action, "superseded", "normalized spelling hits the same-path supersede tier");
507
- assert.equal(second.superseded.id, first.memory.id);
508
- } finally {
509
- cleanup();
510
- close();
511
- }
512
- });
513
-
514
- test("exact supersede tiers scan beyond any window (>200 active documents)", async () => {
515
- const { store, close } = makeService({ documentMemoryEnabled: true });
516
- const { writeDoc, cleanup } = await makeDocDir();
517
- try {
518
- // 新注册的路径必须过文件校验——目标行(窗口外的旧版)用真实文件,其余
519
- // 209 行用虚构路径垫量。Bulk0 先种 = updated_at 最早 = list(DESC) 末位。
520
- const pathA = await writeDoc("a.md");
521
- const pathB = await writeDoc("b.md");
522
- store.saveDocument({ type: "document", title: "Bulk0", content: "c0", source: "tool", doc_path: pathA });
523
- for (let i = 1; i < 210; i++) {
524
- store.saveDocument({ type: "document", title: `Bulk${i}`, content: `c${i}`, source: "tool", doc_path: `X:\\bulk\\${i}.md` });
525
- }
526
- const oldest = store.list({ type: "document", limit: null }).at(-1);
527
- assert.equal(oldest.title, "Bulk0", "precondition: oldest row is outside the 200 window");
528
- const { register } = makeRegistrar(store, { documentMemoryEnabled: true });
529
- // 路径层:窗口外的同路径行必须仍被发现并 supersede。
530
- const byPath = await register({ path: pathA, title: "Fresh path re-register", summary: "new version" });
531
- assert.equal(byPath.action, "superseded");
532
- assert.equal(byPath.superseded.id, oldest.id);
533
- // 标题层:同款全量语义(Bulk0 已归档,此时窗口外最老是 Bulk1)。
534
- const next = store.list({ type: "document", limit: null }).at(-1);
535
- assert.equal(next.title, "Bulk1");
536
- const byTitle = await register({ path: pathB, title: next.title, summary: "another version" });
537
- assert.equal(byTitle.action, "superseded");
538
- assert.equal(byTitle.superseded.id, next.id);
539
- } finally {
540
- cleanup();
541
- close();
542
- }
543
- });
544
-
545
- test("sensitivity is persisted and participates in the supersede matching key", async () => {
546
- const { service, close } = makeService({ documentMemoryEnabled: true });
547
- const { writeDoc, cleanup } = await makeDocDir();
548
- try {
549
- const p = await writeDoc("sens.md");
550
- const first = await service.registerDocument({ path: p, title: "S", summary: "s1", sensitivity: "personal" });
551
- assert.equal(first.memory.sensitivity, "personal", "sensitivity must persist (scopeKeyOf third dim)");
552
- // 同 path + 同 sensitivity = 显式出新版。
553
- const again = await service.registerDocument({ path: p, title: "S2", summary: "s2", sensitivity: "personal" });
554
- assert.equal(again.action, "superseded");
555
- // sensitivity 不同 = 另一 scope 维度,不互判(与 memory_save 的同键分离语义一致)。
556
- const other = await service.registerDocument({ path: p, title: "S3", summary: "s3", sensitivity: "team" });
557
- assert.equal(other.action, "created");
558
- } finally {
559
- cleanup();
560
- close();
561
- }
562
- });
563
-
564
- test("store guards: generic paths cannot mint or retype document rows", () => {
565
- const { store, close } = makeService({});
566
- try {
567
- // save:整类拒绝——doc_path 是任意调用方可捏造的字符串,「必带 doc_path」
568
- // 挡不住绕过注册校验的直铸(CodeRabbit 复核 #882)。
569
- assert.throws(
570
- () => store.save({ type: "document", title: "X", content: "c" }),
571
- /minted only via store\.saveDocument/
572
- );
573
- assert.throws(
574
- () => store.save({ type: "document", title: "X", content: "c", doc_path: "X:\\x.md" }),
575
- /minted only via store\.saveDocument/
576
- );
577
- // saveDocument:唯一铸造口,且保留指针行结构不变量。
578
- assert.throws(
579
- () => store.saveDocument({ type: "document", title: "X", content: "c" }),
580
- /doc_path is required/
581
- );
582
- const pref = store.save({ type: "preference", title: "P", content: "c" });
583
- const doc = store.saveDocument({ type: "document", title: "D", content: "s", doc_path: "X:\\d.md" });
584
- // update / CAS:type 不许改入或改出 document(CAS 绕过 service 守卫,存储层兜底)。
585
- assert.throws(() => store.update(pref.id, { type: "document" }), /cannot be changed to or from/);
586
- assert.throws(() => store.update(doc.id, { type: "preference" }), /cannot be changed to or from/);
587
- assert.throws(
588
- () => store.compareAndUpdate(pref.id, pref.updated_at, { type: "document" }),
589
- /cannot be changed to or from/
590
- );
591
- // 同 type 的摘要修复照常(doc_path 不在 UPDATE SET,天然保持)。
592
- const repaired = store.update(doc.id, { content: "repaired summary" });
593
- assert.equal(repaired.content, "repaired summary");
594
- assert.equal(repaired.doc_path, "X:\\d.md");
595
- } finally {
596
- close();
597
- }
598
- });
599
-
600
- test("sleep cold-scan and the pattern pool exclude document rows at the query layer", () => {
601
- const { store, close } = makeService({});
602
- try {
603
- const proj = store.save({ type: "project", title: "Proj", content: "c" });
604
- const hist = store.save({ type: "history", title: "Hist", content: "c" });
605
- // 最新行是 document——不带排除时它会占满 LIMIT 1 的扫描窗(饿池前置条件)。
606
- store.saveDocument({ type: "document", title: "Doc", content: "s", source: "tool", doc_path: "X:\\d.md" });
607
- // 冷扫描(getUnrecalledSince,sleep phase 2 的归档降级候选):document 豁免。
608
- const cold = store.getUnrecalledSince(0);
609
- assert.ok(cold.some((m) => m.id === proj.id) && cold.some((m) => m.id === hist.id));
610
- assert.ok(cold.every((m) => m.type !== "document"), "pointer rows never become archival-demotion candidates");
611
- // 模式扫描池:excludeTypes 在 LIMIT 之前生效。
612
- const plain = store.list({ limit: 1 });
613
- assert.equal(plain[0].type, "document", "precondition: newest row is a document");
614
- const pool = store.list({ limit: 1, excludeTypes: ["document"] });
615
- assert.notEqual(pool[0].type, "document", "ordinary memories reach the pool even under a tight limit");
616
- // 无界扫描(注册器精确层):limit null = 全量。
617
- assert.equal(store.list({ type: "document", limit: null }).length, 1);
618
- } finally {
619
- close();
620
- }
621
- });
622
-
623
- test("hidden evidence ids (strictScope) are treated as nonexistent by the registrar", async () => {
624
- const { service, close } = makeService({ documentMemoryEnabled: true });
625
- const { writeDoc, cleanup } = await makeDocDir();
626
- try {
627
- const a = service.saveWithDedupe({ type: "preference", title: "P", content: "c1" }).memory;
628
- const b = service.saveWithDedupe({ type: "decision", title: "D", content: "c2" }).memory;
629
- const p = await writeDoc("hidden.md");
630
- // 全部不可见 = 整单拒绝(与捏造同款;先测,避免先落行后又被同路径命中)。
631
- await assert.rejects(
632
- () => service.registerDocument({ path: p, title: "H", summary: "s", evidence: [b.id] }, { hiddenEvidenceIds: [b.id] }),
633
- /unknown, archived or out of scope/
634
- );
635
- // 混合:不可见 id 落 dropped、不进持久化 evidence,kept/dropped 计数保持诚实。
636
- const mixed = await service.registerDocument(
637
- { path: p, title: "H", summary: "s", evidence: [a.id, b.id] },
638
- { hiddenEvidenceIds: [b.id] }
639
- );
640
- assert.equal(mixed.evidence_kept, 1);
641
- assert.equal(mixed.evidence_dropped, 1);
642
- assert.equal(mixed.degraded, true);
643
- assert.deepEqual(mixed.memory.evidence, [a.id]);
644
- } finally {
645
- cleanup();
646
- close();
647
- }
648
- });
649
-
650
- test("narrative gate covers the semantic paths: BM25 fallback keeps narratives out while the flag is off", () => {
651
- const { service, close } = makeService({});
652
- try {
653
- service.saveWithDedupe({ type: "summary", title: "叙述:zebra-topic", content: "zebra-topic narrative bar", source: "narrative", importance: 5 });
654
- // 无 embedder 时首轮必走 BM25 兜底——此前的门只锁规则路,语义路会漏。
655
- const picked = service.injectCandidates({ q: "zebra-topic" });
656
- assert.ok(picked.every((m) => m.source !== "narrative"), "semantic-path candidates respect dreamNarrativeEnabled");
657
- } finally {
658
- close();
659
- }
660
- });
661
-
662
- test("narrative rows inject via the semantic path once dreamNarrativeEnabled is on", () => {
663
- const { service, close } = makeService({ dreamNarrativeEnabled: true });
664
- try {
665
- service.saveWithDedupe({ type: "summary", title: "叙述:zebra-topic", content: "zebra-topic narrative bar", source: "narrative", importance: 5 });
666
- const picked = service.injectCandidates({ q: "zebra-topic" });
667
- assert.ok(picked.some((m) => m.source === "narrative"), "BM25 path admits narratives under the same flag as the rule path");
668
- } finally {
669
- close();
670
- }
671
- });
1
+ // #230:document 型记忆——注册校验 / 摘要+doc_path 落库 / C2 去重 / supersede
2
+ // 记账 / 注入档位与预算 / 写入权分离守卫。口径基线 = #164 设计稿评审线
3
+ // (discussioncomment-18495098 + 维护者对账 18502150)。
4
+
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { createStore } from "../src/store.js";
11
+ import { createService, PINNED_MEMORY_TYPES } from "../src/service.js";
12
+ import { createDocumentRegistrar } from "../src/document.js";
13
+ import { MEMORY_ITEM_SCHEMA } from "../src/tools.js";
14
+ import { TYPE_DECAY_DEFAULTS } from "../src/heat.js";
15
+
16
+ function makeService(config = {}) {
17
+ const store = createStore(":memory:");
18
+ const service = createService({ store, mirror: null, config });
19
+ return { store, service, close: () => store.close() };
20
+ }
21
+
22
+ async function makeDocDir() {
23
+ const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-mneme-doc-"));
24
+ const writeDoc = async (name, content = "# Doc\n\nbody text") => {
25
+ const p = path.join(dir, name);
26
+ await writeFile(p, content, "utf8");
27
+ return p;
28
+ };
29
+ return { dir, writeDoc, cleanup: () => rm(dir, { recursive: true, force: true }) };
30
+ }
31
+
32
+ /** 单元级 registrar:注入假 embedQuery,隔离 vector 档行为。 */
33
+ function makeRegistrar(store, config = {}, embedQuery = async () => null, { pinnedTypes = PINNED_MEMORY_TYPES } = {}) {
34
+ const finalized = [];
35
+ const register = createDocumentRegistrar({
36
+ store,
37
+ config,
38
+ embedQuery,
39
+ // 与 service.js 的 pushContentHistory 同形(FIFO cap 20)
40
+ pushContentHistory: (existing, source) => [
41
+ { content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
42
+ ...(Array.isArray(existing?.content_history) ? existing.content_history : [])
43
+ ].slice(0, 20),
44
+ transaction: (fn) => fn(),
45
+ finalize: (rows) => finalized.push(...rows),
46
+ // #275 拍板 5:service.js 注入的 #249 逐字保真池(这里同款注入,保持行为一致)
47
+ pinnedTypes
48
+ });
49
+ return { register, finalized };
50
+ }
51
+
52
+ // ============================================================ 注册校验(DoD 1)
53
+
54
+ test("registration is refused while the feature flag is off", async () => {
55
+ const { service, close } = makeService({});
56
+ const { writeDoc, cleanup } = await makeDocDir();
57
+ try {
58
+ const p = await writeDoc("a.md");
59
+ await assert.rejects(
60
+ () => service.registerDocument({ path: p, title: "T", summary: "S" }),
61
+ /documentMemoryEnabled/
62
+ );
63
+ } finally {
64
+ cleanup();
65
+ close();
66
+ }
67
+ });
68
+
69
+ test("path validation: required / absolute-only / existing non-empty regular file", async () => {
70
+ const { service, close } = makeService({ documentMemoryEnabled: true });
71
+ const { dir, writeDoc, cleanup } = await makeDocDir();
72
+ try {
73
+ await assert.rejects(
74
+ () => service.registerDocument({ title: "T", summary: "S" }),
75
+ /path is required/
76
+ );
77
+ await assert.rejects(
78
+ () => service.registerDocument({ path: "rel.md", title: "T", summary: "S" }),
79
+ /must be absolute/
80
+ );
81
+ await assert.rejects(
82
+ () => service.registerDocument({ path: path.join(dir, "missing.md"), title: "T", summary: "S" }),
83
+ /not found or empty/
84
+ );
85
+ await assert.rejects(
86
+ () => service.registerDocument({ path: dir, title: "T", summary: "S" }),
87
+ /not found or empty/
88
+ );
89
+ const empty = await writeDoc("empty.md", "");
90
+ await assert.rejects(
91
+ () => service.registerDocument({ path: empty, title: "T", summary: "S" }),
92
+ /not found or empty/
93
+ );
94
+ } finally {
95
+ cleanup();
96
+ close();
97
+ }
98
+ });
99
+
100
+ test("~ paths expand to the real home and register", async () => {
101
+ const { service, close } = makeService({ documentMemoryEnabled: true });
102
+ const { writeDoc, cleanup } = await makeDocDir();
103
+ try {
104
+ const p = await writeDoc("tilde.md");
105
+ const rel = path.relative(os.homedir(), p);
106
+ const res = await service.registerDocument({
107
+ path: `~/${rel.split(path.sep).join("/")}`,
108
+ title: "TildeDoc",
109
+ summary: "S"
110
+ });
111
+ assert.equal(res.action, "created");
112
+ assert.equal(res.memory.doc_path, p);
113
+ } finally {
114
+ cleanup();
115
+ close();
116
+ }
117
+ });
118
+
119
+ test("evidence intersection: valid subset kept, unknown dropped + degraded tag, all-fabricated rejected", async () => {
120
+ const { service, close } = makeService({ documentMemoryEnabled: true });
121
+ const { writeDoc, cleanup } = await makeDocDir();
122
+ try {
123
+ const a = service.saveWithDedupe({ type: "preference", title: "P1", content: "c1" }).memory;
124
+ const b = service.saveWithDedupe({ type: "decision", title: "D1", content: "c2" }).memory;
125
+ const p = await writeDoc("r.md");
126
+
127
+ const partial = await service.registerDocument({
128
+ path: p, title: "R", summary: "S", evidence: [a.id, "nope-1", b.id]
129
+ });
130
+ assert.equal(partial.action, "created");
131
+ assert.deepEqual(partial.memory.evidence, [a.id, b.id]);
132
+ assert.equal(partial.evidence_kept, 2);
133
+ assert.equal(partial.evidence_dropped, 1);
134
+ assert.equal(partial.degraded, true);
135
+ assert.ok(partial.memory.tags.includes("evidence_degraded"));
136
+
137
+ await assert.rejects(
138
+ () => service.registerDocument({ path: p, title: "T2", summary: "S", evidence: ["nope-2"] }),
139
+ /fabricated evidence is rejected/
140
+ );
141
+
142
+ const clean = await service.registerDocument({ path: await writeDoc("clean.md"), title: "T3", summary: "S" });
143
+ assert.equal(clean.degraded, false);
144
+ assert.deepEqual(clean.memory.evidence, []);
145
+ assert.ok(!clean.memory.tags.includes("evidence_degraded"));
146
+ } finally {
147
+ cleanup();
148
+ close();
149
+ }
150
+ });
151
+
152
+ // ============================ 升格吸收的 evidence 归档(#275 拍板 5,同事务)
153
+
154
+ // 拍板原文:registerDocument 成功后同一事务把 absorbed 的 evidence 行翻 archived
155
+ // (可恢复、审计全留),带 opt-out;pinned(constraint / preference)永不自动归档;
156
+ // supersede 的 loser 行同样翻 archived(后者本来就是本模块既有行为,这里一并锁住)。
157
+
158
+ test("absorbed evidence rows are archived with the document; pinned rows are exempt", async () => {
159
+ const { store, service, close } = makeService({ documentMemoryEnabled: true });
160
+ const { writeDoc, cleanup } = await makeDocDir();
161
+ try {
162
+ const pinned = service.saveWithDedupe({ type: "constraint", title: "C", content: "边界条件" }).memory;
163
+ const decision = service.saveWithDedupe({ type: "decision", title: "D", content: "一个决定" }).memory;
164
+ const p = await writeDoc("absorb.md");
165
+
166
+ const res = await service.registerDocument({
167
+ path: p, title: "Absorb", summary: "summary", evidence: [pinned.id, decision.id]
168
+ });
169
+
170
+ assert.equal(res.evidence_kept, 2, "引用本身照旧全留下:doc 行的 evidence 数组是正向链");
171
+ assert.equal(res.evidence_archived, 1, "只有非 pinned 的那条被动");
172
+ const after = service.getById(decision.id);
173
+ assert.equal(after.archived, true, "被吸收的行退出活跃面(「21 行不是 1 行」)");
174
+ assert.equal(after.content, "一个决定", "只翻标志位:内容与审计全留(可恢复)");
175
+ assert.equal(service.getById(pinned.id).archived, false, "#249 的逐字保真池,升格不能绕过");
176
+ assert.equal(store.list().some((m) => m.id === decision.id), false, "默认面不再列出吸收行");
177
+ } finally {
178
+ cleanup();
179
+ close();
180
+ }
181
+ });
182
+
183
+ test("archiveEvidence: false(工具侧 keep_evidence_active)保留吸收行的活跃面", async () => {
184
+ const { service, close } = makeService({ documentMemoryEnabled: true });
185
+ const { writeDoc, cleanup } = await makeDocDir();
186
+ try {
187
+ const d = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
188
+ const res = await service.registerDocument(
189
+ { path: await writeDoc("optout.md"), title: "OptOut", summary: "s", evidence: [d.id] },
190
+ { archiveEvidence: false }
191
+ );
192
+ assert.equal(res.evidence_kept, 1);
193
+ assert.equal(res.evidence_archived, 0, "opt-out 时不归档");
194
+ assert.equal(service.getById(d.id).archived, false);
195
+ } finally {
196
+ cleanup();
197
+ close();
198
+ }
199
+ });
200
+
201
+ test("re-registering a document whose evidence was absorbed keeps the references", async () => {
202
+ // 这条防的是「补丁把常规路径打坏」:吸收行一旦归档,重注册同一份文档(出新版)
203
+ // 时同一批 evidence 会全落 dropped,进而撞上捏造判据——最常规的路径反而报错。
204
+ const { service, close } = makeService({ documentMemoryEnabled: true });
205
+ const { writeDoc, cleanup } = await makeDocDir();
206
+ try {
207
+ const d = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
208
+ const p = await writeDoc("ver.md");
209
+ const first = await service.registerDocument({ path: p, title: "Ver", summary: "v1", evidence: [d.id] });
210
+ assert.equal(first.evidence_archived, 1);
211
+
212
+ const second = await service.registerDocument({ path: p, title: "Ver", summary: "v2", evidence: [d.id] });
213
+ assert.equal(second.action, "superseded");
214
+ assert.deepEqual(second.memory.evidence, [d.id], "吸收行仍为它所属的那份文档背书");
215
+ assert.equal(second.evidence_kept, 1);
216
+ assert.equal(second.degraded, false, "不误报成捏造证据");
217
+ // 认回是窄口径的:已归档行只为**吸收它的那份**文档背书。别的文档(新注册、无
218
+ // 同名目标)拿它当证据,照旧走既有的捏造判据——不因为这条补丁放宽。
219
+ const other = await writeDoc("other.md");
220
+ await assert.rejects(
221
+ () => service.registerDocument({ path: other, title: "Other", summary: "s", evidence: [d.id] }),
222
+ /fabricated evidence is rejected/
223
+ );
224
+ } finally {
225
+ cleanup();
226
+ close();
227
+ }
228
+ });
229
+
230
+ test("registrar without the pinned pool wiring archives nothing (fail-safe)", async () => {
231
+ // 拿不到 pinned 集合就不做这一步:宁可少做,也不能把保真池当普通行收走。
232
+ const store = createStore(":memory:");
233
+ const { writeDoc, cleanup } = await makeDocDir();
234
+ try {
235
+ const d = store.save({ type: "decision", title: "D", content: "c" });
236
+ const { register } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => null, { pinnedTypes: null });
237
+ const res = await register({ path: await writeDoc("noset.md"), title: "NoSet", summary: "s", evidence: [d.id] });
238
+ assert.equal(res.evidence_archived, 0);
239
+ assert.equal(store.getById(d.id).archived, false);
240
+ } finally {
241
+ cleanup();
242
+ store.close();
243
+ }
244
+ });
245
+
246
+ test("自有生命周期的类型不被吸收:document 行保持活跃,同 path 的 supersede 链不断", async () => {
247
+ // 回归点(单盲审查 H1):把另一份 document 行当 evidence 时,若照普通行吸收,它会
248
+ // 被静默归档——走不到 loser 分支(没有 [superseded by] 指针与 content_history),
249
+ // 而 supersede 探测只看活跃行,于是重注册同 path 会再铸一行、同一个文件留下两行。
250
+ const { store, service, close } = makeService({ documentMemoryEnabled: true });
251
+ const { writeDoc, cleanup } = await makeDocDir();
252
+ try {
253
+ const pA = await writeDoc("a.md");
254
+ const docA = (await service.registerDocument({ path: pA, title: "DocA", summary: "v1" })).memory;
255
+ const docB = await service.registerDocument({
256
+ path: await writeDoc("b.md"), title: "DocB", summary: "grounded in A", evidence: [docA.id]
257
+ });
258
+ assert.equal(docB.evidence_archived, 0, "document 行不参与吸收");
259
+ assert.equal(service.getById(docA.id).archived, false, "被引用不改变它的活跃状态");
260
+
261
+ const again = await service.registerDocument({ path: pA, title: "DocA", summary: "v2" });
262
+ assert.equal(again.action, "superseded", "指针行仍能被 supersede 探测看到");
263
+ assert.equal(again.superseded.id, docA.id);
264
+ assert.ok(again.superseded.content.includes(again.memory.id), "旧行拿到 [superseded by] 指针");
265
+ assert.equal(store.all().filter((m) => m.doc_path === pA).length, 2, "同一 path 一行新版 + 一行带指针的旧版,没有多余的第三行");
266
+ } finally {
267
+ cleanup();
268
+ close();
269
+ }
270
+ });
271
+
272
+ test("自有生命周期的类型不被吸收:summary(dream 总览)保持活跃,不会被当成不存在重铸", async () => {
273
+ // 回归点:summary 按 source 身份去重(dream 总览)且按注入档位常驻。被吸收归档后
274
+ // 去重候选集(store.list 默认排除归档)看不到它 → 下一次做梦会再铸一行同源总览。
275
+ const { service, close } = makeService({ documentMemoryEnabled: true });
276
+ const { writeDoc, cleanup } = await makeDocDir();
277
+ try {
278
+ const digest = service.saveWithDedupe({
279
+ type: "summary", title: "当前项目状态", content: "resident digest", source: "dream", importance: 5
280
+ }).memory;
281
+ const res = await service.registerDocument({
282
+ path: await writeDoc("with-digest.md"), title: "DocW", summary: "s", evidence: [digest.id]
283
+ });
284
+ assert.equal(res.evidence_archived, 0, "summary 不参与吸收");
285
+ assert.equal(service.getById(digest.id).archived, false);
286
+
287
+ const again = service.saveWithDedupe({
288
+ type: "summary", title: "当前项目状态", content: "resident digest v2", source: "dream", importance: 5
289
+ });
290
+ assert.equal(again.action, "merged", "下一次做梦仍认得出这行,不会铸出第二行同源总览");
291
+ assert.equal(again.memory.id, digest.id);
292
+ } finally {
293
+ cleanup();
294
+ close();
295
+ }
296
+ });
297
+
298
+ // ============================================================ supersede 记账(DoD 2)
299
+
300
+ test("re-registering the same path supersedes: old row archived + traceable, file untouched", async () => {
301
+ const { service, close } = makeService({ documentMemoryEnabled: true });
302
+ const { writeDoc, cleanup } = await makeDocDir();
303
+ try {
304
+ const p = await writeDoc("v.md");
305
+ const v1 = await service.registerDocument({ path: p, title: "T1", summary: "summary v1", tags: ["doc"] });
306
+ const v2 = await service.registerDocument({ path: p, title: "T2", summary: "summary v2" });
307
+
308
+ assert.equal(v2.action, "superseded");
309
+ assert.equal(v2.superseded.id, v1.memory.id);
310
+ assert.equal(v2.memory.doc_path, p);
311
+
312
+ const old = service.getById(v1.memory.id);
313
+ assert.equal(old.archived, true, "old row archived (no physical delete)");
314
+ assert.ok(old.content.includes(`[superseded by ${v2.memory.id}]`), "loser carries the pointer note");
315
+ assert.equal(old.content_history[0].source, "superseded");
316
+ assert.equal(old.content_history[0].content, "summary v1", "old summary traceable via content_history");
317
+ assert.equal(old.doc_path, p, "old pointer preserved");
318
+
319
+ const fresh = service.getById(v2.memory.id);
320
+ assert.equal(fresh.archived, false);
321
+ assert.equal(fresh.content, "summary v2");
322
+ } finally {
323
+ cleanup();
324
+ close();
325
+ }
326
+ });
327
+
328
+ test("same title on a different path also supersedes (new version of the moved doc)", async () => {
329
+ const { service, close } = makeService({ documentMemoryEnabled: true });
330
+ const { writeDoc, cleanup } = await makeDocDir();
331
+ try {
332
+ const p1 = await writeDoc("old.md");
333
+ const p2 = await writeDoc("new.md");
334
+ const v1 = await service.registerDocument({ path: p1, title: "Same", summary: "s1" });
335
+ const v2 = await service.registerDocument({ path: p2, title: "Same", summary: "s2" });
336
+ assert.equal(v2.action, "superseded");
337
+ assert.equal(v2.superseded.id, v1.memory.id);
338
+ assert.equal(service.getById(v1.memory.id).archived, true);
339
+ assert.equal(service.getById(v2.memory.id).doc_path, p2);
340
+ } finally {
341
+ cleanup();
342
+ close();
343
+ }
344
+ });
345
+
346
+ // ============================================================ C2 去重(DoD 6 前半)
347
+
348
+ test("vector near-duplicate on a different path/title is rejected (agent decides, no silent supersede)", async () => {
349
+ const { writeDoc, cleanup } = await makeDocDir();
350
+ const store = createStore(":memory:");
351
+ try {
352
+ store.saveDocument({
353
+ type: "document", title: "Alpha", content: "existing summary",
354
+ doc_path: "X:\\elsewhere\\a.md", embedding: [1, 0, 0], source: "tool"
355
+ });
356
+ const b = await writeDoc("b.md");
357
+ const c = await writeDoc("c.md");
358
+ const { register } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => [1, 0, 0]);
359
+ await assert.rejects(
360
+ () => register({ path: b, title: "Beta", summary: "similar summary" }),
361
+ /near duplicate/
362
+ );
363
+ // 正交向量 = 无近重复 → 正常创建。
364
+ const { register: register2 } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => [0, 1, 0]);
365
+ const res = await register2({ path: c, title: "Gamma", summary: "unrelated summary" });
366
+ assert.equal(res.action, "created");
367
+ } finally {
368
+ cleanup();
369
+ store.close();
370
+ }
371
+ });
372
+
373
+ test("embedder unavailable: dedupe degrades to path/title tiers, writes still land", async () => {
374
+ const { service, close } = makeService({ documentMemoryEnabled: true });
375
+ const { writeDoc, cleanup } = await makeDocDir();
376
+ try {
377
+ const p1 = await writeDoc("d1.md");
378
+ const p2 = await writeDoc("d2.md");
379
+ const r1 = await service.registerDocument({ path: p1, title: "A", summary: "identical summary text" });
380
+ const r2 = await service.registerDocument({ path: p2, title: "B", summary: "identical summary text" });
381
+ assert.equal(r1.action, "created");
382
+ assert.equal(r2.action, "created", "no vector signal = no near-dup verdict, write is never blocked");
383
+ } finally {
384
+ cleanup();
385
+ close();
386
+ }
387
+ });
388
+
389
+ // ============================================================ 写入权分离守卫(DoD 1/3 旁路防护)
390
+
391
+ test("saveWithDedupe refuses to mint document rows", () => {
392
+ const { service, close } = makeService({});
393
+ try {
394
+ assert.throws(
395
+ () => service.saveWithDedupe({ type: "document", title: "x", content: "y" }),
396
+ /registerDocument/
397
+ );
398
+ } finally {
399
+ close();
400
+ }
401
+ });
402
+
403
+ test("updateMemory cannot retype a row into document, but document summary repair passes", async () => {
404
+ const { service, close } = makeService({ documentMemoryEnabled: true });
405
+ const { writeDoc, cleanup } = await makeDocDir();
406
+ try {
407
+ const pref = service.saveWithDedupe({ type: "preference", title: "P", content: "c" }).memory;
408
+ assert.throws(() => service.update(pref.id, { type: "document" }), /registerDocument/);
409
+
410
+ const p = await writeDoc("fix.md");
411
+ const atomic = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
412
+ const doc = (await service.registerDocument({
413
+ path: p, title: "Doc", summary: "s", evidence: [atomic.id, "nope"]
414
+ })).memory;
415
+ const updated = service.update(doc.id, { content: "repaired summary", tags: ["user-tag"] });
416
+ assert.equal(updated.content, "repaired summary");
417
+ assert.equal(updated.doc_path, p, "pointer untouched by summary repair");
418
+ assert.equal(updated.archived, false);
419
+ assert.ok(
420
+ updated.tags.includes("evidence_degraded") && updated.tags.includes("user-tag"),
421
+ "system signal tag survives a tags-replacing update"
422
+ );
423
+ } finally {
424
+ cleanup();
425
+ close();
426
+ }
427
+ });
428
+
429
+ // ============================================================ 注入档位与预算(DoD 5)
430
+
431
+ function seedInjectionFixtures(service, store) {
432
+ service.saveWithDedupe({ type: "summary", title: "当前项目状态", content: "resident digest", source: "dream", importance: 5, _overwrite: true });
433
+ service.saveWithDedupe({ type: "summary", title: "叙述:topic", content: "narrative bar", source: "narrative", tags: ["topic-n"], importance: 3 });
434
+ // document 行铸造口唯一:测试种子也走 store.saveDocument(saveWithDedupe
435
+ // 有 service 守卫、store.save 有存储层守卫)。
436
+ for (let i = 1; i <= 3; i++) {
437
+ store.saveDocument({ type: "document", title: `Doc${i}`, content: `d${i}`, source: "tool", doc_path: `X:\\${i}.md`, importance: 3 });
438
+ }
439
+ }
440
+
441
+ test("injection: document rows at the next-priority tier, capped by documentInjectBudget; narrative tier re-enabled", () => {
442
+ const { store, service, close } = makeService({ documentMemoryEnabled: true, documentInjectBudget: 2, dreamNarrativeEnabled: true });
443
+ try {
444
+ seedInjectionFixtures(service, store);
445
+ const picked = service.injectCandidates({});
446
+ assert.equal(picked[0].type, "summary");
447
+ assert.equal(picked[0].source, "dream", "resident digest keeps tier 0");
448
+ assert.equal(picked.filter((m) => m.type === "document").length, 2, "document budget caps at 2 of 3");
449
+ assert.ok(picked.some((m) => m.source === "narrative"), "narrative rows inject at the next-priority tier (#230 拍板)");
450
+ } finally {
451
+ close();
452
+ }
453
+ });
454
+
455
+ test("injection: flags off → document and narrative rows stay out, behavior unchanged", () => {
456
+ const { store, service, close } = makeService({});
457
+ try {
458
+ seedInjectionFixtures(service, store);
459
+ const picked = service.injectCandidates({});
460
+ assert.ok(picked.every((m) => m.type !== "document"), "document rows excluded while flag off");
461
+ assert.ok(picked.every((m) => m.source !== "narrative"), "narrative rows excluded while dreamNarrativeEnabled off");
462
+ assert.ok(picked.some((m) => m.type === "summary" && m.source === "dream"), "dream digest unaffected");
463
+ } finally {
464
+ close();
465
+ }
466
+ });
467
+
468
+ // ============================================================ DTO / 扩展面(DoD 3)
469
+
470
+ test("toApiList exposes doc_path on pointer rows only, within MEMORY_ITEM_SCHEMA", async () => {
471
+ const { service, close } = makeService({ documentMemoryEnabled: true });
472
+ const { writeDoc, cleanup } = await makeDocDir();
473
+ try {
474
+ const pref = service.saveWithDedupe({ type: "preference", title: "P", content: "c" }).memory;
475
+ const p = await writeDoc("dto.md");
476
+ const doc = (await service.registerDocument({ path: p, title: "D", summary: "s" })).memory;
477
+
478
+ const prefDto = service.toApiList([pref])[0];
479
+ assert.ok(!("doc_path" in prefDto), "non-pointer rows keep the pre-#230 DTO shape");
480
+ const docDto = service.toApiList([doc])[0];
481
+ assert.equal(docDto.doc_path, p);
482
+ for (const key of Object.keys(docDto)) {
483
+ assert.ok(key in MEMORY_ITEM_SCHEMA.properties, `DTO key "${key}" declared in MEMORY_ITEM_SCHEMA`);
484
+ }
485
+ } finally {
486
+ cleanup();
487
+ close();
488
+ }
489
+ });
490
+
491
+ test("document rows are heat-immune (pointer rows do not decay)", () => {
492
+ assert.equal(TYPE_DECAY_DEFAULTS.document, 0);
493
+ });
494
+
495
+ // ============================================================ CodeRabbit 修复回归(PR #256 review)
496
+
497
+ test("path normalization: equivalent absolute spellings supersede instead of duplicating", async () => {
498
+ const { service, close } = makeService({ documentMemoryEnabled: true });
499
+ const { dir, writeDoc, cleanup } = await makeDocDir();
500
+ try {
501
+ const p = await writeDoc("norm.md");
502
+ const first = await service.registerDocument({ path: p, title: "V1", summary: "s1" });
503
+ // path.join 会把 "." 段吃掉,手工拼出等价但未归一化的写法。
504
+ const dotted = `${dir}${path.sep}.${path.sep}norm.md`;
505
+ const second = await service.registerDocument({ path: dotted, title: "V2", summary: "s2" });
506
+ assert.equal(second.action, "superseded", "normalized spelling hits the same-path supersede tier");
507
+ assert.equal(second.superseded.id, first.memory.id);
508
+ } finally {
509
+ cleanup();
510
+ close();
511
+ }
512
+ });
513
+
514
+ test("exact supersede tiers scan beyond any window (>200 active documents)", async () => {
515
+ const { store, close } = makeService({ documentMemoryEnabled: true });
516
+ const { writeDoc, cleanup } = await makeDocDir();
517
+ try {
518
+ // 新注册的路径必须过文件校验——目标行(窗口外的旧版)用真实文件,其余
519
+ // 209 行用虚构路径垫量。Bulk0 先种 = updated_at 最早 = list(DESC) 末位。
520
+ const pathA = await writeDoc("a.md");
521
+ const pathB = await writeDoc("b.md");
522
+ store.saveDocument({ type: "document", title: "Bulk0", content: "c0", source: "tool", doc_path: pathA });
523
+ for (let i = 1; i < 210; i++) {
524
+ store.saveDocument({ type: "document", title: `Bulk${i}`, content: `c${i}`, source: "tool", doc_path: `X:\\bulk\\${i}.md` });
525
+ }
526
+ const oldest = store.list({ type: "document", limit: null }).at(-1);
527
+ assert.equal(oldest.title, "Bulk0", "precondition: oldest row is outside the 200 window");
528
+ const { register } = makeRegistrar(store, { documentMemoryEnabled: true });
529
+ // 路径层:窗口外的同路径行必须仍被发现并 supersede。
530
+ const byPath = await register({ path: pathA, title: "Fresh path re-register", summary: "new version" });
531
+ assert.equal(byPath.action, "superseded");
532
+ assert.equal(byPath.superseded.id, oldest.id);
533
+ // 标题层:同款全量语义(Bulk0 已归档,此时窗口外最老是 Bulk1)。
534
+ const next = store.list({ type: "document", limit: null }).at(-1);
535
+ assert.equal(next.title, "Bulk1");
536
+ const byTitle = await register({ path: pathB, title: next.title, summary: "another version" });
537
+ assert.equal(byTitle.action, "superseded");
538
+ assert.equal(byTitle.superseded.id, next.id);
539
+ } finally {
540
+ cleanup();
541
+ close();
542
+ }
543
+ });
544
+
545
+ test("sensitivity is persisted and participates in the supersede matching key", async () => {
546
+ const { service, close } = makeService({ documentMemoryEnabled: true });
547
+ const { writeDoc, cleanup } = await makeDocDir();
548
+ try {
549
+ const p = await writeDoc("sens.md");
550
+ const first = await service.registerDocument({ path: p, title: "S", summary: "s1", sensitivity: "personal" });
551
+ assert.equal(first.memory.sensitivity, "personal", "sensitivity must persist (scopeKeyOf third dim)");
552
+ // 同 path + 同 sensitivity = 显式出新版。
553
+ const again = await service.registerDocument({ path: p, title: "S2", summary: "s2", sensitivity: "personal" });
554
+ assert.equal(again.action, "superseded");
555
+ // sensitivity 不同 = 另一 scope 维度,不互判(与 memory_save 的同键分离语义一致)。
556
+ const other = await service.registerDocument({ path: p, title: "S3", summary: "s3", sensitivity: "team" });
557
+ assert.equal(other.action, "created");
558
+ } finally {
559
+ cleanup();
560
+ close();
561
+ }
562
+ });
563
+
564
+ test("store guards: generic paths cannot mint or retype document rows", () => {
565
+ const { store, close } = makeService({});
566
+ try {
567
+ // save:整类拒绝——doc_path 是任意调用方可捏造的字符串,「必带 doc_path」
568
+ // 挡不住绕过注册校验的直铸(CodeRabbit 复核 #882)。
569
+ assert.throws(
570
+ () => store.save({ type: "document", title: "X", content: "c" }),
571
+ /minted only via store\.saveDocument/
572
+ );
573
+ assert.throws(
574
+ () => store.save({ type: "document", title: "X", content: "c", doc_path: "X:\\x.md" }),
575
+ /minted only via store\.saveDocument/
576
+ );
577
+ // saveDocument:唯一铸造口,且保留指针行结构不变量。
578
+ assert.throws(
579
+ () => store.saveDocument({ type: "document", title: "X", content: "c" }),
580
+ /doc_path is required/
581
+ );
582
+ const pref = store.save({ type: "preference", title: "P", content: "c" });
583
+ const doc = store.saveDocument({ type: "document", title: "D", content: "s", doc_path: "X:\\d.md" });
584
+ // update / CAS:type 不许改入或改出 document(CAS 绕过 service 守卫,存储层兜底)。
585
+ assert.throws(() => store.update(pref.id, { type: "document" }), /cannot be changed to or from/);
586
+ assert.throws(() => store.update(doc.id, { type: "preference" }), /cannot be changed to or from/);
587
+ assert.throws(
588
+ () => store.compareAndUpdate(pref.id, pref.updated_at, { type: "document" }),
589
+ /cannot be changed to or from/
590
+ );
591
+ // 同 type 的摘要修复照常(doc_path 不在 UPDATE SET,天然保持)。
592
+ const repaired = store.update(doc.id, { content: "repaired summary" });
593
+ assert.equal(repaired.content, "repaired summary");
594
+ assert.equal(repaired.doc_path, "X:\\d.md");
595
+ } finally {
596
+ close();
597
+ }
598
+ });
599
+
600
+ test("sleep cold-scan and the pattern pool exclude document rows at the query layer", () => {
601
+ const { store, close } = makeService({});
602
+ try {
603
+ const proj = store.save({ type: "project", title: "Proj", content: "c" });
604
+ const hist = store.save({ type: "history", title: "Hist", content: "c" });
605
+ // 最新行是 document——不带排除时它会占满 LIMIT 1 的扫描窗(饿池前置条件)。
606
+ store.saveDocument({ type: "document", title: "Doc", content: "s", source: "tool", doc_path: "X:\\d.md" });
607
+ // 冷扫描(getUnrecalledSince,sleep phase 2 的归档降级候选):document 豁免。
608
+ const cold = store.getUnrecalledSince(0);
609
+ assert.ok(cold.some((m) => m.id === proj.id) && cold.some((m) => m.id === hist.id));
610
+ assert.ok(cold.every((m) => m.type !== "document"), "pointer rows never become archival-demotion candidates");
611
+ // 模式扫描池:excludeTypes 在 LIMIT 之前生效。
612
+ const plain = store.list({ limit: 1 });
613
+ assert.equal(plain[0].type, "document", "precondition: newest row is a document");
614
+ const pool = store.list({ limit: 1, excludeTypes: ["document"] });
615
+ assert.notEqual(pool[0].type, "document", "ordinary memories reach the pool even under a tight limit");
616
+ // 无界扫描(注册器精确层):limit null = 全量。
617
+ assert.equal(store.list({ type: "document", limit: null }).length, 1);
618
+ } finally {
619
+ close();
620
+ }
621
+ });
622
+
623
+ test("hidden evidence ids (strictScope) are treated as nonexistent by the registrar", async () => {
624
+ const { service, close } = makeService({ documentMemoryEnabled: true });
625
+ const { writeDoc, cleanup } = await makeDocDir();
626
+ try {
627
+ const a = service.saveWithDedupe({ type: "preference", title: "P", content: "c1" }).memory;
628
+ const b = service.saveWithDedupe({ type: "decision", title: "D", content: "c2" }).memory;
629
+ const p = await writeDoc("hidden.md");
630
+ // 全部不可见 = 整单拒绝(与捏造同款;先测,避免先落行后又被同路径命中)。
631
+ await assert.rejects(
632
+ () => service.registerDocument({ path: p, title: "H", summary: "s", evidence: [b.id] }, { hiddenEvidenceIds: [b.id] }),
633
+ /unknown, archived or out of scope/
634
+ );
635
+ // 混合:不可见 id 落 dropped、不进持久化 evidence,kept/dropped 计数保持诚实。
636
+ const mixed = await service.registerDocument(
637
+ { path: p, title: "H", summary: "s", evidence: [a.id, b.id] },
638
+ { hiddenEvidenceIds: [b.id] }
639
+ );
640
+ assert.equal(mixed.evidence_kept, 1);
641
+ assert.equal(mixed.evidence_dropped, 1);
642
+ assert.equal(mixed.degraded, true);
643
+ assert.deepEqual(mixed.memory.evidence, [a.id]);
644
+ } finally {
645
+ cleanup();
646
+ close();
647
+ }
648
+ });
649
+
650
+ test("narrative gate covers the semantic paths: BM25 fallback keeps narratives out while the flag is off", () => {
651
+ const { service, close } = makeService({});
652
+ try {
653
+ service.saveWithDedupe({ type: "summary", title: "叙述:zebra-topic", content: "zebra-topic narrative bar", source: "narrative", importance: 5 });
654
+ // 无 embedder 时首轮必走 BM25 兜底——此前的门只锁规则路,语义路会漏。
655
+ const picked = service.injectCandidates({ q: "zebra-topic" });
656
+ assert.ok(picked.every((m) => m.source !== "narrative"), "semantic-path candidates respect dreamNarrativeEnabled");
657
+ } finally {
658
+ close();
659
+ }
660
+ });
661
+
662
+ test("narrative rows inject via the semantic path once dreamNarrativeEnabled is on", () => {
663
+ const { service, close } = makeService({ dreamNarrativeEnabled: true });
664
+ try {
665
+ service.saveWithDedupe({ type: "summary", title: "叙述:zebra-topic", content: "zebra-topic narrative bar", source: "narrative", importance: 5 });
666
+ const picked = service.injectCandidates({ q: "zebra-topic" });
667
+ assert.ok(picked.some((m) => m.source === "narrative"), "BM25 path admits narratives under the same flag as the rule path");
668
+ } finally {
669
+ close();
670
+ }
671
+ });