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 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
- --no-proxy Do not wire the local capture proxy (macOS only)
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
- On macOS, connect also wires the zero-config capture proxy when `forget-proxy`
161
- (from `pip install 'forget-ai[server]'`) is on PATH:
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
- Skip with `--no-proxy`; `disconnect` always unwires and removes the services.
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={"Content-Type": "application/json"},
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={"Content-Type": "application/json"},
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={"Content-Type": "application/json"},
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forget-connect",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Safely connect Claude Code, Codex, and Claude Desktop to Forget memory.",
5
5
  "type": "module",
6
6
  "bin": {
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
- --no-proxy Do not wire the local capture proxy (launchd service +
89
- ANTHROPIC_BASE_URL override + health watchdog; macOS only)
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: true,
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
- await inspectClients(clients, { url: options.url, apiKey, urlFor }),
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
+ }