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
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { summarizeConflictAdvice } from "../conflict-advisor.js";
|
|
7
7
|
import { buildConflictCandidateRecord, resolveConflictCandidate } from "../conflict-engine.js";
|
|
8
8
|
import { ConflictCandidateStore } from "../conflict-store.js";
|
|
9
|
+
import { parseEvolution } from "../memory-evolution.js";
|
|
9
10
|
|
|
10
11
|
const tempDirs: string[] = [];
|
|
11
12
|
|
|
@@ -162,9 +163,11 @@ describe("resolveConflictCandidate", () => {
|
|
|
162
163
|
await conflictStore.save(record);
|
|
163
164
|
|
|
164
165
|
const updatedPayloads: any[] = [];
|
|
166
|
+
const archivedRows: any[] = [];
|
|
167
|
+
const originalExistingText = "User prefers concise, direct replies.";
|
|
165
168
|
const existingMemory = {
|
|
166
169
|
id: "durable-1",
|
|
167
|
-
text:
|
|
170
|
+
text: originalExistingText,
|
|
168
171
|
vector: [1, 1, 1],
|
|
169
172
|
category: "preferences",
|
|
170
173
|
scope: "memory:agent",
|
|
@@ -183,6 +186,14 @@ describe("resolveConflictCandidate", () => {
|
|
|
183
186
|
Object.assign(existingMemory, updates);
|
|
184
187
|
return existingMemory;
|
|
185
188
|
},
|
|
189
|
+
async store(entry: any) {
|
|
190
|
+
archivedRows.push(entry);
|
|
191
|
+
return entry;
|
|
192
|
+
},
|
|
193
|
+
async upsert(entry: any) {
|
|
194
|
+
archivedRows.push(entry);
|
|
195
|
+
return entry;
|
|
196
|
+
},
|
|
186
197
|
},
|
|
187
198
|
embedder: {
|
|
188
199
|
async embedPassage(text: string) {
|
|
@@ -200,7 +211,23 @@ describe("resolveConflictCandidate", () => {
|
|
|
200
211
|
expect(result.updatedMemoryId).toBe("durable-1");
|
|
201
212
|
expect(updatedPayloads).toHaveLength(1);
|
|
202
213
|
expect(updatedPayloads[0]?.updates.text).toBe(record.incoming.text);
|
|
203
|
-
|
|
214
|
+
|
|
215
|
+
// The incoming metadata is carried through, with an evolution block layered on top
|
|
216
|
+
// that links the live row back to the archived version (it is no longer passed verbatim).
|
|
217
|
+
const incomingMeta = JSON.parse(record.incoming.metadata);
|
|
218
|
+
const writtenMeta = JSON.parse(updatedPayloads[0]?.updates.metadata);
|
|
219
|
+
for (const [key, value] of Object.entries(incomingMeta)) {
|
|
220
|
+
if (key === "evolution") continue;
|
|
221
|
+
expect(writtenMeta[key]).toEqual(value);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Accepting the incoming version must not destroy the belief it replaced.
|
|
225
|
+
expect(archivedRows).toHaveLength(1);
|
|
226
|
+
expect(archivedRows[0].text).toBe(originalExistingText);
|
|
227
|
+
const archivedEvo = parseEvolution(archivedRows[0].metadata);
|
|
228
|
+
expect(archivedEvo.status).toBe("superseded");
|
|
229
|
+
expect(archivedEvo.supersededBy).toBe("durable-1");
|
|
230
|
+
expect(parseEvolution(updatedPayloads[0]?.updates.metadata).supersedes).toBe(archivedRows[0].id);
|
|
204
231
|
|
|
205
232
|
const resolved = await conflictStore.getById(record.conflictId);
|
|
206
233
|
expect(resolved?.status).toBe("accepted-incoming");
|
|
@@ -260,6 +287,8 @@ describe("resolveConflictCandidate", () => {
|
|
|
260
287
|
|
|
261
288
|
const mergeSuggestion = summarizeConflictAdvice(record).mergeSuggestion;
|
|
262
289
|
expect(mergeSuggestion).toBeTruthy();
|
|
290
|
+
const archivedRows: any[] = [];
|
|
291
|
+
const originalExistingText = existingMemory.text;
|
|
263
292
|
|
|
264
293
|
const result = await resolveConflictCandidate({
|
|
265
294
|
store: {
|
|
@@ -271,6 +300,14 @@ describe("resolveConflictCandidate", () => {
|
|
|
271
300
|
Object.assign(existingMemory, updates);
|
|
272
301
|
return existingMemory;
|
|
273
302
|
},
|
|
303
|
+
async store(entry: any) {
|
|
304
|
+
archivedRows.push(entry);
|
|
305
|
+
return entry;
|
|
306
|
+
},
|
|
307
|
+
async upsert(entry: any) {
|
|
308
|
+
archivedRows.push(entry);
|
|
309
|
+
return entry;
|
|
310
|
+
},
|
|
274
311
|
},
|
|
275
312
|
embedder: {
|
|
276
313
|
async embedPassage(text: string) {
|
|
@@ -295,6 +332,13 @@ describe("resolveConflictCandidate", () => {
|
|
|
295
332
|
expect(mergedMetadata.promotedFrom?.memoryId).toBe("source-1");
|
|
296
333
|
expect(mergedMetadata.mergedFrom?.conflictId).toBe(record.conflictId);
|
|
297
334
|
|
|
335
|
+
// A merge still replaces the existing row's text, so the pre-merge belief must survive
|
|
336
|
+
// as history — otherwise the "before" half of the merge decision is unrecoverable.
|
|
337
|
+
expect(archivedRows).toHaveLength(1);
|
|
338
|
+
expect(archivedRows[0].text).toBe(originalExistingText);
|
|
339
|
+
expect(parseEvolution(archivedRows[0].metadata).status).toBe("superseded");
|
|
340
|
+
expect(parseEvolution(updatedPayloads[0]?.updates.metadata).supersedes).toBe(archivedRows[0].id);
|
|
341
|
+
|
|
298
342
|
const resolved = await conflictStore.getById(record.conflictId);
|
|
299
343
|
expect(resolved?.status).toBe("merged");
|
|
300
344
|
expect(resolved?.resolutionNotes).toContain("grounded style");
|
|
@@ -110,6 +110,45 @@ describe("ConsolidationEngine", () => {
|
|
|
110
110
|
expect(metaA.version_rank).toBeGreaterThan(metaB.version_rank);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it("never clusters a live entry with its own superseded belief-history row", async () => {
|
|
114
|
+
// A rephrased belief sits far above mergeThreshold from its archived version, and
|
|
115
|
+
// canonicalScore (importance × access count, no recency) ties them exactly — the
|
|
116
|
+
// history row inherits both. Losing that coin flip would mark the LIVE entry
|
|
117
|
+
// consolidated, dropping the current belief out of default retrieval while the
|
|
118
|
+
// abandoned wording stands in as canonical.
|
|
119
|
+
const live = makeEntry({
|
|
120
|
+
id: "live",
|
|
121
|
+
text: "User prefers concise, code-first replies",
|
|
122
|
+
vector: [1, 0, 0],
|
|
123
|
+
});
|
|
124
|
+
// Clustering is skipped outright when a category holds fewer than two ACTIVE entries,
|
|
125
|
+
// so an unrelated second live entry is what makes this scenario reachable at all.
|
|
126
|
+
const unrelated = makeEntry({
|
|
127
|
+
id: "other",
|
|
128
|
+
text: "Project uses Bun as the runtime",
|
|
129
|
+
vector: [0, 0, 1],
|
|
130
|
+
});
|
|
131
|
+
const history = makeEntry({
|
|
132
|
+
id: "hist",
|
|
133
|
+
text: "User prefers concise, direct replies",
|
|
134
|
+
vector: [0, 1, 0],
|
|
135
|
+
metadata: JSON.stringify({
|
|
136
|
+
evolution: { status: "superseded", validUntil: Date.now(), supersededBy: "live" },
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const simMap = new Map([["live", new Map([["hist", 0.98]])]]);
|
|
141
|
+
|
|
142
|
+
const { store, updates } = createMockStore([live, unrelated, history], simMap);
|
|
143
|
+
const engine = new ConsolidationEngine(store, { ...DEFAULT_CONSOLIDATION_CONFIG, mergeThreshold: 0.92 });
|
|
144
|
+
const result = await engine.run("project:test");
|
|
145
|
+
|
|
146
|
+
expect(result.clustersFound).toBe(0);
|
|
147
|
+
expect(result.mergedCount).toBe(0);
|
|
148
|
+
// Above all: the live entry must not have been touched.
|
|
149
|
+
expect(updates.find(u => u.id === "live")).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
113
152
|
it("links related entries below mergeThreshold but above clusterThreshold", async () => {
|
|
114
153
|
const entryA = makeEntry({ id: "a", text: "TypeScript config", vector: [1, 0, 0] });
|
|
115
154
|
const entryB = makeEntry({ id: "b", text: "TypeScript setup", vector: [0.9, 0.1, 0] });
|
|
@@ -331,3 +370,61 @@ describe("formatConsolidationResult", () => {
|
|
|
331
370
|
expect(text).toContain("Conflicts:");
|
|
332
371
|
});
|
|
333
372
|
});
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// computeSynthesisUptake (Artel synthesis_uptake_rate 对应物)
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
import { computeSynthesisUptake } from "../consolidation-engine.js";
|
|
379
|
+
import type { MemoryEntry as UptakeEntry } from "../store.js";
|
|
380
|
+
|
|
381
|
+
function uptakeEntry(id: string, metadata: Record<string, unknown>): UptakeEntry {
|
|
382
|
+
return {
|
|
383
|
+
id,
|
|
384
|
+
text: `t-${id}`,
|
|
385
|
+
vector: [],
|
|
386
|
+
category: "patterns",
|
|
387
|
+
scope: "project:test",
|
|
388
|
+
importance: 0.7,
|
|
389
|
+
timestamp: Date.now(),
|
|
390
|
+
metadata: JSON.stringify(metadata),
|
|
391
|
+
} as UptakeEntry;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function pagedStore(all: UptakeEntry[]) {
|
|
395
|
+
return {
|
|
396
|
+
async listPage({ limit = 1000, offset = 0 }: { limit?: number; offset?: number; includeVector?: boolean }) {
|
|
397
|
+
return all.slice(offset, offset + limit);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
describe("computeSynthesisUptake", () => {
|
|
403
|
+
it("computes uptake over derived insights only", async () => {
|
|
404
|
+
const store = pagedStore([
|
|
405
|
+
uptakeEntry("d1", { cluster_insight: true, accessCount: 2 }), // derived, read
|
|
406
|
+
uptakeEntry("d2", { cross_memory_pattern: true }), // derived, unread
|
|
407
|
+
uptakeEntry("n1", { accessCount: 5 }), // not derived
|
|
408
|
+
]);
|
|
409
|
+
const s = await computeSynthesisUptake(store, 20_000, 2);
|
|
410
|
+
expect(s.scanned).toBe(3);
|
|
411
|
+
expect(s.derivedTotal).toBe(2);
|
|
412
|
+
expect(s.derivedRead).toBe(1);
|
|
413
|
+
expect(s.uptakeRate).toBeCloseTo(0.5, 5);
|
|
414
|
+
expect(s.truncated).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("returns null rate when no derived insights exist", async () => {
|
|
418
|
+
const s = await computeSynthesisUptake(pagedStore([uptakeEntry("n1", {})]));
|
|
419
|
+
expect(s.derivedTotal).toBe(0);
|
|
420
|
+
expect(s.uptakeRate).toBeNull();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("respects scan cap and reports truncation", async () => {
|
|
424
|
+
const all = Array.from({ length: 10 }, (_, i) => uptakeEntry(`e${i}`, { cluster_insight: true, accessCount: 1 }));
|
|
425
|
+
const s = await computeSynthesisUptake(pagedStore(all), 4, 2);
|
|
426
|
+
expect(s.scanned).toBe(4);
|
|
427
|
+
expect(s.truncated).toBe(true);
|
|
428
|
+
expect(s.derivedTotal).toBe(4);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
@@ -7,6 +7,7 @@ import { resetWriteCount } from "../activity-counter.js";
|
|
|
7
7
|
import { resetGcTimestamp } from "../auto-gc.js";
|
|
8
8
|
import { runDream } from "../dream-pipeline.js";
|
|
9
9
|
import { MemoryStore } from "../store.js";
|
|
10
|
+
import { PACKAGE_VERSION } from "../version.js";
|
|
10
11
|
|
|
11
12
|
// Isolate the activity-counter default path away from the repo's data/activity-stats.json.
|
|
12
13
|
let __origDataDir: string | undefined;
|
|
@@ -214,3 +215,48 @@ describe("MCP registry contract", () => {
|
|
|
214
215
|
expect(headerCount("full")).toBe(fullCount);
|
|
215
216
|
});
|
|
216
217
|
});
|
|
218
|
+
|
|
219
|
+
describe("Release metadata contract", () => {
|
|
220
|
+
it("keeps every public version surface aligned with package.json", () => {
|
|
221
|
+
const root = join(import.meta.dir, "..", "..");
|
|
222
|
+
const packageMetadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
223
|
+
const marketplaceMetadata = JSON.parse(
|
|
224
|
+
readFileSync(join(root, ".claude-plugin", "marketplace.json"), "utf8"),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(marketplaceMetadata.plugins).toHaveLength(1);
|
|
228
|
+
expect(PACKAGE_VERSION).toBe(packageMetadata.version);
|
|
229
|
+
expect(marketplaceMetadata.plugins[0].version).toBe(packageMetadata.version);
|
|
230
|
+
|
|
231
|
+
for (const file of ["cli.ts", "mcp-server.ts", "api-server.ts"]) {
|
|
232
|
+
const source = readFileSync(join(root, "src", file), "utf8");
|
|
233
|
+
expect(source).toContain("PACKAGE_VERSION");
|
|
234
|
+
expect(source).not.toMatch(/version:\s*["'](?:1\.0\.0|1\.4\.0)["']/);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("ships a self-contained Claude Code plugin without project MCP overrides", () => {
|
|
239
|
+
const root = join(import.meta.dir, "..", "..");
|
|
240
|
+
const marketplaceMetadata = JSON.parse(
|
|
241
|
+
readFileSync(join(root, ".claude-plugin", "marketplace.json"), "utf8"),
|
|
242
|
+
);
|
|
243
|
+
const plugin = marketplaceMetadata.plugins[0];
|
|
244
|
+
const projectMcp = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8"));
|
|
245
|
+
|
|
246
|
+
expect(plugin.strict).toBe(false);
|
|
247
|
+
expect(plugin.skills).toContain("./.claude-plugin/recallnest-skill");
|
|
248
|
+
expect(plugin.mcpServers.recallnest.args).toContain(
|
|
249
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/start-server.sh",
|
|
250
|
+
);
|
|
251
|
+
expect(plugin.mcpServers.recallnest.env.LOCAL_MEMORY_CONFIG).toBe(
|
|
252
|
+
"${CLAUDE_PLUGIN_DATA}/config.json",
|
|
253
|
+
);
|
|
254
|
+
expect(plugin.mcpServers.recallnest.env.RECALLNEST_MCP_TIER).toBe("full");
|
|
255
|
+
expect(plugin.userConfig.jina_api_key).toMatchObject({
|
|
256
|
+
type: "string",
|
|
257
|
+
sensitive: true,
|
|
258
|
+
required: true,
|
|
259
|
+
});
|
|
260
|
+
expect(projectMcp.mcpServers).toEqual({});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -81,4 +81,49 @@ describe("HP-7: Decay exemption rules", () => {
|
|
|
81
81
|
expect(isDecayExempt(meta, 0.98)).toBe(true);
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
describe("Rule 0: procedural category", () => {
|
|
86
|
+
// A procedural memory's defining trait is low access frequency + long validity.
|
|
87
|
+
// Rules 1-3 all key off access statistics, so without Rule 0 these entries land
|
|
88
|
+
// in the fastest-decaying tier forever.
|
|
89
|
+
const coldPeripheral = JSON.stringify({
|
|
90
|
+
tier: "peripheral",
|
|
91
|
+
accessCount: 0,
|
|
92
|
+
lastAccessedAt: 0,
|
|
93
|
+
tags: ["workflow"],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("exempts patterns even when cold, peripheral and low-importance", () => {
|
|
97
|
+
expect(isDecayExempt(coldPeripheral, 0.30, "patterns")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("exempts patterns with no metadata at all", () => {
|
|
101
|
+
// Checked before the `!metadata` guard on purpose.
|
|
102
|
+
expect(isDecayExempt(undefined, 0.30, "patterns")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("exempts patterns with corrupt metadata", () => {
|
|
106
|
+
expect(isDecayExempt("not-json", 0.30, "patterns")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does NOT exempt cases — they carry an episodic component and should decay", () => {
|
|
110
|
+
expect(isDecayExempt(coldPeripheral, 0.30, "cases")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does NOT exempt declarative categories", () => {
|
|
114
|
+
for (const category of ["events", "profile", "preferences", "entities"]) {
|
|
115
|
+
expect(isDecayExempt(coldPeripheral, 0.30, category)).toBe(false);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("is backward compatible: omitting category preserves Rule 1-3 behaviour", () => {
|
|
120
|
+
expect(isDecayExempt(coldPeripheral, 0.30)).toBe(false);
|
|
121
|
+
expect(isDecayExempt(JSON.stringify({ tags: ["pinned"] }), 0.40)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not exempt on unknown category strings", () => {
|
|
125
|
+
expect(isDecayExempt(coldPeripheral, 0.30, "skills")).toBe(false);
|
|
126
|
+
expect(isDecayExempt(coldPeripheral, 0.30, "")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
84
129
|
});
|