recallnest 2.6.0 → 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.
- package/CHANGELOG.md +191 -0
- package/README.md +131 -14
- package/README_CN.md +121 -17
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +7 -5
- package/src/__tests__/access-tracker-novelty.test.ts +22 -2
- package/src/__tests__/activity-counter.test.ts +38 -38
- package/src/__tests__/age-label.test.ts +33 -0
- package/src/__tests__/cluster-consolidation.test.ts +313 -22
- package/src/__tests__/consolidation-engine.test.ts +90 -0
- package/src/__tests__/contracts.test.ts +2 -2
- package/src/__tests__/data-checkup.test.ts +31 -3
- package/src/__tests__/dream-pipeline.test.ts +41 -1
- package/src/__tests__/dream-scope-isolation.test.ts +386 -0
- package/src/__tests__/dream-write-batching.test.ts +211 -0
- package/src/__tests__/env-config-invariant.test.ts +80 -0
- package/src/__tests__/env-config.test.ts +60 -0
- package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
- package/src/__tests__/maintenance-locks.test.ts +8 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
- package/src/__tests__/memory-output.test.ts +9 -0
- package/src/__tests__/memory-utility.test.ts +256 -0
- package/src/__tests__/openai-contract.test.ts +513 -0
- package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
- package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
- package/src/__tests__/source-heartbeat.test.ts +6 -2
- package/src/__tests__/store-patch-metadata.test.ts +30 -0
- package/src/__tests__/synthesis-contract.test.ts +252 -0
- package/src/__tests__/synthesis-promotion.test.ts +369 -0
- package/src/__tests__/trusted-memory.test.ts +28 -0
- package/src/__tests__/utility-weight.test.ts +57 -0
- package/src/__tests__/verbatim-self-recall.test.ts +279 -0
- package/src/__tests__/version-manager.test.ts +35 -0
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +20 -5
- package/src/env-config.ts +53 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +23 -5
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +12 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/scope-policy.ts +22 -1
- package/src/session-output.ts +4 -3
- package/src/skill-promotion.ts +152 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
|
@@ -59,6 +59,25 @@ function createMockStore(entries: MemoryEntry[], similarityMap: Map<string, Map<
|
|
|
59
59
|
}
|
|
60
60
|
return entry;
|
|
61
61
|
},
|
|
62
|
+
// 2026-08-14: 忠实模拟生产语义 —— 以 mock 存储的最新行起底应用 patchFn、按序执行、
|
|
63
|
+
// 就地落库。批量写同样进 updates 数组,既有的 updates.find() 断言无需改动。
|
|
64
|
+
async patchMetadataBatch(
|
|
65
|
+
patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown> }>,
|
|
66
|
+
_scopeFilter?: string[],
|
|
67
|
+
) {
|
|
68
|
+
let written = 0;
|
|
69
|
+
for (const { id, patchFn } of patches) {
|
|
70
|
+
const entry = data.get(id);
|
|
71
|
+
if (!entry) continue;
|
|
72
|
+
let meta: Record<string, unknown>;
|
|
73
|
+
try { meta = JSON.parse(entry.metadata || "{}") as Record<string, unknown>; } catch { meta = {}; }
|
|
74
|
+
const patched = patchFn(meta, entry);
|
|
75
|
+
entry.metadata = JSON.stringify(patched);
|
|
76
|
+
updates.push({ id, metadata: entry.metadata });
|
|
77
|
+
written++;
|
|
78
|
+
}
|
|
79
|
+
return written;
|
|
80
|
+
},
|
|
62
81
|
},
|
|
63
82
|
};
|
|
64
83
|
}
|
|
@@ -81,6 +100,77 @@ describe("ConsolidationEngine", () => {
|
|
|
81
100
|
expect(result.clustersFound).toBe(0);
|
|
82
101
|
});
|
|
83
102
|
|
|
103
|
+
// 2026-08-14 Bug-1 回归:旧逐条写路径里 cluster 循环基于从不刷新的旧内存串 patch,
|
|
104
|
+
// member 的 consolidatedMeta 写与 canonical 的 sourceMemories 写会把 createVersionGroup
|
|
105
|
+
// 刚落库的 version_group 抹掉(2 成员 cluster 同样全丢),下一个 member 还会新造 groupId。
|
|
106
|
+
// 断言口径是**最终存储态**(getById 终值)——旧测试用 updates.find() 只看首写,恰好
|
|
107
|
+
// 看不见覆盖(互审 C3 指出的断言形态缺陷)。
|
|
108
|
+
it("Bug-1 回归:2 成员 merge 后双方 version_group 同组共存,sourceMemories/consolidatedInto 齐全", async () => {
|
|
109
|
+
const entryA = makeEntry({ id: "a", text: "canonical fact", vector: [1, 0, 0], importance: 0.9 });
|
|
110
|
+
const entryB = makeEntry({ id: "b", text: "duplicate fact", vector: [0.99, 0.1, 0], importance: 0.5 });
|
|
111
|
+
const simMap = new Map([
|
|
112
|
+
["a", new Map([["b", 0.95]])],
|
|
113
|
+
["b", new Map([["a", 0.95]])],
|
|
114
|
+
]);
|
|
115
|
+
const { store } = createMockStore([entryA, entryB], simMap);
|
|
116
|
+
const engine = new ConsolidationEngine(store, { ...DEFAULT_CONSOLIDATION_CONFIG, mergeThreshold: 0.92 });
|
|
117
|
+
await engine.run("project:test");
|
|
118
|
+
|
|
119
|
+
const finalA = JSON.parse((await store.getById("a"))!.metadata!) as Record<string, any>;
|
|
120
|
+
const finalB = JSON.parse((await store.getById("b"))!.metadata!) as Record<string, any>;
|
|
121
|
+
expect(typeof finalA.version_group).toBe("string");
|
|
122
|
+
expect(finalB.version_group).toBe(finalA.version_group); // 同组,且都没被后续写抹掉
|
|
123
|
+
expect(typeof finalA.version_rank).toBe("number");
|
|
124
|
+
expect(typeof finalB.version_rank).toBe("number");
|
|
125
|
+
expect(finalA.evolution.sourceMemories).toContain("b");
|
|
126
|
+
expect(finalB.evolution.status).toBe("consolidated");
|
|
127
|
+
expect(finalB.evolution.consolidatedInto).toBe("a");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("Bug-1 回归:多 member merge 共享同一个 groupId,canonical 的 sourceMemories 不丢前面的 member", async () => {
|
|
131
|
+
const entryA = makeEntry({ id: "a", text: "fact v1", vector: [1, 0, 0], importance: 0.9 });
|
|
132
|
+
const entryB = makeEntry({ id: "b", text: "fact v2", vector: [0.99, 0.1, 0], importance: 0.5 });
|
|
133
|
+
const entryC = makeEntry({ id: "c", text: "fact v3", vector: [0.98, 0.15, 0], importance: 0.4 });
|
|
134
|
+
const simMap = new Map([
|
|
135
|
+
["a", new Map([["b", 0.95], ["c", 0.94]])],
|
|
136
|
+
["b", new Map([["a", 0.95], ["c", 0.93]])],
|
|
137
|
+
["c", new Map([["a", 0.94], ["b", 0.93]])],
|
|
138
|
+
]);
|
|
139
|
+
const { store } = createMockStore([entryA, entryB, entryC], simMap);
|
|
140
|
+
const engine = new ConsolidationEngine(store, { ...DEFAULT_CONSOLIDATION_CONFIG, mergeThreshold: 0.92 });
|
|
141
|
+
await engine.run("project:test");
|
|
142
|
+
|
|
143
|
+
const finalA = JSON.parse((await store.getById("a"))!.metadata!) as Record<string, any>;
|
|
144
|
+
const finalB = JSON.parse((await store.getById("b"))!.metadata!) as Record<string, any>;
|
|
145
|
+
const finalC = JSON.parse((await store.getById("c"))!.metadata!) as Record<string, any>;
|
|
146
|
+
expect(typeof finalA.version_group).toBe("string");
|
|
147
|
+
expect(finalB.version_group).toBe(finalA.version_group); // 旧路径这里各是一个新组
|
|
148
|
+
expect(finalC.version_group).toBe(finalA.version_group);
|
|
149
|
+
expect(finalA.evolution.sourceMemories).toEqual(expect.arrayContaining(["b", "c"])); // 旧路径只剩最后一个
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("link 分支回归:多 member 时 canonical 的 cluster_members 累积全部(不再只剩最后一个)", async () => {
|
|
153
|
+
const entryA = makeEntry({ id: "a", text: "topic center", vector: [1, 0, 0], importance: 0.9 });
|
|
154
|
+
const entryB = makeEntry({ id: "b", text: "related one", vector: [0.9, 0.3, 0], importance: 0.5 });
|
|
155
|
+
const entryC = makeEntry({ id: "c", text: "related two", vector: [0.88, 0.35, 0], importance: 0.4 });
|
|
156
|
+
// 0.85 落在 clusterThreshold(0.82) 与 mergeThreshold(0.92) 之间 → link 分支
|
|
157
|
+
const simMap = new Map([
|
|
158
|
+
["a", new Map([["b", 0.85], ["c", 0.85]])],
|
|
159
|
+
["b", new Map([["a", 0.85], ["c", 0.84]])],
|
|
160
|
+
["c", new Map([["a", 0.85], ["b", 0.84]])],
|
|
161
|
+
]);
|
|
162
|
+
const { store } = createMockStore([entryA, entryB, entryC], simMap);
|
|
163
|
+
const engine = new ConsolidationEngine(store, { ...DEFAULT_CONSOLIDATION_CONFIG, clusterThreshold: 0.82, mergeThreshold: 0.92 });
|
|
164
|
+
await engine.run("project:test");
|
|
165
|
+
|
|
166
|
+
const finalA = JSON.parse((await store.getById("a"))!.metadata!) as Record<string, any>;
|
|
167
|
+
const finalB = JSON.parse((await store.getById("b"))!.metadata!) as Record<string, any>;
|
|
168
|
+
const finalC = JSON.parse((await store.getById("c"))!.metadata!) as Record<string, any>;
|
|
169
|
+
expect(finalA.cluster_members).toEqual(expect.arrayContaining(["b", "c"]));
|
|
170
|
+
expect(finalB.clustered_with).toBe("a");
|
|
171
|
+
expect(finalC.clustered_with).toBe("a");
|
|
172
|
+
});
|
|
173
|
+
|
|
84
174
|
it("merges near-duplicates above mergeThreshold", async () => {
|
|
85
175
|
const entryA = makeEntry({ id: "a", text: "I prefer TypeScript", vector: [1, 0, 0], importance: 0.9 });
|
|
86
176
|
const entryB = makeEntry({ id: "b", text: "I prefer TypeScript language", vector: [0.99, 0.1, 0], importance: 0.5 });
|
|
@@ -185,7 +185,7 @@ describe("MCP registry contract", () => {
|
|
|
185
185
|
it("keeps registered tool count and tier header in sync", () => {
|
|
186
186
|
const source = readFileSync(join(import.meta.dir, "..", "mcp-server.ts"), "utf8");
|
|
187
187
|
const header = source.slice(0, source.indexOf("const TOOL_TIERS"));
|
|
188
|
-
// P3-B: the
|
|
188
|
+
// P3-B: the 44 registerTool(...) calls now live in the tier modules; mcp-server.ts
|
|
189
189
|
// still owns TOOL_TIERS and the tier-count header comment above it.
|
|
190
190
|
const registeredTools = ["mcp-tools-core", "mcp-tools-advanced", "mcp-tools-governance"]
|
|
191
191
|
.flatMap((name) =>
|
|
@@ -208,7 +208,7 @@ describe("MCP registry contract", () => {
|
|
|
208
208
|
return Number(match![1]);
|
|
209
209
|
};
|
|
210
210
|
|
|
211
|
-
expect(registeredTools).toHaveLength(
|
|
211
|
+
expect(registeredTools).toHaveLength(44);
|
|
212
212
|
expect(missingTier).toEqual([]);
|
|
213
213
|
expect(headerCount("core")).toBe(coreCount);
|
|
214
214
|
expect(headerCount("advanced")).toBe(defaultCount);
|
|
@@ -32,11 +32,17 @@ function makeEntry(overrides: Partial<MemoryEntry> & { id: string }): MemoryEntr
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "stats" | "getVectors"> {
|
|
35
|
+
function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "listPage" | "stats" | "getVectors"> {
|
|
36
36
|
return {
|
|
37
37
|
// 复刻真实 store:list() 为性能不返回向量(vector: []),维度/干扰检查需经 getVectors 补回。
|
|
38
38
|
// 旧 mock 直接返回带向量的 entry,与生产行为不符,曾掩盖维度检查在生产假报健康的 bug。
|
|
39
|
-
async list(
|
|
39
|
+
async list(_scope?: string[], _category?: string, limit = 20, offset = 0) {
|
|
40
|
+
return entries.slice(offset, offset + limit).map(e => ({ ...e, vector: [] }));
|
|
41
|
+
},
|
|
42
|
+
async listPage(opts: { limit?: number; offset?: number } = {}) {
|
|
43
|
+
const { limit = 1000, offset = 0 } = opts;
|
|
44
|
+
return entries.slice(offset, offset + limit).map(e => ({ ...e, vector: [] }));
|
|
45
|
+
},
|
|
40
46
|
async stats() {
|
|
41
47
|
return { totalCount: entries.length, scopeCounts: {}, categoryCounts: {} };
|
|
42
48
|
},
|
|
@@ -47,7 +53,7 @@ function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "st
|
|
|
47
53
|
}
|
|
48
54
|
return map;
|
|
49
55
|
},
|
|
50
|
-
} as Pick<MemoryStore, "list" | "stats" | "getVectors">;
|
|
56
|
+
} as Pick<MemoryStore, "list" | "listPage" | "stats" | "getVectors">;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
// ---------------------------------------------------------------------------
|
|
@@ -242,6 +248,28 @@ describe("runDataCheckup", () => {
|
|
|
242
248
|
expect(vgCheck.detail).toContain("missing rank");
|
|
243
249
|
});
|
|
244
250
|
|
|
251
|
+
it("checks version groups against the full corpus when the 10k sample splits a healthy pair", async () => {
|
|
252
|
+
const pair = ["a", "b"].map(id => makeEntry({
|
|
253
|
+
id,
|
|
254
|
+
metadata: JSON.stringify({ version_group: "vg-split", version_rank: 1 }),
|
|
255
|
+
}));
|
|
256
|
+
const base = createMockStore(pair);
|
|
257
|
+
const store = {
|
|
258
|
+
...base,
|
|
259
|
+
// Simulate the recent-sample boundary: only one member is visible to list(),
|
|
260
|
+
// while the paged full-corpus scan still sees both.
|
|
261
|
+
async list() { return [{ ...pair[0], vector: [] }]; },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const report = await runDataCheckup({ store, openConflictCount: 0 });
|
|
265
|
+
|
|
266
|
+
expect(report.totalEntries).toBe(1);
|
|
267
|
+
expect(report.totalAvailable).toBe(2);
|
|
268
|
+
const vgCheck = report.checks.find(c => c.name === "version_groups")!;
|
|
269
|
+
expect(vgCheck.status).toBe("ok");
|
|
270
|
+
expect(vgCheck.detail).toContain("all healthy");
|
|
271
|
+
});
|
|
272
|
+
|
|
245
273
|
it("handles empty database", async () => {
|
|
246
274
|
const report = await runDataCheckup({
|
|
247
275
|
store: createMockStore([]),
|
|
@@ -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
|
-
|
|
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
|
+
});
|