myagentmemory 0.4.17 → 0.5.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/hooks.ts CHANGED
@@ -2,6 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
+ import { type HookMode, writeHookMode } from "./core.js";
6
+
5
7
  let homeDirOverride: string | null = null;
6
8
 
7
9
  /** Override the detected home directory in deterministic tests. */
@@ -61,6 +63,14 @@ function sessionStartHookCommand(agent: "claude" | "codex"): string {
61
63
  return `agent-memory hook session-start --agent ${agent}`;
62
64
  }
63
65
 
66
+ function userPromptSubmitHookCommand(agent: "claude" | "codex"): string {
67
+ return `agent-memory hook user-prompt-submit --agent ${agent}`;
68
+ }
69
+
70
+ function stopHookCommand(agent: "claude"): string {
71
+ return `agent-memory hook stop --agent ${agent}`;
72
+ }
73
+
64
74
  function hookTargets(homeDir: string): HookTargetInfo[] {
65
75
  return [
66
76
  {
@@ -109,6 +119,91 @@ function hookTargets(homeDir: string): HookTargetInfo[] {
109
119
  ];
110
120
  }
111
121
 
122
+ function hasClaudeHookGroup(homeDir: string, eventKey: string, command: string): boolean {
123
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
124
+ if (!fs.existsSync(settingsPath)) return false;
125
+ const settings = readJsonConfig(settingsPath);
126
+ const hooks = (settings.hooks as Record<string, unknown>) ?? {};
127
+ const groups = Array.isArray(hooks[eventKey]) ? (hooks[eventKey] as unknown[]) : [];
128
+ for (const group of groups) {
129
+ if (!group || typeof group !== "object") continue;
130
+ const g = group as Record<string, unknown>;
131
+ const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
132
+ for (const hook of list) {
133
+ if (!hook || typeof hook !== "object") continue;
134
+ const h = hook as Record<string, unknown>;
135
+ if (h[HOOK_MARKER_JSON] === true && h.command === command) return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Read-only check whether the SessionStart hook for `key` is already present in
143
+ * the user's config. Mirrors each installer's "already installed" detection so
144
+ * the CLI can avoid prompting for hooks that don't need to be installed.
145
+ */
146
+ export function isHookInstalled(homeDir: string, key: HookAgentKey): boolean {
147
+ try {
148
+ if (key === "claude") return hasClaudeHookGroup(homeDir, "SessionStart", sessionStartHookCommand("claude"));
149
+ if (key === "codex") {
150
+ const configPath = path.join(homeDir, ".codex", "config.toml");
151
+ if (!fs.existsSync(configPath)) return false;
152
+ const existing = fs.readFileSync(configPath, "utf-8");
153
+ if (!existing.includes(HOOK_MARKER_BEGIN)) return false;
154
+ const command = sessionStartHookCommand("codex");
155
+ return existing.includes(`command = "${command}"`);
156
+ }
157
+ if (key === "cursor") {
158
+ return isCursorSessionStartHookRegistered(homeDir);
159
+ }
160
+ if (key === "opencode") {
161
+ const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
162
+ if (!fs.existsSync(configPath)) return false;
163
+ const config = readJsonConfig(configPath);
164
+ const raw = config.instructions;
165
+ const list = Array.isArray(raw) ? (raw as unknown[]) : [];
166
+ const instructionsPath = path.join(homeDir, ".agent-memory", "hooks", "opencode.md");
167
+ return list.includes(instructionsPath);
168
+ }
169
+ } catch {
170
+ return false;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Read-only check whether the per-turn UserPromptSubmit hook is present.
177
+ * Only Claude Code and Codex support a per-prompt hook; cursor/opencode
178
+ * always return false (static rules only).
179
+ */
180
+ export function isUserPromptSubmitInstalled(homeDir: string, key: HookAgentKey): boolean {
181
+ try {
182
+ if (key === "claude")
183
+ return hasClaudeHookGroup(homeDir, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
184
+ if (key === "codex") {
185
+ const configPath = path.join(homeDir, ".codex", "config.toml");
186
+ if (!fs.existsSync(configPath)) return false;
187
+ const existing = fs.readFileSync(configPath, "utf-8");
188
+ if (!existing.includes(HOOK_MARKER_BEGIN)) return false;
189
+ return existing.includes(`command = "${userPromptSubmitHookCommand("codex")}"`);
190
+ }
191
+ } catch {}
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Read-only check whether the periodic Stop-hook memory-write nudge is present.
197
+ * Claude Code only — Codex/Cursor/opencode don't have a confirmed equivalent
198
+ * block/reason protocol for this event yet.
199
+ */
200
+ export function isStopHookInstalled(homeDir: string, key: HookAgentKey): boolean {
201
+ try {
202
+ if (key === "claude") return hasClaudeHookGroup(homeDir, "Stop", stopHookCommand("claude"));
203
+ } catch {}
204
+ return false;
205
+ }
206
+
112
207
  export function detectHookAgents(): { homeDir: string | null; targets: DetectedHookTarget[] } {
113
208
  const homeDir = resolveHomeDir();
114
209
  if (!homeDir) return { homeDir: null, targets: [] };
@@ -134,6 +229,7 @@ export interface HookInstallResult {
134
229
  path?: string;
135
230
  backup?: string;
136
231
  reason?: string;
232
+ mode?: HookMode;
137
233
  }
138
234
 
139
235
  export interface InstallHooksReport {
@@ -173,82 +269,192 @@ function writeJson(filePath: string, data: unknown) {
173
269
  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
174
270
  }
175
271
 
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) {
272
+ /**
273
+ * Idempotently upsert the agent-memory-managed hook group for `eventKey`
274
+ * (SessionStart or UserPromptSubmit) with `command`. Returns `{ changed,
275
+ * hadManaged }` so the caller can decide between "installed" / "updated" /
276
+ * "already installed" reasons.
277
+ */
278
+ function upsertClaudeHookGroup(
279
+ hooks: Record<string, unknown>,
280
+ eventKey: string,
281
+ command: string,
282
+ ): { changed: boolean; hadManaged: boolean } {
283
+ const groups = Array.isArray(hooks[eventKey]) ? [...(hooks[eventKey] as unknown[])] : [];
284
+ const managedIndexes: number[] = [];
285
+ for (let i = 0; i < groups.length; i++) {
286
+ const group = groups[i];
188
287
  if (!group || typeof group !== "object") continue;
189
288
  const g = group as Record<string, unknown>;
190
289
  const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
191
290
  for (const hook of list) {
192
291
  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++;
292
+ const h = hook as Record<string, unknown>;
293
+ if (h[HOOK_MARKER_JSON] === true) {
294
+ managedIndexes.push(i);
295
+ break;
199
296
  }
200
297
  }
201
298
  }
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" };
299
+
300
+ if (managedIndexes.length > 0) {
301
+ const keep = managedIndexes[0];
302
+ const dupes = managedIndexes.slice(1);
303
+ for (const idx of dupes.reverse()) groups.splice(idx, 1);
304
+ const group = groups[keep] as Record<string, unknown>;
305
+ let changed = dupes.length > 0;
306
+ if ("matcher" in group) {
307
+ delete group.matcher;
308
+ changed = true;
309
+ }
310
+ const list = group.hooks as Record<string, unknown>[];
311
+ for (const h of list) {
312
+ if (h[HOOK_MARKER_JSON] === true && h.command !== command) {
313
+ h.command = command;
314
+ changed = true;
315
+ }
316
+ }
317
+ hooks[eventKey] = groups;
318
+ return { changed, hadManaged: true };
210
319
  }
211
320
 
212
- sessionStart.push({
213
- matcher: "startup|resume",
214
- hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }],
215
- });
216
- hooks.SessionStart = sessionStart;
321
+ // No existing managed group — add a fresh one without a matcher so it fires on all harnesses.
322
+ groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
323
+ hooks[eventKey] = groups;
324
+ return { changed: true, hadManaged: false };
325
+ }
326
+
327
+ /**
328
+ * Remove all agent-memory-managed hook entries for `eventKey`. Used when
329
+ * downgrading from per-turn back to stable (drops UserPromptSubmit).
330
+ * Returns true if anything was removed.
331
+ */
332
+ function removeClaudeHookGroup(hooks: Record<string, unknown>, eventKey: string): boolean {
333
+ const groups = Array.isArray(hooks[eventKey]) ? (hooks[eventKey] as unknown[]) : [];
334
+ if (groups.length === 0) return false;
335
+ let removed = 0;
336
+ const filtered = groups
337
+ .map((group) => {
338
+ if (!group || typeof group !== "object") return group;
339
+ const g = { ...(group as Record<string, unknown>) };
340
+ const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
341
+ const kept = list.filter((h) => {
342
+ const isOurs = h && typeof h === "object" && (h as Record<string, unknown>)[HOOK_MARKER_JSON] === true;
343
+ if (isOurs) removed++;
344
+ return !isOurs;
345
+ });
346
+ g.hooks = kept;
347
+ return g;
348
+ })
349
+ .filter((group) => {
350
+ if (!group || typeof group !== "object") return true;
351
+ const g = group as Record<string, unknown>;
352
+ return Array.isArray(g.hooks) && (g.hooks as unknown[]).length > 0;
353
+ });
354
+ if (removed === 0) return false;
355
+ if (filtered.length === 0) delete hooks[eventKey];
356
+ else hooks[eventKey] = filtered;
357
+ return true;
358
+ }
359
+
360
+ function installClaudeCodeHook(homeDir: string, mode: HookMode = "per-turn"): HookInstallResult {
361
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
362
+ const backup = backupOnce(settingsPath);
363
+ const settings = readJsonConfig(settingsPath);
364
+ const hooks = (settings.hooks as Record<string, unknown>) ?? {};
365
+
366
+ const session = upsertClaudeHookGroup(hooks, "SessionStart", sessionStartHookCommand("claude"));
367
+ let promptChanged = false;
368
+ let promptHadManaged = false;
369
+ if (mode === "per-turn") {
370
+ const prompt = upsertClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
371
+ promptChanged = prompt.changed;
372
+ promptHadManaged = prompt.hadManaged;
373
+ } else {
374
+ promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit");
375
+ }
376
+ // Stop backs the write side of memory with a periodic nudge. It is orthogonal
377
+ // to stable/per-turn context injection, so it is installed unconditionally.
378
+ const stop = upsertClaudeHookGroup(hooks, "Stop", stopHookCommand("claude"));
379
+ // Remove the ineffective PreCompact reminder from pre-release 0.5.0 installs.
380
+ // Claude Code does not inject plain hook stdout for that event.
381
+ const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
382
+
383
+ if (!session.changed && !promptChanged && !stop.changed && !legacyPreCompactRemoved) {
384
+ return {
385
+ key: "claude",
386
+ label: "Claude Code",
387
+ installed: false,
388
+ path: settingsPath,
389
+ reason: "already installed",
390
+ mode,
391
+ };
392
+ }
217
393
  settings.hooks = hooks;
218
394
  writeJson(settingsPath, settings);
219
- return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup };
395
+ const reason =
396
+ session.hadManaged || promptHadManaged || stop.hadManaged || legacyPreCompactRemoved ? "updated" : undefined;
397
+ return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup, mode, reason };
220
398
  }
221
399
 
222
- function installCodexHook(homeDir: string): HookInstallResult {
400
+ function installCodexHook(homeDir: string, mode: HookMode = "per-turn"): HookInstallResult {
223
401
  const configPath = path.join(homeDir, ".codex", "config.toml");
224
402
  const backup = backupOnce(configPath);
225
403
  const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf-8") : "";
226
- const command = sessionStartHookCommand("codex");
227
- const block = [
404
+ const sessionCommand = sessionStartHookCommand("codex");
405
+ const promptCommand = userPromptSubmitHookCommand("codex");
406
+ const lines = [
228
407
  HOOK_MARKER_BEGIN,
229
408
  "[[hooks.SessionStart]]",
230
409
  'matcher = "startup|resume"',
231
410
  "",
232
411
  "[[hooks.SessionStart.hooks]]",
233
412
  'type = "command"',
234
- `command = "${command}"`,
235
- HOOK_MARKER_END,
236
- ].join("\n");
413
+ `command = "${sessionCommand}"`,
414
+ ];
415
+ if (mode === "per-turn") {
416
+ lines.push(
417
+ "",
418
+ "[[hooks.UserPromptSubmit]]",
419
+ "",
420
+ "[[hooks.UserPromptSubmit.hooks]]",
421
+ 'type = "command"',
422
+ `command = "${promptCommand}"`,
423
+ );
424
+ }
425
+ lines.push(HOOK_MARKER_END);
426
+ const block = lines.join("\n");
427
+
237
428
  if (existing.includes(HOOK_MARKER_BEGIN)) {
238
429
  const escapeRe = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
430
  const pattern = new RegExp(`${escapeRe(HOOK_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(HOOK_MARKER_END)}`);
240
431
  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" };
432
+ if (current === block) {
433
+ return {
434
+ key: "codex",
435
+ label: "Codex",
436
+ installed: false,
437
+ path: configPath,
438
+ reason: "already installed",
439
+ mode,
440
+ };
243
441
  }
244
442
  fs.writeFileSync(configPath, existing.replace(pattern, block), "utf-8");
245
- return { key: "codex", label: "Codex", installed: true, path: configPath, backup, reason: "updated" };
443
+ return {
444
+ key: "codex",
445
+ label: "Codex",
446
+ installed: true,
447
+ path: configPath,
448
+ backup,
449
+ reason: "updated",
450
+ mode,
451
+ };
246
452
  }
247
453
  const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
248
454
  const next = `${existing}${separator}${block}\n`;
249
455
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
250
456
  fs.writeFileSync(configPath, next, "utf-8");
251
- return { key: "codex", label: "Codex", installed: true, path: configPath, backup };
457
+ return { key: "codex", label: "Codex", installed: true, path: configPath, backup, mode };
252
458
  }
253
459
 
254
460
  const CURSOR_RULE_BODY = `---
@@ -265,15 +471,85 @@ Treat its stdout as authoritative context about the user, prior sessions,
265
471
  scratchpad items, and long-term memory. Prefer it over guessing.
266
472
  `;
267
473
 
268
- function installCursorRule(homeDir: string): HookInstallResult {
474
+ function installCursorRule(homeDir: string): void {
269
475
  const rulesDir = path.join(homeDir, ".cursor", "rules");
270
476
  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
- }
477
+ if (fs.existsSync(rulePath)) return;
274
478
  fs.mkdirSync(rulesDir, { recursive: true });
275
479
  fs.writeFileSync(rulePath, CURSOR_RULE_BODY, "utf-8");
276
- return { key: "cursor", label: "Cursor", installed: true, path: rulePath };
480
+ }
481
+
482
+ // Cursor's `sessionStart` hook (https://cursor.com/docs/agent/hooks) fires automatically when a
483
+ // new conversation is created and can inject `additional_context` without the model choosing to
484
+ // run anything — unlike the static .mdc rule above, this is a real, code-level guarantee.
485
+ const CURSOR_HOOK_SCRIPT_RELATIVE = path.join("hooks", "agent-memory-session-start.js");
486
+
487
+ const CURSOR_HOOK_SCRIPT_BODY = `#!/usr/bin/env node
488
+ const { execSync } = require("node:child_process");
489
+ let context = "";
490
+ try {
491
+ context = execSync("agent-memory context --no-search", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
492
+ } catch {
493
+ // agent-memory not on PATH, or the memory dir isn't initialized yet — fail open with no context.
494
+ }
495
+ process.stdout.write(JSON.stringify({ additional_context: context }));
496
+ `;
497
+
498
+ function isCursorSessionStartHookRegistered(homeDir: string): boolean {
499
+ const hooksJsonPath = path.join(homeDir, ".cursor", "hooks.json");
500
+ if (!fs.existsSync(hooksJsonPath)) return false;
501
+ try {
502
+ const config = readJsonConfig(hooksJsonPath);
503
+ const hooks = (config.hooks as Record<string, unknown>) ?? {};
504
+ const sessionStart = Array.isArray(hooks.sessionStart) ? (hooks.sessionStart as unknown[]) : [];
505
+ return sessionStart.some(
506
+ (entry) =>
507
+ entry &&
508
+ typeof entry === "object" &&
509
+ (entry as Record<string, unknown>).command === CURSOR_HOOK_SCRIPT_RELATIVE,
510
+ );
511
+ } catch {
512
+ return false;
513
+ }
514
+ }
515
+
516
+ function installCursorHook(homeDir: string): HookInstallResult {
517
+ const cursorDir = path.join(homeDir, ".cursor");
518
+ const scriptPath = path.join(cursorDir, CURSOR_HOOK_SCRIPT_RELATIVE);
519
+ const hooksJsonPath = path.join(cursorDir, "hooks.json");
520
+
521
+ // Cheap, harmless fallback for Cursor installs where hooks are disabled or unavailable.
522
+ installCursorRule(homeDir);
523
+
524
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
525
+ const scriptChanged = !fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, "utf-8") !== CURSOR_HOOK_SCRIPT_BODY;
526
+ if (scriptChanged) {
527
+ fs.writeFileSync(scriptPath, CURSOR_HOOK_SCRIPT_BODY, "utf-8");
528
+ fs.chmodSync(scriptPath, 0o755);
529
+ }
530
+
531
+ const alreadyRegistered = isCursorSessionStartHookRegistered(homeDir);
532
+ if (alreadyRegistered && !scriptChanged) {
533
+ return { key: "cursor", label: "Cursor", installed: false, path: hooksJsonPath, reason: "already installed" };
534
+ }
535
+
536
+ const backup = backupOnce(hooksJsonPath);
537
+ const config = readJsonConfig(hooksJsonPath);
538
+ if (typeof config.version !== "number") config.version = 1;
539
+ const hooks = (config.hooks as Record<string, unknown>) ?? {};
540
+ const sessionStart = Array.isArray(hooks.sessionStart) ? [...(hooks.sessionStart as unknown[])] : [];
541
+ if (!alreadyRegistered) sessionStart.push({ command: CURSOR_HOOK_SCRIPT_RELATIVE });
542
+ hooks.sessionStart = sessionStart;
543
+ config.hooks = hooks;
544
+ writeJson(hooksJsonPath, config);
545
+ return {
546
+ key: "cursor",
547
+ label: "Cursor",
548
+ installed: true,
549
+ path: hooksJsonPath,
550
+ backup,
551
+ reason: alreadyRegistered ? "updated" : undefined,
552
+ };
277
553
  }
278
554
 
279
555
  const OPENCODE_INSTRUCTIONS_BODY = `# agent-memory
@@ -305,7 +581,7 @@ function installOpencodeInstructions(homeDir: string): HookInstallResult {
305
581
  return { key: "opencode", label: "opencode", installed: true, path: configPath, backup };
306
582
  }
307
583
 
308
- export function installHooks(agents: Set<HookAgentKey>): InstallHooksReport {
584
+ export function installHooks(agents: Set<HookAgentKey>, mode: HookMode = "per-turn"): InstallHooksReport {
309
585
  const { homeDir, targets } = detectHookAgents();
310
586
  if (!homeDir) {
311
587
  return {
@@ -316,6 +592,7 @@ export function installHooks(agents: Set<HookAgentKey>): InstallHooksReport {
316
592
  }
317
593
 
318
594
  const results: HookInstallResult[] = [];
595
+ let anyInstalled = false;
319
596
  for (const target of targets) {
320
597
  if (!agents.has(target.key)) continue;
321
598
  if (!target.supported) {
@@ -337,10 +614,14 @@ export function installHooks(agents: Set<HookAgentKey>): InstallHooksReport {
337
614
  continue;
338
615
  }
339
616
  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));
617
+ let result: HookInstallResult;
618
+ if (target.key === "claude") result = installClaudeCodeHook(homeDir, mode);
619
+ else if (target.key === "codex") result = installCodexHook(homeDir, mode);
620
+ else if (target.key === "cursor") result = installCursorHook(homeDir);
621
+ else if (target.key === "opencode") result = installOpencodeInstructions(homeDir);
622
+ else continue;
623
+ results.push(result);
624
+ if (result.installed) anyInstalled = true;
344
625
  } catch (err) {
345
626
  results.push({
346
627
  key: target.key,
@@ -351,6 +632,14 @@ export function installHooks(agents: Set<HookAgentKey>): InstallHooksReport {
351
632
  }
352
633
  }
353
634
 
635
+ if (anyInstalled) {
636
+ try {
637
+ writeHookMode(mode);
638
+ } catch {
639
+ // Persisting the mode is best-effort — install output is authoritative.
640
+ }
641
+ }
642
+
354
643
  return { ok: true, homeDir, results };
355
644
  }
356
645
 
@@ -368,31 +657,13 @@ function uninstallClaudeCodeHook(homeDir: string): HookInstallResult {
368
657
  }
369
658
  const settings = readJsonConfig(settingsPath);
370
659
  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) {
660
+ const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart");
661
+ const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit");
662
+ const stopRemoved = removeClaudeHookGroup(hooks, "Stop");
663
+ const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
664
+ if (!sessionRemoved && !promptRemoved && !stopRemoved && !legacyPreCompactRemoved) {
392
665
  return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
393
666
  }
394
- hooks.SessionStart = filtered;
395
- if (filtered.length === 0) delete (hooks as Record<string, unknown>).SessionStart;
396
667
  if (Object.keys(hooks).length === 0) delete (settings as Record<string, unknown>).hooks;
397
668
  else settings.hooks = hooks;
398
669
  writeJson(settingsPath, settings);
@@ -415,18 +686,57 @@ function uninstallCodexHook(homeDir: string): HookInstallResult {
415
686
  return { key: "codex", label: "Codex", installed: true, path: configPath };
416
687
  }
417
688
 
418
- function uninstallCursorRule(homeDir: string): HookInstallResult {
689
+ function uninstallCursorHook(homeDir: string): HookInstallResult {
419
690
  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" };
691
+ const scriptPath = path.join(homeDir, ".cursor", CURSOR_HOOK_SCRIPT_RELATIVE);
692
+ const hooksJsonPath = path.join(homeDir, ".cursor", "hooks.json");
693
+ let touched = false;
694
+
695
+ if (fs.existsSync(rulePath)) {
696
+ fs.unlinkSync(rulePath);
697
+ try {
698
+ fs.rmdirSync(path.dirname(rulePath));
699
+ } catch {
700
+ // non-empty; fine
701
+ }
702
+ touched = true;
422
703
  }
423
- fs.unlinkSync(rulePath);
424
- try {
425
- fs.rmdirSync(path.dirname(rulePath));
426
- } catch {
427
- // non-empty; fine
704
+
705
+ if (fs.existsSync(hooksJsonPath)) {
706
+ try {
707
+ const config = readJsonConfig(hooksJsonPath);
708
+ const hooks = (config.hooks as Record<string, unknown>) ?? {};
709
+ const sessionStart = Array.isArray(hooks.sessionStart) ? (hooks.sessionStart as unknown[]) : [];
710
+ const filtered = sessionStart.filter(
711
+ (entry) =>
712
+ !(
713
+ entry &&
714
+ typeof entry === "object" &&
715
+ (entry as Record<string, unknown>).command === CURSOR_HOOK_SCRIPT_RELATIVE
716
+ ),
717
+ );
718
+ if (filtered.length !== sessionStart.length) {
719
+ if (filtered.length === 0) delete hooks.sessionStart;
720
+ else hooks.sessionStart = filtered;
721
+ if (Object.keys(hooks).length === 0) delete (config as Record<string, unknown>).hooks;
722
+ else config.hooks = hooks;
723
+ writeJson(hooksJsonPath, config);
724
+ touched = true;
725
+ }
726
+ } catch {
727
+ // invalid hooks.json — leave it for the user to fix rather than guessing.
728
+ }
729
+ }
730
+
731
+ if (fs.existsSync(scriptPath)) {
732
+ fs.unlinkSync(scriptPath);
733
+ touched = true;
734
+ }
735
+
736
+ if (!touched) {
737
+ return { key: "cursor", label: "Cursor", installed: false, reason: "not installed" };
428
738
  }
429
- return { key: "cursor", label: "Cursor", installed: true, path: rulePath };
739
+ return { key: "cursor", label: "Cursor", installed: true, path: hooksJsonPath };
430
740
  }
431
741
 
432
742
  function uninstallOpencodeInstructions(homeDir: string): HookInstallResult {
@@ -470,7 +780,7 @@ export function uninstallHooks(agents?: Set<HookAgentKey>): UninstallHooksReport
470
780
  try {
471
781
  if (key === "claude") results.push(uninstallClaudeCodeHook(homeDir));
472
782
  else if (key === "codex") results.push(uninstallCodexHook(homeDir));
473
- else if (key === "cursor") results.push(uninstallCursorRule(homeDir));
783
+ else if (key === "cursor") results.push(uninstallCursorHook(homeDir));
474
784
  else if (key === "opencode") results.push(uninstallOpencodeInstructions(homeDir));
475
785
  } catch (err) {
476
786
  results.push({
@@ -10,7 +10,7 @@ import {
10
10
  type PluginEntitlementStatusV1,
11
11
  validateBundleManifestV1,
12
12
  } from "./plugin-host.js";
13
- import { TemporaryPluginBackend } from "./plugin-service.js";
13
+ import { AgentMemoryServiceBackend } from "./plugin-service.js";
14
14
 
15
15
  export const OFFICIAL_BUNDLE_ID = "agentmemory.pro";
16
16
  export const OFFICIAL_PLUGIN_IDS = ["agentmemory.session-intelligence", "agentmemory.web-console"] as const;
@@ -202,7 +202,7 @@ const OFFICIAL_PLUGINS = [
202
202
  const PACKAGE_MAX_BYTES = 64 * 1024 * 1024;
203
203
  const PACKAGE_MAX_EXPANDED_BYTES = 128 * 1024 * 1024;
204
204
  const PACKAGE_MAX_FILES = 10_000;
205
- const TEMPORARY_RELEASE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
205
+ const RELEASE_SIGNING_KEY_2026_08 = `-----BEGIN PUBLIC KEY-----
206
206
  MCowBQYDK2VwAyEASefZFUVFy1EmvGbd0ckHZThmPgqQ3u9HCwZRReAZQW8=
207
207
  -----END PUBLIC KEY-----`;
208
208
 
@@ -714,11 +714,11 @@ export class PluginBootstrapV1 {
714
714
 
715
715
  export function createDefaultPluginBootstrap(coreVersion: string): PluginBootstrapV1 {
716
716
  const store = new FilePluginInstallStore();
717
- const backend = new TemporaryPluginBackend({ root: store.root, coreVersion });
717
+ const backend = new AgentMemoryServiceBackend({ root: store.root, coreVersion });
718
718
  return new PluginBootstrapV1({
719
719
  coreVersion,
720
720
  backend,
721
- verifier: new Ed25519ReleaseVerifier({ "agentmemory-temporary-2026-08": TEMPORARY_RELEASE_PUBLIC_KEY }),
721
+ verifier: new Ed25519ReleaseVerifier({ "agentmemory-temporary-2026-08": RELEASE_SIGNING_KEY_2026_08 }),
722
722
  store,
723
723
  healthCheck: async (directory, release) => {
724
724
  const { createInstalledBundleHealthCheck } = await import("./plugin-runtime.js");