@staix/agent-mem 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { canonicalJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile } from "./generation.ts";
6
+ import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
7
+ import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
8
+ import { PROMOTED_KINDS, type Entry } from "./store.ts";
9
+
10
+ const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
11
+ const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
12
+ export const GENERATION_TABLES = ["generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
13
+ export interface GenerationIndexOptions {
14
+ profile: BoundProfile; db: Database; now: () => number;
15
+ apply: (line: string, source: string) => void;
16
+ withMutation: <T>(work: () => T) => T;
17
+ limits?: readonly LegacyCheckpoint[]; commit?: string;
18
+ }
19
+ /** Pure deadline summary shared by migration and prune: no live index or host TTL is a proof. */
20
+ export function explicitExpirySnapshot(rows: readonly PruneSourceRow[]): PruneSnapshot["expiries"] {
21
+ const ops = rows.filter(source => source.row.op !== "note" && source.row.op !== "goal").sort((a, b) => a.row.at.localeCompare(b.row.at) || a.row.id.localeCompare(b.row.id));
22
+ const demotes = new Map<string, Entry>(), settles = new Map<string, Entry>();
23
+ const forgotten = new Set(ops.flatMap(({ row }) => row.op === "forget" ? [row.target] : []));
24
+ for (const source of ops) {
25
+ const op = source.row;
26
+ if (op.op === "demote") demotes.set(op.target, op);
27
+ if (op.op === "settle") {
28
+ const realms = [...(source.own === true ? [true] : []), ...((source.legacy ?? source.own !== true) ? [false] : [])];
29
+ for (const own of realms) { const task = `${own}:${op.target}`; if (!settles.has(task)) settles.set(task, op); }
30
+ }
31
+ }
32
+ return rows.flatMap(({ row, own }) => {
33
+ if (row.op !== "note" || forgotten.has(row.id)) return [];
34
+ const demote = demotes.get(row.id);
35
+ if (row.tier !== "short" && !demote) return [];
36
+ const settled = row.task ? settles.get(`${own === true}:${row.task}`) : undefined;
37
+ // A held decision/contract/fail waits for promotion, not expiry, unless explicitly demoted or dated.
38
+ if (row.tier === "short" && !demote && !row.expires && settled && PROMOTED_KINDS.includes(row.kind)) return [];
39
+ const basis = demote ?? (row.expires ? row : settled);
40
+ const expiredAt = basis?.op === "note" ? basis.expires : basis && "by" in basis ? basis.by : undefined;
41
+ if (!basis || !expiredAt || !Number.isFinite(Date.parse(expiredAt)) || new Date(expiredAt).toISOString() !== expiredAt) return [];
42
+ return [{ targetId: row.id, basisId: basis.id, expiredAt }];
43
+ });
44
+ }
45
+
46
+ /** Generation-owned derived state. Legacy files are inputs, never opened as a MemoryStore. */
47
+ export class GenerationIndex {
48
+ readonly profile: BoundProfile;
49
+ pruned = new Set<string>();
50
+ private readonly reader = new SegmentReader();
51
+ private fullCapture = false;
52
+ private replayProvenance = false;
53
+ constructor(private readonly options: GenerationIndexOptions) {
54
+ this.profile = options.profile;
55
+ const db = options.db;
56
+ db.run("CREATE TABLE IF NOT EXISTS generation_rows (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL, public INTEGER NOT NULL, own INTEGER NOT NULL, legacy INTEGER NOT NULL)");
57
+ if (!(db.query("PRAGMA table_info(generation_rows)").all() as { name: string }[]).some(row => row.name === "legacy")) { db.run("ALTER TABLE generation_rows ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0"); this.replayProvenance = true; }
58
+ db.run("CREATE TABLE IF NOT EXISTS generation_prunes (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL)");
59
+ db.run("CREATE TABLE IF NOT EXISTS generation_targets (id TEXT PRIMARY KEY)");
60
+ db.run("CREATE TABLE IF NOT EXISTS generation_segments (source TEXT NOT NULL, path TEXT NOT NULL, offset INTEGER NOT NULL, hash TEXT NOT NULL, commit_id TEXT, stamp TEXT, PRIMARY KEY(source,path))");
61
+ if (!(db.query("PRAGMA table_info(generation_segments)").all() as { name: string }[]).some(row => row.name === "stamp")) db.run("ALTER TABLE generation_segments ADD COLUMN stamp TEXT");
62
+ db.run("CREATE TABLE IF NOT EXISTS generation_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
63
+ }
64
+ private ownSegments(): LegacySegment[] {
65
+ const prior = this.options.db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments WHERE source='generation'").all() as (LegacyCheckpoint & { stamp: string | null })[];
66
+ return ["log", "local"].flatMap(folder => {
67
+ const dir = join(this.profile.dir, folder);
68
+ if (lstatSync(dir).isSymbolicLink() || !lstatSync(dir).isDirectory()) return fail();
69
+ return readdirSync(dir).sort().filter(name => name.endsWith(".jsonl")).map(name => {
70
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(name)) return fail();
71
+ const path = `${folder}/${name}`, checkpoint = prior.find(row => row.path === path);
72
+ const data = this.reader.read(join(dir, name), checkpoint ? { ...checkpoint, stamp: checkpoint.stamp ?? undefined } : undefined, this.fullCapture);
73
+ return { ...data, source: "generation", path, local: folder === "local" };
74
+ });
75
+ });
76
+ }
77
+ private capture(): { segments: LegacySegment[]; commit?: string } {
78
+ const db = this.options.db;
79
+ const previous = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments WHERE source != 'generation'").all() as (LegacyCheckpoint & { commit_id: string | null; stamp: string | null })[];
80
+ const initial = readProfileJSON(join(this.profile.dir, "migration-sources.json")) as { sources?: LegacyCheckpoint[]; legacyCommit?: string } | undefined;
81
+ const bySource = new Map((initial?.sources ?? []).map(row => [`${row.source}:${row.path}`, row]));
82
+ for (const { commit_id, stamp, ...row } of previous) bySource.set(`${row.source}:${row.path}`, { ...row, ...(stamp ? { stamp } : {}), ...(commit_id ? { commit: commit_id } : {}) });
83
+ const checkpoints = [...bySource.values()];
84
+ const persisted = new Set(previous.map(row => `${row.source}:${row.path}`));
85
+ const completeFrontier = checkpoints.every(row => persisted.has(`${row.source}:${row.path}`));
86
+ const prior = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value ?? initial?.legacyCommit;
87
+ const commit = this.options.commit ?? legacyHead(this.profile.home, this.profile.generation!);
88
+ const legacy = readLegacySnapshot(this.profile.home, { generation: this.profile.generation, ...(commit ? { commit } : {}), checkpoints, ...(prior ? { previousCommit: prior } : {}), ...(this.options.limits ? { limits: this.options.limits } : {}), reader: this.reader, incremental: !this.fullCapture && !this.options.limits && completeFrontier });
89
+ const own = this.ownSegments();
90
+ for (const old of db.query("SELECT path,offset,hash FROM generation_segments WHERE source='generation'").all() as { path: string; offset: number; hash: string }[]) {
91
+ const segment = own.find(s => s.path === old.path);
92
+ if (!segment || segment.offset < old.offset) fail();
93
+ }
94
+ return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
95
+ }
96
+ private index(captured: { segments: LegacySegment[]; commit?: string }): void {
97
+ const db = this.options.db;
98
+ for (const segment of captured.segments) {
99
+ const consumed = (db.query("SELECT offset FROM generation_segments WHERE source=? AND path=?").get(segment.source, segment.path) as { offset: number } | null)?.offset ?? 0;
100
+ if ((segment.start ?? 0) > consumed) return fail();
101
+ for (const line of segment.bytes.subarray(Math.max(0, consumed - (segment.start ?? 0))).toString("utf8").split("\n").filter(Boolean)) {
102
+ let parsed: unknown;
103
+ try { parsed = JSON.parse(line); } catch { if (segment.source === "generation") return fail(); this.recordSkipped(segment); continue; }
104
+ if (segment.source === "generation" && parsed && typeof parsed === "object" && (parsed as { op?: string }).op === "prune") {
105
+ const row = validatePruneRow(parsed, this.profile.generation, segment.local ? "local" : "synced");
106
+ if (segment.path !== `${segment.local ? "local" : "log"}/${row.machine}.jsonl`) fail();
107
+ db.query("INSERT OR IGNORE INTO generation_prunes VALUES (?,?,?)").run(row.id, canonicalJSON(row), Number(segment.local));
108
+ continue;
109
+ }
110
+ const bytes = Buffer.from(line + "\n");
111
+ const row = legacyEntries([{ ...segment, bytes, offset: bytes.length, hash: digest(bytes) }], { strict: segment.source === "generation", onMalformed: () => this.recordSkipped(segment) })[0];
112
+ if (!row) continue;
113
+ const canonical = canonicalJSON(row), old = db.query("SELECT line,own,legacy FROM generation_rows WHERE id=?").get(row.id) as { line: string; own: number; legacy: number } | null;
114
+ if (old && old.line !== canonical) fail();
115
+ this.options.apply(line, segment.path);
116
+ const local = segment.local || row.op === "settle" || row.op === "note" && row.tier === "short";
117
+ const publicWitness = !local && segment.source !== "legacy-local";
118
+ const own = segment.source === "generation", legacy = !own;
119
+ // Notes retain legacy birth when duplicated; ops can independently occur in both birth realms.
120
+ const ownPresence = old ? row.op === "note" ? !!old.own && own : !!old.own || own : own;
121
+ db.query("INSERT INTO generation_rows VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET local=MIN(local,excluded.local), public=MAX(public,excluded.public), own=excluded.own, legacy=excluded.legacy")
122
+ .run(row.id, canonical, Number(local), Number(publicWitness), Number(ownPresence), Number(!!old?.legacy || legacy));
123
+ }
124
+ db.query("INSERT INTO generation_segments VALUES (?,?,?,?,?,?) ON CONFLICT(source,path) DO UPDATE SET offset=excluded.offset,hash=excluded.hash,commit_id=excluded.commit_id,stamp=excluded.stamp")
125
+ .run(segment.source, segment.path, segment.offset, segment.hash, segment.commit ?? null, segment.stamp ?? null);
126
+ }
127
+ if (captured.commit) db.query("INSERT INTO generation_state VALUES ('legacyCommit',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(captured.commit);
128
+ const snapshot = this.snapshot(false);
129
+ const prunes = (db.query("SELECT line,local FROM generation_prunes ORDER BY id").all() as { line: string; local: number }[]).map(row => ({ row: JSON.parse(row.line), placement: row.local ? "local" as const : "synced" as const }));
130
+ const reduced = reducePruneRows(prunes, snapshot, this.profile.generation!);
131
+ db.run("DELETE FROM generation_targets");
132
+ for (const id of [...reduced.targets].sort()) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
133
+ this.pruned = reduced.targets;
134
+ }
135
+ private recordSkipped(segment: LegacySegment): void {
136
+ this.options.db.query("INSERT INTO generation_state VALUES (?,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(CAST(value AS INTEGER)+1 AS TEXT)").run(`skipped:${segment.source}:${segment.path}`);
137
+ }
138
+ skippedRows(): number {
139
+ return (this.options.db.query("SELECT COALESCE(SUM(CAST(value AS INTEGER)),0) AS n FROM generation_state WHERE key LIKE 'skipped:%'").get() as { n: number }).n;
140
+ }
141
+ private frontier(): string {
142
+ const db = this.options.db;
143
+ return db.transaction(() => canonicalJSON({
144
+ segments: db.query("SELECT * FROM generation_segments ORDER BY source,path").all(),
145
+ state: db.query("SELECT * FROM generation_state ORDER BY key").all(),
146
+ })).deferred();
147
+ }
148
+ refresh(attempt = 0): void {
149
+ if (this.replayProvenance) { this.rebuild(); return; }
150
+ const before = this.frontier(), db = this.options.db;
151
+ let input: ReturnType<GenerationIndex["capture"]>;
152
+ try { input = this.capture(); }
153
+ catch (error) { if (attempt < 5 && this.frontier() !== before) return this.refresh(attempt + 1); throw error; }
154
+ if (this.frontier() !== before) { if (attempt < 5) return this.refresh(attempt + 1); return fail(); }
155
+ const known = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments").all() as { source: string; path: string; offset: number; hash: string; commit_id: string | null; stamp: string | null }[];
156
+ const currentCommit = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value;
157
+ const unchanged = known.length === input.segments.length && currentCommit === input.commit && input.segments.every(segment => known.some(row => row.source === segment.source && row.path === segment.path && row.offset === segment.offset && row.hash === segment.hash && row.commit_id === (segment.commit ?? null) && row.stamp === (segment.stamp ?? null)));
158
+ if (unchanged) { this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
159
+ const committed = db.transaction(() => { if (this.frontier() !== before) return false; this.index(input); return true; }).immediate();
160
+ if (!committed) { if (attempt < 5) return this.refresh(attempt + 1); return fail(); }
161
+ }
162
+ rebuild(attempt = 0): void {
163
+ const before = this.frontier();
164
+ let input: ReturnType<GenerationIndex["capture"]>;
165
+ try { this.fullCapture = true; input = this.capture(); }
166
+ catch (error) { this.fullCapture = false; if (attempt < 5 && this.frontier() !== before) return this.rebuild(attempt + 1); throw error; }
167
+ finally { this.fullCapture = false; }
168
+ const committed = this.options.db.transaction(() => {
169
+ if (this.frontier() !== before) return false;
170
+ for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES]) this.options.db.run(`DELETE FROM ${table}`);
171
+ this.index(input); return true;
172
+ }).immediate();
173
+ if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1); return fail(); }
174
+ this.replayProvenance = false;
175
+ }
176
+ own(id: string): boolean { return !!(this.options.db.query("SELECT own FROM generation_rows WHERE id=?").get(id) as { own: number } | null)?.own; }
177
+ snapshot(refresh = true): PruneSnapshot {
178
+ if (refresh) this.refresh();
179
+ return this.options.db.transaction(() => {
180
+ const rows = (this.options.db.query("SELECT line,local,public,own,legacy FROM generation_rows ORDER BY id").all() as { line: string; local: number; public: number; own: number; legacy: number }[]).map(r => ({ row: JSON.parse(r.line) as Entry, placement: r.local ? "local" as const : "synced" as const, publicWitness: !!r.public, own: !!r.own, legacy: !!r.legacy }));
181
+ const pruned = (this.options.db.query("SELECT id FROM generation_targets ORDER BY id").all() as { id: string }[]).map(row => row.id);
182
+ return { rows, expiries: explicitExpirySnapshot(rows), pruned, durablePruneIds: (this.options.db.query("SELECT id FROM generation_prunes ORDER BY id").all() as { id: string }[]).map(r => r.id) };
183
+ }).deferred();
184
+ }
185
+ port(machine: string): PruneStorePort {
186
+ return { generation: this.profile.generation!, machine, now: this.options.now,
187
+ selectorRevision: () => selectorRevision(this.profile.home), snapshot: () => this.snapshot(),
188
+ withMutation: work => this.options.withMutation(work), refresh: () => this.refresh(),
189
+ persistPrunePlan: plan => this.persistPlan(plan), appendPrune: (row, line, seal) => this.append(row, line, seal) };
190
+ }
191
+ private pendingPath(local: boolean, machine: string): string { return join(this.profile.dir, "prune-plans", `pending-${local ? "local" : "log"}-${machine}.json`); }
192
+ /** Every ordinary writer must refuse to append unrelated JSON after a prune crash tail. */
193
+ assertAppendable(local: boolean, machine: string): void {
194
+ if (readProfileJSON(this.pendingPath(local, machine)) !== undefined) fail();
195
+ const file = join(this.profile.dir, local ? "local" : "log", `${machine}.jsonl`);
196
+ if (!existsSync(file)) return;
197
+ if (lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile()) fail();
198
+ const bytes = readFileSync(file);
199
+ if (bytes.length && bytes.at(-1) !== 10) fail();
200
+ }
201
+ private persistPlan(plan: PrunePlan): void {
202
+ const dir = join(this.profile.dir, "prune-plans");
203
+ if (existsSync(dir) && (lstatSync(dir).isSymbolicLink() || !lstatSync(dir).isDirectory())) fail();
204
+ mkdirSync(dir, { mode: 0o700, recursive: true });
205
+ fsyncDirectory(this.profile.dir);
206
+ const file = join(dir, `${plan.seal}.json`), existing = readProfileJSON(file);
207
+ if (existing && canonicalJSON(existing) !== canonicalJSON(plan)) fail();
208
+ if (!existing) writeProfileJSON(file, plan);
209
+ }
210
+ private append(row: PruneRow, line: string, seal: string): "written" | "already-durable" {
211
+ const plan = readProfileJSON(join(this.profile.dir, "prune-plans", `${seal}.json`)) as PrunePlan | undefined;
212
+ if (!plan?.rows.some(item => canonicalJSON(item.payload) === canonicalJSON(row)) || line !== canonicalJSON(row) + "\n") return fail();
213
+ this.refresh();
214
+ const already = this.snapshot(false).durablePruneIds?.includes(row.id);
215
+ const path = join(this.profile.dir, row.placement === "local" ? "local" : "log", `${row.machine}.jsonl`);
216
+ const fd = openSync(path, constants.O_RDWR | (already ? 0 : constants.O_CREAT) | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
217
+ try {
218
+ const data = readFileSync(fd), tail = data.subarray(data.lastIndexOf(10) + 1), bytes = Buffer.from(line);
219
+ const pending = this.pendingPath(row.placement === "local", row.machine);
220
+ const prior = readProfileJSON(pending) as { schema?: string; seal?: string; rowId?: string; lineHash?: string; offset?: number; prefixHash?: string } | undefined;
221
+ if (prior) {
222
+ if (prior.schema !== "agent-mem.prune-append/v1" || prior.seal !== seal || prior.rowId !== row.id || prior.lineHash !== digest(bytes)
223
+ || !Number.isSafeInteger(prior.offset) || prior.offset! < 0 || prior.offset! > data.length || digest(data.subarray(0, prior.offset)) !== prior.prefixHash) return fail();
224
+ if (already ? !data.subarray(prior.offset, prior.offset! + bytes.length).equals(bytes) : prior.offset !== data.length - tail.length) return fail();
225
+ } else if (!already) {
226
+ if (tail.length) return fail();
227
+ writeProfileJSON(pending, { schema: "agent-mem.prune-append/v1", seal, rowId: row.id, lineHash: digest(bytes), offset: data.length, prefixHash: digest(data) });
228
+ }
229
+ if (already) {
230
+ const complete = data.subarray(0, data.lastIndexOf(10) + 1).toString("utf8").split("\n").filter(Boolean);
231
+ if (!complete.some(value => canonicalJSON(JSON.parse(value)) === canonicalJSON(row))) return fail();
232
+ } else {
233
+ if (!bytes.subarray(0, tail.length).equals(tail)) return fail();
234
+ let offset = tail.length;
235
+ while (offset < bytes.length) { const wrote = writeSync(fd, bytes, offset, bytes.length - offset); if (wrote <= 0) fail(); offset += wrote; }
236
+ }
237
+ // A parsed complete line is not proof of fsync. Resume must cross this fence too.
238
+ fsyncSync(fd);
239
+ fsyncDirectory(join(this.profile.dir, row.placement === "local" ? "local" : "log"));
240
+ if (readProfileJSON(pending) !== undefined) { unlinkSync(pending); fsyncDirectory(join(this.profile.dir, "prune-plans")); }
241
+ } finally { closeSync(fd); }
242
+ this.refresh();
243
+ return already ? "already-durable" : "written";
244
+ }
245
+ }
@@ -0,0 +1,248 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, existsSync, lstatSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { legacyEntries, legacyHead, readLegacyGitSnapshot, captureLegacyMain } from "./bridge.ts";
5
+ import { canonicalJSON, generationDescriptor, generationDir, readProfileJSON, validateGeneration } from "./generation.ts";
6
+ import { acquireMutation } from "./mutation.ts";
7
+ import { processLiveness, processSignature, realPath } from "./platform.ts";
8
+ import { validatePruneRow, pruneRowHash, type PruneRow } from "./prune.ts";
9
+ import { remoteVisibility, generationSyncGit as syncGit, type SyncOptions, type SyncResult } from "./sync.ts";
10
+ import type { MemoryStore } from "./store.ts";
11
+ import { createHash } from "node:crypto";
12
+
13
+ const IGNORE = "/*\n!/.gitignore\n!/generation.json\n!/log/\n/log/*\n!/log/*.jsonl\n";
14
+ const zero = "0".repeat(40), oid = (value: string) => /^[a-f0-9]{40}$/.test(value);
15
+ const segment = (path: string) => /^log\/[a-f0-9]{16}\.jsonl$/.test(path);
16
+ const fail = (reason: string): never => { throw new Error(reason); };
17
+ function regular(path: string): boolean {
18
+ if (!existsSync(path)) return false;
19
+ const stat = lstatSync(path);
20
+ if (!stat.isFile() || stat.isSymbolicLink()) fail("generation sync refuses non-regular public files");
21
+ return true;
22
+ }
23
+ function ownRepository(dir: string): boolean {
24
+ const path = join(dir, ".git");
25
+ if (!existsSync(path)) return false;
26
+ const stat = lstatSync(path);
27
+ if (!stat.isDirectory() || stat.isSymbolicLink() || existsSync(join(path, "commondir")) || existsSync(join(path, "objects", "info", "alternates"))) fail("generation sync requires its separate repository");
28
+ return true;
29
+ }
30
+ function lockSync(dir: string): (() => void) | undefined {
31
+ const signature = processSignature(process.pid);
32
+ if (!signature) return undefined;
33
+ const file = join(dir, "sync.lock"), value = canonicalJSON({ pid: process.pid, signature, token: randomBytes(16).toString("hex") });
34
+ // The generation mutation transaction is already held, serializing stale-lock reclamation across callers.
35
+ for (let attempt = 0; attempt < 2; attempt++) {
36
+ let fd: number;
37
+ try { fd = openSync(file, "wx", 0o600); }
38
+ catch {
39
+ try {
40
+ if (!regular(file)) continue;
41
+ const prior = readFileSync(file, "utf8"), owner = JSON.parse(prior) as { pid?: unknown; signature?: unknown };
42
+ if (typeof owner.signature !== "string" || !owner.signature || processLiveness(owner.pid, owner.signature) !== "gone" || readFileSync(file, "utf8") !== prior) return undefined;
43
+ rmSync(file); continue;
44
+ } catch { return undefined; }
45
+ }
46
+ try { writeFileSync(fd, value); } finally { closeSync(fd); }
47
+ return () => { if (regular(file) && readFileSync(file, "utf8") === value) rmSync(file); };
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function validatePublic(bytes: string, generation: string, path: string, checkPrune?: (row: PruneRow) => void): void {
53
+ if (!bytes) return;
54
+ const legacy: string[] = [];
55
+ for (const line of bytes.split("\n").filter(Boolean)) {
56
+ let value: unknown; try { value = JSON.parse(line); } catch { return fail("generation public segment has malformed rows"); }
57
+ const row = value as { schema?: unknown; op?: unknown; tier?: unknown };
58
+ if (row.schema === "agent-mem.prune/v1") {
59
+ const prune = validatePruneRow(value, generation, "synced");
60
+ if (path !== `log/${prune.machine}.jsonl`) fail("generation prune author does not match its segment");
61
+ checkPrune?.(prune);
62
+ }
63
+ else {
64
+ if (row.op === "settle" || row.op === "note" && row.tier !== "long") fail("generation public segment contains local-only rows");
65
+ legacy.push(line);
66
+ }
67
+ }
68
+ const body = Buffer.from(legacy.join("\n") + (legacy.length ? "\n" : ""));
69
+ legacyEntries([{ source: "legacy-main", path, bytes: body, offset: body.length, hash: createHash("sha256").update(body).digest("hex"), local: false, tailBytes: 0 }], { strict: true });
70
+ }
71
+
72
+ /** Separate generation repository, fixed refs and a one-way legacy cache; failures are bounded result receipts. */
73
+ export async function syncGenerationMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
74
+ let pushed = false, releaseMutation: (() => void) | undefined, releaseSync: (() => void) | undefined;
75
+ const result = (error: string): SyncResult => ({ ok: false, pushed, entries: 0, error });
76
+ const profile = store.profile, generation = profile.generation, dir = store.dir;
77
+ const transientRefs: { cache?: string; ref: string }[] = [];
78
+ const git = (args: string[], signal = opts.signal) => syncGit(dir, args, signal);
79
+ const required = async (args: string[], reason: string, signal = opts.signal): Promise<string> => {
80
+ const run = await git(args, signal);
81
+ if (run.code !== 0) fail(opts.signal?.aborted ? "stopped" : reason);
82
+ return run.out;
83
+ };
84
+ try {
85
+ if (opts.signal?.aborted) return result("stopped");
86
+ if (profile.kind !== "generation" || !generation || dir !== generationDir(profile.home, generation) || validateGeneration(profile.home, generation) !== dir) return result("generation profile unavailable");
87
+ const repo = ownRepository(dir);
88
+ if (!opts.remote && !repo) return result("no generation sync remote configured");
89
+ const url = opts.remote ?? await required(["remote", "get-url", "origin"], "no generation sync remote configured");
90
+ if (typeof url !== "string" || !url || url.length > 4096 || url.startsWith("-") || /[\r\n\0]/.test(url)) return result("generation sync remote is invalid");
91
+ if (await (opts.visibility ?? remoteVisibility)(url) !== "private") return result("generation sync requires positively verified private remote visibility");
92
+ if (opts.signal?.aborted) return result("stopped");
93
+ releaseMutation = acquireMutation(dir);
94
+ if (!releaseMutation) return result("another generation mutation is running");
95
+ releaseSync = lockSync(dir);
96
+ if (!releaseSync) return result("another generation sync is running or its owner is unknown");
97
+ validateGeneration(profile.home, generation);
98
+ if (readProfileJSON(join(dir, "prune-plans", `pending-log-${store.machine.id}.json`)) !== undefined) fail("generation prune append requires its original plan to resume before sync");
99
+ const ref = `refs/heads/amem/gen/${generation}`, descriptor = canonicalJSON(generationDescriptor(generation)), own = `log/${store.machine.id}.jsonl`;
100
+ const sources = new Map(store.prunePort().snapshot().rows.map(source => [source.row.id, source]));
101
+ const ownPrune = (row: PruneRow): void => {
102
+ for (const target of row.targets) for (const witness of [{ id: target.id, hash: target.rowHash }, { id: target.basis.id, hash: target.basis.rowHash }]) {
103
+ const source = sources.get(witness.id);
104
+ if (!source || source.placement !== "synced" || !source.publicWitness || pruneRowHash(source.row) !== witness.hash) fail("generation own prune lacks verified public witnesses");
105
+ }
106
+ };
107
+ if (!repo) {
108
+ await required(["--git-dir", join(dir, ".git"), "--work-tree", dir, "init", "-q", dir], "generation repository initialization failed");
109
+ await required(["symbolic-ref", "HEAD", ref], "generation branch initialization failed");
110
+ }
111
+ ownRepository(dir);
112
+ if (realPath(await required(["rev-parse", "--show-toplevel"], "generation worktree unavailable")) !== realPath(dir)) fail("generation working tree is routed outside its directory");
113
+ if (await required(["symbolic-ref", "--quiet", "HEAD"], "generation sync refuses detached HEAD") !== ref) fail("generation sync refuses an unexpected HEAD branch");
114
+ rmSync(join(dir, ".git", "index.lock"), { force: true });
115
+ if (existsSync(join(dir, ".git", "MERGE_HEAD"))) await required(["merge", "--abort"], "generation interrupted merge needs inspection", undefined);
116
+ const who = ["-c", "user.name=agent-mem", "-c", `user.email=agent-mem@${store.machine.id}.invalid`];
117
+ const blob = async (commit: string, path: string): Promise<string> => {
118
+ const out = await required(["show", `${commit}:${path}`], "generation history blob unavailable");
119
+ const size = Number(await required(["cat-file", "-s", `${commit}:${path}`], "generation history size unavailable"));
120
+ if (size !== Buffer.byteLength(out) + 1 && !(size === 0 && out === "")) fail("generation history requires complete newline-terminated public files");
121
+ return out;
122
+ };
123
+ // ponytail: audit every reachable tree at sync; cache verified immutable commit digests if measurements justify it.
124
+ const audited = new Set<string>();
125
+ const history = async (tip: string): Promise<void> => {
126
+ const commits = (await required(["rev-list", tip], "generation history unavailable")).split("\n").filter(Boolean);
127
+ for (const commit of commits) {
128
+ if (!oid(commit)) fail("generation history identity is invalid");
129
+ if (audited.has(commit)) continue;
130
+ const entries = (await required(["ls-tree", "-r", "-z", commit], "generation tree unavailable")).split("\0").filter(Boolean);
131
+ const paths: string[] = [];
132
+ for (const entry of entries) {
133
+ const match = /^(100644) blob ([a-f0-9]{40})\t(.+)$/.exec(entry);
134
+ if (!match) return fail("generation history contains forbidden files or links");
135
+ if (![".gitignore", "generation.json"].includes(match[3]!) && !segment(match[3]!)) fail("generation history contains forbidden files or links");
136
+ const path = match[3]!; paths.push(path);
137
+ const text = await blob(commit, path);
138
+ if (path === "generation.json") { let value: unknown; try { value = JSON.parse(text); } catch { return fail("generation descriptor is invalid"); } if (canonicalJSON(value) !== descriptor) fail("generation history descriptor mismatch"); }
139
+ else if (path === ".gitignore") { if (text !== IGNORE.trim()) fail("generation ignore contract mismatch"); }
140
+ else validatePublic(text, generation, path, path === own ? ownPrune : undefined);
141
+ }
142
+ if (!paths.includes("generation.json") || !paths.includes(".gitignore")) fail("generation history inherits a non-generation commit");
143
+ audited.add(commit);
144
+ }
145
+ };
146
+ const localHead = await git(["rev-parse", "--verify", "--quiet", "HEAD"]);
147
+ if (localHead.code === 0) await history(localHead.out);
148
+ else if (localHead.code !== 1) fail("generation HEAD is unavailable");
149
+ const staged = await required(["diff", "--cached", "--name-only"], "generation index unavailable");
150
+ if (staged.split("\n").filter(Boolean).some(path => ![".gitignore", "generation.json", own].includes(path))) fail("generation index has unrelated staged files");
151
+ regular(join(dir, "generation.json"));
152
+ if (regular(join(dir, own))) {
153
+ const text = readFileSync(join(dir, own), "utf8");
154
+ if (text && !text.endsWith("\n")) fail("generation own segment has an incomplete tail");
155
+ validatePublic(text, generation, own, ownPrune);
156
+ }
157
+ if (existsSync(join(dir, ".gitignore"))) { if (!regular(join(dir, ".gitignore"))) fail("generation ignore unavailable"); }
158
+ writeFileSync(join(dir, ".gitignore"), IGNORE, { mode: 0o600 });
159
+ if (opts.remote) {
160
+ const existing = await git(["remote", "get-url", "origin"]);
161
+ if (existing.code === 0 && existing.out !== url) await required(["remote", "set-url", "origin", url], "generation remote replacement failed");
162
+ if (existing.code !== 0) await required(["remote", "add", "origin", url], "generation remote configuration failed");
163
+ }
164
+ // Bind the actual expanded destinations, including pushurl/insteadOf rules, to the verified private URL.
165
+ // Refuse user configuration that points elsewhere; never silently replace a push URL.
166
+ const fetchURLs = (await required(["remote", "get-url", "--all", "origin"], "generation fetch destination unavailable")).split("\n").filter(Boolean);
167
+ const pushURLs = (await required(["remote", "get-url", "--push", "--all", "origin"], "generation push destination unavailable")).split("\n").filter(Boolean);
168
+ if (fetchURLs.length !== 1 || fetchURLs[0] !== url || pushURLs.length !== 1 || pushURLs[0] !== url) fail("generation sync refuses differing or multiple remote destinations");
169
+ const remoteTip = async (branch: string): Promise<string | undefined> => {
170
+ const listed = await git(["ls-remote", "--exit-code", "--heads", "origin", branch]);
171
+ if (listed.code === 2) return undefined;
172
+ if (listed.code !== 0) return fail(opts.signal?.aborted ? "stopped" : "generation remote lookup failed");
173
+ const match = /^([a-f0-9]{40})\t(.+)$/.exec(listed.out);
174
+ if (!match || match[2] !== branch) return fail("generation remote branch identity mismatch");
175
+ return match[1];
176
+ };
177
+ const pull = async (): Promise<void> => {
178
+ const expected = await remoteTip(ref);
179
+ if (!expected) return;
180
+ const incoming = `refs/amem/incoming/${randomBytes(16).toString("hex")}`;
181
+ transientRefs.push({ ref: incoming });
182
+ await required(["fetch", "-q", "--no-tags", "origin", `${ref}:${incoming}`], "generation fetch failed");
183
+ const tip = await required(["rev-parse", "--verify", incoming], "generation fetched ref unavailable");
184
+ await history(tip);
185
+ // Append-only convergence: a remote must never require rewriting an existing segment prefix.
186
+ const names = (await required(["ls-tree", "-r", "--name-only", tip], "generation incoming tree unavailable")).split("\n").filter(segment);
187
+ for (const path of names) if (regular(join(dir, path))) {
188
+ const local = readFileSync(join(dir, path), "utf8"), remote = await blob(tip, path), complete = remote ? remote + "\n" : "";
189
+ if (!local.startsWith(complete) && !complete.startsWith(local)) fail("generation segment diverged; person inspection required");
190
+ }
191
+ const before = await git(["rev-parse", "--verify", "--quiet", "HEAD"]);
192
+ const merged = await git(before.code === 0 ? [...who, "merge", "-q", "--no-edit", "--no-verify", "--allow-unrelated-histories", incoming] : ["merge", "--ff-only", incoming]);
193
+ if (merged.code !== 0) {
194
+ await syncGit(dir, ["merge", "--abort"]); // cleanup deliberately ignores an already-aborted invocation signal
195
+ fail(opts.signal?.aborted ? "stopped" : "generation merge failed");
196
+ }
197
+ };
198
+ // Commit only this machine's append, plus the fixed public metadata. Never stage the bridge or other machines.
199
+ await required(["add", "--", ".gitignore", "generation.json", ...(existsSync(join(dir, own)) ? [own] : [])], "generation staging failed");
200
+ const dirty = await git(["diff", "--cached", "--quiet"]);
201
+ if (dirty.code === 1) await required([...who, "commit", "-q", "--no-verify", "-m", "memory: generation append"], "generation commit failed");
202
+ else if (dirty.code !== 0) fail("generation staged comparison failed");
203
+ await pull();
204
+ // Fetch legacy main into a separate bare cache; publish its fixed read ref only after FF and CAS validation.
205
+ const main = await remoteTip("refs/heads/main"), manifestHead = legacyHead(profile.home, generation);
206
+ const cache = join(dir, "bridge.git");
207
+ let prior: string | undefined;
208
+ const validateCache = async () => {
209
+ if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory() || existsSync(join(cache, "commondir")) || existsSync(join(cache, "objects", "info", "alternates"))
210
+ || await required(["--git-dir", cache, "rev-parse", "--is-bare-repository"], "legacy cache identity unavailable") !== "true") fail("legacy cache must be an independent bare repository");
211
+ };
212
+ if (existsSync(cache)) {
213
+ await validateCache();
214
+ const pinned = await git(["--git-dir", cache, "rev-parse", "--verify", "--quiet", "refs/remotes/legacy/main"]);
215
+ if (pinned.code === 0 && oid(pinned.out)) prior = pinned.out;
216
+ else if (pinned.code !== 1) fail("legacy cache read ref unavailable");
217
+ }
218
+ if (!main && (prior || manifestHead)) fail("legacy main disappeared; person inspection required");
219
+ if (main) {
220
+ if (!existsSync(cache)) await required(["init", "-q", "--bare", cache], "legacy cache initialization failed");
221
+ await validateCache();
222
+ const temp = `refs/amem/legacy-fetch/${randomBytes(16).toString("hex")}`; transientRefs.push({ cache, ref: temp });
223
+ await required(["--git-dir", cache, "fetch", "-q", "--no-tags", "--", url, `refs/heads/main:${temp}`], "legacy cache fetch failed");
224
+ const head = await required(["--git-dir", cache, "rev-parse", "--verify", temp], "legacy cache fetched ref unavailable");
225
+ if (!oid(head)) fail("legacy cache commit identity invalid");
226
+ for (const previous of new Set([prior, manifestHead].filter((value): value is string => value !== undefined)))
227
+ await required(["--git-dir", cache, "merge-base", "--is-ancestor", previous, head], "legacy main history is not fast-forward");
228
+ readLegacyGitSnapshot(profile.home, generation, head, manifestHead ?? prior);
229
+ await required(["--git-dir", cache, "update-ref", "refs/remotes/legacy/main", head, prior ?? zero], "legacy cache ref changed concurrently");
230
+ // Publish plain-file captured blobs only here, after the Git CAS. Reads keep the prior manifest across a crash.
231
+ captureLegacyMain(profile.home, generation, head, manifestHead);
232
+ }
233
+ if (opts.signal?.aborted) return result("stopped");
234
+ await history(await required(["rev-parse", "--verify", "HEAD"], "generation final HEAD unavailable"));
235
+ let sent = await git(["push", "-q", "origin", `HEAD:${ref}`]);
236
+ if (sent.code !== 0 && !opts.signal?.aborted) { await pull(); await history(await required(["rev-parse", "--verify", "HEAD"], "generation retry HEAD unavailable")); sent = await git(["push", "-q", "origin", `HEAD:${ref}`]); }
237
+ if (sent.code !== 0) fail(opts.signal?.aborted ? "stopped" : "generation push failed");
238
+ pushed = true;
239
+ return store.withDeferredPromotions(() => { store.refresh(); return { ok: true, pushed, entries: store.counts().long }; });
240
+ } catch (error) {
241
+ const reason = error instanceof Error && /^(generation |legacy |stopped)/.test(error.message) ? error.message.slice(0, 200) : "generation sync unavailable";
242
+ return result(reason);
243
+ } finally {
244
+ for (const transient of transientRefs) { try { await syncGit(dir, [...(transient.cache ? ["--git-dir", transient.cache] : []), "update-ref", "-d", transient.ref]); } catch { /* local refs remain non-public if cleanup itself fails */ } }
245
+ try { releaseSync?.(); } catch { /* cleanup cannot turn an observed sync result into an uncaught error */ }
246
+ try { releaseMutation?.(); } catch { /* closed/broken derived lock is a local recovery concern */ }
247
+ }
248
+ }