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/README.md +68 -71
- package/dist/cli-spec.d.ts +7 -1
- package/dist/cli-spec.js +214 -12
- package/dist/cli.js +2049 -156
- package/dist/completions.js +24 -18
- package/dist/core.d.ts +42 -4
- package/dist/core.js +242 -68
- package/dist/hooks.d.ts +21 -1
- package/dist/hooks.js +382 -87
- package/dist/mcp-server.d.ts +27 -0
- package/dist/mcp-server.js +106 -0
- package/dist/plugin-bootstrap.js +4 -4
- package/dist/plugin-host.d.ts +33 -0
- package/dist/plugin-runtime.d.ts +13 -1
- package/dist/plugin-runtime.js +44 -2
- package/dist/plugin-service.d.ts +10 -4
- package/dist/plugin-service.js +52 -8
- package/dist/upgrade.d.ts +80 -0
- package/dist/upgrade.js +243 -0
- package/docs/official-plugin-bootstrap.md +3 -3
- package/package.json +24 -4
- package/scripts/install-skills.sh +1 -1
- package/skills/agent/SKILL.md +12 -2
- package/skills/claude-code/SKILL.md +17 -2
- package/skills/codex/SKILL.md +14 -2
- package/skills/cursor/SKILL.md +14 -2
- package/src/cli-spec.ts +218 -12
- package/src/completions.ts +26 -18
- package/src/core.ts +312 -123
- package/src/hooks.ts +395 -85
- package/src/plugin-bootstrap.ts +4 -4
- package/src/plugin-host.ts +33 -0
- package/src/cli.ts +0 -1332
- package/src/plugin-runtime.ts +0 -390
- package/src/plugin-service.ts +0 -627
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
227
|
-
const
|
|
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 = "${
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
242
|
-
return {
|
|
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 {
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
else if (target.key === "
|
|
343
|
-
else if (target.key === "
|
|
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
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
689
|
+
function uninstallCursorHook(homeDir: string): HookInstallResult {
|
|
419
690
|
const rulePath = path.join(homeDir, ".cursor", "rules", "agent-memory.mdc");
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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:
|
|
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(
|
|
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({
|
package/src/plugin-bootstrap.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type PluginEntitlementStatusV1,
|
|
11
11
|
validateBundleManifestV1,
|
|
12
12
|
} from "./plugin-host.js";
|
|
13
|
-
import {
|
|
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
|
|
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
|
|
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":
|
|
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");
|