recallnest 2.5.4 → 2.6.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 +37 -0
- package/README.md +34 -9
- package/README_CN.md +37 -12
- package/package.json +4 -4
- package/src/__tests__/access-tracker-novelty.test.ts +66 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/capture-engine.test.ts +93 -3
- package/src/__tests__/category-min-scores.test.ts +83 -0
- package/src/__tests__/cluster-consolidation.test.ts +167 -4
- package/src/__tests__/conflict-store.test.ts +46 -2
- package/src/__tests__/consolidation-engine.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +46 -0
- package/src/__tests__/decay-exemption.test.ts +45 -0
- package/src/__tests__/dream-pipeline.test.ts +464 -5
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/hotness-blend.test.ts +207 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/maintenance-locks.test.ts +15 -0
- package/src/__tests__/memory-boundaries.test.ts +32 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/pii-redact.test.ts +19 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/preference-matcher.test.ts +54 -2
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/skill-promotion.test.ts +53 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-upsert.test.ts +17 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/access-tracker.ts +53 -3
- package/src/adaptive-retrieval.ts +11 -0
- package/src/api-server.ts +9 -2
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +88 -11
- package/src/cli.ts +109 -14
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +146 -12
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/data-checkup.ts +1 -1
- package/src/decay-engine.ts +59 -7
- package/src/doctor.ts +4 -2
- package/src/dream-pipeline.ts +359 -11
- package/src/env-config.ts +41 -0
- package/src/eval.ts +154 -11
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/ingest.ts +277 -2
- package/src/kg-extractor.ts +7 -1
- package/src/llm-client.ts +106 -11
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +13 -0
- package/src/mcp-tools-core.ts +5 -1
- package/src/memory-boundaries.ts +23 -2
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-output.ts +16 -2
- package/src/memory-store-port.ts +3 -0
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +52 -2
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/recall-gate.ts +305 -0
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +95 -18
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +70 -5
- package/src/skill-promotion.ts +58 -2
- package/src/store.ts +54 -19
- package/src/term-registry.ts +35 -0
- package/src/ui-server.ts +7 -0
- package/src/version.ts +19 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { archiveBeliefVersion } from "../belief-history.js";
|
|
4
|
+
import { persistMemory } from "../capture-engine.js";
|
|
5
|
+
import { isActiveMemory, parseEvolution, traceEvolution } from "../memory-evolution.js";
|
|
6
|
+
|
|
7
|
+
const TEST_SCOPE = "project:test";
|
|
8
|
+
const CANONICAL_KEY = "user.reply.style";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Store double that mirrors the real MemoryStore where belief history depends on it:
|
|
12
|
+
* store() stamps its own id/timestamp, upsert() honours both, and list() strips vectors
|
|
13
|
+
* (which is why archiving has to re-read or re-embed).
|
|
14
|
+
*/
|
|
15
|
+
function createDeps(options: { withUpsert?: boolean; withGetById?: boolean } = {}) {
|
|
16
|
+
const { withUpsert = true, withGetById = true } = options;
|
|
17
|
+
const storedEntries: any[] = [];
|
|
18
|
+
const embedCalls: string[] = [];
|
|
19
|
+
let seq = 1;
|
|
20
|
+
|
|
21
|
+
const store: Record<string, unknown> = {
|
|
22
|
+
async store(entry: any) {
|
|
23
|
+
const stored = {
|
|
24
|
+
...entry,
|
|
25
|
+
id: entry.id ?? `00000000-0000-0000-0000-${String(seq).padStart(12, "0")}`,
|
|
26
|
+
timestamp: 1_700_000_000_000 + seq,
|
|
27
|
+
};
|
|
28
|
+
seq += 1;
|
|
29
|
+
// The real store() routes through upsert (mergeInsert on id), so writing a known id
|
|
30
|
+
// replaces that row rather than adding a second one with the same id.
|
|
31
|
+
const index = storedEntries.findIndex((item) => item.id === stored.id);
|
|
32
|
+
if (index >= 0) storedEntries[index] = stored;
|
|
33
|
+
else storedEntries.push(stored);
|
|
34
|
+
return stored;
|
|
35
|
+
},
|
|
36
|
+
async list(_scopeFilter?: string[], category?: string, limit = 20, offset = 0) {
|
|
37
|
+
return storedEntries
|
|
38
|
+
.filter((entry) => !category || entry.category === category)
|
|
39
|
+
// The real store drops vectors from list results for performance.
|
|
40
|
+
.map((entry) => ({ ...entry, vector: [] }))
|
|
41
|
+
.slice(offset, offset + limit);
|
|
42
|
+
},
|
|
43
|
+
async update(id: string, updates: any) {
|
|
44
|
+
const index = storedEntries.findIndex((entry) => entry.id === id);
|
|
45
|
+
if (index < 0) return null;
|
|
46
|
+
storedEntries[index] = {
|
|
47
|
+
...storedEntries[index],
|
|
48
|
+
...updates,
|
|
49
|
+
timestamp: updates.timestamp ?? storedEntries[index].timestamp,
|
|
50
|
+
};
|
|
51
|
+
return storedEntries[index];
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (withUpsert) {
|
|
56
|
+
store.upsert = async (entry: any) => {
|
|
57
|
+
const index = storedEntries.findIndex((item) => item.id === entry.id);
|
|
58
|
+
if (index >= 0) storedEntries[index] = { ...entry };
|
|
59
|
+
else storedEntries.push({ ...entry });
|
|
60
|
+
return entry;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (withGetById) {
|
|
64
|
+
store.getById = async (id: string) => storedEntries.find((entry) => entry.id === id) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
storedEntries,
|
|
69
|
+
embedCalls,
|
|
70
|
+
deps: {
|
|
71
|
+
store,
|
|
72
|
+
embedder: {
|
|
73
|
+
async embedPassage(text: string) {
|
|
74
|
+
embedCalls.push(text);
|
|
75
|
+
return [text.length, 1, 0];
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function writeBelief(deps: unknown, text: string) {
|
|
83
|
+
return await persistMemory(deps as any, {
|
|
84
|
+
text,
|
|
85
|
+
category: "preferences",
|
|
86
|
+
scope: TEST_SCOPE,
|
|
87
|
+
source: "manual",
|
|
88
|
+
canonicalKey: CANONICAL_KEY,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("belief history", () => {
|
|
93
|
+
it("keeps every superseded version when a belief changes repeatedly", async () => {
|
|
94
|
+
const { deps, storedEntries } = createDeps();
|
|
95
|
+
|
|
96
|
+
const first = await writeBelief(deps, "User prefers long, thorough replies");
|
|
97
|
+
await writeBelief(deps, "User prefers concise replies");
|
|
98
|
+
await writeBelief(deps, "User prefers concise, code-first replies");
|
|
99
|
+
|
|
100
|
+
// One live canonical row + two archived versions — nothing was overwritten away.
|
|
101
|
+
expect(storedEntries).toHaveLength(3);
|
|
102
|
+
|
|
103
|
+
const canonical = storedEntries.find((entry) => entry.id === first.id);
|
|
104
|
+
expect(canonical.text).toBe("User prefers concise, code-first replies");
|
|
105
|
+
expect(parseEvolution(canonical.metadata).status).toBe("active");
|
|
106
|
+
|
|
107
|
+
const archived = storedEntries.filter((entry) => entry.id !== first.id);
|
|
108
|
+
expect(archived.map((entry) => entry.text).sort()).toEqual([
|
|
109
|
+
"User prefers concise replies",
|
|
110
|
+
"User prefers long, thorough replies",
|
|
111
|
+
].sort());
|
|
112
|
+
|
|
113
|
+
for (const row of archived) {
|
|
114
|
+
const evo = parseEvolution(row.metadata);
|
|
115
|
+
expect(evo.status).toBe("superseded");
|
|
116
|
+
expect(evo.supersededBy).toBe(first.id);
|
|
117
|
+
// A closed interval is what makes an as-of query possible at all.
|
|
118
|
+
expect(evo.validUntil).toBeGreaterThan(0);
|
|
119
|
+
expect(evo.validUntil).toBeGreaterThanOrEqual(evo.validFrom);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("links versions into a chain that traceEvolution can walk back through", async () => {
|
|
124
|
+
const { deps, storedEntries } = createDeps();
|
|
125
|
+
|
|
126
|
+
const first = await writeBelief(deps, "Belief version one about reply style");
|
|
127
|
+
await writeBelief(deps, "Belief version two about reply style");
|
|
128
|
+
await writeBelief(deps, "Belief version three about reply style");
|
|
129
|
+
|
|
130
|
+
const byId = new Map(storedEntries.map((entry) => [entry.id, entry]));
|
|
131
|
+
const trace = await traceEvolution(first.id, async (id) => byId.get(id) ?? null);
|
|
132
|
+
|
|
133
|
+
const texts = trace.map((entry) => byId.get(entry.id)?.text);
|
|
134
|
+
expect(texts).toEqual([
|
|
135
|
+
"Belief version one about reply style",
|
|
136
|
+
"Belief version two about reply style",
|
|
137
|
+
"Belief version three about reply style",
|
|
138
|
+
]);
|
|
139
|
+
expect(trace[trace.length - 1].direction).toBe("self");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("keeps archived versions out of default retrieval", async () => {
|
|
143
|
+
const { deps, storedEntries } = createDeps();
|
|
144
|
+
|
|
145
|
+
await writeBelief(deps, "User is based in Guangzhou");
|
|
146
|
+
await writeBelief(deps, "User is based in Singapore");
|
|
147
|
+
|
|
148
|
+
const visibleByDefault = storedEntries.filter((entry) => isActiveMemory(entry.metadata));
|
|
149
|
+
expect(visibleByDefault).toHaveLength(1);
|
|
150
|
+
expect(visibleByDefault[0].text).toBe("User is based in Singapore");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("treats a revert to an earlier wording as a real change, not a duplicate", async () => {
|
|
154
|
+
const { deps, storedEntries } = createDeps();
|
|
155
|
+
|
|
156
|
+
await writeBelief(deps, "User prefers dark mode");
|
|
157
|
+
await writeBelief(deps, "User prefers light mode");
|
|
158
|
+
// Changing back: the archived row still carries the same canonicalKey, so without a
|
|
159
|
+
// status filter on canonical matching this would be misread as a duplicate write.
|
|
160
|
+
const third = await writeBelief(deps, "User prefers dark mode");
|
|
161
|
+
|
|
162
|
+
expect(third.disposition).toBe("updated");
|
|
163
|
+
|
|
164
|
+
const canonical = storedEntries.find((entry) => isActiveMemory(entry.metadata));
|
|
165
|
+
expect(canonical.text).toBe("User prefers dark mode");
|
|
166
|
+
expect(parseEvolution(canonical.metadata).version).toBe(3);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("archives even when the canonical row has fallen out of the scan window", async () => {
|
|
170
|
+
// findCanonicalMatches only sees the most recent CANONICAL_SCAN_LIMIT rows. Past that,
|
|
171
|
+
// the write falls through to store(), which upserts on the deterministic canonical id —
|
|
172
|
+
// historically an unguarded overwrite. Simulate the miss by blinding list().
|
|
173
|
+
const { deps, storedEntries } = createDeps();
|
|
174
|
+
await writeBelief(deps, "User works from the Guangzhou office");
|
|
175
|
+
|
|
176
|
+
(deps.store as any).list = async () => [];
|
|
177
|
+
|
|
178
|
+
await writeBelief(deps, "User works from the Singapore office");
|
|
179
|
+
|
|
180
|
+
expect(storedEntries).toHaveLength(2);
|
|
181
|
+
const archived = storedEntries.find((entry) => !isActiveMemory(entry.metadata));
|
|
182
|
+
expect(archived).toBeDefined();
|
|
183
|
+
expect(archived.text).toBe("User works from the Guangzhou office");
|
|
184
|
+
expect(parseEvolution(archived.metadata).validUntil).toBeGreaterThan(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("re-embeds the archived text when the store cannot return the original vector", async () => {
|
|
188
|
+
const { deps, embedCalls, storedEntries } = createDeps({ withGetById: false });
|
|
189
|
+
|
|
190
|
+
await writeBelief(deps, "Vectorless original");
|
|
191
|
+
await writeBelief(deps, "Replacement belief");
|
|
192
|
+
|
|
193
|
+
const archived = storedEntries.find((entry) => !isActiveMemory(entry.metadata));
|
|
194
|
+
expect(archived.vector.length).toBeGreaterThan(0);
|
|
195
|
+
expect(embedCalls).toContain("Vectorless original");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("fails loudly rather than silently dropping history it cannot archive", async () => {
|
|
199
|
+
const { deps } = createDeps();
|
|
200
|
+
const failing = {
|
|
201
|
+
store: {
|
|
202
|
+
...(deps.store as Record<string, unknown>),
|
|
203
|
+
upsert: async () => { throw new Error("disk full"); },
|
|
204
|
+
store: async () => { throw new Error("disk full"); },
|
|
205
|
+
},
|
|
206
|
+
embedder: deps.embedder,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
await expect(archiveBeliefVersion(failing as any, {
|
|
210
|
+
id: "00000000-0000-0000-0000-000000000001",
|
|
211
|
+
text: "Belief that must not vanish",
|
|
212
|
+
vector: [1, 2, 3],
|
|
213
|
+
category: "preferences",
|
|
214
|
+
scope: TEST_SCOPE,
|
|
215
|
+
importance: 0.8,
|
|
216
|
+
timestamp: 1_700_000_000_001,
|
|
217
|
+
metadata: "{}",
|
|
218
|
+
})).rejects.toThrow("disk full");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import { persistCaseMemory, persistMemory, persistMemoryBatch, persistWorkflowPattern, promoteMemory } from "../capture-engine.js";
|
|
4
|
+
import { parseEvolution } from "../memory-evolution.js";
|
|
4
5
|
|
|
5
6
|
const TEST_SCOPE = "project:test";
|
|
6
7
|
|
|
@@ -44,6 +45,18 @@ function createDeps() {
|
|
|
44
45
|
};
|
|
45
46
|
return storedEntries[index];
|
|
46
47
|
},
|
|
48
|
+
// Mirrors MemoryStore.upsert (mergeInsert on id): honours the caller's id and
|
|
49
|
+
// timestamp verbatim, unlike store() which stamps its own. Belief-history rows
|
|
50
|
+
// depend on both being preserved.
|
|
51
|
+
async upsert(entry: any) {
|
|
52
|
+
const index = storedEntries.findIndex((item) => item.id === entry.id);
|
|
53
|
+
if (index >= 0) {
|
|
54
|
+
storedEntries[index] = { ...entry };
|
|
55
|
+
} else {
|
|
56
|
+
storedEntries.push({ ...entry });
|
|
57
|
+
}
|
|
58
|
+
return entry;
|
|
59
|
+
},
|
|
47
60
|
async getById(id: string) {
|
|
48
61
|
return storedEntries.find((entry) => entry.id === id) || null;
|
|
49
62
|
},
|
|
@@ -244,8 +257,24 @@ describe("persistMemory", () => {
|
|
|
244
257
|
|
|
245
258
|
expect(second.disposition).toBe("updated");
|
|
246
259
|
expect(second.id).toBe(first.id);
|
|
247
|
-
|
|
248
|
-
|
|
260
|
+
|
|
261
|
+
// The canonical row is still updated in place — but the replaced belief is now kept
|
|
262
|
+
// beside it as a superseded history row instead of being overwritten out of existence.
|
|
263
|
+
expect(storedEntries).toHaveLength(2);
|
|
264
|
+
|
|
265
|
+
const canonical = storedEntries.find((entry) => entry.id === first.id);
|
|
266
|
+
expect(canonical.text).toContain("direct technical replies");
|
|
267
|
+
|
|
268
|
+
const history = storedEntries.find((entry) => entry.id !== first.id);
|
|
269
|
+
expect(history.text).toBe("User prefers concise replies");
|
|
270
|
+
|
|
271
|
+
const historyEvo = parseEvolution(history.metadata);
|
|
272
|
+
expect(historyEvo.status).toBe("superseded");
|
|
273
|
+
expect(historyEvo.supersededBy).toBe(first.id);
|
|
274
|
+
expect(historyEvo.validUntil).toBeGreaterThan(0);
|
|
275
|
+
|
|
276
|
+
// Bidirectional link: the live belief points back at the version it replaced.
|
|
277
|
+
expect(parseEvolution(canonical.metadata).supersedes).toBe(history.id);
|
|
249
278
|
});
|
|
250
279
|
|
|
251
280
|
it("updates the same durable owner when the same atomic preference slot is rephrased", async () => {
|
|
@@ -268,7 +297,13 @@ describe("persistMemory", () => {
|
|
|
268
297
|
expect(second.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
|
|
269
298
|
expect(second.disposition).toBe("updated");
|
|
270
299
|
expect(second.id).toBe(first.id);
|
|
271
|
-
|
|
300
|
+
|
|
301
|
+
// Rephrasing the same preference slot still lands on one canonical row, plus the
|
|
302
|
+
// archived copy of the wording it replaced.
|
|
303
|
+
expect(storedEntries).toHaveLength(2);
|
|
304
|
+
const history = storedEntries.find((entry) => entry.id !== first.id);
|
|
305
|
+
expect(history.text).toBe("我喜欢吃麦当劳的麦辣鸡翅");
|
|
306
|
+
expect(parseEvolution(history.metadata).status).toBe("superseded");
|
|
272
307
|
});
|
|
273
308
|
|
|
274
309
|
it("creates a conflict when the same canonicalKey is reused across durable categories", async () => {
|
|
@@ -1236,3 +1271,58 @@ describe("persistMemory — admission rejection reason passthrough", () => {
|
|
|
1236
1271
|
expect(result.rejectionReason).toBeUndefined();
|
|
1237
1272
|
});
|
|
1238
1273
|
});
|
|
1274
|
+
|
|
1275
|
+
describe("text length contract", () => {
|
|
1276
|
+
// persistMemory validates through StoreMemoryInputSchema, whose text field is
|
|
1277
|
+
// capped at 4000 chars. Pinned here because a lossy branch sits further down
|
|
1278
|
+
// the same function (LC-P5: texts over 8000 get replaced by an LLM summary,
|
|
1279
|
+
// or hard-truncated to 8000 when no LLM is available, with no record of the
|
|
1280
|
+
// original). This cap is the only thing keeping that branch unreachable —
|
|
1281
|
+
// oversized writes are rejected outright rather than silently reduced.
|
|
1282
|
+
//
|
|
1283
|
+
// If this test fails because the cap moved past 8000, that branch has gone
|
|
1284
|
+
// live and originals will start being replaced with nothing to trace them
|
|
1285
|
+
// back to. Sort out the large-text branch before updating this test.
|
|
1286
|
+
const MAX_TEXT = 4000;
|
|
1287
|
+
|
|
1288
|
+
it("rejects oversized text outright rather than silently reducing it", async () => {
|
|
1289
|
+
const { deps, storedEntries } = createDeps();
|
|
1290
|
+
|
|
1291
|
+
await expect(persistMemory(deps as any, {
|
|
1292
|
+
text: "x".repeat(MAX_TEXT + 1),
|
|
1293
|
+
category: "events",
|
|
1294
|
+
scope: TEST_SCOPE,
|
|
1295
|
+
source: "manual",
|
|
1296
|
+
})).rejects.toThrow(/at most 4000 characters/);
|
|
1297
|
+
|
|
1298
|
+
expect(storedEntries.length).toBe(0);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it("keeps the lossy large-text branch out of reach", async () => {
|
|
1302
|
+
const { deps } = createDeps();
|
|
1303
|
+
|
|
1304
|
+
// 9000 chars would hit the > 8000 summarise/truncate path if it were
|
|
1305
|
+
// reachable; the schema stops it first.
|
|
1306
|
+
await expect(persistMemory(deps as any, {
|
|
1307
|
+
text: "detail worth keeping. ".repeat(410),
|
|
1308
|
+
category: "events",
|
|
1309
|
+
scope: TEST_SCOPE,
|
|
1310
|
+
source: "manual",
|
|
1311
|
+
})).rejects.toThrow(/at most 4000 characters/);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it("accepts text right at the cap", async () => {
|
|
1315
|
+
const { deps } = createDeps();
|
|
1316
|
+
const text = "a sentence that carries some actual meaning. ".repeat(120).slice(0, MAX_TEXT);
|
|
1317
|
+
expect(text.length).toBe(MAX_TEXT);
|
|
1318
|
+
|
|
1319
|
+
// Schema boundary only — whether admission control later judges the content
|
|
1320
|
+
// is a separate concern; this asserts the cap itself does not reject.
|
|
1321
|
+
await expect(persistMemory(deps as any, {
|
|
1322
|
+
text,
|
|
1323
|
+
category: "events",
|
|
1324
|
+
scope: TEST_SCOPE,
|
|
1325
|
+
source: "manual",
|
|
1326
|
+
})).resolves.toBeDefined();
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_CATEGORY_MIN_SCORES, DEFAULT_RETRIEVAL_CONFIG } from "../retriever.js";
|
|
4
|
+
import { RETRIEVAL_PROFILES, applyRetrievalProfile } from "../retrieval-profiles.js";
|
|
5
|
+
|
|
6
|
+
const ALL_CATEGORIES = ["profile", "preferences", "entities", "events", "cases", "patterns"];
|
|
4
7
|
|
|
5
8
|
describe("DEFAULT_CATEGORY_MIN_SCORES", () => {
|
|
6
9
|
it("has entries for all 6 durable categories", () => {
|
|
@@ -31,3 +34,83 @@ describe("DEFAULT_CATEGORY_MIN_SCORES", () => {
|
|
|
31
34
|
}
|
|
32
35
|
});
|
|
33
36
|
});
|
|
37
|
+
|
|
38
|
+
describe("retrieval profiles carry scaled category thresholds", () => {
|
|
39
|
+
const scopedProfiles = ["writing", "debug", "fact-check"] as const;
|
|
40
|
+
|
|
41
|
+
it("default profile does not override category thresholds", () => {
|
|
42
|
+
expect(RETRIEVAL_PROFILES.default.overrides.categoryMinScores).toBeUndefined();
|
|
43
|
+
const { config } = applyRetrievalProfile(DEFAULT_RETRIEVAL_CONFIG, "default");
|
|
44
|
+
expect(config.categoryMinScores).toEqual(DEFAULT_RETRIEVAL_CONFIG.categoryMinScores);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A partial map is the trap here: Retriever.minScoreFor() falls back to
|
|
48
|
+
// hardMinScore — not to DEFAULT_CATEGORY_MIN_SCORES — for unlisted categories.
|
|
49
|
+
it("every scoped profile supplies a COMPLETE category map", () => {
|
|
50
|
+
for (const name of scopedProfiles) {
|
|
51
|
+
const map = RETRIEVAL_PROFILES[name].overrides.categoryMinScores;
|
|
52
|
+
expect(map).toBeDefined();
|
|
53
|
+
expect(Object.keys(map!).sort()).toEqual([...ALL_CATEGORIES].sort());
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("thresholds track each profile's overall looseness", () => {
|
|
58
|
+
for (const name of scopedProfiles) {
|
|
59
|
+
const { overrides } = RETRIEVAL_PROFILES[name];
|
|
60
|
+
const looser = overrides.hardMinScore! < DEFAULT_RETRIEVAL_CONFIG.hardMinScore;
|
|
61
|
+
for (const category of ALL_CATEGORIES) {
|
|
62
|
+
const scaled = overrides.categoryMinScores![category];
|
|
63
|
+
const base = DEFAULT_CATEGORY_MIN_SCORES[category];
|
|
64
|
+
if (looser) {
|
|
65
|
+
expect(scaled).toBeLessThan(base);
|
|
66
|
+
} else {
|
|
67
|
+
expect(scaled).toBeGreaterThan(base);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preserves the relative ordering of the default map", () => {
|
|
74
|
+
const defaultOrder = [...ALL_CATEGORIES].sort(
|
|
75
|
+
(a, b) => DEFAULT_CATEGORY_MIN_SCORES[a] - DEFAULT_CATEGORY_MIN_SCORES[b],
|
|
76
|
+
);
|
|
77
|
+
for (const name of scopedProfiles) {
|
|
78
|
+
const map = RETRIEVAL_PROFILES[name].overrides.categoryMinScores!;
|
|
79
|
+
const scaledOrder = [...ALL_CATEGORIES].sort((a, b) => map[a] - map[b]);
|
|
80
|
+
expect(scaledOrder).toEqual(defaultOrder);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps every scaled threshold in valid range (0, 1)", () => {
|
|
85
|
+
for (const name of scopedProfiles) {
|
|
86
|
+
const map = RETRIEVAL_PROFILES[name].overrides.categoryMinScores!;
|
|
87
|
+
for (const threshold of Object.values(map)) {
|
|
88
|
+
expect(threshold).toBeGreaterThan(0);
|
|
89
|
+
expect(threshold).toBeLessThan(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("applyRetrievalProfile propagates the map into the resolved config", () => {
|
|
95
|
+
const { config } = applyRetrievalProfile(DEFAULT_RETRIEVAL_CONFIG, "debug");
|
|
96
|
+
expect(config.categoryMinScores).toEqual(
|
|
97
|
+
RETRIEVAL_PROFILES.debug.overrides.categoryMinScores,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("loosening and tightening both reach category admission", () => {
|
|
102
|
+
// debug (0.34) and writing (0.24) are looser than the 0.35 default …
|
|
103
|
+
const debug = applyRetrievalProfile(DEFAULT_RETRIEVAL_CONFIG, "debug").config;
|
|
104
|
+
const writing = applyRetrievalProfile(DEFAULT_RETRIEVAL_CONFIG, "writing").config;
|
|
105
|
+
expect(debug.categoryMinScores!.patterns).toBeLessThan(DEFAULT_CATEGORY_MIN_SCORES.patterns);
|
|
106
|
+
expect(writing.categoryMinScores!.events).toBeLessThan(DEFAULT_CATEGORY_MIN_SCORES.events);
|
|
107
|
+
// … and writing, being loosest, must admit more than debug.
|
|
108
|
+
expect(writing.categoryMinScores!.patterns).toBeLessThan(debug.categoryMinScores!.patterns);
|
|
109
|
+
|
|
110
|
+
// fact-check (0.38) is the only tighter profile.
|
|
111
|
+
const factCheck = applyRetrievalProfile(DEFAULT_RETRIEVAL_CONFIG, "fact-check").config;
|
|
112
|
+
expect(factCheck.categoryMinScores!.patterns).toBeGreaterThan(
|
|
113
|
+
DEFAULT_CATEGORY_MIN_SCORES.patterns,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -310,6 +310,128 @@ describe("clusterAndConsolidate", () => {
|
|
|
310
310
|
expect(result.clustersConsolidated).toBe(1); // Still counts as processed
|
|
311
311
|
expect(result.insightsGenerated).toBe(0); // No insight generated
|
|
312
312
|
expect(stored.length).toBe(0); // Nothing stored
|
|
313
|
+
expect(result.llmFailures).toBe(0); // 返回 null 不是失败,是合法的"没料可炼"
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// 2026-08-13:上面那条测的是 generateL0 **返回 null**;下面这条测它 **抛异常**。
|
|
317
|
+
// 两者此前走的是完全不同的路径 —— 返回 null 被消化,抛异常会一路冒出 runDream,
|
|
318
|
+
// 把整个 scope 的 dream 炸掉(llm-client 的 chat() 在 catch 里是 `throw err`)。
|
|
319
|
+
// 2026-08-09 memory 周日轮次连挂两次就是这条路径:15s AbortController 超时。
|
|
320
|
+
it("单个 cluster 的 LLM 抛异常时吸收掉,不炸整轮(2026-08-09 memory 轮次根因)", async () => {
|
|
321
|
+
const entries = [
|
|
322
|
+
makeEntry({ id: "a", text: "memory A", vector: [0.9, 0.1, 0] }),
|
|
323
|
+
makeEntry({ id: "b", text: "memory B", vector: [0.88, 0.12, 0] }),
|
|
324
|
+
makeEntry({ id: "c", text: "memory C", vector: [0.92, 0.08, 0] }),
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
const { store, stored } = createMockStore();
|
|
328
|
+
const llm = {
|
|
329
|
+
async generateL0(_text: string): Promise<string | null> {
|
|
330
|
+
throw new Error("Request was aborted.");
|
|
331
|
+
},
|
|
332
|
+
async extractPattern(_texts: string[]): Promise<string | null> {
|
|
333
|
+
return null;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
const embedder = createMockEmbedder();
|
|
337
|
+
|
|
338
|
+
const result = await clusterAndConsolidate({
|
|
339
|
+
entries,
|
|
340
|
+
embedder,
|
|
341
|
+
llm: llm as unknown as LLMClient,
|
|
342
|
+
store,
|
|
343
|
+
scope: "project:test",
|
|
344
|
+
minClusterSize: 3,
|
|
345
|
+
clusterThreshold: 0.75,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// 关键断言:函数正常返回而不是抛出。修复前这一行根本到不了 —— 异常直接冒到调用方。
|
|
349
|
+
expect(result.clustersFound).toBe(1);
|
|
350
|
+
expect(result.clustersConsolidated).toBe(1);
|
|
351
|
+
expect(result.insightsGenerated).toBe(0);
|
|
352
|
+
expect(stored.length).toBe(0);
|
|
353
|
+
// 吸收 ≠ 静默:失败要计数,否则"LLM 全程不可用"和"今天没料可炼"长得一样。
|
|
354
|
+
expect(result.llmFailures).toBe(1);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("多个 cluster 时,一个 LLM 抛异常不影响其他 cluster 产出", async () => {
|
|
358
|
+
// 两个互相正交的簇,各 3 个成员
|
|
359
|
+
const entries = [
|
|
360
|
+
makeEntry({ id: "a1", text: "cluster one A", vector: [0.9, 0.1, 0] }),
|
|
361
|
+
makeEntry({ id: "a2", text: "cluster one B", vector: [0.88, 0.12, 0] }),
|
|
362
|
+
makeEntry({ id: "a3", text: "cluster one C", vector: [0.92, 0.08, 0] }),
|
|
363
|
+
makeEntry({ id: "b1", text: "cluster two A", vector: [0, 0.9, 0.1] }),
|
|
364
|
+
makeEntry({ id: "b2", text: "cluster two B", vector: [0, 0.88, 0.12] }),
|
|
365
|
+
makeEntry({ id: "b3", text: "cluster two C", vector: [0, 0.92, 0.08] }),
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
const { store, stored } = createMockStore();
|
|
369
|
+
let call = 0;
|
|
370
|
+
const llm = {
|
|
371
|
+
async generateL0(_text: string): Promise<string | null> {
|
|
372
|
+
call++;
|
|
373
|
+
if (call === 1) throw new Error("Request was aborted.");
|
|
374
|
+
return "第二个簇的 insight";
|
|
375
|
+
},
|
|
376
|
+
async extractPattern(_texts: string[]): Promise<string | null> {
|
|
377
|
+
return null;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
const embedder = createMockEmbedder();
|
|
381
|
+
|
|
382
|
+
const result = await clusterAndConsolidate({
|
|
383
|
+
entries,
|
|
384
|
+
embedder,
|
|
385
|
+
llm: llm as unknown as LLMClient,
|
|
386
|
+
store,
|
|
387
|
+
scope: "project:test",
|
|
388
|
+
minClusterSize: 3,
|
|
389
|
+
clusterThreshold: 0.75,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(result.clustersFound).toBe(2);
|
|
393
|
+
expect(result.llmFailures).toBe(1);
|
|
394
|
+
// 修复前:第一个簇抛异常 → 整个函数炸掉 → 第二个簇永远跑不到,产出为 0。
|
|
395
|
+
expect(result.insightsGenerated).toBe(1);
|
|
396
|
+
expect(stored.length).toBe(1);
|
|
397
|
+
expect(stored[0].text).toBe("第二个簇的 insight");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("pattern 抽取抛异常时只丢这一簇的 pattern,已写好的 insight 不连坐", async () => {
|
|
401
|
+
const entries = [
|
|
402
|
+
makeEntry({ id: "a", text: "memory A", vector: [0.9, 0.1, 0] }),
|
|
403
|
+
makeEntry({ id: "b", text: "memory B", vector: [0.88, 0.12, 0] }),
|
|
404
|
+
makeEntry({ id: "c", text: "memory C", vector: [0.92, 0.08, 0] }),
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
const { store, stored } = createMockStore();
|
|
408
|
+
const llm = {
|
|
409
|
+
async generateL0(_text: string): Promise<string | null> {
|
|
410
|
+
return "insight 正常产出";
|
|
411
|
+
},
|
|
412
|
+
async extractPattern(_texts: string[]): Promise<string | null> {
|
|
413
|
+
throw new Error("Request was aborted.");
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const embedder = createMockEmbedder();
|
|
417
|
+
|
|
418
|
+
const result = await clusterAndConsolidate({
|
|
419
|
+
entries,
|
|
420
|
+
embedder,
|
|
421
|
+
llm: llm as unknown as LLMClient,
|
|
422
|
+
store,
|
|
423
|
+
scope: "project:test",
|
|
424
|
+
minClusterSize: 3,
|
|
425
|
+
clusterThreshold: 0.75,
|
|
426
|
+
extractPatterns: true,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(result.llmFailures).toBe(1);
|
|
430
|
+
expect(result.patternsExtracted).toBe(0);
|
|
431
|
+
// insight 已经写进去了,不该被 pattern 阶段的失败拖掉
|
|
432
|
+
expect(result.insightsGenerated).toBe(1);
|
|
433
|
+
expect(stored.length).toBe(1);
|
|
434
|
+
expect(stored[0].text).toBe("insight 正常产出");
|
|
313
435
|
});
|
|
314
436
|
|
|
315
437
|
it("respects maxClusters limit", async () => {
|
|
@@ -542,7 +664,7 @@ describe("clusterAndConsolidate — pattern extraction (HP-5)", () => {
|
|
|
542
664
|
expect(patternEntry).toBeDefined();
|
|
543
665
|
expect(patternEntry!.text).toContain("日料");
|
|
544
666
|
expect(patternEntry!.category).toBe("patterns");
|
|
545
|
-
expect(patternEntry!.importance).toBeCloseTo(0.
|
|
667
|
+
expect(patternEntry!.importance).toBeCloseTo(0.8, 5); // = max(0.8),继承不加成
|
|
546
668
|
|
|
547
669
|
const patternMeta = JSON.parse(patternEntry!.metadata!);
|
|
548
670
|
expect(patternMeta.source_cluster_size).toBe(3);
|
|
@@ -615,7 +737,7 @@ describe("clusterAndConsolidate — pattern extraction (HP-5)", () => {
|
|
|
615
737
|
expect(stored.length).toBe(1); // Only insight stored
|
|
616
738
|
});
|
|
617
739
|
|
|
618
|
-
it("
|
|
740
|
+
it("pattern importance 继承源里最高的,不再加成后撞上限", async () => {
|
|
619
741
|
const entries = [
|
|
620
742
|
makeEntry({ id: "a", text: "high importance A", vector: [0.9, 0.1, 0], importance: 0.95 }),
|
|
621
743
|
makeEntry({ id: "b", text: "high importance B", vector: [0.88, 0.12, 0], importance: 0.98 }),
|
|
@@ -642,8 +764,49 @@ describe("clusterAndConsolidate — pattern extraction (HP-5)", () => {
|
|
|
642
764
|
|
|
643
765
|
expect(result.patternsExtracted).toBe(1);
|
|
644
766
|
const patternEntry = stored.find(s => JSON.parse(s.metadata!).cross_memory_pattern);
|
|
645
|
-
// max(0.98) + 0.1 = 1.08 →
|
|
646
|
-
|
|
767
|
+
// 旧行为:max(0.98) + 0.1 = 1.08 → 撞 1.0 上限,派生物顶格。
|
|
768
|
+
// 现在直接取 max(0.98),不存在需要 cap 的情形。
|
|
769
|
+
expect(patternEntry!.importance).toBeCloseTo(0.98, 5);
|
|
770
|
+
// 注意这里源本身就在 pin 带(0.98 >= 0.95),派生物照样继承进去。
|
|
771
|
+
// 「派生物不该继承人工 pin 身份」是另一个问题,单独立项,不在本次改动范围。
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("源都在 pin 线以下时,pattern 不再被加成推过 0.95", async () => {
|
|
775
|
+
// 这是加成最有害的形态:源里最高只有 0.85,本不该享有人工 pin 的待遇,
|
|
776
|
+
// 旧行为 0.85 + 0.1 = 0.95 正好跨进那个频段,于是这条 LLM 抽出来的 pattern
|
|
777
|
+
// 自动拿到永不归档(auto-gc.ts)和永不衰减(decay-engine.ts)的保护。
|
|
778
|
+
const entries = [
|
|
779
|
+
makeEntry({ id: "a", text: "topic A", vector: [0.9, 0.1, 0], importance: 0.85 }),
|
|
780
|
+
makeEntry({ id: "b", text: "topic B", vector: [0.88, 0.12, 0], importance: 0.8 }),
|
|
781
|
+
makeEntry({ id: "c", text: "topic C", vector: [0.92, 0.08, 0], importance: 0.75 }),
|
|
782
|
+
];
|
|
783
|
+
|
|
784
|
+
const { store, stored } = createMockStore();
|
|
785
|
+
const llm = createMockLLM(
|
|
786
|
+
() => "insight",
|
|
787
|
+
() => "discovered pattern",
|
|
788
|
+
);
|
|
789
|
+
const embedder = createMockEmbedder();
|
|
790
|
+
|
|
791
|
+
const result = await clusterAndConsolidate({
|
|
792
|
+
entries,
|
|
793
|
+
embedder,
|
|
794
|
+
llm: llm as unknown as LLMClient,
|
|
795
|
+
store,
|
|
796
|
+
scope: "project:test",
|
|
797
|
+
minClusterSize: 3,
|
|
798
|
+
clusterThreshold: 0.75,
|
|
799
|
+
extractPatterns: true,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
expect(result.patternsExtracted).toBe(1);
|
|
803
|
+
const patternEntry = stored.find(s => JSON.parse(s.metadata!).cross_memory_pattern);
|
|
804
|
+
expect(patternEntry!.importance).toBeCloseTo(0.85, 5);
|
|
805
|
+
expect(patternEntry!.importance).toBeLessThan(0.95);
|
|
806
|
+
|
|
807
|
+
// 派生物也不该压过它自己的源
|
|
808
|
+
const maxSource = Math.max(...entries.map(e => e.importance));
|
|
809
|
+
expect(patternEntry!.importance).toBeLessThanOrEqual(maxSource);
|
|
647
810
|
});
|
|
648
811
|
});
|
|
649
812
|
|