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.
- 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 +5 -4
- 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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { AGE_UNKNOWN, formatAgeLabel, formatIsoAgeLabel } from "../age-label.js";
|
|
4
|
+
|
|
5
|
+
const DAY = 86_400_000;
|
|
6
|
+
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
|
7
|
+
|
|
8
|
+
describe("age label", () => {
|
|
9
|
+
it("renders whole days, floored", () => {
|
|
10
|
+
expect(formatAgeLabel(NOW, NOW)).toBe("0d");
|
|
11
|
+
expect(formatAgeLabel(NOW - 23 * 3_600_000, NOW)).toBe("0d");
|
|
12
|
+
expect(formatAgeLabel(NOW - 1 * DAY, NOW)).toBe("1d");
|
|
13
|
+
expect(formatAgeLabel(NOW - 14 * DAY - 5 * 3_600_000, NOW)).toBe("14d");
|
|
14
|
+
expect(formatAgeLabel(NOW - 400 * DAY, NOW)).toBe("400d");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reads a future stamp as today, not a negative age", () => {
|
|
18
|
+
expect(formatAgeLabel(NOW + 3 * DAY, NOW)).toBe("0d");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not fabricate an age for unusable stamps", () => {
|
|
22
|
+
expect(formatAgeLabel(0, NOW)).toBe(AGE_UNKNOWN);
|
|
23
|
+
expect(formatAgeLabel(undefined, NOW)).toBe(AGE_UNKNOWN);
|
|
24
|
+
expect(formatAgeLabel(Number.NaN, NOW)).toBe(AGE_UNKNOWN);
|
|
25
|
+
expect(formatIsoAgeLabel("not-a-date", NOW)).toBe(AGE_UNKNOWN);
|
|
26
|
+
expect(formatIsoAgeLabel("", NOW)).toBe(AGE_UNKNOWN);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("accepts ISO strings the same way", () => {
|
|
30
|
+
expect(formatIsoAgeLabel("2026-08-03T09:30:00.000Z", NOW)).toBe("14d");
|
|
31
|
+
expect(formatIsoAgeLabel("2026-08-17T00:00:00.000Z", NOW)).toBe("0d");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -3,10 +3,13 @@ import { describe, expect, it } from "bun:test";
|
|
|
3
3
|
import {
|
|
4
4
|
clusterAndConsolidate,
|
|
5
5
|
deduplicateByClusterInsight,
|
|
6
|
+
SYNTHESIS_CONTRACT_VERSION,
|
|
6
7
|
type ClusterConsolidationResult,
|
|
7
8
|
} from "../consolidation-engine.js";
|
|
9
|
+
import { extractBoundaryMetadata } from "../memory-boundaries.js";
|
|
8
10
|
import type { MemoryEntry, MemoryStore } from "../store.js";
|
|
9
11
|
import type { LLMClient } from "../llm-client.js";
|
|
12
|
+
import type { SynthesisVerdict } from "../synthesis-contract.js";
|
|
10
13
|
import type { Embedder } from "../embedder.js";
|
|
11
14
|
import { cosineSimilarity } from "../multi-vector.js";
|
|
12
15
|
|
|
@@ -38,13 +41,21 @@ function makeEntry(overrides: Partial<MemoryEntry> & { id: string; text: string
|
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
/** Create a mock store that tracks store() and update() calls
|
|
42
|
-
|
|
44
|
+
/** Create a mock store that tracks store() and update() calls.
|
|
45
|
+
* 可选 seed:模拟"库中已有行"的 metadata(patchMetadataBatch 的生产语义是以库中
|
|
46
|
+
* 最新行起底应用 patchFn——不给 seed 时起底为空对象)。 */
|
|
47
|
+
function createMockStore(seed: MemoryEntry[] = []) {
|
|
43
48
|
const stored: MemoryEntry[] = [];
|
|
44
49
|
const updates: Array<{ id: string; metadata: string }> = [];
|
|
50
|
+
// 2026-08-14: patchMetadataBatch 的最新态表 —— patchFn 以上一批的结果起底,
|
|
51
|
+
// 忠实模拟生产语义(锁内最新行起底、批间叠加而非互抹)。
|
|
52
|
+
const metaById = new Map<string, Record<string, unknown>>();
|
|
53
|
+
for (const e of seed) {
|
|
54
|
+
try { metaById.set(e.id, JSON.parse(e.metadata || "{}") as Record<string, unknown>); } catch { /* skip bad seed */ }
|
|
55
|
+
}
|
|
45
56
|
let storeCounter = 0;
|
|
46
57
|
|
|
47
|
-
const store: Pick<MemoryStore, "store" | "update"> = {
|
|
58
|
+
const store: Pick<MemoryStore, "store" | "update" | "patchMetadataBatch"> = {
|
|
48
59
|
async store(entry) {
|
|
49
60
|
const full: MemoryEntry = {
|
|
50
61
|
...entry,
|
|
@@ -61,24 +72,47 @@ function createMockStore() {
|
|
|
61
72
|
}
|
|
62
73
|
return { id, text: "", vector: [], category: "events", scope: "project:test", importance: 0.5, timestamp: Date.now(), metadata: upd.metadata || "{}" } as MemoryEntry;
|
|
63
74
|
},
|
|
75
|
+
async patchMetadataBatch(patches, _scopeFilter?) {
|
|
76
|
+
for (const { id, patchFn } of patches) {
|
|
77
|
+
const current = metaById.get(id) ?? {};
|
|
78
|
+
const entry = { id, text: "", vector: [], category: "events", scope: "project:test", importance: 0.5, timestamp: Date.now(), metadata: JSON.stringify(current) } as MemoryEntry;
|
|
79
|
+
const patched = patchFn(current, entry);
|
|
80
|
+
metaById.set(id, patched);
|
|
81
|
+
updates.push({ id, metadata: JSON.stringify(patched) });
|
|
82
|
+
}
|
|
83
|
+
return patches.length;
|
|
84
|
+
},
|
|
64
85
|
};
|
|
65
86
|
|
|
66
|
-
return { store, stored, updates };
|
|
87
|
+
return { store, stored, updates, metaById };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a mock LLM that returns a fixed insight string (or null to simulate abstention).
|
|
92
|
+
*
|
|
93
|
+
* 2026-08-23:合成接口从「返回 string|null」换成 `SynthesisVerdict`(产出 / 弃权 /
|
|
94
|
+
* 校验不过三态)。这个 helper 保留旧的字符串签名,把 null 映射成 `abstained` ——
|
|
95
|
+
* 既有 30 多条断言测的是聚类与写库行为,不该因为合成签名换代而全部重写。
|
|
96
|
+
* 三态本身另有直接断言(见文件末尾「合成契约」一节)。
|
|
97
|
+
*/
|
|
98
|
+
function synthOk(text: string, evidence: number[] = [1, 2]): SynthesisVerdict {
|
|
99
|
+
return { status: "ok", output: { text, evidence } };
|
|
67
100
|
}
|
|
68
101
|
|
|
69
|
-
/** Create a mock LLM that returns a fixed insight string (or null to simulate failure) */
|
|
70
102
|
function createMockLLM(
|
|
71
103
|
insightFn: (text: string) => string | null,
|
|
72
104
|
patternFn?: (texts: string[]) => string | null,
|
|
73
|
-
): Pick<LLMClient, "
|
|
105
|
+
): Pick<LLMClient, "synthesizeClusterInsight" | "synthesizeClusterPattern"> {
|
|
74
106
|
return {
|
|
75
|
-
async
|
|
76
|
-
|
|
107
|
+
async synthesizeClusterInsight(texts: string[]) {
|
|
108
|
+
const out = insightFn(texts.join("\n---\n"));
|
|
109
|
+
return out === null ? { status: "abstained" } : synthOk(out);
|
|
77
110
|
},
|
|
78
|
-
async
|
|
79
|
-
|
|
111
|
+
async synthesizeClusterPattern(texts: string[]) {
|
|
112
|
+
const out = patternFn ? patternFn(texts) : null;
|
|
113
|
+
return out === null ? { status: "abstained" } : synthOk(out);
|
|
80
114
|
},
|
|
81
|
-
} as Pick<LLMClient, "
|
|
115
|
+
} as Pick<LLMClient, "synthesizeClusterInsight" | "synthesizeClusterPattern">;
|
|
82
116
|
}
|
|
83
117
|
|
|
84
118
|
/** Create a mock embedder that returns a fixed vector */
|
|
@@ -147,7 +181,7 @@ describe("clusterAndConsolidate", () => {
|
|
|
147
181
|
makeEntry({ id: "c", text: "TypeScript improves developer experience", vector: [0.92, 0.08, 0], importance: 0.6 }),
|
|
148
182
|
];
|
|
149
183
|
|
|
150
|
-
const { store, stored, updates } = createMockStore();
|
|
184
|
+
const { store, stored, updates } = createMockStore(entries);
|
|
151
185
|
const llm = createMockLLM(() => "TypeScript benefits for development");
|
|
152
186
|
const embedder = createMockEmbedder([0.5, 0.5, 0]);
|
|
153
187
|
|
|
@@ -187,6 +221,37 @@ describe("clusterAndConsolidate", () => {
|
|
|
187
221
|
}
|
|
188
222
|
});
|
|
189
223
|
|
|
224
|
+
// 2026-08-14 Bug-2 回归:旧路径 insight 源回写与 pattern 源回写都从同一个旧内存串
|
|
225
|
+
// 起底 patch,后者会把前者刚写库的 consolidatedInto 抹掉——凡走到 pattern 抽取的
|
|
226
|
+
// cluster,源条目的 consolidatedInto 全丢。新路径两段批量都以最新态起底,叠加共存。
|
|
227
|
+
it("Bug-2 回归:pattern 源回写后 consolidatedInto 与 contributedToPattern 并存(最终态)", async () => {
|
|
228
|
+
const entries = [
|
|
229
|
+
makeEntry({ id: "a", text: "TypeScript is great", vector: [0.9, 0.1, 0], importance: 0.8 }),
|
|
230
|
+
makeEntry({ id: "b", text: "TypeScript is safe", vector: [0.88, 0.12, 0], importance: 0.7 }),
|
|
231
|
+
makeEntry({ id: "c", text: "TypeScript is productive", vector: [0.92, 0.08, 0], importance: 0.6 }),
|
|
232
|
+
];
|
|
233
|
+
const { store, stored, metaById } = createMockStore(entries);
|
|
234
|
+
const llm = createMockLLM(() => "TS insight", () => "TS pattern");
|
|
235
|
+
const embedder = createMockEmbedder([0.5, 0.5, 0]);
|
|
236
|
+
|
|
237
|
+
const result = await clusterAndConsolidate({
|
|
238
|
+
entries, embedder, llm: llm as unknown as LLMClient, store,
|
|
239
|
+
scope: "project:test", minClusterSize: 3, clusterThreshold: 0.75,
|
|
240
|
+
extractPatterns: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result.insightsGenerated).toBe(1);
|
|
244
|
+
expect(result.patternsExtracted).toBe(1);
|
|
245
|
+
const insight = stored.find(e => JSON.parse(e.metadata!).cluster_insight === true)!;
|
|
246
|
+
const pattern = stored.find(e => JSON.parse(e.metadata!).cross_memory_pattern === true)!;
|
|
247
|
+
for (const id of ["a", "b", "c"]) {
|
|
248
|
+
const final = metaById.get(id) as { evolution: Record<string, unknown> };
|
|
249
|
+
expect(final.evolution.consolidatedInto).toBe(insight.id); // 旧路径这里被 pattern 批抹成 undefined
|
|
250
|
+
expect(final.evolution.contributedToPattern).toBe(pattern.id);
|
|
251
|
+
expect(final.evolution.status).toBe("active"); // 3b 不动 status
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
190
255
|
// P0-2/P1-2: insight/pattern idempotency — deterministic id from source cluster ids.
|
|
191
256
|
it("re-running on the same cluster yields a stable, deterministic insight id", async () => {
|
|
192
257
|
const entries = [
|
|
@@ -238,6 +303,9 @@ describe("clusterAndConsolidate", () => {
|
|
|
238
303
|
async update(id, upd) {
|
|
239
304
|
return { id, text: "", vector: [], category: "events", scope: "project:test", importance: 0.5, timestamp: Date.now(), metadata: upd.metadata || "{}" } as MemoryEntry;
|
|
240
305
|
},
|
|
306
|
+
async patchMetadataBatch(patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown> }>) {
|
|
307
|
+
return patches.length; // 本用例只断言 insight 幂等,源回写记录不参与断言
|
|
308
|
+
},
|
|
241
309
|
};
|
|
242
310
|
|
|
243
311
|
const params = {
|
|
@@ -326,11 +394,11 @@ describe("clusterAndConsolidate", () => {
|
|
|
326
394
|
|
|
327
395
|
const { store, stored } = createMockStore();
|
|
328
396
|
const llm = {
|
|
329
|
-
async
|
|
397
|
+
async synthesizeClusterInsight(_texts: string[]): Promise<SynthesisVerdict> {
|
|
330
398
|
throw new Error("Request was aborted.");
|
|
331
399
|
},
|
|
332
|
-
async
|
|
333
|
-
return
|
|
400
|
+
async synthesizeClusterPattern(_texts: string[]): Promise<SynthesisVerdict> {
|
|
401
|
+
return { status: "abstained" };
|
|
334
402
|
},
|
|
335
403
|
};
|
|
336
404
|
const embedder = createMockEmbedder();
|
|
@@ -368,13 +436,13 @@ describe("clusterAndConsolidate", () => {
|
|
|
368
436
|
const { store, stored } = createMockStore();
|
|
369
437
|
let call = 0;
|
|
370
438
|
const llm = {
|
|
371
|
-
async
|
|
439
|
+
async synthesizeClusterInsight(_texts: string[]): Promise<SynthesisVerdict> {
|
|
372
440
|
call++;
|
|
373
441
|
if (call === 1) throw new Error("Request was aborted.");
|
|
374
|
-
return "第二个簇的 insight";
|
|
442
|
+
return synthOk("第二个簇的 insight");
|
|
375
443
|
},
|
|
376
|
-
async
|
|
377
|
-
return
|
|
444
|
+
async synthesizeClusterPattern(_texts: string[]): Promise<SynthesisVerdict> {
|
|
445
|
+
return { status: "abstained" };
|
|
378
446
|
},
|
|
379
447
|
};
|
|
380
448
|
const embedder = createMockEmbedder();
|
|
@@ -406,10 +474,10 @@ describe("clusterAndConsolidate", () => {
|
|
|
406
474
|
|
|
407
475
|
const { store, stored } = createMockStore();
|
|
408
476
|
const llm = {
|
|
409
|
-
async
|
|
410
|
-
return "insight 正常产出";
|
|
477
|
+
async synthesizeClusterInsight(_texts: string[]): Promise<SynthesisVerdict> {
|
|
478
|
+
return synthOk("insight 正常产出");
|
|
411
479
|
},
|
|
412
|
-
async
|
|
480
|
+
async synthesizeClusterPattern(_texts: string[]): Promise<SynthesisVerdict> {
|
|
413
481
|
throw new Error("Request was aborted.");
|
|
414
482
|
},
|
|
415
483
|
};
|
|
@@ -943,3 +1011,226 @@ describe("deduplicateByClusterInsight (LC-P2)", () => {
|
|
|
943
1011
|
expect(deduped.length).toBe(2); // Both pass through
|
|
944
1012
|
});
|
|
945
1013
|
});
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// 2026-08-23 合成契约上线带来的三件行为改动
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
/** 造一个三成员簇(向量足够接近,一定聚成一簇)。 */
|
|
1020
|
+
function trioEntries(): MemoryEntry[] {
|
|
1021
|
+
return [
|
|
1022
|
+
makeEntry({ id: "a", text: "记录 A", vector: [0.9, 0.1, 0], importance: 0.8 }),
|
|
1023
|
+
makeEntry({ id: "b", text: "记录 B", vector: [0.88, 0.12, 0], importance: 0.7 }),
|
|
1024
|
+
makeEntry({ id: "c", text: "记录 C", vector: [0.92, 0.08, 0], importance: 0.6 }),
|
|
1025
|
+
];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const trioParams = { minClusterSize: 3, clusterThreshold: 0.75 };
|
|
1029
|
+
|
|
1030
|
+
describe("合成弃权路径(老 generateL0 完全没有这条路)", () => {
|
|
1031
|
+
it("模型判定「没料可炼」时不写库,且记进 synthesisAbstained", async () => {
|
|
1032
|
+
const { store, stored } = createMockStore();
|
|
1033
|
+
const llm = {
|
|
1034
|
+
async synthesizeClusterInsight(): Promise<SynthesisVerdict> { return { status: "abstained" }; },
|
|
1035
|
+
async synthesizeClusterPattern(): Promise<SynthesisVerdict> { return { status: "abstained" }; },
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
const result = await clusterAndConsolidate({
|
|
1039
|
+
entries: trioEntries(),
|
|
1040
|
+
embedder: createMockEmbedder(),
|
|
1041
|
+
llm: llm as unknown as LLMClient,
|
|
1042
|
+
store,
|
|
1043
|
+
scope: "project:test",
|
|
1044
|
+
extractPatterns: true,
|
|
1045
|
+
...trioParams,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
expect(stored.length).toBe(0);
|
|
1049
|
+
expect(result.insightsGenerated).toBe(0);
|
|
1050
|
+
expect(result.patternsExtracted).toBe(0);
|
|
1051
|
+
expect(result.synthesisAbstained).toBe(2); // insight + pattern 各一次
|
|
1052
|
+
expect(result.synthesisRejected).toBe(0);
|
|
1053
|
+
expect(result.llmFailures).toBe(0); // 弃权不是失败
|
|
1054
|
+
expect(result.clustersConsolidated).toBe(1); // 簇确实处理过
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("校验不过的输出不写库,且与弃权分开计数", async () => {
|
|
1058
|
+
const { store, stored } = createMockStore();
|
|
1059
|
+
const llm = {
|
|
1060
|
+
async synthesizeClusterInsight(): Promise<SynthesisVerdict> {
|
|
1061
|
+
return { status: "rejected", reason: "prompt-echo" };
|
|
1062
|
+
},
|
|
1063
|
+
async synthesizeClusterPattern(): Promise<SynthesisVerdict> {
|
|
1064
|
+
return { status: "rejected", reason: "evidence-too-few" };
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const result = await clusterAndConsolidate({
|
|
1069
|
+
entries: trioEntries(),
|
|
1070
|
+
embedder: createMockEmbedder(),
|
|
1071
|
+
llm: llm as unknown as LLMClient,
|
|
1072
|
+
store,
|
|
1073
|
+
scope: "project:test",
|
|
1074
|
+
extractPatterns: true,
|
|
1075
|
+
...trioParams,
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
expect(stored.length).toBe(0);
|
|
1079
|
+
expect(result.synthesisRejected).toBe(2);
|
|
1080
|
+
expect(result.synthesisAbstained).toBe(0);
|
|
1081
|
+
expect(result.llmFailures).toBe(0); // 拿到回复了,只是不合契约——不是调用失败
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
describe("pattern 与 insight 解耦(R5)", () => {
|
|
1086
|
+
it("insight 弃权时 pattern 照常抽取——改前这簇的 pattern 根本不会被执行", async () => {
|
|
1087
|
+
const { store, stored } = createMockStore();
|
|
1088
|
+
const llm = {
|
|
1089
|
+
async synthesizeClusterInsight(): Promise<SynthesisVerdict> { return { status: "abstained" }; },
|
|
1090
|
+
async synthesizeClusterPattern(): Promise<SynthesisVerdict> {
|
|
1091
|
+
return { status: "ok", output: { text: "跨条目结论", evidence: [1, 2] } };
|
|
1092
|
+
},
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const result = await clusterAndConsolidate({
|
|
1096
|
+
entries: trioEntries(),
|
|
1097
|
+
embedder: createMockEmbedder(),
|
|
1098
|
+
llm: llm as unknown as LLMClient,
|
|
1099
|
+
store,
|
|
1100
|
+
scope: "project:test",
|
|
1101
|
+
extractPatterns: true,
|
|
1102
|
+
...trioParams,
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
expect(result.insightsGenerated).toBe(0);
|
|
1106
|
+
expect(result.patternsExtracted).toBe(1);
|
|
1107
|
+
expect(stored.length).toBe(1);
|
|
1108
|
+
expect(stored[0].text).toBe("跨条目结论");
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it("insight 抛异常时 pattern 仍然跑(两者独立失败)", async () => {
|
|
1112
|
+
const { store } = createMockStore();
|
|
1113
|
+
const llm = {
|
|
1114
|
+
async synthesizeClusterInsight(): Promise<SynthesisVerdict> { throw new Error("Request was aborted."); },
|
|
1115
|
+
async synthesizeClusterPattern(): Promise<SynthesisVerdict> {
|
|
1116
|
+
return { status: "ok", output: { text: "跨条目结论", evidence: [1, 2] } };
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
const result = await clusterAndConsolidate({
|
|
1121
|
+
entries: trioEntries(),
|
|
1122
|
+
embedder: createMockEmbedder(),
|
|
1123
|
+
llm: llm as unknown as LLMClient,
|
|
1124
|
+
store,
|
|
1125
|
+
scope: "project:test",
|
|
1126
|
+
extractPatterns: true,
|
|
1127
|
+
...trioParams,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
expect(result.llmFailures).toBe(1);
|
|
1131
|
+
expect(result.insightsGenerated).toBe(0);
|
|
1132
|
+
expect(result.patternsExtracted).toBe(1);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it("只有 pattern 产出的一轮不算零产出轮次(否则早停会提前触发)", async () => {
|
|
1136
|
+
// 6 个成员分成两簇,insight 全弃权、pattern 全产出。
|
|
1137
|
+
// 若沿用旧判据(没有 insight = 零产出),连续两簇就会触发 diminishing_returns 早停。
|
|
1138
|
+
const entries = [
|
|
1139
|
+
makeEntry({ id: "a1", text: "簇一 A", vector: [0.9, 0.1, 0] }),
|
|
1140
|
+
makeEntry({ id: "a2", text: "簇一 B", vector: [0.88, 0.12, 0] }),
|
|
1141
|
+
makeEntry({ id: "a3", text: "簇一 C", vector: [0.92, 0.08, 0] }),
|
|
1142
|
+
makeEntry({ id: "b1", text: "簇二 A", vector: [0, 0.9, 0.1] }),
|
|
1143
|
+
makeEntry({ id: "b2", text: "簇二 B", vector: [0, 0.88, 0.12] }),
|
|
1144
|
+
makeEntry({ id: "b3", text: "簇二 C", vector: [0, 0.92, 0.08] }),
|
|
1145
|
+
];
|
|
1146
|
+
const { store } = createMockStore();
|
|
1147
|
+
const llm = {
|
|
1148
|
+
async synthesizeClusterInsight(): Promise<SynthesisVerdict> { return { status: "abstained" }; },
|
|
1149
|
+
async synthesizeClusterPattern(): Promise<SynthesisVerdict> {
|
|
1150
|
+
return { status: "ok", output: { text: "跨条目结论", evidence: [1, 2] } };
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const result = await clusterAndConsolidate({
|
|
1155
|
+
entries,
|
|
1156
|
+
embedder: createMockEmbedder(),
|
|
1157
|
+
llm: llm as unknown as LLMClient,
|
|
1158
|
+
store,
|
|
1159
|
+
scope: "project:test",
|
|
1160
|
+
extractPatterns: true,
|
|
1161
|
+
...trioParams,
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
expect(result.clustersFound).toBe(2);
|
|
1165
|
+
expect(result.patternsExtracted).toBe(2);
|
|
1166
|
+
expect(result.earlyStop).toBeUndefined();
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
describe("派生物的 boundary 层级(补的是「靠 scope 命名侥幸判对」那一刀)", () => {
|
|
1171
|
+
it("insight 与 pattern 都显式落 evidence 层,不靠 scope 名字推断", async () => {
|
|
1172
|
+
const { store, stored } = createMockStore();
|
|
1173
|
+
const llm = createMockLLM(() => "一条结论", () => "一条跨条目结论");
|
|
1174
|
+
|
|
1175
|
+
await clusterAndConsolidate({
|
|
1176
|
+
entries: trioEntries(),
|
|
1177
|
+
embedder: createMockEmbedder(),
|
|
1178
|
+
llm: llm as unknown as LLMClient,
|
|
1179
|
+
store,
|
|
1180
|
+
// 刻意用非 transcript scope:改前这里会静默拿到 durable 权威
|
|
1181
|
+
scope: "project:test",
|
|
1182
|
+
extractPatterns: true,
|
|
1183
|
+
...trioParams,
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
expect(stored.length).toBe(2);
|
|
1187
|
+
for (const row of stored) {
|
|
1188
|
+
const meta = JSON.parse(row.metadata!) as { boundary?: { layer?: string; authority?: string } };
|
|
1189
|
+
expect(meta.boundary?.layer).toBe("evidence");
|
|
1190
|
+
expect(meta.boundary?.authority).toBe("distillation");
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("检索侧读得到这个 boundary(与 extractBoundaryMetadata 对得上,不是写了个没人认的字段)", async () => {
|
|
1195
|
+
const { store, stored } = createMockStore();
|
|
1196
|
+
const llm = createMockLLM(() => "一条结论");
|
|
1197
|
+
|
|
1198
|
+
await clusterAndConsolidate({
|
|
1199
|
+
entries: trioEntries(),
|
|
1200
|
+
embedder: createMockEmbedder(),
|
|
1201
|
+
llm: llm as unknown as LLMClient,
|
|
1202
|
+
store,
|
|
1203
|
+
scope: "project:test",
|
|
1204
|
+
...trioParams,
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
// coerceBoundaryMetadata 对 layer/authority/conflictPolicy 三项都做白名单校验,
|
|
1208
|
+
// 任一项写错就整个返回 null —— 所以这条断言同时验了三项都合法。
|
|
1209
|
+
const boundary = extractBoundaryMetadata(stored[0].metadata);
|
|
1210
|
+
expect(boundary).not.toBeNull();
|
|
1211
|
+
expect(boundary!.layer).toBe("evidence");
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it("evidence 编号被翻译成真实的源记忆 id 存下来", async () => {
|
|
1215
|
+
const { store, stored } = createMockStore();
|
|
1216
|
+
const llm = {
|
|
1217
|
+
async synthesizeClusterInsight(): Promise<SynthesisVerdict> {
|
|
1218
|
+
return { status: "ok", output: { text: "一条结论", evidence: [1, 3] } };
|
|
1219
|
+
},
|
|
1220
|
+
async synthesizeClusterPattern(): Promise<SynthesisVerdict> { return { status: "abstained" }; },
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
await clusterAndConsolidate({
|
|
1224
|
+
entries: trioEntries(),
|
|
1225
|
+
embedder: createMockEmbedder(),
|
|
1226
|
+
llm: llm as unknown as LLMClient,
|
|
1227
|
+
store,
|
|
1228
|
+
scope: "project:test",
|
|
1229
|
+
...trioParams,
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
const meta = JSON.parse(stored[0].metadata!) as { evidenceMemories?: string[]; synthesis_contract?: number };
|
|
1233
|
+
expect(meta.evidenceMemories).toEqual(["a", "c"]); // 1-based → 第 1、第 3 个成员
|
|
1234
|
+
expect(meta.synthesis_contract).toBe(SYNTHESIS_CONTRACT_VERSION);
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
@@ -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([]),
|