forget-connect 0.6.0 → 0.6.1
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 +16 -4
- package/assets/hooks/forget_capture.py +20 -1
- package/assets/hooks/forget_sessionstart.py +20 -1
- package/assets/hooks/forget_turnrecall.py +20 -1
- package/package.json +1 -1
- package/src/cli.js +166 -5
- package/src/hooks.js +5 -1
- package/src/proxy.js +96 -1
package/README.md
CHANGED
|
@@ -93,7 +93,9 @@ scripts remain until neither Codex nor Claude Code references them.
|
|
|
93
93
|
--no-rules Do not manage CLAUDE.md or AGENTS.md
|
|
94
94
|
--no-skill Do not install the shared Memory Agent skill
|
|
95
95
|
--no-hooks Do not install Codex/Claude Code memory hooks
|
|
96
|
-
--
|
|
96
|
+
--proxy Opt in to the local capture proxy (macOS only, off by default)
|
|
97
|
+
--no-proxy Accepted for compatibility; the proxy is already off by default
|
|
98
|
+
--purge-capture With disconnect: also delete ~/.forget/proxy/stream
|
|
97
99
|
--no-migrate-enacta Keep matching legacy config and rule blocks
|
|
98
100
|
--dry-run Show which files would change
|
|
99
101
|
--timeout <seconds> Doctor network timeout, from 1 to 60
|
|
@@ -157,8 +159,11 @@ registered by other tools are preserved byte-for-byte.
|
|
|
157
159
|
|
|
158
160
|
## Capture proxy (macOS)
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
The capture proxy is **off by default** since 0.6.1. It records every Claude
|
|
163
|
+
Code request and response (secret-scrubbed, owner-only files) in
|
|
164
|
+
`~/.forget/proxy/stream/`, so it is wired only when you pass `--proxy`.
|
|
165
|
+
With `--proxy` on macOS, and `forget-proxy` (from
|
|
166
|
+
`pip install 'forget-ai[server]'`) on PATH, connect:
|
|
162
167
|
|
|
163
168
|
- registers launchd services `ai.forget.proxy` (KeepAlive, port 8377) and
|
|
164
169
|
`ai.forget.proxy.watchdog` (60s health checks)
|
|
@@ -171,4 +176,11 @@ If the proxy stops answering for three consecutive checks, the watchdog
|
|
|
171
176
|
removes the override (only the value we wrote) and restores your original
|
|
172
177
|
base URL — losing capture is cheaper than losing Claude. When the proxy
|
|
173
178
|
recovers, the override returns unless you changed the value yourself.
|
|
174
|
-
|
|
179
|
+
`disconnect` always unwires and removes the services. It reports the size of
|
|
180
|
+
the capture folder and keeps it unless you pass `--purge-capture` (or answer
|
|
181
|
+
yes when asked). `forget-connect capture purge --before <YYYY-MM-DD>` deletes
|
|
182
|
+
older day files. `doctor` shows whether the proxy is on and how much is stored.
|
|
183
|
+
|
|
184
|
+
If 0.6.0 already wired the proxy on your machine, upgrading does not remove
|
|
185
|
+
it: run `npx forget-connect disconnect` and then connect again without
|
|
186
|
+
`--proxy`.
|
|
@@ -36,6 +36,25 @@ except Exception:
|
|
|
36
36
|
forget_digest = None
|
|
37
37
|
|
|
38
38
|
FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _auth_headers() -> dict:
|
|
42
|
+
"""Content-Type plus, for a loopback server only, the local Bearer key from
|
|
43
|
+
$FORGET_HOME/api-key (written by `forget install-service`). Never sent off-box."""
|
|
44
|
+
headers = {"Content-Type": "application/json"}
|
|
45
|
+
try:
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from urllib.parse import urlsplit
|
|
48
|
+
if (urlsplit(FORGET_URL).hostname or "") not in ("127.0.0.1", "localhost", "::1"):
|
|
49
|
+
return headers
|
|
50
|
+
home = Path(os.environ.get("FORGET_HOME") or Path.home() / ".forget")
|
|
51
|
+
key = (home / "api-key").read_text().strip()
|
|
52
|
+
if key:
|
|
53
|
+
headers["Authorization"] = "Bearer " + key
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return headers
|
|
57
|
+
|
|
39
58
|
STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
|
|
40
59
|
TAIL_BYTES = 400_000
|
|
41
60
|
SNIPPET_LIMIT = 200
|
|
@@ -50,7 +69,7 @@ def _rpc(name: str, arguments: dict, timeout: int = 5) -> None:
|
|
|
50
69
|
request = urllib.request.Request(
|
|
51
70
|
FORGET_URL,
|
|
52
71
|
data=json.dumps(payload).encode("utf-8"),
|
|
53
|
-
headers=
|
|
72
|
+
headers=_auth_headers(),
|
|
54
73
|
)
|
|
55
74
|
urllib.request.urlopen(request, timeout=timeout).read()
|
|
56
75
|
|
|
@@ -21,6 +21,25 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
21
21
|
from forget_project import layered_filter, project_key_for_path, scope_disabled # noqa: E402
|
|
22
22
|
|
|
23
23
|
FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _auth_headers() -> dict:
|
|
27
|
+
"""Content-Type plus, for a loopback server only, the local Bearer key from
|
|
28
|
+
$FORGET_HOME/api-key (written by `forget install-service`). Never sent off-box."""
|
|
29
|
+
headers = {"Content-Type": "application/json"}
|
|
30
|
+
try:
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.parse import urlsplit
|
|
33
|
+
if (urlsplit(FORGET_URL).hostname or "") not in ("127.0.0.1", "localhost", "::1"):
|
|
34
|
+
return headers
|
|
35
|
+
home = Path(os.environ.get("FORGET_HOME") or Path.home() / ".forget")
|
|
36
|
+
key = (home / "api-key").read_text().strip()
|
|
37
|
+
if key:
|
|
38
|
+
headers["Authorization"] = "Bearer " + key
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return headers
|
|
42
|
+
|
|
24
43
|
STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
|
|
25
44
|
CAPSULE_CHAR_BUDGET = 1_600 # ~400 tokens
|
|
26
45
|
HANDOFF_MAX_AGE_SECONDS = 48 * 3600 # a stale shift-note is worse than none
|
|
@@ -135,7 +154,7 @@ def main() -> None:
|
|
|
135
154
|
request = urllib.request.Request(
|
|
136
155
|
FORGET_URL,
|
|
137
156
|
data=json.dumps(payload).encode("utf-8"),
|
|
138
|
-
headers=
|
|
157
|
+
headers=_auth_headers(),
|
|
139
158
|
)
|
|
140
159
|
body = json.loads(urllib.request.urlopen(request, timeout=8).read())
|
|
141
160
|
result = json.loads(body["result"]["content"][0]["text"])
|
|
@@ -42,6 +42,25 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
42
42
|
from forget_project import layered_filter, project_key_for_path, scope_disabled, wants_cross_project # noqa: E402
|
|
43
43
|
|
|
44
44
|
FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _auth_headers() -> dict:
|
|
48
|
+
"""Content-Type plus, for a loopback server only, the local Bearer key from
|
|
49
|
+
$FORGET_HOME/api-key (written by `forget install-service`). Never sent off-box."""
|
|
50
|
+
headers = {"Content-Type": "application/json"}
|
|
51
|
+
try:
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from urllib.parse import urlsplit
|
|
54
|
+
if (urlsplit(FORGET_URL).hostname or "") not in ("127.0.0.1", "localhost", "::1"):
|
|
55
|
+
return headers
|
|
56
|
+
home = Path(os.environ.get("FORGET_HOME") or Path.home() / ".forget")
|
|
57
|
+
key = (home / "api-key").read_text().strip()
|
|
58
|
+
if key:
|
|
59
|
+
headers["Authorization"] = "Bearer " + key
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return headers
|
|
63
|
+
|
|
45
64
|
STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
|
|
46
65
|
# 0.45 → 0.33 (2026-08-23, 다국어 mpnet 전환 실측): 새 공간의 합성 점수 대역이
|
|
47
66
|
# 내려앉았다 — 관련 질의 상위 0.40~0.46, 무관 바닥 0.28. 구 문턱 0.45는 구 공간
|
|
@@ -176,7 +195,7 @@ def _rpc(name: str, arguments: dict, timeout: int = 5) -> dict:
|
|
|
176
195
|
request = urllib.request.Request(
|
|
177
196
|
FORGET_URL,
|
|
178
197
|
data=json.dumps(payload).encode("utf-8"),
|
|
179
|
-
headers=
|
|
198
|
+
headers=_auth_headers(),
|
|
180
199
|
)
|
|
181
200
|
body = json.loads(urllib.request.urlopen(request, timeout=timeout).read())
|
|
182
201
|
return json.loads(body["result"]["content"][0]["text"])
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import readline from "node:readline/promises";
|
|
4
5
|
import { stdin, stdout, stderr } from "node:process";
|
|
5
6
|
import {
|
|
@@ -38,6 +39,11 @@ import {
|
|
|
38
39
|
PROXY_BASE_URL,
|
|
39
40
|
PROXY_LABEL,
|
|
40
41
|
WATCHDOG_LABEL,
|
|
42
|
+
captureInfo,
|
|
43
|
+
legacyLine,
|
|
44
|
+
purgeCaptureBefore,
|
|
45
|
+
formatBytes,
|
|
46
|
+
purgeCapture,
|
|
41
47
|
findExecutable,
|
|
42
48
|
installProxyServices,
|
|
43
49
|
readWiringState,
|
|
@@ -63,6 +69,7 @@ Usage:
|
|
|
63
69
|
forget-connect status [options]
|
|
64
70
|
forget-connect doctor [options]
|
|
65
71
|
forget-connect disconnect [options]
|
|
72
|
+
forget-connect capture purge --before <YYYY-MM-DD> delete older capture day files (asks yes)
|
|
66
73
|
|
|
67
74
|
Options:
|
|
68
75
|
--client <ids> Comma-separated: claude-code,codex,claude-desktop,all
|
|
@@ -85,8 +92,14 @@ Scope:
|
|
|
85
92
|
--no-skill Do not install the shared Memory Agent skill
|
|
86
93
|
--no-hooks Do not install Codex/Claude Code memory hooks (session
|
|
87
94
|
capsule, per-turn recall, session capture)
|
|
88
|
-
--
|
|
89
|
-
ANTHROPIC_BASE_URL override + health watchdog
|
|
95
|
+
--proxy OPT-IN (macOS only): wire the local capture proxy (launchd
|
|
96
|
+
service + ANTHROPIC_BASE_URL override + health watchdog).
|
|
97
|
+
It records every Claude Code request and response
|
|
98
|
+
(secret-scrubbed) in ~/.forget/proxy/stream/, kept 7 days,
|
|
99
|
+
max 2 GB. Off unless you pass --proxy.
|
|
100
|
+
--no-proxy Accepted for compatibility; the proxy is already off by default
|
|
101
|
+
--purge-capture With disconnect: also delete ~/.forget/proxy/stream
|
|
102
|
+
(otherwise only its size is reported and it is kept)
|
|
90
103
|
--no-migrate-enacta Keep matching legacy config and rules blocks
|
|
91
104
|
--dry-run Show the files that would change without writing them
|
|
92
105
|
--timeout <seconds> Doctor network timeout (default: 10)
|
|
@@ -155,7 +168,8 @@ export function parseArgs(argv, env = process.env) {
|
|
|
155
168
|
rules: true,
|
|
156
169
|
skills: true,
|
|
157
170
|
hooks: true,
|
|
158
|
-
proxy:
|
|
171
|
+
proxy: false,
|
|
172
|
+
purgeCapture: false,
|
|
159
173
|
migrateLegacy: true,
|
|
160
174
|
dryRun: false,
|
|
161
175
|
yes: false,
|
|
@@ -168,9 +182,16 @@ export function parseArgs(argv, env = process.env) {
|
|
|
168
182
|
let actionSeen = false;
|
|
169
183
|
for (let index = 0; index < argv.length; index += 1) {
|
|
170
184
|
const arg = argv[index];
|
|
171
|
-
if (["connect", "disconnect", "status", "doctor"].includes(arg) && !actionSeen) {
|
|
185
|
+
if (["connect", "disconnect", "status", "doctor", "capture"].includes(arg) && !actionSeen) {
|
|
172
186
|
options.action = arg;
|
|
173
187
|
actionSeen = true;
|
|
188
|
+
} else if (options.action === "capture" && arg === "purge") {
|
|
189
|
+
options.captureSub = "purge";
|
|
190
|
+
} else if (arg === "--before") {
|
|
191
|
+
options.before = requireValue(argv, index, arg);
|
|
192
|
+
index += 1;
|
|
193
|
+
} else if (arg.startsWith("--before=")) {
|
|
194
|
+
options.before = arg.slice("--before=".length);
|
|
174
195
|
} else if (arg === "--client") {
|
|
175
196
|
options.clientValues.push(requireValue(argv, index, arg));
|
|
176
197
|
index += 1;
|
|
@@ -207,8 +228,12 @@ export function parseArgs(argv, env = process.env) {
|
|
|
207
228
|
options.skills = false;
|
|
208
229
|
} else if (arg === "--no-hooks") {
|
|
209
230
|
options.hooks = false;
|
|
231
|
+
} else if (arg === "--proxy") {
|
|
232
|
+
options.proxy = true;
|
|
210
233
|
} else if (arg === "--no-proxy") {
|
|
211
234
|
options.proxy = false;
|
|
235
|
+
} else if (arg === "--purge-capture") {
|
|
236
|
+
options.purgeCapture = true;
|
|
212
237
|
} else if (arg === "--no-migrate-enacta") {
|
|
213
238
|
options.migrateLegacy = false;
|
|
214
239
|
} else if (arg === "--dry-run") {
|
|
@@ -424,6 +449,16 @@ function isLoopbackUrl(value) {
|
|
|
424
449
|
return LOOPBACK_HOSTS.has(new URL(value).hostname.toLowerCase());
|
|
425
450
|
}
|
|
426
451
|
|
|
452
|
+
export async function readLocalApiKey(env = process.env) {
|
|
453
|
+
// $FORGET_HOME/api-key is written (0600) by `forget install-service`.
|
|
454
|
+
const home = env.FORGET_HOME || path.join(env.HOME ?? env.USERPROFILE ?? os.homedir(), ".forget");
|
|
455
|
+
try {
|
|
456
|
+
return (await readFile(path.join(home, "api-key"), "utf8")).trim();
|
|
457
|
+
} catch {
|
|
458
|
+
return "";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
427
462
|
async function apiKeyFor(options, env) {
|
|
428
463
|
if (!["connect", "doctor"].includes(options.action) || !options.auth || options.dryRun) return "";
|
|
429
464
|
const fromEnv = env.FORGET_API_KEY?.trim();
|
|
@@ -441,6 +476,13 @@ async function apiKeyFor(options, env) {
|
|
|
441
476
|
"Note: FORGET_API_KEY is set but the target is a loopback HTTP server; connecting without a token.\n",
|
|
442
477
|
);
|
|
443
478
|
}
|
|
479
|
+
// Local server: send the install's own key. Harmless while the server does
|
|
480
|
+
// not require auth, and required once it does (phase 2). Not used by doctor,
|
|
481
|
+
// which checks it separately and only warns.
|
|
482
|
+
if (!options.hosted && isLoopbackUrl(options.url) && options.action === "connect") {
|
|
483
|
+
const localKey = await readLocalApiKey(env);
|
|
484
|
+
if (localKey) return validateApiKey(localKey);
|
|
485
|
+
}
|
|
444
486
|
if (options.localAuth) {
|
|
445
487
|
throw new ConfigError("Set FORGET_API_KEY when using --local-auth.");
|
|
446
488
|
}
|
|
@@ -520,6 +562,30 @@ function printDoctor(result) {
|
|
|
520
562
|
}
|
|
521
563
|
stdout.write(`${client.ok ? "✓" : "✗"} ${client.name}: ${details.join(", ")}\n`);
|
|
522
564
|
}
|
|
565
|
+
if (result.capture) {
|
|
566
|
+
const c = result.capture;
|
|
567
|
+
const state = c.service_wired ? (c.capture_env ? "ON" : "passthrough (capture off)") : "off (proxy not installed)";
|
|
568
|
+
stdout.write(`${c.service_wired ? "!" : "○"} Capture proxy: ${state}\n`);
|
|
569
|
+
if (c.exists) {
|
|
570
|
+
stdout.write(` folder ${c.dir}: ${formatBytes(c.bytes)} in ${c.files} day file(s), oldest ${c.oldest ?? "-"}, retention ${c.retention_days} days, cap ${formatBytes(c.max_bytes)}\n`);
|
|
571
|
+
const legacy = legacyLine(c);
|
|
572
|
+
if (legacy) stdout.write(` ${legacy}\n`);
|
|
573
|
+
if (!c.service_wired && c.files) {
|
|
574
|
+
stdout.write(" not being written to now; delete with: forget-connect disconnect --purge-capture\n");
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
stdout.write(" no capture folder\n");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const aw = result.auth_warnings;
|
|
581
|
+
if (aw?.server_has_key && (aw.clients_without_key.length || aw.hooks_without_key)) {
|
|
582
|
+
const who = [...aw.clients_without_key, ...(aw.hooks_without_key ? ["hooks"] : [])].join(", ");
|
|
583
|
+
stdout.write(
|
|
584
|
+
`! Local API key: the server has a key ($FORGET_HOME/api-key) but ${who} do not send it.\n`
|
|
585
|
+
+ " Auth is not required yet, so nothing is broken. It will be required in a later release;\n"
|
|
586
|
+
+ " run `npx forget-connect` (connect) again to update clients and hooks.\n",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
523
589
|
if (result.remote.skipped) {
|
|
524
590
|
stdout.write("○ MCP: not checked until local configuration is fixed\n");
|
|
525
591
|
return;
|
|
@@ -538,6 +604,67 @@ function printDoctor(result) {
|
|
|
538
604
|
}
|
|
539
605
|
}
|
|
540
606
|
|
|
607
|
+
async function confirmYes(question) {
|
|
608
|
+
if (!(stdin.isTTY && stdout.isTTY)) return false;
|
|
609
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
610
|
+
try {
|
|
611
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
612
|
+
return answer === "y" || answer === "yes";
|
|
613
|
+
} finally {
|
|
614
|
+
rl.close();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function runCapturePurge(options, env) {
|
|
619
|
+
if (options.captureSub !== "purge" || !options.before) {
|
|
620
|
+
throw new ConfigError("Usage: forget-connect capture purge --before <YYYY-MM-DD>");
|
|
621
|
+
}
|
|
622
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.before)) {
|
|
623
|
+
throw new ConfigError("--before needs a date like 2026-10-01");
|
|
624
|
+
}
|
|
625
|
+
const info = await captureInfo({ env });
|
|
626
|
+
if (!info.exists || !info.files) {
|
|
627
|
+
stdout.write("No capture files.\n");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
stdout.write(`Capture folder ${info.dir}: ${formatBytes(info.bytes)}, ${info.files} day file(s), ${info.oldest}~${info.newest}\n`);
|
|
631
|
+
stdout.write(`Will delete every day file dated before ${options.before}.\n`);
|
|
632
|
+
if (!(await confirmYes("Delete them? [y/N] "))) {
|
|
633
|
+
stdout.write("Nothing deleted (an interactive yes is required).\n");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const removed = await purgeCaptureBefore({ env }, options.before);
|
|
637
|
+
stdout.write(`Deleted ${removed.length} file(s).\n`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Disconnect never deletes captured conversations silently: it reports the
|
|
641
|
+
// size, and deletes only with --purge-capture or an interactive "yes".
|
|
642
|
+
async function reportCapture(options, env) {
|
|
643
|
+
const info = await captureInfo({ env });
|
|
644
|
+
if (!info.exists || !info.files) return;
|
|
645
|
+
stdout.write(
|
|
646
|
+
` capture folder kept: ${info.dir} (${formatBytes(info.bytes)}, ${info.files} day file(s), oldest ${info.oldest})\n`,
|
|
647
|
+
);
|
|
648
|
+
const legacy = legacyLine(info);
|
|
649
|
+
if (legacy) stdout.write(` ${legacy}\n`);
|
|
650
|
+
let purge = options.purgeCapture;
|
|
651
|
+
if (!purge && stdin.isTTY && stdout.isTTY) {
|
|
652
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
653
|
+
try {
|
|
654
|
+
const answer = (await rl.question("Delete these captured conversations now? [y/N] ")).trim().toLowerCase();
|
|
655
|
+
purge = answer === "y" || answer === "yes";
|
|
656
|
+
} finally {
|
|
657
|
+
rl.close();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (purge) {
|
|
661
|
+
await purgeCapture({ env });
|
|
662
|
+
stdout.write(` deleted ${info.dir}\n`);
|
|
663
|
+
} else {
|
|
664
|
+
stdout.write(" to delete it: forget-connect disconnect --purge-capture\n");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
541
668
|
async function version() {
|
|
542
669
|
const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
543
670
|
return JSON.parse(raw).version;
|
|
@@ -554,6 +681,11 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
554
681
|
return;
|
|
555
682
|
}
|
|
556
683
|
|
|
684
|
+
if (options.action === "capture") {
|
|
685
|
+
await runCapturePurge(options, env);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
557
689
|
const allClients = getClients({ env });
|
|
558
690
|
const clients = await selectClients(options, allClients);
|
|
559
691
|
if (options.action === "status") {
|
|
@@ -604,8 +736,19 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
604
736
|
if (options.action === "doctor") {
|
|
605
737
|
const probeUrl = clients.length ? urlFor(clients[0]) : options.url;
|
|
606
738
|
const probeScope = options.scope ?? scopeFromUrl(probeUrl);
|
|
739
|
+
// The server may hold a key (install-service wrote one). Clients that
|
|
740
|
+
// already send it are fine; clients that don't only get a warning.
|
|
741
|
+
const localKey = isLoopbackUrl(probeUrl) && !options.hosted ? await readLocalApiKey(env) : "";
|
|
742
|
+
const plainStatuses = await inspectClients(clients, { url: options.url, apiKey, urlFor });
|
|
743
|
+
const withKey = localKey
|
|
744
|
+
? await inspectClients(clients, { url: options.url, apiKey: localKey, urlFor })
|
|
745
|
+
: [];
|
|
607
746
|
const local = localDoctorResults(
|
|
608
|
-
|
|
747
|
+
plainStatuses.map((s, i) => (
|
|
748
|
+
localKey && withKey[i]?.authMatches && !s.authMatches
|
|
749
|
+
? { ...s, authMatches: true, transportValid: s.transportValid || withKey[i].transportValid }
|
|
750
|
+
: s
|
|
751
|
+
)),
|
|
609
752
|
{ requireRules: options.rules },
|
|
610
753
|
);
|
|
611
754
|
const remote = local.every((client) => client.ok)
|
|
@@ -636,6 +779,12 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
636
779
|
skipped: true,
|
|
637
780
|
},
|
|
638
781
|
};
|
|
782
|
+
const keyWarnings = [];
|
|
783
|
+
if (localKey) {
|
|
784
|
+
withKey.forEach((item) => {
|
|
785
|
+
if (item.config && !item.authMatches) keyWarnings.push(item.client.name);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
639
788
|
const hooksStatus = clients.some((client) => client.id === "claude-code")
|
|
640
789
|
? await inspectHooks({ env, clientId: "claude-code" })
|
|
641
790
|
: null;
|
|
@@ -654,10 +803,16 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
654
803
|
url: redactUrlForDisplay(probeUrl),
|
|
655
804
|
scope: { configured: Boolean(probeScope) },
|
|
656
805
|
clients: local,
|
|
806
|
+
auth_warnings: {
|
|
807
|
+
server_has_key: Boolean(localKey),
|
|
808
|
+
clients_without_key: keyWarnings,
|
|
809
|
+
hooks_without_key: [hooksStatus, codexHooksStatus].some((h) => h && h.sends_key === false),
|
|
810
|
+
},
|
|
657
811
|
hooks: hooksStatus,
|
|
658
812
|
codex_hooks: codexHooksStatus,
|
|
659
813
|
skills: skillStatus,
|
|
660
814
|
remote,
|
|
815
|
+
capture: await captureInfo({ env }),
|
|
661
816
|
};
|
|
662
817
|
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
663
818
|
else printDoctor(result);
|
|
@@ -882,6 +1037,9 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
882
1037
|
proxyPaths = await removeProxyServices({ env });
|
|
883
1038
|
}
|
|
884
1039
|
}
|
|
1040
|
+
if (options.action === "disconnect" && !options.dryRun) {
|
|
1041
|
+
await reportCapture(options, env);
|
|
1042
|
+
}
|
|
885
1043
|
const verb = options.action === "connect" ? "Connected" : "Disconnected";
|
|
886
1044
|
if (!result.changed.length && !hookScriptPaths.length && !proxyPaths.length && !skillPaths.length) {
|
|
887
1045
|
stdout.write("No changes needed.\n");
|
|
@@ -910,6 +1068,9 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
910
1068
|
}
|
|
911
1069
|
if (manageProxy) {
|
|
912
1070
|
stdout.write(` proxy: capture proxy at ${PROXY_BASE_URL} (launchd ${PROXY_LABEL}, watchdog ${WATCHDOG_LABEL})\n`);
|
|
1071
|
+
stdout.write(" proxy RECORDS every Claude Code request and response (secrets scrubbed, owner-only 0600) in ~/.forget/proxy/stream/; kept 7 days, max 2 GB. Turn off: forget-connect disconnect (add --purge-capture to delete the files)\n");
|
|
1072
|
+
const legacyNote = legacyLine(await captureInfo({ env }));
|
|
1073
|
+
if (legacyNote) stdout.write(` ${legacyNote}\n`);
|
|
913
1074
|
if (proxyUpstream) {
|
|
914
1075
|
stdout.write(` proxy upstream: ${proxyUpstream} (your previous ANTHROPIC_BASE_URL, chained)\n`);
|
|
915
1076
|
}
|
package/src/hooks.js
CHANGED
|
@@ -251,9 +251,12 @@ export async function inspectHooks({ env, clientId = "claude-code" } = {}) {
|
|
|
251
251
|
}
|
|
252
252
|
const registered = hooksInstalled(settingsRaw, { hooksDir, clientId });
|
|
253
253
|
let scriptsPresent = true;
|
|
254
|
+
let sendsKey = true;
|
|
254
255
|
for (const script of HOOK_SCRIPTS) {
|
|
255
256
|
try {
|
|
256
|
-
await readFile(path.join(hooksDir, script), "utf8");
|
|
257
|
+
const body = await readFile(path.join(hooksDir, script), "utf8");
|
|
258
|
+
// only scripts that call the server need the Bearer helper
|
|
259
|
+
if (body.includes("FORGET_MCP_URL") && !body.includes("_auth_headers")) sendsKey = false;
|
|
257
260
|
} catch {
|
|
258
261
|
scriptsPresent = false;
|
|
259
262
|
}
|
|
@@ -264,6 +267,7 @@ export async function inspectHooks({ env, clientId = "claude-code" } = {}) {
|
|
|
264
267
|
return {
|
|
265
268
|
registered,
|
|
266
269
|
scripts_present: scriptsPresent,
|
|
270
|
+
sends_key: sendsKey,
|
|
267
271
|
python3,
|
|
268
272
|
// registered-but-broken is a failure; absent hooks are a legitimate
|
|
269
273
|
// choice (--no-hooks), so they don't fail the check
|
package/src/proxy.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { execFile } from "node:child_process";
|
|
20
20
|
import { constants as fsConstants } from "node:fs";
|
|
21
|
-
import { access, readFile, unlink } from "node:fs/promises";
|
|
21
|
+
import { access, readdir, readFile, rm, stat, unlink } from "node:fs/promises";
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
@@ -429,3 +429,98 @@ export async function removeProxyServices(options = {}) {
|
|
|
429
429
|
}
|
|
430
430
|
return removed;
|
|
431
431
|
}
|
|
432
|
+
|
|
433
|
+
export const DEFAULT_RETENTION_DAYS = 7;
|
|
434
|
+
|
|
435
|
+
export function captureDirFor(options = {}) {
|
|
436
|
+
return path.join(proxyDirFor(options), "stream");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Read-only summary of the capture folder: never opens file contents. */
|
|
440
|
+
export async function captureInfo(options = {}) {
|
|
441
|
+
const env = options.env ?? process.env;
|
|
442
|
+
const dir = captureDirFor(options);
|
|
443
|
+
const rawDays = Number(env.FORGET_PROXY_RETENTION_DAYS);
|
|
444
|
+
const info = {
|
|
445
|
+
dir,
|
|
446
|
+
exists: false,
|
|
447
|
+
files: 0,
|
|
448
|
+
bytes: 0,
|
|
449
|
+
oldest: null,
|
|
450
|
+
newest: null,
|
|
451
|
+
retention_days: Number.isFinite(rawDays) && env.FORGET_PROXY_RETENTION_DAYS ? rawDays : DEFAULT_RETENTION_DAYS,
|
|
452
|
+
max_bytes: Number(env.FORGET_PROXY_MAX_BYTES) || 2 * 1024 ** 3,
|
|
453
|
+
capture_env: !["0", "false", "off", "no"].includes(String(env.FORGET_PROXY_CAPTURE ?? "1").toLowerCase()),
|
|
454
|
+
service_wired: Boolean(await readWiringState(options)),
|
|
455
|
+
};
|
|
456
|
+
let names;
|
|
457
|
+
try {
|
|
458
|
+
names = (await readdir(dir)).filter((n) => n.endsWith(".jsonl")).sort();
|
|
459
|
+
info.exists = true;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error && error.code === "ENOENT") return info;
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
let since = null;
|
|
465
|
+
try {
|
|
466
|
+
const raw = (await readFile(path.join(dir, ".retention-since"), "utf8")).trim();
|
|
467
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) since = raw;
|
|
468
|
+
} catch { /* marker absent: the new proxy has not run, every file is old */ }
|
|
469
|
+
info.since = since;
|
|
470
|
+
info.legacy = { files: 0, bytes: 0, oldest: null, newest: null };
|
|
471
|
+
const legacyNames = [];
|
|
472
|
+
for (const name of names) {
|
|
473
|
+
try {
|
|
474
|
+
const st = await stat(path.join(dir, name));
|
|
475
|
+
info.files += 1;
|
|
476
|
+
info.bytes += st.size;
|
|
477
|
+
const day = name.replace(/\.jsonl$/, "");
|
|
478
|
+
if (since === null || day <= since) {
|
|
479
|
+
info.legacy.files += 1;
|
|
480
|
+
info.legacy.bytes += st.size;
|
|
481
|
+
legacyNames.push(day);
|
|
482
|
+
}
|
|
483
|
+
} catch { /* vanished */ }
|
|
484
|
+
}
|
|
485
|
+
info.legacy.oldest = legacyNames[0] ?? null;
|
|
486
|
+
info.legacy.newest = legacyNames.at(-1) ?? null;
|
|
487
|
+
info.oldest = names[0]?.replace(/\.jsonl$/, "") ?? null;
|
|
488
|
+
info.newest = names.at(-1)?.replace(/\.jsonl$/, "") ?? null;
|
|
489
|
+
return info;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function formatBytes(n) {
|
|
493
|
+
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
|
|
494
|
+
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
495
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
496
|
+
return `${n} B`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Delete only the capture folder. Callers must have explicit consent. */
|
|
500
|
+
export async function purgeCapture(options = {}) {
|
|
501
|
+
await rm(captureDirFor(options), { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** "예전 기록 N GB (a~b), 자동으로 지우지 않음" or null when there are none. */
|
|
505
|
+
export function legacyLine(info) {
|
|
506
|
+
if (!info?.legacy?.files) return null;
|
|
507
|
+
const l = info.legacy;
|
|
508
|
+
return `예전 기록 ${formatBytes(l.bytes)} (${l.oldest}~${l.newest}), 자동으로 지우지 않음`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Delete day files dated strictly before ``before`` (YYYY-MM-DD). */
|
|
512
|
+
export async function purgeCaptureBefore(options, before) {
|
|
513
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(before)) throw new Error("--before needs a date like 2026-10-01");
|
|
514
|
+
const dir = captureDirFor(options);
|
|
515
|
+
const removed = [];
|
|
516
|
+
let names = [];
|
|
517
|
+
try { names = await readdir(dir); } catch { return removed; }
|
|
518
|
+
for (const name of names.sort()) {
|
|
519
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
520
|
+
if (name.replace(/\.jsonl$/, "") < before) {
|
|
521
|
+
await unlink(path.join(dir, name));
|
|
522
|
+
removed.push(name);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return removed;
|
|
526
|
+
}
|