@staix/agent-mem 0.3.1 → 0.5.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.
@@ -0,0 +1,562 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacySegment } from "./bridge.ts";
5
+ import { canonicalJSON, generationDescriptor, generationDir, hashJSON, readProfileJSON, readSelector, validGeneration, validateGeneration, withProfileLock, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLinkRow } from "./generation.ts";
6
+ import { coalesceGenerationSource, explicitExpirySnapshot } from "./generation-index.ts";
7
+ import { pruneId, prunePayloadHash, reducePruneRows, validatePruneRow, type PruneRow, type PruneSourceRow } from "./prune.ts";
8
+ import { acquireMutation } from "./mutation.ts";
9
+ import { canonicalPath, realPath } from "./platform.ts";
10
+ import { assertNoLiveReaders } from "./reader-lease.ts";
11
+ import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
12
+ import { buildGenerationIndex, type Entry } from "./store.ts";
13
+ import { readMigrationSources, type SourcePrefix } from "./migrate.ts";
14
+
15
+ const USER = Symbol("shared-user-shard");
16
+ type Destination = string | typeof USER;
17
+ const JOURNAL = "shards.json";
18
+ const CONSEQUENCE = "Legacy bytes are never rewritten; unannounced older readers cannot be detected and remain safe only because of that rule. Before splitting a live home, ensure every installed host uses openShardStore; ordinary new MemoryStore opens are refused once the shard marker exists. No host capability registry is available to prove an absent host safe.";
19
+
20
+ interface ShardJournal {
21
+ schema: typeof SHARDED_SCHEMA;
22
+ generation: string;
23
+ sourceGeneration?: string;
24
+ phase: "captured" | "active";
25
+ selectorRevision: string;
26
+ sources: SourcePrefix[];
27
+ machine: { id: string; label: string };
28
+ legacyCommit?: string;
29
+ deadLeasesRemoved?: DeadLeaseRemoval[];
30
+ }
31
+ export interface DeadLeaseRemoval { lease: string; at: string }
32
+ export interface ShardMigrationCounts { rows: number; skippedRows: number; byShard: Record<string, number>; userRows: number; byProject: Record<string, number>; tailBytes: number;
33
+ legacyRows: number; generationRows: number; generationLocalRows: number; pruneRows: number; translatedPruneRows: number; refusedRows: number; sharedSupersededByPrivate: number; sharedSupersededByPrivateIds: string[]; crossPrivateReplacement: number; crossPrivateReplacementIds: string[]; danglingRows: number; danglingIds: string[]; legacyLocalRows: number; legacyMainRows: number }
34
+ export interface ShardMigrationResult { mode: ShardMigrationOptions["mode"]; generation: string; active: boolean; alreadyActive?: boolean; counts: ShardMigrationCounts; consequence: string; deadLeasesRemoved?: DeadLeaseRemoval[]; refusals?: string[] }
35
+ export interface ShardMigrationOptions {
36
+ memoryDir: string;
37
+ mode: "dry-run" | "apply" | "resume";
38
+ generation?: string;
39
+ authorizedPerson: boolean;
40
+ tty: boolean;
41
+ checkpoint?: (stage: "after-shard" | "before-active") => void | Promise<void>;
42
+ /** Test seam: runs after the last preflight and reader admission, before the destination shard locks are taken. */
43
+ beforeCapture?: () => void | Promise<void>;
44
+ }
45
+ interface Capture { segments: LegacySegment[]; sources: SourcePrefix[]; legacyCommit?: string; sourceGeneration?: string }
46
+ interface PruneTranslation { schema: "agent-mem.shard-prune-translation/v1"; sourceId: string; sourceHash: string; translatedId: string }
47
+ interface RoutedLine { id: string; line: string; source: string; path: string; end: number; local: boolean; destinations: Destination[]; translation?: PruneTranslation }
48
+ interface RoutePlan extends ShardMigrationCounts { lines: RoutedLine[]; destinations: Set<Destination> }
49
+
50
+ const changed = (): never => { throw new Error("shard migration source or selector changed; inspect local migration state before continuing"); };
51
+ const destinationDir = (memoryDir: string, destination: Destination) => destination === USER ? userShardDir(memoryDir) : shardDir(memoryDir, destination);
52
+ const hashBytes = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
53
+ const sourceKey = (source: string, path: string) => `${source}:${path}`;
54
+ const realmRoot = (dir: string, source: string) => source === "generation" ? dir : join(dir, source === "legacy-main" ? "legacy-main" : "legacy");
55
+ const emptyCounts = (): ShardMigrationCounts => ({ rows: 0, skippedRows: 0, byShard: {}, userRows: 0, byProject: {}, tailBytes: 0, legacyRows: 0, generationRows: 0, generationLocalRows: 0, pruneRows: 0, translatedPruneRows: 0, refusedRows: 0, sharedSupersededByPrivate: 0, sharedSupersededByPrivateIds: [], crossPrivateReplacement: 0, crossPrivateReplacementIds: [], danglingRows: 0, danglingIds: [], legacyLocalRows: 0, legacyMainRows: 0 });
56
+
57
+ function identity(memoryDir: string): { id: string; label: string } {
58
+ const value = readProfileJSON(join(memoryDir, "machine.json"), 4096) as { id?: unknown; label?: unknown } | undefined;
59
+ if (!value || typeof value.id !== "string" || !/^[a-f0-9]{16}$/.test(value.id) || typeof value.label !== "string") throw new Error("initialize a valid legacy machine identity before shard migration");
60
+ return { id: value.id, label: value.label };
61
+ }
62
+
63
+ function readJournal(memoryDir: string, optional = false): ShardJournal | undefined {
64
+ const value = readProfileJSON(join(memoryDir, JOURNAL)) as Partial<ShardJournal> | undefined;
65
+ if (!value) {
66
+ if (optional) return undefined;
67
+ throw new Error("no resumable shard migration journal");
68
+ }
69
+ if (value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || !["captured", "active"].includes(value.phase ?? "")
70
+ || value.sourceGeneration !== undefined && (!validGeneration(value.sourceGeneration) || value.sourceGeneration !== value.generation)
71
+ || typeof value.selectorRevision !== "string" || !Array.isArray(value.sources) || !value.machine
72
+ || typeof value.machine.id !== "string" || typeof value.machine.label !== "string") throw new Error("invalid shard migration journal");
73
+ if (value.deadLeasesRemoved !== undefined && (!Array.isArray(value.deadLeasesRemoved) || value.deadLeasesRemoved.length > 128 || value.deadLeasesRemoved.some(row =>
74
+ !row || typeof row !== "object" || Object.keys(row).some(key => !["lease", "at"].includes(key)) || typeof row.lease !== "string" || !/^[1-9][0-9]*-[0-9a-f]{64}\.json$/.test(row.lease) ||
75
+ typeof row.at !== "string" || Number.isNaN(Date.parse(row.at)) || new Date(row.at).toISOString() !== row.at))) throw new Error("invalid removed reader lease audit");
76
+ return value as ShardJournal;
77
+ }
78
+
79
+ function capture(memoryDir: string, generation: string | undefined, previous?: ShardJournal, sourceGeneration?: string): Capture {
80
+ const origin = sourceGeneration ?? previous?.sourceGeneration;
81
+ const bridgeGeneration = origin ?? generation;
82
+ const legacyCommit = bridgeGeneration ? legacyHead(memoryDir, bridgeGeneration) : undefined;
83
+ const segments = readLegacySnapshot(memoryDir, {
84
+ ...(bridgeGeneration ? { generation: bridgeGeneration } : {}), ...(legacyCommit ? { commit: legacyCommit } : {}),
85
+ ...(bridgeGeneration && previous?.legacyCommit ? { previousCommit: previous.legacyCommit } : {}),
86
+ ...(previous ? { checkpoints: previous.sources.filter(source => source.source !== "generation") } : {}),
87
+ });
88
+ if (origin) {
89
+ validateGeneration(memoryDir, origin);
90
+ const root = generationDir(memoryDir, origin), reader = new SegmentReader(), returned = new Set<string>();
91
+ for (const folder of ["log", "local"]) {
92
+ const dir = join(root, folder), stat = lstatSync(dir);
93
+ if (stat.isSymbolicLink() || !stat.isDirectory()) changed();
94
+ for (const name of readdirSync(dir).sort().filter(name => name.endsWith(".jsonl"))) {
95
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(name)) changed();
96
+ const path = `${folder}/${name}`, prior = previous?.sources.find(source => source.source === "generation" && source.path === path);
97
+ const data = reader.read(join(dir, name), prior, true);
98
+ if (data.tailBytes) throw new Error("selected generation has an incomplete authored tail; finish its original recovery before shard migration");
99
+ segments.push({ ...data, source: "generation", path, local: folder === "local" }); returned.add(path);
100
+ }
101
+ }
102
+ for (const old of previous?.sources ?? []) if (old.source === "generation" && !returned.has(old.path)) changed();
103
+ }
104
+ return { segments, sources: segments.map(({ bytes: _bytes, local: _local, ...source }) => source), ...(legacyCommit ? { legacyCommit } : {}), ...(origin ? { sourceGeneration: origin } : {}) };
105
+ }
106
+
107
+ function* completeLines(segment: LegacySegment): Generator<{ line?: string; end: number }> {
108
+ let offset = 0;
109
+ while (offset < segment.bytes.length) {
110
+ const end = segment.bytes.indexOf(10, offset);
111
+ if (end < 0) changed();
112
+ const raw = segment.bytes.subarray(offset, end); offset = end + 1;
113
+ let line: string;
114
+ try { line = new TextDecoder("utf-8", { fatal: true }).decode(raw); }
115
+ catch { if (segment.source === "generation") throw new Error("invalid selected generation entry"); yield { end: offset }; continue; }
116
+ yield { line, end: offset };
117
+ }
118
+ }
119
+
120
+ function route(captured: Capture, completed?: readonly SourcePrefix[], dryRun = false): RoutePlan {
121
+ const legacy = legacyEntries(captured.segments.filter(segment => segment.source !== "generation"));
122
+ const byId = new Map(legacy.map(row => [row.id, row])), snapshotRows = new Map<string, PruneSourceRow>();
123
+ const occurrences: { row: Entry; line: string; end: number; segment: LegacySegment }[] = [];
124
+ const vaultLinks: { row: VaultLinkRow; line: string; end: number; segment: LegacySegment }[] = [];
125
+ const prunes: { row: PruneRow; line: string; end: number; segment: LegacySegment }[] = [];
126
+ const completedBySource = new Map((completed ?? []).map(source => [sourceKey(source.source, source.path), source.offset]));
127
+ const result = emptyCounts();
128
+ for (const segment of captured.segments) for (const item of completeLines(segment)) {
129
+ const isNew = item.end > (completedBySource.get(sourceKey(segment.source, segment.path)) ?? 0);
130
+ if (item.line === undefined) { if (isNew) result.skippedRows++; continue; }
131
+ if (!item.line.trim()) continue;
132
+ let value: unknown;
133
+ try { value = JSON.parse(item.line); } catch {
134
+ if (segment.source === "generation") throw new Error("invalid selected generation entry");
135
+ if (isNew) result.skippedRows++; continue;
136
+ }
137
+ if (segment.source === "generation" && value && typeof value === "object" && (value as { op?: unknown }).op === "prune") {
138
+ const row = validatePruneRow(value, captured.sourceGeneration, segment.local ? "local" : "synced");
139
+ if (segment.path !== `${segment.local ? "local" : "log"}/${row.machine}.jsonl`) throw new Error("selected generation prune placement mismatch");
140
+ prunes.push({ row, line: item.line, end: item.end, segment }); continue;
141
+ }
142
+ if (segment.source === "generation" && value && typeof value === "object" && (value as { op?: unknown }).op === "vault_link") {
143
+ const row = validateVaultLinkRow(value, captured.sourceGeneration, segment.local ? "local" : "synced");
144
+ if (segment.path !== `local/${row.machine}.jsonl`) throw new Error("selected generation vault link placement mismatch");
145
+ vaultLinks.push({ row, line: item.line, end: item.end, segment }); continue;
146
+ }
147
+ let row: Entry | undefined;
148
+ if (segment.source === "generation") {
149
+ const bytes = Buffer.from(item.line + "\n");
150
+ row = legacyEntries([{ ...segment, bytes, offset: bytes.length, hash: hashBytes(bytes) }], { strict: true })[0];
151
+ if (!row) throw new Error("invalid selected generation entry");
152
+ } else {
153
+ const id = value && typeof value === "object" ? (value as { id?: unknown }).id : undefined;
154
+ row = typeof id === "string" ? byId.get(id) : undefined;
155
+ if (!row || canonicalJSON(value) !== canonicalJSON(row)) { if (isNew) result.skippedRows++; continue; }
156
+ }
157
+ snapshotRows.set(row.id, coalesceGenerationSource(snapshotRows.get(row.id), row, segment));
158
+ byId.set(row.id, row); occurrences.push({ row, line: item.line, end: item.end, segment });
159
+ }
160
+ const entries = [...byId.values()], owners = new Map<string, Destination>();
161
+ for (const row of entries) if (row.op === "note" || row.op === "goal") owners.set(row.id, row.scope === "shared" ? USER : row.project);
162
+ const snapshot = { rows: [...snapshotRows.values()], expiries: explicitExpirySnapshot([...snapshotRows.values()]) };
163
+ const proof = reducePruneRows(prunes.map(({ row, segment }) => ({ row, placement: segment.local ? "local" as const : "synced" as const })), snapshot, captured.sourceGeneration ?? "0".repeat(32));
164
+ if (proof.invalid || proof.pending.length) {
165
+ result.refusedRows += proof.invalid + proof.pending.length;
166
+ if (!dryRun) throw new Error("selected generation prune proof is invalid or incomplete; no shard migration effects were applied");
167
+ }
168
+ const ordered = entries.toSorted((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
169
+ const copies = new Map<string, string>(), descendants = new Map<string, string[]>();
170
+ for (const row of ordered) if (row.op === "note" && row.promotedFrom) {
171
+ copies.set(row.promotedFrom, row.id);
172
+ const children = descendants.get(row.promotedFrom) ?? []; children.push(row.id); descendants.set(row.promotedFrom, children);
173
+ }
174
+ const lastCopy = (id: string): string => {
175
+ const seen = new Set<string>(); let at = id;
176
+ while (copies.has(at)) { if (seen.has(at)) throw new Error("cyclic promotion input; person inspection required"); seen.add(at); at = copies.get(at)!; }
177
+ return at;
178
+ };
179
+ const promotionOwners = (id: string): Destination[] => {
180
+ const seen = new Set<string>([id]), pending = [id], found = new Set<Destination>();
181
+ for (let index = 0; index < pending.length; index++) {
182
+ const at = pending[index]!, owner = owners.get(at); if (owner !== undefined) found.add(owner);
183
+ for (const child of descendants.get(at) ?? []) { if (seen.has(child)) throw new Error("cyclic promotion input; person inspection required"); seen.add(child); pending.push(child); }
184
+ }
185
+ return [...found];
186
+ };
187
+ const forgotten = new Set(entries.flatMap(row => row.op === "forget" ? [row.target] : []));
188
+ const gone = (id: string) => forgotten.has(id) || proof.targets.has(id) || (byId.get(id)?.op === "note" && forgotten.has((byId.get(id) as { promotedFrom?: string }).promotedFrom ?? ""));
189
+ const replacements = new Map<string, { next: string; origin: Entry; when: string }>();
190
+ const relate = (old: string, next: string, origin: Entry) => {
191
+ const when = `${origin.at} ${next}`;
192
+ if (when >= (replacements.get(old)?.when ?? "")) replacements.set(old, { next, origin, when });
193
+ };
194
+ for (const row of ordered) {
195
+ if (row.op === "note" && row.supersedes) relate(row.supersedes, row.id, row);
196
+ if (row.op === "supersede" && row.by) relate(row.target, row.by, row);
197
+ }
198
+ for (const [old, relation] of replacements) {
199
+ const target = lastCopy(old), terminal = lastCopy(relation.next);
200
+ if (gone(terminal) || !owners.has(target)) continue;
201
+ // An embedded relation in another private body needs a witness even when its copy is shared.
202
+ // Explicit supersede ops are safe metadata and follow promotion owners below.
203
+ const visibleOrigin = relation.origin.op !== "note" || owners.get(relation.origin.id) === USER || owners.get(relation.origin.id) === owners.get(target);
204
+ const equivalentOp = !visibleOrigin && entries.some(row => row.op === "supersede" && row.target === old && row.by && lastCopy(row.by) === terminal);
205
+ const next = visibleOrigin || equivalentOp ? terminal : relation.next;
206
+ if (!owners.has(next) && relation.origin.op === "supersede") continue; // IDs-only dangling metadata remains inert until its replacement arrives.
207
+ if (!owners.has(next)) { result.refusedRows++; if (!dryRun) throw new Error("replacement terminal identity is unavailable; person inspection required"); continue; }
208
+ if (owners.get(target) === owners.get(next) || owners.get(next) === USER) continue;
209
+ // ponytail: a body-free lifecycle witness can remove this shared->private ceiling; never copy private replacement text.
210
+ // Expiry without an immutable forget/prune observation stays conservatively live here.
211
+ result.refusedRows++;
212
+ if (owners.get(target) === USER) {
213
+ result.sharedSupersededByPrivateIds.push(target);
214
+ if (!dryRun) throw new Error(`shared superseded by private (${target}); un-supersede, or promote the replacement to shared, then rerun shard migration`);
215
+ } else {
216
+ result.crossPrivateReplacementIds.push(target);
217
+ if (!dryRun) throw new Error(`cross-project private replacement (${target}); un-supersede, or promote the replacement to shared, then rerun shard migration`);
218
+ }
219
+ }
220
+ result.sharedSupersededByPrivateIds = [...new Set(result.sharedSupersededByPrivateIds)].sort(); result.sharedSupersededByPrivate = result.sharedSupersededByPrivateIds.length;
221
+ result.crossPrivateReplacementIds = [...new Set(result.crossPrivateReplacementIds)].sort(); result.crossPrivateReplacement = result.crossPrivateReplacementIds.length;
222
+ const suggestions = new Map<string, Destination[]>(), dangling = new Set<string>();
223
+ const carryDangling = (row: Entry, known: Destination[] = []): Destination[] => {
224
+ dangling.add(row.id); return [...new Set<Destination>([USER, ...known])];
225
+ };
226
+ const destinationsOf = (row: Entry): Destination[] => {
227
+ if (row.op === "note" || row.op === "goal") return [owners.get(row.id)!];
228
+ if (row.op === "settle") {
229
+ const project = row.target.split("#", 1)[0];
230
+ if (!project) return carryDangling(row);
231
+ return [...new Set<Destination>([project, ...entries.filter(entry => entry.op === "note" && entry.task === row.target).map(entry => owners.get(entry.id)!)])];
232
+ }
233
+ if (row.op === "suggest") {
234
+ const split = row.by?.indexOf(":") ?? -1, to = split < 0 ? "" : row.by!.slice(split + 1);
235
+ const from = owners.get(row.target), other = owners.get(to);
236
+ const destinations = from === undefined || other === undefined ? carryDangling(row, [from, other].filter((value): value is Destination => value !== undefined)) : [...new Set<Destination>([from, other])];
237
+ suggestions.set(row.id, destinations); return destinations;
238
+ }
239
+ if (row.op === "confirm" || row.op === "reject") {
240
+ const destinations = suggestions.get(row.target);
241
+ if (!destinations) return carryDangling(row);
242
+ return destinations;
243
+ }
244
+ const owner = owners.get(row.target);
245
+ if (owner === undefined) return carryDangling(row);
246
+ if (row.op === "supersede" && row.by && !owners.has(lastCopy(row.by))) return carryDangling(row, promotionOwners(row.target));
247
+ return row.op === "forget" || row.op === "supersede" ? promotionOwners(row.target) : [owner];
248
+ };
249
+ for (const row of entries) if (row.op === "suggest") destinationsOf(row);
250
+ const destinations = new Set<Destination>([USER, ...owners.values()]), lines: RoutedLine[] = [];
251
+ for (const { row, line, end, segment } of occurrences) {
252
+ const targets = destinationsOf(row); for (const target of targets) destinations.add(target);
253
+ if (end <= (completedBySource.get(sourceKey(segment.source, segment.path)) ?? 0)) continue;
254
+ lines.push({ id: row.id, line, source: segment.source, path: segment.path, end, local: segment.local, destinations: targets });
255
+ if (segment.source === "generation") { result.generationRows++; if (segment.local) result.generationLocalRows++; }
256
+ else { result.legacyRows++; if (segment.source === "legacy-main") result.legacyMainRows++; else result.legacyLocalRows++; }
257
+ }
258
+ result.pruneRows = prunes.filter(input => input.end > (completedBySource.get(sourceKey(input.segment.source, input.segment.path)) ?? 0)).length;
259
+ for (const input of proof.invalid || proof.pending.length ? [] : prunes) {
260
+ const groups = new Map<Destination, PruneRow["targets"]>();
261
+ for (const target of input.row.targets) {
262
+ const destination = owners.get(target.id);
263
+ if (destination === undefined) throw new Error("prune target has no shard owner");
264
+ const group = groups.get(destination) ?? []; group.push(target); groups.set(destination, group);
265
+ }
266
+ for (const [destination, targets] of groups) {
267
+ destinations.add(destination);
268
+ const { id: _id, ...body } = input.row;
269
+ const row = groups.size === 1 ? input.row : { ...body, targets, id: pruneId({ ...body, targets }) };
270
+ validatePruneRow(row, captured.sourceGeneration, input.segment.local ? "local" : "synced");
271
+ const subset = snapshot.rows.filter(source => destinationsOf(source.row).includes(destination));
272
+ const translatedProof = reducePruneRows([{ row, placement: input.segment.local ? "local" : "synced" }], { rows: subset, expiries: explicitExpirySnapshot(subset) }, input.row.generation);
273
+ if (translatedProof.invalid || translatedProof.pending.length) throw new Error("per-shard prune proof is incomplete; no shard migration effects were applied");
274
+ if (input.end <= (completedBySource.get(sourceKey(input.segment.source, input.segment.path)) ?? 0)) continue;
275
+ lines.push({ id: row.id, line: groups.size === 1 ? input.line : canonicalJSON(row), source: "generation", path: input.segment.path, end: input.end, local: input.segment.local, destinations: [destination],
276
+ ...(groups.size > 1 ? { translation: { schema: "agent-mem.shard-prune-translation/v1" as const, sourceId: input.row.id, sourceHash: prunePayloadHash(input.row), translatedId: row.id } } : {}) });
277
+ if (groups.size > 1) result.translatedPruneRows++;
278
+ }
279
+ }
280
+ for (const input of vaultLinks) {
281
+ const witness = byId.get(input.row.target), destination = owners.get(input.row.target);
282
+ if (!witness || witness.op !== "note" || witness.project !== input.row.project || hashJSON(witness) !== input.row.rowHash || destination === undefined) throw new Error("vault link exact target has no shard witness");
283
+ destinations.add(destination);
284
+ if (input.end <= (completedBySource.get(sourceKey(input.segment.source, input.segment.path)) ?? 0)) continue;
285
+ lines.push({ id: input.row.id, line: input.line, source: "generation", path: input.segment.path, end: input.end, local: true, destinations: [destination] });
286
+ result.generationRows++; result.generationLocalRows++;
287
+ }
288
+ result.danglingIds = [...dangling].sort(); result.danglingRows = dangling.size;
289
+ const ordering = new Map(captured.segments.map((segment, index) => [sourceKey(segment.source, segment.path), index]));
290
+ lines.sort((a, b) => ordering.get(sourceKey(a.source, a.path))! - ordering.get(sourceKey(b.source, b.path))! || a.end - b.end);
291
+ const increment = (record: Record<string, number>, key: string) => Object.defineProperty(record, key, { value: (Object.hasOwn(record, key) ? record[key]! : 0) + 1, enumerable: true, writable: true, configurable: true });
292
+ for (const line of lines) for (const destination of line.destinations) { increment(result.byShard, destination === USER ? "user" : destination); if (destination === USER) result.userRows++; else increment(result.byProject, destination); }
293
+ return { ...result, lines, destinations, rows: lines.length, tailBytes: captured.sources.reduce((sum, source) => sum + source.tailBytes, 0) };
294
+ }
295
+
296
+ function prepareShard(memoryDir: string, destination: Destination, generation: string, machine: { id: string; label: string }): string {
297
+ const dir = destinationDir(memoryDir, destination);
298
+ if (destination !== USER) ensureShardDescriptor(memoryDir, destination);
299
+ for (const folder of ["log", "local", "legacy/log", "legacy/local", "legacy-main/log", "legacy-main/local"]) mkdirSync(join(dir, folder), { recursive: true, mode: 0o700 });
300
+ for (const [path, value] of [["generation.json", generationDescriptor(generation)], ["machine.json", machine], ["legacy/machine.json", machine], ["legacy-main/machine.json", machine]] as const) {
301
+ const file = join(dir, path), prior = readProfileJSON(file);
302
+ if (prior === undefined) writeProfileJSON(file, value);
303
+ else if (path === "generation.json" ? canonicalJSON(prior) !== canonicalJSON(value) : (prior as { id?: unknown }).id !== machine.id) changed();
304
+ }
305
+ return dir;
306
+ }
307
+
308
+ /** An interrupted mkdir may precede machine.json; only byte-empty, regular segments lack provenance safely. */
309
+ function copiedBridge(root: string): LegacySegment[] {
310
+ const refused = (): never => { throw new Error("copied bridge identity is unavailable; inspect its original initialization or writer recovery before resuming"); };
311
+ const dir = lstatSync(root, { throwIfNoEntry: false });
312
+ if (!dir) return [];
313
+ if (dir.isSymbolicLink() || !dir.isDirectory()) return refused();
314
+ const machine = lstatSync(join(root, "machine.json"), { throwIfNoEntry: false });
315
+ if (machine) {
316
+ if (machine.isSymbolicLink() || !machine.isFile()) return refused();
317
+ return readLegacySnapshot(root);
318
+ }
319
+ for (const name of readdirSync(root)) {
320
+ if (name !== "log" && name !== "local") return refused();
321
+ const folder = join(root, name), stat = lstatSync(folder);
322
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return refused();
323
+ for (const file of readdirSync(folder)) {
324
+ const path = join(folder, file), entry = lstatSync(path);
325
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(file) || entry.isSymbolicLink() || !entry.isFile() || entry.size !== 0) return refused();
326
+ }
327
+ }
328
+ return [];
329
+ }
330
+
331
+ function existingRows(dir: string, generation: string): { ids: Map<string, Set<string>>; tails: Map<string, Buffer> } {
332
+ const ids = new Map<string, Set<string>>(), tails = new Map<string, Buffer>();
333
+ const legacy = copiedBridge(join(dir, "legacy"));
334
+ const main = copiedBridge(join(dir, "legacy-main")).map(segment => ({ ...segment, source: "legacy-main" }));
335
+ const own: LegacySegment[] = [], reader = new SegmentReader();
336
+ for (const folder of ["log", "local"]) for (const name of (existsSync(join(dir, folder)) ? readdirSync(join(dir, folder)) : []).sort().filter(name => name.endsWith(".jsonl"))) {
337
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(name)) changed();
338
+ own.push({ ...reader.read(join(dir, folder, name), undefined, true), source: "generation", path: `${folder}/${name}`, local: folder === "local" });
339
+ }
340
+ for (const segment of [...legacy, ...main, ...own]) {
341
+ const key = sourceKey(segment.source, segment.path), known = new Set<string>();
342
+ for (const item of completeLines(segment)) {
343
+ if (item.line === undefined) throw new Error("copied shard input is not valid UTF-8");
344
+ if (!item.line.trim()) continue;
345
+ const value = JSON.parse(item.line) as { op?: unknown; id?: unknown };
346
+ if (segment.source === "generation" && value.op === "prune") known.add(validatePruneRow(value, generation, segment.local ? "local" : "synced").id);
347
+ else if (segment.source === "generation" && value.op === "vault_link") known.add(validateVaultLinkRow(value, generation, segment.local ? "local" : "synced").id);
348
+ else {
349
+ const bytes = Buffer.from(item.line + "\n");
350
+ const row = legacyEntries([{ ...segment, bytes, offset: bytes.length, hash: hashBytes(bytes) }], { strict: segment.source === "generation" })[0];
351
+ if (row) known.add(row.id);
352
+ }
353
+ }
354
+ ids.set(key, known);
355
+ if (segment.tailBytes) tails.set(key, readFileSync(join(realmRoot(dir, segment.source), segment.path)).subarray(segment.offset));
356
+ }
357
+ return { ids, tails };
358
+ }
359
+
360
+ /** Read-only append plan shared by preflight and the eventual receipt writer. */
361
+ function receiptRemainder(dir: string, receipts: readonly PruneTranslation[]): Buffer {
362
+ const path = join(dir, "prune-translations.jsonl"), bytes = existsSync(path) ? readFileSync(path) : Buffer.alloc(0);
363
+ const end = bytes.lastIndexOf(10) + 1, complete = bytes.subarray(0, end), tail = bytes.subarray(end);
364
+ const known = new Map<string, PruneTranslation>();
365
+ for (const line of new TextDecoder("utf-8", { fatal: true }).decode(complete).split("\n").filter(Boolean)) {
366
+ const row = JSON.parse(line) as PruneTranslation;
367
+ if (row.schema !== "agent-mem.shard-prune-translation/v1" || !/^[a-f0-9]{32}$/.test(row.sourceId) || !/^[a-f0-9]{32}$/.test(row.translatedId) || !/^[a-f0-9]{64}$/.test(row.sourceHash)) throw new Error("invalid shard prune translation receipt; inspect its original recovery");
368
+ const prior = known.get(row.translatedId); if (prior && canonicalJSON(prior) !== canonicalJSON(row)) changed();
369
+ known.set(row.translatedId, row);
370
+ }
371
+ const pending: Buffer[] = [];
372
+ for (const receipt of receipts) {
373
+ const prior = known.get(receipt.translatedId);
374
+ if (prior) { if (canonicalJSON(prior) !== canonicalJSON(receipt)) changed(); continue; }
375
+ pending.push(Buffer.from(canonicalJSON(receipt) + "\n")); known.set(receipt.translatedId, receipt);
376
+ }
377
+ if (tail.length && (!pending[0] || tail.length >= pending[0].length || !pending[0].subarray(0, tail.length).equals(tail))) throw new Error("shard prune translation receipt tail does not match; inspect its original recovery before resuming");
378
+ return Buffer.concat(pending).subarray(tail.length);
379
+ }
380
+
381
+ /** #176 R2: the person-run recovery for a shard file whose last row a crashed writer left unfinished (docs/curated-shard-migration.md). */
382
+ const tornTail = (memoryDir: string, file: string) => `curated shard copied tail cannot be completed at ${relative(memoryDir, file)}: a writer stopped mid-row. Recover by hand: stop every amem and agent-hub process using this memory home, keep a copy of the file, cut it back to its last newline (the cut bytes are an unfinished row no reader has indexed), then run amem migrate --shards --resume`;
383
+
384
+ function assessDestination(memoryDir: string, destination: Destination, plan: RoutePlan, generation: string, machine: { id: string; label: string }) {
385
+ const dir = destinationDir(memoryDir, destination);
386
+ for (const [path, value] of [["generation.json", generationDescriptor(generation)], ["machine.json", machine], ["legacy/machine.json", machine], ["legacy-main/machine.json", machine]] as const) {
387
+ const prior = readProfileJSON(join(dir, path));
388
+ if (prior !== undefined && (path === "generation.json" ? canonicalJSON(prior) !== canonicalJSON(value) : (prior as { id?: unknown }).id !== machine.id)) changed();
389
+ }
390
+ if (destination !== USER) {
391
+ const descriptor = readProfileJSON(join(dir, "project.json")) as { schema?: unknown; key?: unknown; local?: unknown } | undefined;
392
+ if (descriptor && (descriptor.schema !== SHARD_SCHEMA || descriptor.key !== destination || typeof descriptor.local !== "boolean")) changed();
393
+ }
394
+ const existing = existingRows(dir, generation), completions = new Map<string, { line: RoutedLine; remainder: Buffer }>();
395
+ const lines = plan.lines.filter(line => line.destinations.includes(destination));
396
+ for (const [key, tail] of existing.tails) {
397
+ const next = lines.find(line => sourceKey(line.source, line.path) === key && !existing.ids.get(key)?.has(line.id));
398
+ // A native writer's unrelated tail is not a migration-owned append or recovery record.
399
+ if (!next && key.startsWith("generation:")) continue;
400
+ const bytes = next && Buffer.from(next.line + "\n");
401
+ if (!bytes || tail.length >= bytes.length || !bytes.subarray(0, tail.length).equals(tail)) throw new Error(tornTail(memoryDir, join(realmRoot(dir, key.slice(0, key.indexOf(":"))), key.slice(key.indexOf(":") + 1))));
402
+ completions.set(key, { line: next!, remainder: bytes.subarray(tail.length) });
403
+ }
404
+ const receiptBytes = receiptRemainder(dir, lines.flatMap(line => line.translation ? [line.translation] : []));
405
+ return { existing, completions, receiptBytes };
406
+ }
407
+
408
+ function writeRoutes(memoryDir: string, plan: RoutePlan, generation: string, machine: { id: string; label: string }): Set<Destination> {
409
+ const affected = new Set<Destination>();
410
+ for (const destination of plan.destinations) {
411
+ const assessed = assessDestination(memoryDir, destination, plan, generation, machine);
412
+ const dir = prepareShard(memoryDir, destination, generation, machine), { existing } = assessed;
413
+ if (!existsSync(join(dir, "generation-seal.json"))) affected.add(destination);
414
+ for (const [key, completion] of assessed.completions) {
415
+ const line = completion.line;
416
+ appendFileSync(join(realmRoot(dir, line.source), line.path), completion.remainder, { mode: 0o600 });
417
+ if (!existing.ids.has(key)) existing.ids.set(key, new Set()); existing.ids.get(key)!.add(line.id); affected.add(destination);
418
+ }
419
+ for (const line of plan.lines) {
420
+ if (!line.destinations.includes(destination)) continue;
421
+ const key = sourceKey(line.source, line.path);
422
+ if (!existing.ids.get(key)?.has(line.id)) {
423
+ const target = join(realmRoot(dir, line.source), line.path);
424
+ if (existsSync(target)) { const current = readFileSync(target); if (current.length && current.at(-1) !== 10) throw new Error(tornTail(memoryDir, target)); }
425
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); appendFileSync(target, line.line + "\n", { mode: 0o600 });
426
+ if (!existing.ids.has(key)) existing.ids.set(key, new Set()); existing.ids.get(key)!.add(line.id); affected.add(destination);
427
+ }
428
+ }
429
+ if (assessed.receiptBytes.length) appendFileSync(join(dir, "prune-translations.jsonl"), assessed.receiptBytes, { mode: 0o600 });
430
+ }
431
+ return affected;
432
+ }
433
+
434
+ /** Each destination shard's own writer lock, taken in a fixed order; a busy one refuses before any journal change. */
435
+ function lockDestinations(memoryDir: string, destinations: Iterable<Destination>): () => void {
436
+ const releases: (() => void)[] = [];
437
+ const releaseAll = () => { for (const release of releases.splice(0).reverse()) release(); };
438
+ try {
439
+ for (const dir of [...destinations].map(destination => destinationDir(memoryDir, destination)).sort()) {
440
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
441
+ const release = acquireMutation(dir);
442
+ if (!release) throw new Error("curated shard writer lock is busy; retry the shard migration once that writer finishes");
443
+ releases.push(release);
444
+ }
445
+ } catch (error) { releaseAll(); throw error; }
446
+ return releaseAll;
447
+ }
448
+
449
+ function buildShard(memoryDir: string, destination: Destination, generation: string, selectorRevision: string): void {
450
+ const dir = destinationDir(memoryDir, destination), legacyHome = join(dir, "legacy"), legacyMainHome = join(dir, "legacy-main");
451
+ const sources = [...readMigrationSources(legacyHome), ...readMigrationSources(legacyMainHome).map(source => ({ ...source, source: "legacy-main" }))];
452
+ writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
453
+ const profile: BoundProfile = { kind: "generation", home: memoryDir, dir, generation, selectorRevision,
454
+ legacyHome, legacyMainHome, sharded: true, ...(destination === USER ? { shardUser: true } : { shardKey: destination }) };
455
+ buildGenerationIndex(profile, sources);
456
+ writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
457
+ descriptorHash: hashJSON(generationDescriptor(generation)), sourceDigest: hashJSON({ sources, legacyCommit: undefined }) });
458
+ }
459
+
460
+ function counts(plan: RoutePlan): ShardMigrationCounts {
461
+ const { lines: _lines, destinations: _destinations, ...value } = plan; return value;
462
+ }
463
+ const refusalSummary = (value: ShardMigrationCounts) => {
464
+ const refusals = [...(value.sharedSupersededByPrivate ? [`shared superseded by private: ${value.sharedSupersededByPrivate}`] : []), ...(value.crossPrivateReplacement ? [`cross-project private replacement: ${value.crossPrivateReplacement}; un-supersede, or promote the replacement to shared, then rerun`] : [])];
465
+ return refusals.length ? { refusals } : {};
466
+ };
467
+ const removalEvidence = (journal: ShardJournal | undefined) => journal?.deadLeasesRemoved?.length ? { deadLeasesRemoved: journal.deadLeasesRemoved } : {};
468
+
469
+ export function shardMigrationStatus(memoryDir: string): { active: boolean; generation?: string; phase?: "captured" | "active"; pending: ShardMigrationCounts; deadLeasesRemoved?: DeadLeaseRemoval[]; refusals?: string[] } {
470
+ const dir = existsSync(memoryDir) ? realPath(memoryDir) : canonicalPath(memoryDir), journal = readJournal(dir, true);
471
+ if (!journal) return { active: false, pending: emptyCounts() };
472
+ const plan = route(capture(dir, journal.generation, journal), journal.sources, true);
473
+ return { active: journal.phase === "active", phase: journal.phase, generation: journal.generation, pending: counts(plan), ...refusalSummary(plan), ...removalEvidence(journal) };
474
+ }
475
+
476
+ function preflight(memoryDir: string, options: ShardMigrationOptions, chosen?: string, dryRun = false) {
477
+ const selection = readSelector(memoryDir), previous = readJournal(memoryDir, true), machine = identity(memoryDir);
478
+ if (options.mode === "resume" && !previous) throw new Error("no resumable shard migration journal");
479
+ if (previous && previous.selectorRevision !== selection.revision) changed();
480
+ const selected = selection.selector?.kind === "generation" ? selection.selector.generation : undefined;
481
+ const sourceGeneration = previous?.sourceGeneration ?? selected;
482
+ const generation = options.generation ?? previous?.generation ?? chosen ?? selected ?? randomBytes(16).toString("hex");
483
+ if (previous && generation !== previous.generation || sourceGeneration && generation !== sourceGeneration) changed();
484
+ const captured = capture(memoryDir, generation, previous, sourceGeneration), plan = route(captured, previous?.sources, dryRun);
485
+ const regular = (path: string) => {
486
+ const stat = lstatSync(path, { throwIfNoEntry: false });
487
+ if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("shard migration planned path is not a regular file");
488
+ };
489
+ for (const segment of captured.segments) {
490
+ const source = segment.source === "generation" ? join(generationDir(memoryDir, sourceGeneration!), segment.path)
491
+ : segment.source === "legacy-main" ? join(generationDir(memoryDir, sourceGeneration ?? generation), "bridge-snapshots", segment.commit!, segment.path) : join(memoryDir, segment.path);
492
+ regular(source);
493
+ }
494
+ for (const destination of plan.destinations) {
495
+ assertCuratedShardPaths(memoryDir, destination === USER ? { user: true } : { project: destination });
496
+ const dir = destinationDir(memoryDir, destination);
497
+ regular(join(dir, "prune-translations.jsonl"));
498
+ for (const line of plan.lines) if (line.destinations.includes(destination)) regular(join(realmRoot(dir, line.source), line.path));
499
+ assessDestination(memoryDir, destination, plan, generation, machine);
500
+ }
501
+ return { selection, previous, machine, generation, captured, plan, sourceGeneration };
502
+ }
503
+
504
+ export async function migrateShards(options: ShardMigrationOptions): Promise<ShardMigrationResult> {
505
+ if (options.authorizedPerson !== true || options.tty !== true) throw new Error("shard migration requires an authorized person in a terminal");
506
+ if (!["dry-run", "apply", "resume"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid shard migration request");
507
+ const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
508
+ // Validate every immutable prune and routing decision before even a profile/mutation lock creates metadata.
509
+ const initial = preflight(memoryDir, options, undefined, options.mode === "dry-run");
510
+ if (options.mode === "dry-run") return { mode: options.mode, generation: initial.generation, active: initial.previous?.phase === "active", counts: counts(initial.plan), ...refusalSummary(initial.plan), consequence: CONSEQUENCE, ...removalEvidence(initial.previous) };
511
+ return withProfileLock(memoryDir, async () => {
512
+ const release = acquireMutation(memoryDir);
513
+ if (!release) throw new Error("legacy mutation lock is busy");
514
+ let releaseOwn: (() => void) | undefined;
515
+ let checked: ReturnType<typeof preflight>;
516
+ try {
517
+ if (initial.sourceGeneration) {
518
+ releaseOwn = acquireMutation(generationDir(memoryDir, initial.sourceGeneration));
519
+ if (!releaseOwn) throw new Error("selected generation mutation lock is busy");
520
+ }
521
+ checked = preflight(memoryDir, options, initial.generation);
522
+ if (checked.selection.revision !== initial.selection.revision || checked.machine.id !== initial.machine.id) changed();
523
+ } finally { releaseOwn?.(); release(); }
524
+ const { machine, selection, previous, generation, captured, plan, sourceGeneration } = checked!;
525
+ const journal: ShardJournal = previous ?? { schema: SHARDED_SCHEMA, generation, ...(sourceGeneration ? { sourceGeneration } : {}), phase: "captured", selectorRevision: selection.revision, sources: [], machine };
526
+ const persist = () => writeProfileJSON(join(memoryDir, JOURNAL), journal);
527
+ const admission = () => assertNoLiveReaders(memoryDir, { onDeadLeaseRemoved: ({ lease }) => {
528
+ if (!/^[1-9][0-9]*-[0-9a-f]{64}\.json$/.test(lease)) throw new Error("invalid removed reader lease evidence");
529
+ if (journal.deadLeasesRemoved?.some(row => row.lease === lease)) return;
530
+ journal.deadLeasesRemoved = [...(journal.deadLeasesRemoved ?? []), { lease, at: new Date().toISOString() }].slice(-128);
531
+ persist(); // Positive cleanup remains visible even if a later lease refuses this migration.
532
+ } });
533
+ admission();
534
+ if (options.beforeCapture) await options.beforeCapture();
535
+ const alreadyActive = journal.phase === "active" && plan.lines.length === 0;
536
+ // Existing source checkpoints stay committed only after all affected shards are sealed.
537
+ // A resumed active split must stop serving reads before the first copied append.
538
+ // #176 R1: a shard writer that crashed after preflight could leave a partial row in a file the copy appends to,
539
+ // and writeRoutes would refuse only after the journal says captured. Hold every destination's writer lock from a
540
+ // last tail check through the copy, so that check is what the copy sees. Released before buildShard, which opens
541
+ // its own store; captured already fences shard writers by then. The post-captured tail check in writeRoutes stays
542
+ // reachable only by writers that bypass both this lock and the reader lease (the mutation.ts ponytail ceiling).
543
+ const releaseShards = lockDestinations(memoryDir, plan.destinations);
544
+ let affected: Set<Destination>;
545
+ try {
546
+ for (const destination of plan.destinations) assessDestination(memoryDir, destination, plan, generation, machine);
547
+ if (!alreadyActive) journal.phase = "captured";
548
+ persist();
549
+ affected = writeRoutes(memoryDir, plan, generation, machine);
550
+ } finally { releaseShards(); }
551
+ for (const destination of affected) {
552
+ buildShard(memoryDir, destination, generation, selection.revision);
553
+ if (options.checkpoint) await options.checkpoint("after-shard");
554
+ }
555
+ admission();
556
+ if (options.checkpoint) await options.checkpoint("before-active");
557
+ journal.phase = "active"; journal.sources = captured.sources;
558
+ if (captured.legacyCommit) journal.legacyCommit = captured.legacyCommit;
559
+ persist();
560
+ return { mode: options.mode, generation, active: true, ...(alreadyActive ? { alreadyActive: true } : {}), counts: counts(plan), ...refusalSummary(plan), consequence: CONSEQUENCE, ...removalEvidence(journal) };
561
+ });
562
+ }