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/cascade-forget.ts
CHANGED
|
@@ -68,12 +68,15 @@ export async function cascadeForget(
|
|
|
68
68
|
return { demotedCount: 0, demotedIds: [] };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Find related entries in the same scope
|
|
71
|
+
// Find related entries in the same scope. "exact": the default "family" match turns a
|
|
72
|
+
// colon-less scope into a prefix (`memory` → `memory%`), which would also demote
|
|
73
|
+
// `memory:pivot` / `memory-foo` rows when something in `memory` is forgotten.
|
|
72
74
|
const candidates = await store.vectorSearch(
|
|
73
75
|
forgottenEntry.vector,
|
|
74
76
|
config.maxDemotePerForget * 2, // fetch extra, filter down
|
|
75
77
|
config.similarityThreshold,
|
|
76
78
|
[forgottenEntry.scope],
|
|
79
|
+
"exact",
|
|
77
80
|
);
|
|
78
81
|
|
|
79
82
|
const demotedIds: string[] = [];
|
|
@@ -124,12 +127,13 @@ export async function cascadeForget(
|
|
|
124
127
|
meta.tier = "working";
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
await store.update(entry.id, {
|
|
130
|
+
const demoted = await store.update(entry.id, {
|
|
128
131
|
importance: newImportance,
|
|
129
132
|
metadata: JSON.stringify(meta),
|
|
130
133
|
}, [forgottenEntry.scope]);
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
// update() 返回 null = 这一行在检索之后被删掉了,没有降权成,不计入
|
|
136
|
+
if (demoted) demotedIds.push(entry.id);
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
return { demotedCount: demotedIds.length, demotedIds };
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint mirror — make session checkpoints findable by ordinary memory search.
|
|
3
|
+
*
|
|
4
|
+
* Checkpoints live as JSON files (session-store.ts) and are only read back per session by
|
|
5
|
+
* resume_context / latest_checkpoint; search_memory never saw them. On every save, both
|
|
6
|
+
* write entry points (MCP checkpoint_session, HTTP POST /v1/checkpoint) also mirror the
|
|
7
|
+
* checkpoint into the memory table: one row per session in scope `checkpoint`, overwritten
|
|
8
|
+
* in place by each newer checkpoint of that session. The JSON files remain the history —
|
|
9
|
+
* the mirror keeps no superseded rows, so old versions never compete for retrieval slots.
|
|
10
|
+
*
|
|
11
|
+
* Measured before launch (sync-bridge AI产出/2026-10-07-RecallNest第一下命中率/):
|
|
12
|
+
* first-hit "roughly close" +4–5 / 48 queries, none pushed out. Entry text = that trial's
|
|
13
|
+
* `ent` variant; the follow-up ranking trial found no shorter / re-weighted variant better.
|
|
14
|
+
*
|
|
15
|
+
* Invariants:
|
|
16
|
+
* - A mirror failure never fails the checkpoint save: mirrorCheckpoint never throws.
|
|
17
|
+
* - At most one row per session: the row id is derived from the exact sessionId.
|
|
18
|
+
* - A row only moves forward: an older checkpoint (by updatedAt, then checkpointId — the
|
|
19
|
+
* same order session-store uses) never overwrites a newer one, also across processes
|
|
20
|
+
* (compare-and-write under one cross-process lock).
|
|
21
|
+
*
|
|
22
|
+
* Kill switch, read on every call so running processes stop without a restart:
|
|
23
|
+
* `RECALLNEST_CHECKPOINT_MIRROR=off`, or the file data/checkpoint-mirror.off.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { dirname, resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
import { metaDir } from "./compat.js";
|
|
31
|
+
import { withWriteLock } from "./distill-lock.js";
|
|
32
|
+
import type { Embedder } from "./embedder.js";
|
|
33
|
+
import * as envConfig from "./env-config.js";
|
|
34
|
+
import { detectLang, tokenizeFts } from "./language-hook.js";
|
|
35
|
+
import { CHECKPOINT_MIRROR_SCOPE, type MemoryBoundaryMetadata } from "./memory-boundaries.js";
|
|
36
|
+
import { redactSecrets } from "./pii-detector.js";
|
|
37
|
+
import type { SessionCheckpointRecord } from "./session-schema.js";
|
|
38
|
+
import { checkpointFileName, classifyCheckpointQuality } from "./session-store.js";
|
|
39
|
+
import { logWarn } from "./stderr-log.js";
|
|
40
|
+
import { deterministicId, escapeSqlLiteral, loadLanceDB, STORE_WRITE_LOCK_OPTS, type MemoryEntry, type MemoryStore } from "./store.js";
|
|
41
|
+
import { STORE_WRITE_NESTED_WAIT_MS } from "./store-write-budget.js";
|
|
42
|
+
|
|
43
|
+
export { CHECKPOINT_MIRROR_SCOPE };
|
|
44
|
+
const MIRROR_CATEGORY = "events" as const;
|
|
45
|
+
const MIRROR_IMPORTANCE = 0.5;
|
|
46
|
+
const MIRROR_TIER = "peripheral";
|
|
47
|
+
const MIRROR_LOCK_KEY = "checkpoint-mirror-write";
|
|
48
|
+
const DEFAULT_OFF_FILE = resolve(metaDir(import.meta), "../data/checkpoint-mirror.off");
|
|
49
|
+
const CST_OFFSET_MS = 8 * 3_600_000;
|
|
50
|
+
|
|
51
|
+
const MIRROR_BOUNDARY: MemoryBoundaryMetadata = {
|
|
52
|
+
layer: "session",
|
|
53
|
+
authority: "session-checkpoint",
|
|
54
|
+
conflictPolicy: "latest-wins",
|
|
55
|
+
originalCategory: MIRROR_CATEGORY,
|
|
56
|
+
note: "Session checkpoint mirrored for search; latest checkpoint per session wins.",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CheckpointMirrorStatus =
|
|
60
|
+
| "stored"
|
|
61
|
+
| "replaced"
|
|
62
|
+
| "refreshed"
|
|
63
|
+
| "unchanged"
|
|
64
|
+
| "skipped-minimal"
|
|
65
|
+
| "skipped-stale"
|
|
66
|
+
| "disabled"
|
|
67
|
+
| "failed";
|
|
68
|
+
|
|
69
|
+
export interface CheckpointMirrorResult {
|
|
70
|
+
status: CheckpointMirrorStatus;
|
|
71
|
+
id?: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CheckpointMirrorDeps {
|
|
76
|
+
store: Pick<MemoryStore, "getById" | "upsertUnlessNewer">;
|
|
77
|
+
embedder: Pick<Embedder, "embedPassage">;
|
|
78
|
+
/** Kill-switch file; defaults to data/checkpoint-mirror.off next to the checkpoint files. */
|
|
79
|
+
offFile?: string;
|
|
80
|
+
/** Lock directory override (tests). */
|
|
81
|
+
lockDir?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function checkpointMirrorEnabled(offFile: string = DEFAULT_OFF_FILE): boolean {
|
|
85
|
+
if (!envConfig.checkpointMirror()) return false;
|
|
86
|
+
return !existsSync(offFile);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Stable per-session key. A digest of the exact sessionId: normalizeCanonicalKey would lowercase,
|
|
90
|
+
* fold punctuation and truncate, letting distinct sessions (task_a / task-a / ABC / abc) collide. */
|
|
91
|
+
export function checkpointMirrorKey(sessionId: string): string {
|
|
92
|
+
return `checkpoint-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function checkpointMirrorId(sessionId: string): string {
|
|
96
|
+
return deterministicId(CHECKPOINT_MIRROR_SCOPE, checkpointMirrorKey(sessionId));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Entry text (the trial's `ent` variant — swap the format here only):
|
|
101
|
+
* line 1 YYYY-MM-DD (updatedAt in Beijing time) + " " + task (date only when no task)
|
|
102
|
+
* line 2 summary
|
|
103
|
+
* then decisions / openLoops / nextActions, one line each, items joined by ";"
|
|
104
|
+
* last entities joined by "、"
|
|
105
|
+
* Empty lines are dropped; no field names or labels; files are left out.
|
|
106
|
+
*/
|
|
107
|
+
export function buildCheckpointMirrorText(record: SessionCheckpointRecord): string {
|
|
108
|
+
const date = new Date(Date.parse(record.updatedAt) + CST_OFFSET_MS).toISOString().slice(0, 10);
|
|
109
|
+
const task = record.task?.trim();
|
|
110
|
+
const lines = [task ? `${date} ${task}` : date, record.summary.trim()];
|
|
111
|
+
for (const items of [record.decisions, record.openLoops, record.nextActions]) {
|
|
112
|
+
if (items.length > 0) lines.push(items.map((item) => item.trim()).join(";"));
|
|
113
|
+
}
|
|
114
|
+
if (record.entities.length > 0) lines.push(record.entities.join("、"));
|
|
115
|
+
return lines.filter(Boolean).join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Same order as session-store's newest-first sort: updatedAt, then checkpointId. */
|
|
119
|
+
export function compareCheckpointVersion(
|
|
120
|
+
a: { updatedAt: string; checkpointId: string },
|
|
121
|
+
b: { updatedAt: string; checkpointId: string },
|
|
122
|
+
): number {
|
|
123
|
+
const timeDiff = Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
|
124
|
+
if (timeDiff !== 0) return timeDiff;
|
|
125
|
+
return a.checkpointId.localeCompare(b.checkpointId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseMeta(metadata?: string): Record<string, unknown> {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(metadata || "{}");
|
|
131
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
132
|
+
} catch {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The checkpoint version a mirror row holds; null for rows this module did not write. */
|
|
138
|
+
function mirroredVersion(entry: MemoryEntry): { updatedAt: string; checkpointId: string } | null {
|
|
139
|
+
const meta = parseMeta(entry.metadata);
|
|
140
|
+
if (meta.checkpointMirror !== true) return null;
|
|
141
|
+
const updatedAt = meta.checkpointUpdatedAt;
|
|
142
|
+
const checkpointId = meta.checkpointId;
|
|
143
|
+
return typeof updatedAt === "string" && typeof checkpointId === "string" ? { updatedAt, checkpointId } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildMirrorMetadata(record: SessionCheckpointRecord, text: string, previous: MemoryEntry | null): string {
|
|
147
|
+
const lines = text.split("\n");
|
|
148
|
+
const prevMeta = previous ? parseMeta(previous.metadata) : {};
|
|
149
|
+
// Access stats and tier promotions belong to the session's row, not to one checkpoint version.
|
|
150
|
+
const prevEvolution = prevMeta.evolution as Record<string, unknown> | undefined;
|
|
151
|
+
const carriedEvolution = prevEvolution && (prevEvolution.accessCount != null || prevEvolution.lastAccessedAt != null)
|
|
152
|
+
? { evolution: { accessCount: prevEvolution.accessCount, lastAccessedAt: prevEvolution.lastAccessedAt } }
|
|
153
|
+
: {};
|
|
154
|
+
return JSON.stringify({
|
|
155
|
+
source: "checkpoint",
|
|
156
|
+
sessionId: record.sessionId,
|
|
157
|
+
file: `session-checkpoints/${checkpointFileName(record)}`,
|
|
158
|
+
l0_abstract: lines[0].slice(0, 120),
|
|
159
|
+
l1_overview: lines[1] ?? "",
|
|
160
|
+
l2_content: text,
|
|
161
|
+
tier: typeof prevMeta.tier === "string" ? prevMeta.tier : MIRROR_TIER,
|
|
162
|
+
boundary: MIRROR_BOUNDARY,
|
|
163
|
+
canonicalKey: checkpointMirrorKey(record.sessionId),
|
|
164
|
+
checkpointMirror: true,
|
|
165
|
+
checkpointId: record.checkpointId,
|
|
166
|
+
checkpointUpdatedAt: record.updatedAt,
|
|
167
|
+
resolvedScope: record.resolvedScope,
|
|
168
|
+
...carriedEvolution,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether `record` should be written over `existing`:
|
|
174
|
+
* - no row yet, or a row this module did not write → write;
|
|
175
|
+
* - existing row holds an older version → write (same text: only timestamp / metadata move on);
|
|
176
|
+
* - same version, same text → unchanged; same version, different text (the entry format
|
|
177
|
+
* changed and the backfill is re-run) → write;
|
|
178
|
+
* - existing row holds a newer version → skipped-stale.
|
|
179
|
+
*/
|
|
180
|
+
function decideMirrorWrite(
|
|
181
|
+
existing: MemoryEntry | null,
|
|
182
|
+
record: SessionCheckpointRecord,
|
|
183
|
+
text: string,
|
|
184
|
+
): "write" | "unchanged" | "skipped-stale" {
|
|
185
|
+
const version = existing ? mirroredVersion(existing) : null;
|
|
186
|
+
if (!existing || !version) return "write";
|
|
187
|
+
const cmp = compareCheckpointVersion(record, version);
|
|
188
|
+
if (cmp < 0) return "skipped-stale";
|
|
189
|
+
if (cmp === 0 && existing.text === text) return "unchanged";
|
|
190
|
+
return "write";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Mirror one checkpoint. Never throws — every failure comes back as status "failed" and a
|
|
195
|
+
* one-line stderr warning (session id and error message only, never the checkpoint text).
|
|
196
|
+
*/
|
|
197
|
+
export async function mirrorCheckpoint(
|
|
198
|
+
deps: CheckpointMirrorDeps,
|
|
199
|
+
record: SessionCheckpointRecord,
|
|
200
|
+
): Promise<CheckpointMirrorResult> {
|
|
201
|
+
try {
|
|
202
|
+
if (!checkpointMirrorEnabled(deps.offFile)) return { status: "disabled" };
|
|
203
|
+
if (classifyCheckpointQuality(record) === "minimal") return { status: "skipped-minimal" };
|
|
204
|
+
|
|
205
|
+
const text = redactSecrets(buildCheckpointMirrorText(record)).text;
|
|
206
|
+
const id = checkpointMirrorId(record.sessionId);
|
|
207
|
+
|
|
208
|
+
// Decide outside the lock whether an embedding is needed (the slow part), then
|
|
209
|
+
// re-read and decide again inside the lock before writing.
|
|
210
|
+
const before = await deps.store.getById(id);
|
|
211
|
+
const early = decideMirrorWrite(before, record, text);
|
|
212
|
+
if (early !== "write") return { status: early, id };
|
|
213
|
+
const vector = before && before.text === text && before.vector.length > 0
|
|
214
|
+
? before.vector
|
|
215
|
+
: await deps.embedder.embedPassage(text);
|
|
216
|
+
|
|
217
|
+
return await withWriteLock(MIRROR_LOCK_KEY, async (): Promise<CheckpointMirrorResult> => {
|
|
218
|
+
// Re-checked under the lock: a rollback that turns the mirror off while an embedding
|
|
219
|
+
// is in flight must not see that write land after its scope delete.
|
|
220
|
+
if (!checkpointMirrorEnabled(deps.offFile)) return { status: "disabled" };
|
|
221
|
+
const current = await deps.store.getById(id);
|
|
222
|
+
const decision = decideMirrorWrite(current, record, text);
|
|
223
|
+
if (decision !== "write") return { status: decision, id };
|
|
224
|
+
const language = detectLang(text);
|
|
225
|
+
const written = await deps.store.upsertUnlessNewer({
|
|
226
|
+
id,
|
|
227
|
+
text,
|
|
228
|
+
vector,
|
|
229
|
+
category: MIRROR_CATEGORY,
|
|
230
|
+
scope: CHECKPOINT_MIRROR_SCOPE,
|
|
231
|
+
importance: MIRROR_IMPORTANCE,
|
|
232
|
+
timestamp: Date.parse(record.updatedAt),
|
|
233
|
+
metadata: buildMirrorMetadata(record, text, current),
|
|
234
|
+
language,
|
|
235
|
+
fts_text: tokenizeFts(text, language),
|
|
236
|
+
});
|
|
237
|
+
// The merge itself refuses to move a row back in time (timestamp = the checkpoint's updatedAt).
|
|
238
|
+
// The lock is a flock now and cannot be taken from a live writer; this stays as a second guard.
|
|
239
|
+
if (!written) return { status: "skipped-stale", id };
|
|
240
|
+
const status: CheckpointMirrorStatus = !current ? "stored" : current.text === text ? "refreshed" : "replaced";
|
|
241
|
+
return { status, id };
|
|
242
|
+
}, {
|
|
243
|
+
expireMs: 60_000,
|
|
244
|
+
// The section below takes store-write (upsertUnlessNewer): wait long enough for a holder that
|
|
245
|
+
// is itself waiting on store-write, or the next mirror times out and its row is dropped.
|
|
246
|
+
waitTimeoutMs: STORE_WRITE_NESTED_WAIT_MS,
|
|
247
|
+
...(deps.lockDir ? { lockDir: deps.lockDir } : {}),
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
+
logWarn(`[checkpoint-mirror] session=${record.sessionId} failed: ${message}`);
|
|
252
|
+
return { status: "failed", error: message };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Entry-point wrapper: resolves deps lazily (a failing component factory is caught too) and
|
|
258
|
+
* stops waiting after `deadlineMs` — the write itself keeps going in the background — so a
|
|
259
|
+
* stuck embedding endpoint cannot hold up the checkpoint response.
|
|
260
|
+
*/
|
|
261
|
+
export async function mirrorCheckpointWithDeadline(
|
|
262
|
+
resolveDeps: () => CheckpointMirrorDeps,
|
|
263
|
+
record: SessionCheckpointRecord,
|
|
264
|
+
deadlineMs = 20_000,
|
|
265
|
+
): Promise<CheckpointMirrorResult | { status: "pending" }> {
|
|
266
|
+
let deps: CheckpointMirrorDeps;
|
|
267
|
+
try {
|
|
268
|
+
deps = resolveDeps();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
271
|
+
logWarn(`[checkpoint-mirror] session=${record.sessionId} failed: ${message}`);
|
|
272
|
+
return { status: "failed", error: message };
|
|
273
|
+
}
|
|
274
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
275
|
+
const deadline = new Promise<{ status: "pending" }>((resolveDeadline) => {
|
|
276
|
+
timer = setTimeout(() => resolveDeadline({ status: "pending" }), deadlineMs);
|
|
277
|
+
});
|
|
278
|
+
const work = mirrorCheckpoint(deps, record);
|
|
279
|
+
const outcome = await Promise.race([work, deadline]);
|
|
280
|
+
if (timer) clearTimeout(timer);
|
|
281
|
+
if (outcome.status === "pending") {
|
|
282
|
+
logWarn(`[checkpoint-mirror] session=${record.sessionId} still writing after ${deadlineMs}ms; continuing in background`);
|
|
283
|
+
}
|
|
284
|
+
return outcome;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface CheckpointMirrorBackfillResult {
|
|
288
|
+
checkpoints: number;
|
|
289
|
+
sessions: number;
|
|
290
|
+
/** Sessions with no rich checkpoint at all — nothing to mirror. */
|
|
291
|
+
sessionsWithoutRich: number;
|
|
292
|
+
/** Per-status counts; a dry run reports what a real run would do (would-embed / unchanged / skipped-stale). */
|
|
293
|
+
statuses: Record<string, number>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Pick each session's newest rich checkpoint (same order as compareCheckpointVersion). */
|
|
297
|
+
export function selectLatestRichPerSession(records: SessionCheckpointRecord[]): {
|
|
298
|
+
selected: SessionCheckpointRecord[];
|
|
299
|
+
sessions: number;
|
|
300
|
+
sessionsWithoutRich: number;
|
|
301
|
+
} {
|
|
302
|
+
const bySession = new Map<string, SessionCheckpointRecord[]>();
|
|
303
|
+
for (const record of records) {
|
|
304
|
+
const list = bySession.get(record.sessionId) ?? [];
|
|
305
|
+
list.push(record);
|
|
306
|
+
bySession.set(record.sessionId, list);
|
|
307
|
+
}
|
|
308
|
+
const selected: SessionCheckpointRecord[] = [];
|
|
309
|
+
let sessionsWithoutRich = 0;
|
|
310
|
+
for (const list of bySession.values()) {
|
|
311
|
+
const rich = list.filter((record) => classifyCheckpointQuality(record) === "rich");
|
|
312
|
+
if (rich.length === 0) {
|
|
313
|
+
sessionsWithoutRich++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
selected.push(rich.reduce((best, record) => (compareCheckpointVersion(record, best) > 0 ? record : best)));
|
|
317
|
+
}
|
|
318
|
+
selected.sort(compareCheckpointVersion);
|
|
319
|
+
return { selected, sessions: bySession.size, sessionsWithoutRich };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* One-time (and re-runnable) backfill: mirror every session's newest rich checkpoint, oldest
|
|
324
|
+
* first, one at a time. Rows already holding that version come back "unchanged", so a re-run
|
|
325
|
+
* embeds nothing new.
|
|
326
|
+
*/
|
|
327
|
+
export async function backfillCheckpointMirror(
|
|
328
|
+
deps: CheckpointMirrorDeps,
|
|
329
|
+
records: SessionCheckpointRecord[],
|
|
330
|
+
options: { dryRun?: boolean; onProgress?: (done: number, total: number) => void } = {},
|
|
331
|
+
): Promise<CheckpointMirrorBackfillResult> {
|
|
332
|
+
const { selected, sessions, sessionsWithoutRich } = selectLatestRichPerSession(records);
|
|
333
|
+
const statuses: Record<string, number> = {};
|
|
334
|
+
const bump = (status: string) => { statuses[status] = (statuses[status] ?? 0) + 1; };
|
|
335
|
+
for (const [index, record] of selected.entries()) {
|
|
336
|
+
if (options.dryRun) {
|
|
337
|
+
if (!checkpointMirrorEnabled(deps.offFile)) {
|
|
338
|
+
bump("disabled");
|
|
339
|
+
} else {
|
|
340
|
+
const existing = await deps.store.getById(checkpointMirrorId(record.sessionId));
|
|
341
|
+
const text = redactSecrets(buildCheckpointMirrorText(record)).text;
|
|
342
|
+
const decision = decideMirrorWrite(existing, record, text);
|
|
343
|
+
bump(decision !== "write" ? decision : existing?.text === text ? "would-refresh" : "would-embed");
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
bump((await mirrorCheckpoint(deps, record)).status);
|
|
347
|
+
}
|
|
348
|
+
options.onProgress?.(index + 1, selected.length);
|
|
349
|
+
}
|
|
350
|
+
return { checkpoints: records.length, sessions, sessionsWithoutRich, statuses };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A getById that only reads: connect + openTable + query, never MemoryStore's initialization
|
|
355
|
+
* (which may add columns, create the table or build the FTS index). For --dry-run, so that a
|
|
356
|
+
* dry run against the production store provably writes nothing.
|
|
357
|
+
*/
|
|
358
|
+
export async function openReadOnlyMirrorReader(dbPath: string): Promise<Pick<MemoryStore, "getById">> {
|
|
359
|
+
const lancedb = await loadLanceDB();
|
|
360
|
+
const table = await (await lancedb.connect(dbPath)).openTable("memories");
|
|
361
|
+
return {
|
|
362
|
+
async getById(id: string): Promise<MemoryEntry | null> {
|
|
363
|
+
const rows = await table.query()
|
|
364
|
+
.select(["id", "text", "category", "scope", "importance", "timestamp", "metadata"])
|
|
365
|
+
.where(`id = '${escapeSqlLiteral(id)}'`)
|
|
366
|
+
.limit(1)
|
|
367
|
+
.toArray();
|
|
368
|
+
if (rows.length === 0) return null;
|
|
369
|
+
const row = rows[0];
|
|
370
|
+
return {
|
|
371
|
+
id: String(row.id),
|
|
372
|
+
text: String(row.text),
|
|
373
|
+
vector: [],
|
|
374
|
+
category: row.category as MemoryEntry["category"],
|
|
375
|
+
scope: String(row.scope ?? ""),
|
|
376
|
+
importance: Number(row.importance),
|
|
377
|
+
timestamp: Number(row.timestamp),
|
|
378
|
+
metadata: String(row.metadata || "{}"),
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface CheckpointMirrorPurgeResult {
|
|
385
|
+
rowsBefore: number;
|
|
386
|
+
rowsAfter: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Rollback: turn the mirror off (create the off file), then — holding the mirror's write lock, so
|
|
391
|
+
* a write that already passed its own off-check finishes first — delete every row whose scope is
|
|
392
|
+
* exactly `checkpoint`. Writers that reach the lock afterwards see the off file and stop.
|
|
393
|
+
* Residual: a writer frozen for longer than the lock's 60 s expiry inside its millisecond-long
|
|
394
|
+
* locked section could still insert after the delete; count again a few minutes later.
|
|
395
|
+
*/
|
|
396
|
+
export async function purgeCheckpointMirror(
|
|
397
|
+
dbPath: string,
|
|
398
|
+
options: { offFile?: string; lockDir?: string } = {},
|
|
399
|
+
): Promise<CheckpointMirrorPurgeResult> {
|
|
400
|
+
const offFile = options.offFile ?? DEFAULT_OFF_FILE;
|
|
401
|
+
mkdirSync(dirname(offFile), { recursive: true });
|
|
402
|
+
writeFileSync(offFile, `checkpoint mirror turned off for purge at ${new Date().toISOString()}\n`);
|
|
403
|
+
const exact = `scope = '${escapeSqlLiteral(CHECKPOINT_MIRROR_SCOPE)}'`;
|
|
404
|
+
return withWriteLock(MIRROR_LOCK_KEY, async () => {
|
|
405
|
+
// Open the table only once the lock is held: a handle opened earlier keeps the version it
|
|
406
|
+
// saw and would miss a row another process committed while we waited (互审 Codex 复看 N4).
|
|
407
|
+
const table = await (await (await loadLanceDB()).connect(dbPath)).openTable("memories");
|
|
408
|
+
const rowsBefore = await table.countRows(exact);
|
|
409
|
+
if (rowsBefore > 0) {
|
|
410
|
+
await withWriteLock("store-write", async () => { await table.delete(exact); }, {
|
|
411
|
+
...STORE_WRITE_LOCK_OPTS,
|
|
412
|
+
...(options.lockDir ? { lockDir: options.lockDir } : {}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return { rowsBefore, rowsAfter: await table.countRows(exact) };
|
|
416
|
+
}, { expireMs: 60_000, waitTimeoutMs: 2 * STORE_WRITE_NESTED_WAIT_MS, ...(options.lockDir ? { lockDir: options.lockDir } : {}) });
|
|
417
|
+
}
|