recallnest 2.6.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +131 -14
- package/README_CN.md +121 -17
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +7 -5
- package/src/__tests__/access-tracker-novelty.test.ts +22 -2
- package/src/__tests__/activity-counter.test.ts +38 -38
- package/src/__tests__/age-label.test.ts +33 -0
- package/src/__tests__/cluster-consolidation.test.ts +313 -22
- package/src/__tests__/consolidation-engine.test.ts +90 -0
- package/src/__tests__/contracts.test.ts +2 -2
- package/src/__tests__/data-checkup.test.ts +31 -3
- package/src/__tests__/dream-pipeline.test.ts +41 -1
- package/src/__tests__/dream-scope-isolation.test.ts +386 -0
- package/src/__tests__/dream-write-batching.test.ts +211 -0
- package/src/__tests__/env-config-invariant.test.ts +80 -0
- package/src/__tests__/env-config.test.ts +60 -0
- package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
- package/src/__tests__/maintenance-locks.test.ts +8 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
- package/src/__tests__/memory-output.test.ts +9 -0
- package/src/__tests__/memory-utility.test.ts +256 -0
- package/src/__tests__/openai-contract.test.ts +513 -0
- package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
- package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
- package/src/__tests__/source-heartbeat.test.ts +6 -2
- package/src/__tests__/store-patch-metadata.test.ts +30 -0
- package/src/__tests__/synthesis-contract.test.ts +252 -0
- package/src/__tests__/synthesis-promotion.test.ts +369 -0
- package/src/__tests__/trusted-memory.test.ts +28 -0
- package/src/__tests__/utility-weight.test.ts +57 -0
- package/src/__tests__/verbatim-self-recall.test.ts +279 -0
- package/src/__tests__/version-manager.test.ts +35 -0
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +20 -5
- package/src/env-config.ts +53 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +23 -5
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +12 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/scope-policy.ts +22 -1
- package/src/session-output.ts +4 -3
- package/src/skill-promotion.ts +152 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
|
@@ -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
|
-
|
|
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
|
+
});
|