recallnest 2.6.1 → 3.0.0

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 (74) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +131 -14
  3. package/README_CN.md +121 -17
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +5 -4
  11. package/src/__tests__/access-tracker-novelty.test.ts +22 -2
  12. package/src/__tests__/activity-counter.test.ts +38 -38
  13. package/src/__tests__/age-label.test.ts +33 -0
  14. package/src/__tests__/cluster-consolidation.test.ts +313 -22
  15. package/src/__tests__/consolidation-engine.test.ts +90 -0
  16. package/src/__tests__/contracts.test.ts +2 -2
  17. package/src/__tests__/data-checkup.test.ts +31 -3
  18. package/src/__tests__/dream-pipeline.test.ts +41 -1
  19. package/src/__tests__/dream-scope-isolation.test.ts +386 -0
  20. package/src/__tests__/dream-write-batching.test.ts +211 -0
  21. package/src/__tests__/env-config-invariant.test.ts +80 -0
  22. package/src/__tests__/env-config.test.ts +60 -0
  23. package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
  24. package/src/__tests__/maintenance-locks.test.ts +8 -0
  25. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
  26. package/src/__tests__/memory-output.test.ts +9 -0
  27. package/src/__tests__/memory-utility.test.ts +256 -0
  28. package/src/__tests__/openai-contract.test.ts +513 -0
  29. package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
  30. package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
  31. package/src/__tests__/source-heartbeat.test.ts +6 -2
  32. package/src/__tests__/store-patch-metadata.test.ts +30 -0
  33. package/src/__tests__/synthesis-contract.test.ts +252 -0
  34. package/src/__tests__/synthesis-promotion.test.ts +369 -0
  35. package/src/__tests__/trusted-memory.test.ts +28 -0
  36. package/src/__tests__/utility-weight.test.ts +57 -0
  37. package/src/__tests__/verbatim-self-recall.test.ts +279 -0
  38. package/src/__tests__/version-manager.test.ts +35 -0
  39. package/src/access-tracker.ts +10 -1
  40. package/src/activity-counter.ts +69 -11
  41. package/src/age-label.ts +29 -0
  42. package/src/audit-log.ts +35 -0
  43. package/src/auto-gc.ts +97 -30
  44. package/src/cli.ts +224 -10
  45. package/src/consolidation-engine.ts +317 -123
  46. package/src/data-checkup.ts +17 -3
  47. package/src/dream-pipeline.ts +90 -12
  48. package/src/embedder.ts +20 -5
  49. package/src/env-config.ts +53 -0
  50. package/src/forget-engine.ts +8 -2
  51. package/src/gateway-server.ts +272 -0
  52. package/src/ingest.ts +23 -5
  53. package/src/llm-client.ts +86 -25
  54. package/src/mcp-server.ts +2 -1
  55. package/src/mcp-tools-advanced.ts +7 -2
  56. package/src/mcp-tools-core.ts +11 -1
  57. package/src/mcp-tools-governance.ts +45 -3
  58. package/src/memory-evolution.ts +16 -0
  59. package/src/memory-output.ts +12 -8
  60. package/src/memory-promotion.ts +280 -0
  61. package/src/memory-store-port.ts +32 -3
  62. package/src/memory-utility.ts +314 -0
  63. package/src/recall-ledger.ts +76 -0
  64. package/src/retriever.ts +82 -9
  65. package/src/scope-policy.ts +22 -1
  66. package/src/session-output.ts +4 -3
  67. package/src/skill-promotion.ts +152 -1
  68. package/src/store.ts +231 -71
  69. package/src/synthesis-contract.ts +286 -0
  70. package/src/version-manager.ts +83 -0
  71. package/src/workflow-observation-schema.ts +26 -0
  72. package/integrations/gemini-cli/README.md +0 -57
  73. package/integrations/gemini-cli/setup.sh +0 -64
  74. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
@@ -79,6 +79,7 @@ function createMockStore(entries: MemoryEntry[]): MemoryStore {
79
79
  categoryCounts: {},
80
80
  };
81
81
  },
82
+ async repairSingletonVersionGroups() { return 0; },
82
83
  async store(entry: Partial<MemoryEntry>) {
83
84
  const full = {
84
85
  id: entry.id || `dream-${storeCounter++}`,
@@ -121,6 +122,29 @@ function createMockStore(entries: MemoryEntry[]): MemoryStore {
121
122
  entry.metadata = JSON.stringify(patchFn(meta, entry));
122
123
  return entry;
123
124
  },
125
+ // 2026-08-14 批量通道:语义同上面的单条 patchMetadata,按序逐条以最新行起底
126
+ async patchMetadataBatch(
127
+ patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown> }>,
128
+ _scopeFilter?: string[],
129
+ ) {
130
+ let written = 0;
131
+ for (const { id, patchFn } of patches) {
132
+ const entry = stored.find(e => e.id === id);
133
+ if (!entry) continue;
134
+ let meta: Record<string, unknown>;
135
+ try {
136
+ const parsed: unknown = JSON.parse(entry.metadata || "{}");
137
+ meta = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
138
+ ? (parsed as Record<string, unknown>)
139
+ : {};
140
+ } catch {
141
+ meta = {};
142
+ }
143
+ entry.metadata = JSON.stringify(patchFn(meta, entry));
144
+ written++;
145
+ }
146
+ return written;
147
+ },
124
148
  async vectorSearch(_vec: number[], limit: number, _threshold: number, _scopes?: string[]) {
125
149
  return stored.slice(0, limit).map(e => ({ entry: e, score: 0.85 }));
126
150
  },
@@ -679,12 +703,28 @@ describe("shouldBlockDreamRun", () => {
679
703
  expect(shouldBlockDreamRun({ totalScopes: 475, fatalFailures: 0, transientFailures: 0 })).toBe(false);
680
704
  expect(shouldBlockDreamRun({ totalScopes: 0, fatalFailures: 0, transientFailures: 0 })).toBe(false);
681
705
  });
706
+
707
+ // 2026-08-14: transcript 出队后队列缩到个位数,1/1 = 100% > 20% 曾把单个瞬态锁竞争
708
+ // 判成整轮红(当天验证轮实测)。绝对量豁免:transient ≤ 2 不算「成规模」。
709
+ it("小队列豁免:个位数队列里 1-2 个瞬态失败不判 blocked", () => {
710
+ expect(shouldBlockDreamRun({ totalScopes: 1, fatalFailures: 0, transientFailures: 1 })).toBe(false);
711
+ expect(shouldBlockDreamRun({ totalScopes: 2, fatalFailures: 0, transientFailures: 2 })).toBe(false);
712
+ expect(shouldBlockDreamRun({ totalScopes: 3, fatalFailures: 0, transientFailures: 2 })).toBe(false);
713
+ });
714
+
715
+ it("小队列豁免不放过 fatal,也不放过超出绝对量的比例失败", () => {
716
+ expect(shouldBlockDreamRun({ totalScopes: 1, fatalFailures: 1, transientFailures: 0 })).toBe(true);
717
+ // 3 个 transient 超出绝对豁免(2),且 3/5 = 60% > 20% → 该红还是红
718
+ expect(shouldBlockDreamRun({ totalScopes: 5, fatalFailures: 0, transientFailures: 3 })).toBe(true);
719
+ });
682
720
  });
683
721
 
684
722
  describe("autoRunBudgetMs default", () => {
685
723
  it("caps one --auto sweep well below a day", () => {
686
724
  // 07-24 那轮 4 天 15 小时,堵死三天调度。预算是防跨天的兜底闸。
687
- expect(DEFAULT_DREAM_CONFIG.autoRunBudgetMs).toBe(6 * 60 * 60 * 1000);
725
+ // 2026-08-14: 6h→2h —— transcript 出队后 inline 队列只剩个位数 durable scope,
726
+ // 典型轮次分钟级,2h 是纯兜底(单 scope 路径不走本预算)。
727
+ expect(DEFAULT_DREAM_CONFIG.autoRunBudgetMs).toBe(2 * 60 * 60 * 1000);
688
728
  expect(DEFAULT_DREAM_CONFIG.autoRunBudgetMs).toBeLessThan(24 * 60 * 60 * 1000);
689
729
  });
690
730
  });
@@ -0,0 +1,386 @@
1
+ /**
2
+ * dream 的 scope 隔离与策略排除(2026-08-16)。
3
+ *
4
+ * 为什么单开一个文件而不是塞进 dream-pipeline.test.ts:那边的 mock store **吞掉 scope
5
+ * 参数**(`list()` 无视 scopeFilter、`vectorSearch(_vec, limit, _threshold, _scopes)` 直接
6
+ * 返回全部行),所以哪怕 exact 模式压根没传到 3a,那些测试也照样全绿 —— 互审 C3 指出的
7
+ * 正是这一点。本文件两层:
8
+ * L1 真实 MemoryStore 验 store 的 family/exact 两种语义(现有 prefix 行为必须保住)
9
+ * L2 scope-aware mock 验 dream/3a 是否真把模式传到了每一层取数点
10
+ * 外加 L3 策略排除、L4 派生 insight 不参与 3a。
11
+ */
12
+ import { afterEach, describe, expect, it } from "bun:test";
13
+ import { mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { MemoryStore, type MemoryEntry } from "../store.js";
18
+ import type { MemoryStorePort } from "../memory-store-port.js";
19
+ import type { ScopeMatchMode } from "../scope-policy.js";
20
+ import { matchesScopeFilter } from "../scope-policy.js";
21
+ import { cosineSimilarity } from "../multi-vector.js";
22
+ import { runDream, DEFAULT_DREAM_CONFIG } from "../dream-pipeline.js";
23
+ import { ConsolidationEngine, DEFAULT_CONSOLIDATION_CONFIG } from "../consolidation-engine.js";
24
+ import { getWriteCount, incrementWriteCount, resetWriteCount } from "../activity-counter.js";
25
+ import type { LLMClient } from "../llm-client.js";
26
+ import type { Embedder } from "../embedder.js";
27
+
28
+ const cleanupPaths: string[] = [];
29
+
30
+ afterEach(() => {
31
+ while (cleanupPaths.length > 0) {
32
+ const target = cleanupPaths.pop();
33
+ if (target) rmSync(target, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ function tempDb(): string {
38
+ const dbPath = mkdtempSync(join(tmpdir(), "recallnest-dream-scope-"));
39
+ cleanupPaths.push(dbPath);
40
+ return dbPath;
41
+ }
42
+
43
+ /**
44
+ * ⚠️ activity-counter 的默认 statsPath 指向**生产** `data/activity-stats.json`。
45
+ * 不传 statsPath 就调 resetWriteCount/incrementWriteCount = 直接改真库的写计数
46
+ * (本文件初版就这么干了:把生产的 `memory: 14` 删了,已手工恢复并留 bak)。
47
+ * 所有涉及写计数的测试一律走这个临时路径。
48
+ */
49
+ function tempStatsPath(): { statsPath: string } {
50
+ const dir = mkdtempSync(join(tmpdir(), "recallnest-dream-stats-"));
51
+ cleanupPaths.push(dir);
52
+ return { statsPath: join(dir, "activity-stats.json") };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // L1 — 真实 MemoryStore:family(历史行为)vs exact(新增)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe("MemoryStore scope matching modes", () => {
60
+ async function seeded(): Promise<MemoryStore> {
61
+ const store = new MemoryStore({ dbPath: tempDb(), vectorDim: 3 });
62
+ await store.store({ text: "rule doc chunk", vector: [1, 0, 0], category: "fact", scope: "memory", importance: 0.5 });
63
+ await store.store({ text: "hand-written pivot", vector: [1, 0, 0], category: "fact", scope: "memory:pivot", importance: 0.9 });
64
+ await store.store({ text: "unrelated", vector: [0, 1, 0], category: "fact", scope: "project:other", importance: 0.5 });
65
+ return store;
66
+ }
67
+
68
+ it("list: family 模式对无冒号 scope 按前缀(保住既有行为)", async () => {
69
+ const store = await seeded();
70
+ const rows = await store.list(["memory"], undefined, 100, 0);
71
+ expect(rows.map(r => r.scope).sort()).toEqual(["memory", "memory:pivot"]);
72
+ });
73
+
74
+ it("list: exact 模式只命中同名 scope", async () => {
75
+ const store = await seeded();
76
+ const rows = await store.list(["memory"], undefined, 100, 0, "exact");
77
+ expect(rows.map(r => r.scope)).toEqual(["memory"]);
78
+ });
79
+
80
+ it("stats: 两种模式的 totalCount 不同", async () => {
81
+ const store = await seeded();
82
+ expect((await store.stats(["memory"])).totalCount).toBe(2);
83
+ expect((await store.stats(["memory"], "exact")).totalCount).toBe(1);
84
+ });
85
+
86
+ it("vectorSearch: exact 模式不返回兄弟 scope 的行", async () => {
87
+ const store = await seeded();
88
+ const family = await store.vectorSearch([1, 0, 0], 10, 0.1, ["memory"]);
89
+ expect(family.map(r => r.entry.scope).sort()).toEqual(["memory", "memory:pivot"]);
90
+
91
+ const exact = await store.vectorSearch([1, 0, 0], 10, 0.1, ["memory"], "exact");
92
+ expect(exact.map(r => r.entry.scope)).toEqual(["memory"]);
93
+ });
94
+
95
+ it("含冒号的 scope 两种模式都精确(family 的隐式规则不受影响)", async () => {
96
+ const store = await seeded();
97
+ expect((await store.list(["memory:pivot"], undefined, 100, 0)).length).toBe(1);
98
+ expect((await store.list(["memory:pivot"], undefined, 100, 0, "exact")).length).toBe(1);
99
+ });
100
+
101
+ it("exact 模式下 `memory:` 命中空集 —— 拿冒号形当修法会静默 noop", async () => {
102
+ // 互审 K2:曾被考虑过的"让 weekly 传 memory: 绕开前缀"路线在这里被钉死为反例。
103
+ const store = await seeded();
104
+ expect((await store.list(["memory:"], undefined, 100, 0)).length).toBe(0);
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // L2/L3/L4 — scope-aware mock:验模式有没有真传到每一层
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * 与 dream-pipeline.test.ts 的 mock 的关键差别:**这个 mock 认 scope 也认 mode**。
114
+ * 漏传 exact 会让下面的断言真的红,这正是它存在的理由。
115
+ */
116
+ function createScopeAwareStore(entries: MemoryEntry[]) {
117
+ const stored: MemoryEntry[] = entries.map(e => ({ ...e }));
118
+ let counter = 0;
119
+ const patchedIds: string[] = [];
120
+
121
+ const select = (scopeFilter?: string[], mode: ScopeMatchMode = "family") =>
122
+ stored.filter(e => matchesScopeFilter(e.scope, scopeFilter, mode));
123
+
124
+ const store = {
125
+ async list(scopeFilter?: string[], _category?: string, limit = 20, offset = 0, scopeMatch: ScopeMatchMode = "family") {
126
+ // 复刻生产:list 恒返回假空向量
127
+ return select(scopeFilter, scopeMatch).slice(offset, offset + limit).map(e => ({ ...e, vector: [] as number[] }));
128
+ },
129
+ async listPage(opts: { scopeFilter?: string[]; limit?: number; offset?: number } = {}) {
130
+ const { scopeFilter, limit = 1000, offset = 0 } = opts;
131
+ return select(scopeFilter).slice(offset, offset + limit).map(e => ({ ...e, vector: [] as number[] }));
132
+ },
133
+ async getVectors(ids: string[]) {
134
+ const m = new Map<string, number[]>();
135
+ for (const id of ids) {
136
+ const e = stored.find(x => x.id === id);
137
+ if (e?.vector?.length) m.set(id, e.vector);
138
+ }
139
+ return m;
140
+ },
141
+ async stats(scopeFilter?: string[], scopeMatch: ScopeMatchMode = "family") {
142
+ return { totalCount: select(scopeFilter, scopeMatch).length, scopeCounts: {}, categoryCounts: {} };
143
+ },
144
+ async repairSingletonVersionGroups() { return 0; },
145
+ async getById(id: string) {
146
+ return stored.find(e => e.id === id) ?? null;
147
+ },
148
+ async store(entry: Partial<MemoryEntry>) {
149
+ const full = {
150
+ id: entry.id || `gen-${counter++}`,
151
+ text: entry.text || "",
152
+ vector: entry.vector || [],
153
+ category: entry.category || "events",
154
+ scope: entry.scope || "project:test",
155
+ importance: entry.importance ?? 0.5,
156
+ timestamp: Date.now(),
157
+ metadata: entry.metadata || "{}",
158
+ } as MemoryEntry;
159
+ stored.push(full);
160
+ return full;
161
+ },
162
+ async update(id: string, upd: Partial<MemoryEntry>) {
163
+ const e = stored.find(x => x.id === id);
164
+ if (e && upd.metadata) e.metadata = upd.metadata;
165
+ return e ?? null;
166
+ },
167
+ async patchMetadata(id: string, patchFn: (m: Record<string, unknown>, e: MemoryEntry) => Record<string, unknown>) {
168
+ const e = stored.find(x => x.id === id);
169
+ if (!e) return null;
170
+ patchedIds.push(id);
171
+ e.metadata = JSON.stringify(patchFn(JSON.parse(e.metadata || "{}"), e));
172
+ return e;
173
+ },
174
+ async patchMetadataBatch(
175
+ patches: Array<{ id: string; patchFn: (m: Record<string, unknown>, e: MemoryEntry) => Record<string, unknown> }>,
176
+ ) {
177
+ let written = 0;
178
+ for (const { id, patchFn } of patches) {
179
+ const e = stored.find(x => x.id === id);
180
+ if (!e) continue;
181
+ patchedIds.push(id);
182
+ e.metadata = JSON.stringify(patchFn(JSON.parse(e.metadata || "{}"), e));
183
+ written++;
184
+ }
185
+ return written;
186
+ },
187
+ async vectorSearch(vec: number[], limit = 5, minScore = 0.3, scopeFilter?: string[], scopeMatch: ScopeMatchMode = "family") {
188
+ return select(scopeFilter, scopeMatch)
189
+ .filter(e => e.vector?.length)
190
+ .map(e => ({ entry: e, score: cosineSimilarity(vec, e.vector) }))
191
+ .filter(r => r.score >= minScore)
192
+ .sort((a, b) => b.score - a.score)
193
+ .slice(0, limit);
194
+ },
195
+ };
196
+
197
+ return { store: store as unknown as MemoryStorePort, stored, patchedIds };
198
+ }
199
+
200
+ function entry(over: Partial<MemoryEntry> & { id: string; scope: string }): MemoryEntry {
201
+ return {
202
+ text: over.text ?? `text-${over.id}`,
203
+ vector: over.vector ?? [1, 0, 0],
204
+ category: over.category ?? "events",
205
+ importance: over.importance ?? 0.7,
206
+ timestamp: over.timestamp ?? Date.now(),
207
+ metadata: over.metadata ?? JSON.stringify({ evolution: { status: "active", accessCount: 0 } }),
208
+ ...over,
209
+ } as MemoryEntry;
210
+ }
211
+
212
+ const mockLLM = {
213
+ async generateL0() { return "an insight"; },
214
+ async extractPattern() { return "a pattern"; },
215
+ } as unknown as LLMClient;
216
+
217
+ const mockEmbedder: Pick<Embedder, "embedPassage"> = {
218
+ async embedPassage() { return [1, 0, 0]; },
219
+ };
220
+
221
+ describe("dream scope isolation (exact mode threading)", () => {
222
+ it("exact 模式下 pivot 行既不进 insight 来源,也不被 patch", async () => {
223
+ const memRows = Array.from({ length: 4 }, (_, i) =>
224
+ entry({ id: `mem-${i}`, scope: "memory", text: `memory rule chunk ${i}`, vector: [1, 0.02 * i, 0] }));
225
+ const pivotRows = Array.from({ length: 3 }, (_, i) =>
226
+ entry({ id: `pivot-${i}`, scope: "memory:pivot", text: `hand-written pivot ${i}`, vector: [1, 0.01 * i, 0] }));
227
+ const { store, stored, patchedIds } = createScopeAwareStore([...memRows, ...pivotRows]);
228
+ const stats = tempStatsPath();
229
+
230
+ await runDream({
231
+ store, llm: mockLLM, embedder: mockEmbedder, scope: "memory",
232
+ force: true, scopeMatch: "exact", activityStatsPath: stats.statsPath,
233
+ });
234
+
235
+ // 任何新写入的派生行,其 sourceMemories 都不许含 pivot id
236
+ const derivedSources = stored
237
+ .map(e => { try { return JSON.parse(e.metadata || "{}"); } catch { return {}; } })
238
+ .filter((m: Record<string, unknown>) => m.cluster_insight === true || m.cross_memory_pattern === true)
239
+ .flatMap((m: any) => (m?.evolution?.sourceMemories ?? []) as string[]);
240
+ expect(derivedSources.filter(id => id.startsWith("pivot-"))).toEqual([]);
241
+
242
+ // 也不许有任何 pivot 行被改写 metadata
243
+ expect(patchedIds.filter(id => id.startsWith("pivot-"))).toEqual([]);
244
+ });
245
+
246
+ it("family 模式下 pivot 行会被卷进来 —— 这是修复前的行为,留作对照", async () => {
247
+ const memRows = Array.from({ length: 3 }, (_, i) =>
248
+ entry({ id: `mem-${i}`, scope: "memory", vector: [1, 0.01 * i, 0] }));
249
+ const pivotRows = Array.from({ length: 3 }, (_, i) =>
250
+ entry({ id: `pivot-${i}`, scope: "memory:pivot", vector: [1, 0.01 * i, 0] }));
251
+ const { store } = createScopeAwareStore([...memRows, ...pivotRows]);
252
+
253
+ // 只验取数面:family 下 gather 能看到 6 行,exact 下只有 3 行。
254
+ expect((await store.list(["memory"], undefined, 500, 0)).length).toBe(6);
255
+ expect((await store.list(["memory"], undefined, 500, 0, "exact")).length).toBe(3);
256
+ });
257
+ });
258
+
259
+ describe("neverDreamScopes policy gate", () => {
260
+ it("memory:pivot 被策略排除,force 也不越过", async () => {
261
+ const { store, stored, patchedIds } = createScopeAwareStore(
262
+ Array.from({ length: 5 }, (_, i) => entry({ id: `pivot-${i}`, scope: "memory:pivot", vector: [1, 0.01 * i, 0] })),
263
+ );
264
+ const before = stored.length;
265
+
266
+ const result = await runDream({
267
+ store, llm: mockLLM, embedder: mockEmbedder,
268
+ scope: "memory:pivot", force: true, scopeMatch: "exact",
269
+ activityStatsPath: tempStatsPath().statsPath,
270
+ });
271
+
272
+ expect(result.ran).toBe(false);
273
+ expect(result.output.kind).toBe("skipped");
274
+ expect(result.output.reason).toBe("policy_excluded");
275
+ expect(result.output.effectsWritten).toBe(0);
276
+ expect(stored.length).toBe(before); // 一行都没新写
277
+ expect(patchedIds).toEqual([]); // 一行都没改
278
+ });
279
+
280
+ it("默认名单里就有 memory:pivot(改这条要连同 open-loops 的翻盘条件一起改)", () => {
281
+ expect(DEFAULT_DREAM_CONFIG.neverDreamScopes).toContain("memory:pivot");
282
+ // 与 autoExcludeScopes 是两份名单,语义不同,不许合并
283
+ expect(DEFAULT_DREAM_CONFIG.autoExcludeScopes).not.toContain("memory:pivot");
284
+ });
285
+
286
+ it("不在名单里的 scope 照常跑(策略闸不误伤)", async () => {
287
+ const { store } = createScopeAwareStore(
288
+ Array.from({ length: 4 }, (_, i) => entry({ id: `p-${i}`, scope: "project:normal", vector: [1, 0.01 * i, 0] })),
289
+ );
290
+ const result = await runDream({
291
+ store, llm: mockLLM, embedder: mockEmbedder,
292
+ scope: "project:normal", force: true, scopeMatch: "exact",
293
+ activityStatsPath: tempStatsPath().statsPath,
294
+ });
295
+ expect(result.ran).toBe(true);
296
+ expect(result.output.reason).not.toBe("policy_excluded");
297
+ });
298
+ });
299
+
300
+ describe("3a: derived insights never participate in dedupe", () => {
301
+ it("LLM 派生行不当 canonical,也不把手写原件合并掉", async () => {
302
+ // 复刻 2026-08-16 实测的形状:一条手写原件 + 一条它的 LLM 复述,向量几乎重合、
303
+ // importance 与 accessCount 都打平 —— 修复前 canonicalScore 平局,派生行赢过原件。
304
+ const original = entry({
305
+ id: "hand-written",
306
+ scope: "memory:pivot",
307
+ text: "决定暂缓提交第二个 PR,坚持一次一个 PR 策略,理由是架构级变更需确认维护者意图。",
308
+ vector: [1, 0, 0],
309
+ importance: 0.7,
310
+ metadata: JSON.stringify({
311
+ evolution: { status: "active", accessCount: 0 },
312
+ canonicalKey: "pivot-decision-sequential-pr-submission",
313
+ tags: ["pinned", "src:c98b9988"],
314
+ }),
315
+ });
316
+ const derived = entry({
317
+ id: "llm-restatement",
318
+ scope: "memory:pivot",
319
+ text: "暂缓提交第二个 PR,等待首个 PR 审查反馈。",
320
+ vector: [1, 0.001, 0],
321
+ importance: 0.7,
322
+ metadata: JSON.stringify({
323
+ evolution: { status: "active", accessCount: 0, sourceMemories: ["hand-written"] },
324
+ cluster_insight: true,
325
+ }),
326
+ });
327
+ const { store, stored } = createScopeAwareStore([original, derived]);
328
+
329
+ const engine = new ConsolidationEngine(store, {
330
+ ...DEFAULT_CONSOLIDATION_CONFIG,
331
+ scopeMatch: "exact",
332
+ });
333
+ const result = await engine.run("memory:pivot");
334
+
335
+ expect(result.mergedCount).toBe(0);
336
+ const statusOf = (id: string) => {
337
+ const row = stored.find(e => e.id === id)!;
338
+ return (JSON.parse(row.metadata || "{}") as any)?.evolution?.status;
339
+ };
340
+ expect(statusOf("hand-written")).toBe("active"); // 原件没被顶掉
341
+ expect(statusOf("llm-restatement")).toBe("active"); // 派生行也没被动,只是不参与
342
+ });
343
+
344
+ it("两条同为原始记忆时仍正常合并(排除派生 ≠ 关掉去重)", async () => {
345
+ const a = entry({
346
+ id: "dup-a", scope: "project:dedupe", text: "构建命令是 bun run build", vector: [1, 0, 0], importance: 0.8,
347
+ });
348
+ const b = entry({
349
+ id: "dup-b", scope: "project:dedupe", text: "构建命令为 bun run build", vector: [1, 0.001, 0], importance: 0.5,
350
+ });
351
+ const { store, stored } = createScopeAwareStore([a, b]);
352
+
353
+ const engine = new ConsolidationEngine(store, { ...DEFAULT_CONSOLIDATION_CONFIG, scopeMatch: "exact" });
354
+ const result = await engine.run("project:dedupe");
355
+
356
+ expect(result.mergedCount).toBe(1);
357
+ const loser = stored.find(e => e.id === "dup-b")!;
358
+ expect((JSON.parse(loser.metadata || "{}") as any)?.evolution?.status).toBe("consolidated");
359
+ });
360
+ });
361
+
362
+ describe("--auto 的 activity-stats key 一律 exact(回归锚点)", () => {
363
+ it("无冒号 key 在 exact 下不再吞掉整个家族", async () => {
364
+ // 实测 activity-stats 里有 11 个无冒号 key(cc / global / memory / manual …)。
365
+ // `cc` 一旦达标,family 语义会以 LIKE 'cc%' 把 2026-08-14 刚出队的 transcript
366
+ // 原文层整批捞回 —— 这条测试锁住"不会再发生"。
367
+ const { store } = createScopeAwareStore([
368
+ entry({ id: "cc-durable", scope: "cc" }),
369
+ entry({ id: "cc-transcript-1", scope: "cc:019f0f70" }),
370
+ entry({ id: "cc-transcript-2", scope: "cc:019f60e2" }),
371
+ ]);
372
+ expect((await store.list(["cc"], undefined, 100, 0)).length).toBe(3);
373
+ expect((await store.list(["cc"], undefined, 100, 0, "exact")).map(r => r.id)).toEqual(["cc-durable"]);
374
+ });
375
+
376
+ it("写计数按具体 scope 记,pivot 与 memory 各记各的(不碰生产 stats 文件)", async () => {
377
+ const cfg = tempStatsPath();
378
+ await incrementWriteCount("memory", 3, cfg);
379
+ await incrementWriteCount("memory:pivot", 5, cfg);
380
+ expect(getWriteCount("memory", cfg)).toBe(3);
381
+ expect(getWriteCount("memory:pivot", cfg)).toBe(5);
382
+ await resetWriteCount("memory", cfg);
383
+ expect(getWriteCount("memory", cfg)).toBe(0);
384
+ expect(getWriteCount("memory:pivot", cfg)).toBe(5); // 兄弟 scope 不受牵连
385
+ });
386
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * 2026-08-14 dream 写爆库根治的写入层回归:
3
+ * - store.patchMetadataBatch:真 LanceDB 实例上的批量读改写语义(锁内最新行起底、
4
+ * 按序应用、scope 过滤、单条失败跳过)
5
+ * - activity-counter.pruneWriteCounts:transcript 计数清扫(含未达标条目)
6
+ * - auto-gc 批量归档:maxArchivePerRun 命中时不弃单(flush 后再停)
7
+ */
8
+ import { afterAll, describe, expect, it } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { MemoryStore, type MemoryEntry } from "../store.js";
14
+ import { incrementWriteCount, getWriteCount, pruneWriteCounts, listScopesAboveThreshold } from "../activity-counter.js";
15
+ import { isTranscriptScope } from "../memory-boundaries.js";
16
+ import { maybeRunGc, resetGcTimestamp } from "../auto-gc.js";
17
+
18
+ const tmpDirs: string[] = [];
19
+ afterAll(() => {
20
+ for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
21
+ });
22
+
23
+ function makeIsolatedStore(): { store: MemoryStore; statsPath: string } {
24
+ const dir = mkdtempSync(join(tmpdir(), "rn-batch-"));
25
+ tmpDirs.push(dir);
26
+ return { store: new MemoryStore({ dbPath: join(dir, "lancedb"), vectorDim: 3 }), statsPath: join(dir, "activity-stats.json") };
27
+ }
28
+
29
+ describe("store.patchMetadataBatch(真 LanceDB)", () => {
30
+ it("一次调用批量写 N 行,最终态各自生效", async () => {
31
+ const { store } = makeIsolatedStore();
32
+ const scope = "project:batch";
33
+ const a = await store.store({ text: "row a", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
34
+ const b = await store.store({ text: "row b", vector: [0, 1, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
35
+
36
+ const written = await store.patchMetadataBatch([
37
+ { id: a.id, patchFn: meta => ({ ...meta, marker: "A" }) },
38
+ { id: b.id, patchFn: meta => ({ ...meta, marker: "B" }) },
39
+ ], [scope]);
40
+
41
+ expect(written).toBe(2);
42
+ expect(JSON.parse((await store.getById(a.id))!.metadata!).marker).toBe("A");
43
+ expect(JSON.parse((await store.getById(b.id))!.metadata!).marker).toBe("B");
44
+ });
45
+
46
+ it("patchFn 以库中最新行起底:批量写不吞前一次独立 update 写入的键", async () => {
47
+ const { store } = makeIsolatedStore();
48
+ const scope = "project:fresh";
49
+ const a = await store.store({ text: "row", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
50
+ // 先经独立通道写入一个键(模拟 3a 已提交的 version_group)
51
+ await store.update(a.id, { metadata: JSON.stringify({ version_group: "vg-keep" }) }, [scope]);
52
+ // 批量 patch 只声明自己的键 —— 旧"预物化整串"接口在这里会把 version_group 抹掉
53
+ await store.patchMetadataBatch([
54
+ { id: a.id, patchFn: meta => ({ ...meta, consolidatedInto: "x" }) },
55
+ ], [scope]);
56
+ const final = JSON.parse((await store.getById(a.id))!.metadata!);
57
+ expect(final.version_group).toBe("vg-keep");
58
+ expect(final.consolidatedInto).toBe("x");
59
+ });
60
+
61
+ it("同批 patchFn 按数组序执行(闭包可传递决策)", async () => {
62
+ const { store } = makeIsolatedStore();
63
+ const scope = "project:seq";
64
+ const a = await store.store({ text: "first", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
65
+ const b = await store.store({ text: "second", vector: [0, 1, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
66
+ let decided: string | null = null;
67
+ await store.patchMetadataBatch([
68
+ { id: a.id, patchFn: meta => { decided = "from-first"; return { ...meta, tag: "lead" }; } },
69
+ { id: b.id, patchFn: meta => ({ ...meta, tag: decided }) },
70
+ ], [scope]);
71
+ expect(JSON.parse((await store.getById(b.id))!.metadata!).tag).toBe("from-first");
72
+ });
73
+
74
+ it("scope 过滤拒绝越权行、不存在的 id 跳过、空数组返回 0", async () => {
75
+ const { store } = makeIsolatedStore();
76
+ const a = await store.store({ text: "in scope", vector: [1, 0, 0], category: "events", scope: "project:x", importance: 0.5, metadata: "{}" });
77
+ const written = await store.patchMetadataBatch([
78
+ { id: a.id, patchFn: meta => ({ ...meta, touched: true }) },
79
+ { id: "00000000-0000-4000-8000-000000000000", patchFn: meta => meta },
80
+ ], ["project:other"]); // a 不在此 scope
81
+ expect(written).toBe(0);
82
+ expect(JSON.parse((await store.getById(a.id))!.metadata!).touched).toBeUndefined();
83
+ expect(await store.patchMetadataBatch([], ["project:x"])).toBe(0);
84
+ });
85
+
86
+ it("单条 patchFn 抛错跳过该条,其余照写", async () => {
87
+ const { store } = makeIsolatedStore();
88
+ const scope = "project:err";
89
+ const a = await store.store({ text: "bad", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
90
+ const b = await store.store({ text: "good", vector: [0, 1, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
91
+ const written = await store.patchMetadataBatch([
92
+ { id: a.id, patchFn: () => { throw new Error("boom"); } },
93
+ { id: b.id, patchFn: meta => ({ ...meta, ok: true }) },
94
+ ], [scope]);
95
+ expect(written).toBe(1);
96
+ expect(JSON.parse((await store.getById(b.id))!.metadata!).ok).toBe(true);
97
+ expect(JSON.parse((await store.getById(a.id))!.metadata!).ok).toBeUndefined();
98
+ });
99
+ });
100
+
101
+ describe("pruneWriteCounts(transcript 计数清扫)", () => {
102
+ it("清掉全部 transcript 条目(达标与未达标),durable 一律保留", async () => {
103
+ const dir = mkdtempSync(join(tmpdir(), "rn-prune-"));
104
+ tmpDirs.push(dir);
105
+ const cfg = { statsPath: join(dir, "activity-stats.json") };
106
+ await incrementWriteCount("codex:aaaa1111", 15, cfg); // transcript 达标
107
+ await incrementWriteCount("kimi:bbbb2222", 3, cfg); // transcript 未达标
108
+ await incrementWriteCount("project:harness", 12, cfg); // durable 达标
109
+ await incrementWriteCount("memory", 2, cfg); // durable 未达标
110
+
111
+ const removed = await pruneWriteCounts(isTranscriptScope, cfg);
112
+
113
+ expect(removed.sort()).toEqual(["codex:aaaa1111", "kimi:bbbb2222"]);
114
+ expect(getWriteCount("codex:aaaa1111", cfg)).toBe(0);
115
+ expect(getWriteCount("kimi:bbbb2222", cfg)).toBe(0);
116
+ expect(getWriteCount("project:harness", cfg)).toBe(12);
117
+ expect(getWriteCount("memory", cfg)).toBe(2);
118
+ expect(listScopesAboveThreshold(10, cfg)).toEqual(["project:harness"]);
119
+ });
120
+ });
121
+
122
+ describe("auto-gc 批量归档的上限语义", () => {
123
+ it("maxArchivePerRun 命中时已选中的候选照常提交(flush 不弃单)", async () => {
124
+ resetGcTimestamp();
125
+ const oldTs = Date.now() - 120 * 86_400_000;
126
+ const activeMeta = (): string => JSON.stringify({
127
+ evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, validFrom: oldTs, validUntil: null },
128
+ });
129
+ const rows: Array<{ id: string; text: string; importance: number; timestamp: number; metadata: string; category: string; scope: string }> = [
130
+ { id: "g1", text: "old1", importance: 0.1, timestamp: oldTs, metadata: activeMeta(), category: "events", scope: "project:gc" },
131
+ { id: "g2", text: "old2", importance: 0.1, timestamp: oldTs, metadata: activeMeta(), category: "events", scope: "project:gc" },
132
+ { id: "g3", text: "old3", importance: 0.1, timestamp: oldTs, metadata: activeMeta(), category: "events", scope: "project:gc" },
133
+ ];
134
+ const batchCalls: number[] = [];
135
+ const store = {
136
+ async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
137
+ async repairSingletonVersionGroups() { return 0; },
138
+ async list() { return rows; },
139
+ async listPage(opts: { limit?: number; offset?: number } = {}) {
140
+ const { limit = 1000, offset = 0 } = opts;
141
+ return rows.slice(offset, offset + limit);
142
+ },
143
+ async update() { return null; },
144
+ async patchMetadataBatch(
145
+ patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: unknown) => Record<string, unknown> }>,
146
+ ) {
147
+ batchCalls.push(patches.length);
148
+ for (const { id, patchFn } of patches) {
149
+ const row = rows.find(r => r.id === id);
150
+ if (!row) continue;
151
+ row.metadata = JSON.stringify(patchFn(JSON.parse(row.metadata) as Record<string, unknown>, row));
152
+ }
153
+ return patches.length;
154
+ },
155
+ };
156
+
157
+ const result = await maybeRunGc(
158
+ store as unknown as Parameters<typeof maybeRunGc>[0],
159
+ { minMemoryCount: 1, minHoursSinceLastGc: 0, decayScoreThreshold: 0.99, maxArchivePerRun: 2, minAgeDays: 30 },
160
+ );
161
+
162
+ expect(result.archivedCount).toBe(2); // 上限生效
163
+ const archived = rows.filter(r => (JSON.parse(r.metadata) as { evolution: { status: string } }).evolution.status === "archived");
164
+ expect(archived.length).toBe(2); // 且真的写进去了(break 前 flush,不弃单)
165
+ expect(batchCalls.reduce((a, b) => a + b, 0)).toBe(2);
166
+ });
167
+
168
+ it("即使低于归档数量门槛也会自愈 singleton version-group", async () => {
169
+ resetGcTimestamp();
170
+ let repairCalls = 0;
171
+ const store = {
172
+ async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
173
+ async repairSingletonVersionGroups() { repairCalls++; return 1; },
174
+ async listPage() { return []; },
175
+ async patchMetadataBatch() { return 0; },
176
+ };
177
+
178
+ const result = await maybeRunGc(
179
+ store as unknown as Parameters<typeof maybeRunGc>[0],
180
+ { minMemoryCount: 1000, minHoursSinceLastGc: 0, decayScoreThreshold: 0.15, maxArchivePerRun: 100, minAgeDays: 30 },
181
+ );
182
+
183
+ expect(repairCalls).toBe(1);
184
+ expect(result.triggered).toBe(false);
185
+ expect(result.reason).toBe("below_memory_threshold");
186
+ expect(result.dissolvedVersionGroups).toBe(1);
187
+ });
188
+
189
+ it("单成员组自愈有独立节流,不会被每个 dream scope 重复全库扫描", async () => {
190
+ resetGcTimestamp();
191
+ let repairCalls = 0;
192
+ const store = {
193
+ async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
194
+ async repairSingletonVersionGroups() { repairCalls++; return 0; },
195
+ async listPage() { return []; },
196
+ async patchMetadataBatch() { return 0; },
197
+ };
198
+ const config = {
199
+ minMemoryCount: 1000,
200
+ minHoursSinceLastGc: 24,
201
+ decayScoreThreshold: 0.15,
202
+ maxArchivePerRun: 100,
203
+ minAgeDays: 30,
204
+ };
205
+
206
+ await maybeRunGc(store as unknown as Parameters<typeof maybeRunGc>[0], config);
207
+ await maybeRunGc(store as unknown as Parameters<typeof maybeRunGc>[0], config);
208
+
209
+ expect(repairCalls).toBe(1);
210
+ });
211
+ });