recallnest 2.5.4 → 2.6.1

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 (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +6 -5
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. 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: "User prefers concise, direct replies.",
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
- expect(updatedPayloads[0]?.updates.metadata).toBe(record.incoming.metadata);
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
  });