@staix/agent-mem 0.18.0 → 0.19.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.
@@ -1,7 +1,72 @@
1
1
  import type { ConfigImage } from "./setup-owned.ts";
2
2
  import { createJsonMcpCodec } from "./setup-json.ts";
3
+ import { setTomlPath } from "./setup-toml.ts";
3
4
 
4
5
  const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
5
6
  export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
6
7
  export function putJcodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
7
8
  export function restoreJcodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
9
+
10
+ // #203: jcode v0.99.1 `[hooks]` in <JCODE_HOME>/config.toml (crates/jcode-config-types HooksConfig). Each event accepts a
11
+ // command string or an ordered array of strings; observers in an array all run. amem owns only its own command element.
12
+ export const JCODE_CAPTURE_EVENTS = ["post_tool", "turn_end"] as const;
13
+ const tomlText = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
14
+ const tomlImage = (value: string, mode: number): ConfigImage => ({ bytes: Buffer.from(value).toString("base64"), mode });
15
+ function hooksTable(text: string): Record<string, unknown> | undefined {
16
+ let document: unknown;
17
+ try { document = Bun.TOML.parse(text); } catch { throw Error("Jcode config.toml does not parse as TOML"); }
18
+ const hooks = (document as Record<string, unknown>).hooks;
19
+ if (hooks === undefined) return undefined;
20
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) throw Error("Jcode [hooks] must be a table");
21
+ return hooks as Record<string, unknown>;
22
+ }
23
+ function commands(value: unknown): string[] {
24
+ if (value === undefined) return [];
25
+ if (typeof value === "string") return value.trim() ? [value] : [];
26
+ if (Array.isArray(value) && value.every(item => typeof item === "string")) return value as string[];
27
+ throw Error("Jcode hook commands must be a string or an array of strings");
28
+ }
29
+ /** Adds `command` to each capture event, keeping any existing user command ahead of it (array form). */
30
+ export function putJcodeHooks(before: ConfigImage, command: string): ConfigImage {
31
+ let text = tomlText(before);
32
+ const hooks = hooksTable(text) ?? {};
33
+ for (const event of JCODE_CAPTURE_EVENTS) {
34
+ const existing = commands(hooks[event]);
35
+ if (existing.includes(command)) continue;
36
+ text = setTomlPath(text, ["hooks", event], existing.length ? [...existing, command] : command);
37
+ }
38
+ return tomlImage(text, before.mode);
39
+ }
40
+ /** amem's command elements, per event, that `after` added over `before` (the receipt proves ownership, not the runtime). */
41
+ function ownedCommands(before: ConfigImage, after: ConfigImage): Map<string, string[]> {
42
+ const b = hooksTable(tomlText(before)) ?? {}, a = hooksTable(tomlText(after)) ?? {}, owned = new Map<string, string[]>();
43
+ for (const event of JCODE_CAPTURE_EVENTS) { const prior = commands(b[event]); owned.set(event, commands(a[event]).filter(item => !prior.includes(item))); }
44
+ return owned;
45
+ }
46
+ export function jcodeHooksManaged(before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
47
+ try { const hooks = hooksTable(tomlText(current)) ?? {}; return [...ownedCommands(before, after)].every(([event, owned]) => owned.every(item => commands(hooks[event]).includes(item))); }
48
+ catch { return false; }
49
+ }
50
+ /** Exact restore when untouched; otherwise removes only amem's command elements, keeping user commands and later edits. */
51
+ export function restoreJcodeHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
52
+ if (current.bytes === after.bytes) return { ...before, mode: current.mode };
53
+ if (current.bytes === before.bytes) return current;
54
+ const original = hooksTable(tomlText(before)) ?? {}, owned = ownedCommands(before, after);
55
+ let text = tomlText(current);
56
+ const hooks = hooksTable(text) ?? {};
57
+ for (const event of JCODE_CAPTURE_EVENTS) {
58
+ const live = commands(hooks[event]), mine = owned.get(event) ?? [];
59
+ if (!live.some(item => mine.includes(item))) continue;
60
+ const kept = live.filter(item => !mine.includes(item)), prior = original[event];
61
+ // Return the user's original spelling when only amem's element was added; otherwise keep the remaining array.
62
+ const value = !kept.length ? (prior !== undefined && !commands(prior).length ? prior : undefined)
63
+ : kept.length === 1 && typeof prior === "string" && prior === kept[0] ? prior : kept;
64
+ text = setTomlPath(text, ["hooks", event], value);
65
+ }
66
+ if (hooksTable(tomlText(before)) === undefined) {
67
+ const left = hooksTable(text);
68
+ if (left !== undefined && !Object.keys(left).length) text = setTomlPath(text, ["hooks"], undefined);
69
+ }
70
+ if (before.bytes === null && !Object.keys(Bun.TOML.parse(text) as object).length) return { ...before, mode: current.mode };
71
+ return tomlImage(text, current.mode);
72
+ }
@@ -1,6 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
3
- import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp } from "./setup-jcode.ts";
3
+ import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp, jcodeHooksManaged, restoreJcodeHooks } from "./setup-jcode.ts";
4
4
  import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
5
5
  import { readOpencodeMcp, putOpencodeMcp, restoreOpencodeMcp } from "./setup-opencode.ts";
6
6
  export interface ConfigImage { bytes: string | null; mode: number }
@@ -119,6 +119,7 @@ export function restoreOwned(target: string, index: number, before: ConfigImage,
119
119
  if (target === "agy" && index === 0) return restoreAgyMcp(before, after, current);
120
120
  if (target === "opencode" && index === 0) return restoreOpencodeMcp(before, after, current);
121
121
  if (target === "jcode" && index === 0) return restoreJcodeMcp(before, after, current);
122
+ if (target === "jcode" && index === 1) return restoreJcodeHooks(before, after, current);
122
123
  if (sameImage(current, after)) return before;
123
124
  if (sameImage(current, before)) return current;
124
125
  // Qwen keeps both managed surfaces in one settings file and one receipt image.
@@ -136,6 +137,7 @@ export function managedAfter(target: string, index: number, before: ConfigImage,
136
137
  return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id));
137
138
  }
138
139
  if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
140
+ if (target === "jcode") return jcodeHooksManaged(before, after, current);
139
141
  if (target === "claude" || target === "codex") {
140
142
  const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));
141
143
  return needed.every(row => live.some(value => value.id === row.id));
@@ -143,3 +145,18 @@ export function managedAfter(target: string, index: number, before: ConfigImage,
143
145
  if (target === "kimi") return added(kimiHooks(before), kimiHooks(after), kimiKey).every(row => kimiHooks(current).some(value => kimiKey(value) === kimiKey(row)));
144
146
  return current.bytes === after.bytes;
145
147
  }
148
+
149
+ /** Read-only receipt diagnostics; use the same hook identity rules as semantic undo. */
150
+ export function managedHookEntries(target: string, before: ConfigImage, after: ConfigImage, current: ConfigImage,
151
+ expected: readonly { event: string; command: string }[]): { entry: string; present: boolean }[] {
152
+ if (target === "kimi") {
153
+ const original = kimiHooks(before), installed = kimiHooks(after), live = kimiHooks(current);
154
+ const ours = new Set(added(original, installed, kimiKey).map(kimiKey));
155
+ return installed.filter(row => ours.has(kimiKey(row)) || expected.some(value => value.event === row.event && value.command === row.command))
156
+ .map(row => ({ entry: `hooks.${String(row.event)}`, present: live.some(value => kimiKey(value) === kimiKey(row)) }));
157
+ }
158
+ const strict = target === "qwen", original = hooks(json(before), strict), installed = hooks(json(after), strict), live = hooks(json(current), strict);
159
+ const ours = new Set(added(original, installed, row => row.id).map(row => row.id));
160
+ return installed.filter(row => ours.has(row.id) || expected.some(value => value.event === row.event && value.command === row.command))
161
+ .map(row => ({ entry: `hooks.${row.event}`, present: live.some(value => value.id === row.id) }));
162
+ }
@@ -0,0 +1,76 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { canonicalPath, realPath } from "./memory/platform.ts";
4
+ import { geminiSettingsPath } from "./setup-gemini.ts";
5
+ import type { SetupTarget } from "./setup.ts";
6
+
7
+ export type SetupEnvironment = Readonly<Record<string, string | undefined>>;
8
+ export interface SetupRedirect {
9
+ target: "claude" | "codex";
10
+ from: string;
11
+ to: string;
12
+ reason: string;
13
+ }
14
+ export interface SetupPaths {
15
+ home: string;
16
+ paths: Record<SetupTarget, string[]>;
17
+ /** Pre-redirect targets, only for validating receipts made by an older setup version. */
18
+ legacyPaths: Record<SetupTarget, string[]>;
19
+ redirects: SetupRedirect[];
20
+ }
21
+
22
+ /** Same fallback as setup inventory; assertOpencodeLayout separately rejects relative XDG_CONFIG_HOME. */
23
+ export function opencodeConfigRoot(env: SetupEnvironment, home: string): string {
24
+ const value = env.XDG_CONFIG_HOME;
25
+ return value === undefined || value === "" || !isAbsolute(value) ? join(home, ".config") : value;
26
+ }
27
+
28
+ /** Path recognition only: no app import, probe, directory creation or config read. */
29
+ function orcaHome(root: string, kind: "claude" | "codex", env: SetupEnvironment, home: string): boolean {
30
+ const userData = canonicalPath(resolve(env.ORCA_USER_DATA_PATH?.trim() || join(home, "Library", "Application Support", "orca")));
31
+ const parts = relative(userData, canonicalPath(root)).split(sep);
32
+ return kind === "claude"
33
+ ? parts.length === 3 && parts[0] === "claude-profiles" && !!parts[1] && parts[2] === "home"
34
+ : parts.length === 3 && parts[0] === "codex-accounts" && !!parts[1] && parts[2] === "home"
35
+ || parts.length === 2 && parts[0] === "codex-runtime-home" && parts[1] === "home";
36
+ }
37
+
38
+ /** Shared setup/doctor resolution. Publication still applies setup's named-directory/link guards. */
39
+ export function resolveSetupPaths(env: SetupEnvironment = process.env, givenHome?: string, workspaceRoot?: string): SetupPaths {
40
+ const home = givenHome ?? realPath(resolve(env.HOME?.trim() || homedir()));
41
+ const root = (key: string, fallback: string) => {
42
+ const setting = env[key];
43
+ const present = key === "JCODE_HOME" ? setting !== undefined : key === "QWEN_HOME" ? !!setting : !!setting?.trim();
44
+ if (!present) return join(home, fallback);
45
+ const expanded = key === "QWEN_HOME" && /^~(?:[/\\]|$)/.test(setting!) ? join(home, ...setting!.slice(2).split(/[/\\]/)) : setting!;
46
+ const given = resolve(expanded), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
47
+ return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
48
+ };
49
+ const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code");
50
+ const pi = root("PI_CODING_AGENT_DIR", ".pi/agent"), qwen = root("QWEN_HOME", ".qwen");
51
+ const paths: SetupPaths["paths"] = {
52
+ claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
53
+ codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
54
+ kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
55
+ pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
56
+ qwen: [join(qwen, "settings.json")],
57
+ // #203: config.toml carries the jcode [hooks] capture entries.
58
+ jcode: [join(root("JCODE_HOME", ".jcode"), "mcp.json"), join(root("JCODE_HOME", ".jcode"), "config.toml")],
59
+ opencode: [join(opencodeConfigRoot(env, home), "opencode", "opencode.json")],
60
+ gemini: [geminiSettingsPath(env, home)],
61
+ agy: workspaceRoot ? [join(workspaceRoot, ".agents", "mcp_config.json")] : [],
62
+ };
63
+ const redirects: SetupRedirect[] = [];
64
+ const legacyPaths = Object.fromEntries(Object.entries(paths).map(([target, files]) => [target, [...files]])) as SetupPaths["paths"];
65
+ const redirect = (target: SetupRedirect["target"], index: number, to: string) => {
66
+ const from = paths[target][index]!;
67
+ paths[target][index] = to;
68
+ redirects.push({ target, from, to, reason: "Orca re-syncs this managed file from the personal source" });
69
+ };
70
+ if (orcaHome(claude, "claude", env, home)) redirect("claude", 1, join(home, ".claude", "settings.json"));
71
+ if (orcaHome(codex, "codex", env, home)) {
72
+ redirect("codex", 0, join(home, ".codex", "config.toml"));
73
+ redirect("codex", 1, join(home, ".codex", "hooks.json"));
74
+ }
75
+ return { home, paths, legacyPaths, redirects };
76
+ }
package/src/setup.ts CHANGED
@@ -3,7 +3,11 @@ import { configuredPolicy } from "./memory/config.ts";
3
3
  import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
4
4
  import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
5
5
  import { opencodeServer } from "./setup-opencode.ts";
6
- import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand, geminiSettingsPath } from "./setup-gemini.ts";
6
+ import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand } from "./setup-gemini.ts";
7
+ import { resolveSetupPaths, opencodeConfigRoot } from "./setup-paths.ts";
8
+ import { JCODE_CAPTURE_EVENTS, putJcodeHooks } from "./setup-jcode.ts";
9
+ /** jcode parses hook command lines shell-style and runs them directly (no shell), so single quotes group each path. */
10
+ const jcodeHookCommand = (bun: string, entry: string) => `${quote(bun)} --smol ${quote(entry)} hook jcode`;
7
11
  import { spawnSync } from "node:child_process";
8
12
  import { createHash, randomUUID } from "node:crypto";
9
13
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
@@ -33,6 +37,8 @@ interface DirectoryIdentity { dev: number; ino: number }
33
37
  interface Receipt { createdDirectory?: DirectoryIdentity; version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
34
38
  export interface SetupContext {
35
39
  env?: Env; bun?: string; entry?: string; stdinTTY?: boolean; cwd?: string;
40
+ /** `--root`: an absolute amem install root to register instead of the running one (#199). Ignored when `entry` is given. */
41
+ root?: string;
36
42
  /** @deprecated Retained for source compatibility; explicit apply/undo modes do not prompt. */
37
43
  confirm?: (plan: SetupPlan) => Promise<boolean>;
38
44
  /** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
@@ -41,7 +47,7 @@ export interface SetupContext {
41
47
  native?: (argv: readonly string[], env: Env, cwd: string) => void;
42
48
  }
43
49
  export interface SetupPlan {
44
- targets: SetupTarget[]; mode: "preview" | "apply" | "undo";
50
+ targets: SetupTarget[]; mode: "preview" | "apply" | "undo"; root?: InstallRoot;
45
51
  actions: { target: SetupTarget; version?: ClientVersion; files: string[]; receipt: string; cwd?: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
46
52
  notes: string[];
47
53
  }
@@ -129,39 +135,48 @@ function workspaceBinding(options: SetupContext): WorkspaceBinding {
129
135
  }
130
136
  function context(options: SetupContext, workspace?: WorkspaceBinding) {
131
137
  const env = options.env ?? process.env, home = realPath(resolve(env.HOME?.trim() || homedir()));
132
- const root = (key: string, fallback: string) => {
133
- const setting = env[key];
134
- const present = key === "JCODE_HOME" ? setting !== undefined : key === "QWEN_HOME" ? !!setting : !!setting?.trim();
135
- if (!present) return join(home, fallback);
136
- const expanded = key === "QWEN_HOME" && /^~(?:[/\\]|$)/.test(setting!) ? join(home, ...setting!.slice(2).split(/[/\\]/)) : setting!;
137
- const given = resolve(expanded), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
138
- // Canonicalize the HOME alias only; named configuration-directory symlinks must still be refused.
139
- return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
140
- };
141
- 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"), qwen = root("QWEN_HOME", ".qwen");
142
- const paths: Record<SetupTarget, string[]> = {
143
- claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
144
- codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
145
- kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
146
- pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
147
- qwen: [join(qwen, "settings.json")],
148
- // Absolute JCODE_HOME spellings stay literal; unset uses the platform-home default.
149
- jcode: [join(root("JCODE_HOME", ".jcode"), "mcp.json")],
150
- // xdg-basedir 5.1.0: XDG_CONFIG_HOME verbatim when set and non-empty, else ~/.config (OpenCode v1.18.35 packages/core/src/global.ts).
151
- opencode: [join(opencodeConfigRoot(env, home), "opencode", "opencode.json")],
152
- // Gemini CLI's own user settings, never Antigravity's <home>/.gemini/config/mcp_config.json.
153
- gemini: [geminiSettingsPath(env, home)],
154
- agy: workspace ? [join(workspace.root, ".agents", "mcp_config.json")] : [],
155
- };
138
+ const { paths, legacyPaths, redirects } = resolveSetupPaths(env, home, workspace?.root);
156
139
  const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
157
- const entry = options.entry ?? fileURLToPath(new URL("../bin/amem.js", import.meta.url));
140
+ const install = options.entry === undefined && options.root !== undefined ? installRoot(options.root) : undefined;
141
+ const entry = options.entry ?? (install ? join(install.path, "bin", "amem.js") : fileURLToPath(new URL("../bin/amem.js", import.meta.url)));
158
142
  if (!bun || !isAbsolute(bun) || !isAbsolute(entry)) throw Error("setup needs a PATH-resolved absolute Bun runtime and an absolute installed entry");
159
143
  // The trusted native fixture may use a temporary package; ordinary CLI installation must outlive bunx's cache.
160
144
  if (!options.native) {
161
145
  const roots = [resolve(tmpdir()), realPath(tmpdir())], entries = [resolve(entry), realPath(entry)];
162
146
  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");
163
147
  }
164
- return { env, home, paths, bun, entry, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
148
+ return { env, home, paths, legacyPaths, redirects, bun, entry, root: install, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
149
+ }
150
+ export interface InstallRoot { path: string; version: string }
151
+ const PACKAGE_JSON_MAX = 64 * 1024;
152
+ /**
153
+ * An amem install root to register, such as `<install>/releases/<version>/` (#199). It is resolved to its real path, so a `current` link
154
+ * registers the release it names, and replacing another install never changes what the registered hooks and MCP server load. The root must
155
+ * hold a regular `package.json` named `@staix/agent-mem` (read on one non-following, non-blocking descriptor, at most 64 KiB) and a regular
156
+ * `bin/amem.js`.
157
+ */
158
+ export function installRoot(root: string): InstallRoot {
159
+ if (!isAbsolute(root)) throw Error("setup --root must be an absolute path");
160
+ const refused = (why: string) => Error(`setup --root ${root}: ${why}`);
161
+ let path: string;
162
+ try { path = realPath(root); } catch { throw refused("not found"); }
163
+ let fd: number;
164
+ try { fd = openSync(join(path, "package.json"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { throw refused("no readable package.json"); }
165
+ let text: string;
166
+ try {
167
+ const stat = fstatSync(fd);
168
+ if (!stat.isFile() || stat.size > PACKAGE_JSON_MAX) throw refused("package.json is not a regular file of at most 64 KiB");
169
+ const data = Buffer.alloc(PACKAGE_JSON_MAX + 1); let size = 0, n: number;
170
+ while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
171
+ if (size > PACKAGE_JSON_MAX) throw refused("package.json is not a regular file of at most 64 KiB");
172
+ text = data.subarray(0, size).toString("utf8");
173
+ } finally { closeSync(fd); }
174
+ let pkg: unknown;
175
+ try { pkg = JSON.parse(text); } catch { throw refused("package.json is not JSON"); }
176
+ const { name, version } = (pkg && typeof pkg === "object" ? pkg : {}) as { name?: unknown; version?: unknown };
177
+ if (name !== "@staix/agent-mem" || typeof version !== "string") throw refused("not an @staix/agent-mem install root");
178
+ if (!lstatSync(join(path, "bin", "amem.js"), { throwIfNoEntry: false })?.isFile()) throw refused("no regular bin/amem.js");
179
+ return { path, version };
165
180
  }
166
181
  function command(target: SetupTarget, bun: string, entry: string): string[] | null {
167
182
  if (target === "kimi" || target === "jcode" || target === "opencode" || target === "agy") return null;
@@ -174,11 +189,6 @@ function command(target: SetupTarget, bun: string, entry: string): string[] | nu
174
189
  * (no trim) and falls back to ~/.config only when it is unset or empty. A relative value would resolve against
175
190
  * OpenCode's own cwd, so setup refuses it rather than guess. An absolute value keeps its literal spelling.
176
191
  */
177
- function opencodeConfigRoot(env: Env, home: string): string {
178
- const value = env.XDG_CONFIG_HOME;
179
- // A relative value is refused in assertOpencodeLayout; the path here only has to be well-formed for inventory.
180
- return value === undefined || value === "" || !isAbsolute(value) ? join(home, ".config") : value;
181
- }
182
192
  function assertJcodeHome(env: Env): void {
183
193
  if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
184
194
  }
@@ -195,14 +205,15 @@ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mo
195
205
  for (const name of ["opencode.jsonc", "config.json"]) if (read(join(dir, name)).bytes !== null) throw Error(`OpenCode setup supports one strict-JSON global opencode.json; ${name} beside it is merged too and is not supported`);
196
206
  if ([c.bun, c.entry].some(value => /\{(?:env|file):/.test(value))) throw Error("OpenCode substitutes {env:} and {file:} in configuration; setup cannot register this runtime exactly");
197
207
  }
198
- function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
208
+ function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "jcode" ? ["jcode-hooks-v1"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
199
209
  function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
200
210
  function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
201
211
  const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
202
212
  const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
203
- 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");
213
+ 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 && !(target === "jcode" && value.changes.length === 1)) throw Error("setup receipt does not match this installation");
204
214
  for (let i = 0; i < value.changes.length; i++) {
205
- const change = record(value.changes[i]); if (change.path !== c.paths[target][i] || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
215
+ const change = record(value.changes[i]); const currentPath = c.paths[target][i], legacyRedirect = c.redirects.some(row => row.target === target && row.from === change.path && row.to === currentPath);
216
+ if (change.path !== currentPath && !legacyRedirect || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
206
217
  for (const key of ["before", "after", ...(change.start !== undefined ? ["start"] : [])]) {
207
218
  const v = record(change[key]);
208
219
  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");
@@ -215,12 +226,13 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
215
226
  return value as unknown as Receipt;
216
227
  }
217
228
  function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
218
- if (target === "jcode" || target === "opencode" || target === "gemini" || target === "agy") return [];
229
+ if (target === "jcode") return JCODE_CAPTURE_EVENTS.map(event => ({ event, command: jcodeHookCommand(c.bun, c.entry), timeout: 1 }));
230
+ if (target === "opencode" || target === "gemini" || target === "agy") return [];
219
231
  const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
220
232
  return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
221
233
  : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}${target === "pi" ? "" : " --json"}`, timeout: target === "pi" ? 2.5 : 3 }];
222
234
  }
223
- const lifecycle = (target: SetupTarget) => target === "jcode" || target === "opencode" || target === "gemini" || target === "agy" ? "MCP only; native capture unavailable" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
235
+ const lifecycle = (target: SetupTarget) => target === "opencode" || target === "gemini" || target === "agy" ? "MCP only; native capture unavailable" : target === "jcode" ? "post_tool and turn_end capture hooks" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
224
236
  export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
225
237
  if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all [--apply|--undo]");
226
238
  const targets: SetupTarget[] = target === "all" ? [...SETUP_ALL_TARGETS] : [target as SetupTarget];
@@ -231,34 +243,55 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
231
243
  if (targets.includes("gemini") && (target === "gemini" || available(c, "gemini", options))) assertGeminiHome(c.env);
232
244
  if (mode !== "undo" && targets.includes("gemini")) assertGeminiRuntime(c.bun, c.entry);
233
245
  const pluginNotes: string[] = [];
246
+ for (const row of c.redirects.filter(row => targets.includes(row.target))) {
247
+ pluginNotes.push(`${row.target}: ${row.from} -> ${row.to}. ${row.reason}; start a new Orca session to receive the change.`);
248
+ }
234
249
  if (targets.includes("claude")) {
235
- const settings = json(read(c.paths.claude[1]!));
250
+ const settingsPath = mode === "undo" ? receipt(c, "claude", false)?.changes[1]?.path ?? c.paths.claude[1]! : c.paths.claude[1]!;
251
+ const settings = json(read(settingsPath));
236
252
  // The settings-derived ids are computed outside the registry guard, so a broken installed_plugins.json cannot hide them.
237
253
  const ids = new Set<string>();
238
254
  try { for (const key of Object.keys(record(settings.enabledPlugins ?? {}))) ids.add(key); } catch { /* Optional plugin metadata must never prevent setup or recovery. */ }
239
- try { for (const key of Object.keys(record(json(read(join(dirname(c.paths.claude[1]!), "plugins", "installed_plugins.json"))).plugins ?? {}))) ids.add(key); } catch { /* Optional plugin metadata must never prevent setup or recovery; authoritative settings/receipts keep their guards. */ }
255
+ try { for (const key of Object.keys(record(json(read(join(dirname(settingsPath), "plugins", "installed_plugins.json"))).plugins ?? {}))) ids.add(key); } catch { /* Optional plugin metadata must never prevent setup or recovery; authoritative settings/receipts keep their guards. */ }
240
256
  for (const id of [...ids].filter(key => /^agent-mem(?:@[A-Za-z0-9._-]+)?$/.test(key))) 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.`);
241
257
  }
242
258
  if (targets.includes("qwen")) pluginNotes.push("Qwen capture supports the verified 0.26.1 native format. For custom advanced.runtimeOutputDir, export the matching QWEN_RUNTIME_DIR for manual and SessionEnd ingest.");
243
- if (targets.includes("jcode")) pluginNotes.push("Jcode setup registers MCP only. Native saved sessions do not prove per-message project scope, so capture and ingest hooks are unavailable; no hook or config.toml is installed.");
259
+ if (targets.includes("jcode")) pluginNotes.push("Jcode setup registers MCP and adds `amem hook jcode` to the post_tool and turn_end lifecycle hooks in JCODE_HOME/config.toml, keeping any existing command for those events (array form). Each event is scoped by its own JCODE_HOOK_CWD; events with no cwd, or outside a project, are skipped. Saved sessions and journals are still not ingested (no per-message origin), and user prompts are not captured.");
244
260
  if (targets.includes("gemini")) pluginNotes.push("Gemini CLI setup registers MCP only, at user scope in ~/.gemini/settings.json (GEMINI_CLI_HOME moves it), through the native gemini mcp add in a private staging HOME. Antigravity's ~/.gemini/config/mcp_config.json is never touched. A workspace .gemini/settings.json defining mcpServers.amem overrides this entry. No hook or ingest is installed: see docs/gemini-cli.md for why transcripts are not ingested.");
245
261
  if (targets.includes("opencode")) pluginNotes.push("OpenCode setup registers MCP only in the global strict-JSON opencode.json (mcp.amem, a local server started in the session's project directory). No plugin or ingest hook is installed: see docs/opencode.md for why transcripts are not ingested. A project opencode.json defining mcp.amem overrides this entry.");
246
262
  if (c.workspace) {
247
263
  pluginNotes.push(`Antigravity MCP is pinned to this repository only: ${c.workspace.root}. Restart the CLI/IDE and use one workspace; no global or plugin definitions are replaced, and same-name definitions elsewhere are unsupported. Native capture is unavailable; no hooks are installed. Only strict JSON is supported; JSONC is refused. Native TUI/IDE presence and live behavior are unverified; availability means this direct configuration operation is supported.`);
248
264
  if (!c.workspace.ignored) pluginNotes.push("WARNING: .agents/mcp_config.json is not Git-ignored; it contains absolute local paths. Setup will refuse it if it becomes tracked.");
249
265
  }
266
+ if (c.root) pluginNotes.push(`Registers the install root ${c.root.path} (amem ${c.root.version}) for hooks and MCP; current registrations change only on --apply.`);
250
267
  if (target === "all") pluginNotes.push("Antigravity is repository-scoped and excluded from all; run amem setup agy explicitly inside its repository.");
251
268
  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.");
252
- return { targets, mode, actions: targets.map(target => {
253
- const prior = receipt(c, target, false); for (const file of c.paths[target]) read(file);
254
- const original = read(c.paths[target][0]!);
269
+ return { targets, mode, ...(c.root ? { root: c.root } : {}), actions: targets.map(target => {
270
+ const prior = receipt(c, target, false), files = mode === "undo" && prior ? prior.changes.map(change => change.path) : c.paths[target];
271
+ for (const file of files) read(file);
272
+ if (mode === "undo" && prior?.changes.some((change, index) => change.path !== c.paths[target][index])) {
273
+ pluginNotes.push(`${target}: undo restores the historical receipt files, not the new personal source: ${prior.changes.map(change => change.path).join(", ")}`);
274
+ }
275
+ const original = read(files[0]!);
255
276
  const document: Record<string, unknown> = target === "agy" ? {} : target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
256
277
  const hasAmem = target === "jcode" || target === "opencode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
278
+ if (!prior && mode !== "undo" && (target === "claude" || target === "codex")) {
279
+ try {
280
+ const server = mcpRecord(target, original);
281
+ const sameBinding = server && typeof server === "object" && !Array.isArray(server)
282
+ && (server as Record<string, unknown>).command === c.bun
283
+ && isDeepStrictEqual((server as Record<string, unknown>).args, ["--smol", c.entry, "mcp"])
284
+ && (server as Record<string, unknown>).enabled !== false;
285
+ const hookFile = json(read(c.paths[target][1]!)), groups = record(hookFile.hooks ?? {});
286
+ const sameHook = plannedHooks(c, target).some(row => Array.isArray(groups[row.event]) && hasHook(groups[row.event] as unknown[], row.command, row.timeout));
287
+ if (sameBinding || sameHook) pluginNotes.push(`${target}: matching amem entries are already present without a setup receipt; apply will not duplicate matching hook commands, and undo preserves the original entries.`);
288
+ } catch { /* Advisory only: preparing the real change retains the existing configuration validation. */ }
289
+ }
257
290
  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; } });
258
291
  const add = command(target, c.bun, c.entry), remove = target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
259
292
  const installed = available(c, target, options), version = installed && target !== "agy" && mode !== "undo" ? probeClientVersion(c, target, options) : undefined;
260
293
  if (version?.matches === false) pluginNotes.push(`WARNING: ${target} version ${version.output}; layout verified on ${version.verified}. Apply remains guarded by native configuration validation.`);
261
- return { target, available: installed, ...(version ? { version } : {}), files: c.paths[target], 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" };
294
+ 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" };
262
295
  }), 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."] };
263
296
  }
264
297
  /** Native probes and registration share one private HOME, cwd and configuration layout. */
@@ -312,12 +345,20 @@ function native(argv: readonly string[], env: Env, cwd: string): void {
312
345
  throw Error(`${argv[0]} MCP registration ${reason} in staging; no native output disclosed`);
313
346
  }
314
347
  }
348
+ function hasHook(rows: readonly unknown[], command: string, timeout: number): boolean {
349
+ return rows.some(row => {
350
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
351
+ const group = record(row);
352
+ return [undefined, "", "*"].some(value => value === group.matcher) && Array.isArray(group.hooks)
353
+ && group.hooks.some((h: unknown) => { const value = record(h); return value.type === "command" && value.command === command && value.timeout === timeout; });
354
+ });
355
+ }
315
356
  function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
316
357
  const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
317
358
  for (const event of events) {
318
359
  const rows = hooks[event] ?? []; if (!Array.isArray(rows)) throw Error("setup hook groups must be arrays");
319
360
  const command = commandFor(event);
320
- 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 }] });
361
+ if (!hasHook(rows, command, timeout)) rows.push({ hooks: [{ type: "command", command, timeout }] });
321
362
  hooks[event] = rows;
322
363
  }
323
364
  document.hooks = hooks; return objectImage(document, before);
@@ -341,7 +382,10 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
341
382
  if (target === "opencode") assertOpencodeLayout(c, "apply");
342
383
  if (target === "gemini") assertGeminiHome(c.env);
343
384
  const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
344
- const restored = prior ? prior.changes.map((change, index) => restoreChange(prior, change, index, expected[index]!)) : expected;
385
+ // Only a same-file before-image can restore a target. A newly authoritative personal source owns its current bytes.
386
+ // Mapped per current path: a pre-#203 jcode receipt covers mcp.json only, so config.toml starts from its live image.
387
+ const restored = c.paths[target].map((path, index) => { const change = prior?.changes[index];
388
+ return prior && change && change.path === path ? restoreChange(prior, change, index, expected[index]!) : expected[index]!; });
345
389
  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 };
346
390
  const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
347
391
  const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
@@ -398,6 +442,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
398
442
  } else if (target === "jcode") {
399
443
  // Shared native pools use the daemon cwd; amem must bind the calling session's project.
400
444
  changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], shared: false });
445
+ changes[1]!.after = putJcodeHooks(changes[1]!.before, jcodeHookCommand(c.bun, c.entry));
401
446
  } else if (target === "opencode") {
402
447
  // `opencode mcp add` is interactive for local servers and has no remove; edit the owned entry directly.
403
448
  changes[0]!.after = putMcpRecord(target, changes[0]!.before, opencodeServer(c.bun, c.entry));