myagentmemory 0.4.13 → 0.4.14

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.ts ADDED
@@ -0,0 +1,485 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ let homeDirOverride: string | null = null;
6
+
7
+ /** Override the detected home directory in deterministic tests. */
8
+ export function _setHookHomeDirForTest(directory: string | null): void {
9
+ homeDirOverride = directory;
10
+ }
11
+
12
+ function resolveHomeDir(): string | null {
13
+ if (homeDirOverride !== null) return homeDirOverride;
14
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
15
+ return home && home !== "~" ? home : null;
16
+ }
17
+
18
+ function commandExists(command: string): boolean {
19
+ const envPath = process.env.PATH ?? "";
20
+ if (!envPath) return false;
21
+ const extensions =
22
+ process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""];
23
+ for (const directory of envPath.split(path.delimiter).filter(Boolean)) {
24
+ for (const extension of extensions) {
25
+ const filename = process.platform === "win32" ? `${command}${extension}` : command;
26
+ try {
27
+ fs.accessSync(path.join(directory, filename), fs.constants.X_OK);
28
+ return true;
29
+ } catch {}
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Hook installers (SessionStart auto-injection)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi";
40
+
41
+ export interface HookTargetInfo {
42
+ key: HookAgentKey;
43
+ label: string;
44
+ homeMarker: string;
45
+ detectFiles: string[];
46
+ detectCommand?: string;
47
+ supported: boolean;
48
+ unsupportedReason?: string;
49
+ }
50
+
51
+ export interface DetectedHookTarget extends HookTargetInfo {
52
+ detected: boolean;
53
+ detectReason?: string;
54
+ }
55
+
56
+ const HOOK_MARKER_JSON = "_agentMemory";
57
+ const HOOK_MARKER_BEGIN = "# BEGIN agent-memory hook";
58
+ const HOOK_MARKER_END = "# END agent-memory hook";
59
+
60
+ function sessionStartHookCommand(agent: "claude" | "codex"): string {
61
+ return `agent-memory hook session-start --agent ${agent}`;
62
+ }
63
+
64
+ function hookTargets(homeDir: string): HookTargetInfo[] {
65
+ return [
66
+ {
67
+ key: "claude",
68
+ label: "Claude Code",
69
+ homeMarker: path.join(homeDir, ".claude"),
70
+ detectFiles: [
71
+ path.join(homeDir, ".claude", "settings.json"),
72
+ path.join(homeDir, ".claude", "settings.local.json"),
73
+ ],
74
+ detectCommand: "claude",
75
+ supported: true,
76
+ },
77
+ {
78
+ key: "codex",
79
+ label: "Codex",
80
+ homeMarker: path.join(homeDir, ".codex"),
81
+ detectFiles: [path.join(homeDir, ".codex", "config.toml")],
82
+ detectCommand: "codex",
83
+ supported: true,
84
+ },
85
+ {
86
+ key: "cursor",
87
+ label: "Cursor",
88
+ homeMarker: path.join(homeDir, ".cursor"),
89
+ detectFiles: [],
90
+ supported: true,
91
+ },
92
+ {
93
+ key: "opencode",
94
+ label: "opencode",
95
+ homeMarker: path.join(homeDir, ".config", "opencode"),
96
+ detectFiles: [path.join(homeDir, ".config", "opencode", "opencode.json")],
97
+ detectCommand: "opencode",
98
+ supported: true,
99
+ },
100
+ {
101
+ key: "pi",
102
+ label: "pi",
103
+ homeMarker: path.join(homeDir, ".pi"),
104
+ detectFiles: [],
105
+ detectCommand: "pi",
106
+ supported: false,
107
+ unsupportedReason: "no documented SessionStart hook mechanism",
108
+ },
109
+ ];
110
+ }
111
+
112
+ export function detectHookAgents(): { homeDir: string | null; targets: DetectedHookTarget[] } {
113
+ const homeDir = resolveHomeDir();
114
+ if (!homeDir) return { homeDir: null, targets: [] };
115
+ const targets = hookTargets(homeDir).map<DetectedHookTarget>((target) => {
116
+ if (!fs.existsSync(target.homeMarker)) {
117
+ return { ...target, detected: false, detectReason: `${target.homeMarker} not found` };
118
+ }
119
+ const byFile = target.detectFiles.some((f) => fs.existsSync(f));
120
+ const byCommand = target.detectCommand ? commandExists(target.detectCommand) : false;
121
+ const requires = target.detectFiles.length > 0 || !!target.detectCommand;
122
+ if (requires && !byFile && !byCommand) {
123
+ return { ...target, detected: false, detectReason: "not detected" };
124
+ }
125
+ return { ...target, detected: true };
126
+ });
127
+ return { homeDir, targets };
128
+ }
129
+
130
+ export interface HookInstallResult {
131
+ key: HookAgentKey;
132
+ label: string;
133
+ installed: boolean;
134
+ path?: string;
135
+ backup?: string;
136
+ reason?: string;
137
+ }
138
+
139
+ export interface InstallHooksReport {
140
+ ok: boolean;
141
+ homeDir?: string;
142
+ results: HookInstallResult[];
143
+ error?: string;
144
+ }
145
+
146
+ function backupOnce(filePath: string): string | undefined {
147
+ if (!fs.existsSync(filePath)) return undefined;
148
+ const backupPath = `${filePath}.agent-memory.bak`;
149
+ if (!fs.existsSync(backupPath)) {
150
+ fs.copyFileSync(filePath, backupPath);
151
+ }
152
+ return backupPath;
153
+ }
154
+
155
+ function readJsonConfig(filePath: string): Record<string, unknown> {
156
+ if (!fs.existsSync(filePath)) return {};
157
+ let parsed: unknown;
158
+ try {
159
+ const raw = fs.readFileSync(filePath, "utf-8");
160
+ parsed = raw.trim() ? JSON.parse(raw) : {};
161
+ } catch (error) {
162
+ const detail = error instanceof Error ? error.message : String(error);
163
+ throw new Error(`cannot modify invalid JSON config ${filePath}: ${detail}`);
164
+ }
165
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
166
+ throw new Error(`cannot modify JSON config ${filePath}: root value must be an object`);
167
+ }
168
+ return parsed as Record<string, unknown>;
169
+ }
170
+
171
+ function writeJson(filePath: string, data: unknown) {
172
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
173
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
174
+ }
175
+
176
+ function installClaudeCodeHook(homeDir: string): HookInstallResult {
177
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
178
+ const backup = backupOnce(settingsPath);
179
+ const settings = readJsonConfig(settingsPath);
180
+ const hooks = (settings.hooks as Record<string, unknown>) ?? {};
181
+ const sessionStart = Array.isArray(hooks.SessionStart) ? [...(hooks.SessionStart as unknown[])] : [];
182
+
183
+ // Idempotency: look for any existing entry tagged with our marker.
184
+ const command = sessionStartHookCommand("claude");
185
+ let managed = 0;
186
+ let updated = 0;
187
+ for (const group of sessionStart) {
188
+ if (!group || typeof group !== "object") continue;
189
+ const g = group as Record<string, unknown>;
190
+ const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
191
+ for (const hook of list) {
192
+ if (!hook || typeof hook !== "object") continue;
193
+ const managedHook = hook as Record<string, unknown>;
194
+ if (managedHook[HOOK_MARKER_JSON] !== true) continue;
195
+ managed++;
196
+ if (managedHook.command !== command) {
197
+ managedHook.command = command;
198
+ updated++;
199
+ }
200
+ }
201
+ }
202
+ if (managed && !updated) {
203
+ return { key: "claude", label: "Claude Code", installed: false, path: settingsPath, reason: "already installed" };
204
+ }
205
+ if (updated) {
206
+ hooks.SessionStart = sessionStart;
207
+ settings.hooks = hooks;
208
+ writeJson(settingsPath, settings);
209
+ return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup, reason: "updated" };
210
+ }
211
+
212
+ sessionStart.push({
213
+ matcher: "startup|resume",
214
+ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }],
215
+ });
216
+ hooks.SessionStart = sessionStart;
217
+ settings.hooks = hooks;
218
+ writeJson(settingsPath, settings);
219
+ return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup };
220
+ }
221
+
222
+ function installCodexHook(homeDir: string): HookInstallResult {
223
+ const configPath = path.join(homeDir, ".codex", "config.toml");
224
+ const backup = backupOnce(configPath);
225
+ const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf-8") : "";
226
+ const command = sessionStartHookCommand("codex");
227
+ const block = [
228
+ HOOK_MARKER_BEGIN,
229
+ "[[hooks.SessionStart]]",
230
+ 'matcher = "startup|resume"',
231
+ "",
232
+ "[[hooks.SessionStart.hooks]]",
233
+ 'type = "command"',
234
+ `command = "${command}"`,
235
+ HOOK_MARKER_END,
236
+ ].join("\n");
237
+ if (existing.includes(HOOK_MARKER_BEGIN)) {
238
+ const escapeRe = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+ const pattern = new RegExp(`${escapeRe(HOOK_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(HOOK_MARKER_END)}`);
240
+ const current = existing.match(pattern)?.[0] ?? "";
241
+ if (current.includes(`command = "${command}"`)) {
242
+ return { key: "codex", label: "Codex", installed: false, path: configPath, reason: "already installed" };
243
+ }
244
+ fs.writeFileSync(configPath, existing.replace(pattern, block), "utf-8");
245
+ return { key: "codex", label: "Codex", installed: true, path: configPath, backup, reason: "updated" };
246
+ }
247
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
248
+ const next = `${existing}${separator}${block}\n`;
249
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
250
+ fs.writeFileSync(configPath, next, "utf-8");
251
+ return { key: "codex", label: "Codex", installed: true, path: configPath, backup };
252
+ }
253
+
254
+ const CURSOR_RULE_BODY = `---
255
+ description: Load persistent memory context from agent-memory
256
+ alwaysApply: true
257
+ ---
258
+
259
+ At the start of every conversation, and whenever the user references prior
260
+ context, run:
261
+
262
+ agent-memory context
263
+
264
+ Treat its stdout as authoritative context about the user, prior sessions,
265
+ scratchpad items, and long-term memory. Prefer it over guessing.
266
+ `;
267
+
268
+ function installCursorRule(homeDir: string): HookInstallResult {
269
+ const rulesDir = path.join(homeDir, ".cursor", "rules");
270
+ const rulePath = path.join(rulesDir, "agent-memory.mdc");
271
+ if (fs.existsSync(rulePath)) {
272
+ return { key: "cursor", label: "Cursor", installed: false, path: rulePath, reason: "already installed" };
273
+ }
274
+ fs.mkdirSync(rulesDir, { recursive: true });
275
+ fs.writeFileSync(rulePath, CURSOR_RULE_BODY, "utf-8");
276
+ return { key: "cursor", label: "Cursor", installed: true, path: rulePath };
277
+ }
278
+
279
+ const OPENCODE_INSTRUCTIONS_BODY = `# agent-memory
280
+
281
+ At the start of every session and before answering context-dependent
282
+ questions, run:
283
+
284
+ agent-memory context
285
+
286
+ Treat its stdout as authoritative context about the user, prior sessions,
287
+ scratchpad items, and long-term memory.
288
+ `;
289
+
290
+ function installOpencodeInstructions(homeDir: string): HookInstallResult {
291
+ const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
292
+ const instructionsPath = path.join(homeDir, ".agent-memory", "hooks", "opencode.md");
293
+ const backup = backupOnce(configPath);
294
+ const config = readJsonConfig(configPath);
295
+ const raw = config.instructions;
296
+ const list = Array.isArray(raw) ? [...(raw as unknown[])] : [];
297
+ if (list.includes(instructionsPath)) {
298
+ return { key: "opencode", label: "opencode", installed: false, path: configPath, reason: "already installed" };
299
+ }
300
+ list.push(instructionsPath);
301
+ config.instructions = list;
302
+ fs.mkdirSync(path.dirname(instructionsPath), { recursive: true });
303
+ fs.writeFileSync(instructionsPath, OPENCODE_INSTRUCTIONS_BODY, "utf-8");
304
+ writeJson(configPath, config);
305
+ return { key: "opencode", label: "opencode", installed: true, path: configPath, backup };
306
+ }
307
+
308
+ export function installHooks(agents: Set<HookAgentKey>): InstallHooksReport {
309
+ const { homeDir, targets } = detectHookAgents();
310
+ if (!homeDir) {
311
+ return {
312
+ ok: false,
313
+ results: [],
314
+ error: "Home directory not found. Set HOME (or USERPROFILE on Windows) and retry.",
315
+ };
316
+ }
317
+
318
+ const results: HookInstallResult[] = [];
319
+ for (const target of targets) {
320
+ if (!agents.has(target.key)) continue;
321
+ if (!target.supported) {
322
+ results.push({
323
+ key: target.key,
324
+ label: target.label,
325
+ installed: false,
326
+ reason: target.unsupportedReason ?? "not supported",
327
+ });
328
+ continue;
329
+ }
330
+ if (!target.detected) {
331
+ results.push({
332
+ key: target.key,
333
+ label: target.label,
334
+ installed: false,
335
+ reason: target.detectReason ?? "not detected",
336
+ });
337
+ continue;
338
+ }
339
+ try {
340
+ if (target.key === "claude") results.push(installClaudeCodeHook(homeDir));
341
+ else if (target.key === "codex") results.push(installCodexHook(homeDir));
342
+ else if (target.key === "cursor") results.push(installCursorRule(homeDir));
343
+ else if (target.key === "opencode") results.push(installOpencodeInstructions(homeDir));
344
+ } catch (err) {
345
+ results.push({
346
+ key: target.key,
347
+ label: target.label,
348
+ installed: false,
349
+ reason: err instanceof Error ? err.message : String(err),
350
+ });
351
+ }
352
+ }
353
+
354
+ return { ok: true, homeDir, results };
355
+ }
356
+
357
+ export interface UninstallHooksReport {
358
+ ok: boolean;
359
+ homeDir?: string;
360
+ results: HookInstallResult[];
361
+ error?: string;
362
+ }
363
+
364
+ function uninstallClaudeCodeHook(homeDir: string): HookInstallResult {
365
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
366
+ if (!fs.existsSync(settingsPath)) {
367
+ return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
368
+ }
369
+ const settings = readJsonConfig(settingsPath);
370
+ const hooks = (settings.hooks as Record<string, unknown>) ?? {};
371
+ const sessionStart = Array.isArray(hooks.SessionStart) ? (hooks.SessionStart as unknown[]) : [];
372
+ let removed = 0;
373
+ const filtered = sessionStart
374
+ .map((group) => {
375
+ if (!group || typeof group !== "object") return group;
376
+ const g = { ...(group as Record<string, unknown>) };
377
+ const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
378
+ const kept = list.filter((h) => {
379
+ const isOurs = h && typeof h === "object" && (h as Record<string, unknown>)[HOOK_MARKER_JSON] === true;
380
+ if (isOurs) removed++;
381
+ return !isOurs;
382
+ });
383
+ g.hooks = kept;
384
+ return g;
385
+ })
386
+ .filter((group) => {
387
+ if (!group || typeof group !== "object") return true;
388
+ const g = group as Record<string, unknown>;
389
+ return Array.isArray(g.hooks) && (g.hooks as unknown[]).length > 0;
390
+ });
391
+ if (removed === 0) {
392
+ return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
393
+ }
394
+ hooks.SessionStart = filtered;
395
+ if (filtered.length === 0) delete (hooks as Record<string, unknown>).SessionStart;
396
+ if (Object.keys(hooks).length === 0) delete (settings as Record<string, unknown>).hooks;
397
+ else settings.hooks = hooks;
398
+ writeJson(settingsPath, settings);
399
+ return { key: "claude", label: "Claude Code", installed: true, path: settingsPath };
400
+ }
401
+
402
+ function uninstallCodexHook(homeDir: string): HookInstallResult {
403
+ const configPath = path.join(homeDir, ".codex", "config.toml");
404
+ if (!fs.existsSync(configPath)) {
405
+ return { key: "codex", label: "Codex", installed: false, reason: "not installed" };
406
+ }
407
+ const existing = fs.readFileSync(configPath, "utf-8");
408
+ if (!existing.includes(HOOK_MARKER_BEGIN)) {
409
+ return { key: "codex", label: "Codex", installed: false, reason: "not installed" };
410
+ }
411
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
412
+ const pattern = new RegExp(`\\n?${escapeRe(HOOK_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(HOOK_MARKER_END)}\\n?`, "g");
413
+ const next = existing.replace(pattern, "");
414
+ fs.writeFileSync(configPath, next, "utf-8");
415
+ return { key: "codex", label: "Codex", installed: true, path: configPath };
416
+ }
417
+
418
+ function uninstallCursorRule(homeDir: string): HookInstallResult {
419
+ const rulePath = path.join(homeDir, ".cursor", "rules", "agent-memory.mdc");
420
+ if (!fs.existsSync(rulePath)) {
421
+ return { key: "cursor", label: "Cursor", installed: false, reason: "not installed" };
422
+ }
423
+ fs.unlinkSync(rulePath);
424
+ try {
425
+ fs.rmdirSync(path.dirname(rulePath));
426
+ } catch {
427
+ // non-empty; fine
428
+ }
429
+ return { key: "cursor", label: "Cursor", installed: true, path: rulePath };
430
+ }
431
+
432
+ function uninstallOpencodeInstructions(homeDir: string): HookInstallResult {
433
+ const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
434
+ const instructionsPath = path.join(homeDir, ".agent-memory", "hooks", "opencode.md");
435
+ let touched = false;
436
+ if (fs.existsSync(configPath)) {
437
+ const config = readJsonConfig(configPath);
438
+ const list = Array.isArray(config.instructions) ? (config.instructions as unknown[]) : [];
439
+ const filtered = list.filter((entry) => entry !== instructionsPath);
440
+ if (filtered.length !== list.length) {
441
+ touched = true;
442
+ if (filtered.length === 0) delete (config as Record<string, unknown>).instructions;
443
+ else config.instructions = filtered;
444
+ writeJson(configPath, config);
445
+ }
446
+ }
447
+ if (fs.existsSync(instructionsPath)) {
448
+ fs.unlinkSync(instructionsPath);
449
+ touched = true;
450
+ }
451
+ if (!touched) {
452
+ return { key: "opencode", label: "opencode", installed: false, reason: "not installed" };
453
+ }
454
+ return { key: "opencode", label: "opencode", installed: true, path: configPath };
455
+ }
456
+
457
+ export function uninstallHooks(agents?: Set<HookAgentKey>): UninstallHooksReport {
458
+ const homeDir = resolveHomeDir();
459
+ if (!homeDir) {
460
+ return {
461
+ ok: false,
462
+ results: [],
463
+ error: "Home directory not found. Set HOME (or USERPROFILE on Windows) and retry.",
464
+ };
465
+ }
466
+ const keys: HookAgentKey[] = ["claude", "codex", "cursor", "opencode"];
467
+ const results: HookInstallResult[] = [];
468
+ for (const key of keys) {
469
+ if (agents && !agents.has(key)) continue;
470
+ try {
471
+ if (key === "claude") results.push(uninstallClaudeCodeHook(homeDir));
472
+ else if (key === "codex") results.push(uninstallCodexHook(homeDir));
473
+ else if (key === "cursor") results.push(uninstallCursorRule(homeDir));
474
+ else if (key === "opencode") results.push(uninstallOpencodeInstructions(homeDir));
475
+ } catch (err) {
476
+ results.push({
477
+ key,
478
+ label: key,
479
+ installed: false,
480
+ reason: err instanceof Error ? err.message : String(err),
481
+ });
482
+ }
483
+ }
484
+ return { ok: true, homeDir, results };
485
+ }