myagentmemory 0.4.16 → 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/LICENSE +1 -0
- package/README.md +78 -68
- package/dist/cli-spec.d.ts +7 -1
- package/dist/cli-spec.js +226 -12
- package/dist/cli.js +2140 -182
- 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 +6 -6
- package/dist/plugin-host.d.ts +51 -0
- package/dist/plugin-runtime.d.ts +20 -1
- package/dist/plugin-runtime.js +83 -2
- package/dist/plugin-service.d.ts +10 -4
- package/dist/plugin-service.js +83 -42
- package/dist/upgrade.d.ts +80 -0
- package/dist/upgrade.js +243 -0
- package/docs/official-plugin-bootstrap.md +30 -22
- 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 +230 -12
- package/src/completions.ts +26 -18
- package/src/core.ts +312 -123
- package/src/hooks.ts +395 -85
- package/src/plugin-bootstrap.ts +6 -6
- package/src/plugin-host.ts +53 -0
- package/src/cli.ts +0 -1271
- package/src/plugin-runtime.ts +0 -326
- package/src/plugin-service.ts +0 -628
package/dist/hooks.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HookMode } from "./core.js";
|
|
1
2
|
/** Override the detected home directory in deterministic tests. */
|
|
2
3
|
export declare function _setHookHomeDirForTest(directory: string | null): void;
|
|
3
4
|
export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi";
|
|
@@ -14,6 +15,24 @@ export interface DetectedHookTarget extends HookTargetInfo {
|
|
|
14
15
|
detected: boolean;
|
|
15
16
|
detectReason?: string;
|
|
16
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Read-only check whether the SessionStart hook for `key` is already present in
|
|
20
|
+
* the user's config. Mirrors each installer's "already installed" detection so
|
|
21
|
+
* the CLI can avoid prompting for hooks that don't need to be installed.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isHookInstalled(homeDir: string, key: HookAgentKey): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Read-only check whether the per-turn UserPromptSubmit hook is present.
|
|
26
|
+
* Only Claude Code and Codex support a per-prompt hook; cursor/opencode
|
|
27
|
+
* always return false (static rules only).
|
|
28
|
+
*/
|
|
29
|
+
export declare function isUserPromptSubmitInstalled(homeDir: string, key: HookAgentKey): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Read-only check whether the periodic Stop-hook memory-write nudge is present.
|
|
32
|
+
* Claude Code only — Codex/Cursor/opencode don't have a confirmed equivalent
|
|
33
|
+
* block/reason protocol for this event yet.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isStopHookInstalled(homeDir: string, key: HookAgentKey): boolean;
|
|
17
36
|
export declare function detectHookAgents(): {
|
|
18
37
|
homeDir: string | null;
|
|
19
38
|
targets: DetectedHookTarget[];
|
|
@@ -25,6 +44,7 @@ export interface HookInstallResult {
|
|
|
25
44
|
path?: string;
|
|
26
45
|
backup?: string;
|
|
27
46
|
reason?: string;
|
|
47
|
+
mode?: HookMode;
|
|
28
48
|
}
|
|
29
49
|
export interface InstallHooksReport {
|
|
30
50
|
ok: boolean;
|
|
@@ -32,7 +52,7 @@ export interface InstallHooksReport {
|
|
|
32
52
|
results: HookInstallResult[];
|
|
33
53
|
error?: string;
|
|
34
54
|
}
|
|
35
|
-
export declare function installHooks(agents: Set<HookAgentKey
|
|
55
|
+
export declare function installHooks(agents: Set<HookAgentKey>, mode?: HookMode): InstallHooksReport;
|
|
36
56
|
export interface UninstallHooksReport {
|
|
37
57
|
ok: boolean;
|
|
38
58
|
homeDir?: string;
|
package/dist/hooks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { writeHookMode } from "./core.js";
|
|
4
5
|
let homeDirOverride = null;
|
|
5
6
|
/** Override the detected home directory in deterministic tests. */
|
|
6
7
|
export function _setHookHomeDirForTest(directory) {
|
|
@@ -35,6 +36,12 @@ const HOOK_MARKER_END = "# END agent-memory hook";
|
|
|
35
36
|
function sessionStartHookCommand(agent) {
|
|
36
37
|
return `agent-memory hook session-start --agent ${agent}`;
|
|
37
38
|
}
|
|
39
|
+
function userPromptSubmitHookCommand(agent) {
|
|
40
|
+
return `agent-memory hook user-prompt-submit --agent ${agent}`;
|
|
41
|
+
}
|
|
42
|
+
function stopHookCommand(agent) {
|
|
43
|
+
return `agent-memory hook stop --agent ${agent}`;
|
|
44
|
+
}
|
|
38
45
|
function hookTargets(homeDir) {
|
|
39
46
|
return [
|
|
40
47
|
{
|
|
@@ -82,6 +89,101 @@ function hookTargets(homeDir) {
|
|
|
82
89
|
},
|
|
83
90
|
];
|
|
84
91
|
}
|
|
92
|
+
function hasClaudeHookGroup(homeDir, eventKey, command) {
|
|
93
|
+
const settingsPath = path.join(homeDir, ".claude", "settings.json");
|
|
94
|
+
if (!fs.existsSync(settingsPath))
|
|
95
|
+
return false;
|
|
96
|
+
const settings = readJsonConfig(settingsPath);
|
|
97
|
+
const hooks = settings.hooks ?? {};
|
|
98
|
+
const groups = Array.isArray(hooks[eventKey]) ? hooks[eventKey] : [];
|
|
99
|
+
for (const group of groups) {
|
|
100
|
+
if (!group || typeof group !== "object")
|
|
101
|
+
continue;
|
|
102
|
+
const g = group;
|
|
103
|
+
const list = Array.isArray(g.hooks) ? g.hooks : [];
|
|
104
|
+
for (const hook of list) {
|
|
105
|
+
if (!hook || typeof hook !== "object")
|
|
106
|
+
continue;
|
|
107
|
+
const h = hook;
|
|
108
|
+
if (h[HOOK_MARKER_JSON] === true && h.command === command)
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Read-only check whether the SessionStart hook for `key` is already present in
|
|
116
|
+
* the user's config. Mirrors each installer's "already installed" detection so
|
|
117
|
+
* the CLI can avoid prompting for hooks that don't need to be installed.
|
|
118
|
+
*/
|
|
119
|
+
export function isHookInstalled(homeDir, key) {
|
|
120
|
+
try {
|
|
121
|
+
if (key === "claude")
|
|
122
|
+
return hasClaudeHookGroup(homeDir, "SessionStart", sessionStartHookCommand("claude"));
|
|
123
|
+
if (key === "codex") {
|
|
124
|
+
const configPath = path.join(homeDir, ".codex", "config.toml");
|
|
125
|
+
if (!fs.existsSync(configPath))
|
|
126
|
+
return false;
|
|
127
|
+
const existing = fs.readFileSync(configPath, "utf-8");
|
|
128
|
+
if (!existing.includes(HOOK_MARKER_BEGIN))
|
|
129
|
+
return false;
|
|
130
|
+
const command = sessionStartHookCommand("codex");
|
|
131
|
+
return existing.includes(`command = "${command}"`);
|
|
132
|
+
}
|
|
133
|
+
if (key === "cursor") {
|
|
134
|
+
return isCursorSessionStartHookRegistered(homeDir);
|
|
135
|
+
}
|
|
136
|
+
if (key === "opencode") {
|
|
137
|
+
const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
|
|
138
|
+
if (!fs.existsSync(configPath))
|
|
139
|
+
return false;
|
|
140
|
+
const config = readJsonConfig(configPath);
|
|
141
|
+
const raw = config.instructions;
|
|
142
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
143
|
+
const instructionsPath = path.join(homeDir, ".agent-memory", "hooks", "opencode.md");
|
|
144
|
+
return list.includes(instructionsPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Read-only check whether the per-turn UserPromptSubmit hook is present.
|
|
154
|
+
* Only Claude Code and Codex support a per-prompt hook; cursor/opencode
|
|
155
|
+
* always return false (static rules only).
|
|
156
|
+
*/
|
|
157
|
+
export function isUserPromptSubmitInstalled(homeDir, key) {
|
|
158
|
+
try {
|
|
159
|
+
if (key === "claude")
|
|
160
|
+
return hasClaudeHookGroup(homeDir, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
|
|
161
|
+
if (key === "codex") {
|
|
162
|
+
const configPath = path.join(homeDir, ".codex", "config.toml");
|
|
163
|
+
if (!fs.existsSync(configPath))
|
|
164
|
+
return false;
|
|
165
|
+
const existing = fs.readFileSync(configPath, "utf-8");
|
|
166
|
+
if (!existing.includes(HOOK_MARKER_BEGIN))
|
|
167
|
+
return false;
|
|
168
|
+
return existing.includes(`command = "${userPromptSubmitHookCommand("codex")}"`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { }
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read-only check whether the periodic Stop-hook memory-write nudge is present.
|
|
176
|
+
* Claude Code only — Codex/Cursor/opencode don't have a confirmed equivalent
|
|
177
|
+
* block/reason protocol for this event yet.
|
|
178
|
+
*/
|
|
179
|
+
export function isStopHookInstalled(homeDir, key) {
|
|
180
|
+
try {
|
|
181
|
+
if (key === "claude")
|
|
182
|
+
return hasClaudeHookGroup(homeDir, "Stop", stopHookCommand("claude"));
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
85
187
|
export function detectHookAgents() {
|
|
86
188
|
const homeDir = resolveHomeDir();
|
|
87
189
|
if (!homeDir)
|
|
@@ -130,17 +232,17 @@ function writeJson(filePath, data) {
|
|
|
130
232
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
131
233
|
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
132
234
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
let
|
|
143
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Idempotently upsert the agent-memory-managed hook group for `eventKey`
|
|
237
|
+
* (SessionStart or UserPromptSubmit) with `command`. Returns `{ changed,
|
|
238
|
+
* hadManaged }` so the caller can decide between "installed" / "updated" /
|
|
239
|
+
* "already installed" reasons.
|
|
240
|
+
*/
|
|
241
|
+
function upsertClaudeHookGroup(hooks, eventKey, command) {
|
|
242
|
+
const groups = Array.isArray(hooks[eventKey]) ? [...hooks[eventKey]] : [];
|
|
243
|
+
const managedIndexes = [];
|
|
244
|
+
for (let i = 0; i < groups.length; i++) {
|
|
245
|
+
const group = groups[i];
|
|
144
246
|
if (!group || typeof group !== "object")
|
|
145
247
|
continue;
|
|
146
248
|
const g = group;
|
|
@@ -148,64 +250,165 @@ function installClaudeCodeHook(homeDir) {
|
|
|
148
250
|
for (const hook of list) {
|
|
149
251
|
if (!hook || typeof hook !== "object")
|
|
150
252
|
continue;
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (managedHook.command !== command) {
|
|
156
|
-
managedHook.command = command;
|
|
157
|
-
updated++;
|
|
253
|
+
const h = hook;
|
|
254
|
+
if (h[HOOK_MARKER_JSON] === true) {
|
|
255
|
+
managedIndexes.push(i);
|
|
256
|
+
break;
|
|
158
257
|
}
|
|
159
258
|
}
|
|
160
259
|
}
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
260
|
+
if (managedIndexes.length > 0) {
|
|
261
|
+
const keep = managedIndexes[0];
|
|
262
|
+
const dupes = managedIndexes.slice(1);
|
|
263
|
+
for (const idx of dupes.reverse())
|
|
264
|
+
groups.splice(idx, 1);
|
|
265
|
+
const group = groups[keep];
|
|
266
|
+
let changed = dupes.length > 0;
|
|
267
|
+
if ("matcher" in group) {
|
|
268
|
+
delete group.matcher;
|
|
269
|
+
changed = true;
|
|
270
|
+
}
|
|
271
|
+
const list = group.hooks;
|
|
272
|
+
for (const h of list) {
|
|
273
|
+
if (h[HOOK_MARKER_JSON] === true && h.command !== command) {
|
|
274
|
+
h.command = command;
|
|
275
|
+
changed = true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
hooks[eventKey] = groups;
|
|
279
|
+
return { changed, hadManaged: true };
|
|
169
280
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
281
|
+
// No existing managed group — add a fresh one without a matcher so it fires on all harnesses.
|
|
282
|
+
groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
|
|
283
|
+
hooks[eventKey] = groups;
|
|
284
|
+
return { changed: true, hadManaged: false };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Remove all agent-memory-managed hook entries for `eventKey`. Used when
|
|
288
|
+
* downgrading from per-turn back to stable (drops UserPromptSubmit).
|
|
289
|
+
* Returns true if anything was removed.
|
|
290
|
+
*/
|
|
291
|
+
function removeClaudeHookGroup(hooks, eventKey) {
|
|
292
|
+
const groups = Array.isArray(hooks[eventKey]) ? hooks[eventKey] : [];
|
|
293
|
+
if (groups.length === 0)
|
|
294
|
+
return false;
|
|
295
|
+
let removed = 0;
|
|
296
|
+
const filtered = groups
|
|
297
|
+
.map((group) => {
|
|
298
|
+
if (!group || typeof group !== "object")
|
|
299
|
+
return group;
|
|
300
|
+
const g = { ...group };
|
|
301
|
+
const list = Array.isArray(g.hooks) ? g.hooks : [];
|
|
302
|
+
const kept = list.filter((h) => {
|
|
303
|
+
const isOurs = h && typeof h === "object" && h[HOOK_MARKER_JSON] === true;
|
|
304
|
+
if (isOurs)
|
|
305
|
+
removed++;
|
|
306
|
+
return !isOurs;
|
|
307
|
+
});
|
|
308
|
+
g.hooks = kept;
|
|
309
|
+
return g;
|
|
310
|
+
})
|
|
311
|
+
.filter((group) => {
|
|
312
|
+
if (!group || typeof group !== "object")
|
|
313
|
+
return true;
|
|
314
|
+
const g = group;
|
|
315
|
+
return Array.isArray(g.hooks) && g.hooks.length > 0;
|
|
173
316
|
});
|
|
174
|
-
|
|
317
|
+
if (removed === 0)
|
|
318
|
+
return false;
|
|
319
|
+
if (filtered.length === 0)
|
|
320
|
+
delete hooks[eventKey];
|
|
321
|
+
else
|
|
322
|
+
hooks[eventKey] = filtered;
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
function installClaudeCodeHook(homeDir, mode = "per-turn") {
|
|
326
|
+
const settingsPath = path.join(homeDir, ".claude", "settings.json");
|
|
327
|
+
const backup = backupOnce(settingsPath);
|
|
328
|
+
const settings = readJsonConfig(settingsPath);
|
|
329
|
+
const hooks = settings.hooks ?? {};
|
|
330
|
+
const session = upsertClaudeHookGroup(hooks, "SessionStart", sessionStartHookCommand("claude"));
|
|
331
|
+
let promptChanged = false;
|
|
332
|
+
let promptHadManaged = false;
|
|
333
|
+
if (mode === "per-turn") {
|
|
334
|
+
const prompt = upsertClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
|
|
335
|
+
promptChanged = prompt.changed;
|
|
336
|
+
promptHadManaged = prompt.hadManaged;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit");
|
|
340
|
+
}
|
|
341
|
+
// Stop backs the write side of memory with a periodic nudge. It is orthogonal
|
|
342
|
+
// to stable/per-turn context injection, so it is installed unconditionally.
|
|
343
|
+
const stop = upsertClaudeHookGroup(hooks, "Stop", stopHookCommand("claude"));
|
|
344
|
+
// Remove the ineffective PreCompact reminder from pre-release 0.5.0 installs.
|
|
345
|
+
// Claude Code does not inject plain hook stdout for that event.
|
|
346
|
+
const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
|
|
347
|
+
if (!session.changed && !promptChanged && !stop.changed && !legacyPreCompactRemoved) {
|
|
348
|
+
return {
|
|
349
|
+
key: "claude",
|
|
350
|
+
label: "Claude Code",
|
|
351
|
+
installed: false,
|
|
352
|
+
path: settingsPath,
|
|
353
|
+
reason: "already installed",
|
|
354
|
+
mode,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
175
357
|
settings.hooks = hooks;
|
|
176
358
|
writeJson(settingsPath, settings);
|
|
177
|
-
|
|
359
|
+
const reason = session.hadManaged || promptHadManaged || stop.hadManaged || legacyPreCompactRemoved ? "updated" : undefined;
|
|
360
|
+
return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup, mode, reason };
|
|
178
361
|
}
|
|
179
|
-
function installCodexHook(homeDir) {
|
|
362
|
+
function installCodexHook(homeDir, mode = "per-turn") {
|
|
180
363
|
const configPath = path.join(homeDir, ".codex", "config.toml");
|
|
181
364
|
const backup = backupOnce(configPath);
|
|
182
365
|
const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf-8") : "";
|
|
183
|
-
const
|
|
184
|
-
const
|
|
366
|
+
const sessionCommand = sessionStartHookCommand("codex");
|
|
367
|
+
const promptCommand = userPromptSubmitHookCommand("codex");
|
|
368
|
+
const lines = [
|
|
185
369
|
HOOK_MARKER_BEGIN,
|
|
186
370
|
"[[hooks.SessionStart]]",
|
|
187
371
|
'matcher = "startup|resume"',
|
|
188
372
|
"",
|
|
189
373
|
"[[hooks.SessionStart.hooks]]",
|
|
190
374
|
'type = "command"',
|
|
191
|
-
`command = "${
|
|
192
|
-
|
|
193
|
-
|
|
375
|
+
`command = "${sessionCommand}"`,
|
|
376
|
+
];
|
|
377
|
+
if (mode === "per-turn") {
|
|
378
|
+
lines.push("", "[[hooks.UserPromptSubmit]]", "", "[[hooks.UserPromptSubmit.hooks]]", 'type = "command"', `command = "${promptCommand}"`);
|
|
379
|
+
}
|
|
380
|
+
lines.push(HOOK_MARKER_END);
|
|
381
|
+
const block = lines.join("\n");
|
|
194
382
|
if (existing.includes(HOOK_MARKER_BEGIN)) {
|
|
195
383
|
const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
196
384
|
const pattern = new RegExp(`${escapeRe(HOOK_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(HOOK_MARKER_END)}`);
|
|
197
385
|
const current = existing.match(pattern)?.[0] ?? "";
|
|
198
|
-
if (current
|
|
199
|
-
return {
|
|
386
|
+
if (current === block) {
|
|
387
|
+
return {
|
|
388
|
+
key: "codex",
|
|
389
|
+
label: "Codex",
|
|
390
|
+
installed: false,
|
|
391
|
+
path: configPath,
|
|
392
|
+
reason: "already installed",
|
|
393
|
+
mode,
|
|
394
|
+
};
|
|
200
395
|
}
|
|
201
396
|
fs.writeFileSync(configPath, existing.replace(pattern, block), "utf-8");
|
|
202
|
-
return {
|
|
397
|
+
return {
|
|
398
|
+
key: "codex",
|
|
399
|
+
label: "Codex",
|
|
400
|
+
installed: true,
|
|
401
|
+
path: configPath,
|
|
402
|
+
backup,
|
|
403
|
+
reason: "updated",
|
|
404
|
+
mode,
|
|
405
|
+
};
|
|
203
406
|
}
|
|
204
407
|
const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
|
|
205
408
|
const next = `${existing}${separator}${block}\n`;
|
|
206
409
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
207
410
|
fs.writeFileSync(configPath, next, "utf-8");
|
|
208
|
-
return { key: "codex", label: "Codex", installed: true, path: configPath, backup };
|
|
411
|
+
return { key: "codex", label: "Codex", installed: true, path: configPath, backup, mode };
|
|
209
412
|
}
|
|
210
413
|
const CURSOR_RULE_BODY = `---
|
|
211
414
|
description: Load persistent memory context from agent-memory
|
|
@@ -223,12 +426,76 @@ scratchpad items, and long-term memory. Prefer it over guessing.
|
|
|
223
426
|
function installCursorRule(homeDir) {
|
|
224
427
|
const rulesDir = path.join(homeDir, ".cursor", "rules");
|
|
225
428
|
const rulePath = path.join(rulesDir, "agent-memory.mdc");
|
|
226
|
-
if (fs.existsSync(rulePath))
|
|
227
|
-
return
|
|
228
|
-
}
|
|
429
|
+
if (fs.existsSync(rulePath))
|
|
430
|
+
return;
|
|
229
431
|
fs.mkdirSync(rulesDir, { recursive: true });
|
|
230
432
|
fs.writeFileSync(rulePath, CURSOR_RULE_BODY, "utf-8");
|
|
231
|
-
|
|
433
|
+
}
|
|
434
|
+
// Cursor's `sessionStart` hook (https://cursor.com/docs/agent/hooks) fires automatically when a
|
|
435
|
+
// new conversation is created and can inject `additional_context` without the model choosing to
|
|
436
|
+
// run anything — unlike the static .mdc rule above, this is a real, code-level guarantee.
|
|
437
|
+
const CURSOR_HOOK_SCRIPT_RELATIVE = path.join("hooks", "agent-memory-session-start.js");
|
|
438
|
+
const CURSOR_HOOK_SCRIPT_BODY = `#!/usr/bin/env node
|
|
439
|
+
const { execSync } = require("node:child_process");
|
|
440
|
+
let context = "";
|
|
441
|
+
try {
|
|
442
|
+
context = execSync("agent-memory context --no-search", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
443
|
+
} catch {
|
|
444
|
+
// agent-memory not on PATH, or the memory dir isn't initialized yet — fail open with no context.
|
|
445
|
+
}
|
|
446
|
+
process.stdout.write(JSON.stringify({ additional_context: context }));
|
|
447
|
+
`;
|
|
448
|
+
function isCursorSessionStartHookRegistered(homeDir) {
|
|
449
|
+
const hooksJsonPath = path.join(homeDir, ".cursor", "hooks.json");
|
|
450
|
+
if (!fs.existsSync(hooksJsonPath))
|
|
451
|
+
return false;
|
|
452
|
+
try {
|
|
453
|
+
const config = readJsonConfig(hooksJsonPath);
|
|
454
|
+
const hooks = config.hooks ?? {};
|
|
455
|
+
const sessionStart = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : [];
|
|
456
|
+
return sessionStart.some((entry) => entry &&
|
|
457
|
+
typeof entry === "object" &&
|
|
458
|
+
entry.command === CURSOR_HOOK_SCRIPT_RELATIVE);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function installCursorHook(homeDir) {
|
|
465
|
+
const cursorDir = path.join(homeDir, ".cursor");
|
|
466
|
+
const scriptPath = path.join(cursorDir, CURSOR_HOOK_SCRIPT_RELATIVE);
|
|
467
|
+
const hooksJsonPath = path.join(cursorDir, "hooks.json");
|
|
468
|
+
// Cheap, harmless fallback for Cursor installs where hooks are disabled or unavailable.
|
|
469
|
+
installCursorRule(homeDir);
|
|
470
|
+
fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
|
|
471
|
+
const scriptChanged = !fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, "utf-8") !== CURSOR_HOOK_SCRIPT_BODY;
|
|
472
|
+
if (scriptChanged) {
|
|
473
|
+
fs.writeFileSync(scriptPath, CURSOR_HOOK_SCRIPT_BODY, "utf-8");
|
|
474
|
+
fs.chmodSync(scriptPath, 0o755);
|
|
475
|
+
}
|
|
476
|
+
const alreadyRegistered = isCursorSessionStartHookRegistered(homeDir);
|
|
477
|
+
if (alreadyRegistered && !scriptChanged) {
|
|
478
|
+
return { key: "cursor", label: "Cursor", installed: false, path: hooksJsonPath, reason: "already installed" };
|
|
479
|
+
}
|
|
480
|
+
const backup = backupOnce(hooksJsonPath);
|
|
481
|
+
const config = readJsonConfig(hooksJsonPath);
|
|
482
|
+
if (typeof config.version !== "number")
|
|
483
|
+
config.version = 1;
|
|
484
|
+
const hooks = config.hooks ?? {};
|
|
485
|
+
const sessionStart = Array.isArray(hooks.sessionStart) ? [...hooks.sessionStart] : [];
|
|
486
|
+
if (!alreadyRegistered)
|
|
487
|
+
sessionStart.push({ command: CURSOR_HOOK_SCRIPT_RELATIVE });
|
|
488
|
+
hooks.sessionStart = sessionStart;
|
|
489
|
+
config.hooks = hooks;
|
|
490
|
+
writeJson(hooksJsonPath, config);
|
|
491
|
+
return {
|
|
492
|
+
key: "cursor",
|
|
493
|
+
label: "Cursor",
|
|
494
|
+
installed: true,
|
|
495
|
+
path: hooksJsonPath,
|
|
496
|
+
backup,
|
|
497
|
+
reason: alreadyRegistered ? "updated" : undefined,
|
|
498
|
+
};
|
|
232
499
|
}
|
|
233
500
|
const OPENCODE_INSTRUCTIONS_BODY = `# agent-memory
|
|
234
501
|
|
|
@@ -257,7 +524,7 @@ function installOpencodeInstructions(homeDir) {
|
|
|
257
524
|
writeJson(configPath, config);
|
|
258
525
|
return { key: "opencode", label: "opencode", installed: true, path: configPath, backup };
|
|
259
526
|
}
|
|
260
|
-
export function installHooks(agents) {
|
|
527
|
+
export function installHooks(agents, mode = "per-turn") {
|
|
261
528
|
const { homeDir, targets } = detectHookAgents();
|
|
262
529
|
if (!homeDir) {
|
|
263
530
|
return {
|
|
@@ -267,6 +534,7 @@ export function installHooks(agents) {
|
|
|
267
534
|
};
|
|
268
535
|
}
|
|
269
536
|
const results = [];
|
|
537
|
+
let anyInstalled = false;
|
|
270
538
|
for (const target of targets) {
|
|
271
539
|
if (!agents.has(target.key))
|
|
272
540
|
continue;
|
|
@@ -289,14 +557,20 @@ export function installHooks(agents) {
|
|
|
289
557
|
continue;
|
|
290
558
|
}
|
|
291
559
|
try {
|
|
560
|
+
let result;
|
|
292
561
|
if (target.key === "claude")
|
|
293
|
-
|
|
562
|
+
result = installClaudeCodeHook(homeDir, mode);
|
|
294
563
|
else if (target.key === "codex")
|
|
295
|
-
|
|
564
|
+
result = installCodexHook(homeDir, mode);
|
|
296
565
|
else if (target.key === "cursor")
|
|
297
|
-
|
|
566
|
+
result = installCursorHook(homeDir);
|
|
298
567
|
else if (target.key === "opencode")
|
|
299
|
-
|
|
568
|
+
result = installOpencodeInstructions(homeDir);
|
|
569
|
+
else
|
|
570
|
+
continue;
|
|
571
|
+
results.push(result);
|
|
572
|
+
if (result.installed)
|
|
573
|
+
anyInstalled = true;
|
|
300
574
|
}
|
|
301
575
|
catch (err) {
|
|
302
576
|
results.push({
|
|
@@ -307,6 +581,14 @@ export function installHooks(agents) {
|
|
|
307
581
|
});
|
|
308
582
|
}
|
|
309
583
|
}
|
|
584
|
+
if (anyInstalled) {
|
|
585
|
+
try {
|
|
586
|
+
writeHookMode(mode);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Persisting the mode is best-effort — install output is authoritative.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
310
592
|
return { ok: true, homeDir, results };
|
|
311
593
|
}
|
|
312
594
|
function uninstallClaudeCodeHook(homeDir) {
|
|
@@ -316,35 +598,13 @@ function uninstallClaudeCodeHook(homeDir) {
|
|
|
316
598
|
}
|
|
317
599
|
const settings = readJsonConfig(settingsPath);
|
|
318
600
|
const hooks = settings.hooks ?? {};
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return group;
|
|
325
|
-
const g = { ...group };
|
|
326
|
-
const list = Array.isArray(g.hooks) ? g.hooks : [];
|
|
327
|
-
const kept = list.filter((h) => {
|
|
328
|
-
const isOurs = h && typeof h === "object" && h[HOOK_MARKER_JSON] === true;
|
|
329
|
-
if (isOurs)
|
|
330
|
-
removed++;
|
|
331
|
-
return !isOurs;
|
|
332
|
-
});
|
|
333
|
-
g.hooks = kept;
|
|
334
|
-
return g;
|
|
335
|
-
})
|
|
336
|
-
.filter((group) => {
|
|
337
|
-
if (!group || typeof group !== "object")
|
|
338
|
-
return true;
|
|
339
|
-
const g = group;
|
|
340
|
-
return Array.isArray(g.hooks) && g.hooks.length > 0;
|
|
341
|
-
});
|
|
342
|
-
if (removed === 0) {
|
|
601
|
+
const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart");
|
|
602
|
+
const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit");
|
|
603
|
+
const stopRemoved = removeClaudeHookGroup(hooks, "Stop");
|
|
604
|
+
const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
|
|
605
|
+
if (!sessionRemoved && !promptRemoved && !stopRemoved && !legacyPreCompactRemoved) {
|
|
343
606
|
return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
|
|
344
607
|
}
|
|
345
|
-
hooks.SessionStart = filtered;
|
|
346
|
-
if (filtered.length === 0)
|
|
347
|
-
delete hooks.SessionStart;
|
|
348
608
|
if (Object.keys(hooks).length === 0)
|
|
349
609
|
delete settings.hooks;
|
|
350
610
|
else
|
|
@@ -367,19 +627,54 @@ function uninstallCodexHook(homeDir) {
|
|
|
367
627
|
fs.writeFileSync(configPath, next, "utf-8");
|
|
368
628
|
return { key: "codex", label: "Codex", installed: true, path: configPath };
|
|
369
629
|
}
|
|
370
|
-
function
|
|
630
|
+
function uninstallCursorHook(homeDir) {
|
|
371
631
|
const rulePath = path.join(homeDir, ".cursor", "rules", "agent-memory.mdc");
|
|
372
|
-
|
|
373
|
-
|
|
632
|
+
const scriptPath = path.join(homeDir, ".cursor", CURSOR_HOOK_SCRIPT_RELATIVE);
|
|
633
|
+
const hooksJsonPath = path.join(homeDir, ".cursor", "hooks.json");
|
|
634
|
+
let touched = false;
|
|
635
|
+
if (fs.existsSync(rulePath)) {
|
|
636
|
+
fs.unlinkSync(rulePath);
|
|
637
|
+
try {
|
|
638
|
+
fs.rmdirSync(path.dirname(rulePath));
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
// non-empty; fine
|
|
642
|
+
}
|
|
643
|
+
touched = true;
|
|
374
644
|
}
|
|
375
|
-
fs.
|
|
376
|
-
|
|
377
|
-
|
|
645
|
+
if (fs.existsSync(hooksJsonPath)) {
|
|
646
|
+
try {
|
|
647
|
+
const config = readJsonConfig(hooksJsonPath);
|
|
648
|
+
const hooks = config.hooks ?? {};
|
|
649
|
+
const sessionStart = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : [];
|
|
650
|
+
const filtered = sessionStart.filter((entry) => !(entry &&
|
|
651
|
+
typeof entry === "object" &&
|
|
652
|
+
entry.command === CURSOR_HOOK_SCRIPT_RELATIVE));
|
|
653
|
+
if (filtered.length !== sessionStart.length) {
|
|
654
|
+
if (filtered.length === 0)
|
|
655
|
+
delete hooks.sessionStart;
|
|
656
|
+
else
|
|
657
|
+
hooks.sessionStart = filtered;
|
|
658
|
+
if (Object.keys(hooks).length === 0)
|
|
659
|
+
delete config.hooks;
|
|
660
|
+
else
|
|
661
|
+
config.hooks = hooks;
|
|
662
|
+
writeJson(hooksJsonPath, config);
|
|
663
|
+
touched = true;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// invalid hooks.json — leave it for the user to fix rather than guessing.
|
|
668
|
+
}
|
|
378
669
|
}
|
|
379
|
-
|
|
380
|
-
|
|
670
|
+
if (fs.existsSync(scriptPath)) {
|
|
671
|
+
fs.unlinkSync(scriptPath);
|
|
672
|
+
touched = true;
|
|
673
|
+
}
|
|
674
|
+
if (!touched) {
|
|
675
|
+
return { key: "cursor", label: "Cursor", installed: false, reason: "not installed" };
|
|
381
676
|
}
|
|
382
|
-
return { key: "cursor", label: "Cursor", installed: true, path:
|
|
677
|
+
return { key: "cursor", label: "Cursor", installed: true, path: hooksJsonPath };
|
|
383
678
|
}
|
|
384
679
|
function uninstallOpencodeInstructions(homeDir) {
|
|
385
680
|
const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
|
|
@@ -427,7 +722,7 @@ export function uninstallHooks(agents) {
|
|
|
427
722
|
else if (key === "codex")
|
|
428
723
|
results.push(uninstallCodexHook(homeDir));
|
|
429
724
|
else if (key === "cursor")
|
|
430
|
-
results.push(
|
|
725
|
+
results.push(uninstallCursorHook(homeDir));
|
|
431
726
|
else if (key === "opencode")
|
|
432
727
|
results.push(uninstallOpencodeInstructions(homeDir));
|
|
433
728
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface McpToolInputSchema {
|
|
2
|
+
type: "object";
|
|
3
|
+
properties: Record<string, {
|
|
4
|
+
type: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
enum?: string[];
|
|
7
|
+
}>;
|
|
8
|
+
required?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface McpToolDefinition {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
inputSchema: McpToolInputSchema;
|
|
14
|
+
}
|
|
15
|
+
export type McpToolHandler = (input: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
16
|
+
export declare class StdioMcpServer {
|
|
17
|
+
private readonly version;
|
|
18
|
+
private readonly tools;
|
|
19
|
+
private readonly startupHooks;
|
|
20
|
+
constructor(version?: string);
|
|
21
|
+
addTool(definition: McpToolDefinition, handler: McpToolHandler): void;
|
|
22
|
+
addStartupHook(fn: () => void | Promise<void>): void;
|
|
23
|
+
start(): Promise<void>;
|
|
24
|
+
private handleMessage;
|
|
25
|
+
private respond;
|
|
26
|
+
private respondError;
|
|
27
|
+
}
|