@staix/agent-mem 0.20.3 → 0.22.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 (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +30 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +36 -3
  5. package/src/commands-table.ts +3 -0
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +31 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/commands.ts +3 -2
  11. package/src/memory/generation-index.ts +95 -18
  12. package/src/memory/generation-sync.ts +3 -0
  13. package/src/memory/generation.ts +33 -15
  14. package/src/memory/platform.ts +38 -3
  15. package/src/memory/policy.ts +28 -15
  16. package/src/memory/prune-publisher-lineage.ts +50 -12
  17. package/src/memory/public-prune-activation.ts +4 -4
  18. package/src/memory/public-prune-advance.ts +11 -5
  19. package/src/memory/public-prune-inputs.ts +17 -16
  20. package/src/memory/shard-sync-applications.ts +153 -0
  21. package/src/memory/shard-sync-format.ts +15 -0
  22. package/src/memory/shard-sync-graph.ts +6 -4
  23. package/src/memory/shard-sync-history.ts +238 -0
  24. package/src/memory/shard-sync.ts +66 -151
  25. package/src/memory/shard.ts +32 -6
  26. package/src/memory/store.ts +14 -1
  27. package/src/memory/sync-binding.ts +63 -0
  28. package/src/memory/sync-cadence.ts +2 -1
  29. package/src/memory/sync-placement-abandon.ts +142 -0
  30. package/src/memory/sync-placement-activation-locks.ts +28 -0
  31. package/src/memory/sync-placement-activation-path.ts +2 -0
  32. package/src/memory/sync-placement-activation-proof.ts +68 -0
  33. package/src/memory/sync-placement-activation-storage.ts +167 -0
  34. package/src/memory/sync-placement-activation.ts +185 -0
  35. package/src/memory/sync-placement-active.ts +75 -0
  36. package/src/memory/sync-placement-cache.ts +32 -0
  37. package/src/memory/sync-placement-capture.ts +373 -0
  38. package/src/memory/sync-placement-dependencies.ts +255 -0
  39. package/src/memory/sync-placement-effective-writers.ts +84 -0
  40. package/src/memory/sync-placement-format.ts +182 -0
  41. package/src/memory/sync-placement-git.ts +95 -0
  42. package/src/memory/sync-placement-guard.ts +104 -0
  43. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  44. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  45. package/src/memory/sync-placement-inspection.ts +240 -0
  46. package/src/memory/sync-placement-journal-format.ts +114 -0
  47. package/src/memory/sync-placement-journal-storage.ts +557 -0
  48. package/src/memory/sync-placement-journal.ts +160 -0
  49. package/src/memory/sync-placement-materialize.ts +149 -0
  50. package/src/memory/sync-placement-plan.ts +171 -0
  51. package/src/memory/sync-placement-prerequisites.ts +50 -0
  52. package/src/memory/sync-placement-projection.ts +79 -0
  53. package/src/memory/sync-placement-publication-plan.ts +189 -0
  54. package/src/memory/sync-placement-publication-storage.ts +87 -0
  55. package/src/memory/sync-placement-transport.ts +500 -0
  56. package/src/memory/sync-placement-writer-history.ts +278 -0
  57. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  58. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  59. package/src/memory/sync-placement-writer-roots.ts +167 -0
  60. package/src/memory/sync-placement-writers.ts +135 -0
  61. package/src/memory/sync-policy-inspection.ts +126 -0
  62. package/src/memory/sync-screen.ts +128 -33
  63. package/src/memory/sync.ts +19 -64
  64. package/src/setup.ts +101 -14
  65. package/src/sync-placement-writer-context.ts +136 -0
  66. package/src/ui/inbox-cache.ts +219 -0
  67. package/src/ui/inbox-worker-version.ts +14 -0
  68. package/src/ui/inbox-worker.ts +32 -0
  69. package/src/ui/inbox.ts +281 -0
  70. package/src/ui/panel.js +7 -3
  71. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,136 @@
1
+ /** CLI adapter only. Expected locators are never returned as fresh writer facts. */
2
+ import { types } from "node:util";
3
+ import { realPath } from "./memory/platform.ts";
4
+ import { canonicalJSON } from "./memory/generation.ts";
5
+ import { readSetupWriterRoots } from "./setup.ts";
6
+ import { measureSyncPlacementWriters, extractSyncPlacementWriterFailure, type SyncPlacementWriterFailureDetail, type SyncPlacementWriterMeasurement } from "./memory/sync-placement-writers.ts";
7
+ import { readEffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
8
+ import { requirePlacementHomeFloor } from "./memory/sync-placement-prerequisites.ts";
9
+ import type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
10
+ import { SharedWriterRevisionError } from "./memory/sync-placement-writer-revision-format.ts";
11
+
12
+ export type PlacementWriterRootCategory = "running CLI" | "setup receipt" | "declared writer" | "active locator" | "home floor" | "writer renewal";
13
+ export type PlacementWriterContextPhase = "initial" | "active";
14
+ const failureDetails:Record<SyncPlacementWriterFailureDetail,string>={
15
+ "below-minimum":"release is below 0.21.0", "wrong-package":"package is not @staix/agent-mem", "writable-runtime":"runtime code is group- or world-writable", unreadable:"runtime files are unreadable or missing",
16
+ "invalid-version":"package version is not a supported plain release", "invalid-inventory":"root inventory is invalid or duplicated", "unsafe-runtime":"runtime files have unsafe types or symlinks",
17
+ "missing-runtime":"required bin/amem.js or src/index.ts is missing",bound:"inventory exceeds a supported bound",changed:"install changed during measurement",
18
+ };
19
+ function writerContextMessage(category:PlacementWriterRootCategory,causeClass:"missing"|"changed"|"unavailable",phase:PlacementWriterContextPhase,detail?:SyncPlacementWriterFailureDetail,renewalState?:"pending"|"staging"):string {
20
+ if(category==="writer renewal")return new SharedWriterRevisionError(renewalState==="staging"?"staging":"pending").message;
21
+ if(category==="active locator") return `Sync placement initial root locator is ${causeClass} (active locator category); have the person inspect sync-placement-writer-roots.json and the matching activation metadata with a compatible recovery release, then run amem migrate --sync-placement --dry-run.`;
22
+ if(category==="home floor") return `Sync placement reader-floor proof is ${causeClass} (home floor category); have the person inspect formats.json and use a reader compatible with its recorded floor, then run amem migrate --sync-placement --dry-run.`;
23
+ const cause=detail?`; ${failureDetails[detail]}`:"";
24
+ if(phase==="initial")return `Sync placement initial writer proof is ${causeClass} (${category} category${cause}); ${category==="setup receipt"?"upgrade or repair the managed amem install and its host exact pin, then rerun amem setup <target> --apply":"upgrade or repair the amem install and its host exact pin"}. Declare the complete writer roots and rerun amem migrate --sync-placement --dry-run.`;
25
+ return `Sync placement writer proof is ${causeClass} (${category} category${cause}); restore the inventoried install, or have the person run amem migrate --sync-placement --dry-run and --apply with the complete --writer-root inventory after installing the required 0.22.0 home floor.`;
26
+ }
27
+ export class PlacementWriterContextError extends Error {
28
+ constructor(readonly category: PlacementWriterRootCategory, readonly causeClass: "missing" | "changed" | "unavailable",readonly phase:PlacementWriterContextPhase="active",readonly detail?:SyncPlacementWriterFailureDetail,readonly renewalState?:"pending"|"staging") {
29
+ super(writerContextMessage(category,causeClass,phase,detail,renewalState)); this.name = "PlacementWriterContextError";
30
+ }
31
+ }
32
+ function safeRefusal(error: unknown, category: PlacementWriterRootCategory,phase:PlacementWriterContextPhase="active"): PlacementWriterContextError {
33
+ if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="pending"||code==="staging")return new PlacementWriterContextError("writer renewal","unavailable",phase,undefined,code);}
34
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementWriterContextError) {
35
+ const fields=Object.getOwnPropertyDescriptors(error),root=fields.category,cause=fields.causeClass,detail=fields.detail;
36
+ const category:unknown=root&&"value"in root?root.value:undefined,causeClass:unknown=cause&&"value"in cause?cause.value:undefined;
37
+ if (typeof category==="string" && ["running CLI","setup receipt","declared writer","active locator","home floor","writer renewal"].includes(category)
38
+ && typeof causeClass==="string" && ["missing","changed","unavailable"].includes(causeClass)) {
39
+ const ownDetail=detail&&"value"in detail&&typeof detail.value==="string"&&Object.hasOwn(failureDetails,detail.value)?detail.value as SyncPlacementWriterFailureDetail:undefined;
40
+ const state=fields.renewalState?.value;
41
+ return new PlacementWriterContextError(category as PlacementWriterRootCategory,causeClass as PlacementWriterContextError["causeClass"],phase,ownDetail,state==="staging"||state==="pending"?state:undefined);
42
+ }
43
+ }
44
+ return new PlacementWriterContextError(category,"unavailable",phase);
45
+ }
46
+ const fail = (category: PlacementWriterRootCategory, cause: PlacementWriterContextError["causeClass"],phase:PlacementWriterContextPhase="active",detail?:SyncPlacementWriterFailureDetail): never => { throw new PlacementWriterContextError(category,cause,phase,detail); };
47
+ function required(home: string, ownRoot: string,phase:PlacementWriterContextPhase="active") {
48
+ let own: string, receipt: string[];
49
+ try { own = realPath(ownRoot); } catch { return fail("running CLI","unavailable",phase,"unreadable"); }
50
+ try { receipt = readSetupWriterRoots(home); } catch { return fail("setup receipt","unavailable",phase,"unreadable"); }
51
+ return { own, receipt, roots: [...new Set([own,...receipt])].sort() };
52
+ }
53
+ function rootCategory(root:string|undefined,needed:ReturnType<typeof required>):PlacementWriterRootCategory {
54
+ if(root===needed.own)return "running CLI";return root&&needed.receipt.includes(root)?"setup receipt":"declared writer";
55
+ }
56
+ function measurementRefusal(error:unknown,roots:readonly string[],needed:ReturnType<typeof required>,phase:PlacementWriterContextPhase):PlacementWriterContextError {
57
+ const detail=extractSyncPlacementWriterFailure(error);let root:string|undefined;
58
+ if(detail?.rootIndex!==undefined){try{root=realPath(roots[detail.rootIndex]!);}catch{/* Missing noncanonical declared path is still unavailable, never fresh evidence. */}}
59
+ return new PlacementWriterContextError(rootCategory(root,needed),detail?.code==="changed"?"changed":"unavailable",phase,detail?.detail??"unreadable");
60
+ }
61
+ function measureComplete(home: string, roots: readonly string[], ownRoot: string,phase:PlacementWriterContextPhase="active"): SyncPlacementWriterMeasurement {
62
+ const before = required(home,ownRoot,phase);let result: SyncPlacementWriterMeasurement;
63
+ try { result = measureSyncPlacementWriters(roots); } catch(error) { throw measurementRefusal(error,roots,before,phase); }
64
+ if (!result.roots.some(row => row.root === before.own)) fail("running CLI","missing",phase);
65
+ if (before.receipt.some(root => !result.roots.some(row => row.root === root))) fail("setup receipt","missing",phase);
66
+ const after = required(home,ownRoot,phase);
67
+ if (after.own !== before.own) fail("running CLI","changed",phase,"changed");
68
+ if (canonicalJSON(after.receipt) !== canonicalJSON(before.receipt)) fail("setup receipt","changed",phase,"changed");
69
+ return result;
70
+ }
71
+ export interface PlacementWriterContext {
72
+ measurement: SyncPlacementWriterMeasurement;
73
+ attest: PlacementCapturePort["attest"];
74
+ lastRefusal?: PlacementWriterContextError;
75
+ }
76
+ /** Preview creates no quiescence claim. The coordinator invokes attest only while its live apply fences are held. */
77
+ export function initialPlacementWriterContext(home: string, roots: readonly string[], ownRoot: string): PlacementWriterContext {
78
+ const selected = roots.length ? [...roots] : required(home,ownRoot,"initial").roots;
79
+ const measurement = measureComplete(home,selected,ownRoot,"initial"), canonical = measurement.roots.map(row=>row.root);
80
+ const floor = requirePlacementHomeFloor(home);
81
+ const context: PlacementWriterContext = { measurement, attest: () => {
82
+ try {
83
+ if (requirePlacementHomeFloor(home) !== floor) return fail("home floor","changed","initial");
84
+ const current = measureComplete(home,canonical,ownRoot,"initial");
85
+ if (canonicalJSON(current.writers) !== canonicalJSON(measurement.writers)) {
86
+ const changed=current.roots.find(row=>!measurement.writers.some(old=>old.writerId===row.writerId&&old.version===row.version&&old.pin===row.pin));
87
+ return fail(rootCategory(changed?.root,required(home,ownRoot,"initial")),"changed","initial","changed");
88
+ }
89
+ return { writers: current.writers, legacySyncQuiesced: true };
90
+ } catch (error) { context.lastRefusal = safeRefusal(error,"declared writer","initial"); throw context.lastRefusal; }
91
+ } }; return context;
92
+ }
93
+ /** Active callbacks re-read the bound locator AND measure every package anew. No activation or renewal occurs. */
94
+ export function activePlacementWriterContext(home: string, ownRoot: string): PlacementWriterContext {
95
+ const read = () => {
96
+ requirePlacementHomeFloor(home);
97
+ const effective=readEffectiveSyncPlacementWriters(home);
98
+ const roots=effective.roots;if(!roots)return fail("active locator","unavailable");
99
+ const needed = required(home,ownRoot);
100
+ if(!roots.includes(needed.own))return fail("running CLI","missing");
101
+ if(needed.receipt.some(root=>!roots.includes(root)))return fail("setup receipt","missing");
102
+ const measurement = measureComplete(home,roots,ownRoot);
103
+ if (canonicalJSON(measurement.writers) !== canonicalJSON(effective.writers)) {
104
+ const changed=measurement.roots.find(row=>!effective.writers.some(old=>old.writerId===row.writerId&&old.version===row.version&&old.pin===row.pin));
105
+ return fail(rootCategory(changed?.root,needed),"changed","active","changed");
106
+ }
107
+ effective.assertCurrent();
108
+ return measurement;
109
+ };
110
+ const measurement = read(), context: PlacementWriterContext = { measurement, attest: () => {
111
+ try { return { writers: read().writers, legacySyncQuiesced: true }; }
112
+ catch (error) { context.lastRefusal = safeRefusal(error,"active locator"); throw context.lastRefusal; }
113
+ } }; return context;
114
+ }
115
+
116
+ /** Person preview only: incomplete measurements remain explicit and never become an attestation. */
117
+ export function previewActivePlacementWriters(home: string, ownRoot: string, requested: readonly string[] = []) {
118
+ const effective=readEffectiveSyncPlacementWriters(home),mapped=effective.roots;if(!mapped)return fail("active locator","unavailable");
119
+ const selected = requested.length ? [...requested] : mapped;
120
+ const expected = effective.writers;
121
+ let measurement: SyncPlacementWriterMeasurement;
122
+ try { measurement = measureSyncPlacementWriters(selected); }
123
+ catch { return { complete:false, matches:false, roots:[] as SyncPlacementWriterMeasurement["roots"], expected, missingCategories:["declared writer"] as PlacementWriterRootCategory[], differences:expected.map(row=>({writerId:row.writerId,state:"unverified" as const})),refusalCategory:"declared writer" as PlacementWriterRootCategory }; }
124
+ const needed = required(home,ownRoot), missingCategories: PlacementWriterRootCategory[] = [];
125
+ if (!measurement.roots.some(row=>row.root===needed.own)) missingCategories.push("running CLI");
126
+ if (needed.receipt.some(root=>!measurement.roots.some(row=>row.root===root))) missingCategories.push("setup receipt");
127
+ const differences = expected.map(row=>{
128
+ const actual = measurement.writers.find(value=>value.writerId===row.writerId);
129
+ return {writerId:row.writerId,state:!actual?"missing":canonicalJSON(row)===canonicalJSON(actual)?"unchanged":"changed"};
130
+ });
131
+ for (const row of measurement.writers) if (!expected.some(value=>value.writerId===row.writerId)) differences.push({writerId:row.writerId,state:"added"});
132
+ const matches=missingCategories.length===0&&differences.every(row=>row.state==="unchanged");
133
+ const changed=measurement.roots.find(row=>{const old=expected.find(value=>value.writerId===row.writerId);return !old||old.pin!==row.pin||old.version!==row.version;});
134
+ const refusalCategory:PlacementWriterRootCategory=missingCategories[0]??(changed?.root===needed.own?"running CLI":changed&&needed.receipt.includes(changed.root)?"setup receipt":"declared writer");
135
+ return {complete:true,matches,roots:measurement.roots,expected,missingCategories,differences,refusalCategory};
136
+ }
@@ -0,0 +1,219 @@
1
+ import { fork } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolve } from "node:path";
5
+ import { configuredPolicy } from "../memory/config.ts";
6
+ import { processGroupLiveness, processSignatureAsync, realPath, type Liveness } from "../memory/platform.ts";
7
+ import { INBOX_MAX_BYTES, INBOX_MAX_ITEMS, boundedInboxRows, inboxPolicyDefaults, inboxPolicyVersion, inboxSourceChange, type CapacityMemo, type InboxComputation, type InboxItem, type InboxSources } from "./inbox.ts";
8
+ import { inboxWorkerHash } from "./inbox-worker-version.ts";
9
+
10
+ export const INBOX_WORKER_HASH = inboxWorkerHash();
11
+ export interface InboxJobResult { computation: InboxComputation; sources: InboxSources }
12
+ export interface InboxJob { result: Promise<InboxJobResult>; cancel(): Promise<void>; observeCleanup?(): Liveness }
13
+ export type InboxRunner = (base: string, capacity?: CapacityMemo) => InboxJob;
14
+ export interface InboxSnapshot { items: InboxItem[]; more: number; complete: boolean; refreshing: boolean; generatedAt?: string }
15
+ const WORK_MS = 30_000, REAP_MS = 2000, MIN_START_MS = 30_000, MAX_BACKOFF_MS = 300_000;
16
+ export class InboxCleanupError extends Error {
17
+ readonly group: number | undefined;
18
+ constructor(group?: number) {
19
+ const valid = group !== undefined && Number.isSafeInteger(group) && group > 0 ? group : undefined;
20
+ super(`inbox worker group ${valid ?? "unknown"} cleanup is unresolved`); this.name = "InboxCleanupError"; this.group = valid;
21
+ }
22
+ }
23
+ export class InboxDeadlineError extends InboxCleanupError { constructor(group?: number) { super(group); this.name = "InboxDeadlineError"; } }
24
+ export class InboxChangedError extends Error { constructor() { super("inbox sources changed"); this.name = "InboxChangedError"; } }
25
+ export class InboxRuntimeError extends Error { constructor(message: string) { super(message); this.name = "InboxRuntimeError"; } }
26
+ async function waitForGroupGone(group: number | undefined): Promise<void> {
27
+ if (group === undefined) return;
28
+ const until = performance.now() + REAP_MS;
29
+ for (;;) {
30
+ const state = processGroupLiveness(group);
31
+ if (state === "gone") return;
32
+ if (state === "unknown" || performance.now() >= until) throw new InboxCleanupError(group);
33
+ await new Promise(resolve => setTimeout(resolve, 20));
34
+ }
35
+ }
36
+ /** Native child handle plus verified detached group: no retired group is ever signalled. */
37
+ export const runInboxJob: InboxRunner = (base, capacity) => {
38
+ if (!existsSync(process.execPath)) throw new InboxRuntimeError("bun moved; restart acon serve");
39
+ if (!INBOX_WORKER_HASH || inboxWorkerHash() !== INBOX_WORKER_HASH) throw new InboxRuntimeError("inbox worker changed; restart acon serve");
40
+ const defaults = inboxPolicyDefaults();
41
+ const child = fork(fileURLToPath(new URL("./inbox-worker.ts", import.meta.url)), [], { execPath: process.execPath, execArgv: ["--smol"], detached: true,
42
+ stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "json", env: { ...process.env, AMEM_UI_WORKER_HASH: INBOX_WORKER_HASH } });
43
+ let exited = false, cleaned = false, ready = false, groupKnown = false, terminating = false, changed = false, workerMoved = false, payload: InboxJobResult | undefined;
44
+ let readyProbe: Promise<void> | undefined, force: ReturnType<typeof setTimeout> | undefined;
45
+ let fail!: (error: Error) => void, succeed!: (value: InboxJobResult) => void, endedResolve!: () => void, endedReject!: (error: Error) => void;
46
+ const ended = new Promise<void>((resolve, reject) => { endedResolve = resolve; endedReject = reject; }); void ended.catch(() => {});
47
+ const result = new Promise<InboxJobResult>((resolve, reject) => { succeed = resolve; fail = reject; });
48
+ const kill = (signal: NodeJS.Signals) => {
49
+ if (exited || child.exitCode !== null || child.signalCode !== null) return;
50
+ try { if (child.pid) process.kill(-child.pid, signal); } catch { child.kill(signal); }
51
+ };
52
+ const clear = () => { clearTimeout(deadline); if (force) clearTimeout(force); };
53
+ const clean = () => { cleaned = true; exited = true; clear(); endedResolve(); };
54
+ const observe = (): Liveness => {
55
+ if (cleaned) return "gone";
56
+ const state = child.pid === undefined ? "gone" : processGroupLiveness(child.pid);
57
+ if (state === "gone" && (groupKnown || exited || child.exitCode !== null || child.signalCode !== null || child.pid === undefined)) { clean(); return "gone"; }
58
+ return state === "gone" ? "unknown" : state;
59
+ };
60
+ const terminate = () => {
61
+ if (terminating || exited || cleaned) return;
62
+ terminating = true; kill("SIGTERM");
63
+ if (!exited) { force = setTimeout(() => kill("SIGKILL"), 250); force.unref(); }
64
+ };
65
+ const deadline = setTimeout(() => { terminate(); fail(new InboxDeadlineError(child.pid)); }, WORK_MS); deadline.unref();
66
+ child.on("message", (value: unknown) => {
67
+ const message = value as { type?: unknown; pid?: unknown; group?: unknown; signature?: unknown; workerHash?: unknown; computation?: InboxComputation; sources?: InboxSources };
68
+ if (message?.type === "ready" && !ready && !readyProbe) {
69
+ readyProbe = (async () => {
70
+ if (message.workerHash !== INBOX_WORKER_HASH) { workerMoved = true; terminate(); return; }
71
+ if (!child.pid || message.pid !== child.pid || message.group !== child.pid || typeof message.signature !== "string") { terminate(); return; }
72
+ groupKnown = true;
73
+ const signature = await processSignatureAsync(child.pid);
74
+ if (exited || terminating) return;
75
+ if (signature !== message.signature) { terminate(); return; }
76
+ ready = true; child.send({ type: "run", base, capacity, defaults });
77
+ })().catch(() => terminate());
78
+ } else if (ready && message?.type === "result" && message.computation && message.sources) payload = { computation: message.computation, sources: message.sources };
79
+ else if (ready && message?.type === "changed") changed = true;
80
+ else if (message?.type === "moved") workerMoved = true;
81
+ });
82
+ child.once("error", () => {
83
+ if (child.pid === undefined) { clean(); fail(Error("inbox worker unavailable")); } else terminate();
84
+ });
85
+ child.once("close", code => {
86
+ exited = true; clear();
87
+ if (cleaned) return;
88
+ // The proof budget starts here, not at TERM: unrelated event-loop work before close cannot consume it.
89
+ void waitForGroupGone(child.pid).then(() => {
90
+ clean();
91
+ if (code === 0 && payload && !terminating) succeed(payload);
92
+ else fail(workerMoved ? new InboxRuntimeError("inbox worker changed; restart acon serve") : changed ? new InboxChangedError() : Error("inbox worker unavailable"));
93
+ }).catch(() => { const error = new InboxCleanupError(child.pid); endedReject(error); fail(error); });
94
+ });
95
+ return { result, observeCleanup: observe, async cancel() {
96
+ if (observe() === "gone") { fail(Error("inbox worker cancelled")); return; }
97
+ terminate(); let timer: ReturnType<typeof setTimeout> | undefined;
98
+ try { await Promise.race([ended, new Promise<void>((resolve, reject) => { timer = setTimeout(() => {
99
+ if (observe() === "gone") { fail(Error("inbox worker cancelled")); resolve(); }
100
+ else { const error = new InboxCleanupError(child.pid); endedReject(error); fail(error); reject(error); }
101
+ }, REAP_MS + 1000); })]); fail(Error("inbox worker cancelled")); }
102
+ finally { if (timer) clearTimeout(timer); }
103
+ } };
104
+ };
105
+
106
+ export interface InboxInvalidation { hard?: boolean; capacity?: boolean; removeIds?: readonly string[]; added?: number }
107
+ /** Request-driven refresh only. Cooldown/backoff survive source changes and actions. */
108
+ export class InboxCache {
109
+ private readonly base: string; private readonly runner: InboxRunner; private readonly log: (line: string) => void;
110
+ private readonly now: () => number; private readonly clock: () => number;
111
+ private cache?: InboxJobResult; private capacity?: CapacityMemo; private observed?: InboxSources;
112
+ private task?: InboxJob; private blockedTask?: InboxJob; private blockedGroup?: number; private request?: Promise<InboxSnapshot>;
113
+ private generation = 0; private closed = false; private pending = true; private stale = false;
114
+ private failureCount = 0; private retryAt = 0; private lastStart?: number; private lastDuration = 0;
115
+ private error?: "failure" | "cleanup" | "runtime"; private errorDetail?: string;
116
+ private generatedAt?: string; private policy?: string; private capacityAt?: string; private overflowAt?: string;
117
+ private state = ""; private stateAt = 0;
118
+ constructor(base: string, options: { runner?: InboxRunner; log?: (line: string) => void; now?: () => number; monotonicNow?: () => number } = {}) {
119
+ const absolute = resolve(base); this.base = existsSync(absolute) ? realPath(absolute) : absolute;
120
+ this.runner = options.runner ?? runInboxJob; this.log = options.log ?? (() => {}); this.now = options.now ?? Date.now;
121
+ this.clock = options.monotonicNow ?? (options.now ? options.now : () => performance.now());
122
+ }
123
+ invalidate(input: boolean | InboxInvalidation = {}): void {
124
+ const change = typeof input === "boolean" ? { hard: input, capacity: input } : input;
125
+ this.generation++; this.pending = true; this.stale = true;
126
+ if (change.hard) { this.cache = undefined; this.observed = undefined; }
127
+ else if (this.cache && (change.removeIds?.length || change.added)) {
128
+ const old = this.cache.computation, actual = old.items.filter(item => item.id !== "inbox:more");
129
+ const removed = new Set(change.removeIds ?? []), shown = actual.filter(item => !removed.has(item.id));
130
+ const total = Math.max(shown.length, actual.length + old.more - removed.size + (change.added ?? 0));
131
+ const hint = old.items.find(item => item.id === "inbox:more");
132
+ Object.assign(old, boundedInboxRows(hint ? [...shown, hint] : shown, total));
133
+ }
134
+ if (change.capacity) this.capacity = undefined;
135
+ }
136
+ private allowed(): boolean {
137
+ return this.clock() >= Math.max(this.retryAt, this.lastStart === undefined ? 0 : this.lastStart + Math.max(MIN_START_MS, this.lastDuration));
138
+ }
139
+ private recover(): void {
140
+ if (!this.blockedTask) return;
141
+ const state = this.blockedTask.observeCleanup ? this.blockedTask.observeCleanup()
142
+ : this.blockedGroup === undefined ? "gone" : processGroupLiveness(this.blockedGroup);
143
+ if (state === "gone") { if (this.task === this.blockedTask) this.task = undefined; this.blockedTask = undefined; this.blockedGroup = undefined; this.error = undefined; }
144
+ }
145
+ private failed(error: unknown, job?: InboxJob): void {
146
+ if (error instanceof InboxChangedError) { this.pending = true; return; }
147
+ this.failureCount = Math.min(20, this.failureCount + 1);
148
+ this.retryAt = this.clock() + Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.min(19, this.failureCount - 1));
149
+ this.pending = true; this.error = error instanceof InboxRuntimeError ? "runtime" : error instanceof InboxCleanupError ? "cleanup" : "failure";
150
+ if (error instanceof InboxCleanupError && job && error.group !== undefined) { this.blockedTask = job; this.blockedGroup = error.group; }
151
+ if (error instanceof InboxRuntimeError) { this.cache = undefined; this.errorDetail = error.message; }
152
+ if (!this.closed) try { this.log(error instanceof InboxRuntimeError ? error.message : this.error === "cleanup"
153
+ ? `amem: inbox group ${this.blockedGroup ?? "unknown"} cleanup unresolved; snapshots will observe recovery without signalling it.` : "amem: inbox refresh failed; bounded backoff applies"); } catch {}
154
+ }
155
+ private refresh(): void {
156
+ if (this.closed || this.task || this.blockedTask || !this.pending || !this.allowed()) return;
157
+ const generation = this.generation, started = this.clock(); this.lastStart = started; this.pending = false; this.error = undefined; this.errorDetail = undefined;
158
+ let job: InboxJob;
159
+ try { job = this.runner(this.base, this.capacity); } catch (error) { this.lastDuration = Math.max(0, this.clock() - started); this.failed(error); return; }
160
+ this.task = job;
161
+ void job.result.then(result => {
162
+ const revision = inboxPolicyVersion(this.base), computation = result.computation;
163
+ if (generation !== this.generation || computation.policy !== revision || inboxSourceChange(this.base, result.sources).changed) { this.pending = true; return; }
164
+ if (computation.expiresAt !== null && (!Number.isFinite(computation.expiresAt) || computation.expiresAt < 0)) throw Error("invalid inbox expiry");
165
+ if (computation.expiresAt !== null && this.now() >= computation.expiresAt) { this.invalidate({ hard: true }); return; }
166
+ if (computation.items.length > INBOX_MAX_ITEMS || Buffer.byteLength(JSON.stringify(computation.items)) > INBOX_MAX_BYTES || !Number.isSafeInteger(computation.more) || computation.more < 0) throw Error("invalid inbox result");
167
+ const screen = configuredPolicy(this.base);
168
+ if (computation.items.some(item => item.id !== "inbox:more" && !screen.screenStandalone(item))) throw Error("inbox result declined");
169
+ this.cache = structuredClone(result); this.observed = this.cache.sources; this.capacity = this.cache.computation.capacity;
170
+ const cap = this.cache.computation.items.find(item => item.id === "capacity:proposal");
171
+ if (cap) { this.capacityAt ??= cap.at; cap.at = this.capacityAt; } else this.capacityAt = undefined;
172
+ const overflow = this.cache.computation.items.find(item => item.id === "inbox:more");
173
+ if (overflow) { this.overflowAt ??= overflow.at; overflow.at = this.overflowAt; } else this.overflowAt = undefined;
174
+ this.policy = revision; this.generatedAt = new Date(this.now()).toISOString(); this.stale = false; this.pending = false;
175
+ this.failureCount = 0; this.retryAt = 0; this.error = undefined; this.errorDetail = undefined;
176
+ }).catch(error => this.failed(error, job)).finally(() => {
177
+ this.lastDuration = Math.max(0, this.clock() - started); if (this.task === job) this.task = undefined;
178
+ // No timer or completion callback spawns another process. A later snapshot services one pending rerun.
179
+ });
180
+ }
181
+ private status(): InboxSnapshot {
182
+ const kind = this.error || this.blockedTask ? "unavailable" : "refreshing";
183
+ if (this.state !== kind) { this.state = kind; this.stateAt = Math.max(this.now(), this.stateAt + 1); }
184
+ return { items: [{ id: `inbox:${kind}`, group: "alert", kind: "status", title: kind === "refreshing" ? "Memory inbox is refreshing" : "Memory inbox is unavailable",
185
+ detail: this.errorDetail ?? (kind === "refreshing" ? "Pending items will appear when the refresh finishes." : "Refresh is paused; it will retry after the cooldown."),
186
+ at: new Date(this.stateAt).toISOString(), verbs: [], link: { panel: "review" } }], more: 0, complete: false, refreshing: true };
187
+ }
188
+ snapshot(): Promise<InboxSnapshot> {
189
+ if (this.request) return this.request.then(value => structuredClone(value));
190
+ const request = Promise.resolve().then(() => {
191
+ if (this.closed) throw Error("inbox provider is closed");
192
+ try {
193
+ this.recover(); const revision = inboxPolicyVersion(this.base);
194
+ if (this.policy !== undefined && this.policy !== revision) this.invalidate({ hard: true, capacity: true });
195
+ this.policy = revision;
196
+ if (this.cache) {
197
+ const changed = inboxSourceChange(this.base, this.observed ?? this.cache.sources);
198
+ this.observed = changed.sources;
199
+ if (changed.changed) this.invalidate({ hard: changed.hard, capacity: changed.hard });
200
+ if (this.cache && this.cache.computation.expiresAt !== null && this.now() >= this.cache.computation.expiresAt) this.invalidate({ hard: true });
201
+ if (this.capacity && this.now() >= this.capacity.until && !this.stale) this.invalidate();
202
+ }
203
+ this.refresh();
204
+ if (this.cache && inboxPolicyVersion(this.base) !== revision) throw Error("inbox policy changed");
205
+ if (this.cache && (!this.stale || this.cache.computation.items.length)) {
206
+ this.state = this.stale ? "stale" : "ready";
207
+ return { items: structuredClone(this.cache.computation.items), more: this.cache.computation.more, complete: !this.stale, refreshing: this.stale, generatedAt: this.generatedAt };
208
+ }
209
+ return this.status();
210
+ } catch { this.invalidate({ hard: true, capacity: true }); this.error = "failure"; return this.status(); }
211
+ });
212
+ this.request = request; void request.finally(() => { if (this.request === request) this.request = undefined; }).catch(() => {});
213
+ return request.then(value => structuredClone(value));
214
+ }
215
+ async close(): Promise<void> {
216
+ this.closed = true; this.cache = undefined; this.capacity = undefined; this.generation++;
217
+ const job = this.task ?? this.blockedTask; if (job) await job.cancel(); this.blockedTask = undefined;
218
+ }
219
+ }
@@ -0,0 +1,14 @@
1
+ /** Compatibility pin for the on-disk worker/control code, not an authentication credential. */
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ // ponytail: this pins the worker/control/policy modules and package metadata. Same-version edits to other
6
+ // dependencies retain the host's module-loader trust ceiling; a host-attested immutable package graph is the upgrade.
7
+ export function inboxWorkerHash(): string | undefined {
8
+ try {
9
+ const files = ["./inbox-worker.ts", "./inbox-cache.ts", "./inbox.ts", "./inbox-worker-version.ts", "../memory/candidates.ts", "../memory/policy.ts", "../memory/config.ts", "../memory/platform.ts", "../../package.json"];
10
+ const hash = createHash("sha256");
11
+ for (const file of files) hash.update(JSON.stringify([file, createHash("sha256").update(readFileSync(new URL(file, import.meta.url))).digest("hex")]));
12
+ return hash.digest("hex");
13
+ } catch { return undefined; }
14
+ }
@@ -0,0 +1,32 @@
1
+ /** One-shot, owned background process. Never imported by the console request handler. */
2
+ import { DENY_NAMES, DENY_SEGMENTS, processSignatureAsync, processGroupIdAsync } from "../memory/platform.ts";
3
+ import { inboxWorkerHash } from "./inbox-worker-version.ts";
4
+ import { memoryPolicySnapshot } from "../memory/policy.ts";
5
+ import { computeInbox, createInboxSourceTracker, type CapacityMemo, type InboxDefaults } from "./inbox.ts";
6
+
7
+ process.once("disconnect", () => process.exit());
8
+ // Keep the leader identity while synchronous owned children settle or the parent force-kills the group.
9
+ process.on("SIGTERM", () => {});
10
+ process.once("message", (message: unknown) => {
11
+ try {
12
+ const input = message as { type?: unknown; base?: unknown; capacity?: CapacityMemo; defaults?: InboxDefaults };
13
+ if (input.type !== "run" || typeof input.base !== "string") throw Error("invalid inbox job");
14
+ const defaults = memoryPolicySnapshot(input.defaults);
15
+ if (!defaults || !Array.isArray(defaults.names) || !Array.isArray(defaults.segments)
16
+ || !defaults.names.every(value => typeof value === "string") || !defaults.segments.every(value => typeof value === "string")) throw Error("invalid inbox policy");
17
+ DENY_NAMES.splice(0, DENY_NAMES.length, ...defaults.names); DENY_SEGMENTS.splice(0, DENY_SEGMENTS.length, ...defaults.segments);
18
+ const tracker = createInboxSourceTracker(input.base);
19
+ const computation = computeInbox(input.base, input.capacity, tracker.beforeProject), before = tracker.before(), after = tracker.after();
20
+ const result = before.version === after.version ? { type: "result", computation, sources: after } : { type: "changed" };
21
+ process.send?.(result, () => process.disconnect?.());
22
+ } catch {
23
+ // No source text, paths, literal patterns or caller error messages cross the error channel.
24
+ process.send?.({ type: "failed" }, () => process.disconnect?.());
25
+ }
26
+ });
27
+ const workerHash = inboxWorkerHash();
28
+ if (!workerHash || workerHash !== process.env.AMEM_UI_WORKER_HASH) {
29
+ process.send?.({ type: "moved" }, () => process.disconnect?.());
30
+ } else void Promise.all([processSignatureAsync(process.pid), processGroupIdAsync(process.pid)]).then(([signature, group]) => {
31
+ process.send?.({ type: "ready", pid: process.pid, signature, group, workerHash });
32
+ });