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.
- package/CHANGELOG.md +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- 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
|
-
|
|
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
|
}
|
package/src/belief-history.ts
CHANGED
|
@@ -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(
|
|
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 (
|
|
52
|
-
const full = await
|
|
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
|
|
61
|
+
return await embedder.embedPassage(current.text);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|