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.
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 +7 -5
  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
@@ -20,7 +20,7 @@ const testConfig: Partial<ActivityCounterConfig> = {
20
20
  const A = "cc:project:a";
21
21
  const B = "cc:project:b";
22
22
 
23
- describe("activity-counter (HP-3, per-scope)", () => {
23
+ describe("activity-counter (HP-3, per-scope)", async () => {
24
24
  beforeEach(() => {
25
25
  mkdirSync(TMP_DIR, { recursive: true });
26
26
  const p = testConfig.statsPath!;
@@ -31,95 +31,95 @@ describe("activity-counter (HP-3, per-scope)", () => {
31
31
  rmSync(TMP_DIR, { recursive: true, force: true });
32
32
  });
33
33
 
34
- describe("incrementWriteCount", () => {
35
- it("starts at 0 and increments by 1 for a scope", () => {
34
+ describe("incrementWriteCount", async () => {
35
+ it("starts at 0 and increments by 1 for a scope", async () => {
36
36
  expect(getWriteCount(A, testConfig)).toBe(0);
37
- expect(incrementWriteCount(A, 1, testConfig)).toBe(1);
38
- expect(incrementWriteCount(A, 1, testConfig)).toBe(2);
37
+ expect(await incrementWriteCount(A, 1, testConfig)).toBe(1);
38
+ expect(await incrementWriteCount(A, 1, testConfig)).toBe(2);
39
39
  expect(getWriteCount(A, testConfig)).toBe(2);
40
40
  });
41
41
 
42
- it("increments by arbitrary n", () => {
43
- incrementWriteCount(A, 5, testConfig);
42
+ it("increments by arbitrary n", async () => {
43
+ await incrementWriteCount(A, 5, testConfig);
44
44
  expect(getWriteCount(A, testConfig)).toBe(5);
45
- incrementWriteCount(A, 3, testConfig);
45
+ await incrementWriteCount(A, 3, testConfig);
46
46
  expect(getWriteCount(A, testConfig)).toBe(8);
47
47
  });
48
48
 
49
- it("counts each scope independently", () => {
50
- incrementWriteCount(A, 4, testConfig);
51
- incrementWriteCount(B, 1, testConfig);
49
+ it("counts each scope independently", async () => {
50
+ await incrementWriteCount(A, 4, testConfig);
51
+ await incrementWriteCount(B, 1, testConfig);
52
52
  expect(getWriteCount(A, testConfig)).toBe(4);
53
53
  expect(getWriteCount(B, testConfig)).toBe(1);
54
54
  });
55
55
  });
56
56
 
57
- describe("resetWriteCount", () => {
58
- it("resets only the given scope, leaving others intact", () => {
59
- incrementWriteCount(A, 7, testConfig);
60
- incrementWriteCount(B, 5, testConfig);
61
- resetWriteCount(A, testConfig);
57
+ describe("resetWriteCount", async () => {
58
+ it("resets only the given scope, leaving others intact", async () => {
59
+ await incrementWriteCount(A, 7, testConfig);
60
+ await incrementWriteCount(B, 5, testConfig);
61
+ await resetWriteCount(A, testConfig);
62
62
  expect(getWriteCount(A, testConfig)).toBe(0);
63
63
  expect(getWriteCount(B, testConfig)).toBe(5); // not starved by A's reset
64
64
  });
65
65
  });
66
66
 
67
- describe("listScopesAboveThreshold", () => {
68
- it("returns scopes at or above the threshold only", () => {
69
- incrementWriteCount(A, 12, testConfig);
70
- incrementWriteCount(B, 4, testConfig);
71
- incrementWriteCount("cc:project:c", 10, testConfig);
67
+ describe("listScopesAboveThreshold", async () => {
68
+ it("returns scopes at or above the threshold only", async () => {
69
+ await incrementWriteCount(A, 12, testConfig);
70
+ await incrementWriteCount(B, 4, testConfig);
71
+ await incrementWriteCount("cc:project:c", 10, testConfig);
72
72
  const above = listScopesAboveThreshold(10, testConfig).sort();
73
73
  expect(above).toEqual(["cc:project:a", "cc:project:c"]);
74
74
  });
75
75
 
76
- it("returns empty when no scope qualifies", () => {
77
- incrementWriteCount(A, 2, testConfig);
76
+ it("returns empty when no scope qualifies", async () => {
77
+ await incrementWriteCount(A, 2, testConfig);
78
78
  expect(listScopesAboveThreshold(10, testConfig)).toEqual([]);
79
79
  });
80
80
  });
81
81
 
82
- describe("getDistillTier", () => {
83
- it("returns 'none' when below light threshold", () => {
84
- incrementWriteCount(A, 2, testConfig);
82
+ describe("getDistillTier", async () => {
83
+ it("returns 'none' when below light threshold", async () => {
84
+ await incrementWriteCount(A, 2, testConfig);
85
85
  expect(getDistillTier(A, testConfig)).toBe("none");
86
86
  });
87
87
 
88
- it("returns 'light' at light threshold", () => {
89
- incrementWriteCount(A, 3, testConfig);
88
+ it("returns 'light' at light threshold", async () => {
89
+ await incrementWriteCount(A, 3, testConfig);
90
90
  expect(getDistillTier(A, testConfig)).toBe("light");
91
91
  });
92
92
 
93
- it("returns 'standard' at standard threshold", () => {
94
- incrementWriteCount(A, 10, testConfig);
93
+ it("returns 'standard' at standard threshold", async () => {
94
+ await incrementWriteCount(A, 10, testConfig);
95
95
  expect(getDistillTier(A, testConfig)).toBe("standard");
96
96
  });
97
97
 
98
- it("returns 'deep' at and above deep threshold", () => {
99
- incrementWriteCount(A, 20, testConfig);
98
+ it("returns 'deep' at and above deep threshold", async () => {
99
+ await incrementWriteCount(A, 20, testConfig);
100
100
  expect(getDistillTier(A, testConfig)).toBe("deep");
101
- incrementWriteCount(A, 100, testConfig);
101
+ await incrementWriteCount(A, 100, testConfig);
102
102
  expect(getDistillTier(A, testConfig)).toBe("deep");
103
103
  });
104
104
  });
105
105
 
106
- describe("resilience", () => {
107
- it("handles missing stats file gracefully", () => {
106
+ describe("resilience", async () => {
107
+ it("handles missing stats file gracefully", async () => {
108
108
  expect(getWriteCount(A, testConfig)).toBe(0);
109
109
  expect(getDistillTier(A, testConfig)).toBe("none");
110
110
  expect(listScopesAboveThreshold(1, testConfig)).toEqual([]);
111
111
  });
112
112
 
113
- it("handles corrupt stats file gracefully", () => {
113
+ it("handles corrupt stats file gracefully", async () => {
114
114
  writeFileSync(testConfig.statsPath!, "not-json{{{");
115
115
  expect(getWriteCount(A, testConfig)).toBe(0);
116
116
  });
117
117
 
118
- it("treats the legacy global format as empty (no migration)", () => {
118
+ it("treats the legacy global format as empty (no migration)", async () => {
119
119
  writeFileSync(testConfig.statsPath!, JSON.stringify({ writesSinceLastDistill: 42, lastResetAt: 1 }));
120
120
  expect(getWriteCount(A, testConfig)).toBe(0);
121
121
  // first increment starts a fresh per-scope map
122
- expect(incrementWriteCount(A, 1, testConfig)).toBe(1);
122
+ expect(await incrementWriteCount(A, 1, testConfig)).toBe(1);
123
123
  });
124
124
  });
125
125
  });
@@ -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
+ });