forget-connect 0.5.1 → 0.6.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/cli.js CHANGED
@@ -20,16 +20,38 @@ import {
20
20
  validateScopeId,
21
21
  validateApiKey,
22
22
  } from "./core.js";
23
- import { doctorRemote } from "./doctor.js";
23
+ import { CODEX_REQUIRED_TOOLS, doctorRemote } from "./doctor.js";
24
24
  import {
25
+ commandsDirFor,
25
26
  connectHooksSettings,
26
27
  disconnectHooksSettings,
27
28
  hooksDirFor,
29
+ hooksInstalled,
28
30
  inspectHooks,
31
+ installCommandAssets,
29
32
  installHookScripts,
33
+ removeCommandAssets,
30
34
  removeHookScripts,
31
35
  settingsPathFor,
32
36
  } from "./hooks.js";
37
+ import {
38
+ PROXY_BASE_URL,
39
+ PROXY_LABEL,
40
+ WATCHDOG_LABEL,
41
+ findExecutable,
42
+ installProxyServices,
43
+ readWiringState,
44
+ removeProxyServices,
45
+ unwireProxyEnv,
46
+ wireProxyEnv,
47
+ writeWiringState,
48
+ } from "./proxy.js";
49
+ import {
50
+ inspectMemoryAgentSkill,
51
+ installMemoryAgentSkill,
52
+ removeMemoryAgentSkill,
53
+ skillDirFor,
54
+ } from "./skills.js";
33
55
 
34
56
  const CLIENT_IDS = new Set(["claude-code", "codex", "claude-desktop"]);
35
57
 
@@ -52,13 +74,19 @@ Options:
52
74
 
53
75
  Scope:
54
76
  Local connections default to one canonical scoped endpoint (all clients share it):
55
- /mcp/<client>/http/<os-username>. This keeps each user's and client's
56
- memories isolated. Override with --user-id/--app-id, or opt out with
57
- --no-scope. An explicit --url is installed verbatim.
77
+ /mcp/forget/http/<os-username>. This keeps each user's vault isolated while
78
+ Codex and Claude retain distinct provider profiles and provenance. Override
79
+ with --user-id/--app-id, or opt out with --no-scope. An explicit --url is
80
+ installed verbatim.
58
81
  --no-auth Connect without a Bearer token
82
+ --local-auth Send FORGET_API_KEY to an explicit loopback server
83
+ (required by credential-bound Memory Agent tools)
59
84
  --no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
60
- --no-hooks Do not install Claude Code memory hooks (session capsule,
61
- per-turn recall, conflict alerts, session capture)
85
+ --no-skill Do not install the shared Memory Agent skill
86
+ --no-hooks Do not install Codex/Claude Code memory hooks (session
87
+ capsule, per-turn recall, session capture)
88
+ --no-proxy Do not wire the local capture proxy (launchd service +
89
+ ANTHROPIC_BASE_URL override + health watchdog; macOS only)
62
90
  --no-migrate-enacta Keep matching legacy config and rules blocks
63
91
  --dry-run Show the files that would change without writing them
64
92
  --timeout <seconds> Doctor network timeout (default: 10)
@@ -70,6 +98,7 @@ Scope:
70
98
  Authentication:
71
99
  The default local server needs no token. For --hosted (legacy) set
72
100
  FORGET_API_KEY, or run interactively and paste the key when prompted.
101
+ Credential-bound local tools require FORGET_API_KEY plus --local-auth.
73
102
  Keys are intentionally not accepted as command-line arguments.
74
103
 
75
104
  Examples:
@@ -122,8 +151,11 @@ export function parseArgs(argv, env = process.env) {
122
151
  scope: null,
123
152
  defaultScope: null,
124
153
  auth: true,
154
+ localAuth: false,
125
155
  rules: true,
156
+ skills: true,
126
157
  hooks: true,
158
+ proxy: true,
127
159
  migrateLegacy: true,
128
160
  dryRun: false,
129
161
  yes: false,
@@ -167,10 +199,16 @@ export function parseArgs(argv, env = process.env) {
167
199
  options.noScope = true;
168
200
  } else if (arg === "--no-auth") {
169
201
  options.auth = false;
202
+ } else if (arg === "--local-auth") {
203
+ options.localAuth = true;
170
204
  } else if (arg === "--no-rules") {
171
205
  options.rules = false;
206
+ } else if (arg === "--no-skill") {
207
+ options.skills = false;
172
208
  } else if (arg === "--no-hooks") {
173
209
  options.hooks = false;
210
+ } else if (arg === "--no-proxy") {
211
+ options.proxy = false;
174
212
  } else if (arg === "--no-migrate-enacta") {
175
213
  options.migrateLegacy = false;
176
214
  } else if (arg === "--dry-run") {
@@ -208,6 +246,12 @@ export function parseArgs(argv, env = process.env) {
208
246
  }
209
247
  options.baseUrl = normalizeUrl(options.url);
210
248
  options.hosted = isHostedBaseUrl(options.baseUrl);
249
+ if (options.localAuth && !options.auth) {
250
+ throw new ConfigError("--local-auth and --no-auth are mutually exclusive.");
251
+ }
252
+ if (options.localAuth && !isLoopbackUrl(options.baseUrl)) {
253
+ throw new ConfigError("--local-auth is only allowed for a loopback MCP URL.");
254
+ }
211
255
  if (Boolean(options.userId) !== Boolean(options.appId)) {
212
256
  throw new ConfigError("--user-id and --app-id must be provided together.");
213
257
  }
@@ -223,12 +267,10 @@ export function parseArgs(argv, env = process.env) {
223
267
  options.url = options.scope
224
268
  ? scopedMcpUrl(options.baseUrl, options.scope)
225
269
  : options.baseUrl;
226
- // Cold-install default: scope each client into its own memory pool at
227
- // /mcp/<client>/http/<os-username>. The unscoped /mcp endpoint pools every
228
- // client's memories into the server's fallback scope (cold-install audit
229
- // 2026-07-29), so plain /mcp is now the opt-in (--no-scope), not the
230
- // default. An explicit --url stays verbatim, and hosted keeps requiring an
231
- // explicit account identity — the OS username is not a hosted account.
270
+ // Cold-install default: all providers use one personal vault at
271
+ // /mcp/forget/http/<os-username>; the query profile keeps their tool surface
272
+ // and provenance separate. The unscoped /mcp endpoint pools data into the
273
+ // server fallback, so it remains an explicit opt-in (--no-scope).
232
274
  options.defaultScope = null;
233
275
  if (!options.scope && !options.noScope && !options.hosted && !options.urlExplicit) {
234
276
  const osUser = defaultScopeUserId(env);
@@ -256,17 +298,23 @@ function defaultScopeUserId(env) {
256
298
  }
257
299
 
258
300
  export function urlForClient(options, client) {
259
- if (options.scope) return options.url;
301
+ let resolved = options.url;
302
+ if (options.scope) resolved = options.url;
260
303
  if (options.defaultScope && client) {
261
304
  // Every client shares the canonical pool; which tool wrote a memory is
262
305
  // provenance, not a scope boundary (issue #27). A per-client pool made
263
306
  // Codex writes invisible to Claude and vice versa.
264
- return scopedMcpUrl(options.baseUrl, {
307
+ resolved = scopedMcpUrl(options.baseUrl, {
265
308
  userId: options.defaultScope.userId,
266
309
  appId: CANONICAL_APP_ID,
267
310
  });
268
311
  }
269
- return options.url;
312
+ if (client?.id === "codex" || client?.id === "claude-code") {
313
+ const parsed = new URL(normalizeUrl(resolved));
314
+ parsed.searchParams.set("profile", client.id === "codex" ? "codex" : "claude");
315
+ return parsed.toString().replace(/\/$/, "");
316
+ }
317
+ return resolved;
270
318
  }
271
319
 
272
320
  function requestedClientIds(values) {
@@ -386,12 +434,16 @@ async function apiKeyFor(options, env) {
386
434
  "Bearer authentication requires HTTPS. Use --no-auth for a local HTTP server.",
387
435
  );
388
436
  }
389
- // A leftover hosted key must not block the local-first default flow, and
390
- // a loopback target never puts the secret on the wire anyway.
437
+ if (options.localAuth) return validateApiKey(fromEnv);
438
+ // A leftover hosted key must not silently become a local credential.
439
+ // Credential-bound local features opt in with --local-auth.
391
440
  stderr.write(
392
441
  "Note: FORGET_API_KEY is set but the target is a loopback HTTP server; connecting without a token.\n",
393
442
  );
394
443
  }
444
+ if (options.localAuth) {
445
+ throw new ConfigError("Set FORGET_API_KEY when using --local-auth.");
446
+ }
395
447
  if (!options.hosted) return "";
396
448
  const prompted = (await promptHidden("Paste your Forget API key: ")).trim();
397
449
  if (!prompted) {
@@ -442,7 +494,13 @@ function printDoctor(result) {
442
494
  const hooksDetail = result.hooks.registered
443
495
  ? `registered, scripts ${result.hooks.scripts_present ? "present" : "missing"}, python3 ${result.hooks.python3 ? "ok" : "missing"}`
444
496
  : "not installed (use connect without --no-hooks)";
445
- stdout.write(`${result.hooks.ok ? "✓" : "✗"} Hooks: ${hooksDetail}\n`);
497
+ stdout.write(`${result.hooks.ok ? "✓" : "✗"} Claude Code hooks: ${hooksDetail}\n`);
498
+ }
499
+ if (result.codex_hooks) {
500
+ const hooksDetail = result.codex_hooks.registered
501
+ ? `registered, scripts ${result.codex_hooks.scripts_present ? "present" : "missing"}, python3 ${result.codex_hooks.python3 ? "ok" : "missing"}`
502
+ : "not installed (use connect without --no-hooks)";
503
+ stdout.write(`${result.codex_hooks.ok ? "✓" : "✗"} Codex hooks: ${hooksDetail}\n`);
446
504
  }
447
505
  for (const client of result.clients) {
448
506
  const details = [
@@ -521,7 +579,13 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
521
579
  }
522
580
  const installed = scopeFromUrl(configuredServerUrl(client, raw));
523
581
  if (!installed) continue;
524
- urlOverrides.set(client.id, scopedMcpUrl(installed.baseUrl, installed));
582
+ const installedScopedUrl = scopedMcpUrl(installed.baseUrl, installed);
583
+ urlOverrides.set(client.id, urlForClient({
584
+ ...options,
585
+ url: installedScopedUrl,
586
+ scope: installed,
587
+ defaultScope: null,
588
+ }, client));
525
589
  if (!first) first = installed;
526
590
  }
527
591
  if (first) {
@@ -552,6 +616,7 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
552
616
  clientVersion: await version(),
553
617
  expectedScope: probeScope,
554
618
  requireScope: options.hosted,
619
+ ...(clients[0]?.id === "codex" ? { requiredTools: CODEX_REQUIRED_TOOLS } : {}),
555
620
  })
556
621
  : {
557
622
  ok: false,
@@ -572,14 +637,26 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
572
637
  },
573
638
  };
574
639
  const hooksStatus = clients.some((client) => client.id === "claude-code")
575
- ? await inspectHooks({ env })
640
+ ? await inspectHooks({ env, clientId: "claude-code" })
641
+ : null;
642
+ const codexHooksStatus = clients.some((client) => client.id === "codex")
643
+ ? await inspectHooks({ env, clientId: "codex" })
576
644
  : null;
645
+ const skillStatus = await Promise.all(
646
+ clients
647
+ .filter((client) => ["claude-code", "codex"].includes(client.id))
648
+ .map(async (client) => ({ client: client.id, ...await inspectMemoryAgentSkill(client.id, { env }) })),
649
+ );
577
650
  const result = {
578
- ok: local.every((client) => client.ok) && remote.ok && (hooksStatus?.ok ?? true),
651
+ ok: local.every((client) => client.ok) && remote.ok
652
+ && (hooksStatus?.ok ?? true) && (codexHooksStatus?.ok ?? true)
653
+ && (!options.skills || skillStatus.every((skill) => skill.installed && skill.current)),
579
654
  url: redactUrlForDisplay(probeUrl),
580
655
  scope: { configured: Boolean(probeScope) },
581
656
  clients: local,
582
657
  hooks: hooksStatus,
658
+ codex_hooks: codexHooksStatus,
659
+ skills: skillStatus,
583
660
  remote,
584
661
  };
585
662
  if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
@@ -595,12 +672,15 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
595
672
  migrateLegacy: options.migrateLegacy,
596
673
  legacyUrls: [...new Set([options.url, options.baseUrl, HOSTED_MCP_URL])],
597
674
  });
675
+ const skillClients = clients.filter((client) => ["claude-code", "codex"].includes(client.id));
676
+ const manageSkills = skillClients.length > 0
677
+ && (options.action === "disconnect" || options.skills);
598
678
 
599
- // Claude Code is the only client with a hook system today; the hooks are
600
- // what make memory arrive without being asked. Disconnect always cleans
601
- // them up, even when the install used --no-hooks.
679
+ // Codex and Claude Code use different lifecycle event sets but share the
680
+ // same fail-open scripts. Disconnect removes only our entries.
602
681
  const claudeCode = clients.find((client) => client.id === "claude-code");
603
- let manageHooks = Boolean(claudeCode)
682
+ const hookClients = clients.filter((client) => ["claude-code", "codex"].includes(client.id));
683
+ let manageHooks = hookClients.length > 0
604
684
  && (options.action === "disconnect" || options.hooks);
605
685
  if (manageHooks && process.platform === "win32" && options.action === "connect") {
606
686
  // The hook commands are POSIX shell strings and the scripts need
@@ -608,33 +688,112 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
608
688
  stderr.write("Note: memory hooks are not yet supported on Windows; skipping hook install.\n");
609
689
  manageHooks = false;
610
690
  }
691
+ // The capture proxy (zero-config memory capture) rides the same
692
+ // settings.json: env.ANTHROPIC_BASE_URL points Claude Code at the local
693
+ // forget-proxy. launchd-only for now, so connect wires it on macOS;
694
+ // disconnect always unwires — a no-op wherever connect never ran.
695
+ let manageProxy = Boolean(claudeCode)
696
+ && (options.action === "disconnect" || options.proxy);
697
+ if (manageProxy && options.action === "connect" && process.platform !== "darwin") {
698
+ stderr.write("Note: the capture proxy service requires launchd (macOS); skipping proxy wiring.\n");
699
+ manageProxy = false;
700
+ }
701
+ if (manageProxy && options.action === "connect" && !options.dryRun
702
+ && !(await findExecutable("forget-proxy", { env }))) {
703
+ // Checked before any file changes: an env override pointing at a proxy
704
+ // that cannot start would break the user's Claude, not just capture.
705
+ stderr.write("Note: forget-proxy not found on PATH; skipping proxy wiring (pip install 'forget-ai[server]').\n");
706
+ manageProxy = false;
707
+ }
708
+
611
709
  const hooksDir = manageHooks ? hooksDirFor({ env }) : "";
710
+ const hookSettings = new Map();
711
+ let proxyPlan = null;
612
712
  if (manageHooks) {
613
- const settingsPath = settingsPathFor({ env });
614
- let settingsRaw = "";
615
- try {
616
- settingsRaw = await readFile(settingsPath, "utf8");
617
- } catch (error) {
618
- if (!error || error.code !== "ENOENT") throw error;
713
+ for (const client of hookClients) {
714
+ const settingsPath = settingsPathFor({ env, clientId: client.id });
715
+ let settingsRaw = "";
716
+ try {
717
+ settingsRaw = await readFile(settingsPath, "utf8");
718
+ } catch (error) {
719
+ if (!error || error.code !== "ENOENT") throw error;
720
+ }
721
+ const settingsNext = options.action === "connect"
722
+ ? connectHooksSettings(settingsRaw, {
723
+ hooksDir,
724
+ url: urlFor(client),
725
+ clientId: client.id,
726
+ })
727
+ : disconnectHooksSettings(settingsRaw, { clientId: client.id });
728
+ hookSettings.set(client.id, { client, settingsPath, settingsRaw, settingsNext });
729
+ }
730
+ }
731
+ if (manageProxy) {
732
+ const settingsPath = settingsPathFor({ env, clientId: "claude-code" });
733
+ let record = hookSettings.get("claude-code");
734
+ if (!record) {
735
+ let settingsRaw = "";
736
+ try {
737
+ settingsRaw = await readFile(settingsPath, "utf8");
738
+ } catch (error) {
739
+ if (!error || error.code !== "ENOENT") throw error;
740
+ }
741
+ record = { client: claudeCode, settingsPath, settingsRaw, settingsNext: settingsRaw };
742
+ }
743
+ let settingsNext = record.settingsNext;
744
+ if (manageProxy && options.action === "connect") {
745
+ proxyPlan = wireProxyEnv(settingsNext);
746
+ if (proxyPlan.status === "skip") {
747
+ // (c) an odd configuration skips the whole wiring, loudly — being
748
+ // invisible must never mean silently breaking the user's setup.
749
+ stderr.write(`Warning: ${proxyPlan.reason}; skipping proxy wiring. Nothing was changed.\n`);
750
+ manageProxy = false;
751
+ proxyPlan = null;
752
+ } else {
753
+ settingsNext = proxyPlan.next;
754
+ }
619
755
  }
620
- const settingsNext = options.action === "connect"
621
- ? connectHooksSettings(settingsRaw, { hooksDir, url: urlFor(claudeCode) })
622
- : disconnectHooksSettings(settingsRaw);
623
- if (settingsRaw !== settingsNext) {
756
+ if (manageProxy && options.action === "disconnect") {
757
+ const wiring = await readWiringState({ env });
758
+ settingsNext = unwireProxyEnv(settingsNext, {
759
+ original: wiring?.original_base_url ?? null,
760
+ }).next;
761
+ }
762
+ hookSettings.set("claude-code", { ...record, settingsNext });
763
+ }
764
+ for (const record of hookSettings.values()) {
765
+ if (record.settingsRaw !== record.settingsNext) {
624
766
  changes.push({
625
- client: claudeCode,
626
- filePath: settingsPath,
627
- before: settingsRaw,
628
- after: settingsNext,
629
- kind: "hooks",
767
+ client: record.client,
768
+ filePath: record.settingsPath,
769
+ before: record.settingsRaw,
770
+ after: record.settingsNext,
771
+ kind: "settings",
630
772
  sensitive: false,
631
773
  backup: options.action === "connect",
632
774
  });
633
775
  }
634
776
  }
777
+ let removeSharedHookScripts = false;
778
+ if (manageHooks && options.action === "disconnect") {
779
+ let anyRegistered = false;
780
+ for (const clientId of ["claude-code", "codex"]) {
781
+ const staged = hookSettings.get(clientId);
782
+ let raw = staged?.settingsNext ?? "";
783
+ if (!staged) {
784
+ try {
785
+ raw = await readFile(settingsPathFor({ env, clientId }), "utf8");
786
+ } catch (error) {
787
+ if (!error || error.code !== "ENOENT") throw error;
788
+ }
789
+ }
790
+ if (hooksInstalled(raw, { hooksDir, clientId })) anyRegistered = true;
791
+ }
792
+ removeSharedHookScripts = !anyRegistered;
793
+ }
635
794
 
636
795
  if (options.dryRun) {
637
- if (!changes.length && !manageHooks) {
796
+ if (!changes.length && !manageHooks && !manageProxy && !manageSkills) {
638
797
  stdout.write("No changes needed.\n");
639
798
  return;
640
799
  }
@@ -643,22 +802,88 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
643
802
  }
644
803
  if (manageHooks && options.action === "connect") {
645
804
  stdout.write(`Would install hook scripts into ${hooksDir}\n`);
805
+ if (claudeCode) {
806
+ stdout.write(`Would install slash commands (/forget, /forget-settings) into ${commandsDirFor({ env })}\n`);
807
+ }
646
808
  }
647
809
  if (manageHooks && options.action === "disconnect") {
648
- stdout.write(`Would remove hook scripts from ${hooksDir}\n`);
810
+ if (removeSharedHookScripts) stdout.write(`Would remove hook scripts from ${hooksDir}\n`);
811
+ if (claudeCode) {
812
+ stdout.write(`Would remove our slash commands from ${commandsDirFor({ env })} (user-edited files are kept)\n`);
813
+ }
814
+ }
815
+ if (manageProxy && options.action === "connect") {
816
+ stdout.write(`Would register launchd services ${PROXY_LABEL} + ${WATCHDOG_LABEL} (capture proxy at ${PROXY_BASE_URL})\n`);
817
+ if (proxyPlan?.upstream) {
818
+ stdout.write(`Would chain the existing ANTHROPIC_BASE_URL as the proxy upstream: ${proxyPlan.upstream}\n`);
819
+ }
820
+ }
821
+ if (manageProxy && options.action === "disconnect") {
822
+ stdout.write(`Would remove launchd services ${PROXY_LABEL} + ${WATCHDOG_LABEL} and restore ANTHROPIC_BASE_URL\n`);
823
+ }
824
+ if (manageSkills) {
825
+ for (const client of skillClients) {
826
+ const verb = options.action === "connect" ? "install" : "remove";
827
+ stdout.write(`Would ${verb} the Memory Agent skill at ${skillDirFor(client.id, { env })}\n`);
828
+ }
649
829
  }
650
830
  return;
651
831
  }
652
832
 
653
833
  const result = await applyPlan(changes);
834
+ let skillPaths = [];
835
+ if (manageSkills) {
836
+ for (const client of skillClients) {
837
+ const skillResult = options.action === "connect"
838
+ ? await installMemoryAgentSkill(client.id, { env })
839
+ : await removeMemoryAgentSkill(client.id, { env });
840
+ skillPaths = skillPaths.concat(skillResult.written ?? skillResult.removed ?? []);
841
+ if ((skillResult.skipped ?? skillResult.kept ?? []).length) {
842
+ stderr.write(`Note: kept an existing unowned Memory Agent skill for ${client.name}.\n`);
843
+ }
844
+ }
845
+ }
654
846
  let hookScriptPaths = [];
655
847
  if (manageHooks) {
656
- hookScriptPaths = options.action === "connect"
657
- ? await installHookScripts(hooksDir)
658
- : await removeHookScripts(hooksDir);
848
+ if (options.action === "connect") hookScriptPaths = await installHookScripts(hooksDir);
849
+ else if (removeSharedHookScripts) hookScriptPaths = await removeHookScripts(hooksDir);
850
+ if (claudeCode) {
851
+ const commandsDir = commandsDirFor({ env });
852
+ const commandPaths = options.action === "connect"
853
+ ? await installCommandAssets(commandsDir)
854
+ : await removeCommandAssets(commandsDir);
855
+ hookScriptPaths = hookScriptPaths.concat(commandPaths);
856
+ }
857
+ }
858
+ let proxyPaths = [];
859
+ let proxyUpstream = null;
860
+ if (manageProxy) {
861
+ if (options.action === "connect") {
862
+ // State first: the watchdog and a future disconnect must know the
863
+ // user's original base URL before either could ever act on it. A
864
+ // no-op re-connect keeps the existing record (it holds the original).
865
+ let wiring = await readWiringState({ env });
866
+ if (proxyPlan.status === "wire" || !wiring) {
867
+ wiring = await writeWiringState({ env }, {
868
+ upstream: proxyPlan.upstream,
869
+ original: proxyPlan.original,
870
+ });
871
+ }
872
+ proxyUpstream = wiring.upstream ?? null;
873
+ const service = await installProxyServices({ env }, { upstream: proxyUpstream });
874
+ proxyPaths = service.written;
875
+ if (!service.installed) {
876
+ stderr.write(
877
+ `Warning: capture proxy service not fully registered — ${service.reason}\n`
878
+ + " If Claude Code cannot reach the API, run: forget-connect disconnect --client claude-code\n",
879
+ );
880
+ }
881
+ } else {
882
+ proxyPaths = await removeProxyServices({ env });
883
+ }
659
884
  }
660
885
  const verb = options.action === "connect" ? "Connected" : "Disconnected";
661
- if (!result.changed.length && !hookScriptPaths.length) {
886
+ if (!result.changed.length && !hookScriptPaths.length && !proxyPaths.length && !skillPaths.length) {
662
887
  stdout.write("No changes needed.\n");
663
888
  } else {
664
889
  stdout.write(`${verb} ${clients.map((client) => client.name).join(", ")}.\n`);
@@ -666,6 +891,8 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
666
891
  for (const filePath of result.backups) stdout.write(` backup ${filePath}\n`);
667
892
  const hookVerb = options.action === "connect" ? "installed" : "removed";
668
893
  for (const filePath of hookScriptPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
894
+ for (const filePath of proxyPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
895
+ for (const filePath of skillPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
669
896
  }
670
897
  if (options.action === "connect") {
671
898
  if (options.scope) {
@@ -676,7 +903,19 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
676
903
  );
677
904
  }
678
905
  if (manageHooks) {
679
- stdout.write(" hooks: session capsule, per-turn recall, conflict alerts, session capture (needs python3 on PATH)\n");
906
+ stdout.write(" hooks: shared capsule, per-turn recall, and session capture (needs python3 on PATH)\n");
907
+ if (claudeCode) {
908
+ stdout.write(" commands: /forget (the recall dial) · /forget-settings (status, doctor, cloud usage)\n");
909
+ }
910
+ }
911
+ if (manageProxy) {
912
+ stdout.write(` proxy: capture proxy at ${PROXY_BASE_URL} (launchd ${PROXY_LABEL}, watchdog ${WATCHDOG_LABEL})\n`);
913
+ if (proxyUpstream) {
914
+ stdout.write(` proxy upstream: ${proxyUpstream} (your previous ANTHROPIC_BASE_URL, chained)\n`);
915
+ }
916
+ }
917
+ if (manageSkills) {
918
+ stdout.write(" skill: shared Memory Agent quote, approval, receipt, and revoke contract\n");
680
919
  }
681
920
  if (options.hosted && !options.scope) {
682
921
  stdout.write(
package/src/core.js CHANGED
@@ -43,6 +43,19 @@ export const MEMORY_RULES = [
43
43
  RULES_END,
44
44
  ].join("\n");
45
45
 
46
+ export const CODEX_MEMORY_RULES = [
47
+ RULES_START,
48
+ "# Memory (Forget)",
49
+ "You have the user's long-term memory via the `forget` MCP server.",
50
+ "At session start and on resume/continue requests, call `prepare_codex_context` once with a short query for the current work and the exact current working directory as `client_workdir`. Use only its project-bound results. If it returns `project_unresolved`, `empty`, or `unavailable`, continue without memory; do not fall back to generic autopilot or cross-project task state.",
51
+ "Whenever the user refers to their own past decisions, preferences, plans, people, or prior discussions, call `search_memories` FIRST. For project-specific history, prefer `prepare_codex_context(query, client_workdir)` so another project's state cannot enter the capsule.",
52
+ "Results may carry a `trust` label: green (user-stated or tool-observed) is safe to act on; yellow (agent-inferred or self-summarized) requires confirmation before real-world action; red is superseded; unlabeled is yellow.",
53
+ "When the user states a durable decision, preference, or lasting fact, save it with `add_memory`; use `source_role=\"user\"` only for the user's own words and check `get_event_status` before claiming the write completed. Never record a planned action as completed without evidence.",
54
+ "Use `supersede_memory` to retire a wrong fact and `confirm_memory` when evidence verifies a provisional claim. When a context trace materially influenced work, call `record_context_outcome` with the memory ids actually used or rejected.",
55
+ "Use `team_read` and `team_note` only for explicit agent collaboration, never as a substitute for personal memory.",
56
+ RULES_END,
57
+ ].join("\n");
58
+
46
59
  export class ConfigError extends Error {
47
60
  constructor(message) {
48
61
  super(message);
@@ -315,16 +328,16 @@ function removeRulesBlock(text, startMarker, endMarker) {
315
328
  return left || right;
316
329
  }
317
330
 
318
- export function installRules(raw, { migrateLegacy = true } = {}) {
331
+ export function installRules(raw, { migrateLegacy = true, rules = MEMORY_RULES } = {}) {
319
332
  let next = raw;
320
333
  if (migrateLegacy) {
321
334
  next = removeRulesBlock(next, LEGACY_RULES_START, LEGACY_RULES_END);
322
335
  }
323
336
  const bounds = findManagedBlock(next, RULES_START, RULES_END);
324
337
  if (bounds) {
325
- return next.slice(0, bounds.start) + MEMORY_RULES + next.slice(bounds.end);
338
+ return next.slice(0, bounds.start) + rules + next.slice(bounds.end);
326
339
  }
327
- return next ? `${next}\n${MEMORY_RULES}\n` : `${MEMORY_RULES}\n`;
340
+ return next ? `${next}\n${rules}\n` : `${rules}\n`;
328
341
  }
329
342
 
330
343
  export function removeRules(raw) {
@@ -579,10 +592,10 @@ function rulesConnected(raw) {
579
592
  }
580
593
  }
581
594
 
582
- function rulesCurrent(raw) {
595
+ function rulesCurrent(raw, rules = MEMORY_RULES) {
583
596
  try {
584
597
  const bounds = findManagedBlock(raw, RULES_START, RULES_END);
585
- return Boolean(bounds && raw.slice(bounds.start, bounds.end) === MEMORY_RULES);
598
+ return Boolean(bounds && raw.slice(bounds.start, bounds.end) === rules);
586
599
  } catch {
587
600
  return false;
588
601
  }
@@ -602,7 +615,10 @@ export async function inspectClients(clients, { url = "", apiKey = "", urlFor =
602
615
  client,
603
616
  config: configConnected(client, configRaw),
604
617
  rules: client.rulesPath ? rulesConnected(rulesRaw) : null,
605
- rulesCurrent: client.rulesPath ? rulesCurrent(rulesRaw) : null,
618
+ rulesCurrent: client.rulesPath ? rulesCurrent(
619
+ rulesRaw,
620
+ client.id === "codex" ? CODEX_MEMORY_RULES : MEMORY_RULES,
621
+ ) : null,
606
622
  ...expected,
607
623
  };
608
624
  }),
@@ -631,8 +647,8 @@ export async function buildPlan(
631
647
  }
632
648
  const changes = [];
633
649
  for (const client of clients) {
634
- // urlFor lets connect scope each client into its own memory pool
635
- // (/mcp/{app}/http/{user}) while everything else shares one plan.
650
+ // urlFor lets every provider share one vault scope while retaining its
651
+ // own query profile and provenance in the installed URL.
636
652
  const clientUrl = urlFor ? urlFor(client) : url;
637
653
  const configRaw = await readOptional(client.configPath);
638
654
  let configNext;
@@ -665,7 +681,10 @@ export async function buildPlan(
665
681
  if (client.rulesPath && installInstructionRules) {
666
682
  const rulesRaw = await readOptional(client.rulesPath);
667
683
  const rulesNext = action === "connect"
668
- ? installRules(rulesRaw, { migrateLegacy })
684
+ ? installRules(rulesRaw, {
685
+ migrateLegacy,
686
+ rules: client.id === "codex" ? CODEX_MEMORY_RULES : MEMORY_RULES,
687
+ })
669
688
  : removeRules(rulesRaw);
670
689
  addChange(
671
690
  changes,
package/src/doctor.js CHANGED
@@ -9,6 +9,29 @@ export const REQUIRED_TOOLS = [
9
9
  "record_context_outcome",
10
10
  "record_task_state",
11
11
  "get_task_state",
12
+ "catalog_search",
13
+ "product_quote",
14
+ "grant_create",
15
+ "agent_consult",
16
+ "receipt_verify",
17
+ "grant_revoke",
18
+ ];
19
+ export const CODEX_REQUIRED_TOOLS = [
20
+ "prepare_codex_context",
21
+ "search_memories",
22
+ "add_memory",
23
+ "supersede_memory",
24
+ "confirm_memory",
25
+ "get_event_status",
26
+ "record_context_outcome",
27
+ "team_read",
28
+ "team_note",
29
+ "catalog_search",
30
+ "product_quote",
31
+ "grant_create",
32
+ "agent_consult",
33
+ "receipt_verify",
34
+ "grant_revoke",
12
35
  ];
13
36
 
14
37
  function addSensitiveValueVariants(variants, value) {