recallnest 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +42 -17
  3. package/README_CN.md +31 -15
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +10 -2
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +434 -68
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer.ts +11 -2
  28. package/src/distill-lock.ts +246 -74
  29. package/src/dream-pipeline.ts +3 -1
  30. package/src/env-config.ts +118 -0
  31. package/src/flock.ts +97 -0
  32. package/src/forget-engine.ts +342 -58
  33. package/src/gateway-server.ts +4 -0
  34. package/src/ingest-plan.ts +56 -0
  35. package/src/ingest.ts +69 -104
  36. package/src/kg-contradiction.ts +753 -0
  37. package/src/kg-store.ts +1 -1
  38. package/src/known-item-eval.ts +455 -0
  39. package/src/llm-client.ts +13 -26
  40. package/src/llm-consolidation.ts +2 -2
  41. package/src/mcp-tool-deps.ts +6 -0
  42. package/src/mcp-tools-advanced.ts +80 -45
  43. package/src/mcp-tools-core.ts +23 -17
  44. package/src/mcp-tools-governance.ts +13 -7
  45. package/src/memory-assets.ts +144 -8
  46. package/src/memory-boundaries.ts +19 -0
  47. package/src/memory-evolution.ts +9 -0
  48. package/src/memory-lint.ts +1 -1
  49. package/src/memory-output.ts +276 -14
  50. package/src/memory-reconcile.ts +1437 -0
  51. package/src/memory-schema.ts +11 -0
  52. package/src/memory-store-port.ts +1 -0
  53. package/src/minis-archive.ts +151 -0
  54. package/src/noise-filter.ts +15 -2
  55. package/src/pii-detector.ts +31 -1
  56. package/src/pivot-apply.ts +3 -4
  57. package/src/preference-matcher.ts +40 -28
  58. package/src/prospective-memory.ts +7 -4
  59. package/src/retriever.ts +297 -57
  60. package/src/retroactive-boost.ts +3 -2
  61. package/src/runtime-config.ts +6 -1
  62. package/src/scope-policy.ts +1 -0
  63. package/src/session-distiller.ts +6 -2
  64. package/src/session-output.ts +11 -0
  65. package/src/session-schema.ts +5 -2
  66. package/src/session-store.ts +57 -9
  67. package/src/store-receipts.ts +109 -0
  68. package/src/store-write-budget.ts +37 -0
  69. package/src/store.ts +298 -57
  70. package/src/text-fingerprint.ts +25 -0
  71. package/src/trigger-store.ts +443 -0
  72. package/src/ui-server.ts +22 -21
  73. package/src/version-manager.ts +19 -5
  74. package/src/write-verifier.ts +16 -11
package/src/audit-log.ts CHANGED
@@ -18,6 +18,8 @@ export type AuditOperation =
18
18
  | "supersede"
19
19
  | "consolidate"
20
20
  | "forget"
21
+ /** A forget that stopped after changing something (see forget-engine.ts). Not a `forget`: the memory may still be there. */
22
+ | "forget_incomplete"
21
23
  | "cascade_forget"
22
24
  | "reject";
23
25
 
package/src/auto-gc.ts CHANGED
@@ -22,7 +22,7 @@ import type { RetentionPolicy } from "./retention-policy.js";
22
22
  import type { AuditLogger } from "./audit-log.js";
23
23
  import * as envConfig from "./env-config.js";
24
24
  import { isUsageSignalActive } from "./usage-tracker.js";
25
- import { acquireLock, releaseLock, getLastDistillTime, stampLock, lockPathForKey } from "./distill-lock.js";
25
+ import { acquireLock, releaseLock, getLastDistillTime, stampLock, clearStamp, lockPathForKey } from "./distill-lock.js";
26
26
  import { logInfo } from "./stderr-log.js";
27
27
 
28
28
  // ---------------------------------------------------------------------------
@@ -315,9 +315,10 @@ export function resetGcTimestamp(): void {
315
315
  lastGcTimestamp = 0;
316
316
  lastVersionGroupRepairTimestamp = 0;
317
317
  try {
318
- releaseLock({ lockPath: lockPathForKey("gc-last") });
318
+ clearStamp({ lockPath: lockPathForKey("gc-last") });
319
+ clearStamp({ lockPath: lockPathForKey("version-group-repair-last") });
320
+ // Only releases gc-run if this process holds it; another holder's lock is never removed.
319
321
  releaseLock({ lockPath: lockPathForKey("gc-run") });
320
- releaseLock({ lockPath: lockPathForKey("version-group-repair-last") });
321
322
  } catch {
322
323
  /* ignore — nothing to clear */
323
324
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { deterministicId, type MemoryEntry, type MemoryStore } from "./store.js";
22
+ import type { MemoryStoreUpdate } from "./memory-store-port.js";
22
23
  import { parseEvolution, patchEvolution, type EvolutionMetadata } from "./memory-evolution.js";
23
24
  import { detectLang, tokenizeFts } from "./language-hook.js";
24
25
  import type { Embedder } from "./embedder.js";
@@ -45,41 +46,25 @@ export interface BeliefArchiveResult {
45
46
  * canonical-match candidates come from — so the entry handed to us usually has no vector.
46
47
  * Re-read the full row first; only re-embed if the store can't give it back.
47
48
  */
48
- async function resolveVector(deps: BeliefHistoryDeps, current: MemoryEntry): Promise<number[]> {
49
+ async function resolveVector(
50
+ store: Partial<Pick<MemoryStore, "getById">>,
51
+ embedder: Pick<Embedder, "embedPassage">,
52
+ current: MemoryEntry,
53
+ ): Promise<number[]> {
49
54
  if (current.vector && current.vector.length > 0) return current.vector;
50
55
 
51
- if (deps.store.getById) {
52
- const full = await deps.store.getById(current.id);
56
+ if (store.getById) {
57
+ const full = await store.getById(current.id);
53
58
  if (full?.vector && full.vector.length > 0) return full.vector;
54
59
  }
55
60
 
56
- return await deps.embedder.embedPassage(current.text);
61
+ return await embedder.embedPassage(current.text);
57
62
  }
58
63
 
59
- /**
60
- * Archive the current content of a canonical memory as a `superseded` history row.
61
- *
62
- * Throws rather than degrading to a silent overwrite: a belief-changing write that cannot
63
- * preserve the old belief is exactly the failure this module exists to prevent, so callers
64
- * must not proceed with the overwrite if this rejects.
65
- *
66
- * Known limitation — archive and overwrite are two writes with no transaction around them.
67
- * If the caller's update() fails after this returns, the history row is already committed
68
- * while the canonical row still holds that same text, leaving a superseded duplicate of a
69
- * belief that is in fact still live. Damage is bounded: nothing is destroyed, and the
70
- * deterministic id means a retry reuses the same row rather than piling up copies.
71
- */
72
- export async function archiveBeliefVersion(
73
- deps: BeliefHistoryDeps,
74
- current: MemoryEntry,
75
- options: { now?: number } = {},
76
- ): Promise<BeliefArchiveResult> {
77
- const now = options.now ?? Date.now();
78
- const currentEvo = parseEvolution(current.metadata, current.timestamp);
79
-
80
- // Text is part of the id, not just (id, timestamp): two belief changes landing in the
81
- // same millisecond would otherwise collide and the second archive would overwrite the
82
- // first. With the text folded in, a collision means the content is identical anyway.
64
+ // Text is part of the id, not just (id, timestamp): two belief changes landing in the
65
+ // same millisecond would otherwise collide and the second archive would overwrite the
66
+ // first. With the text folded in, a collision means the content is identical anyway.
67
+ function beliefHistoryId(current: MemoryEntry): string {
83
68
  const historyId = deterministicId(
84
69
  current.scope,
85
70
  `${BELIEF_HISTORY_ID_PREFIX}${current.id}@${current.timestamp}\n${current.text}`,
@@ -87,11 +72,19 @@ export async function archiveBeliefVersion(
87
72
  if (historyId === current.id) {
88
73
  throw new Error(`Belief history id collided with canonical id ${current.id}`);
89
74
  }
75
+ return historyId;
76
+ }
90
77
 
91
- const vector = await resolveVector(deps, current);
78
+ /**
79
+ * Build the `superseded` history row for `current` (pure — no I/O). Throws when no vector is
80
+ * available or the history id would collide with the canonical id.
81
+ */
82
+ function buildBeliefHistoryRow(current: MemoryEntry, vector: number[], now: number): MemoryEntry {
83
+ const historyId = beliefHistoryId(current);
92
84
  if (vector.length === 0) {
93
85
  throw new Error(`Cannot archive belief ${current.id}: no vector available`);
94
86
  }
87
+ const currentEvo = parseEvolution(current.metadata, current.timestamp);
95
88
 
96
89
  // The history row inherits the old metadata wholesale (canonicalKey, boundary, emotion…)
97
90
  // and only has its evolution block rewritten to close the interval.
@@ -104,7 +97,7 @@ export async function archiveBeliefVersion(
104
97
  supersedes: currentEvo.supersedes,
105
98
  });
106
99
 
107
- const historyRow: MemoryEntry = {
100
+ return {
108
101
  id: historyId,
109
102
  text: current.text,
110
103
  vector,
@@ -123,6 +116,35 @@ export async function archiveBeliefVersion(
123
116
  language: current.language || detectLang(current.text),
124
117
  fts_text: current.fts_text || tokenizeFts(current.text, current.language || detectLang(current.text)),
125
118
  };
119
+ }
120
+
121
+ /**
122
+ * Archive the current content of a canonical memory as a `superseded` history row.
123
+ *
124
+ * Throws rather than degrading to a silent overwrite: a belief-changing write that cannot
125
+ * preserve the old belief is exactly the failure this module exists to prevent, so callers
126
+ * must not proceed with the overwrite if this rejects.
127
+ *
128
+ * This is the two-write form: the history row is committed here and the caller overwrites
129
+ * the canonical row afterwards, with no lock held across the two. Callers that hold a full
130
+ * MemoryStore use replaceBeliefVersion() instead, which re-reads the canonical row and does
131
+ * both writes in one store-write critical section; this function remains only as the
132
+ * fallback for narrower store deps (test doubles) that lack replaceWithArchivedVersion.
133
+ * On that fallback path, if the canonical row is deleted between the caller's read and its
134
+ * overwrite, the history row written here stays behind pointing at a row that no longer
135
+ * exists; and if the overwrite fails for another reason, it is a superseded duplicate of a
136
+ * belief that is in fact still live. The deterministic id means a retry reuses the same row
137
+ * rather than piling up copies.
138
+ */
139
+ export async function archiveBeliefVersion(
140
+ deps: BeliefHistoryDeps,
141
+ current: MemoryEntry,
142
+ options: { now?: number } = {},
143
+ ): Promise<BeliefArchiveResult> {
144
+ const now = options.now ?? Date.now();
145
+ beliefHistoryId(current); // fail on an id collision before doing any I/O
146
+ const vector = await resolveVector(deps.store, deps.embedder, current);
147
+ const historyRow = buildBeliefHistoryRow(current, vector, now);
126
148
 
127
149
  if (deps.store.upsert) {
128
150
  await deps.store.upsert(historyRow);
@@ -132,7 +154,91 @@ export async function archiveBeliefVersion(
132
154
  await deps.store.store(historyRow);
133
155
  }
134
156
 
135
- return { historyId, validUntil: now };
157
+ return { historyId: historyRow.id, validUntil: now };
158
+ }
159
+
160
+ /** The canonical row a belief replacement was aimed at no longer exists; nothing was written. */
161
+ export class BeliefTargetMissingError extends Error {
162
+ readonly memoryId: string;
163
+ constructor(memoryId: string) {
164
+ super(`Belief target ${memoryId} no longer exists; no history row was written and nothing was overwritten`);
165
+ this.name = "BeliefTargetMissingError";
166
+ this.memoryId = memoryId;
167
+ }
168
+ }
169
+
170
+ export interface BeliefReplaceDeps {
171
+ store: Pick<MemoryStore, "replaceWithArchivedVersion"> & Partial<Pick<MemoryStore, "getById">>;
172
+ embedder: Pick<Embedder, "embedPassage">;
173
+ }
174
+
175
+ /** What the caller's updates builder sees — all derived from the row re-read under the lock. */
176
+ export interface BeliefReplaceContext {
177
+ current: MemoryEntry;
178
+ previousEvolution: EvolutionMetadata;
179
+ historyId: string;
180
+ now: number;
181
+ }
182
+
183
+ export type BeliefReplaceResult =
184
+ | { status: "missing" }
185
+ | { status: "unchanged"; current: MemoryEntry }
186
+ | { status: "replaced"; entry: MemoryEntry; historyId: string; validUntil: number };
187
+
188
+ /**
189
+ * Archive the canonical row's current version and overwrite it, in one store-write critical
190
+ * section (MemoryStore.replaceWithArchivedVersion): the row is re-read under the lock, and
191
+ * if it is gone nothing at all is written — no history row is left pointing at a row that a
192
+ * concurrent forget just removed.
193
+ *
194
+ * The history row and the updates are built from the row as re-read under the lock, not from
195
+ * `expected` (the caller's earlier read), so a concurrent edit is what gets archived. The one
196
+ * slow step — resolving a vector when the row carries none (re-read or re-embed) — runs before
197
+ * the lock is taken, for `expected`'s text; it is used only if the re-read row has no vector of
198
+ * its own and still holds that same text.
199
+ *
200
+ * `skip(current)` returning true leaves everything as it is (result "unchanged").
201
+ * `buildUpdates` runs under the lock: it must be pure and synchronous.
202
+ *
203
+ * Time: unless `options.now` is given, the single timestamp used for the history row's
204
+ * validUntil, the canonical row's timestamp / validFrom (via ctx.now) and the returned
205
+ * validUntil is taken inside the lock, and is strictly later than the re-read row's own
206
+ * timestamp and validFrom. Strictly: history ids hash (canonical id, timestamp, text), so a
207
+ * revision time that repeats (canonical timestamp in the future because another machine's
208
+ * clock is fast) would let A → B → A → C reuse version 1's history id, overwrite it and close
209
+ * an A↔B backward-link cycle. Taking it before the lock (and before vector resolution) let a
210
+ * replacement that committed in between leave a history row with validFrom > validUntil and
211
+ * move the canonical timestamp backwards.
212
+ *
213
+ * language / fts_text of the history row are always recomputed from its text, as the old
214
+ * archive path did: the row re-read under the lock carries the "en" / raw-text placeholders
215
+ * legacy rows were migrated with, and copying those would mislabel Chinese history rows.
216
+ */
217
+ export async function replaceBeliefVersion(
218
+ deps: BeliefReplaceDeps,
219
+ expected: MemoryEntry,
220
+ buildUpdates: (ctx: BeliefReplaceContext) => MemoryStoreUpdate,
221
+ options: { now?: number; scopeFilter?: string[]; skip?: (current: MemoryEntry) => boolean } = {},
222
+ ): Promise<BeliefReplaceResult> {
223
+ const fallbackVector = await resolveVector(deps.store, deps.embedder, expected);
224
+
225
+ let lockedNow = 0;
226
+ const result = await deps.store.replaceWithArchivedVersion(expected.id, (current) => {
227
+ if (options.skip?.(current)) return null;
228
+ const previousEvolution = parseEvolution(current.metadata, current.timestamp);
229
+ const now = options.now ?? Math.max(Date.now(), current.timestamp + 1, previousEvolution.validFrom + 1);
230
+ lockedNow = now;
231
+ const vector = current.vector.length > 0
232
+ ? current.vector
233
+ : current.text === expected.text ? fallbackVector : [];
234
+ const history = buildBeliefHistoryRow({ ...current, language: undefined, fts_text: undefined }, vector, now);
235
+ return { history, updates: buildUpdates({ current, previousEvolution, historyId: history.id, now }) };
236
+ }, options.scopeFilter);
237
+
238
+ if (result.status === "replaced") {
239
+ return { status: "replaced", entry: result.entry, historyId: result.history.id, validUntil: lockedNow };
240
+ }
241
+ return result;
136
242
  }
137
243
 
138
244
  /**