@pharaoh-so/mcp 0.3.59 → 0.3.61

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.
@@ -0,0 +1,268 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { gitBefore, sessionKey, updateWorktreeCueState } from "./local.mjs";
5
+
6
+ const MAP_TOOL = "mcp__pharaoh__get_codebase_map";
7
+ const SHA = /^(?!0{40}$)[a-f0-9]{40}$/i;
8
+ const DIGEST = /^[a-f0-9]{64}$/;
9
+ const MAPPED_FILE = /\.(?:[cm]?[jt]s|[jt]sx|py)$/i;
10
+ const STATE_TTL = 12 * 60 * 60 * 1000;
11
+ const MAX_SCOPES = 8;
12
+ const MAX_SEARCHES = 2;
13
+ const MAX_WARNED = 8;
14
+ const MAX_EDIT_PATHS = 20;
15
+ const MAX_SOURCE_BYTES = 512 * 1024;
16
+
17
+ function digest(value) {
18
+ return createHash("sha256").update(value).digest("hex");
19
+ }
20
+
21
+ function responseText(value) {
22
+ if (typeof value === "string") {
23
+ if (/^\s*[[{]/.test(value)) {
24
+ try {
25
+ return responseText(JSON.parse(value));
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+ return [value];
31
+ }
32
+ if (value?.isError || value?.is_error) return [];
33
+ const content = Array.isArray(value) ? value : value?.content;
34
+ return Array.isArray(content)
35
+ ? content.flatMap((block) =>
36
+ block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
37
+ )
38
+ : [];
39
+ }
40
+
41
+ function mappedRevision(input, target, repos) {
42
+ const requested = input.tool_input?.repo;
43
+ if (typeof requested !== "string") return;
44
+ const full = target.repo.toLowerCase();
45
+ const short = full.split("/")[1];
46
+ if (requested.toLowerCase() !== full) {
47
+ if (requested.toLowerCase() !== short) return;
48
+ const matches = new Set(
49
+ repos
50
+ .filter((repo) => typeof repo === "string")
51
+ .map((repo) => repo.toLowerCase())
52
+ .filter((repo) => repo.split("/").at(-1) === short),
53
+ );
54
+ if (matches.size !== 1 || !matches.has(full)) return;
55
+ }
56
+ const maps = responseText(input.tool_response).filter((text) =>
57
+ text
58
+ .split(/\r?\n/)
59
+ .some((line) =>
60
+ [`# ${short} codebase map`, `# ${full} codebase map`].includes(line.toLowerCase()),
61
+ ),
62
+ );
63
+ if (maps.length !== 1) return;
64
+ const revisions = [...maps[0].matchAll(/^Indexed commit: ([^\r\n]+)\r?$/gm)];
65
+ const revision = revisions.length === 1 && revisions[0][1];
66
+ if (revision && SHA.test(revision)) return revision.toLowerCase();
67
+ }
68
+
69
+ function editedPaths(input, client, root) {
70
+ let paths;
71
+ if (client === "codex") {
72
+ if (input.tool_name !== "apply_patch") return [];
73
+ const patch = input.tool_input?.command;
74
+ const succeeded = responseText(input.tool_response).some((text) =>
75
+ /^Success\. Updated the following files:/m.test(text),
76
+ );
77
+ if (!succeeded || typeof patch !== "string" || !patch.startsWith("*** Begin Patch\n"))
78
+ return [];
79
+ paths = [
80
+ ...patch.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm),
81
+ ].map((match) => match[1]);
82
+ } else {
83
+ if (!["Edit", "Write", "MultiEdit"].includes(input.tool_name)) return [];
84
+ paths = [input.tool_input?.file_path];
85
+ }
86
+ if (paths.length > MAX_EDIT_PATHS) return [];
87
+ const inCheckout = paths.flatMap((path) => {
88
+ if (typeof path !== "string" || path.length > 512 || !MAPPED_FILE.test(path)) return [];
89
+ const absolute = resolve(input.cwd ?? root, path);
90
+ let parent;
91
+ try {
92
+ parent = realpathSync(dirname(absolute));
93
+ } catch {
94
+ return [];
95
+ }
96
+ const local = relative(root, join(parent, basename(absolute)));
97
+ if (!local || local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) return [];
98
+ return [local];
99
+ });
100
+ return [...new Set(inCheckout)];
101
+ }
102
+
103
+ function worktreeBlob(path, remaining) {
104
+ let fd;
105
+ try {
106
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
107
+ } catch (error) {
108
+ return error.code === "ENOENT" ? null : undefined;
109
+ }
110
+ try {
111
+ const before = fstatSync(fd);
112
+ if (!before.isFile() || before.size > remaining) return;
113
+ const bytes = Buffer.alloc(before.size);
114
+ if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) return;
115
+ const after = fstatSync(fd);
116
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) return;
117
+ return {
118
+ size: bytes.length,
119
+ oid: createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"),
120
+ };
121
+ } finally {
122
+ closeSync(fd);
123
+ }
124
+ }
125
+
126
+ function changedPaths(root, revision, paths, deadline, git) {
127
+ const comparisonDeadline = Math.min(deadline, performance.now() + 700);
128
+ const run = (...args) =>
129
+ git(
130
+ root,
131
+ Math.min(comparisonDeadline, performance.now() + 300),
132
+ "--literal-pathspecs",
133
+ ...args,
134
+ );
135
+ try {
136
+ // Git reads tree and index metadata only. Hash raw regular-file bytes
137
+ // locally so checkout attributes cannot invoke an executable filter.
138
+ const tree = run("ls-tree", "-rz", revision, "--", ...paths);
139
+ const indexed = new Map(
140
+ tree
141
+ .split("\0")
142
+ .filter(Boolean)
143
+ .map((entry) => {
144
+ const tab = entry.indexOf("\t");
145
+ const [mode, type, oid] = entry.slice(0, tab).split(" ");
146
+ return [
147
+ entry.slice(tab + 1),
148
+ /^100(?:644|755)$/.test(mode) && type === "blob" && SHA.test(oid) ? oid : null,
149
+ ];
150
+ }),
151
+ );
152
+ const added = paths.filter((path) => !indexed.has(path));
153
+ const visible = added.length
154
+ ? new Set(
155
+ run("ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...added).split(
156
+ "\0",
157
+ ),
158
+ )
159
+ : new Set();
160
+ let remaining = MAX_SOURCE_BYTES;
161
+ const changed = [];
162
+ for (const path of paths) {
163
+ if (performance.now() >= comparisonDeadline) return [];
164
+ const previous = indexed.get(path);
165
+ if (!previous && !visible.has(path)) continue;
166
+ const local = worktreeBlob(join(root, path), remaining);
167
+ if (local === undefined) continue;
168
+ remaining -= local?.size ?? 0;
169
+ if ((local?.oid ?? undefined) !== previous) changed.push(digest(path));
170
+ }
171
+ if (performance.now() >= comparisonDeadline) return [];
172
+ return changed;
173
+ } catch {
174
+ // A missing revision or failed comparison leaves the cue available for a later edit.
175
+ return [];
176
+ }
177
+ }
178
+
179
+ function validEntry(entry, now) {
180
+ return (
181
+ DIGEST.test(entry?.key ?? "") &&
182
+ Number.isFinite(entry.expires) &&
183
+ entry.expires > now &&
184
+ entry.expires <= now + STATE_TTL &&
185
+ (entry.revision === undefined || SHA.test(entry.revision)) &&
186
+ Array.isArray(entry.searches) &&
187
+ entry.searches.length <= MAX_SEARCHES &&
188
+ entry.searches.every((item) => typeof item === "string" && DIGEST.test(item)) &&
189
+ Array.isArray(entry.warned) &&
190
+ entry.warned.length <= MAX_WARNED &&
191
+ entry.warned.every((item) => typeof item === "string" && DIGEST.test(item))
192
+ );
193
+ }
194
+
195
+ /** Process local hook evidence without dispatching tools or retaining their payloads. */
196
+ export function worktreeCue(
197
+ input,
198
+ {
199
+ client,
200
+ target,
201
+ binding,
202
+ repos = [],
203
+ stateDirectory,
204
+ structuralSearch,
205
+ deadline = Infinity,
206
+ git = gitBefore,
207
+ now = Date.now(),
208
+ },
209
+ ) {
210
+ const session = sessionKey(client, input.session_id);
211
+ if (!session || !binding || performance.now() >= deadline) return;
212
+ const event = input.hook_event_name;
213
+ const search = event === "PreToolUse" && structuralSearch;
214
+ const revision =
215
+ event === "PostToolUse" && input.tool_name === MAP_TOOL && mappedRevision(input, target, repos);
216
+ const paths = event === "PostToolUse" ? editedPaths(input, client, target.root) : [];
217
+ if (!search && !revision && !paths.length) return;
218
+ const key = digest(`${binding}\0${session}\0${target.root}\0${target.repo.toLowerCase()}`);
219
+
220
+ const context = updateWorktreeCueState(
221
+ stateDirectory,
222
+ (state) => {
223
+ if (state !== undefined && (state?.version !== 1 || !Array.isArray(state.entries))) return;
224
+ const entries = (state?.entries ?? [])
225
+ .filter((entry) => validEntry(entry, now))
226
+ .slice(0, MAX_SCOPES);
227
+ const existing = entries.find((entry) => entry.key === key);
228
+ const current = existing ? { ...existing } : { key, searches: [], warned: [] };
229
+ let notice;
230
+ if (revision) {
231
+ if (current.revision === revision) return;
232
+ current.revision = revision;
233
+ current.warned = [];
234
+ } else if (search) {
235
+ if (current.revision || current.searches.length >= MAX_SEARCHES) return;
236
+ const head = git(
237
+ target.root,
238
+ Math.min(deadline, performance.now() + 300),
239
+ "rev-parse",
240
+ "HEAD",
241
+ );
242
+ if (!SHA.test(head)) return;
243
+ const requested = digest(`${head}\0${search.tool}\0${search.symbol}`);
244
+ if (current.searches.includes(requested)) return;
245
+ current.searches = [...current.searches, requested];
246
+ notice = `For structural evidence on ${search.symbol}, try Pharaoh's ${search.tool}. The graph covers the indexed default branch. Use the current worktree for local changes.`;
247
+ } else {
248
+ if (!current.revision || current.warned.length >= MAX_WARNED) return;
249
+ const changed = changedPaths(target.root, current.revision, paths, deadline, git);
250
+ const unseen = changed.filter((path) => !current.warned.includes(path));
251
+ if (!unseen.length) return;
252
+ current.warned = [...current.warned, ...unseen].slice(0, MAX_WARNED);
253
+ notice = `The Pharaoh evidence consulted earlier reflects the indexed default branch at ${current.revision.slice(0, 12)}. This edited scope differs from that revision. Check the local diff or source before relying on that evidence.`;
254
+ }
255
+ if (performance.now() >= deadline) return;
256
+ current.expires = now + STATE_TTL;
257
+ return {
258
+ state: {
259
+ version: 1,
260
+ entries: [current, ...entries.filter((entry) => entry.key !== key)].slice(0, MAX_SCOPES),
261
+ },
262
+ context: notice,
263
+ };
264
+ },
265
+ now,
266
+ );
267
+ if (context) return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
268
+ }
@@ -7,6 +7,7 @@ const EVENTS = {
7
7
  };
8
8
  const GLOBAL_MARKER = "# pharaoh-project-memory:user-v1";
9
9
  const LEGACY_MARKER = / # pharaoh-project-memory:[a-f0-9]{64}$/;
10
+ const TOOL_EVENTS = ["PreToolUse", "PostToolUse"];
10
11
 
11
12
  function isObject(value) {
12
13
  return value && typeof value === "object" && !Array.isArray(value);
@@ -33,14 +34,14 @@ function matchesUserHook(value) {
33
34
  }
34
35
 
35
36
  /** Keep unrelated handlers even when they share an event group with ours. */
36
- function replaceHooks(config, client, matches, command, installEvents = EVENTS[client]) {
37
+ function replaceHooks(config, events, matches, additions) {
37
38
  if (!isObject(config) || (config.hooks !== undefined && !isObject(config.hooks))) {
38
39
  throw new Error("The existing hooks configuration is not a JSON object.");
39
40
  }
40
41
  const hooks = { ...config.hooks };
41
- for (const event of EVENTS[client]) {
42
+ for (const event of events) {
42
43
  const current = hooks[event];
43
- const install = command && installEvents.includes(event);
44
+ const install = additions[event];
44
45
  if (!install && (current === undefined || (Array.isArray(current) && !current.length)))
45
46
  continue;
46
47
  if (
@@ -54,7 +55,7 @@ function replaceHooks(config, client, matches, command, installEvents = EVENTS[c
54
55
  if (rest.length === group.hooks.length) return [group];
55
56
  return rest.length ? [{ ...group, hooks: rest }] : [];
56
57
  });
57
- if (install) kept.push({ hooks: [{ type: "command", command, timeout: 3 }] });
58
+ if (install) kept.push(install);
58
59
  if (kept.length) hooks[event] = kept;
59
60
  else delete hooks[event];
60
61
  }
@@ -63,24 +64,50 @@ function replaceHooks(config, client, matches, command, installEvents = EVENTS[c
63
64
  : { ...config, hooks };
64
65
  }
65
66
 
67
+ function group(command, matcher) {
68
+ return { ...(matcher && { matcher }), hooks: [{ type: "command", command, timeout: 3 }] };
69
+ }
70
+
66
71
  export function updateHooks(config, client, target, remove, hookPath, node = process.execPath) {
67
72
  const marker = legacyMarker(client, target.root);
68
73
  if (!remove && !hookPath) throw new Error("A pinned project memory hook is required.");
69
74
  const command = remove
70
75
  ? null
71
76
  : `${[node, hookPath, client, target.root, target.repo].map(shellWord).join(" ")} ${marker}`;
72
- return replaceHooks(config, client, matchesLegacyHook(client, target.root), command);
77
+ const events = EVENTS[client];
78
+ const additions = command
79
+ ? Object.fromEntries(events.map((event) => [event, group(command)]))
80
+ : {};
81
+ return replaceHooks(config, events, matchesLegacyHook(client, target.root), additions);
73
82
  }
74
83
 
75
- export function updateUserHooks(config, client, hookPath, node = process.execPath) {
76
- const command = hookPath
77
- ? `${[node, hookPath, client, "--user"].map(shellWord).join(" ")} ${GLOBAL_MARKER}`
78
- : null;
79
- return replaceHooks(config, client, matchesUserHook, command, ["UserPromptSubmit"]);
84
+ export function updateUserHooks(
85
+ config,
86
+ client,
87
+ hookPath,
88
+ node = process.execPath,
89
+ graphCues = false,
90
+ ) {
91
+ const additions = {};
92
+ if (hookPath) {
93
+ const command = (mode) =>
94
+ `${[node, hookPath, client, mode].map(shellWord).join(" ")} ${GLOBAL_MARKER}`;
95
+ additions.UserPromptSubmit = group(command("--user"));
96
+ if (graphCues) {
97
+ const graph = command("--graph");
98
+ const edit = client === "codex" ? "apply_patch" : "Edit|Write|MultiEdit";
99
+ additions.PreToolUse = group(graph, "^Bash$");
100
+ additions.PostToolUse = group(graph, `^(?:${edit}|mcp__pharaoh__get_codebase_map)$`);
101
+ }
102
+ }
103
+ const toolEvents = TOOL_EVENTS.filter(
104
+ (event) => additions[event] || hasHooks(config, [event], matchesUserHook),
105
+ );
106
+ return replaceHooks(config, [...EVENTS[client], ...toolEvents], matchesUserHook, additions);
80
107
  }
81
108
 
82
- function hasHooks(config, client, matches) {
83
- return EVENTS[client].some((event) => {
109
+ function hasHooks(config, events, matches) {
110
+ return events.some((event) => {
84
111
  const groups = config?.hooks?.[event];
85
112
  return (
86
113
  Array.isArray(groups) &&
@@ -93,9 +120,9 @@ function hasHooks(config, client, matches) {
93
120
  }
94
121
 
95
122
  export function hasLegacyHooks(config, client, root) {
96
- return hasHooks(config, client, matchesLegacyHook(client, root));
123
+ return hasHooks(config, EVENTS[client], matchesLegacyHook(client, root));
97
124
  }
98
125
 
99
126
  export function hasUserHooks(config, client) {
100
- return hasHooks(config, client, matchesUserHook);
127
+ return hasHooks(config, [...EVENTS[client], ...TOOL_EVENTS], matchesUserHook);
101
128
  }
@@ -52,7 +52,7 @@ export function memoryReminder(repo) {
52
52
  return {
53
53
  hookSpecificOutput: {
54
54
  hookEventName: "UserPromptSubmit",
55
- additionalContext: `For work in ${repo}, use pharaoh_memory_recall if prior decisions may matter. Use pharaoh_memory_expand if you need the full record, and pharaoh_memory_capture for one supported candidate when the task permits. Mention memory only when it changes a decision.`,
55
+ additionalContext: `For work in ${repo}, call pharaoh_memory_recall before source inspection when prior decisions could matter. Use specific, non-sensitive terms. Use pharaoh_memory_expand only for needed evidence and pharaoh_memory_capture for one supported candidate when the user permits a write. Mention memory only if it changes a decision.`,
56
56
  },
57
57
  };
58
58
  }
@@ -252,6 +252,35 @@ function recoverLock(path, now) {
252
252
  return true;
253
253
  }
254
254
 
255
+ function acquireLocalLock(directory, path, now) {
256
+ return (
257
+ acquireLock(directory, path, now) ??
258
+ (recoverLock(path, now) ? acquireLock(directory, path, now) : undefined)
259
+ );
260
+ }
261
+
262
+ /** Update only the bounded worktree cue metadata. Concurrent events can skip. */
263
+ export function updateWorktreeCueState(directory, update, now = Date.now()) {
264
+ makePrivateDirectory(directory);
265
+ const release = acquireLocalLock(directory, join(directory, "worktree-cues.lock"), now);
266
+ if (!release) return;
267
+ try {
268
+ const path = join(directory, "worktree-cues.json");
269
+ const file = lstatSync(path, { throwIfNoEntry: false });
270
+ if (file && file.uid !== process.getuid?.()) return;
271
+ const before = file ? readJson(path) : undefined;
272
+ if (file && before === undefined) return;
273
+ const change = update(before);
274
+ if (!change) return;
275
+ const data = JSON.stringify(change.state);
276
+ if (Buffer.byteLength(data) > 8192) return;
277
+ atomicWrite(path, data);
278
+ return change.context;
279
+ } finally {
280
+ release();
281
+ }
282
+ }
283
+
255
284
  /** Serialize local state transitions only; network operations never hold this lock. */
256
285
  export function turnStore(home = homedir(), now = Date.now) {
257
286
  const directory = join(home, ".pharaoh", "project-memory-turns-v2");
@@ -276,9 +305,7 @@ export function turnStore(home = homedir(), now = Date.now) {
276
305
  if (!TOKEN.test(key)) return;
277
306
  const path = join(directory, `${key}.lock`);
278
307
  const time = now();
279
- const release =
280
- acquireLock(directory, path, time) ??
281
- (recoverLock(path, time) ? acquireLock(directory, path, time) : undefined);
308
+ const release = acquireLocalLock(directory, path, time);
282
309
  if (!release) return onBusy?.();
283
310
  try {
284
311
  const stored = readJson(overlapPath(key));
@@ -4,6 +4,8 @@ import { handleEvent, memoryReminder, skipMemoryPrompt } from "./hook-events.mjs
4
4
  import { readStdin } from "./local.mjs";
5
5
  import {
6
6
  activeMemoryRuntime,
7
+ memoryClientRoot,
8
+ projectMemorySession,
7
9
  readMemoryCredentials,
8
10
  resolveMemoryHookTarget,
9
11
  } from "./user-target.mjs";
@@ -16,19 +18,49 @@ try {
16
18
  const [client, root, repo] = process.argv.slice(2);
17
19
  const input = await readStdin(1024 * 1024);
18
20
  let result;
19
- if (root === "--user") {
21
+ if (root === "--user" || root === "--graph") {
22
+ const reminder = root === "--user";
23
+ const event = input?.hook_event_name;
24
+ if (
25
+ reminder
26
+ ? event !== "UserPromptSubmit" || skipMemoryPrompt(input.prompt)
27
+ : event !== "PreToolUse" && event !== "PostToolUse"
28
+ )
29
+ process.exit(0);
30
+ let structuralSearch;
31
+ if (event === "PreToolUse") {
32
+ if (input.tool_name !== "Bash") process.exit(0);
33
+ const { classifyBashGrep } = await import("./route-grep.mjs");
34
+ structuralSearch = classifyBashGrep(input.tool_input?.command);
35
+ if (!structuralSearch) process.exit(0);
36
+ }
20
37
  const runtime = dirname(fileURLToPath(import.meta.url));
21
38
  if (!activeMemoryRuntime(client, runtime)) process.exit(0);
22
- if (input?.hook_event_name !== "UserPromptSubmit" || skipMemoryPrompt(input.prompt))
23
- process.exit(0);
39
+ const { readHookPreferences } = await import("./preferences.mjs");
40
+ const choice = readHookPreferences()[client];
41
+ if (choice?.memory === false || (!reminder && choice?.graph === false)) process.exit(0);
42
+ const credentials = await readMemoryCredentials();
24
43
  const eligible = await resolveMemoryHookTarget(
25
44
  client,
26
45
  input?.cwd,
27
- await readMemoryCredentials(),
28
- true,
46
+ credentials,
47
+ reminder,
29
48
  deadline,
30
49
  );
31
- if (eligible && performance.now() < deadline) result = memoryReminder(eligible.repo);
50
+ if (!eligible || performance.now() >= deadline) process.exit(0);
51
+ if (reminder) result = memoryReminder(eligible.repo);
52
+ else {
53
+ const { worktreeCue } = await import("./graph-cues.mjs");
54
+ result = worktreeCue(input, {
55
+ client,
56
+ target: eligible,
57
+ binding: projectMemorySession(credentials),
58
+ repos: credentials?.repos,
59
+ stateDirectory: memoryClientRoot(),
60
+ structuralSearch,
61
+ deadline,
62
+ });
63
+ }
32
64
  } else {
33
65
  result = handleEvent(input, {
34
66
  client,
@@ -38,7 +70,7 @@ try {
38
70
  deadline,
39
71
  });
40
72
  }
41
- if (result) process.stdout.write(JSON.stringify(result));
73
+ if (result && performance.now() < deadline) process.stdout.write(JSON.stringify(result));
42
74
  } catch {
43
75
  // Project memory never blocks ordinary work or opens a continuation.
44
76
  } finally {
@@ -0,0 +1,12 @@
1
+ export type MemoryHookClient = "claude-code" | "codex";
2
+ export type HookPreferences = Partial<
3
+ Record<MemoryHookClient, { memory?: boolean; graph?: boolean }>
4
+ >;
5
+ export const MEMORY_HOOK_CLIENTS: readonly MemoryHookClient[];
6
+ export function readHookPreferences(home?: string): HookPreferences;
7
+ export function writeHookPreferences(
8
+ clients: HookPreferences,
9
+ home?: string,
10
+ expected?: HookPreferences,
11
+ ): void;
12
+ export function resetHookPreferences(disable: boolean, home?: string): void;
@@ -0,0 +1,70 @@
1
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { privateMemoryPath } from "./bundle.mjs";
5
+ import { atomicWrite } from "./local.mjs";
6
+ import { memoryClientRoot } from "./user-target.mjs";
7
+
8
+ export const MEMORY_HOOK_CLIENTS = ["claude-code", "codex"];
9
+ const FILE = "hook-preferences-v1.json";
10
+
11
+ export function readHookPreferences(home = homedir()) {
12
+ const root = memoryClientRoot(home);
13
+ const path = join(root, FILE);
14
+ try {
15
+ privateMemoryPath(root, true);
16
+ if (privateMemoryPath(path, false).size > 1024)
17
+ throw new Error("Project memory preferences are too large.");
18
+ const { version, clients } = JSON.parse(readFileSync(path, "utf8"));
19
+ if (version !== 1 || !clients || typeof clients !== "object" || Array.isArray(clients))
20
+ throw new Error("Project memory preferences have an unknown format.");
21
+ for (const client of MEMORY_HOOK_CLIENTS) {
22
+ if (!Object.hasOwn(clients, client)) continue;
23
+ const choice = clients[client];
24
+ if (
25
+ !choice ||
26
+ typeof choice !== "object" ||
27
+ Array.isArray(choice) ||
28
+ ["memory", "graph"].some(
29
+ (key) => Object.hasOwn(choice, key) && typeof choice[key] !== "boolean",
30
+ )
31
+ )
32
+ throw new Error("Project memory preferences contain an invalid choice.");
33
+ }
34
+ return clients;
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") return {};
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ export function writeHookPreferences(clients, home = homedir(), expected) {
42
+ const previous = readHookPreferences(home);
43
+ if (expected && JSON.stringify(expected) !== JSON.stringify(previous))
44
+ throw new Error("Project memory preferences changed while setup was asking.");
45
+ if (JSON.stringify(clients) === JSON.stringify(previous)) return;
46
+ const root = memoryClientRoot(home);
47
+ const path = join(root, FILE);
48
+ if (Object.keys(clients).length === 0) {
49
+ rmSync(path, { force: true });
50
+ return;
51
+ }
52
+ mkdirSync(root, { recursive: true, mode: 0o700 });
53
+ privateMemoryPath(root, true);
54
+ atomicWrite(path, `${JSON.stringify({ version: 1, clients })}\n`);
55
+ }
56
+
57
+ export function resetHookPreferences(disable, home = homedir()) {
58
+ if (typeof process.getuid !== "function") return;
59
+ const choices = readHookPreferences(home);
60
+ for (const client of MEMORY_HOOK_CLIENTS) {
61
+ const choice = choices[client];
62
+ if (disable) choices[client] = { memory: false, graph: false };
63
+ else if (choice) {
64
+ if (choice.memory === true) delete choice.memory;
65
+ if (choice.graph === true) delete choice.graph;
66
+ if (Object.keys(choice).length === 0) delete choices[client];
67
+ }
68
+ }
69
+ writeHookPreferences(choices, home);
70
+ }
@@ -1,14 +1,40 @@
1
- export function syncMemoryHooks(options?: {
2
- credentials?: {
3
- sse_url: string;
4
- project_memory?: Array<{ repo: string; revision: string | null }>;
5
- project_memory_status?: "unavailable";
6
- };
7
- clients?: string[];
8
- enable?: boolean;
1
+ type MemoryCredentials = {
2
+ sse_url: string;
3
+ project_memory?: Array<{ repo: string; revision: string | null }>;
4
+ project_memory_status?: "unavailable";
5
+ };
6
+
7
+ type MemoryHookChoices = {
8
+ accepted: string[];
9
+ declined: string[];
10
+ graphClients: string[];
11
+ notices: string[];
12
+ removeUnentitled?: boolean;
13
+ };
14
+
15
+ type MemoryHookSetup = {
16
+ credentials?: MemoryCredentials;
9
17
  home?: string;
10
18
  env?: NodeJS.ProcessEnv;
11
19
  cwd?: string;
12
20
  node?: string;
13
21
  packageRoot?: URL;
14
- }): string[];
22
+ };
23
+
24
+ export function chooseMemoryHooks(options: {
25
+ credentials?: MemoryCredentials;
26
+ clients: string[];
27
+ requested?: boolean;
28
+ ask: (question: string, defaultAnswer: boolean) => Promise<boolean | null>;
29
+ home?: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ }): Promise<MemoryHookChoices>;
32
+
33
+ export function applyMemoryHookChoices(
34
+ decision: MemoryHookChoices,
35
+ options?: MemoryHookSetup,
36
+ ): string[];
37
+
38
+ export function syncMemoryHooks(
39
+ options?: MemoryHookSetup & { clients?: string[]; graphClients?: string[]; enable?: boolean },
40
+ ): string[];