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
@@ -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
- function createMockStore() {
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, "generateL0" | "extractPattern"> {
105
+ ): Pick<LLMClient, "synthesizeClusterInsight" | "synthesizeClusterPattern"> {
74
106
  return {
75
- async generateL0(text: string) {
76
- return insightFn(text);
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 extractPattern(texts: string[]) {
79
- return patternFn ? patternFn(texts) : null;
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, "generateL0" | "extractPattern">;
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 generateL0(_text: string): Promise<string | null> {
397
+ async synthesizeClusterInsight(_texts: string[]): Promise<SynthesisVerdict> {
330
398
  throw new Error("Request was aborted.");
331
399
  },
332
- async extractPattern(_texts: string[]): Promise<string | null> {
333
- return null;
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 generateL0(_text: string): Promise<string | null> {
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 extractPattern(_texts: string[]): Promise<string | null> {
377
- return null;
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 generateL0(_text: string): Promise<string | null> {
410
- return "insight 正常产出";
477
+ async synthesizeClusterInsight(_texts: string[]): Promise<SynthesisVerdict> {
478
+ return synthOk("insight 正常产出");
411
479
  },
412
- async extractPattern(_texts: string[]): Promise<string | null> {
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 43 registerTool(...) calls now live in the tier modules; mcp-server.ts
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(43);
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() { return entries.map(e => ({ ...e, vector: [] })); },
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([]),