@staix/agent-mem 0.13.0 → 0.14.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,73 @@
1
+ /** Metadata-only input-set ancestry for the generation reader's cold path. Never loads proof bodies. */
2
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canonicalJSON, hashJSON } from "./generation.ts";
5
+ import { realPath } from "./platform.ts";
6
+
7
+ export const PUBLIC_PRUNE_INPUT_MAX_BYTES = 65_536;
8
+ export const PUBLIC_PRUNE_REPLAY_MAX_BYTES = 256 * 1024 * 1024;
9
+ export const PUBLIC_PRUNE_MAX_SETS = 4096;
10
+ export interface PublicPruneInputSetV1 {
11
+ schema: "agent-mem.public-prune-input-set/v1"; receivingGeneration: string; previous: string | null; inputs: string[];
12
+ }
13
+ export class PublicPruneInputError extends Error {
14
+ constructor(readonly code: "unavailable" | "resource" | "binding" = "unavailable") {
15
+ super(`public prune input ${code}`); this.name = "PublicPruneInputError";
16
+ }
17
+ }
18
+ function guard(ok: unknown, resource = false): asserts ok { if (!ok) throw new PublicPruneInputError(resource ? "resource" : "unavailable"); }
19
+ function protectedCall<T>(work: () => T): T {
20
+ try { return work(); } catch (error) { if (error instanceof PublicPruneInputError) throw error; throw new PublicPruneInputError(); }
21
+ }
22
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
23
+ const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && /^[a-f0-9]+$/.test(v);
24
+ export function validatePublicPruneInputSet(v: unknown): PublicPruneInputSetV1 {
25
+ return protectedCall(() => {
26
+ guard(object(v) && Object.keys(v).length === 4 && ["schema", "receivingGeneration", "previous", "inputs"].every(key => Object.hasOwn(v, key)));
27
+ guard(Buffer.byteLength(canonicalJSON(v)) <= PUBLIC_PRUNE_INPUT_MAX_BYTES, true);
28
+ guard(v.schema === "agent-mem.public-prune-input-set/v1" && hex(v.receivingGeneration, 32) && (v.previous === null || hex(v.previous, 64)) && Array.isArray(v.inputs));
29
+ let previous = "";
30
+ for (const id of v.inputs) { guard(hex(id, 64) && id > previous); previous = id; }
31
+ return { schema: v.schema, receivingGeneration: v.receivingGeneration, previous: v.previous, inputs: [...v.inputs] as string[] };
32
+ });
33
+ }
34
+ function owned(stat: BigIntStats, directory = false): void {
35
+ guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === 1n)
36
+ && stat.uid === BigInt(process.getuid?.() ?? -1) && (stat.mode & 0o077n) === 0n);
37
+ }
38
+ const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
39
+ /** Complete bounded chain, closed canonical metadata, exact hashes and monotone membership. */
40
+ export function readPublicPruneInputSetChain(input: { dir: string; receivingGeneration: string; inputSetHash: string }): { commitments: string[]; inputs: string[] } {
41
+ return protectedCall(() => {
42
+ guard(hex(input.receivingGeneration, 32) && hex(input.inputSetHash, 64));
43
+ const original = lstatSync(input.dir, { bigint: true }); owned(original, true);
44
+ const dir = realPath(input.dir), root = lstatSync(dir, { bigint: true }); owned(root, true); guard(root.dev === original.dev && root.ino === original.ino);
45
+ const folder = join(dir, "public-prune-input-sets"), parent = lstatSync(folder, { bigint: true }); owned(parent, true);
46
+ const frontier = new Map<string, BigIntStats>(), commitments: string[] = [], seen = new Set<string>();
47
+ let id: string | null = input.inputSetHash, later: Set<string> | undefined, inputs: string[] = [], total = 0;
48
+ while (id !== null) {
49
+ guard(!seen.has(id)); guard(seen.size < PUBLIC_PRUNE_MAX_SETS, true); seen.add(id); commitments.push(id);
50
+ const file = join(folder, id + ".json"), before = lstatSync(file, { bigint: true }); owned(before);
51
+ guard(before.size > 0n && before.size <= BigInt(PUBLIC_PRUNE_INPUT_MAX_BYTES), true);
52
+ total += Number(before.size); guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES, true);
53
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let bytes: Buffer;
54
+ try {
55
+ const opened = fstatSync(fd, { bigint: true }); owned(opened); guard(same(before, opened));
56
+ const data = Buffer.alloc(Number(opened.size) + 1); let count = 0;
57
+ while (count < data.length) { const n = readSync(fd, data, count, data.length - count, count); if (!n) break; count += n; }
58
+ const after = fstatSync(fd, { bigint: true }), named = lstatSync(file, { bigint: true }); owned(after); owned(named);
59
+ guard(count === Number(before.size) && same(before, after) && same(before, named)); bytes = data.subarray(0, count); frontier.set(file, before);
60
+ } finally { closeSync(fd); }
61
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), raw: unknown = JSON.parse(text), canonical = canonicalJSON(raw);
62
+ guard(text === canonical || text === canonical + "\n");
63
+ const set = validatePublicPruneInputSet(raw); guard(hashJSON(set) === id && set.receivingGeneration === input.receivingGeneration);
64
+ if (later) guard(set.inputs.every(member => later!.has(member))); else inputs = set.inputs;
65
+ later = new Set(set.inputs); id = set.previous;
66
+ }
67
+ for (const [file, before] of frontier) { const now = lstatSync(file, { bigint: true }); owned(now); guard(same(before, now)); }
68
+ for (const [file, before] of [[input.dir, original], [dir, root], [folder, parent]] as const) {
69
+ const now = lstatSync(file, { bigint: true }); owned(now, true); guard(now.dev === before.dev && now.ino === before.ino);
70
+ }
71
+ return { commitments, inputs: [...inputs] };
72
+ });
73
+ }
@@ -0,0 +1,455 @@
1
+ /** Durable evidence and candidate sets. Activation/advance alone commits a selector; sync owns transport admission. */
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { constants, closeSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync, type Stats } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { canonicalJSON, fsyncDirectory, hashJSON } from "./generation.ts";
6
+ import { acquireMutation, assertMutationHandle, type MutationHandle } from "./mutation.ts";
7
+ import { realPath } from "./platform.ts";
8
+ import { entryId, type Entry } from "./store.ts";
9
+ import { prunePayloadHash } from "./prune.ts";
10
+ import { shardSyncRef, type ShardSyncRealm } from "./shard-sync-format.ts";
11
+ import { verifyPublicPruneWitness, type VerifiedPublicPruneWitness } from "./public-prune-witness.ts";
12
+
13
+ import { PUBLIC_PRUNE_INPUT_MAX_BYTES, PUBLIC_PRUNE_REPLAY_MAX_BYTES, PUBLIC_PRUNE_MAX_SETS, PublicPruneInputError, validatePublicPruneInputSet, type PublicPruneInputSetV1 } from "./public-prune-input-chain.ts";
14
+ export { PUBLIC_PRUNE_INPUT_MAX_BYTES, PUBLIC_PRUNE_REPLAY_MAX_BYTES, PUBLIC_PRUNE_MAX_SETS, PublicPruneInputError, validatePublicPruneInputSet, readPublicPruneInputSetChain, type PublicPruneInputSetV1 } from "./public-prune-input-chain.ts";
15
+ export const PUBLIC_PRUNE_GRAPH_MAX_BYTES = 80 * 1024 * 1024;
16
+ export const PUBLIC_PRUNE_GRAPH_MAX_OBJECTS = 256;
17
+ const OBJECT_MAX = 1024 * 1024 + 64, ARCHIVE_FILES_MAX = 16_384;
18
+ type Source = { remoteHash: string; ref: string; commit: string; manifestPath: string; manifestBlob: string };
19
+ export interface PublicPruneInputV1 {
20
+ schema: "agent-mem.public-prune-input/v1"; realm: ShardSyncRealm; receivingGeneration: string;
21
+ selectorRevision: string; witnessHash: string; source: Source;
22
+ }
23
+ export interface PublicPruneBinding { realm: ShardSyncRealm; receivingGeneration: string; selectorRevision: string; inputSetHash: string | null }
24
+ /** Complete Git object bytes, including the uncompressed '<type> <size>\0' header. */
25
+ export interface PublicPruneObject { oid: string; bytes: Uint8Array }
26
+ export interface PublicPruneInputOptions {
27
+ dir: string; binding: PublicPruneBinding; current: () => PublicPruneBinding;
28
+ /** null prepares an explicit empty activation-set candidate, without activating it. */
29
+ input: { source: Source; witnessHash: string; objects: readonly PublicPruneObject[] } | null;
30
+ }
31
+ declare const planBrand: unique symbol;
32
+ export interface PublicPruneInputPlan { readonly [planBrand]: true }
33
+ export interface PublicPruneInputRegistration { inputHash?: string; inputSetHash: string; previous: string | null }
34
+ export interface PublicPruneReplayInput {
35
+ dir: string; realm: ShardSyncRealm; receivingGeneration: string; inputSetHash: string;
36
+ nativeTargets?: readonly string[]; ordinary?: readonly Entry[];
37
+ }
38
+ export interface PublicPruneProjection {
39
+ targets: Set<string>; rowHashes: Map<string, string>; authoredPrunes: number; inputs: string[]; commitments: string[];
40
+ }
41
+ export type PublicPrunePersistPhase = `${"object" | "input" | "set"}-${"temp" | "fsync" | "installed"}`;
42
+ const fail = (code: PublicPruneInputError["code"] = "unavailable"): never => { throw new PublicPruneInputError(code); };
43
+ function guard(ok: unknown, code?: PublicPruneInputError["code"]): asserts ok { if (!ok) fail(code); }
44
+ function protectedCall<T>(work: () => T): T { try { return work(); } catch (error) { if (error instanceof PublicPruneInputError) throw error; return fail(); } }
45
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
46
+ const exact = (v: Record<string, unknown>, keys: string[]) => Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
47
+ const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && /^[a-f0-9]+$/.test(v);
48
+ const sha1 = (v: Uint8Array) => createHash("sha1").update(v).digest("hex");
49
+ function selectedRealm(v: unknown): ShardSyncRealm {
50
+ guard(object(v));
51
+ if (v.scope === "user") { guard(exact(v, ["scope"])); return { scope: "user" }; }
52
+ guard(exact(v, ["scope", "key"]) && v.scope === "project" && typeof v.key === "string" && v.key.length > 0 && Buffer.byteLength(v.key) <= PUBLIC_PRUNE_INPUT_MAX_BYTES);
53
+ return { scope: "project", key: v.key };
54
+ }
55
+ function boundedJSON(v: unknown): void { guard(Buffer.byteLength(canonicalJSON(v)) <= PUBLIC_PRUNE_INPUT_MAX_BYTES, "resource"); }
56
+ function source(v: unknown, realm: ShardSyncRealm, witnessHash: string): Source {
57
+ guard(object(v) && exact(v, ["remoteHash", "ref", "commit", "manifestPath", "manifestBlob"]));
58
+ guard(hex(v.remoteHash, 64) && hex(v.commit, 40) && hex(v.manifestBlob, 40) && v.ref === shardSyncRef(realm));
59
+ guard(v.manifestPath === `prune-witnesses/${witnessHash}.json`);
60
+ return { remoteHash: v.remoteHash, ref: v.ref, commit: v.commit, manifestPath: v.manifestPath as string, manifestBlob: v.manifestBlob };
61
+ }
62
+ export function validatePublicPruneInput(v: unknown): PublicPruneInputV1 {
63
+ return protectedCall(() => {
64
+ guard(object(v) && exact(v, ["schema", "realm", "receivingGeneration", "selectorRevision", "witnessHash", "source"])); boundedJSON(v);
65
+ guard(v.schema === "agent-mem.public-prune-input/v1" && hex(v.receivingGeneration, 32) && (hex(v.selectorRevision, 64) || v.selectorRevision === "absent") && hex(v.witnessHash, 64));
66
+ const realm = selectedRealm(v.realm);
67
+ return { schema: v.schema, realm, receivingGeneration: v.receivingGeneration, selectorRevision: v.selectorRevision, witnessHash: v.witnessHash, source: source(v.source, realm, v.witnessHash) };
68
+ });
69
+ }
70
+ function binding(v: PublicPruneBinding): PublicPruneBinding {
71
+ guard(object(v) && exact(v, ["realm", "receivingGeneration", "selectorRevision", "inputSetHash"]));
72
+ guard(hex(v.receivingGeneration, 32) && (hex(v.selectorRevision, 64) || v.selectorRevision === "absent") && (v.inputSetHash === null || hex(v.inputSetHash, 64)));
73
+ return { realm: selectedRealm(v.realm), receivingGeneration: v.receivingGeneration, selectorRevision: v.selectorRevision, inputSetHash: v.inputSetHash };
74
+ }
75
+ function json(bytes: Buffer): unknown {
76
+ guard(bytes.length > 0 && bytes.length <= PUBLIC_PRUNE_INPUT_MAX_BYTES, "resource");
77
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes), value: unknown = JSON.parse(text), canonical = canonicalJSON(value);
78
+ guard(text === canonical || text === canonical + "\n"); return value;
79
+ }
80
+ interface GitObject { type: "commit" | "tree" | "blob"; body: Buffer }
81
+ function gitObject(oid: string, bytes: Buffer): GitObject {
82
+ guard(hex(oid, 40) && bytes.length <= OBJECT_MAX, "resource"); guard(sha1(bytes) === oid);
83
+ const end = bytes.indexOf(0); guard(end > 0 && end < 40);
84
+ const header = bytes.subarray(0, end).toString("utf8"), match = /^(commit|tree|blob) (0|[1-9][0-9]*)$/.exec(header);
85
+ guard(match && match[0] === header && Number(match[2]) === bytes.length - end - 1);
86
+ const body = bytes.subarray(end + 1);
87
+ guard(match[1] !== "commit" || body.length <= PUBLIC_PRUNE_INPUT_MAX_BYTES, "resource");
88
+ return { type: match[1] as GitObject["type"], body };
89
+ }
90
+ function treeEntries(body: Buffer): Map<string, { mode: string; oid: string }> {
91
+ const entries = new Map<string, { mode: string; oid: string }>();
92
+ for (let offset = 0; offset < body.length;) {
93
+ const space = body.indexOf(32, offset), end = body.indexOf(0, space + 1);
94
+ guard(space > offset && end > space + 1 && end + 21 <= body.length);
95
+ const mode = body.subarray(offset, space).toString("utf8"), name = new TextDecoder("utf-8", { fatal: true }).decode(body.subarray(space + 1, end));
96
+ guard(["40000", "100644"].includes(mode) && name !== "." && name !== ".." && !name.includes("/") && !entries.has(name));
97
+ entries.set(name, { mode, oid: body.subarray(end + 1, end + 21).toString("hex") }); offset = end + 21;
98
+ }
99
+ return entries;
100
+ }
101
+ /** Exact path closure only. Sibling object bodies are neither requested nor retained. */
102
+ function graph(input: PublicPruneInputV1, load: (oid: string) => Buffer): { proof: VerifiedPublicPruneWitness; used: Set<string> } {
103
+ const used = new Set<string>(), cache = new Map<string, GitObject>(), trees = new Map<string, ReturnType<typeof treeEntries>>(); let total = 0;
104
+ const get = (oid: string, type: GitObject["type"]): GitObject => {
105
+ let item = cache.get(oid);
106
+ if (!item) {
107
+ const bytes = load(oid); total += bytes.length; guard(total <= PUBLIC_PRUNE_GRAPH_MAX_BYTES && used.size < PUBLIC_PRUNE_GRAPH_MAX_OBJECTS, "resource");
108
+ item = gitObject(oid, bytes); used.add(oid); cache.set(oid, item);
109
+ }
110
+ guard(item.type === type); return item;
111
+ };
112
+ const commit = get(input.source.commit, "commit").body, end = commit.indexOf(10); guard(end > 0);
113
+ const first = commit.subarray(0, end).toString("utf8");
114
+ const root = /^tree ([a-f0-9]{40})$/.exec(first); guard(root && root[0] === first);
115
+ const path = (name: string): { oid: string; body: Buffer } => {
116
+ const parts = name.split("/"); let oid = root[1]!;
117
+ for (let i = 0; i < parts.length; i++) {
118
+ let entries = trees.get(oid); if (!entries) { entries = treeEntries(get(oid, "tree").body); trees.set(oid, entries); }
119
+ const entry = entries.get(parts[i]!); guard(entry);
120
+ const last = i === parts.length - 1; guard(entry.mode === (last ? "100644" : "40000")); oid = entry.oid;
121
+ }
122
+ return { oid, body: get(oid, "blob").body };
123
+ };
124
+ const expected = { schema: "agent-mem.shard-sync/v2", ...input.realm };
125
+ guard(canonicalJSON(json(path("shard.json").body)) === canonicalJSON(expected));
126
+ const manifest = path(input.source.manifestPath); guard(manifest.oid === input.source.manifestBlob);
127
+ const raw = json(manifest.body); guard(object(raw) && Array.isArray(raw.witnesses) && raw.witnesses.length <= 64 && object(raw.pruneOccurrence));
128
+ const refs = [raw.pruneOccurrence, ...raw.witnesses], packs = new Map<string, { pack: string; blob: string; bytes: Buffer }>();
129
+ for (const ref of refs) {
130
+ guard(object(ref) && typeof ref.pack === "string" && ref.pack.length === 140 && /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.exec(ref.pack)?.[0] === ref.pack && hex(ref.blob, 40));
131
+ if (packs.has(ref.pack)) { guard(packs.get(ref.pack)!.blob === ref.blob); continue; }
132
+ const found = path(ref.pack); guard(found.oid === ref.blob); packs.set(ref.pack, { pack: ref.pack, blob: ref.blob, bytes: found.body });
133
+ }
134
+ const proof = verifyPublicPruneWitness(raw, { realm: input.realm, packs: [...packs.values()] });
135
+ guard(proof.status === "verified" && proof.hash === input.witnessHash);
136
+ return { proof, used };
137
+ }
138
+ interface Prepared { dir: string; dev: number; ino: number; binding: PublicPruneBinding; current: () => PublicPruneBinding; input?: PublicPruneInputV1; objects: Map<string, Buffer> }
139
+ const plans = new WeakMap<PublicPruneInputPlan, Prepared>();
140
+ export function preparePublicPruneInput(options: PublicPruneInputOptions): PublicPruneInputPlan {
141
+ return protectedCall(() => {
142
+ const expected = binding(options.binding); guard(canonicalJSON(binding(options.current())) === canonicalJSON(expected), "binding");
143
+ const objects = new Map<string, Buffer>(); let input: PublicPruneInputV1 | undefined;
144
+ if (options.input !== null) {
145
+ const value = options.input; guard(value && Array.isArray(value.objects)); guard(value.objects.length <= PUBLIC_PRUNE_GRAPH_MAX_OBJECTS, "resource");
146
+ let total = 0;
147
+ for (const item of value.objects) {
148
+ guard(object(item) && exact(item, ["oid", "bytes"]) && hex(item.oid, 40) && item.bytes instanceof Uint8Array);
149
+ total += item.bytes.byteLength; guard(item.bytes.byteLength <= OBJECT_MAX && total <= PUBLIC_PRUNE_GRAPH_MAX_BYTES, "resource"); guard(!objects.has(item.oid));
150
+ objects.set(item.oid, Buffer.from(item.bytes));
151
+ }
152
+ input = validatePublicPruneInput({ schema: "agent-mem.public-prune-input/v1", realm: expected.realm, receivingGeneration: expected.receivingGeneration,
153
+ selectorRevision: expected.selectorRevision, witnessHash: value.witnessHash, source: value.source });
154
+ const checked = graph(input, oid => { const bytes = objects.get(oid); guard(bytes); return bytes; });
155
+ guard(checked.used.size === objects.size); // never retain whole sibling subtrees or unrelated private bodies
156
+ }
157
+ guard(canonicalJSON(binding(options.current())) === canonicalJSON(expected), "binding");
158
+ const token = Object.freeze({}) as PublicPruneInputPlan;
159
+ const dir = rootDir(options.dir), stat = lstatSync(dir);
160
+ plans.set(token, { dir, dev: stat.dev, ino: stat.ino, binding: expected, current: options.current, input, objects }); return token;
161
+ });
162
+ }
163
+ type Kind = "object" | "input" | "set";
164
+ const folders: Record<Kind, string> = { object: "public-prune-objects", input: "public-prune-inputs", set: "public-prune-input-sets" };
165
+ function filename(kind: Kind, id: string): string { guard(hex(id, kind === "object" ? 40 : 64)); return `${id}.${kind === "object" ? "git" : "json"}`; }
166
+ function privateStat(stat: Stats, directory = false, links = 1): void {
167
+ guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === links));
168
+ guard(stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0);
169
+ }
170
+ function rootDir(dir: string): string { privateStat(lstatSync(dir), true); return realPath(dir); }
171
+ function folder(dir: string, kind: Kind, create = false): string {
172
+ const name = join(dir, folders[kind]), stat = lstatSync(name, { throwIfNoEntry: false });
173
+ if (!stat) { guard(create); mkdirSync(name, { mode: 0o700 }); fsyncDirectory(dir); }
174
+ privateStat(lstatSync(name), true); return name;
175
+ }
176
+ function read(dir: string, kind: Kind, id: string, observed?: (file: string, stat: Stats) => void): Buffer {
177
+ const file = join(folder(dir, kind), filename(kind, id)), before = lstatSync(file); privateStat(before);
178
+ guard(before.size > 0 && before.size <= (kind === "object" ? OBJECT_MAX : PUBLIC_PRUNE_INPUT_MAX_BYTES), "resource");
179
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
180
+ try {
181
+ const opened = fstatSync(fd); privateStat(opened); guard(opened.dev === before.dev && opened.ino === before.ino);
182
+ const bytes = readFileSync(fd), after = fstatSync(fd), named = lstatSync(file); privateStat(after); privateStat(named);
183
+ guard(bytes.length === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && named.dev === before.dev && named.ino === before.ino); observed?.(file, before); return bytes;
184
+ } finally { closeSync(fd); }
185
+ }
186
+ type Loader = (kind: Kind, id: string) => Buffer;
187
+ function replay(input: Omit<PublicPruneReplayInput, "dir">, load: Loader): PublicPruneProjection {
188
+ const selected = selectedRealm(input.realm); guard(hex(input.receivingGeneration, 32) && hex(input.inputSetHash, 64));
189
+ const cache = new Map<string, Buffer>(); let total = 0;
190
+ const data: Loader = (kind, id) => {
191
+ const key = `${kind}:${id}`; let bytes = cache.get(key);
192
+ if (!bytes) { bytes = load(kind, id); total += bytes.length; guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES, "resource"); cache.set(key, bytes); } return bytes;
193
+ };
194
+ const sets = new Set<string>(); let id: string | null = input.inputSetHash, later: Set<string> | undefined, members: string[] = [];
195
+ while (id !== null) {
196
+ guard(!sets.has(id)); guard(sets.size < PUBLIC_PRUNE_MAX_SETS, "resource"); sets.add(id);
197
+ const set = validatePublicPruneInputSet(json(data("set", id))); guard(hashJSON(set) === id && set.receivingGeneration === input.receivingGeneration);
198
+ if (later) guard(set.inputs.every(hash => later!.has(hash))); else members = set.inputs;
199
+ later = new Set(set.inputs); id = set.previous;
200
+ }
201
+ const targets = new Set<string>(), rowHashes = new Map<string, string>(), identities = new Map<string, string>();
202
+ for (const member of members) {
203
+ const descriptor = validatePublicPruneInput(json(data("input", member)));
204
+ guard(hashJSON(descriptor) === member && descriptor.receivingGeneration === input.receivingGeneration && canonicalJSON(descriptor.realm) === canonicalJSON(selected));
205
+ const { proof } = graph(descriptor, oid => data("object", oid)), prune = proof.manifest.prune, hash = prunePayloadHash(prune), key = `${canonicalJSON(selected)}:${prune.id}`;
206
+ guard(!identities.has(key) || identities.get(key) === hash); identities.set(key, hash);
207
+ for (const ref of proof.manifest.witnesses) { guard(!rowHashes.has(ref.id) || rowHashes.get(ref.id) === ref.rowHash); rowHashes.set(ref.id, ref.rowHash); }
208
+ for (const target of prune.targets) targets.add(target.id);
209
+ }
210
+ for (const id of input.nativeTargets ?? []) { guard(hex(id, 32)); targets.add(id); }
211
+ for (const row of input.ordinary ?? []) {
212
+ const { id, ...body } = row; guard(hex(id, 32) && row.schema === "agent-hub.memory/v1" && entryId(body) === id);
213
+ guard(!rowHashes.has(id) || rowHashes.get(id) === hashJSON(row));
214
+ }
215
+ return { targets, rowHashes, authoredPrunes: identities.size, inputs: [...members], commitments: [...sets] };
216
+ }
217
+ export function reducePublicPruneInputs(input: PublicPruneReplayInput): PublicPruneProjection {
218
+ return protectedCall(() => { const dir = rootDir(input.dir); return replay(input, (kind, id) => read(dir, kind, id)); });
219
+ }
220
+ /** Reclaim only private staging names under the real writer fence. Validate the
221
+ * complete inventory first; a final name is never discarded or repaired. */
222
+ function recoverLinkedTemps(dir: string, mutation: MutationHandle): void {
223
+ assertMutationHandle(mutation, dir);
224
+ const actions: { base: string; baseStat: Stats; temp: string; stat: Stats; final?: string }[] = [];
225
+ const pendingName = (name: string) => name.length === 41 && /^\.pending-[a-f0-9]{32}$/.exec(name)?.[0] === name;
226
+ for (const kind of Object.keys(folders) as Kind[]) {
227
+ const base = join(dir, folders[kind]); if (!lstatSync(base, { throwIfNoEntry: false })) continue;
228
+ folder(dir, kind); const baseStat = lstatSync(base), names = readdirSync(base); guard(names.length <= ARCHIVE_FILES_MAX, "resource");
229
+ const inodes = new Map<string, { name: string; stat: Stats }[]>();
230
+ for (const name of names) {
231
+ const pending = pendingName(name), id = name.slice(0, kind === "object" ? 40 : 64);
232
+ guard(pending || filename(kind, id) === name);
233
+ const stat = lstatSync(join(base, name)); guard(stat.nlink === 1 || stat.nlink === 2); privateStat(stat, false, stat.nlink);
234
+ const key = `${stat.dev}:${stat.ino}`, entries = inodes.get(key) ?? []; entries.push({ name, stat }); inodes.set(key, entries);
235
+ }
236
+ for (const entries of inodes.values()) {
237
+ const pending = entries.find(item => pendingName(item.name));
238
+ if (entries[0]!.stat.nlink === 1) {
239
+ guard(entries.length === 1);
240
+ if (pending) actions.push({ base, baseStat, temp: join(base, pending.name), stat: pending.stat });
241
+ continue;
242
+ }
243
+ guard(pending && entries.length === 2); const final = entries.find(item => item !== pending); guard(final && final.stat.nlink === 2);
244
+ const id = final.name.slice(0, kind === "object" ? 40 : 64); guard(filename(kind, id) === final.name);
245
+ const file = join(base, final.name), temp = join(base, pending.name), fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
246
+ try {
247
+ const opened = fstatSync(fd); privateStat(opened, false, 2);
248
+ guard(opened.dev === final.stat.dev && opened.ino === final.stat.ino && opened.size > 0 && opened.size <= (kind === "object" ? OBJECT_MAX : PUBLIC_PRUNE_INPUT_MAX_BYTES), "resource");
249
+ const bytes = readFileSync(fd); guard(bytes.length === opened.size);
250
+ if (kind === "object") gitObject(id, bytes);
251
+ else {
252
+ const value = kind === "input" ? validatePublicPruneInput(json(bytes)) : validatePublicPruneInputSet(json(bytes));
253
+ guard(hashJSON(value) === id);
254
+ }
255
+ for (const name of [file, temp]) { const named = lstatSync(name); privateStat(named, false, 2); guard(named.dev === opened.dev && named.ino === opened.ino && named.size === opened.size && named.mtimeMs === opened.mtimeMs); }
256
+ actions.push({ base, baseStat, temp, stat: opened, final: file });
257
+ } finally { closeSync(fd); }
258
+ }
259
+ }
260
+ // Single-link temps can be arbitrarily incomplete, including sparse crash files.
261
+ // They carry no membership and are neither read into memory nor charged as proof.
262
+ for (const action of actions) {
263
+ assertMutationHandle(mutation, dir);
264
+ const parent = lstatSync(action.base); privateStat(parent, true); guard(parent.dev === action.baseStat.dev && parent.ino === action.baseStat.ino);
265
+ const links = action.final ? 2 : 1, fd = openSync(action.temp, constants.O_RDONLY | constants.O_NOFOLLOW);
266
+ try {
267
+ const opened = fstatSync(fd); privateStat(opened, false, links);
268
+ guard(opened.dev === action.stat.dev && opened.ino === action.stat.ino && opened.size === action.stat.size && opened.mtimeMs === action.stat.mtimeMs && opened.ctimeMs === action.stat.ctimeMs);
269
+ for (const name of action.final ? [action.final, action.temp] : [action.temp]) {
270
+ const named = lstatSync(name); privateStat(named, false, links);
271
+ guard(named.dev === opened.dev && named.ino === opened.ino && named.size === opened.size && named.mtimeMs === opened.mtimeMs && named.ctimeMs === opened.ctimeMs);
272
+ }
273
+ assertMutationHandle(mutation, dir); unlinkSync(action.temp); fsyncDirectory(action.base);
274
+ } finally { closeSync(fd); }
275
+ }
276
+ }
277
+ function capacity(dir: string, writes: Map<string, { kind: Kind; id: string; bytes: Buffer }>): void {
278
+ let total = 0, count = 0;
279
+ for (const kind of Object.keys(folders) as Kind[]) {
280
+ const name = join(dir, folders[kind]); if (!lstatSync(name, { throwIfNoEntry: false })) continue;
281
+ folder(dir, kind);
282
+ for (const entry of readdirSync(name)) {
283
+ const stat = lstatSync(join(name, entry)); privateStat(stat); total += stat.size; count++;
284
+ const candidate = [...writes.values()].find(value => value.kind === kind && filename(kind, value.id) === entry);
285
+ if (candidate) { guard(read(dir, kind, candidate.id).equals(candidate.bytes)); writes.delete(`${kind}:${candidate.id}`); }
286
+ guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES && count <= ARCHIVE_FILES_MAX, "resource");
287
+ }
288
+ }
289
+ for (const value of writes.values()) { total += value.bytes.length; count++; }
290
+ guard(total <= PUBLIC_PRUNE_REPLAY_MAX_BYTES && count <= ARCHIVE_FILES_MAX, "resource");
291
+ }
292
+ function write(dir: string, kind: Kind, id: string, bytes: Buffer, observe?: (phase: PublicPrunePersistPhase) => void): void {
293
+ const base = folder(dir, kind, true), file = join(base, filename(kind, id));
294
+ if (lstatSync(file, { throwIfNoEntry: false })) { guard(read(dir, kind, id).equals(bytes)); return; }
295
+ const temp = join(base, `.pending-${randomBytes(16).toString("hex")}`), fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
296
+ try {
297
+ let offset = 0; while (offset < bytes.length) { const written = writeSync(fd, bytes, offset, bytes.length - offset); guard(written > 0); offset += written; }
298
+ observe?.(`${kind}-temp`); fsyncSync(fd); observe?.(`${kind}-fsync`);
299
+ linkSync(temp, file); fsyncDirectory(base); observe?.(`${kind}-installed`);
300
+ } finally { closeSync(fd); unlinkSync(temp); fsyncDirectory(base); }
301
+ }
302
+ /** Persist evidence under the existing writer fence. A returned hash is still only a candidate commitment. */
303
+ export function persistPublicPruneInput(plan: PublicPruneInputPlan, options: { observe?: (phase: PublicPrunePersistPhase) => void; mutation?: MutationHandle } = {}): PublicPruneInputRegistration {
304
+ return protectedCall(() => {
305
+ const prepared = plans.get(plan); guard(prepared); const expected = prepared.binding;
306
+ const dir = rootDir(prepared.dir);
307
+ const current = () => {
308
+ const stat = lstatSync(dir); privateStat(stat, true);
309
+ guard(stat.dev === prepared.dev && stat.ino === prepared.ino && canonicalJSON(binding(prepared.current())) === canonicalJSON(expected), "binding");
310
+ }; current();
311
+ // The shared SQLite lock is an ownership fence, never an archive or membership source.
312
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) { const stat = lstatSync(join(dir, "mutation-lock.db" + suffix), { throwIfNoEntry: false }); if (stat) privateStat(stat); }
313
+ const release = options.mutation ?? acquireMutation(dir); guard(release); assertMutationHandle(release, dir); try {
314
+ current(); assertMutationHandle(release, dir); recoverLinkedTemps(dir, release); current(); const previous = expected.inputSetHash;
315
+ const members = previous ? replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash: previous }, (kind, id) => read(dir, kind, id)).inputs : [];
316
+ const inputHash = prepared.input && hashJSON(prepared.input), includes = inputHash !== undefined && members.includes(inputHash);
317
+ const set: PublicPruneInputSetV1 = { schema: "agent-mem.public-prune-input-set/v1", receivingGeneration: expected.receivingGeneration,
318
+ previous, inputs: [...new Set([...members, ...(inputHash ? [inputHash] : [])])].sort() };
319
+ validatePublicPruneInputSet(set);
320
+ const inputSetHash = previous && (!inputHash || includes) ? previous : hashJSON(set);
321
+ const writes = new Map<string, { kind: Kind; id: string; bytes: Buffer }>();
322
+ for (const [oid, bytes] of prepared.objects) writes.set(`object:${oid}`, { kind: "object", id: oid, bytes });
323
+ if (prepared.input && inputHash) writes.set(`input:${inputHash}`, { kind: "input", id: inputHash, bytes: Buffer.from(canonicalJSON(prepared.input) + "\n") });
324
+ if (inputSetHash !== previous) writes.set(`set:${inputSetHash}`, { kind: "set", id: inputSetHash, bytes: Buffer.from(canonicalJSON(set) + "\n") });
325
+ // Preflight the complete future committed view and byte budget before the first archive write.
326
+ replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash }, (kind, id) => writes.get(`${kind}:${id}`)?.bytes ?? read(dir, kind, id));
327
+ capacity(dir, writes); current();
328
+ for (const value of writes.values()) { assertMutationHandle(release, dir); write(dir, value.kind, value.id, value.bytes, options.observe); current(); assertMutationHandle(release, dir); }
329
+ return { ...(inputHash ? { inputHash } : {}), inputSetHash, previous };
330
+ } finally { if (!options.mutation) release(); }
331
+ });
332
+ }
333
+
334
+ /** In-process verified-byte frontier only. Never serialized or used as membership.
335
+ * Immutable source changes/deletion force replay/refusal; every new handle replays. */
336
+ export function createPublicPruneReplayCache(input: Omit<PublicPruneReplayInput, "nativeTargets" | "ordinary">): () => PublicPruneProjection {
337
+ let cached: PublicPruneProjection | undefined; const stamps = new Map<string, string>();
338
+ const stamp = (stat: Stats) => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.uid, stat.mode, stat.nlink].join(":");
339
+ const root = rootDir(input.dir), original = lstatSync(root);
340
+ return () => protectedCall(() => {
341
+ const named = lstatSync(root); privateStat(named, true); guard(named.dev === original.dev && named.ino === original.ino);
342
+ const current = () => {
343
+ for (const parent of new Set([...stamps.keys()].map(file => dirname(file)))) privateStat(lstatSync(parent), true);
344
+ return [...stamps].every(([file, expected]) => { const stat = lstatSync(file); privateStat(stat); return stamp(stat) === expected; });
345
+ };
346
+ if (!cached || !current()) {
347
+ stamps.clear(); cached = replay(input, (kind, id) => read(root, kind, id, (file, stat) => stamps.set(file, stamp(stat))));
348
+ guard(current());
349
+ }
350
+ return { targets: new Set(cached.targets), rowHashes: new Map(cached.rowHashes), authoredPrunes: cached.authoredPrunes, inputs: [...cached.inputs], commitments: [...cached.commitments] };
351
+ });
352
+ }
353
+
354
+ /** Reads only the declared immutable descriptor, never discovers membership by scanning. */
355
+ export function readPublicPruneInputDescriptor(input: { dir: string; hash: string }): PublicPruneInputV1 {
356
+ return protectedCall(() => {
357
+ guard(hex(input.hash, 64)); const dir = rootDir(input.dir);
358
+ const value = validatePublicPruneInput(json(read(dir, "input", input.hash))); guard(hashJSON(value) === input.hash); return value;
359
+ });
360
+ }
361
+
362
+ declare const batchBrand: unique symbol;
363
+ export interface PublicPruneInputBatch { readonly [batchBrand]: true }
364
+ interface PreparedBatch {
365
+ dir: string; dev: number; ino: number; binding: PublicPruneBinding; current: () => PublicPruneBinding;
366
+ inputs: Prepared[]; mutation: MutationHandle;
367
+ }
368
+ type ArchiveWrite = { kind: Kind; id: string; bytes: Buffer };
369
+ const batches = new WeakMap<PublicPruneInputBatch, PreparedBatch>();
370
+ function batchCurrent(batch: PreparedBatch, mutation: MutationHandle): void {
371
+ assertMutationHandle(mutation, batch.dir);
372
+ const dir = rootDir(batch.dir), stat = lstatSync(dir);
373
+ guard(stat.dev === batch.dev && stat.ino === batch.ino && canonicalJSON(binding(batch.current())) === canonicalJSON(batch.binding), "binding");
374
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
375
+ const lock = lstatSync(join(dir, "mutation-lock.db" + suffix), { throwIfNoEntry: false }); if (lock) privateStat(lock);
376
+ }
377
+ }
378
+ /** Reconstruct the entire future view, with one aggregate archive budget. No effects. */
379
+ function batchOverlay(batch: PreparedBatch): { writes: Map<string, ArchiveWrite>; result: PublicPruneInputRegistration } {
380
+ const expected = batch.binding, writes = new Map<string, ArchiveWrite>(); let pendingBytes = 0;
381
+ const add = (kind: Kind, id: string, bytes: Buffer) => {
382
+ const key = `${kind}:${id}`, existing = writes.get(key);
383
+ if (existing) { guard(existing.bytes.equals(bytes)); return; }
384
+ pendingBytes += bytes.length; guard(pendingBytes <= PUBLIC_PRUNE_REPLAY_MAX_BYTES && writes.size < ARCHIVE_FILES_MAX, "resource");
385
+ writes.set(key, { kind, id, bytes });
386
+ };
387
+ const load: Loader = (kind, id) => writes.get(`${kind}:${id}`)?.bytes ?? read(batch.dir, kind, id);
388
+ let previous = expected.inputSetHash, lastInputHash: string | undefined;
389
+ let members = previous ? replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash: previous }, load).inputs : [];
390
+ for (const prepared of batch.inputs) {
391
+ guard(prepared.input); const input = prepared.input, inputHash = hashJSON(input); lastInputHash = inputHash;
392
+ // Every descriptor stays bound to the actual initial selector revision, never a simulated callback.
393
+ guard(canonicalJSON(prepared.binding) === canonicalJSON(expected));
394
+ const checked = graph(input, oid => { const bytes = prepared.objects.get(oid); guard(bytes); return bytes; });
395
+ guard(checked.used.size === prepared.objects.size);
396
+ for (const [oid, bytes] of prepared.objects) add("object", oid, bytes);
397
+ add("input", inputHash, Buffer.from(canonicalJSON(input) + "\n"));
398
+ if (!members.includes(inputHash)) {
399
+ members = [...members, inputHash].sort();
400
+ const set: PublicPruneInputSetV1 = { schema: "agent-mem.public-prune-input-set/v1", receivingGeneration: expected.receivingGeneration, previous, inputs: members };
401
+ validatePublicPruneInputSet(set); previous = hashJSON(set); add("set", previous, Buffer.from(canonicalJSON(set) + "\n"));
402
+ }
403
+ }
404
+ if (previous === null) {
405
+ const set: PublicPruneInputSetV1 = { schema: "agent-mem.public-prune-input-set/v1", receivingGeneration: expected.receivingGeneration, previous: null, inputs: [] };
406
+ previous = hashJSON(set); add("set", previous, Buffer.from(canonicalJSON(set) + "\n"));
407
+ }
408
+ replay({ realm: expected.realm, receivingGeneration: expected.receivingGeneration, inputSetHash: previous }, load);
409
+ // capacity removes already-complete candidates only from its private working copy.
410
+ capacity(batch.dir, writes);
411
+ return { writes, result: { inputSetHash: previous, previous: expected.inputSetHash, ...(lastInputHash ? { inputHash: lastInputHash } : {}) } };
412
+ }
413
+ /** Preflight all future candidates before transport publication. This writes no
414
+ * evidence, but may reclaim strict unregistered crash temps under the supplied fence. */
415
+ export function preparePublicPruneInputBatch(options: {
416
+ dir: string; binding: PublicPruneBinding; current: () => PublicPruneBinding; inputs: NonNullable<PublicPruneInputOptions["input"]>[];
417
+ }, mutation: MutationHandle): PublicPruneInputBatch {
418
+ return protectedCall(() => {
419
+ const expected = binding(options.binding), dir = rootDir(options.dir), stat = lstatSync(dir);
420
+ const batch: PreparedBatch = { dir, dev: stat.dev, ino: stat.ino, binding: expected, current: options.current, inputs: [], mutation };
421
+ batchCurrent(batch, mutation); guard(Array.isArray(options.inputs));
422
+ // Bound before graph snapshots accumulate; no lifetime selected member is silently omitted.
423
+ guard(options.inputs.length <= PUBLIC_PRUNE_MAX_SETS, "resource");
424
+ let graphBytes = 0; const shared = new Map<string, Buffer>();
425
+ for (const input of options.inputs) {
426
+ const plan = preparePublicPruneInput({ dir, binding: expected, current: options.current, input }), prepared = plans.get(plan)!;
427
+ // Keep one byte copy of identical object bodies across every prepared graph.
428
+ for (const [oid, bytes] of prepared.objects) {
429
+ const existing = shared.get(oid);
430
+ if (existing) { guard(existing.equals(bytes)); prepared.objects.set(oid, existing); }
431
+ else { graphBytes += bytes.length; guard(graphBytes <= PUBLIC_PRUNE_REPLAY_MAX_BYTES, "resource"); shared.set(oid, bytes); }
432
+ }
433
+ batch.inputs.push(prepared); batchCurrent(batch, mutation);
434
+ }
435
+ recoverLinkedTemps(dir, mutation); batchCurrent(batch, mutation);
436
+ batchOverlay(batch); batchCurrent(batch, mutation);
437
+ const token = Object.freeze({}) as PublicPruneInputBatch; batches.set(token, batch); return token;
438
+ });
439
+ }
440
+ /** Durable create-once archive install; the root selector remains the caller's separate commit point. */
441
+ export function persistPublicPruneInputBatch(batch: PublicPruneInputBatch, options?: {
442
+ observe?: (phase: PublicPrunePersistPhase) => void; mutation: MutationHandle;
443
+ }): PublicPruneInputRegistration {
444
+ return protectedCall(() => {
445
+ const prepared = batches.get(batch); guard(prepared); const mutation = options?.mutation ?? prepared.mutation;
446
+ batchCurrent(prepared, mutation); recoverLinkedTemps(prepared.dir, mutation); batchCurrent(prepared, mutation);
447
+ const { writes, result } = batchOverlay(prepared); batchCurrent(prepared, mutation);
448
+ for (const item of writes.values()) {
449
+ batchCurrent(prepared, mutation); write(prepared.dir, item.kind, item.id, item.bytes, options?.observe); batchCurrent(prepared, mutation);
450
+ }
451
+ // Validate installed bytes too, including exact retries and interrupted prior attempts.
452
+ replay({ realm: prepared.binding.realm, receivingGeneration: prepared.binding.receivingGeneration, inputSetHash: result.inputSetHash }, (kind, id) => read(prepared.dir, kind, id));
453
+ batchCurrent(prepared, mutation); return result;
454
+ });
455
+ }