@pharaoh-so/mcp 0.3.60 → 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.
- package/.mcp.json +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/helpers.d.ts +3 -2
- package/dist/helpers.js +7 -4
- package/dist/index.js +40 -6
- package/hooks/route-grep.mjs +63 -0
- package/package.json +1 -1
- package/project-memory/bundle.mjs +9 -2
- package/project-memory/graph-cues.mjs +268 -0
- package/project-memory/hook-config.mjs +41 -14
- package/project-memory/hook-events.mjs +1 -1
- package/project-memory/local.mjs +30 -3
- package/project-memory/pharaoh-memory-hook.mjs +39 -7
- package/project-memory/preferences.d.mts +12 -0
- package/project-memory/preferences.mjs +70 -0
- package/project-memory/setup.d.mts +35 -9
- package/project-memory/setup.mjs +134 -10
package/.mcp.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
26
26
|
- The `pharaoh` skill's connection URL is `https://mcp.pharaoh.so/mcp` instead of `/sse`. The skill also carries the guidance that was only in the server instructions: one Pharaoh call answers what grep and file reads take many turns to piece together, load Pharaoh's tools before the first code search when your client defers them, summarize a block marked "Trace view available" instead of reproducing it, and check related repos when a change touches shared interfaces.
|
|
27
27
|
- The server instructions state facts: what Pharaoh maps, which tool answers which question, what the graph leaves to grep, how repos are mapped and what each account tool does. Asking Pharaoh first, not re-checking its answers with grep, surfacing and crediting its ★ blocks and the rest of that guidance now live in the `pharaoh` skill. Claude Code and OpenClaw list the skill in every session and load its body only when they use it. The Grep hook and the optional `~/.claude/CLAUDE.md` line are unchanged and apply from the start of a session.
|
|
28
28
|
|
|
29
|
+
## [0.3.61] - 2026-11-05
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- Eligible project memory pilot setup recommends quiet reminders and remembers declines through upgrades. The normal authenticated MCP connection provides memory when hooks are disabled or unavailable.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Optional pilot worktree cues suggest graph tools for structural Bash searches and flag local edits that differ from a previously consulted codebase map revision.
|
|
36
|
+
|
|
29
37
|
## [0.3.60] - 2026-11-05
|
|
30
38
|
|
|
31
39
|
### Added
|
package/dist/helpers.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const NPX_COMMAND = "npx @pharaoh-so/mcp";
|
|
|
5
5
|
/** Write one or more lines to stderr. */
|
|
6
6
|
export declare function printLines(...lines: string[]): void;
|
|
7
7
|
/**
|
|
8
|
-
* Ask a yes/no question
|
|
8
|
+
* Ask a yes/no question. Enter uses the default; `y` or `yes` is yes; `n`
|
|
9
9
|
* or `no` is no; anything else asks again. Ctrl-C exits (130). On a terminal,
|
|
10
10
|
* keys pressed before the question showed (an Enter while sign-in waited) are
|
|
11
11
|
* dropped, so only a key pressed after reading it answers.
|
|
@@ -13,11 +13,12 @@ export declare function printLines(...lines: string[]): void;
|
|
|
13
13
|
* @param question - The prompt, ending in something like `[Y/n] `.
|
|
14
14
|
* @param input - Where the answer is read (defaults to stdin).
|
|
15
15
|
* @param output - Where the prompt is written (defaults to stderr, like every other line).
|
|
16
|
+
* @param defaultAnswer - The answer for Enter (defaults to yes).
|
|
16
17
|
* @returns The answer, or null when input ended before one.
|
|
17
18
|
*/
|
|
18
19
|
export declare function askYesNo(question: string, input?: NodeJS.ReadableStream & {
|
|
19
20
|
isTTY?: boolean;
|
|
20
|
-
}, output?: NodeJS.WritableStream): Promise<boolean | null>;
|
|
21
|
+
}, output?: NodeJS.WritableStream, defaultAnswer?: boolean): Promise<boolean | null>;
|
|
21
22
|
/** Parse CLI arguments. */
|
|
22
23
|
export declare function parseArgs(argv?: string[]): {
|
|
23
24
|
server: string;
|
package/dist/helpers.js
CHANGED
|
@@ -15,7 +15,7 @@ export function printLines(...lines) {
|
|
|
15
15
|
/** How long to read and drop input that was typed before a question showed. */
|
|
16
16
|
const TYPE_AHEAD_MS = 50;
|
|
17
17
|
/**
|
|
18
|
-
* Ask a yes/no question
|
|
18
|
+
* Ask a yes/no question. Enter uses the default; `y` or `yes` is yes; `n`
|
|
19
19
|
* or `no` is no; anything else asks again. Ctrl-C exits (130). On a terminal,
|
|
20
20
|
* keys pressed before the question showed (an Enter while sign-in waited) are
|
|
21
21
|
* dropped, so only a key pressed after reading it answers.
|
|
@@ -23,9 +23,10 @@ const TYPE_AHEAD_MS = 50;
|
|
|
23
23
|
* @param question - The prompt, ending in something like `[Y/n] `.
|
|
24
24
|
* @param input - Where the answer is read (defaults to stdin).
|
|
25
25
|
* @param output - Where the prompt is written (defaults to stderr, like every other line).
|
|
26
|
+
* @param defaultAnswer - The answer for Enter (defaults to yes).
|
|
26
27
|
* @returns The answer, or null when input ended before one.
|
|
27
28
|
*/
|
|
28
|
-
export async function askYesNo(question, input = process.stdin, output = process.stderr) {
|
|
29
|
+
export async function askYesNo(question, input = process.stdin, output = process.stderr, defaultAnswer = true) {
|
|
29
30
|
if (input.isTTY)
|
|
30
31
|
await dropTypedAhead(input);
|
|
31
32
|
const rl = createInterface({ input, output });
|
|
@@ -39,7 +40,9 @@ export async function askYesNo(question, input = process.stdin, output = process
|
|
|
39
40
|
// The iterator ends on EOF; rl.question never settles then.
|
|
40
41
|
for await (const reply of rl) {
|
|
41
42
|
const answer = reply.trim().toLowerCase();
|
|
42
|
-
if (answer === ""
|
|
43
|
+
if (answer === "")
|
|
44
|
+
return defaultAnswer;
|
|
45
|
+
if (answer === "y" || answer === "yes")
|
|
43
46
|
return true;
|
|
44
47
|
if (answer === "n" || answer === "no")
|
|
45
48
|
return false;
|
|
@@ -96,7 +99,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
96
99
|
return { server, logout };
|
|
97
100
|
}
|
|
98
101
|
export function printUsage() {
|
|
99
|
-
printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config, the Claude Code plugin and the CLAUDE.md line, then exit", " --clean Remove Pharaoh setup, including managed local project memory hooks", " --memory-hooks
|
|
102
|
+
printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config, the Claude Code plugin and the CLAUDE.md line, then exit", " --clean Remove Pharaoh setup, including managed local project memory hooks", " --memory-hooks Accept project memory hooks during eligible interactive setup", " --disable-memory-hooks Remove Pharaoh's memory hooks and remember the choice", " --install-skills Reinstall the Claude Code plugin and OpenClaw skills", " --version, -v Print the version", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
|
|
100
103
|
}
|
|
101
104
|
/**
|
|
102
105
|
* Validate that a server-supplied SSE URL shares the same origin as the configured server.
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* mode until device flow completes, then swap to SSE in
|
|
13
13
|
* place — the stdio session never disconnects.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { resetHookPreferences } from "../project-memory/preferences.mjs";
|
|
16
|
+
import { applyMemoryHookChoices, chooseMemoryHooks, syncMemoryHooks, } from "../project-memory/setup.mjs";
|
|
16
17
|
import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
|
|
17
18
|
import { AuthRequiredBackend, createSseBackend } from "./backend.js";
|
|
18
19
|
import { deleteCredentials, readCredentials, writeCredentials, } from "./credentials.js";
|
|
@@ -44,6 +45,34 @@ function reportMemorySetup(options) {
|
|
|
44
45
|
return false;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
function clearMemoryHookChoices(disable) {
|
|
49
|
+
try {
|
|
50
|
+
resetHookPreferences(disable);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
printLines("Pharaoh: could not update local project memory choices.");
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function setupMemoryHooks(credentials, clients, requested) {
|
|
59
|
+
try {
|
|
60
|
+
const decision = await chooseMemoryHooks({
|
|
61
|
+
credentials,
|
|
62
|
+
clients,
|
|
63
|
+
requested,
|
|
64
|
+
ask: (question, defaultAnswer) => process.stderr.isTTY
|
|
65
|
+
? askYesNo(question, process.stdin, process.stderr, defaultAnswer)
|
|
66
|
+
: Promise.resolve(null),
|
|
67
|
+
});
|
|
68
|
+
const notices = applyMemoryHookChoices(decision, { credentials });
|
|
69
|
+
if (notices.length)
|
|
70
|
+
printLines(...notices);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
printLines("Pharaoh: could not finish local project memory setup. MCP remains available.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
47
76
|
async function main() {
|
|
48
77
|
const args = process.argv.slice(2);
|
|
49
78
|
if (args.includes("--help") || args.includes("-h")) {
|
|
@@ -68,7 +97,8 @@ async function main() {
|
|
|
68
97
|
return;
|
|
69
98
|
}
|
|
70
99
|
if (args.includes("--disable-memory-hooks")) {
|
|
71
|
-
|
|
100
|
+
const saved = clearMemoryHookChoices(true);
|
|
101
|
+
if (!reportMemorySetup() || !saved)
|
|
72
102
|
process.exitCode = 1;
|
|
73
103
|
return;
|
|
74
104
|
}
|
|
@@ -77,10 +107,11 @@ async function main() {
|
|
|
77
107
|
if (args.includes("--clean")) {
|
|
78
108
|
const { cleanCodexSetup, cleanSlate } = await import("./setup.js");
|
|
79
109
|
const { uninstallClaudePlugin } = await import("./install-skills.js");
|
|
110
|
+
clearMemoryHookChoices(false);
|
|
111
|
+
reportMemorySetup();
|
|
80
112
|
cleanSlate();
|
|
81
113
|
uninstallClaudePlugin();
|
|
82
114
|
cleanCodexSetup();
|
|
83
|
-
reportMemorySetup();
|
|
84
115
|
printLines("Pharaoh: local setup cleanup finished.");
|
|
85
116
|
process.exit(0);
|
|
86
117
|
}
|
|
@@ -90,10 +121,11 @@ async function main() {
|
|
|
90
121
|
const { uninstallClaudePlugin } = await import("./install-skills.js");
|
|
91
122
|
// Credentials first: a cleanup step that fails must never leave the tokens behind.
|
|
92
123
|
deleteCredentials();
|
|
124
|
+
clearMemoryHookChoices(false);
|
|
125
|
+
reportMemorySetup();
|
|
93
126
|
cleanSlate();
|
|
94
127
|
uninstallClaudePlugin();
|
|
95
128
|
cleanCodexSetup();
|
|
96
|
-
reportMemorySetup();
|
|
97
129
|
printLines("Pharaoh: credentials cleared and local setup cleanup finished.");
|
|
98
130
|
process.exit(0);
|
|
99
131
|
}
|
|
@@ -130,8 +162,10 @@ async function main() {
|
|
|
130
162
|
if (runCodexSetup())
|
|
131
163
|
clients.push("codex");
|
|
132
164
|
}
|
|
133
|
-
if (!clients.length)
|
|
165
|
+
if (!clients.length) {
|
|
166
|
+
await setupMemoryHooks(newCreds, clients, args.includes("--memory-hooks"));
|
|
134
167
|
process.exit(1);
|
|
168
|
+
}
|
|
135
169
|
if (clients.includes("claude-code")) {
|
|
136
170
|
process.stderr.write(" Setting up...");
|
|
137
171
|
const { keptCommands } = runInstallSkills(undefined, { silent: true });
|
|
@@ -144,7 +178,7 @@ async function main() {
|
|
|
144
178
|
if (claudeMd)
|
|
145
179
|
printLines(claudeMd);
|
|
146
180
|
}
|
|
147
|
-
|
|
181
|
+
await setupMemoryHooks(newCreds, clients, args.includes("--memory-hooks"));
|
|
148
182
|
// The registration is pinned to this version; upgrading is a deliberate
|
|
149
183
|
// re-run. Interactive only, and quiet when the registry can't be reached.
|
|
150
184
|
const latest = await fetchLatestVersion();
|
package/hooks/route-grep.mjs
CHANGED
|
@@ -103,6 +103,69 @@ export function routeGrep(input, signedIn, redirected) {
|
|
|
103
103
|
].join(" ");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Match one plain Bash rg invocation with an explicit declaration or call pattern.
|
|
108
|
+
* @param {unknown} command
|
|
109
|
+
* @returns {{ symbol: string, tool: "search_functions" | "get_blast_radius" } | undefined}
|
|
110
|
+
*/
|
|
111
|
+
export function classifyBashGrep(command) {
|
|
112
|
+
if (typeof command !== "string" || command.length > 1024 || /[\r\n]/.test(command)) return;
|
|
113
|
+
const source = command.trim();
|
|
114
|
+
// Only the quoting and flags needed by a simple rg search. Unrecognized
|
|
115
|
+
// shell syntax falls through, including expansions, redirects and pipelines.
|
|
116
|
+
const word = /(?:'([^']*)'|"([^"$`\\]*)"|([A-Za-z0-9_./:-]+))(?:[ \t]+|$)/gy;
|
|
117
|
+
const args = [];
|
|
118
|
+
while (word.lastIndex < source.length) {
|
|
119
|
+
const match = word.exec(source);
|
|
120
|
+
if (!match) return;
|
|
121
|
+
args.push(match[1] ?? match[2] ?? match[3]);
|
|
122
|
+
}
|
|
123
|
+
if (args.shift() !== "rg") return;
|
|
124
|
+
|
|
125
|
+
const scope = {};
|
|
126
|
+
while (args[0]?.startsWith("-")) {
|
|
127
|
+
const option = args.shift();
|
|
128
|
+
if (option === "--") break;
|
|
129
|
+
if (["-n", "--line-number", "--no-heading", "-S", "--smart-case"].includes(option ?? ""))
|
|
130
|
+
continue;
|
|
131
|
+
const value = args.shift();
|
|
132
|
+
if (!value) return;
|
|
133
|
+
if ((option === "-g" || option === "--glob") && scope.glob === undefined) scope.glob = value;
|
|
134
|
+
else if ((option === "-t" || option === "--type") && scope.type === undefined)
|
|
135
|
+
scope.type = value;
|
|
136
|
+
else return;
|
|
137
|
+
}
|
|
138
|
+
const [pattern, path] = args;
|
|
139
|
+
const globExtensions = scope.glob?.match(/^\*\.(?:\{([a-z]+(?:,[a-z]+)*)\}|([a-z]+))$/i);
|
|
140
|
+
if (
|
|
141
|
+
!pattern ||
|
|
142
|
+
args.length > 2 ||
|
|
143
|
+
(path &&
|
|
144
|
+
(!/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(path) ||
|
|
145
|
+
path.startsWith("/") ||
|
|
146
|
+
path.split("/").includes(".."))) ||
|
|
147
|
+
[scope.glob, path].some((value) =>
|
|
148
|
+
/(?:^|[/.\-_])(?:config|settings|env|docs|documentation|fixtures|snapshots)(?:[/.\-_]|$)/i.test(
|
|
149
|
+
value ?? "",
|
|
150
|
+
),
|
|
151
|
+
) ||
|
|
152
|
+
(scope.glob &&
|
|
153
|
+
(!globExtensions ||
|
|
154
|
+
!(globExtensions[1] ?? globExtensions[2])
|
|
155
|
+
.toLowerCase()
|
|
156
|
+
.split(",")
|
|
157
|
+
.every((extension) => MAPPED_EXTENSIONS.has(extension)))) ||
|
|
158
|
+
!searchesMappedCode({ type: scope.type }) ||
|
|
159
|
+
!searchesMappedCode({ path })
|
|
160
|
+
)
|
|
161
|
+
return;
|
|
162
|
+
const symbol = symbolSearched(pattern);
|
|
163
|
+
if (!symbol || symbol.length > 80) return;
|
|
164
|
+
if (/\\?\($/.test(pattern)) return { symbol, tool: "get_blast_radius" };
|
|
165
|
+
if (/^(?:(?:export|default|async) +)*(?:function|class|def|interface|type|enum)\b/.test(pattern))
|
|
166
|
+
return { symbol, tool: "search_functions" };
|
|
167
|
+
}
|
|
168
|
+
|
|
106
169
|
/** Hook entry point: read the payload from stdin, reply with a PreToolUse deny or nothing. */
|
|
107
170
|
function main() {
|
|
108
171
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pharaoh-so/mcp",
|
|
3
3
|
"mcpName": "so.pharaoh/pharaoh",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.61",
|
|
5
5
|
"description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for coding agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { dirname, join, relative } from "node:path";
|
|
13
13
|
|
|
14
14
|
const LIMIT = 256 * 1024;
|
|
15
|
-
export const
|
|
15
|
+
export const LEGACY_MEMORY_RUNTIME_FILES = [
|
|
16
16
|
"execute-turn.mjs",
|
|
17
17
|
"hook-config.mjs",
|
|
18
18
|
"hook-events.mjs",
|
|
@@ -25,6 +25,13 @@ export const MEMORY_RUNTIME_FILES = [
|
|
|
25
25
|
"runtime/package.json",
|
|
26
26
|
"runtime/refresh.js",
|
|
27
27
|
];
|
|
28
|
+
export const MEMORY_RUNTIME_FILES = [
|
|
29
|
+
...LEGACY_MEMORY_RUNTIME_FILES,
|
|
30
|
+
"bundle.mjs",
|
|
31
|
+
"graph-cues.mjs",
|
|
32
|
+
"preferences.mjs",
|
|
33
|
+
"route-grep.mjs",
|
|
34
|
+
];
|
|
28
35
|
|
|
29
36
|
function digest(files) {
|
|
30
37
|
return createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
@@ -51,7 +58,7 @@ export function packagedMemoryBundle(packageRoot = new URL("../", import.meta.ur
|
|
|
51
58
|
} else {
|
|
52
59
|
const location = name.startsWith("runtime/")
|
|
53
60
|
? new URL(`dist/${name.slice(8)}`, packageRoot)
|
|
54
|
-
: new URL(
|
|
61
|
+
: new URL(`${name === "route-grep.mjs" ? "hooks" : "project-memory"}/${name}`, packageRoot);
|
|
55
62
|
try {
|
|
56
63
|
bytes = readFileSync(location);
|
|
57
64
|
} catch {
|
|
@@ -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,
|
|
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
|
|
42
|
+
for (const event of events) {
|
|
42
43
|
const current = hooks[event];
|
|
43
|
-
const install =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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,
|
|
83
|
-
return
|
|
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},
|
|
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
|
}
|
package/project-memory/local.mjs
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
46
|
+
credentials,
|
|
47
|
+
reminder,
|
|
29
48
|
deadline,
|
|
30
49
|
);
|
|
31
|
-
if (eligible
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
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[];
|
package/project-memory/setup.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
|
4
4
|
import { bundledHook, installMemoryBundle, packagedMemoryBundle } from "./bundle.mjs";
|
|
5
5
|
import { hasLegacyHooks, hasUserHooks, updateHooks, updateUserHooks } from "./hook-config.mjs";
|
|
6
6
|
import { atomicWrite, checkout, git, hasMemoryGit } from "./local.mjs";
|
|
7
|
+
import { MEMORY_HOOK_CLIENTS, readHookPreferences, writeHookPreferences } from "./preferences.mjs";
|
|
7
8
|
import {
|
|
8
9
|
legacySettingsPath,
|
|
9
10
|
matchingMemoryHint,
|
|
@@ -13,8 +14,6 @@ import {
|
|
|
13
14
|
readHookSettings,
|
|
14
15
|
} from "./user-target.mjs";
|
|
15
16
|
|
|
16
|
-
const CLIENTS = ["claude-code", "codex"];
|
|
17
|
-
|
|
18
17
|
function userSettings(client, home, env) {
|
|
19
18
|
const codex = client === "codex";
|
|
20
19
|
const directory =
|
|
@@ -82,7 +81,7 @@ function migrateCheckout(client, target, notices) {
|
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive) {
|
|
84
|
+
function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive, graphCues) {
|
|
86
85
|
let known = Boolean(alreadyActive);
|
|
87
86
|
try {
|
|
88
87
|
const settings = inspectSettings(userSettings(client, home, env));
|
|
@@ -95,7 +94,7 @@ function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive)
|
|
|
95
94
|
hasLegacyHooks(settings.config, client, target.root);
|
|
96
95
|
writeSettings(
|
|
97
96
|
settings,
|
|
98
|
-
updateUserHooks(settings.config, client, hookPath, node),
|
|
97
|
+
updateUserHooks(settings.config, client, hookPath, node, graphCues),
|
|
99
98
|
target,
|
|
100
99
|
checkoutHook,
|
|
101
100
|
);
|
|
@@ -110,10 +109,118 @@ function writeActivation(path, active) {
|
|
|
110
109
|
else rmSync(path, { force: true });
|
|
111
110
|
}
|
|
112
111
|
|
|
112
|
+
/** Resolve explicit choices only after a supported client's MCP registration. */
|
|
113
|
+
export async function chooseMemoryHooks({
|
|
114
|
+
credentials,
|
|
115
|
+
clients,
|
|
116
|
+
requested = false,
|
|
117
|
+
ask,
|
|
118
|
+
home = homedir(),
|
|
119
|
+
env = process.env,
|
|
120
|
+
}) {
|
|
121
|
+
const choices = { accepted: [], declined: [], graphClients: [], notices: [] };
|
|
122
|
+
if (typeof process.getuid !== "function") {
|
|
123
|
+
if (requested)
|
|
124
|
+
choices.notices.push("Pharaoh: project memory hooks need a POSIX client with system Git.");
|
|
125
|
+
return choices;
|
|
126
|
+
}
|
|
127
|
+
if (credentials?.project_memory_status === "unavailable") {
|
|
128
|
+
if (requested)
|
|
129
|
+
choices.notices.push(
|
|
130
|
+
"Pharaoh: project memory eligibility could not be checked. Retry setup to enable hooks.",
|
|
131
|
+
);
|
|
132
|
+
return choices;
|
|
133
|
+
}
|
|
134
|
+
if (!projectMemoryHint(credentials).length) {
|
|
135
|
+
if (requested) choices.notices.push("Pharaoh: this session has no project memory hook grant.");
|
|
136
|
+
return credentials ? { ...choices, removeUnentitled: true } : choices;
|
|
137
|
+
}
|
|
138
|
+
if (!clients.length || !hasMemoryGit) {
|
|
139
|
+
if (requested && !hasMemoryGit)
|
|
140
|
+
choices.notices.push("Pharaoh: project memory hooks need a POSIX client with system Git.");
|
|
141
|
+
return choices;
|
|
142
|
+
}
|
|
143
|
+
const previous = readHookPreferences(home);
|
|
144
|
+
const active = readActiveMemoryClients(home);
|
|
145
|
+
const next = { ...previous };
|
|
146
|
+
for (const client of MEMORY_HOOK_CLIENTS) {
|
|
147
|
+
if (!clients.includes(client)) continue;
|
|
148
|
+
const label = client === "codex" ? "Codex" : "Claude Code";
|
|
149
|
+
let config;
|
|
150
|
+
try {
|
|
151
|
+
config = inspectSettings(userSettings(client, home, env)).config;
|
|
152
|
+
} catch {
|
|
153
|
+
// An unreadable settings file cannot establish a previous installation.
|
|
154
|
+
}
|
|
155
|
+
if (client === "claude-code" && config?.disableAllHooks === true) continue;
|
|
156
|
+
const choice = { ...previous[client] };
|
|
157
|
+
const memoryHook =
|
|
158
|
+
active[client] &&
|
|
159
|
+
hasUserHooks({ hooks: { UserPromptSubmit: config?.hooks?.UserPromptSubmit } }, client);
|
|
160
|
+
if (requested) choice.memory = true;
|
|
161
|
+
else if (choice.memory !== false && (choice.memory !== true || !memoryHook)) {
|
|
162
|
+
const answer = await ask(
|
|
163
|
+
`Enable Pharaoh project memory hooks in ${label} (recommended)? [Y/n] `,
|
|
164
|
+
true,
|
|
165
|
+
);
|
|
166
|
+
if (answer === null) continue;
|
|
167
|
+
choice.memory = answer;
|
|
168
|
+
if (!answer && choice.graph === true) delete choice.graph;
|
|
169
|
+
}
|
|
170
|
+
if (choice.memory === true) {
|
|
171
|
+
let graphEnabled =
|
|
172
|
+
choice.graph === true &&
|
|
173
|
+
["PreToolUse", "PostToolUse"].every((event) =>
|
|
174
|
+
hasUserHooks({ hooks: { [event]: config?.hooks?.[event] } }, client),
|
|
175
|
+
);
|
|
176
|
+
if (requested || choice.graph === undefined || (choice.graph === true && !graphEnabled)) {
|
|
177
|
+
const answer = await ask(
|
|
178
|
+
`Enable advisory Pharaoh worktree cues in ${label}? [y/N] `,
|
|
179
|
+
false,
|
|
180
|
+
);
|
|
181
|
+
if (answer !== null) {
|
|
182
|
+
choice.graph = answer;
|
|
183
|
+
graphEnabled = answer;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
choices.accepted.push(client);
|
|
187
|
+
if (graphEnabled) choices.graphClients.push(client);
|
|
188
|
+
} else {
|
|
189
|
+
choices.declined.push(client);
|
|
190
|
+
}
|
|
191
|
+
next[client] = choice;
|
|
192
|
+
}
|
|
193
|
+
writeHookPreferences(next, home, previous);
|
|
194
|
+
return choices;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Apply the outcome against the saved choices, which can change after the prompt. */
|
|
198
|
+
export function applyMemoryHookChoices(decision, options = {}) {
|
|
199
|
+
const { accepted, declined, graphClients, removeUnentitled } = decision;
|
|
200
|
+
const notices = [...decision.notices];
|
|
201
|
+
if (removeUnentitled)
|
|
202
|
+
return [...notices, ...syncMemoryHooks({ ...options, clients: MEMORY_HOOK_CLIENTS })];
|
|
203
|
+
|
|
204
|
+
if (declined.length) {
|
|
205
|
+
const saved = readHookPreferences(options.home);
|
|
206
|
+
const clients = declined.filter((client) => saved[client]?.memory === false);
|
|
207
|
+
if (clients.length) notices.push(...syncMemoryHooks({ ...options, clients }));
|
|
208
|
+
}
|
|
209
|
+
if (accepted.length) {
|
|
210
|
+
const saved = readHookPreferences(options.home);
|
|
211
|
+
const clients = accepted.filter((client) => saved[client]?.memory === true);
|
|
212
|
+
const cues = graphClients.filter((client) => saved[client]?.graph === true);
|
|
213
|
+
if (clients.length)
|
|
214
|
+
notices.push(...syncMemoryHooks({ ...options, clients, graphClients: cues, enable: true }));
|
|
215
|
+
}
|
|
216
|
+
return notices;
|
|
217
|
+
}
|
|
218
|
+
|
|
113
219
|
/** Run only during setup or explicit cleanup of the local memory hooks. */
|
|
114
220
|
export function syncMemoryHooks({
|
|
115
221
|
credentials,
|
|
116
|
-
clients =
|
|
222
|
+
clients = MEMORY_HOOK_CLIENTS,
|
|
223
|
+
graphClients = [],
|
|
117
224
|
enable = false,
|
|
118
225
|
home = homedir(),
|
|
119
226
|
env = process.env,
|
|
@@ -150,8 +257,8 @@ export function syncMemoryHooks({
|
|
|
150
257
|
}
|
|
151
258
|
}
|
|
152
259
|
|
|
153
|
-
for (const client of
|
|
154
|
-
if (
|
|
260
|
+
for (const client of MEMORY_HOOK_CLIENTS) {
|
|
261
|
+
if (!clients.includes(client)) continue;
|
|
155
262
|
const next = { ...active, [client]: digest };
|
|
156
263
|
try {
|
|
157
264
|
// Enable the replacement before swapping any settings, so failed
|
|
@@ -159,7 +266,16 @@ export function syncMemoryHooks({
|
|
|
159
266
|
const replacing = enable && active[client] !== digest;
|
|
160
267
|
if (replacing) writeActivation(activePath, next);
|
|
161
268
|
try {
|
|
162
|
-
syncUserHooks(
|
|
269
|
+
syncUserHooks(
|
|
270
|
+
client,
|
|
271
|
+
home,
|
|
272
|
+
env,
|
|
273
|
+
target,
|
|
274
|
+
installed,
|
|
275
|
+
node,
|
|
276
|
+
active[client],
|
|
277
|
+
graphClients.includes(client),
|
|
278
|
+
);
|
|
163
279
|
} catch (error) {
|
|
164
280
|
if (replacing) writeActivation(activePath, active);
|
|
165
281
|
throw error;
|
|
@@ -176,8 +292,16 @@ export function syncMemoryHooks({
|
|
|
176
292
|
}
|
|
177
293
|
|
|
178
294
|
// Deactivate loaded user hooks even if known settings could not be edited.
|
|
179
|
-
if (!enable
|
|
180
|
-
|
|
295
|
+
if (!enable) {
|
|
296
|
+
const remaining = Object.fromEntries(
|
|
297
|
+
Object.entries(active).filter(([client]) => !clients.includes(client)),
|
|
298
|
+
);
|
|
299
|
+
if (Object.keys(remaining).length !== Object.keys(active).length)
|
|
300
|
+
writeActivation(activePath, remaining);
|
|
301
|
+
}
|
|
302
|
+
for (const client of enable ? configured : clients) {
|
|
303
|
+
if (MEMORY_HOOK_CLIENTS.includes(client)) migrateCheckout(client, target, notices);
|
|
304
|
+
}
|
|
181
305
|
if (configured.length) {
|
|
182
306
|
notices.push(
|
|
183
307
|
"Pharaoh project memory reminders are set up for your eligible repositories. Start a new local conversation.",
|