@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,255 @@
1
+ import { types } from "node:util";
2
+ import { checkedEntry } from "./bridge.ts";
3
+ import { canonicalJSON, hashJSON, validateVaultLinkRow } from "./generation.ts";
4
+ import { OP_CONTRACTS } from "./ops-contract.ts";
5
+ import { validatePruneRow } from "./prune.ts";
6
+ import { SUGGESTED } from "./store.ts";
7
+ import { placementKeyId, validatePlacementKey, type PlacementKey, type PlacementRealm } from "./sync-placement-journal-format.ts";
8
+ import type { OutboundVerdict, SyncPolicyCategory } from "./sync-screen.ts";
9
+
10
+ export interface DependencySource {
11
+ key: PlacementKey; row: unknown; sourcePlacement: "local" | "synced"; policy: OutboundVerdict; held: boolean;
12
+ }
13
+ /** Trusted input only after the caller independently verifies the exact public occurrence.
14
+ * Neither source placement nor a journal exposure flag constitutes this proof. */
15
+ export interface VerifiedDependencyEndpoint { key: PlacementKey; row: unknown }
16
+ export interface PlacementDependencyInput {
17
+ generation: string; selectedRealms: readonly PlacementRealm[]; rows: readonly DependencySource[];
18
+ publicEndpoints?: readonly VerifiedDependencyEndpoint[];
19
+ /** Exact original prune keys whose complete realm/generation/target/basis witness graph the caller verified. */
20
+ verifiedPrunes?: readonly PlacementKey[];
21
+ }
22
+ export type DependencyHoldReason = SyncPolicyCategory | "journal-hold" | "source-local" | "related-local" | "unsupported-reference" | "unverified-prune";
23
+ export interface DependencyDecision { key: PlacementKey; eligible: boolean; reason?: DependencyHoldReason; dependencies: PlacementKey[] }
24
+ export class PlacementDependencyInputError extends Error {
25
+ constructor() { super("Sync placement dependency inputs are invalid, incomplete or exceed supported bounds; capture and verify the selected sources again."); this.name = "PlacementDependencyInputError"; }
26
+ }
27
+ function fail(): never { throw new PlacementDependencyInputError(); }
28
+ const fullId = (v: unknown): v is string => typeof v === "string" && v.length === 32 && /^[a-f0-9]+$/.test(v);
29
+ const realmId = (v: PlacementRealm) => canonicalJSON(v);
30
+ const categories: readonly string[] = ["secret", "personal-data", "deny-path", "private-or-invalid", "host"];
31
+ interface Reference { id: string; op?: "note" | "goal" | "suggest"; hash?: string; publicHeld?: boolean; sameRealm?: boolean }
32
+ interface Node {
33
+ key: PlacementKey; row: Record<string, unknown>; output: boolean; sourcePlacement: "local" | "synced";
34
+ policy: OutboundVerdict; held: boolean; public: boolean; reason?: DependencyHoldReason; syntaxValid: boolean; refs: Reference[];
35
+ }
36
+ /** Descriptor-only bounded JSON copy, including private flags: those are data for the mandatory verdict,
37
+ * never a reason to invoke getters or silently omit a source. */
38
+ // Check physical cardinality and canonical source bytes before constructing any copied source collection.
39
+ function preflight(raw: unknown): void {
40
+ const descriptors = (value: unknown, array = false) => {
41
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value) !== array || Object.getOwnPropertySymbols(value).length) return fail();
42
+ const proto = Object.getPrototypeOf(value);
43
+ if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) return fail();
44
+ return Object.getOwnPropertyDescriptors(value);
45
+ };
46
+ const fields = descriptors(raw);
47
+ const arrayFields = (value: unknown, max: number) => {
48
+ if (!value || typeof value !== "object" || types.isProxy(value) || !Array.isArray(value)) return fail();
49
+ const length = Object.getOwnPropertyDescriptor(value, "length")?.value;
50
+ if (!Number.isSafeInteger(length) || length > max) return fail();
51
+ const own = descriptors(value, true);
52
+ if (Object.keys(own).length !== length + 1) return fail();
53
+ for (let i = 0; i < length; i++) if (!own[String(i)] || !("value" in own[String(i)]!) || !own[String(i)]!.enumerable) fail();
54
+ return { own, length: length as number };
55
+ };
56
+ for (const [key, field] of Object.entries(fields)) if (!("value" in field) || !field.enumerable || !["generation", "selectedRealms", "rows", "publicEndpoints", "verifiedPrunes"].includes(key)) fail();
57
+ arrayFields(fields.selectedRealms?.value, 256);
58
+ const sources = arrayFields(fields.rows?.value, 20000);
59
+ let bytes = 0, nodes = 0;
60
+ const seen = new Set<object>();
61
+ const size = (v: unknown, depth: number): number => {
62
+ if (++nodes > 1_000_000 || depth > 64) return fail();
63
+ if (v === null) return 4;
64
+ if (typeof v === "boolean") return v ? 4 : 5;
65
+ if (typeof v === "number") { if (!Number.isFinite(v)) return fail(); return String(v).length; }
66
+ if (typeof v === "string") { if (Buffer.byteLength(v) > 8 * 1024 * 1024) return fail(); return Buffer.byteLength(JSON.stringify(v)); }
67
+ if (!v || typeof v !== "object" || types.isProxy(v) || seen.has(v)) return fail();
68
+ seen.add(v);
69
+ try {
70
+ if (Array.isArray(v)) {
71
+ const { own, length } = arrayFields(v, 20000); let total = 2 + Math.max(0, length - 1);
72
+ for (let i = 0; i < length; i++) { total += size(own[String(i)]!.value, depth + 1); if (total > 8 * 1024 * 1024) fail(); }
73
+ return total;
74
+ }
75
+ const own = descriptors(v); let total = 2, count = 0;
76
+ for (const [key, field] of Object.entries(own)) {
77
+ if (!("value" in field) || !field.enumerable) fail();
78
+ total += size(key, depth + 1) + 1 + size(field.value, depth + 1) + Number(count++ > 0);
79
+ if (total > 8 * 1024 * 1024) fail();
80
+ }
81
+ return total;
82
+ } finally { seen.delete(v); }
83
+ };
84
+ for (let i = 0; i < sources.length; i++) {
85
+ const source = descriptors(sources.own[String(i)]!.value), row = source.row;
86
+ if (!row || !("value" in row)) return fail();
87
+ bytes += size(row.value, 0) + 1;
88
+ if (bytes > 8 * 1024 * 1024) fail();
89
+ }
90
+ for (const key of ["publicEndpoints", "verifiedPrunes"]) if (fields[key]) arrayFields(fields[key]!.value, 20000);
91
+ }
92
+ /** ponytail: descriptor-only JSON safety stops at depth64, one million nodes and16MiB including supplied evidence.
93
+ * A reviewed chunked endpoint interface is required before larger proofs can enter this compiler. */
94
+ function copyInput(value: unknown): unknown {
95
+ let bytes = 0, nodes = 0;
96
+ const seen = new Set<object>();
97
+ const visit = (v: unknown, depth: number): unknown => {
98
+ if (++nodes > 1_000_000 || depth > 64) fail();
99
+ if (v === null || typeof v === "boolean") { bytes += 5; return v; }
100
+ if (typeof v === "number") { if (!Number.isFinite(v)) fail(); bytes += 24; return v; }
101
+ if (typeof v === "string") { bytes += Buffer.byteLength(v); if (bytes > 16 * 1024 * 1024) fail(); return v; }
102
+ if (!v || typeof v !== "object" || types.isProxy(v) || seen.has(v) || Object.getOwnPropertySymbols(v).length) fail();
103
+ const array = Array.isArray(v), proto = Object.getPrototypeOf(v);
104
+ if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) fail();
105
+ const fields = Object.getOwnPropertyDescriptors(v); seen.add(v);
106
+ try {
107
+ if (array) {
108
+ const length = fields.length?.value;
109
+ if (!Number.isSafeInteger(length) || length > 20000 || Object.keys(fields).length !== length + 1) fail();
110
+ const out: unknown[] = [];
111
+ for (let i = 0; i < length; i++) { const field = fields[String(i)]; if (!field || !("value" in field) || !field.enumerable) fail(); out.push(visit(field.value, depth + 1)); }
112
+ return out;
113
+ }
114
+ const out: Record<string, unknown> = Object.create(null);
115
+ for (const [key, field] of Object.entries(fields)) { if (!("value" in field) || !field.enumerable) fail(); bytes += Buffer.byteLength(key); out[key] = visit(field.value, depth + 1); }
116
+ return out;
117
+ } finally { seen.delete(v); }
118
+ };
119
+ return visit(value, 0);
120
+ }
121
+ function object(value: unknown): Record<string, unknown> { if (!value || typeof value !== "object" || Array.isArray(value)) return fail(); return value as Record<string, unknown>; }
122
+ function keys(value: Record<string, unknown>, required: string[], optional: string[] = []): void {
123
+ if (required.some(k => !Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) fail();
124
+ }
125
+ function realm(value: unknown): PlacementRealm { return validatePlacementKey({ realm: value, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm; }
126
+ function syntax(node: Node, generation: string): boolean {
127
+ const row = node.row;
128
+ try {
129
+ // Existing validators require a plain JSON object, not our safe null-prototype copy.
130
+ const plain = JSON.parse(canonicalJSON(row));
131
+ if (row.op === "prune") validatePruneRow(plain, generation, node.sourcePlacement);
132
+ else if (row.op === "vault_link") validateVaultLinkRow(plain, generation, "local");
133
+ else if (typeof row.op === "string" && Object.hasOwn(OP_CONTRACTS, row.op)) checkedEntry(plain);
134
+ else return false;
135
+ return true;
136
+ } catch { return false; }
137
+ }
138
+ function utc(value: unknown): boolean { return typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; }
139
+ function suggestion(row: Record<string, unknown>): { from: string; to: string } | undefined {
140
+ if (row.op !== "suggest" || !fullId(row.target) || typeof row.by !== "string") return;
141
+ const match = /^([^:]+):([a-f0-9]{32})$/.exec(row.by);
142
+ if (!match || match[0] !== row.by || !(SUGGESTED as readonly string[]).includes(match[1]!) || row.target === match[2]) return;
143
+ return { from: row.target, to: match[2]! };
144
+ }
145
+ function visible(from: Node, to: Node): boolean {
146
+ // Shared/user facts must not acquire a dependency on a private project endpoint.
147
+ const sharedEndpoint = to.key.realm.scope === "user" && (to.row.scope === undefined || to.row.scope === "shared");
148
+ if (from.key.realm.scope === "user" || from.row.scope === "shared") return sharedEndpoint;
149
+ return sharedEndpoint
150
+ || from.key.realm.scope === "project" && to.key.realm.scope === "project" && from.key.realm.keyHash === to.key.realm.keyHash;
151
+ }
152
+ /** Pure, body-free dependency decisions. The caller supplies authoritative coalesced physical rows and
153
+ * mandatory policy/journal verdicts; this function performs no screening, hydration, I/O or publication. */
154
+ export function compilePlacementDependencies(raw: PlacementDependencyInput): DependencyDecision[] {
155
+ try { return compile(raw); } catch (error) { if (error instanceof PlacementDependencyInputError) throw error; return fail(); }
156
+ }
157
+ function compile(raw: PlacementDependencyInput): DependencyDecision[] {
158
+ preflight(raw);
159
+ const input = object(copyInput(raw)); keys(input, ["generation", "selectedRealms", "rows"], ["publicEndpoints", "verifiedPrunes"]);
160
+ if (!fullId(input.generation) || !Array.isArray(input.selectedRealms) || input.selectedRealms.length > 256 || !Array.isArray(input.rows)
161
+ || input.rows.length > 20000 || input.publicEndpoints !== undefined && !Array.isArray(input.publicEndpoints)
162
+ || input.verifiedPrunes !== undefined && !Array.isArray(input.verifiedPrunes)) return fail();
163
+ const selected = new Set(input.selectedRealms.map(x => realmId(realm(x)))); if (!selected.size || selected.size !== input.selectedRealms.length) fail();
164
+ const nodes: Node[] = [], byKey = new Map<string, Node>(), byId = new Map<string, Node[]>(), hashes = new Map<string, string>();
165
+ let sourceBytes = 0;
166
+ const add = (value: unknown, output: boolean): void => {
167
+ const item = object(value); keys(item, output ? ["key", "row", "sourcePlacement", "policy", "held"] : ["key", "row"]);
168
+ const key = validatePlacementKey(item.key), row = object(item.row), id = placementKeyId(key);
169
+ if (!selected.has(realmId(key.realm)) || row.id !== key.id || hashJSON(row) !== key.rowHash) fail();
170
+ if (hashes.has(key.id) && hashes.get(key.id) !== key.rowHash) fail(); hashes.set(key.id, key.rowHash);
171
+ if (output && (sourceBytes += Buffer.byteLength(canonicalJSON(row) + "\n")) > 8 * 1024 * 1024) fail();
172
+ let policy: OutboundVerdict = { ok: true };
173
+ if (output) {
174
+ const verdict = object(item.policy); keys(verdict, ["ok"], ["category"]);
175
+ if (verdict.ok === false && typeof verdict.category === "string" && categories.includes(verdict.category)) policy = { ok: false, category: verdict.category as SyncPolicyCategory };
176
+ else if (verdict.ok !== true || Object.hasOwn(verdict, "category")) fail();
177
+ if (!["local", "synced"].includes(item.sourcePlacement as string) || typeof item.held !== "boolean") fail();
178
+ }
179
+ const old = byKey.get(id);
180
+ if (old) { if (output || old.public) fail(); old.public = true; return; }
181
+ const node: Node = { key, row, output, sourcePlacement: output ? item.sourcePlacement as "local" | "synced" : "synced", policy,
182
+ held: output ? item.held as boolean : false, public: !output, syntaxValid: false, refs: [] };
183
+ nodes.push(node); byKey.set(id, node); byId.set(key.id, [...(byId.get(key.id) ?? []), node]);
184
+ };
185
+ for (const value of input.rows) add(value, true);
186
+ const endpoints = input.publicEndpoints as unknown[] | undefined;
187
+ if ((endpoints?.length ?? 0) > 20000) fail(); for (const value of endpoints ?? []) add(value, false);
188
+ const proofs = input.verifiedPrunes as unknown[] | undefined;
189
+ if ((proofs?.length ?? 0) > 20000) fail();
190
+ const verified = new Set((proofs ?? []).map(value => { const key = validatePlacementKey(value); if (!selected.has(realmId(key.realm))) fail(); return placementKeyId(key); }));
191
+ const heldIds = new Set(nodes.filter(node => node.held).map(node => node.key.id));
192
+ const policyHolds = new Map<string, SyncPolicyCategory>();
193
+ for (const node of nodes) if (!node.policy.ok) policyHolds.set(node.key.id, node.policy.category);
194
+ const refs = (node: Node, ids: unknown[], op?: Reference["op"], publicHeld = false): boolean => {
195
+ if (ids.some(id => !fullId(id))) return false;
196
+ node.refs.push(...ids.map(id => ({ id: id as string, ...(op ? { op } : {}), ...(publicHeld ? { publicHeld: true } : {}) }))); return true;
197
+ };
198
+ for (const node of nodes) {
199
+ const row = node.row;
200
+ node.syntaxValid = syntax(node, input.generation);
201
+ if (!node.syntaxValid) { node.reason = "unsupported-reference"; continue; }
202
+ if (node.sourcePlacement === "local" || row.op === "settle" || row.op === "vault_link" || row.op === "note" && row.tier === "short") node.reason = "source-local";
203
+ else if (heldIds.has(node.key.id)) node.reason = "journal-hold";
204
+ else if (policyHolds.has(node.key.id)) node.reason = policyHolds.get(node.key.id)!;
205
+ let valid = true;
206
+ switch (row.op) {
207
+ case "note": break; // Both ancestry fields are weak; never fetch or export their originals.
208
+ case "goal": valid = Array.isArray(row.dependsOn) && refs(node, row.dependsOn, "goal"); break;
209
+ case "supersede": valid = row.target !== row.by && refs(node, [row.target, row.by], "note"); break;
210
+ case "goal_state": valid = ["open", "active", "blocked", "done", "dropped"].includes(row.by as string) && refs(node, [row.target], "goal"); break;
211
+ case "goal_link": valid = typeof row.by === "string" && row.by.length > 0 && refs(node, [row.target], "goal"); break;
212
+ case "lease": case "lease_force": valid = utc(row.by) && refs(node, [row.target], "goal"); break;
213
+ case "suggest": { const edge = suggestion(row); valid = !!edge && refs(node, [edge.from, edge.to], "note"); break; }
214
+ case "confirm": case "reject": {
215
+ valid = refs(node, [row.target], "suggest");
216
+ const suggestions = fullId(row.target) ? byId.get(row.target) ?? [] : [];
217
+ const edge = suggestions.map(n => suggestion(n.row)).find(Boolean);
218
+ valid = valid && !!edge && refs(node, [edge.from, edge.to], "note"); break;
219
+ }
220
+ case "forget": valid = refs(node, [row.target], "note", true); break;
221
+ case "demote": valid = utc(row.by) && refs(node, [row.target], "note", true); break;
222
+ case "prune":
223
+ if (!verified.has(placementKeyId(node.key))) node.reason ??= "unverified-prune";
224
+ for (const target of row.targets as { id: string; rowHash: string; basis: { id: string; rowHash: string } }[]) {
225
+ node.refs.push({ id: target.id, hash: target.rowHash, sameRealm: true, publicHeld: true }, { id: target.basis.id, hash: target.basis.rowHash, sameRealm: true, publicHeld: true });
226
+ }
227
+ break;
228
+ case "settle": case "vault_link": break;
229
+ default: valid = false;
230
+ }
231
+ if (!valid) node.reason ??= "unsupported-reference";
232
+ }
233
+ const candidates = (from: Node, ref: Reference): Node[] => (byId.get(ref.id) ?? []).filter(to => visible(from, to)
234
+ && (!ref.op || to.row.op === ref.op) && (!ref.hash || to.key.rowHash === ref.hash)
235
+ && (!ref.sameRealm || realmId(from.key.realm) === realmId(to.key.realm)));
236
+ const usable = (from: Node, ref: Reference): Node | undefined => candidates(from, ref).find(to => {
237
+ if (to.public && ref.publicHeld && to.syntaxValid) return true;
238
+ return !heldIds.has(to.key.id) && !policyHolds.has(to.key.id) && !to.reason;
239
+ });
240
+ const reverse = new Map<string, Set<Node>>();
241
+ for (const node of nodes) for (const ref of node.refs) {
242
+ let dependents = reverse.get(ref.id); if (!dependents) reverse.set(ref.id, dependents = new Set()); dependents.add(node);
243
+ }
244
+ const queue = [...nodes];
245
+ for (let at = 0; at < queue.length; at++) {
246
+ const node = queue[at]!; if (node.reason) continue;
247
+ const endpoints = node.refs.map(ref => usable(node, ref));
248
+ let good = endpoints.every(Boolean);
249
+ if (good && node.row.op === "supersede") good = endpoints[0]!.row.scope !== "shared" || endpoints[1]!.row.scope === "shared";
250
+ if (!good) { node.reason = "related-local"; for (const dependent of reverse.get(node.key.id) ?? []) queue.push(dependent); }
251
+ }
252
+ return nodes.filter(node => node.output).map(node => ({ key: validatePlacementKey(node.key), eligible: !node.reason,
253
+ ...(node.reason ? { reason: node.reason } : {}), dependencies: [...new Map(node.refs.flatMap(ref => candidates(node, ref).map(to => [placementKeyId(to.key), to.key] as const))).values()].sort((a,b) => placementKeyId(a).localeCompare(placementKeyId(b))).map(validatePlacementKey) }))
254
+ .sort((a,b) => placementKeyId(a.key).localeCompare(placementKeyId(b.key)));
255
+ }
@@ -0,0 +1,182 @@
1
+ import { types } from "node:util";
2
+ import { canonicalJSON, hashJSON } from "./generation.ts";
3
+ import { SHARDED_V2_FIELDS } from "./shard.ts";
4
+ import type { SourcePrefix } from "./migrate.ts";
5
+ import type { ShardSyncRealm } from "./shard-sync-format.ts";
6
+
7
+ export const PLACEMENT_FORMAT = "agent-mem.sync-placement/v1";
8
+ export const PLACEMENT_PROFILE_SCHEMA = "agent-mem.profile/v3";
9
+ export const PLACEMENT_SHARDS_SCHEMA = "agent-mem.shards/v3";
10
+ export const PLACEMENT_DESCRIPTOR_SCHEMA = "agent-mem.shard-sync/v3";
11
+ const FOREIGN_FORMAT = "agent-mem.public-prune-witness/v1", METADATA_MAX = 256 * 1024, WIRE_MAX = 64 * 1024, SOURCES_MAX = 20_000;
12
+
13
+ export class SyncPlacementFormatError extends Error {
14
+ readonly code = "sync_placement_format";
15
+ constructor() {
16
+ super("Sync placement metadata is invalid, unsupported or exceeds its bound. Inspect the saved activation plan and use compatible writers before retrying.");
17
+ this.name = "SyncPlacementFormatError";
18
+ }
19
+ }
20
+ export interface PlacementProfile {
21
+ schema: typeof PLACEMENT_PROFILE_SCHEMA; kind: "generation"; generation: string;
22
+ foreignPruneFormat: typeof FOREIGN_FORMAT; pruneInputSetHash: string;
23
+ publicationFormat: typeof PLACEMENT_FORMAT; placementHead: string;
24
+ }
25
+ export interface PlacementShardsFields {
26
+ generation: string; phase: "active"; selectorRevision: string;
27
+ sourceGeneration?: string; sources: SourcePrefix[]; machine: { id: string; label: string };
28
+ legacyCommit?: string; deadLeasesRemoved?: { lease: string; at: string }[];
29
+ }
30
+ export interface PlacementShards extends PlacementShardsFields {
31
+ schema: typeof PLACEMENT_SHARDS_SCHEMA; publicationFormat: typeof PLACEMENT_FORMAT; placementHead: string;
32
+ }
33
+ export interface PlacementBaseV2 {
34
+ profile: { schema: "agent-mem.profile/v2"; kind: "generation"; generation: string; foreignPruneFormat: typeof FOREIGN_FORMAT; pruneInputSetHash: string };
35
+ shards: PlacementShardsFields & { schema: "agent-mem.shards/v2" };
36
+ }
37
+ export type PlacementDescriptor = { schema: typeof PLACEMENT_DESCRIPTOR_SCHEMA; publicationFormat: typeof PLACEMENT_FORMAT }
38
+ & ({ scope: "user" } | { scope: "project"; key: string });
39
+ export interface PlacementMarkers { profile: PlacementProfile; shards: PlacementShards }
40
+ const fail = (): never => { throw new SyncPlacementFormatError(); };
41
+ const hex = (value: unknown, width: number): value is string => typeof value === "string" && value.length === width && new RegExp(`^[a-f0-9]{${width}}$`).test(value);
42
+ const has = (value: Record<string, unknown>, key: string): boolean => Object.hasOwn(value, key);
43
+ function record(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
44
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)) return fail();
45
+ const prototype = Object.getPrototypeOf(value);
46
+ if (prototype !== Object.prototype && prototype !== null || Object.getOwnPropertySymbols(value).length) return fail();
47
+ const keys = Object.getOwnPropertyNames(value), allowed = new Set([...required, ...optional]);
48
+ if (keys.length < required.length || keys.length > allowed.size || required.some(key => !Object.hasOwn(value, key))) return fail();
49
+ const copy: Record<string, unknown> = Object.create(null);
50
+ for (const key of keys) {
51
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
52
+ if (!allowed.has(key) || !descriptor || !("value" in descriptor) || !descriptor.enumerable) return fail();
53
+ copy[key] = descriptor.value;
54
+ }
55
+ return copy;
56
+ }
57
+ function text(value: unknown, maximum = METADATA_MAX): string {
58
+ if (typeof value !== "string" || Buffer.byteLength(value) > maximum) return fail();
59
+ return value;
60
+ }
61
+ function integer(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return fail(); return value; }
62
+ function bounded<T>(value: T, maximum = METADATA_MAX): T { if (Buffer.byteLength(canonicalJSON(value) + "\n") > maximum) return fail(); return value; }
63
+ function list(value: unknown, maximum: number): unknown[] {
64
+ if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length) return fail();
65
+ const length = Object.getOwnPropertyDescriptor(value, "length")?.value;
66
+ if (!Number.isSafeInteger(length) || length < 0 || length > maximum || Object.getOwnPropertyNames(value).length !== length + 1) return fail();
67
+ const result: unknown[] = [];
68
+ for (let index = 0; index < length; index++) {
69
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
70
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) return fail();
71
+ result.push(descriptor.value);
72
+ }
73
+ return result;
74
+ }
75
+ function sourcePrefixes(value: unknown): SourcePrefix[] {
76
+ const result: SourcePrefix[] = [], seen = new Set<string>(); let bytes = 2;
77
+ for (const input of list(value, SOURCES_MAX)) {
78
+ const row = record(input, ["source", "path", "offset", "hash", "tailBytes"], ["commit", "stamp"]);
79
+ if (typeof row.source !== "string" || !["legacy-local", "legacy-main", "generation"].includes(row.source)) return fail();
80
+ const path = text(row.path);
81
+ if (/^(log|local)\/[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.exec(path)?.[0] !== path || row.source === "legacy-main" && !path.startsWith("log/")) return fail();
82
+ if (!hex(row.hash, 64) || (row.source === "legacy-main" ? !hex(row.commit, 40) : has(row, "commit"))) return fail();
83
+ const source: SourcePrefix = { source: row.source, path, offset: integer(row.offset), hash: row.hash as string, tailBytes: integer(row.tailBytes) };
84
+ if (has(row, "commit")) source.commit = row.commit as string;
85
+ if (has(row, "stamp")) { source.stamp = text(row.stamp); if (source.stamp.length > 1024) return fail(); }
86
+ const key = `${source.source}:${source.path}`; if (seen.has(key)) return fail(); seen.add(key);
87
+ bytes += Buffer.byteLength(canonicalJSON(source)) + 1; if (bytes > METADATA_MAX) return fail(); result.push(source);
88
+ }
89
+ return result;
90
+ }
91
+ function shardFields(value: Record<string, unknown>, profile: { generation: string }): PlacementShardsFields {
92
+ if (value.phase !== "active" || value.generation !== profile.generation || !hex(value.selectorRevision, 64)) return fail();
93
+ const machine = record(value.machine, ["id", "label"]); if (!hex(machine.id, 16)) return fail();
94
+ const result: PlacementShardsFields = { generation: profile.generation, phase: "active", selectorRevision: value.selectorRevision,
95
+ sources: sourcePrefixes(value.sources), machine: { id: machine.id, label: text(machine.label) } };
96
+ if (has(value, "sourceGeneration")) { if (value.sourceGeneration !== profile.generation) return fail(); result.sourceGeneration = profile.generation; }
97
+ if (has(value, "legacyCommit")) { if (!hex(value.legacyCommit, 40)) return fail(); result.legacyCommit = value.legacyCommit; }
98
+ if (has(value, "deadLeasesRemoved")) {
99
+ const audits: { lease: string; at: string }[] = []; let bytes = 2;
100
+ for (const input of list(value.deadLeasesRemoved, 128)) {
101
+ const audit = record(input, ["lease", "at"]), lease = text(audit.lease), at = text(audit.at);
102
+ if (/^[1-9][0-9]*-[a-f0-9]{64}\.json$/.exec(lease)?.[0] !== lease || !Number.isFinite(Date.parse(at)) || new Date(at).toISOString() !== at) return fail();
103
+ const row = { lease, at }; bytes += Buffer.byteLength(canonicalJSON(row)) + 1; if (bytes > METADATA_MAX) return fail(); audits.push(row);
104
+ }
105
+ result.deadLeasesRemoved = audits;
106
+ }
107
+ return result;
108
+ }
109
+ function checked<T>(work: () => T): T { try { return work(); } catch { return fail(); } }
110
+ export function validatePlacementProfile(value: unknown): PlacementProfile {
111
+ return checked<PlacementProfile>(() => {
112
+ const row = record(value, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash", "publicationFormat", "placementHead"]);
113
+ if (row.schema !== PLACEMENT_PROFILE_SCHEMA || row.kind !== "generation" || !hex(row.generation, 32) || row.foreignPruneFormat !== FOREIGN_FORMAT || !hex(row.pruneInputSetHash, 64) || row.publicationFormat !== PLACEMENT_FORMAT || !hex(row.placementHead, 64)) return fail();
114
+ return bounded({ schema: PLACEMENT_PROFILE_SCHEMA, kind: "generation", generation: row.generation, foreignPruneFormat: FOREIGN_FORMAT,
115
+ pruneInputSetHash: row.pruneInputSetHash, publicationFormat: PLACEMENT_FORMAT, placementHead: row.placementHead });
116
+ });
117
+ }
118
+ export function validatePlacementShards(value: unknown, profile: PlacementProfile): PlacementShards {
119
+ return checked<PlacementShards>(() => {
120
+ const bound = validatePlacementProfile(profile), row = record(value, ["schema", "generation", "phase", "selectorRevision", "sources", "machine", "publicationFormat", "placementHead"], ["sourceGeneration", "legacyCommit", "deadLeasesRemoved"]);
121
+ if (row.schema !== PLACEMENT_SHARDS_SCHEMA || row.publicationFormat !== PLACEMENT_FORMAT || row.placementHead !== bound.placementHead || row.selectorRevision !== hashJSON(bound)) return fail();
122
+ return bounded({ ...shardFields(row, bound), schema: PLACEMENT_SHARDS_SCHEMA, publicationFormat: PLACEMENT_FORMAT, placementHead: bound.placementHead });
123
+ });
124
+ }
125
+ export function validatePlacementDescriptor(value: unknown, expectedRealm?: ShardSyncRealm): PlacementDescriptor {
126
+ return checked<PlacementDescriptor>(() => {
127
+ const row = record(value, ["schema", "scope", "publicationFormat"], ["key"]);
128
+ if (row.schema !== PLACEMENT_DESCRIPTOR_SCHEMA || row.publicationFormat !== PLACEMENT_FORMAT) return fail();
129
+ let result: PlacementDescriptor;
130
+ if (row.scope === "user" && !has(row, "key")) result = { schema: PLACEMENT_DESCRIPTOR_SCHEMA, scope: "user", publicationFormat: PLACEMENT_FORMAT };
131
+ else if (row.scope === "project" && typeof row.key === "string" && row.key.length) result = { schema: PLACEMENT_DESCRIPTOR_SCHEMA, scope: "project", key: text(row.key, WIRE_MAX), publicationFormat: PLACEMENT_FORMAT };
132
+ else return fail();
133
+ if (expectedRealm !== undefined) {
134
+ const expected = record(expectedRealm, ["scope"], ["key"]);
135
+ if (expected.scope !== result.scope || result.scope === "user" && has(expected, "key") || result.scope === "project" && expected.key !== result.key) return fail();
136
+ }
137
+ return bounded(result, WIRE_MAX);
138
+ });
139
+ }
140
+ export function validatePlacementBaseV2(profile: unknown, shards: unknown): PlacementBaseV2 {
141
+ return checked<PlacementBaseV2>(() => {
142
+ const p = record(profile, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"]);
143
+ if (p.schema !== "agent-mem.profile/v2" || p.kind !== "generation" || !hex(p.generation, 32) || p.foreignPruneFormat !== FOREIGN_FORMAT || !hex(p.pruneInputSetHash, 64)) return fail();
144
+ const normalized: PlacementBaseV2["profile"] = { schema: "agent-mem.profile/v2", kind: "generation", generation: p.generation, foreignPruneFormat: FOREIGN_FORMAT, pruneInputSetHash: p.pruneInputSetHash };
145
+ // Read imported constants inside the function: generation/shard guard imports may form a module cycle.
146
+ const s = record(shards, ["schema", "generation", "phase", "selectorRevision", "sources", "machine"], SHARDED_V2_FIELDS.filter(key => !["schema", "generation", "phase", "selectorRevision", "sources", "machine"].includes(key)));
147
+ if (s.schema !== "agent-mem.shards/v2" || s.selectorRevision !== hashJSON(normalized)) return fail();
148
+ return { profile: bounded(normalized), shards: bounded({ ...shardFields(s, normalized), schema: "agent-mem.shards/v2" }) };
149
+ });
150
+ }
151
+ /** Placement upgrade only. Shape/authority checks remain the responsibility of the exact marker-pair validator. */
152
+ export function validatePlacementVersionTransition(fromSchema: unknown, toSchema: unknown): void {
153
+ const families = ["agent-mem.profile", "agent-mem.shards", "agent-mem.shard-sync"];
154
+ for (const family of families) if (toSchema === `${family}/v3` && [1, 2, 3].some(version => fromSchema === `${family}/v${version}`)) return;
155
+ fail();
156
+ }
157
+ export function validatePlacementMarkerTransition(oldProfile: unknown, oldShards: unknown, newProfile: unknown, newShards: unknown): PlacementMarkers {
158
+ return checked<PlacementMarkers>(() => {
159
+ const base = validatePlacementBaseV2(oldProfile, oldShards), profile = validatePlacementProfile(newProfile), shards = validatePlacementShards(newShards, profile);
160
+ const { schema: _p, publicationFormat: _pf, placementHead: _ph, ...profileRest } = profile;
161
+ const { schema: _bp, ...baseProfileRest } = base.profile;
162
+ const { schema: _s, selectorRevision: _sr, publicationFormat: _sf, placementHead: _sh, ...shardsRest } = shards;
163
+ const { schema: _bs, selectorRevision: _bsr, ...baseShardsRest } = base.shards;
164
+ if (canonicalJSON(profileRest) !== canonicalJSON(baseProfileRest) || canonicalJSON(shardsRest) !== canonicalJSON(baseShardsRest)) return fail();
165
+ return { profile, shards };
166
+ });
167
+ }
168
+ function decode<T>(bytes: Uint8Array, maximum: number, validate: (value: unknown) => T): T {
169
+ return checked(() => {
170
+ if (types.isProxy(bytes) || !types.isUint8Array(bytes)) return fail();
171
+ const prototype = Object.getPrototypeOf(Uint8Array.prototype);
172
+ const intrinsic = (key: string) => Object.getOwnPropertyDescriptor(prototype, key)!.get!.call(bytes);
173
+ const size = intrinsic("byteLength") as number;
174
+ if (!size || size > maximum) return fail();
175
+ const view = new Uint8Array(intrinsic("buffer") as ArrayBuffer, intrinsic("byteOffset") as number, size);
176
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(view), result = validate(JSON.parse(text));
177
+ if (canonicalJSON(result) + "\n" !== text) return fail(); return result;
178
+ });
179
+ }
180
+ export const decodePlacementProfile = (bytes: Uint8Array): PlacementProfile => decode(bytes, METADATA_MAX, validatePlacementProfile);
181
+ export const decodePlacementShards = (bytes: Uint8Array, profile: PlacementProfile): PlacementShards => decode(bytes, METADATA_MAX, value => validatePlacementShards(value, profile));
182
+ export const decodePlacementDescriptor = (bytes: Uint8Array, expectedRealm?: ShardSyncRealm): PlacementDescriptor => decode(bytes, WIRE_MAX, value => validatePlacementDescriptor(value, expectedRealm));
@@ -0,0 +1,95 @@
1
+ /** Pure Git object planning. Bytes are unchanged object bodies; no object is installed or published here. */
2
+ import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
4
+ import { PUBLIC_PRUNE_REPLAY_MAX_BYTES } from "./public-prune-input-chain.ts";
5
+ import { decodePlacementTransportDescriptor, decodeShardManifest, isShardManifestPath, SHARD_PACK_MAX_BYTES, type ShardSyncRealm } from "./shard-sync-format.ts";
6
+
7
+ export interface PlannedGitObject { type: "blob" | "tree" | "commit"; oid: string; bytes: Buffer; path: string | null }
8
+ export interface PlacementGitObjects { objects: PlannedGitObject[]; tree: string; commit: string }
9
+ export class PlacementGitObjectError extends Error {
10
+ constructor() { super("Sync placement Git objects are invalid or exceed supported bounds; rebuild the exact verified publication plan."); this.name = "PlacementGitObjectError"; }
11
+ }
12
+ function fail(): never { throw new PlacementGitObjectError(); }
13
+ const oid = (v: unknown): v is string => typeof v === "string" && v.length === 40 && /^[a-f0-9]+$/.test(v);
14
+ const PACK = /^rows\/([a-f0-9]{64})\/([a-f0-9]{64})\.jsonl$/;
15
+ const hash = (v: Uint8Array) => createHash("sha256").update(v).digest("hex");
16
+ const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
17
+ const getBuffer = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer")!.get!;
18
+ const getOffset = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteOffset")!.get!;
19
+ const getLength = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")!.get!;
20
+ const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!;
21
+ interface TreeNode { directories: Map<string, TreeNode>; blobs: Map<string, string> }
22
+ function node(): TreeNode { return { directories: new Map(), blobs: new Map() }; }
23
+ function object(type: PlannedGitObject["type"], bytes: Buffer, path: string | null): PlannedGitObject {
24
+ return { type, oid: createHash("sha1").update(`${type} ${bytes.length}\0`).update(bytes).digest("hex"), bytes, path };
25
+ }
26
+ /** Exact Git object bodies and identities, with frozen commit metadata and no-op tree reuse.
27
+ * Retained public bytes share the existing replay bound; new source's separate 8MiB allowance is the caller's proof. */
28
+ export function compilePlacementGitObjects(files: ReadonlyMap<string, Uint8Array>, parent: string | null, createdUnixSeconds: number,
29
+ previous?: { tree: string; commit: string }): PlacementGitObjects {
30
+ try { return compile(files, parent, createdUnixSeconds, previous); } catch { return fail(); }
31
+ }
32
+ function compile(files: ReadonlyMap<string, Uint8Array>, parent: string | null, createdUnixSeconds: number,
33
+ previous?: { tree: string; commit: string }): PlacementGitObjects {
34
+ if (!files || typeof files !== "object" || types.isProxy(files) || Object.getPrototypeOf(files) !== Map.prototype
35
+ || !Number.isSafeInteger(createdUnixSeconds) || createdUnixSeconds < 0 || parent !== null && !oid(parent)) return fail();
36
+ const count = mapSize.call(files);
37
+ if (!Number.isSafeInteger(count) || count < 1 || count > 20000) return fail();
38
+ if (previous !== undefined) {
39
+ if (!previous || typeof previous !== "object" || types.isProxy(previous) || Object.getOwnPropertySymbols(previous).length) return fail();
40
+ const fields = Object.getOwnPropertyDescriptors(previous);
41
+ if (Object.keys(fields).sort().join() !== "commit,tree" || !fields.tree || !fields.commit || !("value" in fields.tree) || !("value" in fields.commit)
42
+ || !oid(fields.tree.value) || !oid(fields.commit.value)) return fail();
43
+ previous = { tree: fields.tree.value, commit: fields.commit.value };
44
+ }
45
+ const entries: { path: string; bytes: Buffer }[] = []; let total = 0;
46
+ for (const [path, value] of Map.prototype.entries.call(files) as Iterable<[unknown, unknown]>) {
47
+ if (typeof path !== "string" || types.isProxy(value) || !(value instanceof Uint8Array)) return fail();
48
+ const pack = PACK.exec(path), validPack = pack?.[0] === path;
49
+ if (path !== "shard.json" && !validPack && !isShardManifestPath(path)) return fail();
50
+ const length = getLength.call(value) as number, maximum = validPack ? SHARD_PACK_MAX_BYTES : 64 * 1024;
51
+ if (length < 1 || length > maximum || (total += length) > PUBLIC_PRUNE_REPLAY_MAX_BYTES) return fail();
52
+ const bytes = Buffer.from(Buffer.from(getBuffer.call(value) as ArrayBuffer, getOffset.call(value) as number, length));
53
+ if (bytes.at(-1) !== 10 || validPack && hash(bytes) !== pack![2]) return fail();
54
+ if (isShardManifestPath(path)) decodeShardManifest(path, bytes);
55
+ if (path === "shard.json") {
56
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record<string, unknown>;
57
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fail();
58
+ const realm: ShardSyncRealm = parsed.scope === "user" ? { scope: "user" }
59
+ : parsed.scope === "project" && typeof parsed.key === "string" ? { scope: "project", key: parsed.key } : fail();
60
+ decodePlacementTransportDescriptor(bytes, realm);
61
+ }
62
+ entries.push({ path, bytes });
63
+ }
64
+ if (entries.length !== count || !entries.some(entry => entry.path === "shard.json")) return fail();
65
+ entries.sort((a,b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
66
+ const objects: PlannedGitObject[] = [], trees: PlannedGitObject[] = [], root = node();
67
+ for (const entry of entries) {
68
+ const blob = object("blob", entry.bytes, entry.path); objects.push(blob);
69
+ const parts = entry.path.split("/"); let target = root;
70
+ for (const name of parts.slice(0, -1)) { if (target.blobs.has(name)) return fail(); let next = target.directories.get(name); if (!next) target.directories.set(name, next = node()); target = next; }
71
+ const name = parts.at(-1)!; if (target.directories.has(name) || target.blobs.has(name)) return fail(); target.blobs.set(name, blob.oid);
72
+ }
73
+ const buildTree = (tree: TreeNode, path: string): string => {
74
+ const children: { name: string; oid: string; directory: boolean }[] = [...tree.blobs].map(([name, id]) => ({ name, oid: id, directory: false }));
75
+ for (const [name, directory] of [...tree.directories].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
76
+ children.push({ name, oid: buildTree(directory, path ? `${path}/${name}` : name), directory: true });
77
+ children.sort((a,b) => Buffer.compare(Buffer.from(a.name + (a.directory ? "/" : "")), Buffer.from(b.name + (b.directory ? "/" : ""))));
78
+ let length = 0; const chunks: Buffer[] = [];
79
+ for (const child of children) {
80
+ const prefix = Buffer.from(`${child.directory ? "40000" : "100644"} ${child.name}\0`);
81
+ length += prefix.length + 20; if (length > SHARD_PACK_MAX_BYTES) return fail(); chunks.push(prefix, Buffer.from(child.oid, "hex"));
82
+ }
83
+ const result = object("tree", Buffer.concat(chunks, length), path); trees.push(result); return result.oid;
84
+ };
85
+ const tree = buildTree(root, "");
86
+ trees.sort((a,b) => (b.path ? b.path.split("/").length : 0) - (a.path ? a.path.split("/").length : 0)
87
+ || (a.path! < b.path! ? -1 : a.path! > b.path! ? 1 : 0));
88
+ objects.push(...trees);
89
+ if (previous?.tree === tree) return { objects, tree, commit: previous.commit };
90
+ const identity = `agent-mem <agent-mem@sync.invalid> ${createdUnixSeconds} +0000`;
91
+ const body = Buffer.from(`tree ${tree}\n${parent === null ? "" : `parent ${parent}\n`}author ${identity}\ncommitter ${identity}\n\nmemory: placement shard append\n`);
92
+ if (body.length > 64 * 1024) return fail();
93
+ const commit = object("commit", body, null); objects.push(commit);
94
+ return { objects, tree, commit: commit.oid };
95
+ }