@staix/agent-mem 0.14.0 → 0.16.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/src/setup.ts CHANGED
@@ -2,6 +2,8 @@ import { cliAttribution } from "./memory/cli-attribution.ts";
2
2
  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
+ import { opencodeServer } from "./setup-opencode.ts";
6
+ import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand, geminiSettingsPath } from "./setup-gemini.ts";
5
7
  import { spawnSync } from "node:child_process";
6
8
  import { createHash, randomUUID } from "node:crypto";
7
9
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
@@ -14,13 +16,13 @@ import { memoryHome } from "./memory/home.ts";
14
16
  import { label, PLAIN, singleLine, type Style } from "./memory/style.ts";
15
17
  import { canonicalPath, memoryGitEnv, memoryGitSync, processLiveness, processSignature, realPath } from "./memory/platform.ts";
16
18
 
17
- export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
19
+ export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy"] as const;
18
20
  /** Workspace-scoped Antigravity setup is always selected explicitly. */
19
21
  export const SETUP_ALL_TARGETS = SETUP_TARGETS.filter(target => target !== "agy");
20
22
  export type SetupTarget = typeof SETUP_TARGETS[number];
21
23
  // Only recorded layout verification versions belong here; null means no version was recorded.
22
24
  const SETUP_VERIFIED_VERSIONS: Readonly<Record<SetupTarget, string | null>> = {
23
- claude: null, codex: "0.165.0", kimi: null, pi: null, qwen: "0.26.1", jcode: "0.99.1", agy: null,
25
+ claude: null, codex: "0.165.0", kimi: null, pi: null, qwen: "0.26.1", jcode: "0.99.1", opencode: "1.18.35", gemini: "0.61.0", agy: null,
24
26
  };
25
27
  interface ClientVersion { output: string | null; verified: string | null; matches: boolean | null }
26
28
  type Env = Readonly<Record<string, string | undefined>>;
@@ -145,6 +147,10 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
145
147
  qwen: [join(qwen, "settings.json")],
146
148
  // Absolute JCODE_HOME spellings stay literal; unset uses the platform-home default.
147
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)],
148
154
  agy: workspace ? [join(workspace.root, ".agents", "mcp_config.json")] : [],
149
155
  };
150
156
  const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
@@ -158,10 +164,21 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
158
164
  return { env, home, paths, bun, entry, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
159
165
  }
160
166
  function command(target: SetupTarget, bun: string, entry: string): string[] | null {
161
- if (target === "kimi" || target === "jcode" || target === "agy") return null;
167
+ if (target === "kimi" || target === "jcode" || target === "opencode" || target === "agy") return null;
162
168
  if (target === "qwen") return [target, "mcp", "add", "--scope", "user", "--transport", "stdio", "amem", bun, "--", "--smol", entry, "mcp"];
169
+ if (target === "gemini") return geminiAddCommand(bun, entry);
163
170
  return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
164
171
  }
172
+ /**
173
+ * OpenCode 1.18.35 resolves its config root with xdg-basedir 5.1.0, which uses XDG_CONFIG_HOME verbatim when set
174
+ * (no trim) and falls back to ~/.config only when it is unset or empty. A relative value would resolve against
175
+ * OpenCode's own cwd, so setup refuses it rather than guess. An absolute value keeps its literal spelling.
176
+ */
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
+ }
165
182
  function assertJcodeHome(env: Env): void {
166
183
  if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
167
184
  }
@@ -169,6 +186,15 @@ function assertJcodeRuntime(c: ReturnType<typeof context>): void {
169
186
  assertJcodeHome(c.env);
170
187
  if ([c.bun, c.entry].some(value => value.includes("${"))) throw Error("Jcode interpolates environment placeholders in MCP command paths; setup cannot register this runtime exactly");
171
188
  }
189
+ /** OpenCode merges config.json, opencode.json, then opencode.jsonc from its global directory, and env routes can replace it. */
190
+ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mode"]): void {
191
+ const xdg = c.env.XDG_CONFIG_HOME;
192
+ if (xdg !== undefined && xdg !== "" && !isAbsolute(xdg)) throw Error("OpenCode setup requires an absolute XDG_CONFIG_HOME; unset it to use ~/.config");
193
+ if (mode !== "undo") for (const key of ["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG_CONTENT"]) if (c.env[key]?.trim()) throw Error(`OpenCode setup refuses ${key}: it routes configuration away from the global file this setup owns`);
194
+ const dir = dirname(c.paths.opencode[0]!);
195
+ 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
+ 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
+ }
172
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] : [])]); }
173
199
  function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
174
200
  function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
@@ -189,18 +215,21 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
189
215
  return value as unknown as Receipt;
190
216
  }
191
217
  function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
192
- if (target === "jcode" || target === "agy") return [];
218
+ if (target === "jcode" || target === "opencode" || target === "gemini" || target === "agy") return [];
193
219
  const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
194
220
  return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
195
221
  : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}${target === "pi" ? "" : " --json"}`, timeout: target === "pi" ? 2.5 : 3 }];
196
222
  }
197
- const lifecycle = (target: SetupTarget) => target === "jcode" || target === "agy" ? "MCP only; native capture unavailable" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
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";
198
224
  export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
199
- if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|agy|all [--apply|--undo]");
225
+ 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]");
200
226
  const targets: SetupTarget[] = target === "all" ? [...SETUP_ALL_TARGETS] : [target as SetupTarget];
201
227
  if (targets.includes("jcode")) assertJcodeHome(options.env ?? process.env);
202
228
  const c = context(options, targets.includes("agy") ? workspaceBinding(options) : undefined);
203
229
  if (mode !== "undo" && targets.includes("jcode")) assertJcodeRuntime(c);
230
+ if (targets.includes("opencode") && (target === "opencode" || available(c, "opencode", options))) assertOpencodeLayout(c, mode);
231
+ if (targets.includes("gemini") && (target === "gemini" || available(c, "gemini", options))) assertGeminiHome(c.env);
232
+ if (mode !== "undo" && targets.includes("gemini")) assertGeminiRuntime(c.bun, c.entry);
204
233
  const pluginNotes: string[] = [];
205
234
  if (targets.includes("claude")) {
206
235
  const settings = json(read(c.paths.claude[1]!));
@@ -212,6 +241,8 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
212
241
  }
213
242
  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.");
214
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.");
244
+ 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
+ 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.");
215
246
  if (c.workspace) {
216
247
  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.`);
217
248
  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.");
@@ -222,9 +253,9 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
222
253
  const prior = receipt(c, target, false); for (const file of c.paths[target]) read(file);
223
254
  const original = read(c.paths[target][0]!);
224
255
  const document: Record<string, unknown> = target === "agy" ? {} : target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
225
- const hasAmem = target === "jcode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
256
+ const hasAmem = target === "jcode" || target === "opencode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
226
257
  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; } });
227
- const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
258
+ const add = command(target, c.bun, c.entry), remove = target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
228
259
  const installed = available(c, target, options), version = installed && target !== "agy" && mode !== "undo" ? probeClientVersion(c, target, options) : undefined;
229
260
  if (version?.matches === false) pluginNotes.push(`WARNING: ${target} version ${version.output}; layout verified on ${version.verified}. Apply remains guarded by native configuration validation.`);
230
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" };
@@ -237,6 +268,10 @@ function stagingEnvironment(c: ReturnType<typeof context>, target: SetupTarget,
237
268
  env.QWEN_RUNTIME_DIR = join(staging, "qwen-runtime");
238
269
  env.QWEN_CODE_SYSTEM_SETTINGS_PATH = join(staging, "qwen-system-settings.json");
239
270
  env.QWEN_CODE_SYSTEM_DEFAULTS_PATH = join(staging, "qwen-system-defaults.json");
271
+ // Gemini CLI: GEMINI_CLI_HOME overrides HOME for all its state; system settings paths are isolated too.
272
+ env.GEMINI_CLI_HOME = staging;
273
+ env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = join(staging, "gemini-system-settings.json");
274
+ env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH = join(staging, "gemini-system-defaults.json");
240
275
  mkdirSync(env.TMPDIR!, { mode: 0o700 });
241
276
  if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
242
277
  // The staging workspace is a trusted private binding, not a Git repository or the real caller cwd.
@@ -266,7 +301,7 @@ function probeClientVersion(c: ReturnType<typeof context>, target: SetupTarget,
266
301
  function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
267
302
  // In older free-form Pi CLIs, an unknown subcommand can be treated as a model prompt.
268
303
  const help = spawnSync(target, ["--help"], { cwd, env: { ...env }, encoding: "utf8", timeout: 5000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
269
- if (help.status !== 0 || !/^\s+(?:(?:pi|qwen)\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
304
+ if (help.status !== 0 || !/^\s+(?:(?:pi|qwen|gemini)\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
270
305
  }
271
306
  function native(argv: readonly string[], env: Env, cwd: string): void {
272
307
  const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
@@ -303,6 +338,8 @@ function restoreChange(value: Receipt, change: Change, index: number, live: Imag
303
338
  }
304
339
  function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): Prepared {
305
340
  if (target === "jcode") assertJcodeRuntime(c);
341
+ if (target === "opencode") assertOpencodeLayout(c, "apply");
342
+ if (target === "gemini") assertGeminiHome(c.env);
306
343
  const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
307
344
  const restored = prior ? prior.changes.map((change, index) => restoreChange(prior, change, index, expected[index]!)) : expected;
308
345
  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 };
@@ -319,7 +356,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
319
356
  const text = expected[0]!.bytes === null ? "" : Buffer.from(expected[0]!.bytes!, "base64").toString("utf8");
320
357
  const document = target === "codex" ? toml(text) : json(expected[0]!);
321
358
  const servers = document[target === "codex" ? "mcp_servers" : "mcpServers"];
322
- if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
359
+ if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)(target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
323
360
  (options.native ?? native)(argv, env, staging);
324
361
  changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
325
362
  if (changes[0]!.after.bytes === null) throw Error(`${target} native registration produced no configuration`);
@@ -361,6 +398,9 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
361
398
  } else if (target === "jcode") {
362
399
  // Shared native pools use the daemon cwd; amem must bind the calling session's project.
363
400
  changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], shared: false });
401
+ } else if (target === "opencode") {
402
+ // `opencode mcp add` is interactive for local servers and has no remove; edit the owned entry directly.
403
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, opencodeServer(c.bun, c.entry));
364
404
  } else {
365
405
  const document = json(changes[0]!.before), servers = document.mcpServers === undefined ? {} : record(document.mcpServers);
366
406
  servers.amem = { command: c.bun, args: ["--smol", c.entry, "mcp"] }; document.mcpServers = servers;