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/src/hooks.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // The hooks layer is the product thesis made real: memory that arrives
2
- // without being asked. Claude Code gets four harness hooks — a session-start
3
- // context capsule, per-turn push recall with conflict-zone alerts, and
4
- // session capture feeding the outcome flywheel. Other clients (Codex,
5
- // Desktop) rely on instruction rules until they grow hook systems.
2
+ // without being asked. Claude Code and Codex share the same scripts and
3
+ // ownership rules, while each receives only lifecycle events its client
4
+ // actually emits. Desktop continues to rely on instruction rules.
6
5
  //
7
6
  // First rule of touching ~/.claude/settings.json: it is the user's file.
8
7
  // Foreign hooks (other tools register there too) must survive connect,
@@ -20,6 +19,7 @@ export const HOOK_SCRIPTS = [
20
19
  "forget_sessionstart.py",
21
20
  "forget_turnrecall.py",
22
21
  "forget_capture.py",
22
+ "forget_bstate.py", // compact structured working state shared by both clients
23
23
  "forget_project.py", // shared project-boundary detection (imported by the others)
24
24
  "forget_projecttag.py", // PreToolUse: stamps memory/task writes with the cwd's project
25
25
  ];
@@ -47,6 +47,9 @@ export function hooksDirFor(options = {}) {
47
47
  export function settingsPathFor(options = {}) {
48
48
  const env = options.env ?? process.env;
49
49
  const home = options.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
50
+ if (options.clientId === "codex") {
51
+ return path.join(env.CODEX_HOME ?? path.join(home, ".codex"), "hooks.json");
52
+ }
50
53
  return path.join(home, ".claude", "settings.json");
51
54
  }
52
55
 
@@ -55,8 +58,8 @@ export function hookCommand(script, { hooksDir, url }) {
55
58
  return `FORGET_MCP_URL=${shellQuote(url)} python3 ${shellQuote(scriptPath)}`;
56
59
  }
57
60
 
58
- export function hookEntries({ hooksDir, url }) {
59
- return {
61
+ export function hookEntries({ hooksDir, url, clientId = "claude-code" }) {
62
+ const shared = {
60
63
  SessionStart: {
61
64
  command: hookCommand("forget_sessionstart.py", { hooksDir, url }),
62
65
  timeout: 12,
@@ -67,6 +70,23 @@ export function hookEntries({ hooksDir, url }) {
67
70
  timeout: 6,
68
71
  statusMessage: "forget: checking relevant memories",
69
72
  },
73
+ };
74
+ if (clientId === "codex") {
75
+ return {
76
+ ...shared,
77
+ SessionStart: {
78
+ ...shared.SessionStart,
79
+ matcher: "startup|resume|compact",
80
+ },
81
+ Stop: {
82
+ command: hookCommand("forget_capture.py", { hooksDir, url }),
83
+ timeout: 10,
84
+ statusMessage: "forget: capturing turn + outcome",
85
+ },
86
+ };
87
+ }
88
+ return {
89
+ ...shared,
70
90
  PreCompact: {
71
91
  command: hookCommand("forget_capture.py", { hooksDir, url }),
72
92
  timeout: 10,
@@ -127,10 +147,11 @@ function validatedHooks(config, label) {
127
147
  return hooks;
128
148
  }
129
149
 
130
- export function connectHooksSettings(raw, { hooksDir, url }) {
131
- const config = parseJsonStrict(raw, "settings.json");
132
- const hooks = validatedHooks(config, "settings.json");
133
- for (const [event, entry] of Object.entries(hookEntries({ hooksDir, url }))) {
150
+ export function connectHooksSettings(raw, { hooksDir, url, clientId = "claude-code" }) {
151
+ const label = clientId === "codex" ? "hooks.json" : "settings.json";
152
+ const config = parseJsonStrict(raw, label);
153
+ const hooks = validatedHooks(config, label);
154
+ for (const [event, entry] of Object.entries(hookEntries({ hooksDir, url, clientId }))) {
134
155
  const groups = withoutOurHooks(hooks[event] ?? []);
135
156
  const group = {
136
157
  hooks: [{
@@ -148,10 +169,11 @@ export function connectHooksSettings(raw, { hooksDir, url }) {
148
169
  return `${JSON.stringify(config, null, 2)}\n`;
149
170
  }
150
171
 
151
- export function disconnectHooksSettings(raw) {
152
- const config = parseJsonStrict(raw, "settings.json");
172
+ export function disconnectHooksSettings(raw, { clientId = "claude-code" } = {}) {
173
+ const label = clientId === "codex" ? "hooks.json" : "settings.json";
174
+ const config = parseJsonStrict(raw, label);
153
175
  if (config.hooks === undefined) return raw;
154
- const hooks = validatedHooks(config, "settings.json");
176
+ const hooks = validatedHooks(config, label);
155
177
  let touched = false;
156
178
  for (const [event, groups] of Object.entries(hooks)) {
157
179
  const cleaned = withoutOurHooks(groups);
@@ -168,15 +190,19 @@ export function disconnectHooksSettings(raw) {
168
190
  return `${JSON.stringify(config, null, 2)}\n`;
169
191
  }
170
192
 
171
- export function hooksInstalled(raw, { hooksDir } = {}) {
193
+ export function hooksInstalled(raw, { hooksDir, clientId = "claude-code" } = {}) {
172
194
  let config;
173
195
  try {
174
- config = parseJsonStrict(raw, "settings.json");
196
+ config = parseJsonStrict(raw, clientId === "codex" ? "hooks.json" : "settings.json");
175
197
  } catch {
176
198
  return false;
177
199
  }
178
200
  if (!isObject(config.hooks)) return false;
179
- const events = Object.keys(hookEntries({ hooksDir: hooksDir ?? "", url: "http://x" }));
201
+ const events = Object.keys(hookEntries({
202
+ hooksDir: hooksDir ?? "",
203
+ url: "http://x",
204
+ clientId,
205
+ }));
180
206
  return events.every((event) => {
181
207
  const groups = config.hooks[event];
182
208
  return Array.isArray(groups) && groups.some(
@@ -214,16 +240,16 @@ export async function installHookScripts(hooksDir) {
214
240
  return written;
215
241
  }
216
242
 
217
- export async function inspectHooks({ env } = {}) {
243
+ export async function inspectHooks({ env, clientId = "claude-code" } = {}) {
218
244
  const hooksDir = hooksDirFor({ env });
219
- const settingsPath = settingsPathFor({ env });
245
+ const settingsPath = settingsPathFor({ env, clientId });
220
246
  let settingsRaw = "";
221
247
  try {
222
248
  settingsRaw = await readFile(settingsPath, "utf8");
223
249
  } catch {
224
250
  settingsRaw = "";
225
251
  }
226
- const registered = hooksInstalled(settingsRaw, { hooksDir });
252
+ const registered = hooksInstalled(settingsRaw, { hooksDir, clientId });
227
253
  let scriptsPresent = true;
228
254
  for (const script of HOOK_SCRIPTS) {
229
255
  try {
package/src/proxy.js ADDED
@@ -0,0 +1,431 @@
1
+ // Zero-config capture proxy wiring (proxy-native redesign §1).
2
+ //
3
+ // The proxy is the data plane's spine: pointing Claude Code's
4
+ // ANTHROPIC_BASE_URL at a local passthrough proxy turns every LLM call into
5
+ // an out-of-band capture, no cooperation from the agent required. The user
6
+ // never has to know it exists — connect registers a launchd KeepAlive
7
+ // service (`ai.forget.proxy`, same convention as ai.forget.server) plus a
8
+ // health watchdog, and writes the env override into ~/.claude/settings.json.
9
+ //
10
+ // Three wiring rules, in priority order:
11
+ // (a) an existing custom base URL is *chained* as the proxy's --upstream,
12
+ // never discarded — the user's gateway keeps seeing their traffic;
13
+ // (b) a value that is already our proxy is a no-op;
14
+ // (c) anything unparseable or structurally odd skips the wiring entirely
15
+ // with a warning. Zero-config must never mean silently broken.
16
+ // Disconnect and the watchdog are symmetric: they only ever remove the value
17
+ // *we* wrote, and they restore the original the user had.
18
+
19
+ import { execFile } from "node:child_process";
20
+ import { constants as fsConstants } from "node:fs";
21
+ import { access, readFile, unlink } from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { atomicWrite, parseJsonStrict } from "./core.js";
26
+
27
+ export const PROXY_LABEL = "ai.forget.proxy";
28
+ export const WATCHDOG_LABEL = "ai.forget.proxy.watchdog";
29
+ export const PROXY_HOST = "127.0.0.1";
30
+ export const PROXY_PORT = 8377;
31
+ export const PROXY_BASE_URL = `http://${PROXY_HOST}:${PROXY_PORT}`;
32
+ export const WATCHDOG_SCRIPT = "forget_proxy_watchdog.py";
33
+ export const WATCHDOG_INTERVAL_SECONDS = 60;
34
+
35
+ const ENV_KEY = "ANTHROPIC_BASE_URL";
36
+ const LOOPBACK_HOSTS = new Set([PROXY_HOST, "localhost", "[::1]"]);
37
+
38
+ function isObject(value) {
39
+ return value !== null && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+
42
+ // --- paths -------------------------------------------------------------------
43
+
44
+ function homeFor(options = {}) {
45
+ const env = options.env ?? process.env;
46
+ return options.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
47
+ }
48
+
49
+ export function forgetHomeFor(options = {}) {
50
+ const env = options.env ?? process.env;
51
+ return env.FORGET_HOME || path.join(homeFor(options), ".forget");
52
+ }
53
+
54
+ export function proxyDirFor(options = {}) {
55
+ return path.join(forgetHomeFor(options), "proxy");
56
+ }
57
+
58
+ export function wiringStatePathFor(options = {}) {
59
+ return path.join(proxyDirFor(options), "wiring.json");
60
+ }
61
+
62
+ export function watchdogStatePathFor(options = {}) {
63
+ return path.join(proxyDirFor(options), "watchdog-state.json");
64
+ }
65
+
66
+ export function watchdogScriptPathFor(options = {}) {
67
+ return path.join(proxyDirFor(options), WATCHDOG_SCRIPT);
68
+ }
69
+
70
+ export function launchAgentsDirFor(options = {}) {
71
+ return path.join(homeFor(options), "Library", "LaunchAgents");
72
+ }
73
+
74
+ export function plistPathFor(label, options = {}) {
75
+ return path.join(launchAgentsDirFor(options), `${label}.plist`);
76
+ }
77
+
78
+ // --- settings.json env wiring (pure) ----------------------------------------
79
+
80
+ export function isProxyBaseUrl(value) {
81
+ if (typeof value !== "string") return false;
82
+ let parsed;
83
+ try {
84
+ parsed = new URL(value.trim());
85
+ } catch {
86
+ return false;
87
+ }
88
+ return (
89
+ parsed.protocol === "http:"
90
+ && LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())
91
+ && (parsed.port || "80") === String(PROXY_PORT)
92
+ && (parsed.pathname === "/" || parsed.pathname === "")
93
+ && !parsed.search
94
+ );
95
+ }
96
+
97
+ function chainableUpstream(value) {
98
+ let parsed;
99
+ try {
100
+ parsed = new URL(value.trim());
101
+ } catch {
102
+ return null;
103
+ }
104
+ if (!["http:", "https:"].includes(parsed.protocol)) return null;
105
+ return value.trim().replace(/\/+$/, "");
106
+ }
107
+
108
+ /**
109
+ * Decide what connect does to the settings.json env block.
110
+ * Returns { status: "wire"|"noop"|"skip", next, upstream, original, reason }.
111
+ * Pure: never touches the filesystem, never throws on user data.
112
+ */
113
+ export function wireProxyEnv(raw) {
114
+ let config;
115
+ try {
116
+ config = parseJsonStrict(raw, "settings.json");
117
+ } catch {
118
+ // (c) a settings file we cannot parse is a settings file we do not edit.
119
+ return {
120
+ status: "skip",
121
+ next: raw,
122
+ upstream: null,
123
+ original: null,
124
+ reason: "settings.json is not valid JSON",
125
+ };
126
+ }
127
+ if (config.env !== undefined && !isObject(config.env)) {
128
+ return {
129
+ status: "skip",
130
+ next: raw,
131
+ upstream: null,
132
+ original: null,
133
+ reason: "the existing env value in settings.json is not a JSON object",
134
+ };
135
+ }
136
+ const env = config.env ?? {};
137
+ const current = env[ENV_KEY];
138
+ if (current === undefined || current === "") {
139
+ // Fresh wire: no override existed; upstream stays the proxy's default.
140
+ env[ENV_KEY] = PROXY_BASE_URL;
141
+ config.env = env;
142
+ return {
143
+ status: "wire",
144
+ next: `${JSON.stringify(config, null, 2)}\n`,
145
+ upstream: null,
146
+ original: null,
147
+ reason: "",
148
+ };
149
+ }
150
+ if (isProxyBaseUrl(current)) {
151
+ // (b) already us — nothing to do.
152
+ return { status: "noop", next: raw, upstream: null, original: null, reason: "" };
153
+ }
154
+ if (typeof current !== "string") {
155
+ return {
156
+ status: "skip",
157
+ next: raw,
158
+ upstream: null,
159
+ original: null,
160
+ reason: `the existing ${ENV_KEY} in settings.json is not a string`,
161
+ };
162
+ }
163
+ const upstream = chainableUpstream(current);
164
+ if (!upstream) {
165
+ return {
166
+ status: "skip",
167
+ next: raw,
168
+ upstream: null,
169
+ original: null,
170
+ reason: `the existing ${ENV_KEY} in settings.json is not an http(s) URL`,
171
+ };
172
+ }
173
+ // (a) chain the user's gateway behind the proxy and take its place.
174
+ env[ENV_KEY] = PROXY_BASE_URL;
175
+ config.env = env;
176
+ return {
177
+ status: "wire",
178
+ next: `${JSON.stringify(config, null, 2)}\n`,
179
+ upstream,
180
+ original: current,
181
+ reason: "",
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Remove the env override — only when the current value is ours — and put
187
+ * back whatever the user originally had. Symmetric with wireProxyEnv.
188
+ * Returns { status: "unwired"|"noop"|"skip", next, reason }.
189
+ */
190
+ export function unwireProxyEnv(raw, { original = null } = {}) {
191
+ let config;
192
+ try {
193
+ config = parseJsonStrict(raw, "settings.json");
194
+ } catch {
195
+ return { status: "skip", next: raw, reason: "settings.json is not valid JSON" };
196
+ }
197
+ if (!isObject(config.env) || !(ENV_KEY in config.env)) {
198
+ return { status: "noop", next: raw, reason: "" };
199
+ }
200
+ if (!isProxyBaseUrl(config.env[ENV_KEY])) {
201
+ // Not our value — the user owns it now; never touch.
202
+ return { status: "noop", next: raw, reason: "" };
203
+ }
204
+ if (typeof original === "string" && original) {
205
+ config.env[ENV_KEY] = original;
206
+ } else {
207
+ delete config.env[ENV_KEY];
208
+ if (!Object.keys(config.env).length) delete config.env;
209
+ }
210
+ return { status: "unwired", next: `${JSON.stringify(config, null, 2)}\n`, reason: "" };
211
+ }
212
+
213
+ // --- launchd plists (same convention as forget/cli.py's ai.forget.server) ---
214
+
215
+ function xmlEscape(value) {
216
+ return String(value)
217
+ .replaceAll("&", "&")
218
+ .replaceAll("<", "&lt;")
219
+ .replaceAll(">", "&gt;");
220
+ }
221
+
222
+ export function proxyPlist({ execPath, upstream = null, forgetHome, logPath }) {
223
+ const args = [execPath, "--host", PROXY_HOST, "--port", String(PROXY_PORT)];
224
+ // --upstream only when chaining a custom gateway; the default upstream
225
+ // stays owned by the proxy binary so it cannot drift in a stale plist.
226
+ if (upstream) args.push("--upstream", upstream);
227
+ const argStrings = args
228
+ .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
229
+ .join("\n");
230
+ return `<?xml version="1.0" encoding="UTF-8"?>
231
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
232
+ <plist version="1.0">
233
+ <dict>
234
+ <key>Label</key><string>${PROXY_LABEL}</string>
235
+ <key>ProgramArguments</key>
236
+ <array>
237
+ ${argStrings}
238
+ </array>
239
+ <key>EnvironmentVariables</key>
240
+ <dict><key>FORGET_HOME</key><string>${xmlEscape(forgetHome)}</string></dict>
241
+ <key>RunAtLoad</key><true/>
242
+ <key>KeepAlive</key><true/>
243
+ <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
244
+ <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
245
+ </dict>
246
+ </plist>
247
+ `;
248
+ }
249
+
250
+ export function watchdogPlist({ python3, scriptPath, forgetHome, logPath }) {
251
+ return `<?xml version="1.0" encoding="UTF-8"?>
252
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
+ <plist version="1.0">
254
+ <dict>
255
+ <key>Label</key><string>${WATCHDOG_LABEL}</string>
256
+ <key>ProgramArguments</key>
257
+ <array>
258
+ <string>${xmlEscape(python3)}</string>
259
+ <string>${xmlEscape(scriptPath)}</string>
260
+ </array>
261
+ <key>EnvironmentVariables</key>
262
+ <dict><key>FORGET_HOME</key><string>${xmlEscape(forgetHome)}</string></dict>
263
+ <key>RunAtLoad</key><true/>
264
+ <key>StartInterval</key><integer>${WATCHDOG_INTERVAL_SECONDS}</integer>
265
+ <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
266
+ <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
267
+ </dict>
268
+ </plist>
269
+ `;
270
+ }
271
+
272
+ // --- wiring state (what the watchdog and disconnect need to undo us) --------
273
+
274
+ export async function readWiringState(options = {}) {
275
+ let raw;
276
+ try {
277
+ raw = await readFile(wiringStatePathFor(options), "utf8");
278
+ } catch {
279
+ return null;
280
+ }
281
+ try {
282
+ const parsed = JSON.parse(raw);
283
+ return isObject(parsed) ? parsed : null;
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ export async function writeWiringState(options, { upstream, original }) {
290
+ const state = {
291
+ proxy_url: PROXY_BASE_URL,
292
+ original_base_url: original ?? null,
293
+ upstream: upstream ?? null,
294
+ wired_at: new Date().toISOString(),
295
+ };
296
+ await atomicWrite(
297
+ wiringStatePathFor(options),
298
+ `${JSON.stringify(state, null, 2)}\n`,
299
+ 0o600,
300
+ );
301
+ return state;
302
+ }
303
+
304
+ // --- executables -------------------------------------------------------------
305
+
306
+ export async function findExecutable(name, options = {}) {
307
+ const env = options.env ?? process.env;
308
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
309
+ if (!dir) continue;
310
+ const candidate = path.join(dir, name);
311
+ try {
312
+ await access(candidate, fsConstants.X_OK);
313
+ return candidate;
314
+ } catch {
315
+ // keep looking
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+
321
+ // --- launchd side effects -----------------------------------------------------
322
+
323
+ function launchctl(args) {
324
+ // bootout of a not-loaded service and bootstrap of an already-running one
325
+ // both fail by design; callers decide which failures matter.
326
+ return new Promise((resolve) => {
327
+ execFile("launchctl", args, (error, stdout, stderr) => {
328
+ resolve({ ok: !error, stdout: String(stdout), stderr: String(stderr) });
329
+ });
330
+ });
331
+ }
332
+
333
+ export function launchctlEnabled(options = {}) {
334
+ const env = options.env ?? process.env;
335
+ const platform = options.platform ?? process.platform;
336
+ // FORGET_PROXY_LAUNCHCTL=skip is the test seam: file wiring is exercised
337
+ // end-to-end while the real launchd is never touched from a test run.
338
+ return platform === "darwin" && env.FORGET_PROXY_LAUNCHCTL !== "skip";
339
+ }
340
+
341
+ function watchdogAssetPath() {
342
+ return fileURLToPath(new URL(`../assets/proxy/${WATCHDOG_SCRIPT}`, import.meta.url));
343
+ }
344
+
345
+ /**
346
+ * Install everything except the settings.json edit (the caller owns that
347
+ * through the normal change plan): watchdog script, both plists, launchd
348
+ * registration. Returns { installed, written, reason }.
349
+ */
350
+ export async function installProxyServices(options, { upstream = null } = {}) {
351
+ const execPath = await findExecutable("forget-proxy", options);
352
+ if (!execPath) {
353
+ return {
354
+ installed: false,
355
+ written: [],
356
+ reason: "forget-proxy executable not found on PATH (pip install 'forget-ai[server]')",
357
+ };
358
+ }
359
+ const python3 = (await findExecutable("python3", options)) ?? "/usr/bin/python3";
360
+ const forgetHome = forgetHomeFor(options);
361
+ const written = [];
362
+
363
+ const scriptPath = watchdogScriptPathFor(options);
364
+ await atomicWrite(scriptPath, await readFile(watchdogAssetPath(), "utf8"), 0o755);
365
+ written.push(scriptPath);
366
+
367
+ const proxyPlistPath = plistPathFor(PROXY_LABEL, options);
368
+ await atomicWrite(
369
+ proxyPlistPath,
370
+ proxyPlist({ execPath, upstream, forgetHome, logPath: path.join(forgetHome, "proxy.log") }),
371
+ 0o644,
372
+ );
373
+ written.push(proxyPlistPath);
374
+
375
+ const watchdogPlistPath = plistPathFor(WATCHDOG_LABEL, options);
376
+ await atomicWrite(
377
+ watchdogPlistPath,
378
+ watchdogPlist({
379
+ python3,
380
+ scriptPath,
381
+ forgetHome,
382
+ logPath: path.join(forgetHome, "proxy-watchdog.log"),
383
+ }),
384
+ 0o644,
385
+ );
386
+ written.push(watchdogPlistPath);
387
+
388
+ if (launchctlEnabled(options)) {
389
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 0}`;
390
+ for (const [label, plistFile] of [
391
+ [PROXY_LABEL, proxyPlistPath],
392
+ [WATCHDOG_LABEL, watchdogPlistPath],
393
+ ]) {
394
+ await launchctl(["bootout", `${domain}/${label}`]);
395
+ const boot = await launchctl(["bootstrap", domain, plistFile]);
396
+ if (!boot.ok) {
397
+ return {
398
+ installed: false,
399
+ written,
400
+ reason: `launchctl bootstrap ${label} failed: ${boot.stderr.trim() || "unknown error"}`,
401
+ };
402
+ }
403
+ }
404
+ }
405
+ return { installed: true, written, reason: "", execPath };
406
+ }
407
+
408
+ /** Symmetric teardown: bootout, then remove every file connect created. */
409
+ export async function removeProxyServices(options = {}) {
410
+ if (launchctlEnabled(options)) {
411
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 0}`;
412
+ await launchctl(["bootout", `${domain}/${WATCHDOG_LABEL}`]);
413
+ await launchctl(["bootout", `${domain}/${PROXY_LABEL}`]);
414
+ }
415
+ const removed = [];
416
+ for (const target of [
417
+ plistPathFor(WATCHDOG_LABEL, options),
418
+ plistPathFor(PROXY_LABEL, options),
419
+ watchdogScriptPathFor(options),
420
+ wiringStatePathFor(options),
421
+ watchdogStatePathFor(options),
422
+ ]) {
423
+ try {
424
+ await unlink(target);
425
+ removed.push(target);
426
+ } catch (error) {
427
+ if (!error || error.code !== "ENOENT") throw error;
428
+ }
429
+ }
430
+ return removed;
431
+ }
package/src/skills.js ADDED
@@ -0,0 +1,95 @@
1
+ import { readFile, rmdir, unlink } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { atomicWrite } from "./core.js";
7
+
8
+ export const MEMORY_AGENT_SKILL = "memory-agent";
9
+ export const SKILL_MARKER = "forget-connect:skill";
10
+ const SKILL_FILES = ["SKILL.md", path.join("agents", "openai.yaml")];
11
+
12
+ function assetRoot() {
13
+ return fileURLToPath(new URL(`../assets/skills/${MEMORY_AGENT_SKILL}/`, import.meta.url));
14
+ }
15
+
16
+ function homeFor(env = process.env) {
17
+ return env.HOME ?? env.USERPROFILE ?? os.homedir();
18
+ }
19
+
20
+ export function skillDirFor(clientId, { env = process.env } = {}) {
21
+ const home = homeFor(env);
22
+ if (clientId === "codex") {
23
+ const codexHome = env.CODEX_HOME ? path.resolve(env.CODEX_HOME) : path.join(home, ".codex");
24
+ return path.join(codexHome, "skills", MEMORY_AGENT_SKILL);
25
+ }
26
+ if (clientId === "claude-code") {
27
+ return path.join(home, ".claude", "skills", MEMORY_AGENT_SKILL);
28
+ }
29
+ return "";
30
+ }
31
+
32
+ async function readOptional(filePath) {
33
+ try {
34
+ return await readFile(filePath, "utf8");
35
+ } catch (error) {
36
+ if (error?.code === "ENOENT") return null;
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ async function ownedSkill(targetDir) {
42
+ const entry = await readOptional(path.join(targetDir, "SKILL.md"));
43
+ return entry === null || entry.includes(SKILL_MARKER);
44
+ }
45
+
46
+ export async function installMemoryAgentSkill(clientId, { env = process.env } = {}) {
47
+ const targetDir = skillDirFor(clientId, { env });
48
+ if (!targetDir) return { written: [], skipped: [] };
49
+ if (!(await ownedSkill(targetDir))) {
50
+ return { written: [], skipped: [targetDir] };
51
+ }
52
+ const written = [];
53
+ for (const relative of SKILL_FILES) {
54
+ const source = await readFile(path.join(assetRoot(), relative), "utf8");
55
+ const target = path.join(targetDir, relative);
56
+ const existing = await readOptional(target);
57
+ if (existing === source) continue;
58
+ await atomicWrite(target, source, 0o644);
59
+ written.push(target);
60
+ }
61
+ return { written, skipped: [] };
62
+ }
63
+
64
+ export async function removeMemoryAgentSkill(clientId, { env = process.env } = {}) {
65
+ const targetDir = skillDirFor(clientId, { env });
66
+ if (!targetDir || !(await ownedSkill(targetDir))) return { removed: [], kept: targetDir ? [targetDir] : [] };
67
+ const removed = [];
68
+ for (const relative of [...SKILL_FILES].reverse()) {
69
+ const target = path.join(targetDir, relative);
70
+ try {
71
+ await unlink(target);
72
+ removed.push(target);
73
+ } catch (error) {
74
+ if (error?.code !== "ENOENT") throw error;
75
+ }
76
+ }
77
+ await rmdir(path.join(targetDir, "agents")).catch((error) => {
78
+ if (!error || !["ENOENT", "ENOTEMPTY"].includes(error.code)) throw error;
79
+ });
80
+ await rmdir(targetDir).catch((error) => {
81
+ if (!error || !["ENOENT", "ENOTEMPTY"].includes(error.code)) throw error;
82
+ });
83
+ return { removed, kept: [] };
84
+ }
85
+
86
+ export async function inspectMemoryAgentSkill(clientId, { env = process.env } = {}) {
87
+ const targetDir = skillDirFor(clientId, { env });
88
+ if (!targetDir) return { installed: false, current: false, path: "" };
89
+ const installed = await readOptional(path.join(targetDir, "SKILL.md"));
90
+ if (installed === null || !installed.includes(SKILL_MARKER)) {
91
+ return { installed: false, current: false, path: targetDir };
92
+ }
93
+ const expected = await readFile(path.join(assetRoot(), "SKILL.md"), "utf8");
94
+ return { installed: true, current: installed === expected, path: targetDir };
95
+ }