@staix/agent-mem 0.20.2 → 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 (54) 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 +8 -1
  5. package/src/commands-table.ts +2 -1
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +25 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/generation-index.ts +95 -18
  11. package/src/memory/generation-sync.ts +3 -0
  12. package/src/memory/generation.ts +33 -15
  13. package/src/memory/platform.ts +38 -3
  14. package/src/memory/policy.ts +28 -15
  15. package/src/memory/prune-publisher-lineage.ts +50 -12
  16. package/src/memory/public-prune-advance.ts +11 -5
  17. package/src/memory/public-prune-inputs.ts +17 -16
  18. package/src/memory/shard-sync-applications.ts +153 -0
  19. package/src/memory/shard-sync-format.ts +15 -0
  20. package/src/memory/shard-sync-graph.ts +6 -4
  21. package/src/memory/shard-sync-history.ts +238 -0
  22. package/src/memory/shard-sync.ts +67 -151
  23. package/src/memory/shard.ts +32 -6
  24. package/src/memory/store.ts +14 -1
  25. package/src/memory/sync-binding.ts +63 -0
  26. package/src/memory/sync-placement-active.ts +75 -0
  27. package/src/memory/sync-placement-cache.ts +32 -0
  28. package/src/memory/sync-placement-capture.ts +292 -0
  29. package/src/memory/sync-placement-dependencies.ts +255 -0
  30. package/src/memory/sync-placement-format.ts +182 -0
  31. package/src/memory/sync-placement-git.ts +95 -0
  32. package/src/memory/sync-placement-guard.ts +98 -0
  33. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  34. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  35. package/src/memory/sync-placement-inspection.ts +236 -0
  36. package/src/memory/sync-placement-journal-format.ts +114 -0
  37. package/src/memory/sync-placement-journal-storage.ts +513 -0
  38. package/src/memory/sync-placement-journal.ts +153 -0
  39. package/src/memory/sync-placement-materialize.ts +149 -0
  40. package/src/memory/sync-placement-plan.ts +171 -0
  41. package/src/memory/sync-placement-projection.ts +79 -0
  42. package/src/memory/sync-placement-publication-plan.ts +189 -0
  43. package/src/memory/sync-placement-publication-storage.ts +87 -0
  44. package/src/memory/sync-placement-transport.ts +489 -0
  45. package/src/memory/sync-policy-inspection.ts +126 -0
  46. package/src/memory/sync-screen.ts +128 -33
  47. package/src/memory/sync.ts +20 -64
  48. package/src/setup.ts +69 -14
  49. package/src/ui/inbox-cache.ts +219 -0
  50. package/src/ui/inbox-worker-version.ts +14 -0
  51. package/src/ui/inbox-worker.ts +32 -0
  52. package/src/ui/inbox.ts +281 -0
  53. package/src/ui/panel.js +7 -3
  54. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,126 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync } from "node:fs";
3
+ import { types } from "node:util";
4
+ import { join } from "node:path";
5
+ import { SegmentReadBudgetError } from "./bridge.ts";
6
+ import { DENY_NAMES, DENY_SEGMENTS } from "./platform.ts";
7
+ import { memoryPolicySnapshot } from "./policy.ts";
8
+ import { loadAmemConfig } from "./config.ts";
9
+ import { canonicalJSON, readProfileJSON, selectorRevision } from "./generation.ts";
10
+ import { assertFormatFloor } from "./formats.ts";
11
+ import type { MemoryStore } from "./store.ts";
12
+ import { shardSyncBinding } from "./sync-binding.ts";
13
+ import { outboundPolicyEvaluator, visitOwnSegment, SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS, type RowScreen, type SyncPolicyCategory } from "./sync-screen.ts";
14
+
15
+ export type { SyncPolicyCategory } from "./sync-screen.ts";
16
+ export interface SyncPolicyInspectionOptions {
17
+ /** Narrows the machine policy, over the same canonical content projection as SyncOptions.screen. */
18
+ screen?: RowScreen;
19
+ /** Optional host epoch, as in SyncOptions.policyVersion. A change makes the inspection incomplete. */
20
+ policyVersion?: () => string;
21
+ }
22
+ export interface SyncPolicyDeclinedRow { id: string; categories: SyncPolicyCategory[] }
23
+ export type SyncPolicyInspection = {
24
+ complete: true;
25
+ declined: SyncPolicyDeclinedRow[];
26
+ counts: { scanned: number; declined: number; byCategory: Record<SyncPolicyCategory, number> };
27
+ /** SHA-256 over the sorted complete declined IDs/categories, never over private content or policy patterns. */
28
+ fingerprint: string;
29
+ } | { complete: false; reason: "input-unavailable" | "input-bound" | "input-changed" | "binding-changed" | "policy-unavailable" };
30
+
31
+ const hash = (value: unknown) => createHash("sha256").update(canonicalJSON(value)).digest("hex");
32
+
33
+ /** Bounded, read-only inspection of exactly the opened sync targets. No refresh, rebind, publication or transport. */
34
+ export function inspectSyncPolicy(store: MemoryStore, options: SyncPolicyInspectionOptions = {}): SyncPolicyInspection {
35
+ let failure: Extract<SyncPolicyInspection, {complete: false}>["reason"] = "input-unavailable";
36
+ try {
37
+ assertFormatFloor(store.profile.home, "sync");
38
+ const targets = [...store.openedSyncTargets()];
39
+ const metadata = [...new Set([join(store.profile.home, "profile.json"), join(store.profile.home, "shards.json"), join(store.profile.home, "aliases.jsonl"), join(store.profile.home, "amem.config.json"), join(store.profile.home, "machine.json"), ...targets.flatMap(target => [join(target.dir, "machine.json"), join(target.dir, "project.json"), ...(target.profile.kind === "generation" ? [join(target.dir, "generation.json"), join(target.dir, "generation-seal.json"), join(target.dir, "migration-sources.json")] : [])])])];
40
+ const stamps = () => metadata.map(file => {
41
+ const stat = lstatSync(file, { bigint: true, throwIfNoEntry: false });
42
+ if (!stat) return "absent";
43
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(SYNC_SCREEN_MAX_BYTES)) throw new Error("metadata unavailable");
44
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String);
45
+ });
46
+ const binding = () => hash({
47
+ stamps: stamps(),
48
+ root: store.profile,
49
+ selector: selectorRevision(store.profile.home),
50
+ ...(store.profile.sharded ? { shards: shardSyncBinding(store) } : {}),
51
+ targets: targets.map(target => ({ profile: target.profile, machine: target.machine,
52
+ selector: selectorRevision(target.profile.home),
53
+ machineFile: readProfileJSON(join(target.dir, "machine.json"), 4096),
54
+ ...(target.profile.kind === "generation" ? {
55
+ generation: readProfileJSON(join(target.dir, "generation.json"), 4096),
56
+ seal: readProfileJSON(join(target.dir, "generation-seal.json")),
57
+ } : {}),
58
+ })),
59
+ });
60
+ // Stale handles are not silently rebound, including a monotone selector change supported by sync itself.
61
+ if (targets.some(target => selectorRevision(target.profile.home) !== target.profile.selectorRevision)) return { complete: false, reason: "binding-changed" };
62
+ if (targets.some(target => (readProfileJSON(join(target.dir, "machine.json"), 4096) as {id?: unknown} | undefined)?.id !== target.machine.id)) return { complete: false, reason: "binding-changed" };
63
+ const epoch = binding();
64
+ failure = "policy-unavailable";
65
+ const policyVersion = options.policyVersion?.();
66
+ if (options.policyVersion !== undefined && typeof policyVersion !== "string") throw new Error("host policy unavailable");
67
+ const defaults = () => {
68
+ const value = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS });
69
+ if (!value) throw new Error("policy unavailable");
70
+ return hash(value);
71
+ };
72
+ const defaultEpoch = defaults();
73
+ const policy = () => hash({ config: loadAmemConfig(store.profile.home).config, defaults: defaults() });
74
+ failure = "policy-unavailable";
75
+ const policyEpoch = policy(), evaluate = outboundPolicyEvaluator(store.profile.home, options.screen);
76
+ const assertCurrent = () => {
77
+ failure = "binding-changed";
78
+ if (binding() !== epoch || canonicalJSON(store.openedSyncTargets().map(target => [target.dir, target.machine.id])) !== canonicalJSON(targets.map(target => [target.dir, target.machine.id]))) throw new Error("binding changed");
79
+ failure = "policy-unavailable";
80
+ if (policy() !== policyEpoch || options.policyVersion?.() !== policyVersion) throw new Error("policy changed");
81
+ };
82
+ const ids = new Map<string, { hash: string; categories: Set<SyncPolicyCategory> }>();
83
+ let scanned = 0;
84
+ const visit = (row: Record<string, unknown>) => {
85
+ failure = "input-bound";
86
+ if (++scanned > SYNC_SCREEN_MAX_ROWS) throw new Error("row bound");
87
+ failure = "input-unavailable";
88
+ if (typeof row.id !== "string" || !/^[a-f0-9]{32}$/.test(row.id)) throw new Error("row id unavailable");
89
+ const rowHash = hash(row), prior = ids.get(row.id);
90
+ if (prior && prior.hash !== rowHash) throw new Error("row identity changed");
91
+ failure = "policy-unavailable";
92
+ if (defaults() !== defaultEpoch) throw new Error("default policy changed");
93
+ const verdict = evaluate(row);
94
+ if (defaults() !== defaultEpoch) throw new Error("default policy changed");
95
+ const item = prior ?? { hash: rowHash, categories: new Set<SyncPolicyCategory>() };
96
+ if (!verdict.ok) item.categories.add(verdict.category);
97
+ ids.set(row.id, item);
98
+ if (options.policyVersion?.() !== policyVersion) { failure = "policy-unavailable"; throw new Error("host policy changed"); }
99
+ };
100
+ const checks: (() => void)[] = [];
101
+ for (const target of targets) {
102
+ assertCurrent(); failure = "input-unavailable";
103
+ if (target.profile.kind === "generation") checks.push(target.inspectOutboundRows(visit, { maxBytes: SYNC_SCREEN_MAX_BYTES, maxRows: SYNC_SCREEN_MAX_ROWS }));
104
+ else checks.push(visitOwnSegment(target.dir, target.machine.id, visit));
105
+ assertCurrent();
106
+ }
107
+ for (const check of checks) { failure = "input-changed"; check(); }
108
+ assertCurrent();
109
+ const declined = [...ids].filter(([, item]) => item.categories.size > 0).sort(([a], [b]) => a.localeCompare(b))
110
+ .map(([id, item]) => ({ id, categories: [...item.categories].sort() }));
111
+ const byCategory: Record<SyncPolicyCategory, number> = { secret: 0, "personal-data": 0, "deny-path": 0, "private-or-invalid": 0, host: 0 };
112
+ for (const row of declined) for (const category of row.categories) byCategory[category]++;
113
+ return { complete: true, declined, counts: { scanned, declined: declined.length, byCategory }, fingerprint: hash(declined) };
114
+ } catch (error) {
115
+ let reason: Extract<SyncPolicyInspection, { complete: false }>["reason"] = failure;
116
+ // Caller errors are not trusted diagnostics: never read message, getters or proxy properties.
117
+ try {
118
+ if (error && typeof error === "object" && !types.isProxy(error)) {
119
+ const code = Object.getOwnPropertyDescriptor(error, "code")?.value;
120
+ if (["input-unavailable", "input-bound", "input-changed"].includes(code)) reason = code;
121
+ else if (error instanceof SegmentReadBudgetError) reason = "input-bound";
122
+ }
123
+ } catch { /* An exotic caller exception cannot escape as a data-bearing error. */ }
124
+ return { complete: false, reason };
125
+ }
126
+ }
@@ -1,7 +1,12 @@
1
- import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
2
4
  import { join } from "node:path";
5
+ import { OP_CONTRACTS } from "./ops-contract.ts";
6
+ import { SUGGESTED } from "./store.ts";
7
+ import { checkedEntry } from "./bridge.ts";
3
8
  import { loadAmemConfig } from "./config.ts";
4
- import { createStandalonePolicy } from "./policy.ts";
9
+ import { createStandalonePolicy, type StandalonePolicyDecline } from "./policy.ts";
5
10
  import type { MemoryStore } from "./store.ts";
6
11
 
7
12
  /**
@@ -19,7 +24,8 @@ import type { MemoryStore } from "./store.ts";
19
24
  * ponytail: a person clears a declined entry by removing the offending pattern or editing the policy; sync does not skip a row, and `amem forget` is an
20
25
  * append-only op that leaves the original row in the log. Upgrade: a per-row placement that keeps a declined note local without refusing the sync.
21
26
  */
22
- const MAX_BYTES = 8 * 1024 * 1024, MAX_ROWS = 20_000;
27
+ export const SYNC_SCREEN_MAX_BYTES = 8 * 1024 * 1024, SYNC_SCREEN_MAX_ROWS = 20_000;
28
+ const MAX_BYTES = SYNC_SCREEN_MAX_BYTES, MAX_ROWS = SYNC_SCREEN_MAX_ROWS;
23
29
 
24
30
  export type SyncScreen = { ok: true } | { ok: false; reason: string };
25
31
 
@@ -27,19 +33,55 @@ export type SyncScreen = { ok: true } | { ok: false; reason: string };
27
33
  * What a row says: everything except the fields the library itself generates (ids, hashes, times, the op and enum names, machine and actor ids). Left IN on purpose
28
34
  * are `project`, `peer`, `task`, `about`, `title`, `text`, `milestones` and any field this file has never heard of: an unknown text field must be screened, never skipped.
29
35
  */
30
- const GENERATED = new Set(["id", "schema", "op", "at", "machine", "actor", "target", "promotedFrom", "supersedes", "expires", "by", "tier", "scope", "kind", "source"]);
31
- function textOf(row: Record<string, unknown>): Record<string, unknown> {
32
- const out: Record<string, unknown> = {};
33
- for (const [key, value] of Object.entries(row)) if (!GENERATED.has(key)) out[key] = value;
36
+ const GENERATED = new Set(["id", "schema", "op", "at", "machine", "actor", "promotedFrom", "supersedes", "expires", "tier", "scope", "kind", "source"]);
37
+ /** Only a known operation's validated id/enum/time operand is generated metadata.
38
+ * A task key, an unknown operand field or an unsupported operand shape remains content. */
39
+ function generatedOperand(operation: unknown, key: string, value: unknown): boolean {
40
+ if (key !== "target" && key !== "by" || typeof operation !== "string" || !Object.hasOwn(OP_CONTRACTS, operation)) return false;
41
+ const field = OP_CONTRACTS[operation]?.payloadSchema?.fields[key];
42
+ if (typeof value !== "string" || !field) return false;
43
+ if (field.format === "id") return /^[a-f0-9]{32}$/.test(value);
44
+ if (field.format === "utc") return Number.isFinite(Date.parse(value));
45
+ if (field.values) return field.values.includes(value);
46
+ // Suggestion operands are a closed relation enum plus an immutable entry id, not task text.
47
+ if (operation === "suggest" && key === "by") {
48
+ const match = /^([^:]+):[a-f0-9]{32}$/.exec(value);
49
+ return match !== null && (SUGGESTED as readonly string[]).includes(match[1]!);
50
+ }
51
+ return false;
52
+ }
53
+
54
+ export function syncOutboundContent(row: unknown): Record<string, unknown> {
55
+ if (!row || typeof row !== "object" || Array.isArray(row) || types.isProxy(row) || Object.getOwnPropertySymbols(row).length) throw new Error("outbound row unavailable");
56
+ const out: Record<string, unknown> = {}, descriptors = Object.getOwnPropertyDescriptors(row);
57
+ const operation = descriptors.op?.value;
58
+ for (const [key, descriptor] of Object.entries(descriptors)) {
59
+ if (GENERATED.has(key) || generatedOperand(operation, key, descriptor.value)) continue;
60
+ if (!("value" in descriptor) || !descriptor.enumerable) throw new Error("outbound row unavailable");
61
+ Object.defineProperty(out, key, { value: descriptor.value, enumerable: true, writable: true, configurable: true });
62
+ }
34
63
  return out;
35
64
  }
36
65
 
37
66
  /** A row passes when the machine's configured policy and the caller's extra screen (if any) both accept its text. */
38
67
  export type RowScreen = (row: Record<string, unknown>) => boolean;
39
- export function outboundScreen(home: string, extra?: RowScreen): RowScreen {
68
+ export type SyncPolicyCategory = StandalonePolicyDecline | "host";
69
+ export type OutboundVerdict = { ok: true } | { ok: false; category: SyncPolicyCategory };
70
+ /** One evaluator for actual sync and public inspection, with the same projection and AND composition. */
71
+ export function outboundPolicyEvaluator(home: string, extra?: RowScreen): (row: Record<string, unknown>) => OutboundVerdict {
40
72
  const { config } = loadAmemConfig(home);
41
73
  const policy = createStandalonePolicy({ denyPaths: config.denyPaths, piiPatterns: config.piiPatterns });
42
- return row => { const text = textOf(row); return policy.screenStandalone(text) && (extra?.(text) ?? true); };
74
+ return row => {
75
+ const text = syncOutboundContent(row), own = policy.inspectStandalone(text);
76
+ if (!own.ok) return own;
77
+ const accepted = extra === undefined ? true : extra(text);
78
+ if (typeof accepted !== "boolean") throw new Error("outbound policy unavailable");
79
+ return accepted ? { ok: true } : { ok: false, category: "host" };
80
+ };
81
+ }
82
+ export function outboundScreen(home: string, extra?: RowScreen): RowScreen {
83
+ const evaluate = outboundPolicyEvaluator(home, extra);
84
+ return row => evaluate(row).ok;
43
85
  }
44
86
  const refusal = (declined: string[]): SyncScreen => declined.length ? { ok: false, reason: `the configured privacy policy declines ${declined.length} long-term entr${declined.length === 1 ? "y" : "ies"} (${declined.slice(0, 8).join(", ")}${declined.length > 8 ? ", ..." : ""}); fix the entry or the policy, then sync again` } : { ok: true };
45
87
  const idOf = (row: Record<string, unknown>) => typeof row.id === "string" ? row.id.slice(0, 8) : "(no id)";
@@ -48,39 +90,92 @@ const idOf = (row: Record<string, unknown>) => typeof row.id === "string" ? row.
48
90
  export function screenOutboundRows(store: MemoryStore, pass: RowScreen): SyncScreen {
49
91
  const declined: string[] = [];
50
92
  let rows = 0;
51
- let snapshot: ReturnType<ReturnType<MemoryStore["prunePort"]>["snapshot"]>;
52
- // A bootstrap-only handle has no snapshot: that is a refusal like any unreadable source, never an exception out of sync (which promises a result).
53
- try { snapshot = store.prunePort().snapshot(); } catch { return { ok: false, reason: "this store's long-term rows cannot be read, so they were not screened" }; }
54
- for (const source of snapshot.rows) {
55
- if (source.placement !== "synced" || !(source.own || source.legacy)) continue;
56
- if (++rows > MAX_ROWS) return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened` };
57
- if (!pass(source.row as unknown as Record<string, unknown>)) declined.push(idOf(source.row as unknown as Record<string, unknown>));
58
- }
93
+ // Actual sync retains its existing canonical refresh/recovery snapshot. Public inspection uses the read-only
94
+ // source visitor instead, so it never performs this derived-state mutation or adopts a torn prune recovery tail.
95
+ try {
96
+ const snapshot = store.prunePort().snapshot();
97
+ for (const source of snapshot.rows) {
98
+ if (source.placement !== "synced" || !(source.own || source.legacy)) continue;
99
+ if (++rows > MAX_ROWS) return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened` };
100
+ if (!pass(source.row as unknown as Record<string, unknown>)) declined.push(idOf(source.row as unknown as Record<string, unknown>));
101
+ }
102
+ } catch { return { ok: false, reason: "this store's long-term rows cannot be read, so they were not screened; inspect the local inputs and reopen the store before retrying" }; }
59
103
  return refusal(declined);
60
104
  }
61
105
 
62
- /** Screen one machine's own long-term segment under `dir`. */
63
- export function screenOwnSegment(dir: string, machineId: string, pass: RowScreen): SyncScreen {
64
- const file = join(dir, "log", `${machineId}.jsonl`);
106
+ export class SyncScreenInputError extends Error {
107
+ constructor(readonly code: "input-unavailable" | "input-bound" | "input-changed", readonly bound?: "bytes" | "rows") { super(code); }
108
+ }
109
+ const sameStat = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
110
+
111
+ /** One bounded physical segment. The returned check pins it across the other opened targets and host callbacks too. */
112
+ export function visitOwnSegment(dir: string, machineId: string, visit: (row: Record<string, unknown>) => void): () => void {
113
+ const parent = join(dir, "log"), parentStat = lstatSync(parent, { bigint: true, throwIfNoEntry: false });
114
+ if (parentStat && (!parentStat.isDirectory() || parentStat.isSymbolicLink())) throw new SyncScreenInputError("input-unavailable");
115
+ const checkParent = () => {
116
+ const now = lstatSync(parent, { bigint: true, throwIfNoEntry: false });
117
+ if (parentStat ? !now || !now.isDirectory() || now.isSymbolicLink() || now.dev !== parentStat.dev || now.ino !== parentStat.ino : now !== undefined) throw new SyncScreenInputError("input-changed");
118
+ };
119
+ const file = join(parent, `${machineId}.jsonl`);
65
120
  let fd: number;
66
121
  try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
67
122
  catch (error) {
68
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true }; // nothing authored here: nothing to push
69
- return { ok: false, reason: "this machine's long-term segment cannot be read, so it was not screened" };
123
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return () => {
124
+ checkParent();
125
+ if (lstatSync(file, { throwIfNoEntry: false })) throw new SyncScreenInputError("input-changed");
126
+ };
127
+ throw new SyncScreenInputError("input-unavailable");
70
128
  }
71
129
  try {
72
- const stat = fstatSync(fd);
73
- if (!stat.isFile() || stat.size > MAX_BYTES) return { ok: false, reason: `this machine's long-term segment is not a plain file within ${MAX_BYTES} bytes, so it was not screened` };
74
- const declined: string[] = [];
75
- let rows = 0;
76
- for (const line of readFileSync(fd, "utf8").split("\n")) {
130
+ const stat = fstatSync(fd, { bigint: true });
131
+ if (!stat.isFile() || stat.nlink !== 1n) throw new SyncScreenInputError("input-unavailable");
132
+ if (stat.size > BigInt(MAX_BYTES)) throw new SyncScreenInputError("input-bound", "bytes");
133
+ const check = () => {
134
+ checkParent();
135
+ const named = lstatSync(file, { bigint: true });
136
+ if (!named.isFile() || named.isSymbolicLink() || !sameStat(stat, named)) throw new SyncScreenInputError("input-changed");
137
+ };
138
+ check();
139
+ // No whole-file string/split array: the stat bound precedes allocation; the row bound precedes parsing each row.
140
+ const bytes = Buffer.alloc(Number(stat.size));
141
+ let at = 0;
142
+ while (at < bytes.length) { const n = readSync(fd, bytes, at, bytes.length - at, at); if (!n) throw new SyncScreenInputError("input-changed"); at += n; }
143
+ if (bytes.length && bytes.at(-1) !== 10) throw new SyncScreenInputError("input-unavailable");
144
+ let start = 0, rows = 0;
145
+ const decoder = new TextDecoder("utf-8", { fatal: true });
146
+ for (let end = 0; end < bytes.length; end++) {
147
+ if (bytes[end] !== 10) continue;
148
+ if (start === end) { start = end + 1; continue; }
149
+ const line = decoder.decode(bytes.subarray(start, end)); start = end + 1;
77
150
  if (!line.trim()) continue;
78
- if (++rows > MAX_ROWS) return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened` };
79
- let row: Record<string, unknown>;
80
- try { row = JSON.parse(line) as Record<string, unknown>; } catch { return { ok: false, reason: "a line of this machine's long-term segment does not parse, so it was not screened" }; }
81
- // Every row kind is read, not only notes: a goal's title and milestones sync too, and so does any text an op adds later (see `textOf`).
82
- if (!pass(row)) declined.push(idOf(row));
151
+ if (++rows > MAX_ROWS) throw new SyncScreenInputError("input-bound", "rows");
152
+ let row: unknown;
153
+ try { row = checkedEntry(JSON.parse(line)); } catch { throw new SyncScreenInputError("input-unavailable"); }
154
+ visit(row as Record<string, unknown>);
83
155
  }
84
- return refusal(declined);
156
+ const expected = createHash("sha256").update(bytes).digest("hex"), digest = createHash("sha256"), buffer = Buffer.alloc(65_536);
157
+ at = 0;
158
+ while (at < bytes.length) { const n = readSync(fd, buffer, 0, Math.min(buffer.length, bytes.length - at), at); if (!n) throw new SyncScreenInputError("input-changed"); digest.update(buffer.subarray(0, n)); at += n; }
159
+ if (expected !== digest.digest("hex") || !sameStat(stat, fstatSync(fd, { bigint: true }))) throw new SyncScreenInputError("input-changed");
160
+ check(); return check;
85
161
  } finally { closeSync(fd); }
86
162
  }
163
+
164
+ /** Screen one machine's own long-term segment under `dir`, using the inspector's same bounded reader. */
165
+ export function screenOwnSegment(dir: string, machineId: string, pass: RowScreen): SyncScreen {
166
+ const declined: string[] = [];
167
+ try { visitOwnSegment(dir, machineId, row => { if (!pass(row)) declined.push(idOf(row)); })(); }
168
+ catch (error) {
169
+ let code: unknown, bound: unknown;
170
+ if (error && typeof error === "object" && !types.isProxy(error)) {
171
+ code = Object.getOwnPropertyDescriptor(error, "code")?.value;
172
+ bound = Object.getOwnPropertyDescriptor(error, "bound")?.value;
173
+ }
174
+ const inspect = "; inspect the local input and reopen the store before retrying";
175
+ if (code === "input-bound" && bound === "rows") return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened${inspect}` };
176
+ if (code === "input-bound" && bound === "bytes") return { ok: false, reason: `this machine's long-term segment exceeds ${MAX_BYTES} bytes, so it was not screened${inspect}` };
177
+ if (code === "input-changed") return { ok: false, reason: "this machine's long-term segment changed while being screened, so it was not screened; wait for writers to finish and retry" };
178
+ return { ok: false, reason: "this machine's long-term segment is not readable as complete valid rows, so it was not screened" + inspect };
179
+ }
180
+ return refusal(declined);
181
+ }
@@ -1,13 +1,16 @@
1
+ import { syncPlacementMemory } from "./sync-placement-transport.ts";
2
+ import type { PlacementCapturePort } from "./sync-placement-capture.ts";
3
+ import { placementRuntimeRefusal } from "./sync-placement-guard.ts";
1
4
  import { assertFormatFloor } from "./formats.ts";
2
- import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
3
- import { createHash } from "node:crypto";
4
- import { dirname, join, relative, sep } from "node:path";
5
+ import { closeSync, constants, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
6
+ import { join } from "node:path";
5
7
  import { rebindPublicPruneProfile } from "./generation.ts";
6
8
  import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
7
9
  import { acquireMutation } from "./mutation.ts";
8
10
  import type { MemoryStore } from "./store.ts";
9
11
  import { syncGenerationMemory } from "./generation-sync.ts";
10
12
  import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen } from "./sync-screen.ts";
13
+ import { shardSyncBinding } from "./sync-binding.ts";
11
14
 
12
15
  /**
13
16
  * #258 phase 2: hub memory between a person's machines through a git remote they configure. The memory directory is a
@@ -16,6 +19,8 @@ import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen }
16
19
  */
17
20
  export type Visibility = "public" | "private" | "unknown";
18
21
  export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string;
22
+ placementPublication?: { attemptHash?: string; outcome: "committed" | "unknown" | "not-needed"; withheld: number; exported: number;
23
+ lastPolicyHash?: string; lastPolicyCheckUnixMs?: number; policyCheckSource?: "live" | "durable-intent"; policyCeiling: "last-positive-check-before-in-flight-push"; recovered?: boolean };
19
24
  pruneApplication?: {transport:"durable"|"unconfirmed";proofComplete:number;projectionActive:number|null;nativeApplied:number|null;held:number;phase:"preflight"|"prepared"|"active"|"complete"};
20
25
  }
21
26
 
@@ -214,6 +219,8 @@ function lock(dir: string): (() => void) | undefined {
214
219
  }
215
220
 
216
221
  export interface SyncOptions {
222
+ /** Fresh compatibility facts for an already activated v3 profile; never activates a legacy profile or bypasses policy. */
223
+ placementAttestation?: PlacementCapturePort["attest"];
217
224
  /** Set (or replace) the remote first: a person's choice. */
218
225
  remote?: string;
219
226
  visibility?: (url: string) => Promise<Visibility>;
@@ -225,67 +232,13 @@ export interface SyncOptions {
225
232
  signal?: AbortSignal;
226
233
  }
227
234
 
228
- /** Metadata-only sharded sync epoch. Hash complete source bytes, never notes or unopened shards. */
229
- export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
230
- /** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
231
- export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
232
- function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
233
- const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
234
- if (!store.profile.sharded) return unavailable();
235
- const uid = process.getuid?.(); if (uid === undefined) return unavailable();
236
- 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;
237
- const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
238
- if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
239
- || stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
240
- };
241
- const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
242
- const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
243
- const directory = (dir: string): void => {
244
- if (directories.has(dir)) return;
245
- const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
246
- if (dir !== home) directory(dirname(dir));
247
- const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
248
- emit(["directory", dir, String(before.dev), String(before.ino)]);
249
- checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
250
- };
251
- // Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
252
- const source = (file: string, privateMode = true): void => {
253
- directory(dirname(file));
254
- let before: BigIntStats;
255
- try { before = lstatSync(file, { bigint: true }); }
256
- catch (error) {
257
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
258
- emit(["source", file, "absent"]);
259
- checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
260
- }
261
- owned(before, false, privateMode);
262
- const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
263
- try {
264
- const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
265
- const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
266
- for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
267
- const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
268
- if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
269
- emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
270
- } finally { closeSync(fd); }
271
- checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
272
- };
273
- try {
274
- directory(home);
275
- for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
276
- const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
277
- for (const target of targets) {
278
- directory(target.dir);
279
- emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
280
- for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
281
- }
282
- for (const check of checks) check();
283
- return digest.digest("hex");
284
- } catch { return unavailable(); }
285
- }
235
+ export { shardSyncBinding, shardSyncSourceBinding } from "./sync-binding.ts";
286
236
 
287
237
  export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
288
238
  assertFormatFloor(store.profile.home, "sync");
239
+ const placementRefusal = placementRuntimeRefusal(store.profile.home, store.profile);
240
+ if (placementRefusal) return { ok: false, pushed: false, entries: 0, error: placementRefusal };
241
+ if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return syncPlacementMemory(store, opts);
289
242
  if (store.profile.sharded) {
290
243
  try {
291
244
  // Refresh only through the existing proved monotone profile rebind before pinning this attempt.
@@ -297,20 +250,23 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
297
250
  assertBinding();
298
251
  const screen = outboundScreen(store.profile.home, opts.screen);
299
252
  const pass: RowScreen = row => { assertBinding(); const accepted = screen(row); assertBinding(); if (typeof accepted !== "boolean") throw new Error("invalid screen result"); return accepted; };
253
+ const errors: string[] = [];
300
254
  for (const target of store.openedSyncTargets()) {
301
255
  const screened = screenOutboundRows(target, pass); assertBinding();
302
- if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
256
+ if (!screened.ok) errors.push(screened.reason);
303
257
  }
258
+ if (errors.length) return { ok: false, pushed: false, entries: 0, error: errors.join("; ") };
304
259
  const transport = await import("./shard-sync.ts"); assertBinding();
305
260
  return transport.syncShardedMemory(store, opts);
306
261
  } catch { return { ok: false, pushed: false, entries: 0, error: "sharded sync binding or policy unavailable or changed" }; }
307
262
  }
308
263
  // F4: before anything is staged or pushed, this machine's own long-term notes pass the person's configured policy (every profile: single log, generation, shards).
309
- const pass = outboundScreen(store.profile.home, opts.screen);
264
+ const pass = outboundScreen(store.profile.home, opts.screen), errors: string[] = [];
310
265
  for (const target of store.openedSyncTargets()) {
311
266
  const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
312
- if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
267
+ if (!screened.ok) errors.push(screened.reason);
313
268
  }
269
+ if (errors.length) return { ok: false, pushed: false, entries: 0, error: errors.join("; ") };
314
270
  if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
315
271
  const dir = store.dir;
316
272
  const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });