@staix/agent-mem 0.6.0 → 0.7.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
package/src/setup.ts ADDED
@@ -0,0 +1,327 @@
1
+ import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
2
+ import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { isDeepStrictEqual } from "node:util";
11
+ import { HOOK_EVENTS } from "./hook-contract.ts";
12
+ import { memoryHome } from "./memory/home.ts";
13
+ import { canonicalPath, processLiveness, processSignature, realPath } from "./memory/platform.ts";
14
+
15
+ export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi"] as const;
16
+ export type SetupTarget = typeof SETUP_TARGETS[number];
17
+ type Env = Readonly<Record<string, string | undefined>>;
18
+ type Image = ConfigImage;
19
+ interface PlannedHook { event: string; command: string; timeout: number }
20
+ interface Change { key: string; path: string; before: Image; after: Image; start?: Image }
21
+ interface Receipt { version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
22
+ export interface SetupContext {
23
+ env?: Env; bun?: string; entry?: string; stdinTTY?: boolean;
24
+ /** Trusted confirmation seam; ordinary CLI use always asks the person through stdin. */
25
+ confirm?: (plan: SetupPlan) => Promise<boolean>;
26
+ /** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
27
+ beforePublish?: (file: string) => void;
28
+ /** Trusted test seam; production always runs the named native CLI in a private staging HOME. */
29
+ native?: (argv: readonly string[], env: Env, cwd: string) => void;
30
+ }
31
+ export interface SetupPlan {
32
+ targets: SetupTarget[]; mode: "preview" | "apply" | "undo";
33
+ actions: { target: SetupTarget; files: string[]; receipt: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
34
+ notes: string[];
35
+ }
36
+ const MAX = SETUP_CONFIG_BYTES, JOURNAL_MAX = 12 * MAX;
37
+ const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
38
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
39
+ function record(value: unknown): Record<string, unknown> {
40
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("setup configuration must be an object");
41
+ return value as Record<string, unknown>;
42
+ }
43
+ function json(image: Image): Record<string, unknown> {
44
+ try { return image.bytes === null ? {} : record(JSON.parse(Buffer.from(image.bytes, "base64").toString("utf8"))); }
45
+ catch { throw Error("setup configuration does not parse as a JSON object"); }
46
+ }
47
+ function toml(text: string): Record<string, unknown> {
48
+ try { return record(Bun.TOML.parse(text)); } catch { throw Error("setup configuration does not parse as TOML"); }
49
+ }
50
+ function image(text: string, mode = 0o600): Image { return { bytes: Buffer.from(text).toString("base64"), mode }; }
51
+ function objectImage(value: unknown, before: Image): Image { return image(JSON.stringify(value, null, 2) + "\n", before.mode); }
52
+ function names(file: string): void {
53
+ // Configuration files and their containing directories cannot redirect an apply/undo through a symlink.
54
+ let cursor = file;
55
+ while (true) {
56
+ try { if (lstatSync(cursor).isSymbolicLink()) throw Error("setup refuses symbolic-link configuration paths"); }
57
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
58
+ const parent = dirname(cursor); if (parent === cursor) break; cursor = parent;
59
+ }
60
+ }
61
+ function read(file: string, max = MAX): Image {
62
+ names(file);
63
+ let fd: number;
64
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
65
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { bytes: null, mode: 0o600 }; throw error; }
66
+ try {
67
+ const stat = fstatSync(fd);
68
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > max) throw Error("setup configuration unavailable or oversized");
69
+ const data = Buffer.alloc(stat.size + 1), size = readSync(fd, data, 0, data.length, 0), after = fstatSync(fd);
70
+ if (size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw Error("setup configuration changed while reading");
71
+ return { bytes: data.subarray(0, size).toString("base64"), mode: stat.mode & 0o777 };
72
+ } finally { closeSync(fd); }
73
+ }
74
+ function write(file: string, value: Image, expected?: Image, beforePublish?: (file: string) => void): void {
75
+ names(file);
76
+ if (value.bytes === null) { beforePublish?.(file); if (expected && !sameImage(read(file), expected)) throw Error("setup configuration changed before removal"); rmSync(file, { force: true }); return; }
77
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); names(dirname(file));
78
+ const temporary = join(dirname(file), `.amem-setup-${randomUUID()}`);
79
+ try {
80
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
81
+ try { writeFileSync(fd, Buffer.from(value.bytes, "base64")); fchmodSync(fd, value.mode); fsyncSync(fd); }
82
+ finally { closeSync(fd); }
83
+ beforePublish?.(file);
84
+ if (expected && !sameImage(read(file), expected)) throw Error("setup configuration changed before publication");
85
+ // ponytail: an external CLI can still write between this comparison and rename; a shared CLI lock or native transactional API is needed to close that window.
86
+ names(file); renameSync(temporary, file); const parent = openSync(dirname(file), constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
87
+ } finally { rmSync(temporary, { force: true }); }
88
+ }
89
+ function context(options: SetupContext) {
90
+ const env = options.env ?? process.env, home = realPath(resolve(env.HOME?.trim() || homedir()));
91
+ const root = (key: string, fallback: string) => {
92
+ if (!env[key]?.trim()) return join(home, fallback);
93
+ const given = resolve(env[key]!), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
94
+ // Canonicalize the HOME alias only; named configuration-directory symlinks must still be refused.
95
+ return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
96
+ };
97
+ const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code"), pi = root("PI_CODING_AGENT_DIR", ".pi/agent");
98
+ const paths: Record<SetupTarget, string[]> = {
99
+ claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
100
+ codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
101
+ kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
102
+ pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
103
+ };
104
+ const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
105
+ const entry = options.entry ?? fileURLToPath(new URL("../bin/amem.js", import.meta.url));
106
+ if (!bun || !isAbsolute(bun) || !isAbsolute(entry)) throw Error("setup needs a PATH-resolved absolute Bun runtime and an absolute installed entry");
107
+ // The trusted native fixture may use a temporary package; ordinary CLI installation must outlive bunx's cache.
108
+ if (!options.native) {
109
+ const roots = [resolve(tmpdir()), realPath(tmpdir())], entries = [resolve(entry), realPath(entry)];
110
+ if (roots.some(root => entries.some(file => { const part = relative(root, file); return part !== ".." && !part.startsWith("../") && !isAbsolute(part); }))) throw Error("setup refuses a temporary package entry; install amem in a persistent location first");
111
+ }
112
+ return { env, home, paths, bun, entry, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
113
+ }
114
+ function command(target: SetupTarget, bun: string, entry: string): string[] | null {
115
+ if (target === "kimi") return null;
116
+ return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
117
+ }
118
+ function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry]); }
119
+ function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, `${target}.json`); }
120
+ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
121
+ const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
122
+ const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
123
+ if (value.version !== 1 || value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || requireRuntime && value.intent !== intent(c, target) || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== c.paths[target].length) throw Error("setup receipt does not match this installation");
124
+ for (let i = 0; i < value.changes.length; i++) {
125
+ const change = record(value.changes[i]); if (change.path !== c.paths[target][i] || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
126
+ for (const key of ["before", "after", ...(change.start !== undefined ? ["start"] : [])]) {
127
+ const v = record(change[key]);
128
+ if (!Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
129
+ }
130
+ }
131
+ return value as unknown as Receipt;
132
+ }
133
+ function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
134
+ const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
135
+ return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
136
+ : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}`, timeout: target === "pi" ? 2.5 : 3 }];
137
+ }
138
+ const lifecycle = (target: SetupTarget) => target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
139
+ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
140
+ if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|all [--apply|--undo]");
141
+ const c = context(options), targets = target === "all" ? [...SETUP_TARGETS] : [target as SetupTarget];
142
+ const pluginNotes: string[] = [];
143
+ if (targets.includes("claude")) {
144
+ const settings = json(read(c.paths.claude[1]!));
145
+ try {
146
+ const registry = json(read(join(dirname(c.paths.claude[1]!), "plugins", "installed_plugins.json")));
147
+ const ids = new Set([...Object.keys(record(settings.enabledPlugins ?? {})), ...Object.keys(record(registry.plugins ?? {}))].filter(key => /^agent-mem(?:@[A-Za-z0-9._-]+)?$/.test(key)));
148
+ for (const id of ids) pluginNotes.push(`Installed Claude plugin ${id} detected: if enabled, direct setup would duplicate its MCP server and seven hooks. Choose plugin installation or direct setup; disable/uninstall the plugin before applying direct setup.`);
149
+ } catch { /* Optional plugin metadata must never prevent setup or recovery; authoritative settings/receipts keep their guards. */ }
150
+ }
151
+ return { targets, mode, actions: targets.map(target => {
152
+ const prior = receipt(c, target, false); for (const file of c.paths[target]) read(file);
153
+ const original = read(c.paths[target][0]!);
154
+ const document = target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
155
+ const hasAmem = Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
156
+ const unchanged = !!prior && prior.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => { try { const live = read(change.path); restoreChange(prior, change, index, live); return managedAfter(target, index, change.before, change.after, live); } catch { return false; } });
157
+ const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" ? ["--scope", "user"] : []), "amem"];
158
+ return { target, available: available(c, target, options), files: c.paths[target], receipt: journal(c, target), 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 || !available(c, target, options) ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
159
+ }), notes: [...pluginNotes, ...(/(?:\/installs\/bun\/[^/]+|\/Cellar\/bun\/[^/]+)\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is confirmed in a person's terminal.", "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."] };
160
+ }
161
+ function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
162
+ // In older free-form Pi CLIs, an unknown subcommand can be treated as a model prompt.
163
+ const help = spawnSync(target, ["--help"], { cwd, env: { ...env }, encoding: "utf8", timeout: 5000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
164
+ if (help.status !== 0 || !/^\s+(?:pi\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
165
+ }
166
+ function native(argv: readonly string[], env: Env, cwd: string): void {
167
+ const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
168
+ if (result.status !== 0) throw Error(`${argv[0]} MCP registration failed in staging; no native output disclosed`);
169
+ }
170
+ function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
171
+ const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
172
+ for (const event of events) {
173
+ const rows = hooks[event] ?? []; if (!Array.isArray(rows)) throw Error("setup hook groups must be arrays");
174
+ const command = commandFor(event);
175
+ if (!rows.some(row => [undefined, "", "*"].includes(row?.matcher) && Array.isArray(row?.hooks) && row.hooks.some((h: unknown) => { const value = record(h); return value.type === "command" && value.command === command && value.timeout === timeout; }))) rows.push({ hooks: [{ type: "command", command, timeout }] });
176
+ hooks[event] = rows;
177
+ }
178
+ document.hooks = hooks; return objectImage(document, before);
179
+ }
180
+ function piExtension(bun: string, entry: string): string {
181
+ return `// amem setup managed session-end ingest; no resident worker or instruction injection.\nimport { spawnSync } from 'node:child_process';\nexport default function (pi) {\n pi.on('session_shutdown', (_event, ctx) => {\n try { spawnSync(${JSON.stringify(bun)}, ['--smol', ${JSON.stringify(entry)}, 'ingest', 'pi'], { cwd: ctx.cwd, stdio: 'ignore', timeout: 2500 }); } catch {}\n });\n}\n`;
182
+ }
183
+ interface Prepared { value: Receipt; expected: Image[]; unchanged: boolean }
184
+ function restoreChange(value: Receipt, change: Change, index: number, live: Image): Image {
185
+ const start = value.phase === "prepared" && change.start && !sameImage(change.start, change.before) ? change.start : undefined;
186
+ if (start) {
187
+ const newest = managedAfter(value.target, index, change.before, change.after, live), previous = managedAfter(value.target, index, change.before, start, live);
188
+ if (newest && previous) return restoreOwned(value.target, index, change.before, start, restoreOwned(value.target, index, change.before, change.after, live));
189
+ if (previous) return restoreOwned(value.target, index, change.before, start, live);
190
+ if (newest) return restoreOwned(value.target, index, change.before, change.after, live);
191
+ }
192
+ return restoreOwned(value.target, index, change.before, change.after, live);
193
+ }
194
+ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): Prepared {
195
+ const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
196
+ const restored = prior ? prior.changes.map((change, index) => restoreChange(prior, change, index, expected[index]!)) : expected;
197
+ if (prior?.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => managedAfter(target, index, change.before, change.after, expected[index]!))) return { value: prior, expected, unchanged: true };
198
+ const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
199
+ const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
200
+ try {
201
+ const env: Record<string, string | undefined> = { ...c.env, HOME: staging, AMEM_HOME: join(staging, "memory"), AGENTHUB_HOME: join(staging, "hub"), CODEX_HOME: join(staging, ".codex"), KIMI_CODE_HOME: join(staging, ".kimi-code"), PI_CODING_AGENT_DIR: join(staging, ".pi", "agent") };
202
+ Object.assign(env, { XDG_CONFIG_HOME: join(staging, ".config"), XDG_DATA_HOME: join(staging, ".local", "share"), XDG_CACHE_HOME: join(staging, ".cache"), XDG_STATE_HOME: join(staging, ".local", "state"), TMPDIR: join(staging, "tmp"), TMP: join(staging, "tmp"), TEMP: join(staging, "tmp") });
203
+ mkdirSync(env.TMPDIR!, { mode: 0o700 });
204
+ if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
205
+ const staged = context({ ...options, env });
206
+ // Codex refuses an explicit CODEX_HOME that does not exist, even for mcp add.
207
+ mkdirSync(dirname(staged.paths[target][0]!), { recursive: true, mode: 0o700 });
208
+ for (let i = 0; i < changes.length; i++) if (expected[i]!.bytes !== null) write(staged.paths[target][i]!, expected[i]!);
209
+ const argv = command(target, c.bun, c.entry);
210
+ if (argv && prior?.intent === intent(c, target)) {
211
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, mcpRecord(target, prior.changes[0]!.after));
212
+ } else if (argv) {
213
+ if (!options.native) verifyNativeMcp(target, env, staging);
214
+ const text = expected[0]!.bytes === null ? "" : Buffer.from(expected[0]!.bytes!, "base64").toString("utf8");
215
+ const document = target === "codex" ? toml(text) : json(expected[0]!);
216
+ const servers = document[target === "codex" ? "mcp_servers" : "mcpServers"];
217
+ if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" ? ["--scope", "user"] : []), "amem"], env, staging);
218
+ (options.native ?? native)(argv, env, staging);
219
+ changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
220
+ if (changes[0]!.after.bytes === null) throw Error("native registration produced no configuration");
221
+ const installedText = Buffer.from(changes[0]!.after.bytes!, "base64").toString("utf8");
222
+ const installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
223
+ const installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
224
+ if (installedServer.command !== c.bun || JSON.stringify(installedServer.args) !== JSON.stringify(["--smol", c.entry, "mcp"]) || installedServer.enabled === false) throw Error("native registration did not produce the planned amem server");
225
+ const serverKey = target === "codex" ? "mcp_servers" : "mcpServers";
226
+ const withoutAmem = (value: Record<string, unknown>) => { const copy = { ...value }, servers = { ...record(value[serverKey] ?? {}) }; delete servers.amem; copy[serverKey] = servers; return copy; };
227
+ if (!isDeepStrictEqual(withoutAmem(document)[serverKey], withoutAmem(installed)[serverKey])) throw Error("native registration changed unrelated MCP servers; no configuration published");
228
+ if (target === "codex") {
229
+ if (!isDeepStrictEqual(withoutAmem(document), withoutAmem(installed))) throw Error("native registration changed unrelated settings; no configuration published");
230
+ } else {
231
+ // Keep all unrelated JSON settings from the original; use only the CLI-generated server record.
232
+ document.mcpServers = { ...record(document.mcpServers ?? {}), amem: installedServer };
233
+ changes[0]!.after = objectImage(document, changes[0]!.before);
234
+ }
235
+ } else {
236
+ const document = json(changes[0]!.before), servers = document.mcpServers === undefined ? {} : record(document.mcpServers);
237
+ servers.amem = { command: c.bun, args: ["--smol", c.entry, "mcp"] }; document.mcpServers = servers;
238
+ changes[0]!.after = objectImage(document, changes[0]!.before);
239
+ }
240
+ const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind}`;
241
+ if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`);
242
+ if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3);
243
+ if (target === "kimi") {
244
+ const old = changes[1]!.before.bytes === null ? "" : Buffer.from(changes[1]!.before.bytes!, "base64").toString("utf8");
245
+ const parsed = toml(old), hooks = parsed.hooks ?? [];
246
+ if (!Array.isArray(hooks)) throw Error("Kimi hooks must be an array");
247
+ if (!hooks.some(h => record(h).event === "SessionEnd" && record(h).command === capture("kimi"))) hooks.push({ event: "SessionEnd", command: capture("kimi"), timeout: 3 });
248
+ changes[1]!.after = image(updateTomlArray(old, ["hooks"], hooks), changes[1]!.before.mode);
249
+ }
250
+ if (target === "pi") {
251
+ const content = piExtension(c.bun, c.entry);
252
+ if (!prior && changes[1]!.before.bytes !== null && changes[1]!.before.bytes !== image(content).bytes) throw Error("Pi amem-session extension already exists and is not this setup's file");
253
+ changes[1]!.after = image(content, changes[1]!.before.mode);
254
+ }
255
+ // Never publish an image that this installer's own read/undo boundary cannot reopen.
256
+ 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");
257
+ return { value: { version: 1, target, intent: intent(c, target), phase: "prepared", changes }, expected, unchanged: false };
258
+ } finally { rmSync(staging, { recursive: true, force: true }); }
259
+ }
260
+ function save(c: ReturnType<typeof context>, value: Receipt): void { write(journal(c, value.target), image(JSON.stringify(value) + "\n")); }
261
+ function available(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): boolean { return !!options.native || Bun.which(target, { PATH: c.env.PATH ?? process.env.PATH }) !== null; }
262
+ export interface SetupApplyResult { applied: SetupTarget[]; unchanged: SetupTarget[]; skipped?: { target: SetupTarget; reason: string }[] }
263
+ /** Caller enforces person markers/TTY and confirmation; CLI-owned unrelated fields remain live. */
264
+ function applyUnlocked(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
265
+ const c = context(options), skipped: { target: SetupTarget; reason: string }[] = [];
266
+ const targets = plan.targets.filter(target => { if (available(c, target, options)) return true; if (plan.targets.length === 1) throw Error(`${target} CLI is not installed`); skipped.push({ target, reason: "CLI not installed" }); return false; });
267
+ const prepared = targets.map(target => prepare(c, target, options));
268
+ for (const item of prepared) if (!item.unchanged) for (let i = 0; i < item.value.changes.length; i++) if (!sameImage(read(item.value.changes[i]!.path), item.expected[i]!)) throw Error("setup configuration changed during preparation");
269
+ const applied: SetupTarget[] = [], unchanged: SetupTarget[] = [];
270
+ for (const item of prepared) {
271
+ const value = item.value;
272
+ if (item.unchanged) { unchanged.push(value.target); continue; }
273
+ save(c, value);
274
+ 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]!, options.beforePublish); }
275
+ value.phase = "applied"; save(c, value); applied.push(value.target);
276
+ }
277
+ return { applied, unchanged, ...(skipped.length ? { skipped } : {}) };
278
+ }
279
+ /** Byte-exact for untouched files, otherwise remove/restore only this installer's owned entries. */
280
+ function undoUnlocked(plan: SetupPlan, options: SetupContext = {}): { restored: SetupTarget[] } {
281
+ const c = context(options), receipts = plan.targets.map(target => receipt(c, target, false)).filter((value): value is Receipt => !!value);
282
+ const pending = receipts.map(value => ({ value, files: value.changes.map((change, index) => { const expected = read(change.path); return { change, expected, restored: restoreChange(value, change, index, expected) }; }) }));
283
+ for (const item of pending) for (const file of item.files) if (!sameImage(read(file.change.path), file.expected)) throw Error("setup configuration changed during undo preparation");
284
+ for (const item of pending) { for (const file of item.files) if (!sameImage(file.expected, file.restored)) write(file.change.path, file.restored, file.expected, options.beforePublish); rmSync(journal(c, item.value.target)); }
285
+ return { restored: receipts.map(value => value.target) };
286
+ }
287
+
288
+ function withSetupLock<T>(options: SetupContext, run: () => T): T {
289
+ const c = context(options); names(c.state); mkdirSync(c.state, { recursive: true, mode: 0o700 });
290
+ if (lstatSync(c.state).mode & 0o077) throw Error("setup backup directory must be private");
291
+ const file = join(c.state, "setup.lock"), signature = processSignature(process.pid);
292
+ if (!signature) throw Error("setup process identity unavailable");
293
+ let fd: number;
294
+ try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
295
+ catch (error) {
296
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
297
+ const original = read(file), owner = original.bytes && record(JSON.parse(Buffer.from(original.bytes, "base64").toString("utf8")));
298
+ if (!owner || !Number.isSafeInteger(owner.pid) || Number(owner.pid) <= 0 || typeof owner.signature !== "string") throw Error("setup is busy or its lock is unreadable");
299
+ const live = processLiveness(Number(owner.pid)), actual = live === "live" ? processSignature(Number(owner.pid)) : undefined;
300
+ const stale = live === "gone" || live === "live" && actual !== undefined && actual !== owner.signature;
301
+ if (!stale || digest(read(file)) !== digest(original)) throw Error("setup is busy; retry after the active setup exits");
302
+ rmSync(file); fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
303
+ }
304
+ const token = JSON.stringify({ pid: process.pid, signature, nonce: randomUUID() });
305
+ try { writeFileSync(fd, token); fsyncSync(fd); return run(); }
306
+ finally { closeSync(fd); if (read(file).bytes === Buffer.from(token).toString("base64")) rmSync(file); }
307
+ }
308
+ export function applySetup(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
309
+ return withSetupLock(options, () => applyUnlocked(plan, options));
310
+ }
311
+ export function undoSetup(plan: SetupPlan, options: SetupContext = {}): { restored: SetupTarget[] } {
312
+ return withSetupLock(options, () => undoUnlocked(plan, options));
313
+ }
314
+
315
+ export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext = {}): Promise<string> {
316
+ const plan = planSetup(target, mode, options);
317
+ if (mode === "preview") return JSON.stringify(plan, null, 2);
318
+ if (!(options.stdinTTY ?? process.stdin.isTTY)) throw Error("setup --apply/--undo require a terminal on stdin");
319
+ const confirm = options.confirm ?? (async (shown: SetupPlan) => {
320
+ process.stdout.write(JSON.stringify(shown, null, 2) + "\n");
321
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
322
+ try { return (await terminal.question(`Type yes to ${mode} the listed configuration changes: `)).trim() === "yes"; }
323
+ finally { terminal.close(); }
324
+ });
325
+ if (!await confirm(plan)) return JSON.stringify({ cancelled: true });
326
+ return JSON.stringify(mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options), null, 2);
327
+ }