myagentmemory 0.5.3 → 0.5.5

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 CHANGED
@@ -24,7 +24,7 @@ Prefer to have an agent drive the whole thing — install, configure, verify, th
24
24
 
25
25
  - AgentMemory injects your decisions, scratchpad, and daily log at session start — no copy-paste, no re-explaining.
26
26
  - Repeated corrections become durable memory you can inspect and undo (Pro).
27
- - Every memory is a plain Markdown file you own. Memory content, session content, queries, and repository paths stay on this machine.
27
+ - Every memory is a plain Markdown file you own. AgentMemory's services do not receive memory content, session content, queries, or repository paths. Context you ask AgentMemory to return to a coding agent is then subject to that agent or model provider's data handling.
28
28
 
29
29
  AgentMemory does not provide a Python SDK, does not provide a vector database, and does not provide a knowledge graph. It is a local Markdown store with a CLI, agent skills, and optional full-text and semantic search via [qmd](https://github.com/tobi/qmd). See [product boundary](docs/product-boundary.md) for full scope.
30
30
 
@@ -36,9 +36,9 @@ AgentMemory does not provide a Python SDK, does not provide a vector database, a
36
36
 
37
37
  **Core remembers what you save. Pro learns from what you do.** Core remains free, MIT-licensed, and useful forever. Pro adds three things:
38
38
 
39
- - **Remember past sessions** — ask *"what did we decide about auth?"* across Claude Code, Codex, and Cursor.
39
+ - **Remember past sessions** — ask *"what did we decide about auth?"* across Claude Code, Codex, and Pi session history. Cursor can use AgentMemory's skills and hooks, but Cursor transcript ingestion is not currently supported.
40
40
  - **Learn from your patterns** — turn repeated corrections into memory you can inspect and undo.
41
- - **Private by default** — memory and session content index locally. Pro installation uses a pseudonymous installation identifier and bounded compatibility metadata, never your memory or session content.
41
+ - **Private by default** — memory and session content index locally. AgentMemory's services receive only a pseudonymous installation identifier and bounded compatibility metadata, never your memory or session content. Recall results provided locally to a coding agent are subject to that agent or model provider's data handling.
42
42
 
43
43
  Preview what Pro would find in your existing sessions *before* installing anything:
44
44
 
@@ -50,7 +50,7 @@ agent-memory learn
50
50
  agent-memory dashboard
51
51
  ```
52
52
 
53
- Pre-install preview: up to 50 local sessions per day. Free installed preview: 20 recalls + 5 learning scans per local day. Memory, session, query, and repository content stay on your machine; installation sends only a pseudonymous identifier and bounded compatibility metadata. Full detail on [privacy, signing, and installation](docs/official-plugin-bootstrap.md).
53
+ Pre-install preview: up to 50 local sessions per day. Free installed preview: 20 recalls + 5 learning scans per local day. AgentMemory's services do not receive memory, session, query, or repository content; installation sends only a pseudonymous identifier and bounded compatibility metadata. Recall results provided locally to a coding agent are subject to that agent or model provider's data handling. Full detail on [privacy, signing, and installation](docs/official-plugin-bootstrap.md).
54
54
 
55
55
  ## Installation
56
56
 
@@ -59,7 +59,7 @@ Pre-install preview: up to 50 local sessions per day. Free installed preview: 20
59
59
  brew tap jayzeng/agentmemory https://github.com/jayzeng/agentmemory
60
60
  brew install jayzeng/agentmemory/agent-memory
61
61
 
62
- # Install the portable CLI globally (Node.js 20+; macOS, Linux, or Windows)
62
+ # Install the portable Core CLI globally (Node.js 20+; Pro session recall requires Node.js 22.13+)
63
63
  npm install -g myagentmemory
64
64
 
65
65
  # If corporate TLS inspection requires a private CA, use your organization's CA file:
package/dist/cli-spec.js CHANGED
@@ -67,7 +67,7 @@ export const PLUGIN_COMMAND_DESCRIPTIONS = {
67
67
  install: "authenticate if needed, then install or upgrade the official bundle",
68
68
  update: "upgrade an existing official bundle when a compatible release exists",
69
69
  uninstall: "remove official plugin executables while preserving user data",
70
- manage: "open the AgentMemory account and billing website",
70
+ manage: "open account management when that future service is available",
71
71
  };
72
72
  export const WORKER_ACTION_DESCRIPTIONS = {};
73
73
  export const SCRATCHPAD_ACTION_DESCRIPTIONS = {
package/dist/cli.js CHANGED
@@ -124,10 +124,6 @@ function levenshtein(a, b) {
124
124
  }
125
125
  return prev[b.length];
126
126
  }
127
- // ---------------------------------------------------------------------------
128
- // Pro plan / cap-exhausted UX
129
- // ---------------------------------------------------------------------------
130
- const UPGRADE_URL = "https://agentmemory.paperpilot.me/upgrade";
131
127
  function detectCapExhausted(result) {
132
128
  if (result.ok !== false)
133
129
  return null;
@@ -194,13 +190,11 @@ function printCapExhaustedBox(command, info) {
194
190
  "─────────────────────────────────────────────────────────────",
195
191
  usedLine,
196
192
  "",
197
- " Upgrade for unlimited recall + automatic capture:",
198
- ` ${UPGRADE_URL}`,
193
+ " Paid plans are not available yet. Try again after the free-preview allowance resets.",
199
194
  "─────────────────────────────────────────────────────────────",
200
195
  "",
201
196
  ];
202
197
  console.error(lines.join("\n"));
203
- openExternalUrl(UPGRADE_URL);
204
198
  }
205
199
  // Persist the last usage decision to disk so `pro status` can show counters.
206
200
  function cacheProUsage(decision) {
@@ -394,9 +388,9 @@ function printProOverview(installed) {
394
388
  console.log("Core remembers what you save. Pro learns from what you do.");
395
389
  console.log("");
396
390
  console.log("AgentMemory Pro:");
397
- console.log(' Remember past sessions Ask "what did we decide about auth?" across Claude Code, Codex, and Cursor.');
391
+ console.log(' Remember past sessions Ask "what did we decide about auth?" across Claude Code, Codex, and Pi history.');
398
392
  console.log(" Learn from your patterns Turn repeated corrections into memory you can inspect and undo.");
399
- console.log(" Private by default Memory and session content index locally and never leave this machine.");
393
+ console.log(" Private by default AgentMemory services never receive memory or session content.");
400
394
  console.log("");
401
395
  if (installed) {
402
396
  printProUsageCounters();
@@ -443,7 +437,7 @@ function printPluginResult(result, json, allowBrowser) {
443
437
  console.log(`AgentMemory Pro${version} has an update available.`);
444
438
  break;
445
439
  case "uninstalled":
446
- console.log("AgentMemory Pro executable components were removed. Memory and billing state were preserved.");
440
+ console.log("AgentMemory Pro executable components were removed. Memory and local activation state were preserved.");
447
441
  break;
448
442
  case "not_installed":
449
443
  console.log("AgentMemory Pro is not installed.");
@@ -674,10 +668,15 @@ const STOP_NAG_REASON = "Before stopping: if this session produced a durable fac
674
668
  "ignore this and stop normally.";
675
669
  /**
676
670
  * Stop hook handler — fires at the end of every assistant turn (not once per
677
- * session). Blocks at most once every STOP_NAG_INTERVAL turns per session_id
678
- * to nudge a memory-write check without being disruptive. Always allows the
679
- * stop (empty stdout) on missing session_id, `stop_hook_active` (Claude Code's
680
- * own re-entrancy signal never block twice in a row), or any internal error.
671
+ * session). Continues the conversation at most once every STOP_NAG_INTERVAL
672
+ * turns per session_id to nudge a memory-write check without being
673
+ * disruptive. Uses `hookSpecificOutput.additionalContext` rather than
674
+ * `decision: "block"`functionally identical (both go through the same
675
+ * `stop_hook_active` re-entry check and Claude Code's loop-protection cap),
676
+ * but additionalContext renders as "Stop hook feedback" in the transcript
677
+ * instead of the alarming-looking "Stop hook error". Always allows the stop
678
+ * (empty stdout) on missing session_id, `stop_hook_active` (Claude Code's own
679
+ * re-entrancy signal — never nag twice in a row), or any internal error.
681
680
  */
682
681
  async function cmdStop(_flags) {
683
682
  const TIMEOUT_MS = 3_000;
@@ -695,7 +694,9 @@ async function cmdStop(_flags) {
695
694
  if (!sessionId || payload?.stop_hook_active === true)
696
695
  return;
697
696
  if (shouldNagOnStop(sessionId, Date.now())) {
698
- process.stdout.write(JSON.stringify({ decision: "block", reason: STOP_NAG_REASON }));
697
+ process.stdout.write(JSON.stringify({
698
+ hookSpecificOutput: { hookEventName: "Stop", additionalContext: STOP_NAG_REASON },
699
+ }));
699
700
  }
700
701
  })().catch(() => {
701
702
  // Any failure in the Stop hook must be swallowed — never trap the user
@@ -1698,7 +1699,7 @@ async function cmdSetup(flags) {
1698
1699
  console.log(colorize("The local plugin is live. Feel the magic now:", "green"));
1699
1700
  console.log(` ${colorize('agent-memory recall "what did we decide about auth?"', "cyan")} — search past sessions`);
1700
1701
  console.log(` ${colorize("agent-memory learn", "cyan")} — surface repeated corrections`);
1701
- console.log(` ${colorize("agent-memory worker start", "cyan")} capture new sessions in real time`);
1702
+ console.log(` ${colorize("agent-memory index", "cyan")} refresh the supported local session index`);
1702
1703
  console.log(` ${colorize("agent-memory dashboard", "cyan")} — private local dashboard`);
1703
1704
  }
1704
1705
  else if (skipPlugin) {
@@ -1852,10 +1853,10 @@ function printProPitch(mode) {
1852
1853
  else {
1853
1854
  console.log(`${colorize("Optional: AgentMemory Pro", "bold")} — ${colorize("memory that learns from your work", "dim")}`);
1854
1855
  }
1855
- console.log(` ${colorize("Recall across sessions", "cyan")} Ask "what did we decide about auth?" across Claude, Codex, Cursor.`);
1856
+ console.log(` ${colorize("Recall across sessions", "cyan")} Ask "what did we decide about auth?" across Claude Code, Codex, and Pi history.`);
1856
1857
  console.log(` ${colorize("Learn from corrections", "cyan")} Turn repeated fixes into memory you can inspect and undo.`);
1857
- console.log(` ${colorize("Real-time capture", "cyan")} Local worker indexes new sessions as they happen.`);
1858
- console.log(` ${colorize("Private by default", "cyan")} Memory and session content stay on this device — no account required.`);
1858
+ console.log(` ${colorize("Local session index", "cyan")} Scan supported session history without uploading it to AgentMemory.`);
1859
+ console.log(` ${colorize("Private by default", "cyan")} AgentMemory services never receive memory or session content.`);
1859
1860
  console.log(` ${colorize("Included at no cost:", "green")} ${colorize("20 recalls + 5 learning scans per day", "bold")}. Local indexing and dashboard remain free.`);
1860
1861
  console.log("");
1861
1862
  }
@@ -2383,9 +2384,10 @@ Usage:
2383
2384
  agent-memory plugin manage [--no-browser]
2384
2385
 
2385
2386
  The public core remains fully usable without AgentMemory Pro. Install uses a random
2386
- installation identifier and requires no account or email. The free tier includes
2387
- 20 recalls and 5 learning scans per local day; indexing and the Memory Dashboard
2388
- remain available. Memory and session content stay on this device.`);
2387
+ installation identifier and requires no account or email. The free tier includes
2388
+ 20 recalls and 5 learning scans per local day; indexing and the Memory Dashboard
2389
+ remain available. AgentMemory services never receive memory or session content;
2390
+ recall results are subject to the coding agent or model provider you invoke.`);
2389
2391
  }
2390
2392
  function pluginCommandFailure(command, error) {
2391
2393
  return {
@@ -3123,11 +3125,16 @@ async function cmdServe(flags) {
3123
3125
  server.addTool({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }, (input) => runtime.runMcpTool(tool.name, input));
3124
3126
  }
3125
3127
  server.addStartupHook(() => runtime.runMcpStartup());
3128
+ server.addShutdownHook(() => runtime.runMcpShutdown());
3126
3129
  }
3127
3130
  catch {
3128
3131
  // Pro not installed or failed to load — serve with core tools only.
3129
3132
  }
3130
3133
  await server.start();
3134
+ // Hard backstop: a Pro plugin's fs.watch handles (or any other resource
3135
+ // that keeps the event loop alive) must never prevent this process from
3136
+ // exiting once stdin has closed.
3137
+ process.exit(0);
3131
3138
  }
3132
3139
  // ---------------------------------------------------------------------------
3133
3140
  // Usage
@@ -3416,7 +3423,7 @@ async function main() {
3416
3423
  capability: "session",
3417
3424
  });
3418
3425
  if (decision.state === "exhausted") {
3419
- console.error(`AgentMemory free session allowance resets in ${formatResetTime(decision.resetAt)}. Upgrade: ${UPGRADE_URL}`);
3426
+ console.error(`AgentMemory free session allowance resets in ${formatResetTime(decision.resetAt)}. Paid plans are not available yet.`);
3420
3427
  }
3421
3428
  }
3422
3429
  }
package/dist/hooks.js CHANGED
@@ -125,7 +125,13 @@ function hasClaudeHookGroup(homeDir, eventKey, command) {
125
125
  if (!hook || typeof hook !== "object")
126
126
  continue;
127
127
  const h = hook;
128
- if (h[HOOK_MARKER_JSON] === true && h.command === command)
128
+ // Match by command alone (marker is optional) legacy entries that
129
+ // predate HOOK_MARKER_JSON already run this exact command, and the
130
+ // command string is specific enough to agent-memory that it's a safe
131
+ // signal on its own. Otherwise a pre-marker install is invisible here,
132
+ // doctor/isHookInstalled falsely report "not installed" for hooks that
133
+ // are actually live, and upsertClaudeHookGroup can't find them to dedupe.
134
+ if (h.command === command)
129
135
  return true;
130
136
  }
131
137
  }
@@ -330,9 +336,10 @@ function uninstallPiMemoryDelegate(homeDir) {
330
336
  */
331
337
  function upsertClaudeHookGroup(hooks, eventKey, command) {
332
338
  const groups = Array.isArray(hooks[eventKey]) ? [...hooks[eventKey]] : [];
333
- const managedIndexes = [];
334
- for (let i = 0; i < groups.length; i++) {
335
- const group = groups[i];
339
+ // Locate every agent-memory-owned hook entry — matched by marker, or by
340
+ // exact command string for legacy pre-marker installs wherever it lives.
341
+ const owned = [];
342
+ for (const group of groups) {
336
343
  if (!group || typeof group !== "object")
337
344
  continue;
338
345
  const g = group;
@@ -341,44 +348,68 @@ function upsertClaudeHookGroup(hooks, eventKey, command) {
341
348
  if (!hook || typeof hook !== "object")
342
349
  continue;
343
350
  const h = hook;
344
- if (h[HOOK_MARKER_JSON] === true) {
345
- managedIndexes.push(i);
346
- break;
347
- }
351
+ if (h[HOOK_MARKER_JSON] === true || h.command === command)
352
+ owned.push({ group: g, hook: h });
348
353
  }
349
354
  }
350
- if (managedIndexes.length > 0) {
351
- const keep = managedIndexes[0];
352
- const dupes = managedIndexes.slice(1);
353
- for (const idx of dupes.reverse())
354
- groups.splice(idx, 1);
355
- const group = groups[keep];
356
- let changed = dupes.length > 0;
357
- if ("matcher" in group) {
358
- delete group.matcher;
359
- changed = true;
360
- }
361
- const list = group.hooks;
362
- for (const h of list) {
363
- if (h[HOOK_MARKER_JSON] === true && h.command !== command) {
364
- h.command = command;
365
- changed = true;
366
- }
367
- }
355
+ if (owned.length === 0) {
356
+ // No existing managed hook anywhere — add a fresh group without a matcher so it fires on all harnesses.
357
+ groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
368
358
  hooks[eventKey] = groups;
369
- return { changed, hadManaged: true };
370
- }
371
- // No existing managed group add a fresh one without a matcher so it fires on all harnesses.
372
- groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
373
- hooks[eventKey] = groups;
374
- return { changed: true, hadManaged: false };
359
+ return { changed: true, hadManaged: false };
360
+ }
361
+ // Keep the first owned entry in place fixed up in place, preserving its
362
+ // identity so a no-op re-install reports unchanged and remove every
363
+ // other owned entry from its own group's hooks array, whether it's a
364
+ // duplicate in another group or piled up alongside the keeper in the same
365
+ // group. Never delete a whole group, since it could carry unrelated
366
+ // hand-added hooks.
367
+ const keeper = owned[0];
368
+ let changed = false;
369
+ if (keeper.hook.command !== command) {
370
+ keeper.hook.command = command;
371
+ changed = true;
372
+ }
373
+ if (keeper.hook[HOOK_MARKER_JSON] !== true) {
374
+ keeper.hook[HOOK_MARKER_JSON] = true;
375
+ changed = true;
376
+ }
377
+ for (const dupe of owned.slice(1)) {
378
+ const list = dupe.group.hooks;
379
+ const idx = list.indexOf(dupe.hook);
380
+ if (idx !== -1)
381
+ list.splice(idx, 1);
382
+ changed = true;
383
+ }
384
+ // Only clear the keeper's group matcher when that group is exclusively
385
+ // ours (no unrelated hooks left in it after dedup) — never when it also
386
+ // carries an unrelated hand-added hook.
387
+ const keeperList = keeper.group.hooks;
388
+ if (keeperList.length === 1 && "matcher" in keeper.group) {
389
+ delete keeper.group.matcher;
390
+ changed = true;
391
+ }
392
+ // Drop any group left with zero hooks; never drop one that still has
393
+ // unrelated hooks in it.
394
+ const filtered = groups.filter((group) => {
395
+ if (!group || typeof group !== "object")
396
+ return true;
397
+ const g = group;
398
+ return !Array.isArray(g.hooks) || g.hooks.length > 0;
399
+ });
400
+ hooks[eventKey] = filtered;
401
+ return { changed, hadManaged: true };
375
402
  }
376
403
  /**
377
404
  * Remove all agent-memory-managed hook entries for `eventKey`. Used when
378
405
  * downgrading from per-turn back to stable (drops UserPromptSubmit).
406
+ * `command`, when given, also matches legacy entries that predate
407
+ * HOOK_MARKER_JSON — the same broadened detection upsertClaudeHookGroup and
408
+ * hasClaudeHookGroup use — so a pre-marker install isn't left behind after a
409
+ * downgrade/uninstall that reports success.
379
410
  * Returns true if anything was removed.
380
411
  */
381
- function removeClaudeHookGroup(hooks, eventKey) {
412
+ function removeClaudeHookGroup(hooks, eventKey, command) {
382
413
  const groups = Array.isArray(hooks[eventKey]) ? hooks[eventKey] : [];
383
414
  if (groups.length === 0)
384
415
  return false;
@@ -390,7 +421,8 @@ function removeClaudeHookGroup(hooks, eventKey) {
390
421
  const g = { ...group };
391
422
  const list = Array.isArray(g.hooks) ? g.hooks : [];
392
423
  const kept = list.filter((h) => {
393
- const isOurs = h && typeof h === "object" && h[HOOK_MARKER_JSON] === true;
424
+ const hook = h && typeof h === "object" ? h : null;
425
+ const isOurs = !!hook && (hook[HOOK_MARKER_JSON] === true || (!!command && hook.command === command));
394
426
  if (isOurs)
395
427
  removed++;
396
428
  return !isOurs;
@@ -426,7 +458,7 @@ function installClaudeCodeHook(homeDir, mode = "per-turn") {
426
458
  promptHadManaged = prompt.hadManaged;
427
459
  }
428
460
  else {
429
- promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit");
461
+ promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
430
462
  }
431
463
  // Stop backs the write side of memory with a periodic nudge. It is orthogonal
432
464
  // to stable/per-turn context injection, so it is installed unconditionally.
@@ -779,9 +811,9 @@ function uninstallClaudeCodeHook(homeDir) {
779
811
  }
780
812
  const settings = readJsonConfig(settingsPath);
781
813
  const hooks = settings.hooks ?? {};
782
- const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart");
783
- const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit");
784
- const stopRemoved = removeClaudeHookGroup(hooks, "Stop");
814
+ const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart", sessionStartHookCommand("claude"));
815
+ const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
816
+ const stopRemoved = removeClaudeHookGroup(hooks, "Stop", stopHookCommand("claude"));
785
817
  const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
786
818
  if (!sessionRemoved && !promptRemoved && !stopRemoved && !legacyPreCompactRemoved) {
787
819
  return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
@@ -17,9 +17,11 @@ export declare class StdioMcpServer {
17
17
  private readonly version;
18
18
  private readonly tools;
19
19
  private readonly startupHooks;
20
+ private readonly shutdownHooks;
20
21
  constructor(version?: string);
21
22
  addTool(definition: McpToolDefinition, handler: McpToolHandler): void;
22
23
  addStartupHook(fn: () => void | Promise<void>): void;
24
+ addShutdownHook(fn: () => void | Promise<void>): void;
23
25
  start(): Promise<void>;
24
26
  private handleMessage;
25
27
  private respond;
@@ -9,6 +9,7 @@ export class StdioMcpServer {
9
9
  version;
10
10
  tools = new Map();
11
11
  startupHooks = [];
12
+ shutdownHooks = [];
12
13
  constructor(version = "0.0.0") {
13
14
  this.version = version;
14
15
  }
@@ -18,6 +19,9 @@ export class StdioMcpServer {
18
19
  addStartupHook(fn) {
19
20
  this.startupHooks.push(fn);
20
21
  }
22
+ addShutdownHook(fn) {
23
+ this.shutdownHooks.push(fn);
24
+ }
21
25
  async start() {
22
26
  // Run all startup hooks before entering the message loop.
23
27
  for (const hook of this.startupHooks)
@@ -40,6 +44,17 @@ export class StdioMcpServer {
40
44
  rl.on("close", resolve);
41
45
  process.stdin.on("end", resolve);
42
46
  });
47
+ // A hook may hold resources (e.g. fs.watch handles) that keep the event
48
+ // loop alive past stdin close — run them, but don't let one broken hook
49
+ // block the others or block process exit.
50
+ for (const hook of this.shutdownHooks) {
51
+ try {
52
+ await hook();
53
+ }
54
+ catch {
55
+ // Non-fatal — the caller still hard-exits after start() returns.
56
+ }
57
+ }
43
58
  }
44
59
  handleMessage(msg) {
45
60
  const id = msg.id;
@@ -147,7 +147,12 @@ export interface PluginMcpToolInputSchema {
147
147
  export interface PluginMcpToolV1 {
148
148
  name: string;
149
149
  description: string;
150
- requiredCapability: string;
150
+ /**
151
+ * Capability checked on every invocation. Optional only to keep plugin API 1
152
+ * source-compatible with bundles built before capability-gated MCP tools
153
+ * were introduced; legacy tools load but are denied until updated.
154
+ */
155
+ requiredCapability?: string;
151
156
  inputSchema: PluginMcpToolInputSchema;
152
157
  run(input: Record<string, unknown>): unknown | Promise<unknown>;
153
158
  }
@@ -160,6 +165,7 @@ export interface AgentMemoryPluginHostV1 {
160
165
  registerContextProvider?(provider: PluginContextProviderV1): void;
161
166
  registerMcpTool?(tool: PluginMcpToolV1): void;
162
167
  registerMcpStartup?(fn: () => void | Promise<void>): void;
168
+ registerMcpShutdown?(fn: () => void | Promise<void>): void;
163
169
  getStateDirectory(): string;
164
170
  getMemoryDirectory(): string;
165
171
  getEntitlement(): Promise<PluginEntitlementStatusV1>;
@@ -15,6 +15,7 @@ export declare class InstalledPluginRuntimeV1 {
15
15
  private readonly contextProviders;
16
16
  private readonly mcpTools;
17
17
  private readonly mcpStartupHooks;
18
+ private readonly mcpShutdownHooks;
18
19
  private loaded;
19
20
  constructor(options: PluginRuntimeOptionsV1);
20
21
  load(): Promise<boolean>;
@@ -44,6 +45,7 @@ export declare class InstalledPluginRuntimeV1 {
44
45
  */
45
46
  runMcpTool(name: string, input: Record<string, unknown>): Promise<unknown>;
46
47
  runMcpStartup(): Promise<void>;
48
+ runMcpShutdown(): Promise<void>;
47
49
  private createHost;
48
50
  private refreshEntitlement;
49
51
  }
@@ -65,6 +65,7 @@ export class InstalledPluginRuntimeV1 {
65
65
  contextProviders = [];
66
66
  mcpTools = [];
67
67
  mcpStartupHooks = [];
68
+ mcpShutdownHooks = [];
68
69
  loaded = false;
69
70
  constructor(options) {
70
71
  this.options = options;
@@ -200,6 +201,10 @@ export class InstalledPluginRuntimeV1 {
200
201
  const tool = this.mcpTools.find((candidate) => candidate.name === name);
201
202
  if (!tool)
202
203
  return { error: `Unknown MCP tool: ${name}` };
204
+ if (!tool.requiredCapability)
205
+ return {
206
+ error: `The ${name} tool was built for an older plugin API and must be updated before it can run`,
207
+ };
203
208
  const entitlement = await this.refreshEntitlement();
204
209
  if (!isPluginCapabilityEnabled(entitlement, tool.requiredCapability))
205
210
  return { error: `Capability ${tool.requiredCapability} is not enabled for the ${name} tool` };
@@ -209,6 +214,10 @@ export class InstalledPluginRuntimeV1 {
209
214
  for (const hook of this.mcpStartupHooks)
210
215
  await hook();
211
216
  }
217
+ async runMcpShutdown() {
218
+ for (const hook of this.mcpShutdownHooks)
219
+ await hook();
220
+ }
212
221
  createHost(manifest) {
213
222
  const descriptors = new Map(manifest.commands.map((command) => [command.name, command]));
214
223
  const stateRoot = path.join(this.store.root, "state");
@@ -257,7 +266,7 @@ export class InstalledPluginRuntimeV1 {
257
266
  this.contextProviders.push({ provider, pluginId: manifest.id });
258
267
  },
259
268
  registerMcpTool: (tool) => {
260
- if (!(manifest.capabilities ?? []).includes(tool.requiredCapability))
269
+ if (tool.requiredCapability && !(manifest.capabilities ?? []).includes(tool.requiredCapability))
261
270
  throw new PluginBootstrapFailure("plugin_mcp_tool_invalid", `Plugin ${manifest.id} registered an MCP tool with an undeclared capability`);
262
271
  if (!tool.name || this.mcpTools.some((existing) => existing.name === tool.name))
263
272
  throw new PluginBootstrapFailure("plugin_mcp_tool_invalid", `Plugin MCP tool ${tool.name || "(unnamed)"} is invalid or already registered`);
@@ -266,6 +275,9 @@ export class InstalledPluginRuntimeV1 {
266
275
  registerMcpStartup: (fn) => {
267
276
  this.mcpStartupHooks.push(fn);
268
277
  },
278
+ registerMcpShutdown: (fn) => {
279
+ this.mcpShutdownHooks.push(fn);
280
+ },
269
281
  getStateDirectory: () => stateDirectory,
270
282
  getMemoryDirectory: () => {
271
283
  assertPermission(manifest, "memory:read");
@@ -1,4 +1,4 @@
1
- import { type PluginAccessDecisionV1, type PluginBootstrapBackendV1, type PluginNextActionV1, type PluginSessionUsageDecisionV1, type SignedPluginReleaseV1 } from "./plugin-bootstrap.js";
1
+ import { type PluginAccessDecisionV1, type PluginBootstrapBackendV1, type PluginNextActionV1, type SignedPluginReleaseV1 } from "./plugin-bootstrap.js";
2
2
  import { type PluginEntitlementStatusV1 } from "./plugin-host.js";
3
3
  interface AgentMemoryServiceBackendOptions {
4
4
  root?: string;
@@ -27,9 +27,6 @@ export declare class AgentMemoryServiceBackend implements PluginBootstrapBackend
27
27
  channel: string;
28
28
  allowAuthentication: boolean;
29
29
  }): Promise<PluginAccessDecisionV1>;
30
- reserveSession(operationId: string): Promise<PluginSessionUsageDecisionV1>;
31
- commitSession(operationId: string): Promise<PluginSessionUsageDecisionV1>;
32
- releaseSession(operationId: string): Promise<PluginSessionUsageDecisionV1>;
33
30
  listReleases(request: {
34
31
  bundleId: string;
35
32
  channel: string;
@@ -43,7 +40,6 @@ export declare class AgentMemoryServiceBackend implements PluginBootstrapBackend
43
40
  private activationPath;
44
41
  private readActivation;
45
42
  private writeActivation;
46
- private sessionUsage;
47
43
  private request;
48
44
  }
49
45
  export declare class TemporaryPluginBackend extends AgentMemoryServiceBackend {
@@ -139,8 +139,8 @@ function activationPage(action, error) {
139
139
  <p class="terminal-note" id="terminal-note">Your terminal will finish setup after activation.</p>
140
140
  <details>
141
141
  <summary>What’s shared during activation</summary>
142
- <p>Your email identifies your free daily allowance. The CLI also sends core and bundle versions, platform, architecture, and release channel. The service stores a daily count of opaque session-start operations. Activation records expire after 365 days without use.</p>
143
- <p class="never-sent"><strong>Never sent:</strong> The request never includes memory, session content, queries, repository paths, raw agent session identifiers, IP addresses, or user-agent strings.</p>
142
+ <p>Your email identifies your free daily allowance. The CLI also sends core and bundle versions, platform, architecture, and release channel. The service stores bounded activation metadata. Activation records expire after 365 days without use.</p>
143
+ <p class="never-sent"><strong>Not included in AgentMemory's application payload:</strong> memory, session content, queries, repository paths, raw agent session identifiers, IP addresses, or user-agent strings.</p>
144
144
  </details>
145
145
  </section>
146
146
  </main>
@@ -394,8 +394,6 @@ export class AgentMemoryServiceBackend {
394
394
  validatePluginEntitlementStatusV1(value.entitlement);
395
395
  if (typeof value.artifactGrant !== "string" || !value.artifactGrant)
396
396
  throw new PluginBootstrapFailure("service_response_invalid", "The access response omitted its artifact grant");
397
- if (typeof value.usageCredential !== "string" || !ACTIVATION_CREDENTIAL.test(value.usageCredential))
398
- throw new PluginBootstrapFailure("service_response_invalid", "The access response omitted its usage credential");
399
397
  const recallQuota = value.entitlement.capabilities.recall?.quota;
400
398
  const learningQuota = value.entitlement.capabilities.learning?.quota;
401
399
  if (value.entitlement.plan !== "free" ||
@@ -408,18 +406,9 @@ export class AgentMemoryServiceBackend {
408
406
  value.entitlement.capabilities["session-worker"]?.enabled !== false ||
409
407
  value.entitlement.capabilities["web-console"]?.enabled !== true)
410
408
  throw new PluginBootstrapFailure("service_response_invalid", "The free preview policy is invalid");
411
- this.writeActivation(installationId, value.usageCredential, 1);
409
+ this.writeActivation(installationId);
412
410
  return { kind: "granted", entitlement: value.entitlement, artifactGrant: value.artifactGrant };
413
411
  }
414
- async reserveSession(operationId) {
415
- return this.sessionUsage("reserve", operationId);
416
- }
417
- async commitSession(operationId) {
418
- return this.sessionUsage("commit", operationId);
419
- }
420
- async releaseSession(operationId) {
421
- return this.sessionUsage("release", operationId);
422
- }
423
412
  async listReleases(request) {
424
413
  const response = await this.request(`${this.apiOrigin}/v1/plugin/releases`, {
425
414
  headers: { Authorization: `Bearer ${request.artifactGrant}` },
@@ -474,28 +463,28 @@ export class AgentMemoryServiceBackend {
474
463
  if (!stat.isFile() || stat.isSymbolicLink() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0))
475
464
  return null;
476
465
  const value = JSON.parse(fs.readFileSync(activationPath, "utf-8"));
477
- if (value.schemaVersion !== 3 ||
478
- typeof value.installationId !== "string" ||
466
+ if (typeof value.installationId !== "string" ||
479
467
  !/^am_install_[A-Za-z0-9_-]{32}$/.test(value.installationId) ||
480
468
  !Number.isFinite(Date.parse(value.activatedAt)) ||
481
- !ACTIVATION_CREDENTIAL.test(value.usageCredential) ||
482
- !Number.isSafeInteger(value.dailySessionLimit) ||
483
- value.dailySessionLimit <= 0 ||
484
- value.dailySessionLimit > 10_000)
469
+ (value.schemaVersion !== 4 &&
470
+ (value.schemaVersion !== 3 ||
471
+ typeof value.usageCredential !== "string" ||
472
+ !ACTIVATION_CREDENTIAL.test(value.usageCredential) ||
473
+ !Number.isSafeInteger(value.dailySessionLimit))))
485
474
  return null;
486
- return value;
475
+ return {
476
+ schemaVersion: 4,
477
+ installationId: value.installationId,
478
+ activatedAt: value.activatedAt,
479
+ };
487
480
  }
488
481
  catch {
489
482
  return null;
490
483
  }
491
484
  }
492
- writeActivation(installationId, usageCredential, dailySessionLimit) {
485
+ writeActivation(installationId) {
493
486
  if (!/^am_install_[A-Za-z0-9_-]{32}$/.test(installationId))
494
487
  throw new PluginBootstrapFailure("activation_failed", "The installation identifier is invalid");
495
- if (!ACTIVATION_CREDENTIAL.test(usageCredential))
496
- throw new PluginBootstrapFailure("activation_failed", "The activation credential is invalid");
497
- if (!Number.isSafeInteger(dailySessionLimit) || dailySessionLimit <= 0 || dailySessionLimit > 10_000)
498
- throw new PluginBootstrapFailure("activation_failed", "The free session allowance is invalid");
499
488
  const target = this.activationPath();
500
489
  fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
501
490
  const rootStat = fs.lstatSync(this.root);
@@ -509,43 +498,12 @@ export class AgentMemoryServiceBackend {
509
498
  throw new PluginBootstrapFailure("activation_path_invalid", "The plugin activation directory is unsafe");
510
499
  const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`;
511
500
  fs.writeFileSync(temporary, `${JSON.stringify({
512
- schemaVersion: 3,
501
+ schemaVersion: 4,
513
502
  installationId,
514
503
  activatedAt: new Date().toISOString(),
515
- usageCredential,
516
- dailySessionLimit,
517
504
  }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
518
505
  fs.renameSync(temporary, target);
519
506
  }
520
- async sessionUsage(action, operationId) {
521
- const activation = this.readActivation();
522
- if (!activation)
523
- throw new PluginBootstrapFailure("auth_required", "Run plugin install to activate AgentMemory");
524
- const response = await this.request(`${this.apiOrigin}/v1/plugin/sessions/${action}`, {
525
- method: "POST",
526
- headers: {
527
- Authorization: `Bearer ${activation.usageCredential}`,
528
- "Content-Type": "application/json",
529
- },
530
- body: JSON.stringify({ schemaVersion: 1, operationId }),
531
- });
532
- const value = (await readJson(response));
533
- const decision = value.decision;
534
- if (!decision ||
535
- typeof decision.allowed !== "boolean" ||
536
- !["reserved", "committed", "released", "exhausted", "missing"].includes(String(decision.state)) ||
537
- !Number.isSafeInteger(decision.limit) ||
538
- Number(decision.limit) <= 0 ||
539
- !Number.isSafeInteger(decision.used) ||
540
- Number(decision.used) < 0 ||
541
- !Number.isSafeInteger(decision.remaining) ||
542
- Number(decision.remaining) < 0 ||
543
- typeof decision.resetAt !== "string" ||
544
- !Number.isFinite(Date.parse(decision.resetAt)) ||
545
- typeof decision.idempotent !== "boolean")
546
- throw new PluginBootstrapFailure("service_response_invalid", "The session usage response is invalid");
547
- return decision;
548
- }
549
507
  async request(url, init = {}) {
550
508
  let response;
551
509
  try {
package/dist/upgrade.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * retried before the next cache refresh, and never thrown.
16
16
  */
17
17
  import { type SpawnOptions } from "node:child_process";
18
- export type InstallManager = "bun" | "npm" | "pnpm" | "yarn" | "unknown";
18
+ export type InstallManager = "bun" | "homebrew" | "npm" | "pnpm" | "yarn" | "unknown";
19
19
  export interface InstallMethod {
20
20
  manager: InstallManager;
21
21
  global: boolean;
@@ -77,6 +77,14 @@ export declare function readUpgradePolicy(): UpgradePolicy & {
77
77
  };
78
78
  /** Atomically persist the auto-upgrade policy. Merges with whatever is already on disk. */
79
79
  export declare function writeUpgradePolicy(patch: Partial<UpgradePolicy>): UpgradePolicy;
80
+ export declare function resolveSelfLaunch(input: {
81
+ execPath: string;
82
+ scriptCandidate: string | undefined;
83
+ fileExists: (candidate: string) => boolean;
84
+ }): {
85
+ command: string;
86
+ args: string[];
87
+ };
80
88
  /**
81
89
  * Best-effort detection of how `myagentmemory` was installed. Path signatures
82
90
  * are heuristic but cover the common managers. On no match we fall back to
package/dist/upgrade.js CHANGED
@@ -167,12 +167,34 @@ export function writeUpgradePolicy(patch) {
167
167
  // ---------------------------------------------------------------------------
168
168
  // Install-method detection
169
169
  // ---------------------------------------------------------------------------
170
+ export function resolveSelfLaunch(input) {
171
+ const scriptCandidate = input.scriptCandidate;
172
+ const isRealScript = typeof scriptCandidate === "string" &&
173
+ scriptCandidate.length > 0 &&
174
+ !scriptCandidate.startsWith("/$bunfs/") &&
175
+ input.fileExists(scriptCandidate);
176
+ return isRealScript ? { command: input.execPath, args: [scriptCandidate] } : { command: input.execPath, args: [] };
177
+ }
178
+ function currentSelfLaunch() {
179
+ return resolveSelfLaunch({
180
+ execPath: process.execPath,
181
+ scriptCandidate: process.argv[1],
182
+ fileExists: fs.existsSync,
183
+ });
184
+ }
170
185
  function selfInstallPath() {
186
+ const launch = currentSelfLaunch();
187
+ const candidate = launch.args[0] ?? launch.command;
171
188
  try {
172
- return url.fileURLToPath(import.meta.url);
189
+ return fs.realpathSync(candidate);
173
190
  }
174
191
  catch {
175
- return process.argv[1] ?? "";
192
+ try {
193
+ return url.fileURLToPath(import.meta.url);
194
+ }
195
+ catch {
196
+ return candidate;
197
+ }
176
198
  }
177
199
  }
178
200
  /**
@@ -184,6 +206,16 @@ export function detectInstallMethod(location = selfInstallPath()) {
184
206
  const normalized = location.replace(/\\/g, "/");
185
207
  const home = os.homedir().replace(/\\/g, "/");
186
208
  const pkg = `${NPM_PACKAGE_NAME}@latest`;
209
+ // Compiled CLI installed by the official Homebrew formula. Resolve symlinks
210
+ // before detection so /opt/homebrew/bin/agent-memory reaches its Cellar path.
211
+ if (normalized.includes("/Cellar/agent-memory/")) {
212
+ return {
213
+ manager: "homebrew",
214
+ global: true,
215
+ origin: location,
216
+ command: ["brew", "upgrade", "jayzeng/agentmemory/agent-memory"],
217
+ };
218
+ }
187
219
  // bun global install
188
220
  if (normalized.includes("/.bun/install/global/") || normalized.includes("/bun/install/global/")) {
189
221
  return { manager: "bun", global: true, origin: location, command: ["bun", "add", "-g", pkg] };
@@ -241,11 +273,10 @@ export function runInstaller(method, opts = {}) {
241
273
  */
242
274
  export function refreshUpgradeCacheBackground() {
243
275
  try {
244
- const binary = process.argv[0];
245
- const script = process.argv[1];
246
- if (!binary || !script)
276
+ const launch = currentSelfLaunch();
277
+ if (!launch.command)
247
278
  return;
248
- const child = spawn(binary, [script, "upgrade", "--background", "--refresh", "--quiet", "--json"], {
279
+ const child = spawn(launch.command, [...launch.args, "upgrade", "--background", "--refresh", "--quiet", "--json"], {
249
280
  detached: true,
250
281
  stdio: "ignore",
251
282
  env: { ...process.env, AGENT_MEMORY_UPGRADE_BACKGROUND: "1" },
@@ -89,7 +89,7 @@ The `pro` namespace is the user-facing surface. The `plugin` namespace remains s
89
89
  - `status` is read-only. It reports the installed bundle, selected channel, compatibility, entitlement state, and update availability.
90
90
  - `install` authenticates when necessary, then installs, upgrades, or reports current state.
91
91
  - `update` requires an existing installation and never starts a new purchase implicitly.
92
- - `uninstall` removes executable plugin material and the active receipt. It preserves core memory, plugin state, and the permission-restricted activation credential.
92
+ - `uninstall` removes executable plugin material and the active receipt. It preserves core memory, plugin state, and the permission-restricted local activation record.
93
93
  - `manage` remains unavailable until authenticated account and billing management exists.
94
94
 
95
95
  Installed plugins contribute top-level commands including `recall` and `learn`; `dashboard` is a product-facing alias for the lower-level `web` command. Bootstrap command names are reserved by the core and cannot be replaced by a plugin.
@@ -172,7 +172,6 @@ An Enterprise administrator may pre-provision an organization entitlement or man
172
172
  The service exposes:
173
173
 
174
174
  - `POST /v1/plugin/access` for an anonymous free-preview policy, compatibility credential, and short-lived artifact grant;
175
- - `POST /v1/plugin/sessions/reserve|commit|release` for migration compatibility with activation-v2 clients;
176
175
  - `GET /v1/plugin/releases` for an Ed25519-signed release selected from the private R2 catalog;
177
176
  - `GET|HEAD /v1/artifacts/download` for the exact content-addressed object authorized by the bearer grant.
178
177
 
@@ -191,7 +190,7 @@ The bootstrap may send only:
191
190
  - core version, plugin-host API version, platform, and architecture;
192
191
  - requested bundle ID, installed bundle version, and release channel;
193
192
  - a pseudonymous license or organization identifier;
194
- - protocol nonces, opaque quota operation IDs, and authentication material required for the request.
193
+ - protocol nonces and authentication material required for the request.
195
194
 
196
195
  It must never send memory contents, search queries, session contents, raw agent session identifiers, working-directory names, repository names, filesystem paths, or qmd data. The bounded allowance counter is authorization state, not general product telemetry.
197
196
 
@@ -267,7 +266,7 @@ An install or upgrade must:
267
266
 
268
267
  Failure before activation leaves the previous version active. Failure immediately after activation restores the previous receipt. Concurrent installers do not interleave. The core never invokes package-manager lifecycle scripts or elevates privileges.
269
268
 
270
- Uninstall removes executable versions, the active receipt, contributed skills, and managed hooks. It does not remove `MEMORY.md`, daily logs, topics, scratchpad items, source session logs, plugin-created review data, or billing state. The top-level `agent-memory uninstall` command composes this with hook/skill/MCP/completion removal in one step; its explicit `--data` flag additionally deletes the memory directory and the entire plugin install root (bundles, receipts, and the activation credential) once the user opts in and confirms.
269
+ Uninstall removes executable versions, the active receipt, contributed skills, and managed hooks. It does not remove `MEMORY.md`, daily logs, topics, scratchpad items, source session logs, plugin-created review data, or local activation state. The top-level `agent-memory uninstall` command composes this with hook/skill/MCP/completion removal in one step; its explicit `--data` flag additionally deletes the memory directory and the entire plugin install root (bundles, receipts, and activation state) once the user opts in and confirms.
271
270
 
272
271
  ## Plugin host API v1
273
272
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagentmemory",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "agentmemory (agent-memory) is persistent memory for coding agents (Claude Code, OpenAI Codex, Cursor, Agent) with qmd-powered semantic search across daily logs, long-term memory, and scratchpad",
5
5
  "main": "./dist/core.js",
6
6
  "types": "./dist/core.d.ts",
@@ -93,7 +93,6 @@
93
93
  "dist/plugin-runtime.js",
94
94
  "dist/plugin-service.d.ts",
95
95
  "dist/plugin-service.js",
96
-
97
96
  "dist/mcp-server.d.ts",
98
97
  "dist/mcp-server.js",
99
98
  "dist/upgrade.d.ts",
package/src/cli-spec.ts CHANGED
@@ -82,7 +82,7 @@ export const PLUGIN_COMMAND_DESCRIPTIONS: Record<(typeof PLUGIN_COMMANDS)[number
82
82
  install: "authenticate if needed, then install or upgrade the official bundle",
83
83
  update: "upgrade an existing official bundle when a compatible release exists",
84
84
  uninstall: "remove official plugin executables while preserving user data",
85
- manage: "open the AgentMemory account and billing website",
85
+ manage: "open account management when that future service is available",
86
86
  };
87
87
 
88
88
  export const WORKER_ACTION_DESCRIPTIONS: Record<(typeof WORKER_ACTIONS)[number], string> = {};
package/src/hooks.ts CHANGED
@@ -156,7 +156,13 @@ function hasClaudeHookGroup(homeDir: string, eventKey: string, command: string):
156
156
  for (const hook of list) {
157
157
  if (!hook || typeof hook !== "object") continue;
158
158
  const h = hook as Record<string, unknown>;
159
- if (h[HOOK_MARKER_JSON] === true && h.command === command) return true;
159
+ // Match by command alone (marker is optional) legacy entries that
160
+ // predate HOOK_MARKER_JSON already run this exact command, and the
161
+ // command string is specific enough to agent-memory that it's a safe
162
+ // signal on its own. Otherwise a pre-marker install is invisible here,
163
+ // doctor/isHookInstalled falsely report "not installed" for hooks that
164
+ // are actually live, and upsertClaudeHookGroup can't find them to dedupe.
165
+ if (h.command === command) return true;
160
166
  }
161
167
  }
162
168
  return false;
@@ -388,55 +394,81 @@ function upsertClaudeHookGroup(
388
394
  command: string,
389
395
  ): { changed: boolean; hadManaged: boolean } {
390
396
  const groups = Array.isArray(hooks[eventKey]) ? [...(hooks[eventKey] as unknown[])] : [];
391
- const managedIndexes: number[] = [];
392
- for (let i = 0; i < groups.length; i++) {
393
- const group = groups[i];
397
+
398
+ // Locate every agent-memory-owned hook entry matched by marker, or by
399
+ // exact command string for legacy pre-marker installs — wherever it lives.
400
+ const owned: Array<{ group: Record<string, unknown>; hook: Record<string, unknown> }> = [];
401
+ for (const group of groups) {
394
402
  if (!group || typeof group !== "object") continue;
395
403
  const g = group as Record<string, unknown>;
396
404
  const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
397
405
  for (const hook of list) {
398
406
  if (!hook || typeof hook !== "object") continue;
399
407
  const h = hook as Record<string, unknown>;
400
- if (h[HOOK_MARKER_JSON] === true) {
401
- managedIndexes.push(i);
402
- break;
403
- }
408
+ if (h[HOOK_MARKER_JSON] === true || h.command === command) owned.push({ group: g, hook: h });
404
409
  }
405
410
  }
406
411
 
407
- if (managedIndexes.length > 0) {
408
- const keep = managedIndexes[0];
409
- const dupes = managedIndexes.slice(1);
410
- for (const idx of dupes.reverse()) groups.splice(idx, 1);
411
- const group = groups[keep] as Record<string, unknown>;
412
- let changed = dupes.length > 0;
413
- if ("matcher" in group) {
414
- delete group.matcher;
415
- changed = true;
416
- }
417
- const list = group.hooks as Record<string, unknown>[];
418
- for (const h of list) {
419
- if (h[HOOK_MARKER_JSON] === true && h.command !== command) {
420
- h.command = command;
421
- changed = true;
422
- }
423
- }
412
+ if (owned.length === 0) {
413
+ // No existing managed hook anywhere — add a fresh group without a matcher so it fires on all harnesses.
414
+ groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
424
415
  hooks[eventKey] = groups;
425
- return { changed, hadManaged: true };
426
- }
427
-
428
- // No existing managed group add a fresh one without a matcher so it fires on all harnesses.
429
- groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
430
- hooks[eventKey] = groups;
431
- return { changed: true, hadManaged: false };
416
+ return { changed: true, hadManaged: false };
417
+ }
418
+
419
+ // Keep the first owned entry in place fixed up in place, preserving its
420
+ // identity so a no-op re-install reports unchanged and remove every
421
+ // other owned entry from its own group's hooks array, whether it's a
422
+ // duplicate in another group or piled up alongside the keeper in the same
423
+ // group. Never delete a whole group, since it could carry unrelated
424
+ // hand-added hooks.
425
+ const keeper = owned[0];
426
+ let changed = false;
427
+ if (keeper.hook.command !== command) {
428
+ keeper.hook.command = command;
429
+ changed = true;
430
+ }
431
+ if (keeper.hook[HOOK_MARKER_JSON] !== true) {
432
+ keeper.hook[HOOK_MARKER_JSON] = true;
433
+ changed = true;
434
+ }
435
+ for (const dupe of owned.slice(1)) {
436
+ const list = dupe.group.hooks as unknown[];
437
+ const idx = list.indexOf(dupe.hook);
438
+ if (idx !== -1) list.splice(idx, 1);
439
+ changed = true;
440
+ }
441
+
442
+ // Only clear the keeper's group matcher when that group is exclusively
443
+ // ours (no unrelated hooks left in it after dedup) — never when it also
444
+ // carries an unrelated hand-added hook.
445
+ const keeperList = keeper.group.hooks as unknown[];
446
+ if (keeperList.length === 1 && "matcher" in keeper.group) {
447
+ delete keeper.group.matcher;
448
+ changed = true;
449
+ }
450
+
451
+ // Drop any group left with zero hooks; never drop one that still has
452
+ // unrelated hooks in it.
453
+ const filtered = groups.filter((group) => {
454
+ if (!group || typeof group !== "object") return true;
455
+ const g = group as Record<string, unknown>;
456
+ return !Array.isArray(g.hooks) || (g.hooks as unknown[]).length > 0;
457
+ });
458
+ hooks[eventKey] = filtered;
459
+ return { changed, hadManaged: true };
432
460
  }
433
461
 
434
462
  /**
435
463
  * Remove all agent-memory-managed hook entries for `eventKey`. Used when
436
464
  * downgrading from per-turn back to stable (drops UserPromptSubmit).
465
+ * `command`, when given, also matches legacy entries that predate
466
+ * HOOK_MARKER_JSON — the same broadened detection upsertClaudeHookGroup and
467
+ * hasClaudeHookGroup use — so a pre-marker install isn't left behind after a
468
+ * downgrade/uninstall that reports success.
437
469
  * Returns true if anything was removed.
438
470
  */
439
- function removeClaudeHookGroup(hooks: Record<string, unknown>, eventKey: string): boolean {
471
+ function removeClaudeHookGroup(hooks: Record<string, unknown>, eventKey: string, command?: string): boolean {
440
472
  const groups = Array.isArray(hooks[eventKey]) ? (hooks[eventKey] as unknown[]) : [];
441
473
  if (groups.length === 0) return false;
442
474
  let removed = 0;
@@ -446,7 +478,8 @@ function removeClaudeHookGroup(hooks: Record<string, unknown>, eventKey: string)
446
478
  const g = { ...(group as Record<string, unknown>) };
447
479
  const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
448
480
  const kept = list.filter((h) => {
449
- const isOurs = h && typeof h === "object" && (h as Record<string, unknown>)[HOOK_MARKER_JSON] === true;
481
+ const hook = h && typeof h === "object" ? (h as Record<string, unknown>) : null;
482
+ const isOurs = !!hook && (hook[HOOK_MARKER_JSON] === true || (!!command && hook.command === command));
450
483
  if (isOurs) removed++;
451
484
  return !isOurs;
452
485
  });
@@ -478,7 +511,7 @@ function installClaudeCodeHook(homeDir: string, mode: HookMode = "per-turn"): Ho
478
511
  promptChanged = prompt.changed;
479
512
  promptHadManaged = prompt.hadManaged;
480
513
  } else {
481
- promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit");
514
+ promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
482
515
  }
483
516
  // Stop backs the write side of memory with a periodic nudge. It is orthogonal
484
517
  // to stable/per-turn context injection, so it is installed unconditionally.
@@ -848,9 +881,9 @@ function uninstallClaudeCodeHook(homeDir: string): HookInstallResult {
848
881
  }
849
882
  const settings = readJsonConfig(settingsPath);
850
883
  const hooks = (settings.hooks as Record<string, unknown>) ?? {};
851
- const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart");
852
- const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit");
853
- const stopRemoved = removeClaudeHookGroup(hooks, "Stop");
884
+ const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart", sessionStartHookCommand("claude"));
885
+ const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
886
+ const stopRemoved = removeClaudeHookGroup(hooks, "Stop", stopHookCommand("claude"));
854
887
  const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
855
888
  if (!sessionRemoved && !promptRemoved && !stopRemoved && !legacyPreCompactRemoved) {
856
889
  return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
@@ -176,7 +176,12 @@ export interface PluginMcpToolInputSchema {
176
176
  export interface PluginMcpToolV1 {
177
177
  name: string;
178
178
  description: string;
179
- requiredCapability: string;
179
+ /**
180
+ * Capability checked on every invocation. Optional only to keep plugin API 1
181
+ * source-compatible with bundles built before capability-gated MCP tools
182
+ * were introduced; legacy tools load but are denied until updated.
183
+ */
184
+ requiredCapability?: string;
180
185
  inputSchema: PluginMcpToolInputSchema;
181
186
  run(input: Record<string, unknown>): unknown | Promise<unknown>;
182
187
  }
@@ -190,6 +195,7 @@ export interface AgentMemoryPluginHostV1 {
190
195
  registerContextProvider?(provider: PluginContextProviderV1): void;
191
196
  registerMcpTool?(tool: PluginMcpToolV1): void;
192
197
  registerMcpStartup?(fn: () => void | Promise<void>): void;
198
+ registerMcpShutdown?(fn: () => void | Promise<void>): void;
193
199
  getStateDirectory(): string;
194
200
  getMemoryDirectory(): string;
195
201
  getEntitlement(): Promise<PluginEntitlementStatusV1>;