@staix/agent-mem 0.10.0 → 0.12.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
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { isDeepStrictEqual } from "node:util";
11
11
  import { HOOK_EVENTS } from "./hook-contract.ts";
12
12
  import { memoryHome } from "./memory/home.ts";
13
+ import { label, PLAIN, type Style } from "./memory/style.ts";
13
14
  import { canonicalPath, memoryGitEnv, memoryGitSync, processLiveness, processSignature, realPath } from "./memory/platform.ts";
14
15
 
15
16
  export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
@@ -152,7 +153,8 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
152
153
  }
153
154
  function command(target: SetupTarget, bun: string, entry: string): string[] | null {
154
155
  if (target === "kimi" || target === "jcode" || target === "agy") return null;
155
- return [target, "mcp", "add", ...(target === "claude" || target === "qwen" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
156
+ if (target === "qwen") return [target, "mcp", "add", "--scope", "user", "--transport", "stdio", "amem", bun, "--", "--smol", entry, "mcp"];
157
+ return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
156
158
  }
157
159
  function assertJcodeHome(env: Env): void {
158
160
  if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
@@ -227,7 +229,12 @@ function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
227
229
  }
228
230
  function native(argv: readonly string[], env: Env, cwd: string): void {
229
231
  const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
230
- if (result.status !== 0) throw Error(`${argv[0]} MCP registration failed in staging; no native output disclosed`);
232
+ if (result.status !== 0) {
233
+ const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
234
+ const argumentsRejected = argv[0] === "qwen" && `${result.stderr ?? ""}\n${result.stdout ?? ""}`.includes("Not enough non-option arguments:");
235
+ const reason = timedOut ? "timed out" : argumentsRejected ? "rejected command arguments" : "failed";
236
+ throw Error(`${argv[0]} MCP registration ${reason} in staging; no native output disclosed`);
237
+ }
231
238
  }
232
239
  function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
233
240
  const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
@@ -284,16 +291,33 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
284
291
  if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
285
292
  (options.native ?? native)(argv, env, staging);
286
293
  changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
287
- if (changes[0]!.after.bytes === null) throw Error("native registration produced no configuration");
294
+ if (changes[0]!.after.bytes === null) throw Error(`${target} native registration produced no configuration`);
288
295
  const installedText = Buffer.from(changes[0]!.after.bytes!, "base64").toString("utf8");
289
- const installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
290
- const installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
291
- 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");
296
+ let installed: Record<string, unknown>, installedServer: Record<string, unknown>;
297
+ try {
298
+ installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
299
+ installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
300
+ } catch { throw Error(`${target} native registration produced invalid configuration; no configuration published`); }
301
+ if (installedServer.command !== c.bun || JSON.stringify(installedServer.args) !== JSON.stringify(["--smol", c.entry, "mcp"]) || installedServer.enabled === false) throw Error(`${target} native registration did not produce the planned amem server`);
292
302
  const serverKey = target === "codex" ? "mcp_servers" : "mcpServers";
293
303
  const withoutAmem = (value: Record<string, unknown>) => { const copy = { ...value }, servers = { ...record(value[serverKey] ?? {}) }; delete servers.amem; copy[serverKey] = servers; return copy; };
294
- if (!isDeepStrictEqual(withoutAmem(document)[serverKey], withoutAmem(installed)[serverKey])) throw Error("native registration changed unrelated MCP servers; no configuration published");
304
+ const originalUnrelated = withoutAmem(document), installedUnrelated = withoutAmem(installed);
305
+ if (target === "codex") {
306
+ // Restore the omitted default only in this detached comparison, never in native output.
307
+ const originalServers = record(originalUnrelated[serverKey]), installedServers = record(installedUnrelated[serverKey]);
308
+ for (const [name, value] of Object.entries(originalServers)) {
309
+ if (!Object.hasOwn(installedServers, name)) continue;
310
+ const before = record(value), after = installedServers[name];
311
+ if (typeof before.command === "string" && Object.hasOwn(before, "args") && Array.isArray(before.args) && before.args.length === 0
312
+ && after && typeof after === "object" && !Array.isArray(after) && !Object.hasOwn(after, "args")) {
313
+ installedServers[name] = { ...after, args: [] };
314
+ }
315
+ }
316
+ }
317
+ if (!isDeepStrictEqual(originalUnrelated[serverKey], installedUnrelated[serverKey])) throw Error(`${target} native registration changed unrelated MCP servers; no configuration published`);
295
318
  if (target === "codex") {
296
- if (!isDeepStrictEqual(withoutAmem(document), withoutAmem(installed))) throw Error("native registration changed unrelated settings; no configuration published");
319
+ if (!isDeepStrictEqual(originalUnrelated, installedUnrelated)) throw Error("codex native registration changed unrelated settings; no configuration published");
320
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, installedServer);
297
321
  } else {
298
322
  // Keep all unrelated JSON settings from the original; use only the CLI-generated server record.
299
323
  // Qwen's restored image also retires its previous managed hook on a runtime upgrade.
@@ -425,16 +449,54 @@ export function undoSetup(plan: SetupPlan, options: SetupContext = {}): { restor
425
449
  return withSetupLock(options, () => undoUnlocked(plan, options));
426
450
  }
427
451
 
428
- export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext = {}): Promise<string> {
429
- const plan = planSetup(target, mode, options);
430
- if (mode === "preview") return JSON.stringify(plan, null, 2);
452
+ /** Person-facing setup plan (#125): each target once, with its effective files, MCP and hook changes, then skipped clients and notes. `--json` keeps the raw plan. */
453
+ export function setupPlanText(plan: SetupPlan, s: Style = PLAIN): string {
454
+ const verb = plan.mode === "undo" ? "undo" : plan.mode === "apply" ? "apply" : "preview";
455
+ // Undo restores every target with a receipt whether or not its CLI is still installed (undoUnlocked), so undo never collapses an action.
456
+ const full = (action: SetupPlan["actions"][number]) => plan.mode === "undo" || action.available;
457
+ const skipped = plan.actions.filter(action => !full(action)), changing = plan.actions.filter(action => full(action) && action.mcpChange !== "unchanged");
458
+ const lines = [`${s.bold(`amem setup ${verb}:`)} ${plan.targets.length} target${plan.targets.length === 1 ? "" : "s"}, ${changing.length} with ${plan.mode === "undo" ? "restores" : "MCP changes"}${skipped.length ? `, ${skipped.length} skipped` : ""}`, ""];
459
+ for (const action of plan.actions) {
460
+ if (!full(action)) { lines.push(`${label(s, "skipped")} ${s.bold(action.target)}: CLI not installed`); continue; }
461
+ const state = action.mcpChange === "unchanged" ? "ok" : "attention";
462
+ const what = action.mcpChange === "unchanged" ? plan.mode === "undo" ? "no setup receipt; nothing to restore" : "already set up; no change" : action.mcpChange === "restore" ? `restore original MCP/hook files${action.available ? "" : " (CLI not installed)"}` : `MCP ${action.mcpChange}`;
463
+ lines.push(`${label(s, state)} ${s.bold(action.target)}: ${what} ${s.dim(`(${action.state})`)}`);
464
+ for (const file of action.files) lines.push(` file ${file}`);
465
+ if (action.cwd) lines.push(` cwd ${action.cwd}`);
466
+ lines.push(` receipt ${action.receipt}`);
467
+ if (action.available && action.registration) lines.push(` command ${action.registration.map(quote).join(" ")}`);
468
+ for (const hook of action.hooks) lines.push(` hook ${hook.event}: ${hook.command} (timeout ${hook.timeout})`);
469
+ if (action.available && !action.hooks.length && plan.mode !== "undo") lines.push(` hook ${s.dim("none")}`);
470
+ lines.push(` ${s.dim(action.lifecycle)}`);
471
+ }
472
+ const warnings = plan.notes.filter(note => /^WARNING|detected|version-specific/.test(note)), notes = plan.notes.filter(note => !warnings.includes(note));
473
+ if (skipped.length) lines.push("", `Skipped clients: ${skipped.map(action => action.target).join(", ")} (not installed)`);
474
+ if (warnings.length) lines.push("", "Warnings:", ...warnings.map(note => ` - ${s.attention(note)}`));
475
+ lines.push("", "Notes:", ...notes.map(note => ` - ${note}`));
476
+ if (plan.mode === "preview") lines.push("", `Next: amem setup ${plan.targets.length === 1 ? plan.targets[0] : "all"} --apply (asks before writing); --json prints the full plan`);
477
+ return lines.join("\n");
478
+ }
479
+
480
+ function setupResultText(result: SetupApplyResult | { restored: SetupTarget[] }, s: Style): string {
481
+ if ("restored" in result) return `${label(s, "ok")} restored: ${result.restored.join(", ") || "none"}`;
482
+ return [`${label(s, "ok")} applied: ${result.applied.join(", ") || "none"}`, `${label(s, "ok")} unchanged: ${result.unchanged.join(", ") || "none"}`,
483
+ ...(result.skipped ?? []).map(item => `${label(s, "skipped")} ${item.target}: ${item.reason}`)].join("\n");
484
+ }
485
+
486
+ /** What the default confirmation prints before its prompt: the raw plan under `--json`, the readable plan otherwise. */
487
+ export function setupConfirmationText(plan: SetupPlan, json: boolean, s: Style = PLAIN): string { return json ? JSON.stringify(plan, null, 2) : setupPlanText(plan, s); }
488
+
489
+ export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext & { json?: boolean; style?: Style } = {}): Promise<string> {
490
+ const plan = planSetup(target, mode, options), s = options.json ? PLAIN : options.style ?? PLAIN;
491
+ if (mode === "preview") return options.json ? JSON.stringify(plan, null, 2) : setupPlanText(plan, s);
431
492
  if (!(options.stdinTTY ?? process.stdin.isTTY)) throw Error("setup --apply/--undo require a terminal on stdin");
432
493
  const confirm = options.confirm ?? (async (shown: SetupPlan) => {
433
- process.stdout.write(JSON.stringify(shown, null, 2) + "\n");
494
+ process.stdout.write(setupConfirmationText(shown, options.json === true, s) + "\n\n");
434
495
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
435
496
  try { return (await terminal.question(`Type yes to ${mode} the listed configuration changes: `)).trim() === "yes"; }
436
497
  finally { terminal.close(); }
437
498
  });
438
- if (!await confirm(plan)) return JSON.stringify({ cancelled: true });
439
- return JSON.stringify(mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options), null, 2);
499
+ if (!await confirm(plan)) return options.json ? JSON.stringify({ cancelled: true }) : "setup cancelled; nothing changed";
500
+ const result = mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options);
501
+ return options.json ? JSON.stringify(result, null, 2) : setupResultText(result, s);
440
502
  }