@modusensus/dsh-mneme 0.8.4 → 0.8.6

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 (75) hide show
  1. package/README.md +45 -8
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +106 -5
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +128 -12
  9. package/lib/dream.js +46 -11
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/index.js +65 -3
  13. package/lib/inject.js +91 -15
  14. package/lib/lang.js +6 -0
  15. package/lib/local-embedder.js +44 -6
  16. package/lib/organize.js +450 -0
  17. package/lib/recall-stats.js +23 -1
  18. package/lib/runtime/layout.js +30 -0
  19. package/lib/runtime/loader.js +4 -1
  20. package/lib/runtime/verify.js +19 -6
  21. package/lib/search/adaptive.js +38 -0
  22. package/lib/service.js +165 -12
  23. package/lib/settings.js +35 -4
  24. package/lib/store.js +126 -8
  25. package/lib/summarize.js +253 -8
  26. package/lib/tools.js +136 -8
  27. package/package.json +6 -6
  28. package/runtime-manifest.json +104 -174
  29. package/scripts/build-runtime-manifest.mjs +8 -1
  30. package/scripts/sync-test-badge.mjs +18 -12
  31. package/src/api-standalone.js +11 -0
  32. package/src/api.js +21 -0
  33. package/src/config.js +106 -5
  34. package/src/document.js +221 -0
  35. package/src/dream/sleep.js +128 -12
  36. package/src/dream.js +46 -11
  37. package/src/guide.js +44 -0
  38. package/src/heat.js +2 -0
  39. package/src/index.js +65 -3
  40. package/src/inject.js +91 -15
  41. package/src/lang.js +6 -0
  42. package/src/local-embedder.js +44 -6
  43. package/src/organize.js +450 -0
  44. package/src/recall-stats.js +23 -1
  45. package/src/runtime/layout.js +30 -0
  46. package/src/runtime/loader.js +4 -1
  47. package/src/runtime/verify.js +19 -6
  48. package/src/search/adaptive.js +38 -0
  49. package/src/service.js +165 -12
  50. package/src/settings.js +35 -4
  51. package/src/store.js +126 -8
  52. package/src/summarize.js +253 -8
  53. package/src/tools.js +136 -8
  54. package/test/adaptive-inject-budget.test.js +85 -0
  55. package/test/api.test.js +66 -3
  56. package/test/client.test.js +63 -2
  57. package/test/document.test.js +523 -0
  58. package/test/dream.test.js +51 -0
  59. package/test/inject-content-cap.test.js +5 -3
  60. package/test/inject-pools.test.js +170 -0
  61. package/test/inject-recall.test.js +105 -0
  62. package/test/inject.test.js +35 -1
  63. package/test/lastrunat-persist.test.js +101 -0
  64. package/test/llm-audit.test.js +247 -0
  65. package/test/local-embedder.test.js +45 -2
  66. package/test/organize.test.js +418 -0
  67. package/test/recall-stats.test.js +7 -1
  68. package/test/runtime-loader.test.js +18 -0
  69. package/test/runtime-manifest.test.js +2 -1
  70. package/test/runtime-verify.test.js +65 -0
  71. package/test/sleep.test.js +154 -0
  72. package/test/store.test.js +13 -0
  73. package/test/summarize.test.js +407 -3
  74. package/test/tools-dto-schema-matrix.test.js +325 -0
  75. package/test/tools.test.js +36 -3
@@ -0,0 +1,523 @@
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 } 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) {
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
+ });
47
+ return { register, finalized };
48
+ }
49
+
50
+ // ============================================================ 注册校验(DoD 1)
51
+
52
+ test("registration is refused while the feature flag is off", async () => {
53
+ const { service, close } = makeService({});
54
+ const { writeDoc, cleanup } = await makeDocDir();
55
+ try {
56
+ const p = await writeDoc("a.md");
57
+ await assert.rejects(
58
+ () => service.registerDocument({ path: p, title: "T", summary: "S" }),
59
+ /documentMemoryEnabled/
60
+ );
61
+ } finally {
62
+ cleanup();
63
+ close();
64
+ }
65
+ });
66
+
67
+ test("path validation: required / absolute-only / existing non-empty regular file", async () => {
68
+ const { service, close } = makeService({ documentMemoryEnabled: true });
69
+ const { dir, writeDoc, cleanup } = await makeDocDir();
70
+ try {
71
+ await assert.rejects(
72
+ () => service.registerDocument({ title: "T", summary: "S" }),
73
+ /path is required/
74
+ );
75
+ await assert.rejects(
76
+ () => service.registerDocument({ path: "rel.md", title: "T", summary: "S" }),
77
+ /must be absolute/
78
+ );
79
+ await assert.rejects(
80
+ () => service.registerDocument({ path: path.join(dir, "missing.md"), title: "T", summary: "S" }),
81
+ /not found or empty/
82
+ );
83
+ await assert.rejects(
84
+ () => service.registerDocument({ path: dir, title: "T", summary: "S" }),
85
+ /not found or empty/
86
+ );
87
+ const empty = await writeDoc("empty.md", "");
88
+ await assert.rejects(
89
+ () => service.registerDocument({ path: empty, title: "T", summary: "S" }),
90
+ /not found or empty/
91
+ );
92
+ } finally {
93
+ cleanup();
94
+ close();
95
+ }
96
+ });
97
+
98
+ test("~ paths expand to the real home and register", async () => {
99
+ const { service, close } = makeService({ documentMemoryEnabled: true });
100
+ const { writeDoc, cleanup } = await makeDocDir();
101
+ try {
102
+ const p = await writeDoc("tilde.md");
103
+ const rel = path.relative(os.homedir(), p);
104
+ const res = await service.registerDocument({
105
+ path: `~/${rel.split(path.sep).join("/")}`,
106
+ title: "TildeDoc",
107
+ summary: "S"
108
+ });
109
+ assert.equal(res.action, "created");
110
+ assert.equal(res.memory.doc_path, p);
111
+ } finally {
112
+ cleanup();
113
+ close();
114
+ }
115
+ });
116
+
117
+ test("evidence intersection: valid subset kept, unknown dropped + degraded tag, all-fabricated rejected", async () => {
118
+ const { service, close } = makeService({ documentMemoryEnabled: true });
119
+ const { writeDoc, cleanup } = await makeDocDir();
120
+ try {
121
+ const a = service.saveWithDedupe({ type: "preference", title: "P1", content: "c1" }).memory;
122
+ const b = service.saveWithDedupe({ type: "decision", title: "D1", content: "c2" }).memory;
123
+ const p = await writeDoc("r.md");
124
+
125
+ const partial = await service.registerDocument({
126
+ path: p, title: "R", summary: "S", evidence: [a.id, "nope-1", b.id]
127
+ });
128
+ assert.equal(partial.action, "created");
129
+ assert.deepEqual(partial.memory.evidence, [a.id, b.id]);
130
+ assert.equal(partial.evidence_kept, 2);
131
+ assert.equal(partial.evidence_dropped, 1);
132
+ assert.equal(partial.degraded, true);
133
+ assert.ok(partial.memory.tags.includes("evidence_degraded"));
134
+
135
+ await assert.rejects(
136
+ () => service.registerDocument({ path: p, title: "T2", summary: "S", evidence: ["nope-2"] }),
137
+ /fabricated evidence is rejected/
138
+ );
139
+
140
+ const clean = await service.registerDocument({ path: await writeDoc("clean.md"), title: "T3", summary: "S" });
141
+ assert.equal(clean.degraded, false);
142
+ assert.deepEqual(clean.memory.evidence, []);
143
+ assert.ok(!clean.memory.tags.includes("evidence_degraded"));
144
+ } finally {
145
+ cleanup();
146
+ close();
147
+ }
148
+ });
149
+
150
+ // ============================================================ supersede 记账(DoD 2)
151
+
152
+ test("re-registering the same path supersedes: old row archived + traceable, file untouched", async () => {
153
+ const { service, close } = makeService({ documentMemoryEnabled: true });
154
+ const { writeDoc, cleanup } = await makeDocDir();
155
+ try {
156
+ const p = await writeDoc("v.md");
157
+ const v1 = await service.registerDocument({ path: p, title: "T1", summary: "summary v1", tags: ["doc"] });
158
+ const v2 = await service.registerDocument({ path: p, title: "T2", summary: "summary v2" });
159
+
160
+ assert.equal(v2.action, "superseded");
161
+ assert.equal(v2.superseded.id, v1.memory.id);
162
+ assert.equal(v2.memory.doc_path, p);
163
+
164
+ const old = service.getById(v1.memory.id);
165
+ assert.equal(old.archived, true, "old row archived (no physical delete)");
166
+ assert.ok(old.content.includes(`[superseded by ${v2.memory.id}]`), "loser carries the pointer note");
167
+ assert.equal(old.content_history[0].source, "superseded");
168
+ assert.equal(old.content_history[0].content, "summary v1", "old summary traceable via content_history");
169
+ assert.equal(old.doc_path, p, "old pointer preserved");
170
+
171
+ const fresh = service.getById(v2.memory.id);
172
+ assert.equal(fresh.archived, false);
173
+ assert.equal(fresh.content, "summary v2");
174
+ } finally {
175
+ cleanup();
176
+ close();
177
+ }
178
+ });
179
+
180
+ test("same title on a different path also supersedes (new version of the moved doc)", async () => {
181
+ const { service, close } = makeService({ documentMemoryEnabled: true });
182
+ const { writeDoc, cleanup } = await makeDocDir();
183
+ try {
184
+ const p1 = await writeDoc("old.md");
185
+ const p2 = await writeDoc("new.md");
186
+ const v1 = await service.registerDocument({ path: p1, title: "Same", summary: "s1" });
187
+ const v2 = await service.registerDocument({ path: p2, title: "Same", summary: "s2" });
188
+ assert.equal(v2.action, "superseded");
189
+ assert.equal(v2.superseded.id, v1.memory.id);
190
+ assert.equal(service.getById(v1.memory.id).archived, true);
191
+ assert.equal(service.getById(v2.memory.id).doc_path, p2);
192
+ } finally {
193
+ cleanup();
194
+ close();
195
+ }
196
+ });
197
+
198
+ // ============================================================ C2 去重(DoD 6 前半)
199
+
200
+ test("vector near-duplicate on a different path/title is rejected (agent decides, no silent supersede)", async () => {
201
+ const { writeDoc, cleanup } = await makeDocDir();
202
+ const store = createStore(":memory:");
203
+ try {
204
+ store.saveDocument({
205
+ type: "document", title: "Alpha", content: "existing summary",
206
+ doc_path: "X:\\elsewhere\\a.md", embedding: [1, 0, 0], source: "tool"
207
+ });
208
+ const b = await writeDoc("b.md");
209
+ const c = await writeDoc("c.md");
210
+ const { register } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => [1, 0, 0]);
211
+ await assert.rejects(
212
+ () => register({ path: b, title: "Beta", summary: "similar summary" }),
213
+ /near duplicate/
214
+ );
215
+ // 正交向量 = 无近重复 → 正常创建。
216
+ const { register: register2 } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => [0, 1, 0]);
217
+ const res = await register2({ path: c, title: "Gamma", summary: "unrelated summary" });
218
+ assert.equal(res.action, "created");
219
+ } finally {
220
+ cleanup();
221
+ store.close();
222
+ }
223
+ });
224
+
225
+ test("embedder unavailable: dedupe degrades to path/title tiers, writes still land", async () => {
226
+ const { service, close } = makeService({ documentMemoryEnabled: true });
227
+ const { writeDoc, cleanup } = await makeDocDir();
228
+ try {
229
+ const p1 = await writeDoc("d1.md");
230
+ const p2 = await writeDoc("d2.md");
231
+ const r1 = await service.registerDocument({ path: p1, title: "A", summary: "identical summary text" });
232
+ const r2 = await service.registerDocument({ path: p2, title: "B", summary: "identical summary text" });
233
+ assert.equal(r1.action, "created");
234
+ assert.equal(r2.action, "created", "no vector signal = no near-dup verdict, write is never blocked");
235
+ } finally {
236
+ cleanup();
237
+ close();
238
+ }
239
+ });
240
+
241
+ // ============================================================ 写入权分离守卫(DoD 1/3 旁路防护)
242
+
243
+ test("saveWithDedupe refuses to mint document rows", () => {
244
+ const { service, close } = makeService({});
245
+ try {
246
+ assert.throws(
247
+ () => service.saveWithDedupe({ type: "document", title: "x", content: "y" }),
248
+ /registerDocument/
249
+ );
250
+ } finally {
251
+ close();
252
+ }
253
+ });
254
+
255
+ test("updateMemory cannot retype a row into document, but document summary repair passes", async () => {
256
+ const { service, close } = makeService({ documentMemoryEnabled: true });
257
+ const { writeDoc, cleanup } = await makeDocDir();
258
+ try {
259
+ const pref = service.saveWithDedupe({ type: "preference", title: "P", content: "c" }).memory;
260
+ assert.throws(() => service.update(pref.id, { type: "document" }), /registerDocument/);
261
+
262
+ const p = await writeDoc("fix.md");
263
+ const atomic = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
264
+ const doc = (await service.registerDocument({
265
+ path: p, title: "Doc", summary: "s", evidence: [atomic.id, "nope"]
266
+ })).memory;
267
+ const updated = service.update(doc.id, { content: "repaired summary", tags: ["user-tag"] });
268
+ assert.equal(updated.content, "repaired summary");
269
+ assert.equal(updated.doc_path, p, "pointer untouched by summary repair");
270
+ assert.equal(updated.archived, false);
271
+ assert.ok(
272
+ updated.tags.includes("evidence_degraded") && updated.tags.includes("user-tag"),
273
+ "system signal tag survives a tags-replacing update"
274
+ );
275
+ } finally {
276
+ cleanup();
277
+ close();
278
+ }
279
+ });
280
+
281
+ // ============================================================ 注入档位与预算(DoD 5)
282
+
283
+ function seedInjectionFixtures(service, store) {
284
+ service.saveWithDedupe({ type: "summary", title: "当前项目状态", content: "resident digest", source: "dream", importance: 5, _overwrite: true });
285
+ service.saveWithDedupe({ type: "summary", title: "叙述:topic", content: "narrative bar", source: "narrative", tags: ["topic-n"], importance: 3 });
286
+ // document 行铸造口唯一:测试种子也走 store.saveDocument(saveWithDedupe
287
+ // 有 service 守卫、store.save 有存储层守卫)。
288
+ for (let i = 1; i <= 3; i++) {
289
+ store.saveDocument({ type: "document", title: `Doc${i}`, content: `d${i}`, source: "tool", doc_path: `X:\\${i}.md`, importance: 3 });
290
+ }
291
+ }
292
+
293
+ test("injection: document rows at the next-priority tier, capped by documentInjectBudget; narrative tier re-enabled", () => {
294
+ const { store, service, close } = makeService({ documentMemoryEnabled: true, documentInjectBudget: 2, dreamNarrativeEnabled: true });
295
+ try {
296
+ seedInjectionFixtures(service, store);
297
+ const picked = service.injectCandidates({});
298
+ assert.equal(picked[0].type, "summary");
299
+ assert.equal(picked[0].source, "dream", "resident digest keeps tier 0");
300
+ assert.equal(picked.filter((m) => m.type === "document").length, 2, "document budget caps at 2 of 3");
301
+ assert.ok(picked.some((m) => m.source === "narrative"), "narrative rows inject at the next-priority tier (#230 拍板)");
302
+ } finally {
303
+ close();
304
+ }
305
+ });
306
+
307
+ test("injection: flags off → document and narrative rows stay out, behavior unchanged", () => {
308
+ const { store, service, close } = makeService({});
309
+ try {
310
+ seedInjectionFixtures(service, store);
311
+ const picked = service.injectCandidates({});
312
+ assert.ok(picked.every((m) => m.type !== "document"), "document rows excluded while flag off");
313
+ assert.ok(picked.every((m) => m.source !== "narrative"), "narrative rows excluded while dreamNarrativeEnabled off");
314
+ assert.ok(picked.some((m) => m.type === "summary" && m.source === "dream"), "dream digest unaffected");
315
+ } finally {
316
+ close();
317
+ }
318
+ });
319
+
320
+ // ============================================================ DTO / 扩展面(DoD 3)
321
+
322
+ test("toApiList exposes doc_path on pointer rows only, within MEMORY_ITEM_SCHEMA", async () => {
323
+ const { service, close } = makeService({ documentMemoryEnabled: true });
324
+ const { writeDoc, cleanup } = await makeDocDir();
325
+ try {
326
+ const pref = service.saveWithDedupe({ type: "preference", title: "P", content: "c" }).memory;
327
+ const p = await writeDoc("dto.md");
328
+ const doc = (await service.registerDocument({ path: p, title: "D", summary: "s" })).memory;
329
+
330
+ const prefDto = service.toApiList([pref])[0];
331
+ assert.ok(!("doc_path" in prefDto), "non-pointer rows keep the pre-#230 DTO shape");
332
+ const docDto = service.toApiList([doc])[0];
333
+ assert.equal(docDto.doc_path, p);
334
+ for (const key of Object.keys(docDto)) {
335
+ assert.ok(key in MEMORY_ITEM_SCHEMA.properties, `DTO key "${key}" declared in MEMORY_ITEM_SCHEMA`);
336
+ }
337
+ } finally {
338
+ cleanup();
339
+ close();
340
+ }
341
+ });
342
+
343
+ test("document rows are heat-immune (pointer rows do not decay)", () => {
344
+ assert.equal(TYPE_DECAY_DEFAULTS.document, 0);
345
+ });
346
+
347
+ // ============================================================ CodeRabbit 修复回归(PR #256 review)
348
+
349
+ test("path normalization: equivalent absolute spellings supersede instead of duplicating", async () => {
350
+ const { service, close } = makeService({ documentMemoryEnabled: true });
351
+ const { dir, writeDoc, cleanup } = await makeDocDir();
352
+ try {
353
+ const p = await writeDoc("norm.md");
354
+ const first = await service.registerDocument({ path: p, title: "V1", summary: "s1" });
355
+ // path.join 会把 "." 段吃掉,手工拼出等价但未归一化的写法。
356
+ const dotted = `${dir}${path.sep}.${path.sep}norm.md`;
357
+ const second = await service.registerDocument({ path: dotted, title: "V2", summary: "s2" });
358
+ assert.equal(second.action, "superseded", "normalized spelling hits the same-path supersede tier");
359
+ assert.equal(second.superseded.id, first.memory.id);
360
+ } finally {
361
+ cleanup();
362
+ close();
363
+ }
364
+ });
365
+
366
+ test("exact supersede tiers scan beyond any window (>200 active documents)", async () => {
367
+ const { store, close } = makeService({ documentMemoryEnabled: true });
368
+ const { writeDoc, cleanup } = await makeDocDir();
369
+ try {
370
+ // 新注册的路径必须过文件校验——目标行(窗口外的旧版)用真实文件,其余
371
+ // 209 行用虚构路径垫量。Bulk0 先种 = updated_at 最早 = list(DESC) 末位。
372
+ const pathA = await writeDoc("a.md");
373
+ const pathB = await writeDoc("b.md");
374
+ store.saveDocument({ type: "document", title: "Bulk0", content: "c0", source: "tool", doc_path: pathA });
375
+ for (let i = 1; i < 210; i++) {
376
+ store.saveDocument({ type: "document", title: `Bulk${i}`, content: `c${i}`, source: "tool", doc_path: `X:\\bulk\\${i}.md` });
377
+ }
378
+ const oldest = store.list({ type: "document", limit: null }).at(-1);
379
+ assert.equal(oldest.title, "Bulk0", "precondition: oldest row is outside the 200 window");
380
+ const { register } = makeRegistrar(store, { documentMemoryEnabled: true });
381
+ // 路径层:窗口外的同路径行必须仍被发现并 supersede。
382
+ const byPath = await register({ path: pathA, title: "Fresh path re-register", summary: "new version" });
383
+ assert.equal(byPath.action, "superseded");
384
+ assert.equal(byPath.superseded.id, oldest.id);
385
+ // 标题层:同款全量语义(Bulk0 已归档,此时窗口外最老是 Bulk1)。
386
+ const next = store.list({ type: "document", limit: null }).at(-1);
387
+ assert.equal(next.title, "Bulk1");
388
+ const byTitle = await register({ path: pathB, title: next.title, summary: "another version" });
389
+ assert.equal(byTitle.action, "superseded");
390
+ assert.equal(byTitle.superseded.id, next.id);
391
+ } finally {
392
+ cleanup();
393
+ close();
394
+ }
395
+ });
396
+
397
+ test("sensitivity is persisted and participates in the supersede matching key", async () => {
398
+ const { service, close } = makeService({ documentMemoryEnabled: true });
399
+ const { writeDoc, cleanup } = await makeDocDir();
400
+ try {
401
+ const p = await writeDoc("sens.md");
402
+ const first = await service.registerDocument({ path: p, title: "S", summary: "s1", sensitivity: "personal" });
403
+ assert.equal(first.memory.sensitivity, "personal", "sensitivity must persist (scopeKeyOf third dim)");
404
+ // 同 path + 同 sensitivity = 显式出新版。
405
+ const again = await service.registerDocument({ path: p, title: "S2", summary: "s2", sensitivity: "personal" });
406
+ assert.equal(again.action, "superseded");
407
+ // sensitivity 不同 = 另一 scope 维度,不互判(与 memory_save 的同键分离语义一致)。
408
+ const other = await service.registerDocument({ path: p, title: "S3", summary: "s3", sensitivity: "team" });
409
+ assert.equal(other.action, "created");
410
+ } finally {
411
+ cleanup();
412
+ close();
413
+ }
414
+ });
415
+
416
+ test("store guards: generic paths cannot mint or retype document rows", () => {
417
+ const { store, close } = makeService({});
418
+ try {
419
+ // save:整类拒绝——doc_path 是任意调用方可捏造的字符串,「必带 doc_path」
420
+ // 挡不住绕过注册校验的直铸(CodeRabbit 复核 #882)。
421
+ assert.throws(
422
+ () => store.save({ type: "document", title: "X", content: "c" }),
423
+ /minted only via store\.saveDocument/
424
+ );
425
+ assert.throws(
426
+ () => store.save({ type: "document", title: "X", content: "c", doc_path: "X:\\x.md" }),
427
+ /minted only via store\.saveDocument/
428
+ );
429
+ // saveDocument:唯一铸造口,且保留指针行结构不变量。
430
+ assert.throws(
431
+ () => store.saveDocument({ type: "document", title: "X", content: "c" }),
432
+ /doc_path is required/
433
+ );
434
+ const pref = store.save({ type: "preference", title: "P", content: "c" });
435
+ const doc = store.saveDocument({ type: "document", title: "D", content: "s", doc_path: "X:\\d.md" });
436
+ // update / CAS:type 不许改入或改出 document(CAS 绕过 service 守卫,存储层兜底)。
437
+ assert.throws(() => store.update(pref.id, { type: "document" }), /cannot be changed to or from/);
438
+ assert.throws(() => store.update(doc.id, { type: "preference" }), /cannot be changed to or from/);
439
+ assert.throws(
440
+ () => store.compareAndUpdate(pref.id, pref.updated_at, { type: "document" }),
441
+ /cannot be changed to or from/
442
+ );
443
+ // 同 type 的摘要修复照常(doc_path 不在 UPDATE SET,天然保持)。
444
+ const repaired = store.update(doc.id, { content: "repaired summary" });
445
+ assert.equal(repaired.content, "repaired summary");
446
+ assert.equal(repaired.doc_path, "X:\\d.md");
447
+ } finally {
448
+ close();
449
+ }
450
+ });
451
+
452
+ test("sleep cold-scan and the pattern pool exclude document rows at the query layer", () => {
453
+ const { store, close } = makeService({});
454
+ try {
455
+ const proj = store.save({ type: "project", title: "Proj", content: "c" });
456
+ const hist = store.save({ type: "history", title: "Hist", content: "c" });
457
+ // 最新行是 document——不带排除时它会占满 LIMIT 1 的扫描窗(饿池前置条件)。
458
+ store.saveDocument({ type: "document", title: "Doc", content: "s", source: "tool", doc_path: "X:\\d.md" });
459
+ // 冷扫描(getUnrecalledSince,sleep phase 2 的归档降级候选):document 豁免。
460
+ const cold = store.getUnrecalledSince(0);
461
+ assert.ok(cold.some((m) => m.id === proj.id) && cold.some((m) => m.id === hist.id));
462
+ assert.ok(cold.every((m) => m.type !== "document"), "pointer rows never become archival-demotion candidates");
463
+ // 模式扫描池:excludeTypes 在 LIMIT 之前生效。
464
+ const plain = store.list({ limit: 1 });
465
+ assert.equal(plain[0].type, "document", "precondition: newest row is a document");
466
+ const pool = store.list({ limit: 1, excludeTypes: ["document"] });
467
+ assert.notEqual(pool[0].type, "document", "ordinary memories reach the pool even under a tight limit");
468
+ // 无界扫描(注册器精确层):limit null = 全量。
469
+ assert.equal(store.list({ type: "document", limit: null }).length, 1);
470
+ } finally {
471
+ close();
472
+ }
473
+ });
474
+
475
+ test("hidden evidence ids (strictScope) are treated as nonexistent by the registrar", async () => {
476
+ const { service, close } = makeService({ documentMemoryEnabled: true });
477
+ const { writeDoc, cleanup } = await makeDocDir();
478
+ try {
479
+ const a = service.saveWithDedupe({ type: "preference", title: "P", content: "c1" }).memory;
480
+ const b = service.saveWithDedupe({ type: "decision", title: "D", content: "c2" }).memory;
481
+ const p = await writeDoc("hidden.md");
482
+ // 全部不可见 = 整单拒绝(与捏造同款;先测,避免先落行后又被同路径命中)。
483
+ await assert.rejects(
484
+ () => service.registerDocument({ path: p, title: "H", summary: "s", evidence: [b.id] }, { hiddenEvidenceIds: [b.id] }),
485
+ /unknown, archived or out of scope/
486
+ );
487
+ // 混合:不可见 id 落 dropped、不进持久化 evidence,kept/dropped 计数保持诚实。
488
+ const mixed = await service.registerDocument(
489
+ { path: p, title: "H", summary: "s", evidence: [a.id, b.id] },
490
+ { hiddenEvidenceIds: [b.id] }
491
+ );
492
+ assert.equal(mixed.evidence_kept, 1);
493
+ assert.equal(mixed.evidence_dropped, 1);
494
+ assert.equal(mixed.degraded, true);
495
+ assert.deepEqual(mixed.memory.evidence, [a.id]);
496
+ } finally {
497
+ cleanup();
498
+ close();
499
+ }
500
+ });
501
+
502
+ test("narrative gate covers the semantic paths: BM25 fallback keeps narratives out while the flag is off", () => {
503
+ const { service, close } = makeService({});
504
+ try {
505
+ service.saveWithDedupe({ type: "summary", title: "叙述:zebra-topic", content: "zebra-topic narrative bar", source: "narrative", importance: 5 });
506
+ // 无 embedder 时首轮必走 BM25 兜底——此前的门只锁规则路,语义路会漏。
507
+ const picked = service.injectCandidates({ q: "zebra-topic" });
508
+ assert.ok(picked.every((m) => m.source !== "narrative"), "semantic-path candidates respect dreamNarrativeEnabled");
509
+ } finally {
510
+ close();
511
+ }
512
+ });
513
+
514
+ test("narrative rows inject via the semantic path once dreamNarrativeEnabled is on", () => {
515
+ const { service, close } = makeService({ dreamNarrativeEnabled: true });
516
+ try {
517
+ service.saveWithDedupe({ type: "summary", title: "叙述:zebra-topic", content: "zebra-topic narrative bar", source: "narrative", importance: 5 });
518
+ const picked = service.injectCandidates({ q: "zebra-topic" });
519
+ assert.ok(picked.some((m) => m.source === "narrative"), "BM25 path admits narratives under the same flag as the rule path");
520
+ } finally {
521
+ close();
522
+ }
523
+ });
@@ -1255,3 +1255,54 @@ test("issue#126: a differentiate decision on the dream path produces one receipt
1255
1255
  assert.ok(receipts.every((r) => r.count_before === 1 && r.count_after === 1), "no count change for differentiate");
1256
1256
  store.close();
1257
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
+ });
@@ -67,7 +67,7 @@ test("block budget scales with the cap (raised cap is not defeated by the 1500 g
67
67
  assert.ok(text.includes("乙"), "second entry still rendered");
68
68
  });
69
69
 
70
- test("_full_content (sleep-compressed) path stays verbatim without a hint", () => {
70
+ test("_full_content (sleep-compressed) path obeys the cap and says so (#266 评审)", () => {
71
71
  const { store, contexts, service } = setup();
72
72
  const mem = service.saveWithDedupe({ type: "preference", title: "压缩记忆", content: "占位", importance: 5 }).memory;
73
73
  // 模拟 sleep 降级:content=压缩摘要(400 字符 > 默认 300 上限),原文入库 _full_content
@@ -75,8 +75,10 @@ test("_full_content (sleep-compressed) path stays verbatim without a hint", () =
75
75
  store.db.prepare("UPDATE memories SET content=?, _full_content=? WHERE id=?")
76
76
  .run(summary, "原始长文".repeat(100), mem.id);
77
77
  const text = contexts[0].text({});
78
- assert.ok(text.includes(summary), "summary injected verbatim beyond the cap");
79
- assert.ok(!text.includes("已截断"), "no truncation hint on the compressed path");
78
+ // 「降级过」不等于「天然够短」:原先这条早返回的唯一效果就是绕过上限,现在统一截断。
79
+ assert.ok(!text.includes(summary), "oversized summary is not injected whole");
80
+ assert.ok(text.includes("压缩摘要。".repeat(60)), "the part inside the cap is still there (300 chars)");
81
+ assert.ok(text.includes("已截断"), "the cut is reported, never silent");
80
82
  });
81
83
 
82
84
  test("default cap stays 300 (ellipsis regression of Bug6)", () => {