@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/package.json +2 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +2 -1
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +25 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +67 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +292 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +98 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +236 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +513 -0
- package/src/memory/sync-placement-journal.ts +153 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +489 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +20 -64
- package/src/setup.ts +69 -14
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
package/src/setup.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface SetupContext {
|
|
|
39
39
|
env?: Env; bun?: string; entry?: string; stdinTTY?: boolean; cwd?: string;
|
|
40
40
|
/** `--root`: an absolute amem install root to register instead of the running one (#199). Ignored when `entry` is given. */
|
|
41
41
|
root?: string;
|
|
42
|
+
/** `--adopt-current` (#241): with apply, set a stale receipt aside and adopt the live wiring as the new baseline. A receipt that is not stale is kept. */
|
|
43
|
+
adoptCurrent?: boolean;
|
|
42
44
|
/** @deprecated Retained for source compatibility; explicit apply/undo modes do not prompt. */
|
|
43
45
|
confirm?: (plan: SetupPlan) => Promise<boolean>;
|
|
44
46
|
/** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
|
|
@@ -271,8 +273,14 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
|
|
|
271
273
|
if (target === "all") pluginNotes.push("Antigravity is repository-scoped and excluded from all; run amem setup agy explicitly inside its repository.");
|
|
272
274
|
if (targets.includes("jcode")) pluginNotes.push("Launch Jcode with JCODE_DISABLE_CLAUDE_MCP=1 and without a same-name project override: imported Claude stdio entries can replace this private amem binding with a shared daemon-cwd server. Use a new workspace-bound session when changing projects.");
|
|
273
275
|
return { targets, mode, ...(c.root ? { root: c.root } : {}), actions: targets.map(target => {
|
|
274
|
-
const
|
|
276
|
+
const receipted = receipt(c, target, false), files = mode === "undo" && receipted ? receipted.changes.map(change => change.path) : c.paths[target];
|
|
275
277
|
for (const file of files) read(file);
|
|
278
|
+
const stale = mode !== "undo" && !!receipted && staleReceipt(target, receipted);
|
|
279
|
+
if (stale) pluginNotes.push(options.adoptCurrent
|
|
280
|
+
? `${target}: the setup receipt is stale (its managed entries were replaced at another amem install root outside setup). Apply keeps it as ${journal(c, target)}.stale-<UTC time> and adopts the current wiring; --undo then restores that wiring.`
|
|
281
|
+
: `${target}: the setup receipt is stale (its managed entries were replaced at another amem install root outside setup). Apply refuses; rerun with --apply --adopt-current to keep the receipt aside and adopt the current wiring, which --undo then restores.`);
|
|
282
|
+
// An adopted stale receipt plans like the receipt-less path that apply takes.
|
|
283
|
+
const prior = stale && options.adoptCurrent ? undefined : receipted;
|
|
276
284
|
if (mode === "undo" && prior?.changes.some((change, index) => change.path !== c.paths[target][index])) {
|
|
277
285
|
pluginNotes.push(`${target}: undo restores the historical receipt files, not the new personal source: ${prior.changes.map(change => change.path).join(", ")}`);
|
|
278
286
|
}
|
|
@@ -295,7 +303,7 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
|
|
|
295
303
|
const add = command(target, c.bun, c.entry), remove = target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
|
|
296
304
|
const installed = available(c, target, options), version = installed && target !== "agy" && mode !== "undo" ? probeClientVersion(c, target, options) : undefined;
|
|
297
305
|
if (version?.matches === false) pluginNotes.push(`WARNING: ${target} version ${version.output}; layout verified on ${version.verified}. Apply remains guarded by native configuration validation.`);
|
|
298
|
-
return { target, available: installed, ...(version ? { version } : {}), files, receipt: journal(c, target), ...(target === "agy" ? { cwd: c.workspace!.root } : {}), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !installed ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
|
|
306
|
+
return { target, available: installed, ...(version ? { version } : {}), files, receipt: journal(c, target), ...(target === "agy" ? { cwd: c.workspace!.root } : {}), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !installed ? [] : [...(hasAmem ? [remove] : []), add], state: stale ? options.adoptCurrent ? "stale receipt; current wiring will be adopted" : "stale receipt; apply needs --adopt-current" : prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
|
|
299
307
|
}), notes: [...pluginNotes, ...(/\/[^/]*\d+\.\d+[^/]*\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is passed; explicit modes apply without a prompt.", "Native registration commands run in a private staging HOME; only the listed configuration files are published.", "Original file bytes restore exactly when untouched; otherwise undo preserves CLI-owned changes and restores only managed entries, refusing edits to those entries.", "No AGENTS.md, CLAUDE.md or rule files are edited.", `A private ${join(c.state, "setup.lock")} serializes mutations; temporary ${join(realPath(tmpdir()), "amem-setup-*")} staging homes are removed.`, "Codex requires its native /hooks review before newly registered hooks run; setup does not grant hook trust. Runtime registrations use the PATH-resolved absolute Bun path without resolving its stable symlink; setup all reports and skips missing clients."] };
|
|
300
308
|
}
|
|
301
309
|
/** Native probes and registration share one private HOME, cwd and configuration layout. */
|
|
@@ -393,16 +401,56 @@ function piExtension(bun: string, entry: string): string {
|
|
|
393
401
|
function previousRuntimes(target: SetupTarget, images: readonly Image[], bun: string, entry: string): { bun: string; entry: string }[] {
|
|
394
402
|
const found = new Map<string, { bun: string; entry: string }>();
|
|
395
403
|
for (const image of images) {
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
398
|
-
const server = value as Record<string, unknown>, args = server.args;
|
|
399
|
-
if (typeof server.command === "string" && isAbsolute(server.command) && Array.isArray(args) && args.length === 3
|
|
400
|
-
&& args[0] === "--smol" && typeof args[1] === "string" && isAbsolute(args[1]) && args[2] === "mcp"
|
|
401
|
-
&& (server.command !== bun || args[1] !== entry)) found.set(JSON.stringify([server.command, args[1]]), { bun: server.command, entry: args[1] });
|
|
404
|
+
const runtime = launcher(mcpRecord(target, image));
|
|
405
|
+
if (runtime && (runtime.bun !== bun || runtime.entry !== entry)) found.set(JSON.stringify([runtime.bun, runtime.entry]), runtime);
|
|
402
406
|
}
|
|
403
407
|
return [...found.values()];
|
|
404
408
|
}
|
|
405
|
-
|
|
409
|
+
/** The runtime of a generated `<bun> --smol <entry> mcp` server record, if the record is exactly that shape. */
|
|
410
|
+
function launcher(value: unknown): { bun: string; entry: string } | undefined {
|
|
411
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
412
|
+
const server = value as Record<string, unknown>, args = server.args;
|
|
413
|
+
return typeof server.command === "string" && isAbsolute(server.command) && Array.isArray(args) && args.length === 3
|
|
414
|
+
&& args[0] === "--smol" && typeof args[1] === "string" && isAbsolute(args[1]) && args[2] === "mcp" ? { bun: server.command, entry: args[1] } : undefined;
|
|
415
|
+
}
|
|
416
|
+
/** Generated capture commands of a prior runtime, current and pre-#145 (no `--json`) forms. */
|
|
417
|
+
const priorCaptures = (launches: readonly string[], kind: string) => launches.flatMap(launch => [`${launch} ingest ${kind} --json`, `${launch} ingest ${kind}`]);
|
|
418
|
+
const STALE_RECEIPT = "setup receipt is stale: its managed entries were replaced at another amem install root; rerun with --adopt-current to adopt the current wiring";
|
|
419
|
+
/**
|
|
420
|
+
* #241: the receipt cannot restore, none of its runtime's entries remain in its files, and the live MCP entry launches another install root.
|
|
421
|
+
* The wiring was moved outside setup, so the receipt no longer owns anything live. Any trace of the receipt's runtime keeps it authoritative.
|
|
422
|
+
*/
|
|
423
|
+
function staleReceipt(target: SetupTarget, prior: Receipt): boolean {
|
|
424
|
+
let files: Image[];
|
|
425
|
+
try {
|
|
426
|
+
files = prior.changes.map(change => read(change.path));
|
|
427
|
+
const owned = launcher(mcpRecord(target, prior.changes[0]!.after)), live = launcher(mcpRecord(target, files[0]!));
|
|
428
|
+
if (!owned || !live || live.entry === owned.entry) return false;
|
|
429
|
+
// Raw and shell-quoted entry, searched in the raw text (with JSON escaping, for the Pi extension source)
|
|
430
|
+
// and in every decoded JSON/TOML string value, so escapes such as `\/` cannot hide a live entry.
|
|
431
|
+
const traces = [owned.entry, quote(owned.entry)], escaped = traces.map(value => JSON.stringify(value).slice(1, -1));
|
|
432
|
+
if (files.some(file => file.bytes !== null && decodedTexts(Buffer.from(file.bytes, "base64").toString("utf8"))
|
|
433
|
+
.some((value, index) => (index === 0 ? [...traces, ...escaped] : traces).some(trace => value.includes(trace))))) return false;
|
|
434
|
+
} catch { return false; }
|
|
435
|
+
try { prior.changes.forEach((change, index) => restoreChange(prior, change, index, files[index]!)); return false; } catch { return true; }
|
|
436
|
+
}
|
|
437
|
+
/** The raw text first, then every string value it decodes to as JSON, else as TOML. */
|
|
438
|
+
function decodedTexts(raw: string): string[] {
|
|
439
|
+
const out = [raw], walk = (value: unknown): void => {
|
|
440
|
+
if (typeof value === "string") out.push(value);
|
|
441
|
+
else if (value && typeof value === "object") for (const item of Object.values(value)) walk(item);
|
|
442
|
+
};
|
|
443
|
+
try { walk(JSON.parse(raw)); } catch { try { walk(Bun.TOML.parse(raw)); } catch { /* Not JSON or TOML (the Pi extension): the raw text alone. */ } }
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
/** Keep a stale receipt beside the new one instead of deleting it; the person can inspect or restore it. */
|
|
447
|
+
function setReceiptAside(file: string): void {
|
|
448
|
+
const aside = `${file}.stale-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "")}`;
|
|
449
|
+
names(aside);
|
|
450
|
+
if (lstatSync(aside, { throwIfNoEntry: false })) throw Error("setup is busy; retry after the active setup exits");
|
|
451
|
+
renameSync(file, aside); const parent = openSync(dirname(file), constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
452
|
+
}
|
|
453
|
+
interface Prepared { value: Receipt; expected: Image[]; unchanged: boolean; setAside?: string }
|
|
406
454
|
function restoreChange(value: Receipt, change: Change, index: number, live: Image): Image {
|
|
407
455
|
const start = value.phase === "prepared" && change.start && !sameImage(change.start, change.before) ? change.start : undefined;
|
|
408
456
|
if (start) {
|
|
@@ -417,7 +465,13 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
417
465
|
if (target === "jcode") assertJcodeRuntime(c);
|
|
418
466
|
if (target === "opencode") assertOpencodeLayout(c, "apply");
|
|
419
467
|
if (target === "gemini") assertGeminiHome(c.env);
|
|
420
|
-
|
|
468
|
+
let prior = receipt(c, target, false), setAside: string | undefined;
|
|
469
|
+
if (prior && staleReceipt(target, prior)) {
|
|
470
|
+
if (!options.adoptCurrent) throw Error(STALE_RECEIPT);
|
|
471
|
+
// Adopt the live wiring through the receipt-less path (#238); the receipt is set aside only when this target publishes.
|
|
472
|
+
setAside = journal(c, target); prior = undefined;
|
|
473
|
+
}
|
|
474
|
+
const expected = c.paths[target].map(path => read(path));
|
|
421
475
|
// Only a same-file before-image can restore a target. A newly authoritative personal source owns its current bytes.
|
|
422
476
|
// Mapped per current path: a pre-#203 jcode receipt covers mcp.json only, so config.toml starts from its live image.
|
|
423
477
|
const restored = c.paths[target].map((path, index) => { const change = prior?.changes[index];
|
|
@@ -494,9 +548,9 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
494
548
|
}
|
|
495
549
|
const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind} --json`;
|
|
496
550
|
if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`, 1, event => oldLaunches.map(launch => `${launch} hook ${event}`));
|
|
497
|
-
if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3, () => oldLaunches
|
|
551
|
+
if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3, () => priorCaptures(oldLaunches, "codex"));
|
|
498
552
|
// Qwen's user MCP and hooks share settings.json: publish and journal one combined image.
|
|
499
|
-
if (target === "qwen") changes[0]!.after = hookDocument(changes[0]!.after, ["SessionEnd"], () => capture("qwen"), 3, () => oldLaunches
|
|
553
|
+
if (target === "qwen") changes[0]!.after = hookDocument(changes[0]!.after, ["SessionEnd"], () => capture("qwen"), 3, () => priorCaptures(oldLaunches, "qwen"));
|
|
500
554
|
if (target === "kimi") {
|
|
501
555
|
const old = changes[1]!.before.bytes === null ? "" : Buffer.from(changes[1]!.before.bytes!, "base64").toString("utf8");
|
|
502
556
|
const parsed = toml(old), rawHooks = parsed.hooks ?? [];
|
|
@@ -504,7 +558,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
504
558
|
let hooks: unknown[] = rawHooks;
|
|
505
559
|
if (oldLaunches.length) hooks = hooks.filter(h => {
|
|
506
560
|
const row = record(h);
|
|
507
|
-
if (row.event !== "SessionEnd" || !oldLaunches.
|
|
561
|
+
if (row.event !== "SessionEnd" || !priorCaptures(oldLaunches, "kimi").includes(String(row.command))) return true;
|
|
508
562
|
if (![undefined, "", "*"].includes(row.matcher as string | undefined) || row.timeout !== 3
|
|
509
563
|
|| Object.keys(row).some(key => !["event", "command", "matcher", "timeout"].includes(key)))
|
|
510
564
|
throw Error("previous-runtime hook settings are customized; review hook configuration before retrying setup");
|
|
@@ -522,7 +576,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
522
576
|
}
|
|
523
577
|
// Never publish an image that this installer's own read/undo boundary cannot reopen.
|
|
524
578
|
if (changes.some(change => change.after.bytes !== null && Buffer.from(change.after.bytes, "base64").byteLength > MAX)) throw Error("planned setup configuration exceeds the size bound; no configuration published");
|
|
525
|
-
return { value: { version: previous.length && changes.some((change, index) => retiresOriginalHook(target, index, change.before, change.after)) ? 2 : 1, target, intent: intent(c, target), phase: "prepared", changes, ...(prior?.createdDirectory ? { createdDirectory: prior.createdDirectory } : {}) }, expected, unchanged: false };
|
|
579
|
+
return { value: { version: previous.length && changes.some((change, index) => retiresOriginalHook(target, index, change.before, change.after)) ? 2 : 1, target, intent: intent(c, target), phase: "prepared", changes, ...(prior?.createdDirectory ? { createdDirectory: prior.createdDirectory } : {}) }, expected, unchanged: false, ...(setAside ? { setAside } : {}) };
|
|
526
580
|
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
527
581
|
}
|
|
528
582
|
function save(c: ReturnType<typeof context>, value: Receipt): void { write(journal(c, value.target), image(JSON.stringify(value) + "\n")); }
|
|
@@ -570,6 +624,7 @@ function applyUnlocked(plan: SetupPlan, options: SetupContext = {}): SetupApplyR
|
|
|
570
624
|
const value = item.value;
|
|
571
625
|
if (value.target === "agy") c.workspace!.assertCurrent();
|
|
572
626
|
if (item.unchanged) { unchanged.push(value.target); continue; }
|
|
627
|
+
if (item.setAside) setReceiptAside(item.setAside);
|
|
573
628
|
save(c, value);
|
|
574
629
|
prepareCreatedDirectory(c, value);
|
|
575
630
|
for (let i = 0; i < value.changes.length; i++) { const change = value.changes[i]!; if (!sameImage(item.expected[i]!, change.after)) write(change.path, change.after, item.expected[i]!, publishGuard(c, value.target, options)); }
|
|
@@ -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
|
+
});
|