@staix/agent-mem 0.20.3 → 0.21.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.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +3 -0
  5. package/src/hook-spool.ts +45 -3
  6. package/src/index.ts +25 -2
  7. package/src/memory/bridge.ts +66 -18
  8. package/src/memory/candidates.ts +71 -9
  9. package/src/memory/generation-index.ts +95 -18
  10. package/src/memory/generation-sync.ts +3 -0
  11. package/src/memory/generation.ts +33 -15
  12. package/src/memory/platform.ts +38 -3
  13. package/src/memory/policy.ts +28 -15
  14. package/src/memory/prune-publisher-lineage.ts +50 -12
  15. package/src/memory/public-prune-advance.ts +11 -5
  16. package/src/memory/public-prune-inputs.ts +17 -16
  17. package/src/memory/shard-sync-applications.ts +153 -0
  18. package/src/memory/shard-sync-format.ts +15 -0
  19. package/src/memory/shard-sync-graph.ts +6 -4
  20. package/src/memory/shard-sync-history.ts +238 -0
  21. package/src/memory/shard-sync.ts +67 -151
  22. package/src/memory/shard.ts +32 -6
  23. package/src/memory/store.ts +14 -1
  24. package/src/memory/sync-binding.ts +63 -0
  25. package/src/memory/sync-placement-active.ts +75 -0
  26. package/src/memory/sync-placement-cache.ts +32 -0
  27. package/src/memory/sync-placement-capture.ts +292 -0
  28. package/src/memory/sync-placement-dependencies.ts +255 -0
  29. package/src/memory/sync-placement-format.ts +182 -0
  30. package/src/memory/sync-placement-git.ts +95 -0
  31. package/src/memory/sync-placement-guard.ts +98 -0
  32. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  33. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  34. package/src/memory/sync-placement-inspection.ts +236 -0
  35. package/src/memory/sync-placement-journal-format.ts +114 -0
  36. package/src/memory/sync-placement-journal-storage.ts +513 -0
  37. package/src/memory/sync-placement-journal.ts +153 -0
  38. package/src/memory/sync-placement-materialize.ts +149 -0
  39. package/src/memory/sync-placement-plan.ts +171 -0
  40. package/src/memory/sync-placement-projection.ts +79 -0
  41. package/src/memory/sync-placement-publication-plan.ts +189 -0
  42. package/src/memory/sync-placement-publication-storage.ts +87 -0
  43. package/src/memory/sync-placement-transport.ts +489 -0
  44. package/src/memory/sync-policy-inspection.ts +126 -0
  45. package/src/memory/sync-screen.ts +128 -33
  46. package/src/memory/sync.ts +20 -64
  47. package/src/ui/inbox-cache.ts +219 -0
  48. package/src/ui/inbox-worker-version.ts +14 -0
  49. package/src/ui/inbox-worker.ts +32 -0
  50. package/src/ui/inbox.ts +281 -0
  51. package/src/ui/panel.js +7 -3
  52. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,238 @@
1
+ /** Complete selected-realm immutable history audit; Git acquisition and binding checks belong to the caller. */
2
+ import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
4
+ import { canonicalJSON, validGeneration } from "./generation.ts";
5
+ import { validatePruneRow, type PruneRow } from "./prune.ts";
6
+ import { verifyPublicPruneWitness } from "./public-prune-witness.ts";
7
+ import { parsePublicPruneGitTree } from "./shard-sync-graph.ts";
8
+ import { decodeShardDescriptor, decodePlacementTransportDescriptor, decodeShardPack, decodeShardManifest, isShardManifestPath,
9
+ shardSyncRef, SHARD_PACK_MAX_BYTES, type ShardSyncRealm, type ShardSyncRow } from "./shard-sync-format.ts";
10
+
11
+ export interface ShardHistoryOrigin { ref: string; commit: string; pack: string; blob: string }
12
+ export interface ShardHistoryManifest { path: string; bytes: Buffer; value: unknown; row: PruneRow; eligible: boolean; commit: string; blob: string }
13
+ export interface ShardHistory {
14
+ rows: Map<string, ShardSyncRow>; files: Map<string, Buffer>; origins: Map<string, ShardHistoryOrigin>;
15
+ version: "v1" | "v2" | "v3"; manifests: Map<string, ShardHistoryManifest>;
16
+ }
17
+ export interface ShardHistoryAuditorOptions {
18
+ realm: ShardSyncRealm; generation: string; placement: boolean; publicPrune?: boolean;
19
+ /** Complete successful Git output only. The port rechecks receiving bindings around every await. */
20
+ git(args: string[]): Promise<string>;
21
+ /** Caller acquisition bounds and validates objects. Audit also retains identity/newline/bound checks. */
22
+ blob(oid: string, path: string): Promise<Buffer>;
23
+ /** Required by placement publication. Omitting this retains only the legacy trusted-Git abstraction.
24
+ * Body bytes, without the Git object header; the auditor verifies each requested identity itself. */
25
+ object?(oid: string, type: "commit" | "tree"): Promise<Buffer>;
26
+ }
27
+ export type ShardHistoryCause = "unavailable" | "blob" | "forbidden-entry" | "public-prune-floor";
28
+ const refusals: Record<ShardHistoryCause, string> = {
29
+ unavailable: "Shard history is unavailable, inconsistent or unsupported; retain and inspect the complete selected history before continuing.",
30
+ blob: "Shard history blob is unavailable or its identity, bound or complete newline is invalid; retain and inspect the selected history before continuing.",
31
+ "forbidden-entry": "Shard history contains a forbidden historical tree entry; retain and inspect the selected history before continuing.",
32
+ "public-prune-floor": "Shard history requires the v2 public prune floor; activate compatible public-prune support before continuing.",
33
+ };
34
+ export class ShardHistoryError extends Error {
35
+ readonly code: ShardHistoryCause;
36
+ constructor(code: ShardHistoryCause = "unavailable") {
37
+ const safe = typeof code === "string" && Object.hasOwn(refusals, code) ? code : "unavailable";
38
+ super(refusals[safe]); this.code = safe; this.name = "ShardHistoryError";
39
+ }
40
+ }
41
+ function cause(error: unknown): ShardHistoryCause {
42
+ try {
43
+ if (!error || typeof error !== "object" || types.isProxy(error) || !(error instanceof ShardHistoryError)) return "unavailable";
44
+ const descriptor = Object.getOwnPropertyDescriptor(error, "code");
45
+ return descriptor && "value" in descriptor && typeof descriptor.value === "string" && Object.hasOwn(refusals, descriptor.value)
46
+ ? descriptor.value as ShardHistoryCause : "unavailable";
47
+ } catch { return "unavailable"; }
48
+ }
49
+ /** Never reads an external message/getter; only this module's closed own data-code selects safe static text. */
50
+ export function shardHistoryRefusal(error: unknown): string { return refusals[cause(error)]; }
51
+ function fail(code: ShardHistoryCause = "unavailable"): never { throw new ShardHistoryError(code); }
52
+ const oid = (v: unknown): v is string => typeof v === "string" && v.length === 40 && /^[a-f0-9]+$/.test(v);
53
+ const packPath = (v: string) => /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.exec(v)?.[0] === v;
54
+ const digest = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
55
+ const rank = { v1: 1, v2: 2, v3: 3 };
56
+ function detached(value: ShardHistory): ShardHistory {
57
+ return { version: value.version, rows: new Map([...value.rows].map(([id, row]) => [id, structuredClone(row)])),
58
+ files: new Map([...value.files].map(([path, bytes]) => [path, Buffer.from(bytes)])),
59
+ origins: new Map([...value.origins].map(([id, origin]) => [id, { ...origin }])),
60
+ manifests: new Map([...value.manifests].map(([path, manifest]) => [path, { ...manifest, bytes: Buffer.from(manifest.bytes), value: structuredClone(manifest.value), row: structuredClone(manifest.row) }])) };
61
+ }
62
+ export class ShardHistoryAuditor {
63
+ readonly #options: ShardHistoryAuditorOptions;
64
+ readonly #ref: string;
65
+ readonly #histories = new Map<string, ShardHistory>();
66
+ readonly #audited = new Set<string>();
67
+ readonly #blobs = new Map<string, Buffer>();
68
+ readonly #decoded = new Map<string, ShardSyncRow[]>();
69
+ readonly #origins = new Map<string, ShardHistoryOrigin>();
70
+ readonly #manifestOrigins = new Map<string, { commit: string; blob: string }>();
71
+ readonly #parents = new Map<string, string[]>();
72
+ #running = false;
73
+ constructor(options: ShardHistoryAuditorOptions) {
74
+ this.#ref = shardSyncRef(options.realm);
75
+ if (!validGeneration(options.generation) || typeof options.placement !== "boolean" || options.publicPrune !== undefined && typeof options.publicPrune !== "boolean") fail();
76
+ this.#options = { ...options, realm: structuredClone(options.realm), publicPrune: options.publicPrune ?? options.placement };
77
+ }
78
+ async #blob(object: string, path: string): Promise<Buffer> {
79
+ try {
80
+ const maximum = path === "shard.json" || isShardManifestPath(path) ? 64 * 1024 : SHARD_PACK_MAX_BYTES;
81
+ let bytes = this.#blobs.get(object);
82
+ if (!bytes) {
83
+ const result = await this.#options.blob(object, path);
84
+ if (!(result instanceof Uint8Array)) fail("blob"); bytes = Buffer.from(result);
85
+ if (bytes.length < 1 || bytes.length > maximum || bytes.at(-1) !== 10
86
+ || createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") !== object) fail("blob");
87
+ this.#blobs.set(object, bytes);
88
+ }
89
+ if (bytes.length > maximum) fail("blob"); return bytes;
90
+ } catch { return fail("blob"); }
91
+ }
92
+ async #object(object: string, type: "commit" | "tree"): Promise<Buffer> {
93
+ if (!this.#options.object || !oid(object)) return fail();
94
+ const body = await this.#options.object(object, type);
95
+ if (!(body instanceof Uint8Array) || body.length > (type === "commit" ? 65536 : SHARD_PACK_MAX_BYTES)) return fail();
96
+ const bytes = Buffer.from(body);
97
+ if (createHash("sha1").update(`${type} ${bytes.length}\0`).update(bytes).digest("hex") !== object) return fail();
98
+ return bytes;
99
+ }
100
+ async #commit(commit: string, expected?: readonly string[]): Promise<string> {
101
+ const bytes = await this.#object(commit, "commit"), end = bytes.indexOf(10), headerEnd = bytes.indexOf(Buffer.from("\n\n"));
102
+ if (end !== 45 || headerEnd < end || !bytes.subarray(0, 5).equals(Buffer.from("tree "))) return fail();
103
+ const rootBytes = bytes.subarray(5, end), tree = rootBytes.toString("ascii");
104
+ if (!oid(tree) || !rootBytes.equals(Buffer.from(tree))) return fail();
105
+ const parents: string[] = [];
106
+ let at = end + 1;
107
+ while (at < headerEnd) {
108
+ const next = bytes.indexOf(10, at); if (next < at || next > headerEnd) return fail();
109
+ const line = bytes.subarray(at, next); at = next + 1;
110
+ if (line.subarray(0, 7).equals(Buffer.from("parent "))) {
111
+ const parentBytes = line.subarray(7), parent = parentBytes.toString("ascii");
112
+ if (!oid(parent) || !parentBytes.equals(Buffer.from(parent))) return fail(); parents.push(parent);
113
+ } else if (line.subarray(0, 5).equals(Buffer.from("tree "))) return fail();
114
+ }
115
+ const known = expected ?? this.#parents.get(commit);
116
+ if (known && canonicalJSON(parents) !== canonicalJSON(known)) return fail();
117
+ if (expected) this.#parents.set(commit, parents);
118
+ return tree;
119
+ }
120
+ async #treeEntries(root: string): Promise<string[]> {
121
+ // Raw trees are scoped to this one snapshot; retaining a full tree map for every historical commit is quadratic.
122
+ const parsed = new Map<string, ReturnType<typeof parsePublicPruneGitTree>>(), entries: string[] = [];
123
+ const walk = async (object: string, prefix: string): Promise<void> => {
124
+ let tree = parsed.get(object);
125
+ if (!tree) {
126
+ const bytes = await this.#object(object, "tree");
127
+ try { tree = parsePublicPruneGitTree(bytes); } catch { return fail("forbidden-entry"); }
128
+ parsed.set(object, tree);
129
+ }
130
+ for (const [name, entry] of tree) {
131
+ const path = prefix ? `${prefix}/${name}` : name;
132
+ if (entry.mode === "40000") {
133
+ const allowed = !prefix ? name === "rows" || name === "prune-witnesses"
134
+ : prefix === "rows" && name.length === 64 && /^[a-f0-9]+$/.test(name);
135
+ if (!allowed) return fail("forbidden-entry"); await walk(entry.oid, path);
136
+ } else {
137
+ if (path !== "shard.json" && !packPath(path) && !isShardManifestPath(path)) return fail("forbidden-entry");
138
+ entries.push(`100644 blob ${entry.oid}\t${path}`);
139
+ }
140
+ }
141
+ };
142
+ await walk(root, ""); return entries;
143
+ }
144
+ async #tree(commit: string, verifiedRoot?: string): Promise<ShardHistory> {
145
+ const entries = this.#options.object ? await this.#treeEntries(verifiedRoot ?? await this.#commit(commit))
146
+ : (await this.#options.git(["ls-tree", "-r", "-z", commit])).split("\0").filter(Boolean);
147
+ const rows = new Map<string, ShardSyncRow>(), files = new Map<string, Buffer>(), origins = new Map<string, ShardHistoryOrigin>();
148
+ const objects = new Map<string, string>(), manifests = new Map<string, ShardHistoryManifest>();
149
+ for (const entry of entries) {
150
+ const match = /^100644 blob ([a-f0-9]{40})\t(.+)$/.exec(entry);
151
+ if (!match || match[0] !== entry) fail("forbidden-entry");
152
+ const path = match[2]!, object = match[1]!;
153
+ if (files.has(path) || path !== "shard.json" && !packPath(path) && !isShardManifestPath(path)) fail("forbidden-entry");
154
+ files.set(path, await this.#blob(object, path)); objects.set(path, object);
155
+ }
156
+ const descriptor = files.get("shard.json"); if (!descriptor) return fail();
157
+ const version = this.#options.placement ? decodePlacementTransportDescriptor(descriptor, this.#options.realm) : decodeShardDescriptor(descriptor, this.#options.realm);
158
+ if (version !== "v1" && !this.#options.publicPrune) fail("public-prune-floor");
159
+ for (const [path, bytes] of files) if (packPath(path)) {
160
+ const object = objects.get(path)!; let pack = this.#decoded.get(object);
161
+ if (!pack) { pack = decodeShardPack(path, bytes, this.#options.realm, this.#options.generation); this.#decoded.set(object, pack); }
162
+ else if (!path.endsWith(`/${digest(bytes)}.jsonl`) || pack.some(row => path !== `rows/${digest(row.machine)}/${digest(bytes)}.jsonl`)) fail();
163
+ for (const row of pack) {
164
+ const old = rows.get(row.id); if (old && canonicalJSON(old) !== canonicalJSON(row)) fail(); rows.set(row.id, row);
165
+ if (!origins.has(row.id)) origins.set(row.id, this.#origins.get(row.id) ?? { ref: this.#ref, commit, pack: path, blob: object });
166
+ }
167
+ }
168
+ for (const [path, bytes] of files) if (isShardManifestPath(path)) {
169
+ if (version === "v1") fail();
170
+ const value = decodeShardManifest(path, bytes), candidate = value as { prune?: unknown; pruneOccurrence?: { pack: string; blob: string }; witnesses?: { pack: string; blob: string }[] };
171
+ const row = validatePruneRow(candidate.prune, undefined, "synced");
172
+ if (canonicalJSON(rows.get(row.id)) !== canonicalJSON(row) || !candidate.pruneOccurrence || !Array.isArray(candidate.witnesses)) fail();
173
+ const selected = new Map<string, { pack: string; blob: string; bytes: Buffer }>();
174
+ for (const ref of [candidate.pruneOccurrence, ...candidate.witnesses]) {
175
+ if (!ref || typeof ref.pack !== "string" || objects.get(ref.pack) !== ref.blob || !files.has(ref.pack)) fail();
176
+ selected.set(ref.pack, { pack: ref.pack, blob: ref.blob, bytes: files.get(ref.pack)! });
177
+ }
178
+ const proof = verifyPublicPruneWitness(value, { realm: this.#options.realm, packs: [...selected.values()] });
179
+ if (proof.status === "invalid") fail();
180
+ const origin = this.#manifestOrigins.get(path) ?? { commit, blob: objects.get(path)! };
181
+ manifests.set(path, { path, bytes, value, row, eligible: proof.status === "verified", ...origin });
182
+ }
183
+ return { rows, files, origins, version, manifests };
184
+ }
185
+ #accept(current: ShardHistory): void {
186
+ // Commit origins only after the complete tree and every parent retention rule passed.
187
+ for (const [id, origin] of current.origins) { if (!this.#origins.has(id)) this.#origins.set(id, origin); current.origins.set(id, this.#origins.get(id)!); }
188
+ for (const [path, manifest] of current.manifests) {
189
+ const first = this.#manifestOrigins.get(path) ?? { commit: manifest.commit, blob: manifest.blob };
190
+ this.#manifestOrigins.set(path, first); current.manifests.set(path, { ...manifest, ...first });
191
+ }
192
+ }
193
+ /** Ancestry of the immutable hash-verified commit graph, never a second Git interpretation of mutable cache bytes.
194
+ * The caller still proves this tip is the selected actual remote head. Legacy trusted-Git ports are insufficient. */
195
+ async verifiedAncestor(ancestor: string, tip: string): Promise<boolean> {
196
+ if (!this.#options.object || !oid(ancestor) || !oid(tip)) return fail();
197
+ await this.history(tip);
198
+ const pending = [tip], seen = new Set<string>();
199
+ while (pending.length) {
200
+ const commit = pending.pop()!; if (seen.has(commit)) continue; seen.add(commit);
201
+ const parents = this.#parents.get(commit);
202
+ if (!this.#audited.has(commit) || !parents) return fail();
203
+ if (commit === ancestor) return true;
204
+ for (const parent of parents) pending.push(parent);
205
+ }
206
+ return false;
207
+ }
208
+ /** Only requested tips retain full row maps. Parent trees are reconstructed with decoded immutable blobs. */
209
+ async history(tip: string): Promise<ShardHistory> {
210
+ if (!oid(tip) || this.#running) return fail(); this.#running = true;
211
+ try {
212
+ const cached = this.#histories.get(tip); if (cached) return detached(cached);
213
+ const lines = (await this.#options.git(["rev-list", "--topo-order", "--reverse", "--parents", tip])).split("\n").filter(Boolean);
214
+ let latest: ShardHistory | undefined; const seen = new Set<string>();
215
+ // ponytail: complete selected history still costs time and immutable selected-blob memory per invocation.
216
+ // Upgrade with a validated immutable commit/blob digest cache, never an arbitrary history lifetime cliff.
217
+ for (const line of lines) {
218
+ const [commit, ...parents] = line.split(" ");
219
+ if (!oid(commit) || parents.some(parent => !oid(parent)) || seen.has(commit)) return fail(); seen.add(commit);
220
+ const verifiedRoot = this.#options.object ? await this.#commit(commit, parents) : undefined;
221
+ if (this.#audited.has(commit)) { if (commit === tip) latest = await this.#tree(commit, verifiedRoot); continue; }
222
+ const current = await this.#tree(commit, verifiedRoot);
223
+ for (const parent of parents) {
224
+ if (!this.#audited.has(parent) || !seen.has(parent)) return fail();
225
+ const previous = this.#histories.get(parent) ?? await this.#tree(parent);
226
+ if (rank[current.version] < rank[previous.version]) fail();
227
+ if (previous.version !== "v1") for (const [path, bytes] of previous.files)
228
+ if (path !== "shard.json" && !current.files.get(path)?.equals(bytes)) fail();
229
+ for (const row of previous.rows.values()) if (canonicalJSON(current.rows.get(row.id)) !== canonicalJSON(row)) fail();
230
+ }
231
+ this.#accept(current); this.#audited.add(commit); if (commit === tip) latest = current;
232
+ }
233
+ if (!latest || lines.at(-1)?.split(" ")[0] !== tip) return fail();
234
+ this.#histories.set(tip, latest); return detached(latest);
235
+ } catch (error) { return fail(cause(error)); }
236
+ finally { this.#running = false; }
237
+ }
238
+ }
@@ -1,3 +1,8 @@
1
+ import { syncPlacementMemory } from "./sync-placement-transport.ts";
2
+ import { ShardSyncApplications, ShardSyncApplicationError } from "./shard-sync-applications.ts";
3
+ import { ShardHistoryAuditor, ShardHistoryError, shardHistoryRefusal } from "./shard-sync-history.ts";
4
+ import { validatePlacementProfile, type PlacementProfile } from "./sync-placement-format.ts";
5
+ import { placementRuntimeRefusal } from "./sync-placement-guard.ts";
1
6
  import { assertPublisherBoundary, ensurePublisherBoundary, publisherPruneLineages } from "./prune-publisher-lineage.ts";
2
7
  import { verifyPublicPruneWitness, type PublicPruneWitnessV1 } from "./public-prune-witness.ts";
3
8
  import { readPublicPruneView, type PublicPruneViewV1 } from "./public-prune-view.ts";
@@ -7,7 +12,7 @@ import { PublicPruneInputError, PUBLIC_PRUNE_REPLAY_MAX_BYTES, PUBLIC_PRUNE_MAX_
7
12
  import { randomBytes, createHash } from "node:crypto";
8
13
  import { constants, closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, rmSync, unlinkSync, writeSync } from "node:fs";
9
14
  import { join } from "node:path";
10
- import { canonicalJSON, fsyncDirectory, hashJSON, readProfileJSON, readSelector, rebindPublicPruneProfile } from "./generation.ts";
15
+ import { canonicalJSON, fsyncDirectory, hashJSON, readProfileJSON, readSelector, rebindPublicPruneProfile, legacyProfileMarkerShape, type ProfileSelector } from "./generation.ts";
11
16
  import { generationSyncLock } from "./generation-sync.ts";
12
17
  import { acquireMutation, assertMutationHandle, type MutationHandle } from "./mutation.ts";
13
18
  import { realPath } from "./platform.ts";
@@ -73,13 +78,30 @@ function selectorEvidence(folder: string, file: string, prefix?: Buffer | true):
73
78
  if(canonicalJSON(value)+"\n"!==text)refuse("prior selector unavailable");return{value,bytes:data,check};
74
79
  } finally {closeSync(fd);}
75
80
  }
81
+ /** Closed prior selectors never authorize profile activation or change a pending prune payload. */
82
+ function retainedSelector(store: MemoryStore, value: unknown, revision: string): { pruneInputSetHash: string } {
83
+ const candidate=value as {schema?:unknown}|undefined;
84
+ let prior: PlacementProfile | Extract<ProfileSelector, {schema:"agent-mem.profile/v2"}>;
85
+ if(candidate?.schema==="agent-mem.profile/v3") {
86
+ prior=validatePlacementProfile(value);
87
+ if(!store.profile.publicationFormat||prior.publicationFormat!==store.profile.publicationFormat||prior.placementHead!==store.profile.placementHead)
88
+ refuse("pending selector activation binding unavailable");
89
+ } else {
90
+ if(!legacyProfileMarkerShape(value)||value?.schema!=="agent-mem.profile/v2")refuse("pending selector binding unavailable");
91
+ prior=value;
92
+ }
93
+ if(prior.generation!==store.profile.generation||prior.foreignPruneFormat!==store.profile.foreignPruneFormat||hashJSON(prior)!==revision)
94
+ refuse("pending selector binding unavailable");
95
+ return prior;
96
+ }
76
97
  function pendingSelector(store:MemoryStore,plan:PrunePlan,required=false):(()=>void)|undefined {
77
98
  if(!required&&plan.selectorRevision===store.profile.selectorRevision)return undefined;
78
99
  if(!store.profile.foreignPruneFormat)refuse("pending selector unavailable");
79
100
  const history=join(store.dir,"prune-plans","selector-history"),file=join(history,plan.seal+".json"),evidence=selectorEvidence(history,file),prior=evidence.value as Record<string,unknown>|undefined;
80
- if(!prior||Object.keys(prior).length!==5||prior.schema!=="agent-mem.profile/v2"||prior.kind!=="generation"||prior.generation!==store.profile.generation
81
- ||prior.foreignPruneFormat!==store.profile.foreignPruneFormat||typeof prior.pruneInputSetHash!=="string"||!/^[a-f0-9]{64}$/.test(prior.pruneInputSetHash)||hashJSON(prior)!==plan.selectorRevision)refuse("pending selector binding unavailable");
82
- const bound={...store.profile,selectorRevision:plan.selectorRevision,pruneInputSetHash:prior.pruneInputSetHash};
101
+ const checked=retainedSelector(store,prior,plan.selectorRevision);
102
+ // A historical v2 selector remains evidence only. Retain the current v3 activation binding while proving
103
+ // its public-prune view can advance monotonically to the current selector, never enabling a v2 writer.
104
+ const bound={...store.profile,selectorRevision:plan.selectorRevision,pruneInputSetHash:checked.pruneInputSetHash};
83
105
  const check=()=>{evidence.check();const probe={...bound};rebindPublicPruneProfile(probe);if(probe.selectorRevision!==store.profile.selectorRevision)refuse("pending selector ancestry unavailable");};check();return check;
84
106
  }
85
107
  /** Recognize only the exact persisted transport shape; this reads metadata and never resumes an append. */
@@ -144,10 +166,8 @@ function selectorRetention(target: Target, advancing: boolean, install = false,
144
166
  const validate=(plan:PrunePlan,value:unknown)=>{
145
167
  const row=plan.rows[0]?.payload,prior=value as Record<string,unknown>|undefined;
146
168
  if(plan.rows.length!==1||!row||plan.generation!==target.generation||row.generation!==target.generation
147
- ||canonicalJSON(plan)!==canonicalJSON(importPrunePlan(target.store,target.realm,row,plan.selectorRevision))
148
- ||!prior||Object.keys(prior).length!==5||prior.schema!=="agent-mem.profile/v2"||prior.kind!=="generation"
149
- ||prior.generation!==target.generation||prior.foreignPruneFormat!==target.store.profile.foreignPruneFormat
150
- ||typeof prior.pruneInputSetHash!=="string"||!/^[a-f0-9]{64}$/.test(prior.pruneInputSetHash)||hashJSON(prior)!==plan.selectorRevision)refuse("selector recovery plan binding unavailable");
169
+ ||canonicalJSON(plan)!==canonicalJSON(importPrunePlan(target.store,target.realm,row,plan.selectorRevision)))refuse("selector recovery plan binding unavailable");
170
+ retainedSelector(target.store,prior,plan.selectorRevision);
151
171
  };
152
172
  for(const [seal,record] of records){
153
173
  const active=pending.get(seal),plan=active?.plan??validatePrunePlan(readProfileJSON(join(plans,seal+".json")));
@@ -423,23 +443,16 @@ async function prepareHolds(targets: readonly Target[], root: string, url: strin
423
443
  }
424
444
  }
425
445
  }
426
- function mergePackOrigins(into: Map<string, HoldSource>, byRealm: Map<string, Map<string, HoldSource>>, rows: readonly ShardSyncRow[], source: HoldSource): void {
427
- const first = byRealm.get(source.ref) ?? new Map<string, HoldSource>(); byRealm.set(source.ref, first);
428
- // Oldest-first history keeps a receipt's source stable across tip advancement and partial-write retry.
429
- for (const row of rows) {
430
- if (!first.has(row.id)) first.set(row.id, source);
431
- if (!into.has(row.id)) into.set(row.id, first.get(row.id)!);
432
- }
433
- }
434
-
435
- /** Sync only borrowed canonical project/user targets. Internal error/phase observers are test-only, never CLI/SyncOptions ports. */
436
446
  export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions = {}, testOnlyUnexpectedError?: (error: unknown) => void, testOnlyPhase?: (phase: SyncPhase) => void): Promise<SyncResult> {
447
+ const placementRefusal = placementRuntimeRefusal(store.profile.home, store.profile);
448
+ if (placementRefusal) return { ok: false, pushed: false, entries: 0, error: placementRefusal };
449
+ if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return syncPlacementMemory(store, opts);
437
450
  let pushed = false, releaseTransport: (() => void) | undefined, releaseSync: (() => void) | undefined;
438
451
  const releases: (() => void)[] = [], transient: string[] = [], temporary: string[] = [];
439
452
  const v2 = store.profile.foreignPruneFormat === "agent-mem.public-prune-witness/v1";
440
453
  let expectedRevision = store.profile.selectorRevision;
441
454
  let expectedBinding: string | undefined, expectedSources: string | undefined, expectedPolicy: string | undefined;
442
- const application: NonNullable<SyncResult["pruneApplication"]> = { transport:"unconfirmed", proofComplete:0, projectionActive:null, nativeApplied:null, held:0, phase:"preflight" };
455
+ let application: NonNullable<SyncResult["pruneApplication"]> = { transport:"unconfirmed", proofComplete:0, projectionActive:null, nativeApplied:null, held:0, phase:"preflight" };
443
456
  const fail = (error: string): SyncResult => ({ ok: false, pushed, entries: 0, error, ...(v2 ? {pruneApplication:{...application,transport:pushed?"durable":"unconfirmed"}} : {}) });
444
457
  let root = "", cache = "", targets: Target[] = [], homeMutation:MutationHandle|undefined;
445
458
  let boundView:PublicPruneViewV1|undefined;
@@ -529,7 +542,7 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
529
542
  const release = acquireMutation(target.store.dir); if (!release) refuse(`${shardSyncLabel(target.realm)}: another source mutation is running`); releases.push(release); target.mutation=release;
530
543
  if (v2) ensurePublisherBoundary(target.store.profile,release);
531
544
  }
532
- const blobCache = new Map<string, Buffer>(), decoded = new Map<string, ShardSyncRow[]>();
545
+ const blobCache = new Map<string, Buffer>();
533
546
  const blob = async (object: string, path: string): Promise<Buffer> => {
534
547
  const maximum = path === "shard.json" || isShardManifestPath(path) ? 64 * 1024 : SHARD_PACK_MAX_BYTES, prior = blobCache.get(object);
535
548
  if (prior) { if (prior.length > maximum) refuse("shard sync blob exceeds its bound"); return prior; }
@@ -542,69 +555,16 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
542
555
  if (identity !== object) refuse("shard sync public blob identity mismatch");
543
556
  blobCache.set(object, bytes); return bytes;
544
557
  };
545
- type History = { rows: Map<string, ShardSyncRow>; files: Map<string, Buffer>; origins: Map<string, HoldSource>; version:"v1"|"v2"; manifests:Map<string,SyncManifest> };
546
- // Keep only requested tips. Per-commit full row maps would make append-only history quadratic in memory.
547
- const histories = new Map<string, History>(), audited = new Set<string>();
548
- const firstOrigins = new Map<string, Map<string, HoldSource>>();
549
- const manifestOrigins = new Map<string,{commit:string;blob:string}>();
550
- const treeState = async (commit: string, target: Target): Promise<History> => {
551
- const entries=(await required(["ls-tree","-r","-z",commit],"shard sync tree unavailable")).split("\0").filter(Boolean);
552
- const rows=new Map<string,ShardSyncRow>(),files=new Map<string,Buffer>(),origins=new Map<string,HoldSource>(),objects=new Map<string,string>(),manifests=new Map<string,SyncManifest>();
553
- for(const entry of entries){
554
- const match=/^100644 blob ([a-f0-9]{40})\t(.+)$/.exec(entry);
555
- if(!match||match[0]!==entry||match[2]!=="shard.json"&&!PACK.test(match[2]!)&&!isShardManifestPath(match[2]!))refuse(`${shardSyncLabel(target.realm)}: forbidden historical tree entry`);
556
- const path=match[2]!,oid=match[1]!,bytes=await blob(oid,path);files.set(path,bytes);objects.set(path,oid);
557
- }
558
- const descriptor=files.get("shard.json");if(!descriptor)refuse(`${shardSyncLabel(target.realm)}: historical descriptor missing`);
559
- const version=decodeShardDescriptor(descriptor,target.realm);
560
- if(version==="v2"&&!v2)refuse("remote shard requires the explicitly activated public prune view");
561
- for(const [path,bytes] of files)if(PACK.test(path)){
562
- const oid=objects.get(path)!,key=`${shardSyncRef(target.realm)}:${oid}`;let pack=decoded.get(key);
563
- if(!pack){pack=decodeShardPack(path,bytes,target.realm,target.generation);decoded.set(key,pack);}
564
- else if(!path.endsWith(`/${digest(bytes)}.jsonl`)||pack.some(row=>path!==`rows/${digest(row.machine)}/${digest(bytes)}.jsonl`))refuse("immutable pack path changed");
565
- mergeRows(rows,pack,target.realm);mergePackOrigins(origins,firstOrigins,pack,{ref:shardSyncRef(target.realm),commit,pack:path,blob:oid});
566
- }
567
- for(const [path,bytes] of files)if(isShardManifestPath(path)){
568
- if(version!=="v2")refuse("a v1 tree cannot contain prune manifests");
569
- const value=decodeShardManifest(path,bytes),candidate=value as {prune?:unknown;pruneOccurrence?:{pack:string;blob:string};witnesses?:{pack:string;blob:string}[]};
570
- const row=validatePruneRow(candidate.prune,undefined,"synced");
571
- if(canonicalJSON(rows.get(row.id))!==canonicalJSON(row))refuse("manifest original prune is missing or conflicts");
572
- if(!candidate.pruneOccurrence||!Array.isArray(candidate.witnesses))refuse("manifest references unavailable");
573
- const selected=new Map<string,{pack:string;blob:string;bytes:Buffer}>();
574
- for(const ref of [candidate.pruneOccurrence,...candidate.witnesses]){
575
- if(!ref||typeof ref.pack!=="string"||objects.get(ref.pack)!==ref.blob||!files.has(ref.pack))refuse("manifest complete pack is unavailable");
576
- selected.set(ref.pack,{pack:ref.pack,blob:ref.blob,bytes:files.get(ref.pack)!});
577
- }
578
- const proof=verifyPublicPruneWitness(value,{realm:target.realm,packs:[...selected.values()]});
579
- if(proof.status==="invalid")refuse("manifest lacks complete original public proof");
580
- const identity=`${shardSyncRef(target.realm)}:${path}`,origin=manifestOrigins.get(identity)??{commit,blob:objects.get(path)!};manifestOrigins.set(identity,origin);
581
- manifests.set(path,{path,bytes,value,row,eligible:proof.status==="verified",...origin});
582
- }
583
- return{rows,files,origins,version,manifests};
584
- };
585
- const history = async (tip: string, target: Target): Promise<History> => {
586
- const key = `${shardSyncRef(target.realm)}:${tip}`, cached = histories.get(key); if (cached) return cached;
587
- const commits = (await required(["rev-list", "--topo-order", "--reverse", "--parents", tip], "shard sync history unavailable")).split("\n").filter(Boolean);
588
- let latest: History | undefined;
589
- // ponytail: complete selected history still costs time and selected pack memory per invocation.
590
- // Upgrade: a validated immutable commit/blob digest cache, never an arbitrary lifetime refusal cliff.
591
- for (const line of commits) {
592
- const [commit, ...parents] = line.split(" ");
593
- if (!commit || !OID.test(commit) || parents.some(parent => !OID.test(parent))) refuse("shard sync history identity invalid");
594
- const identity = `${shardSyncRef(target.realm)}:${commit}`;
595
- if (audited.has(identity)) { if (commit === tip) latest = await treeState(commit, target); continue; }
596
- const current = await treeState(commit, target);
597
- for (const parent of parents) {
598
- if (!audited.has(`${shardSyncRef(target.realm)}:${parent}`)) refuse(`${shardSyncLabel(target.realm)}: historical parent unavailable`);
599
- const previous = histories.get(`${shardSyncRef(target.realm)}:${parent}`) ?? await treeState(parent, target);
600
- if(previous.version==="v2"&&current.version!=="v2")refuse("shard history cannot downgrade from v2");
601
- if(previous.version==="v2")for(const [path,bytes] of previous.files)if(path!=="shard.json"&&!current.files.get(path)?.equals(bytes))refuse("immutable v2 history artifact removed or changed");
602
- for (const row of previous.rows.values()) if (canonicalJSON(current.rows.get(row.id)) !== canonicalJSON(row)) refuse(`${shardSyncLabel(target.realm)}: historical original row removed or replaced`);
603
- }
604
- audited.add(identity); if (commit === tip) latest = current;
558
+ const auditors = new Map<string, ShardHistoryAuditor>();
559
+ const history = (tip: string, target: Target) => {
560
+ const ref = shardSyncRef(target.realm);
561
+ let auditor = auditors.get(ref);
562
+ if (!auditor) {
563
+ auditor = new ShardHistoryAuditor({ realm: target.realm, generation: target.generation, placement: false, publicPrune: v2,
564
+ git: args => required(args, "shard sync history unavailable"), blob });
565
+ auditors.set(ref, auditor);
605
566
  }
606
- if (!latest) refuse("shard sync history unavailable");
607
- histories.set(key, latest); return latest;
567
+ return auditor.history(tip);
608
568
  };
609
569
  const fetchTargets = async (): Promise<void> => {
610
570
  for (const target of targets) {
@@ -691,23 +651,6 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
691
651
  || !decodeShardPack(value.source.pack, bytes, target.realm, target.generation).some(item => canonicalJSON(item) === canonicalJSON(value.row))
692
652
  || value.source.ref !== shardSyncRef(target.realm)) refuse(`${shardSyncLabel(target.realm)}: prune hold source mismatch`);
693
653
  };
694
- const loadExistingApplications = async () => {
695
- if(!v2)return;
696
- const selected=readSelector(store.profile.home).selector;if(selected?.schema!=="agent-mem.profile/v2")refuse("selected v2 profile unavailable");
697
- boundView=readPublicPruneView(store.profile.home,selected.pruneInputSetHash,store.profile.generation!);
698
- for(const target of targets){
699
- target.registered=[];const key=target.realm.scope==="user"?"user":`project:${target.realm.key}`,set=boundView.realms[key];if(!set)continue;
700
- const projection=reducePublicPruneInputs({dir:target.store.dir,realm:target.realm,receivingGeneration:target.generation,inputSetHash:set});
701
- for(const hash of projection.inputs){
702
- const descriptor=readPublicPruneInputDescriptor({dir:target.store.dir,hash});
703
- if(!target.remote||descriptor.source.remoteHash!==digest(url))refuse("registered prune source history disappeared or its remote binding changed");
704
- await required(["merge-base","--is-ancestor",descriptor.source.commit,target.remote],"registered prune source is missing from replacement history");
705
- const original=await history(descriptor.source.commit,target),manifest=original.manifests.get(descriptor.source.manifestPath);
706
- if(!manifest||!manifest.eligible||manifest.blob!==descriptor.source.manifestBlob||descriptor.source.ref!==shardSyncRef(target.realm)||digest(canonicalJSON(manifest.value))!==descriptor.witnessHash)refuse("registered prune anchor no longer has exact audited evidence");
707
- target.registered.push(descriptor);
708
- }
709
- }
710
- };
711
654
  const objectCache=new Map<string,{type:"commit"|"tree"|"blob";body:Uint8Array}>(), graphObjects=new Map<string,Uint8Array>();
712
655
  // ponytail: one sync retains at most the 256 MiB replay budget across selected realms; segmented acquisition is the upgrade.
713
656
  let objectBytes=0;
@@ -724,57 +667,20 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
724
667
  if(result.code!==0||result.out.length!==size)refuse("Git object bytes unavailable");
725
668
  const value:{type:"commit"|"tree"|"blob";body:Uint8Array}={type:type as "commit"|"tree"|"blob",body:result.out};objectBytes+=encodedSize;objectCache.set(oid,value);return value;
726
669
  };
727
- const prepareApplications = async () => {
728
- if(!v2)return;application.proofComplete=0;application.held=0;
729
- for(const target of targets){
730
- const current=await history(target.commit!,target);target.manifests=new Map(current.manifests);
731
- target.eligible=new Set([...current.manifests.values()].filter(item=>item.eligible).map(item=>item.row.id));
732
- application.proofComplete+=target.eligible.size;
733
- const key=target.realm.scope==="user"?"user":`project:${target.realm.key}`,initial=boundView!.realms[key]??null;
734
- const inputs=[];
735
- for(const record of [...current.manifests.values()].sort((a,b)=>a.path<b.path?-1:1)){
736
- if(!record.eligible||record.row.generation===target.generation)continue;
737
- const witnessHash=digest(canonicalJSON(record.value));
738
- if(target.registered.some(item=>item.witnessHash===witnessHash))continue;
739
- if(inputs.length>=PUBLIC_PRUNE_MAX_SETS)throw new PublicPruneInputError("resource");
740
- const acquired=await collectPublicPruneGitGraph({commit:record.commit,manifestPath:record.path,manifestBlob:record.blob,realm:target.realm,readObject});
741
- // Share exact raw objects before accumulating inputs; repeated manifests cannot multiply graph memory.
742
- const objects=acquired.map(item=>{
743
- const prior=graphObjects.get(item.oid);if(prior&&!Buffer.from(prior).equals(item.bytes))refuse("conflicting Git graph bytes");
744
- const bytes=prior??item.bytes;graphObjects.set(item.oid,bytes);
745
- const cached=objectCache.get(item.oid)!;objectCache.set(item.oid,{type:cached.type,body:bytes.subarray(bytes.indexOf(0)+1)});return{oid:item.oid,bytes};
746
- });
747
- inputs.push({witnessHash,source:{remoteHash:digest(url),ref:shardSyncRef(target.realm),commit:record.commit,manifestPath:record.path,manifestBlob:record.blob},objects});
748
- }
749
- target.batch=undefined;
750
- if(inputs.length){
751
- const binding={realm:target.realm,receivingGeneration:target.generation,selectorRevision:expectedRevision,inputSetHash:initial};
752
- target.batch=preparePublicPruneInputBatch({dir:target.store.dir,binding,current:()=>{check();return{...binding,selectorRevision:expectedRevision};},inputs},target.mutation!);
753
- }
754
- }
755
- await prepareHolds(targets,root,url,proveHoldSource);application.held=targets.reduce((n,item)=>n+item.holds.length,0);application.phase="prepared";
756
- };
757
- const installApplications = async () => {
758
- if(!v2)return;
759
- const realms={...boundView!.realms};
760
- for(const target of targets)if(target.batch){
761
- const installed=persistPublicPruneInputBatch(target.batch,{mutation:target.mutation!,observe:()=>check()});
762
- realms[target.realm.scope==="user"?"user":`project:${target.realm.key}`]=installed.inputSetHash;testOnlyPhase?.("inputs-installed");
763
- }
764
- const view:PublicPruneViewV1={...boundView!,realms};
765
- const changed=canonicalJSON(view)!==canonicalJSON(boundView);
766
- if(changed){
767
- for(const target of targets)selectorRetention(target,true,true);
768
- const result=await advancePublicPruneView({memoryDir:store.profile.home,view,expectedRevision,mutations:[homeMutation!,...targets.map(item=>item.mutation!)]});
769
- expectedRevision=result.revision;boundView=view;
770
- // The same open handles validate/rebind; no close/reopen or authority flag is used.
771
- for(const target of targets)target.store.refresh();
772
- if(shardSyncSourceBinding(store)!==expectedSources||opts.policyVersion?.()!==expectedPolicy)refuse("source binding or policy changed during view commit");
773
- expectedBinding=shardSyncBinding(store);
774
- testOnlyPhase?.("view-committed");
775
- }
776
- application.projectionActive=targets.reduce((n,target)=>n+[...target.eligible].filter(id=>(target.rows.get(id) as PruneRow).generation!==target.generation).length,0);application.phase="active";check();
777
- };
670
+ const applications = v2 ? new ShardSyncApplications({ home: store.profile.home, root, url, targets, homeMutation: homeMutation!,
671
+ getRevision: () => expectedRevision, setRevision: revision => { expectedRevision = revision; }, check, history,
672
+ candidateHistory: target => history(target.commit!, target), readObject,
673
+ retainGraphObject: (oid, bytes) => { const cached = objectCache.get(oid)!; objectCache.set(oid, {type:cached.type,body:bytes.subarray(bytes.indexOf(0)+1)}); },
674
+ assertAncestor: (ancestor, descendant) => required(["merge-base","--is-ancestor",ancestor,descendant], "registered prune source is missing from selected history").then(() => {}),
675
+ afterAdvance: () => {
676
+ if (shardSyncSourceBinding(store) !== expectedSources || opts.policyVersion?.() !== expectedPolicy) refuse("source binding or policy changed during view commit");
677
+ expectedBinding = shardSyncBinding(store);
678
+ }, phase: testOnlyPhase,
679
+ }) : undefined;
680
+ if (applications) application = applications.application;
681
+ const loadExistingApplications = async () => { await applications?.loadExisting(); };
682
+ const prepareApplications = async () => { await applications?.prepare(); };
683
+ const installApplications = async () => { await applications?.install(); };
778
684
  localTargets(); await fetchTargets(); localTargets(); provePrunes(targets); await loadExistingApplications(); await prepareHolds(targets, root, url, proveHoldSource); check();
779
685
  // Every selected realm, including its complete remote history/prunes, is preflighted before the first append or publication.
780
686
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -814,7 +720,8 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
814
720
  }
815
721
  return fail("shard sync selected-ref push failed");
816
722
  } catch (error) {
817
- if (error instanceof PublicPruneInputError || error instanceof ShardSyncFormatError || error instanceof SyncRefusal) return fail(error.message);
723
+ if (error instanceof ShardHistoryError) return fail(shardHistoryRefusal(error));
724
+ if (error instanceof PublicPruneInputError || error instanceof ShardSyncFormatError || error instanceof ShardSyncApplicationError || error instanceof SyncRefusal) return fail(error.message);
818
725
  try { testOnlyUnexpectedError?.(error); } catch { /* A test observer cannot change the neutral result. */ }
819
726
  return fail("shard sync unavailable");
820
727
  } finally {
@@ -825,3 +732,12 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
825
732
  try { releaseTransport?.(); } catch { /* private lock cleanup */ }
826
733
  }
827
734
  }
735
+
736
+
737
+ // Internal application seams shared by the independently admitted placement coordinator.
738
+ // These are deliberately absent from the package barrel: validation and complete fetched-proof preflight stay mandatory.
739
+ export type { Target as ShardSyncApplicationTarget, PendingPrune as ShardSyncPendingPrune, HoldSource as ShardSyncHoldSource,
740
+ HoldReceipt as ShardSyncHoldReceipt, Hold as ShardSyncHold, SyncPhase as ShardSyncApplicationPhase,
741
+ SyncManifest as ShardSyncApplicationManifest, ProveHoldSource as ShardSyncProveHoldSource };
742
+ export { sourceRows as shardSourceRows, provePrunes as shardProvePrunes, importRows as shardImportRows,
743
+ selectorRetention as shardSelectorRetention, prepareHolds as shardPrepareHolds, installHold as shardInstallHold, holdWarning as shardHoldWarning };