forget-connect 0.5.2 → 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/src/proxy.js ADDED
@@ -0,0 +1,526 @@
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, readdir, readFile, rm, stat, 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
+ }
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
+ }
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
+ }