forget-connect 0.5.2 → 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/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 -44
- 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 +283 -55
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +45 -19
- package/src/proxy.js +431 -0
- package/src/skills.js +95 -0
package/src/cli.js
CHANGED
|
@@ -20,12 +20,13 @@ 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
25
|
commandsDirFor,
|
|
26
26
|
connectHooksSettings,
|
|
27
27
|
disconnectHooksSettings,
|
|
28
28
|
hooksDirFor,
|
|
29
|
+
hooksInstalled,
|
|
29
30
|
inspectHooks,
|
|
30
31
|
installCommandAssets,
|
|
31
32
|
installHookScripts,
|
|
@@ -33,6 +34,24 @@ import {
|
|
|
33
34
|
removeHookScripts,
|
|
34
35
|
settingsPathFor,
|
|
35
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";
|
|
36
55
|
|
|
37
56
|
const CLIENT_IDS = new Set(["claude-code", "codex", "claude-desktop"]);
|
|
38
57
|
|
|
@@ -55,13 +74,19 @@ Options:
|
|
|
55
74
|
|
|
56
75
|
Scope:
|
|
57
76
|
Local connections default to one canonical scoped endpoint (all clients share it):
|
|
58
|
-
/mcp
|
|
59
|
-
|
|
60
|
-
--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.
|
|
61
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)
|
|
62
84
|
--no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
|
|
63
|
-
--no-
|
|
64
|
-
|
|
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)
|
|
65
90
|
--no-migrate-enacta Keep matching legacy config and rules blocks
|
|
66
91
|
--dry-run Show the files that would change without writing them
|
|
67
92
|
--timeout <seconds> Doctor network timeout (default: 10)
|
|
@@ -73,6 +98,7 @@ Scope:
|
|
|
73
98
|
Authentication:
|
|
74
99
|
The default local server needs no token. For --hosted (legacy) set
|
|
75
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.
|
|
76
102
|
Keys are intentionally not accepted as command-line arguments.
|
|
77
103
|
|
|
78
104
|
Examples:
|
|
@@ -125,8 +151,11 @@ export function parseArgs(argv, env = process.env) {
|
|
|
125
151
|
scope: null,
|
|
126
152
|
defaultScope: null,
|
|
127
153
|
auth: true,
|
|
154
|
+
localAuth: false,
|
|
128
155
|
rules: true,
|
|
156
|
+
skills: true,
|
|
129
157
|
hooks: true,
|
|
158
|
+
proxy: true,
|
|
130
159
|
migrateLegacy: true,
|
|
131
160
|
dryRun: false,
|
|
132
161
|
yes: false,
|
|
@@ -170,10 +199,16 @@ export function parseArgs(argv, env = process.env) {
|
|
|
170
199
|
options.noScope = true;
|
|
171
200
|
} else if (arg === "--no-auth") {
|
|
172
201
|
options.auth = false;
|
|
202
|
+
} else if (arg === "--local-auth") {
|
|
203
|
+
options.localAuth = true;
|
|
173
204
|
} else if (arg === "--no-rules") {
|
|
174
205
|
options.rules = false;
|
|
206
|
+
} else if (arg === "--no-skill") {
|
|
207
|
+
options.skills = false;
|
|
175
208
|
} else if (arg === "--no-hooks") {
|
|
176
209
|
options.hooks = false;
|
|
210
|
+
} else if (arg === "--no-proxy") {
|
|
211
|
+
options.proxy = false;
|
|
177
212
|
} else if (arg === "--no-migrate-enacta") {
|
|
178
213
|
options.migrateLegacy = false;
|
|
179
214
|
} else if (arg === "--dry-run") {
|
|
@@ -211,6 +246,12 @@ export function parseArgs(argv, env = process.env) {
|
|
|
211
246
|
}
|
|
212
247
|
options.baseUrl = normalizeUrl(options.url);
|
|
213
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
|
+
}
|
|
214
255
|
if (Boolean(options.userId) !== Boolean(options.appId)) {
|
|
215
256
|
throw new ConfigError("--user-id and --app-id must be provided together.");
|
|
216
257
|
}
|
|
@@ -226,12 +267,10 @@ export function parseArgs(argv, env = process.env) {
|
|
|
226
267
|
options.url = options.scope
|
|
227
268
|
? scopedMcpUrl(options.baseUrl, options.scope)
|
|
228
269
|
: options.baseUrl;
|
|
229
|
-
// Cold-install default:
|
|
230
|
-
// /mcp
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// default. An explicit --url stays verbatim, and hosted keeps requiring an
|
|
234
|
-
// 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).
|
|
235
274
|
options.defaultScope = null;
|
|
236
275
|
if (!options.scope && !options.noScope && !options.hosted && !options.urlExplicit) {
|
|
237
276
|
const osUser = defaultScopeUserId(env);
|
|
@@ -259,17 +298,23 @@ function defaultScopeUserId(env) {
|
|
|
259
298
|
}
|
|
260
299
|
|
|
261
300
|
export function urlForClient(options, client) {
|
|
262
|
-
|
|
301
|
+
let resolved = options.url;
|
|
302
|
+
if (options.scope) resolved = options.url;
|
|
263
303
|
if (options.defaultScope && client) {
|
|
264
304
|
// Every client shares the canonical pool; which tool wrote a memory is
|
|
265
305
|
// provenance, not a scope boundary (issue #27). A per-client pool made
|
|
266
306
|
// Codex writes invisible to Claude and vice versa.
|
|
267
|
-
|
|
307
|
+
resolved = scopedMcpUrl(options.baseUrl, {
|
|
268
308
|
userId: options.defaultScope.userId,
|
|
269
309
|
appId: CANONICAL_APP_ID,
|
|
270
310
|
});
|
|
271
311
|
}
|
|
272
|
-
|
|
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;
|
|
273
318
|
}
|
|
274
319
|
|
|
275
320
|
function requestedClientIds(values) {
|
|
@@ -389,12 +434,16 @@ async function apiKeyFor(options, env) {
|
|
|
389
434
|
"Bearer authentication requires HTTPS. Use --no-auth for a local HTTP server.",
|
|
390
435
|
);
|
|
391
436
|
}
|
|
392
|
-
|
|
393
|
-
//
|
|
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.
|
|
394
440
|
stderr.write(
|
|
395
441
|
"Note: FORGET_API_KEY is set but the target is a loopback HTTP server; connecting without a token.\n",
|
|
396
442
|
);
|
|
397
443
|
}
|
|
444
|
+
if (options.localAuth) {
|
|
445
|
+
throw new ConfigError("Set FORGET_API_KEY when using --local-auth.");
|
|
446
|
+
}
|
|
398
447
|
if (!options.hosted) return "";
|
|
399
448
|
const prompted = (await promptHidden("Paste your Forget API key: ")).trim();
|
|
400
449
|
if (!prompted) {
|
|
@@ -445,7 +494,13 @@ function printDoctor(result) {
|
|
|
445
494
|
const hooksDetail = result.hooks.registered
|
|
446
495
|
? `registered, scripts ${result.hooks.scripts_present ? "present" : "missing"}, python3 ${result.hooks.python3 ? "ok" : "missing"}`
|
|
447
496
|
: "not installed (use connect without --no-hooks)";
|
|
448
|
-
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`);
|
|
449
504
|
}
|
|
450
505
|
for (const client of result.clients) {
|
|
451
506
|
const details = [
|
|
@@ -524,7 +579,13 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
524
579
|
}
|
|
525
580
|
const installed = scopeFromUrl(configuredServerUrl(client, raw));
|
|
526
581
|
if (!installed) continue;
|
|
527
|
-
|
|
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));
|
|
528
589
|
if (!first) first = installed;
|
|
529
590
|
}
|
|
530
591
|
if (first) {
|
|
@@ -555,6 +616,7 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
555
616
|
clientVersion: await version(),
|
|
556
617
|
expectedScope: probeScope,
|
|
557
618
|
requireScope: options.hosted,
|
|
619
|
+
...(clients[0]?.id === "codex" ? { requiredTools: CODEX_REQUIRED_TOOLS } : {}),
|
|
558
620
|
})
|
|
559
621
|
: {
|
|
560
622
|
ok: false,
|
|
@@ -575,14 +637,26 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
575
637
|
},
|
|
576
638
|
};
|
|
577
639
|
const hooksStatus = clients.some((client) => client.id === "claude-code")
|
|
578
|
-
? 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" })
|
|
579
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
|
+
);
|
|
580
650
|
const result = {
|
|
581
|
-
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)),
|
|
582
654
|
url: redactUrlForDisplay(probeUrl),
|
|
583
655
|
scope: { configured: Boolean(probeScope) },
|
|
584
656
|
clients: local,
|
|
585
657
|
hooks: hooksStatus,
|
|
658
|
+
codex_hooks: codexHooksStatus,
|
|
659
|
+
skills: skillStatus,
|
|
586
660
|
remote,
|
|
587
661
|
};
|
|
588
662
|
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -598,12 +672,15 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
598
672
|
migrateLegacy: options.migrateLegacy,
|
|
599
673
|
legacyUrls: [...new Set([options.url, options.baseUrl, HOSTED_MCP_URL])],
|
|
600
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);
|
|
601
678
|
|
|
602
|
-
// Claude Code
|
|
603
|
-
//
|
|
604
|
-
// 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.
|
|
605
681
|
const claudeCode = clients.find((client) => client.id === "claude-code");
|
|
606
|
-
|
|
682
|
+
const hookClients = clients.filter((client) => ["claude-code", "codex"].includes(client.id));
|
|
683
|
+
let manageHooks = hookClients.length > 0
|
|
607
684
|
&& (options.action === "disconnect" || options.hooks);
|
|
608
685
|
if (manageHooks && process.platform === "win32" && options.action === "connect") {
|
|
609
686
|
// The hook commands are POSIX shell strings and the scripts need
|
|
@@ -611,33 +688,112 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
611
688
|
stderr.write("Note: memory hooks are not yet supported on Windows; skipping hook install.\n");
|
|
612
689
|
manageHooks = false;
|
|
613
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
|
+
|
|
614
709
|
const hooksDir = manageHooks ? hooksDirFor({ env }) : "";
|
|
710
|
+
const hookSettings = new Map();
|
|
711
|
+
let proxyPlan = null;
|
|
615
712
|
if (manageHooks) {
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
+
}
|
|
622
755
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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) {
|
|
627
766
|
changes.push({
|
|
628
|
-
client:
|
|
629
|
-
filePath: settingsPath,
|
|
630
|
-
before: settingsRaw,
|
|
631
|
-
after: settingsNext,
|
|
632
|
-
kind: "
|
|
767
|
+
client: record.client,
|
|
768
|
+
filePath: record.settingsPath,
|
|
769
|
+
before: record.settingsRaw,
|
|
770
|
+
after: record.settingsNext,
|
|
771
|
+
kind: "settings",
|
|
633
772
|
sensitive: false,
|
|
634
773
|
backup: options.action === "connect",
|
|
635
774
|
});
|
|
636
775
|
}
|
|
637
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
|
+
}
|
|
638
794
|
|
|
639
795
|
if (options.dryRun) {
|
|
640
|
-
if (!changes.length && !manageHooks) {
|
|
796
|
+
if (!changes.length && !manageHooks && !manageProxy && !manageSkills) {
|
|
641
797
|
stdout.write("No changes needed.\n");
|
|
642
798
|
return;
|
|
643
799
|
}
|
|
@@ -646,29 +802,88 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
646
802
|
}
|
|
647
803
|
if (manageHooks && options.action === "connect") {
|
|
648
804
|
stdout.write(`Would install hook scripts into ${hooksDir}\n`);
|
|
649
|
-
|
|
805
|
+
if (claudeCode) {
|
|
806
|
+
stdout.write(`Would install slash commands (/forget, /forget-settings) into ${commandsDirFor({ env })}\n`);
|
|
807
|
+
}
|
|
650
808
|
}
|
|
651
809
|
if (manageHooks && options.action === "disconnect") {
|
|
652
|
-
stdout.write(`Would remove hook scripts from ${hooksDir}\n`);
|
|
653
|
-
|
|
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
|
+
}
|
|
654
829
|
}
|
|
655
830
|
return;
|
|
656
831
|
}
|
|
657
832
|
|
|
658
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
|
+
}
|
|
659
846
|
let hookScriptPaths = [];
|
|
660
847
|
if (manageHooks) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
+
}
|
|
669
884
|
}
|
|
670
885
|
const verb = options.action === "connect" ? "Connected" : "Disconnected";
|
|
671
|
-
if (!result.changed.length && !hookScriptPaths.length) {
|
|
886
|
+
if (!result.changed.length && !hookScriptPaths.length && !proxyPaths.length && !skillPaths.length) {
|
|
672
887
|
stdout.write("No changes needed.\n");
|
|
673
888
|
} else {
|
|
674
889
|
stdout.write(`${verb} ${clients.map((client) => client.name).join(", ")}.\n`);
|
|
@@ -676,6 +891,8 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
676
891
|
for (const filePath of result.backups) stdout.write(` backup ${filePath}\n`);
|
|
677
892
|
const hookVerb = options.action === "connect" ? "installed" : "removed";
|
|
678
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`);
|
|
679
896
|
}
|
|
680
897
|
if (options.action === "connect") {
|
|
681
898
|
if (options.scope) {
|
|
@@ -686,8 +903,19 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
686
903
|
);
|
|
687
904
|
}
|
|
688
905
|
if (manageHooks) {
|
|
689
|
-
stdout.write(" hooks:
|
|
690
|
-
|
|
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");
|
|
691
919
|
}
|
|
692
920
|
if (options.hosted && !options.scope) {
|
|
693
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) {
|