forget-connect 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -21
- package/assets/commands/forget-settings.md +25 -0
- package/assets/commands/forget.md +24 -0
- package/assets/hooks/forget_bstate.py +229 -0
- package/assets/hooks/forget_capture.py +22 -5
- package/assets/hooks/forget_sessionstart.py +21 -3
- package/assets/hooks/forget_toolrecall.py +118 -0
- package/assets/hooks/forget_turnrecall.py +644 -42
- package/assets/plugins/memory-agent/.claude-plugin/plugin.json +15 -0
- package/assets/plugins/memory-agent/.codex-plugin/plugin.json +29 -0
- package/assets/plugins/memory-agent/README.md +9 -0
- package/assets/plugins/memory-agent/skills/memory-agent/SKILL.md +22 -0
- package/assets/plugins/memory-agent/skills/memory-agent/agents/openai.yaml +6 -0
- package/assets/proxy/forget_proxy_watchdog.py +215 -0
- package/assets/skills/memory-agent/SKILL.md +22 -0
- package/assets/skills/memory-agent/agents/openai.yaml +6 -0
- package/package.json +7 -3
- package/src/cli.js +286 -47
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +99 -19
- package/src/proxy.js +431 -0
- package/src/skills.js +95 -0
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
|
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,
|
|
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({
|
|
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 {
|
|
@@ -258,3 +284,57 @@ export async function removeHookScripts(hooksDir) {
|
|
|
258
284
|
}
|
|
259
285
|
return removed;
|
|
260
286
|
}
|
|
287
|
+
|
|
288
|
+
// --- slash commands (/forget, /forget-settings) — the dial in the editor ---
|
|
289
|
+
//
|
|
290
|
+
// Same file-ownership rule as settings.json: a command file without our
|
|
291
|
+
// marker is the user's (hand-written or edited with the marker removed) and
|
|
292
|
+
// is never touched — not on connect, not on disconnect.
|
|
293
|
+
|
|
294
|
+
export const COMMAND_ASSETS = ["forget.md", "forget-settings.md"];
|
|
295
|
+
export const COMMAND_MARKER = "forget-connect:command";
|
|
296
|
+
|
|
297
|
+
export function commandsDirFor(options = {}) {
|
|
298
|
+
const env = options.env ?? process.env;
|
|
299
|
+
const home = options.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
300
|
+
return path.join(home, ".claude", "commands");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function commandAssetsDir() {
|
|
304
|
+
return fileURLToPath(new URL("../assets/commands/", import.meta.url));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function installCommandAssets(commandsDir) {
|
|
308
|
+
const written = [];
|
|
309
|
+
for (const name of COMMAND_ASSETS) {
|
|
310
|
+
const target = path.join(commandsDir, name);
|
|
311
|
+
let existing = null;
|
|
312
|
+
try {
|
|
313
|
+
existing = await readFile(target, "utf8");
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
316
|
+
}
|
|
317
|
+
if (existing !== null && !existing.includes(COMMAND_MARKER)) continue;
|
|
318
|
+
const content = await readFile(path.join(commandAssetsDir(), name), "utf8");
|
|
319
|
+
if (existing === content) continue;
|
|
320
|
+
await atomicWrite(target, content, 0o644);
|
|
321
|
+
written.push(target);
|
|
322
|
+
}
|
|
323
|
+
return written;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function removeCommandAssets(commandsDir) {
|
|
327
|
+
const removed = [];
|
|
328
|
+
for (const name of COMMAND_ASSETS) {
|
|
329
|
+
const target = path.join(commandsDir, name);
|
|
330
|
+
try {
|
|
331
|
+
const existing = await readFile(target, "utf8");
|
|
332
|
+
if (!existing.includes(COMMAND_MARKER)) continue;
|
|
333
|
+
await unlink(target);
|
|
334
|
+
removed.push(target);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return removed;
|
|
340
|
+
}
|
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("<", "<")
|
|
219
|
+
.replaceAll(">", ">");
|
|
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
|
+
}
|