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/README.md +65 -21
- package/assets/commands/forget-settings.md +25 -0
- package/assets/commands/forget.md +24 -0
- package/assets/hooks/forget_bstate.py +229 -0
- package/assets/hooks/forget_capture.py +22 -5
- package/assets/hooks/forget_sessionstart.py +21 -3
- package/assets/hooks/forget_toolrecall.py +118 -0
- package/assets/hooks/forget_turnrecall.py +644 -42
- package/assets/plugins/memory-agent/.claude-plugin/plugin.json +15 -0
- package/assets/plugins/memory-agent/.codex-plugin/plugin.json +29 -0
- package/assets/plugins/memory-agent/README.md +9 -0
- package/assets/plugins/memory-agent/skills/memory-agent/SKILL.md +22 -0
- package/assets/plugins/memory-agent/skills/memory-agent/agents/openai.yaml +6 -0
- package/assets/proxy/forget_proxy_watchdog.py +215 -0
- package/assets/skills/memory-agent/SKILL.md +22 -0
- package/assets/skills/memory-agent/agents/openai.yaml +6 -0
- package/package.json +7 -3
- package/src/cli.js +286 -47
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +99 -19
- package/src/proxy.js +431 -0
- package/src/skills.js +95 -0
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
|
|
56
|
-
|
|
57
|
-
--no-scope. An explicit --url is
|
|
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-
|
|
61
|
-
|
|
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:
|
|
227
|
-
// /mcp
|
|
228
|
-
//
|
|
229
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
307
|
+
resolved = scopedMcpUrl(options.baseUrl, {
|
|
265
308
|
userId: options.defaultScope.userId,
|
|
266
309
|
appId: CANONICAL_APP_ID,
|
|
267
310
|
});
|
|
268
311
|
}
|
|
269
|
-
|
|
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
|
-
|
|
390
|
-
//
|
|
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 ? "✓" : "✗"}
|
|
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
|
-
|
|
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
|
|
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
|
|
600
|
-
//
|
|
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
|
-
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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:
|
|
626
|
-
filePath: settingsPath,
|
|
627
|
-
before: settingsRaw,
|
|
628
|
-
after: settingsNext,
|
|
629
|
-
kind: "
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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:
|
|
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) +
|
|
338
|
+
return next.slice(0, bounds.start) + rules + next.slice(bounds.end);
|
|
326
339
|
}
|
|
327
|
-
return next ? `${next}\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) ===
|
|
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(
|
|
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
|
|
635
|
-
//
|
|
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, {
|
|
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) {
|