@pharaoh-so/mcp 0.3.56 → 0.3.57
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 +5 -0
- package/dist/helpers.js +1 -4
- package/package.json +1 -1
- package/project-memory/execute-turn.mjs +76 -47
- package/project-memory/hook-events.mjs +46 -70
- package/project-memory/local.mjs +69 -52
- package/project-memory/memory-api.mjs +51 -33
- package/project-memory/pharaoh-memory-hook.mjs +23 -9
- package/project-memory/pharaoh-memory.mjs +11 -6
- package/project-memory/user-target.mjs +27 -13
package/.mcp.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,11 @@ 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.57] - 2026-11-04
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- Project memory uses compact recall by default. The agent can save one candidate during the task, and completion ends without a memory follow-up.
|
|
33
|
+
|
|
29
34
|
## [0.3.56] - 2026-11-04
|
|
30
35
|
|
|
31
36
|
### Fixed
|
package/dist/helpers.js
CHANGED
|
@@ -153,10 +153,7 @@ export function tokenToCredentials(token, sseUrl, server) {
|
|
|
153
153
|
github_login: token.github_login ?? null,
|
|
154
154
|
tenant_name: token.tenant_name ?? null,
|
|
155
155
|
repos: token.repos ?? [],
|
|
156
|
-
...(memory.length ? { project_memory: memory } : {}),
|
|
157
|
-
...(memory.some((entry) => entry.revision === null)
|
|
158
|
-
? { project_memory_session: randomUUID() }
|
|
159
|
-
: {}),
|
|
156
|
+
...(memory.length ? { project_memory: memory, project_memory_session: randomUUID() } : {}),
|
|
160
157
|
...(production && token.project_memory_status === "unavailable"
|
|
161
158
|
? { project_memory_status: "unavailable" }
|
|
162
159
|
: {}),
|
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.57",
|
|
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",
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { git, visibleGitIndex } from "./local.mjs";
|
|
2
2
|
import { PilotError, requestMemory } from "./memory-api.mjs";
|
|
3
|
+
import { projectMemorySession } from "./user-target.mjs";
|
|
3
4
|
|
|
4
5
|
const SHA = /^[a-f0-9]{40}$/i;
|
|
5
6
|
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
|
6
7
|
const TERMS = /^[\p{L}\p{N}._+@ $/#:-]+$/u;
|
|
7
8
|
const SECRET =
|
|
8
9
|
/\b(?:[a-f0-9]{32,}|ph(?:at|rt)_[a-z0-9_-]{12,})\b|:\/\/|\b[\w.-]+@[a-z0-9.-]+\.[a-z]{2,}\b/i;
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Apply the hosted recap safety rules before any agent-written text hits disk.
|
|
11
|
+
const UNSAFE_RECAP = [
|
|
12
|
+
/(?:phat|phrt|pss|dev)_[a-f\d]{64}/i,
|
|
13
|
+
/\x60{3}|~~~|-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
|
|
14
|
+
/\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|(?:AKIA|ASIA)[A-Z0-9]{16})\b/i,
|
|
15
|
+
/\bBearer[ \t]+[A-Za-z0-9_.~+/=-]{12,}|\b(?:password|secret|api[_ -]?key|access[_ -]?token)\s*[:=]\s*\S{6,}/i,
|
|
16
|
+
/^\s*(?:assistant|user|system|tool)\s*:/im,
|
|
17
|
+
/^\s*(?:import\s.+\sfrom\s+["']|(?:const|let|var)\s+\w+\s*=|(?:export\s+)?(?:async\s+)?function\s+\w+\s*\(|def\s+\w+\s*\()/m,
|
|
18
|
+
/\p{Bidi_Control}/u,
|
|
19
|
+
];
|
|
11
20
|
const REF_PATH = /^[\p{L}\p{N}._+@ -]+(?:\/[\p{L}\p{N}._+@ -]+)*$/u;
|
|
12
21
|
|
|
22
|
+
function safeRecap(text) {
|
|
23
|
+
return (
|
|
24
|
+
![...text].some((char) => {
|
|
25
|
+
const code = char.charCodeAt(0);
|
|
26
|
+
return (code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127;
|
|
27
|
+
}) && !UNSAFE_RECAP.some((pattern) => pattern.test(text))
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
function hasKeys(input, allowed) {
|
|
14
32
|
return (
|
|
15
33
|
input &&
|
|
@@ -27,7 +45,7 @@ function normalizeTerm(value) {
|
|
|
27
45
|
term.split(" ").length <= 8 &&
|
|
28
46
|
TERMS.test(term) &&
|
|
29
47
|
!SECRET.test(term) &&
|
|
30
|
-
|
|
48
|
+
safeRecap(term)
|
|
31
49
|
) {
|
|
32
50
|
return term;
|
|
33
51
|
}
|
|
@@ -57,23 +75,23 @@ function validRef(ref) {
|
|
|
57
75
|
ref.path.length <= 180 &&
|
|
58
76
|
REF_PATH.test(ref.path) &&
|
|
59
77
|
!ref.path.split("/").some((part) => part === "." || part === "..") &&
|
|
60
|
-
|
|
78
|
+
safeRecap(ref.path))) &&
|
|
61
79
|
(ref.symbol === undefined ||
|
|
62
80
|
(typeof ref.symbol === "string" &&
|
|
63
81
|
ref.symbol.length <= 100 &&
|
|
64
82
|
/^[\w$][\w$.#:-]*$/u.test(ref.symbol) &&
|
|
65
|
-
|
|
66
|
-
(ref.pullRequest === undefined ||
|
|
83
|
+
safeRecap(ref.symbol))) &&
|
|
84
|
+
(ref.pullRequest === undefined ||
|
|
85
|
+
(Number.isInteger(ref.pullRequest) &&
|
|
86
|
+
ref.pullRequest > 0 &&
|
|
87
|
+
ref.pullRequest <= 1_000_000_000)) &&
|
|
67
88
|
(ref.commit === undefined || (typeof ref.commit === "string" && SHA.test(ref.commit)))
|
|
68
89
|
);
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
function validRecap(text, limit) {
|
|
72
93
|
return (
|
|
73
|
-
typeof text === "string" &&
|
|
74
|
-
text.trim().length > 0 &&
|
|
75
|
-
text.length <= limit &&
|
|
76
|
-
!UNSAFE_RECAP.test(text)
|
|
94
|
+
typeof text === "string" && text.trim().length > 0 && text.length <= limit && safeRecap(text)
|
|
77
95
|
);
|
|
78
96
|
}
|
|
79
97
|
|
|
@@ -88,7 +106,7 @@ function captureDraft(input) {
|
|
|
88
106
|
(!Array.isArray(input.refs) || input.refs.length > 6 || !input.refs.every(validRef)))
|
|
89
107
|
) {
|
|
90
108
|
throw new PilotError(
|
|
91
|
-
"Use a
|
|
109
|
+
"Use a JSON candidate with kind, summary (500 characters), optional detail (1,000), verification (300), and up to six refs.",
|
|
92
110
|
);
|
|
93
111
|
}
|
|
94
112
|
return input;
|
|
@@ -148,20 +166,19 @@ function validPair(id, version) {
|
|
|
148
166
|
export async function executeTurn(
|
|
149
167
|
operation,
|
|
150
168
|
token,
|
|
151
|
-
{ target, store, read, request = requestMemory, compact =
|
|
169
|
+
{ target, store, read, request = requestMemory, compact = true, loadCaptureCredentials },
|
|
152
170
|
) {
|
|
153
171
|
const change = (from, to) => {
|
|
154
172
|
const state = store.transition(token, target, from, to);
|
|
155
173
|
if (!state) throw new PilotError("This project memory turn is stale or not eligible.");
|
|
156
174
|
return state;
|
|
157
175
|
};
|
|
158
|
-
if (operation === "nothing-material") {
|
|
159
|
-
change(["nudged"], "acknowledged");
|
|
160
|
-
return { acknowledged: true };
|
|
161
|
-
}
|
|
162
176
|
if (operation === "receipt") {
|
|
163
177
|
const receipt = store.readReceipt(token, target);
|
|
164
|
-
if (!receipt)
|
|
178
|
+
if (!receipt)
|
|
179
|
+
throw new PilotError(
|
|
180
|
+
"No confirmed capture receipt is available for this turn. Check Project Memory before retrying.",
|
|
181
|
+
);
|
|
165
182
|
return receipt;
|
|
166
183
|
}
|
|
167
184
|
let input;
|
|
@@ -193,48 +210,60 @@ export async function executeTurn(
|
|
|
193
210
|
throw new PilotError("This project memory turn ended before its expansion returned.");
|
|
194
211
|
return result;
|
|
195
212
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
213
|
+
const capture = operation === "capture";
|
|
214
|
+
if (!capture && operation !== "recall") throw new PilotError("Unknown project memory operation.");
|
|
215
|
+
const draft = capture ? captureDraft(input) : { terms: recallTerms(input) };
|
|
198
216
|
if (JSON.stringify(draft).includes(token))
|
|
199
217
|
throw new PilotError("The local turn token cannot appear in a memory request.");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
throw new PilotError("The checkout has no full Git commit to cite.");
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
218
|
+
if (capture) {
|
|
219
|
+
const gitRev = git(target.root, "rev-parse", "HEAD");
|
|
220
|
+
if (!SHA.test(gitRev)) throw new PilotError("The checkout has no full Git commit to cite.");
|
|
221
|
+
const refs = draft.refs ? await citeRefs(target.root, gitRev, draft.refs) : undefined;
|
|
222
|
+
const credentials = await loadCaptureCredentials?.();
|
|
223
|
+
const session = projectMemorySession(credentials);
|
|
224
|
+
if (!session || !(Date.parse(credentials.expires_at) > Date.now()))
|
|
225
|
+
throw new PilotError("Reconnect to Pharaoh to save project memory.");
|
|
226
|
+
const dispatched = store.startCapture(token, target, session, (identity) =>
|
|
227
|
+
request("capture", { ...draft, gitRev, refs, ...identity }, { credentials }),
|
|
228
|
+
);
|
|
229
|
+
if (!dispatched)
|
|
230
|
+
throw new PilotError("This turn is stale, read-only, or has already attempted a capture.");
|
|
231
|
+
let record;
|
|
232
|
+
try {
|
|
233
|
+
({ record } = await dispatched);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof PilotError && error.rejected)
|
|
236
|
+
store.saveReceipt(token, target, "rejected");
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
store.saveReceipt(token, target, "confirmed", record);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new PilotError("Pharaoh saved the candidate. Check Project Memory for the record.");
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const state = change(["pending"], "recalling");
|
|
206
247
|
const payload = {
|
|
207
248
|
repo: state.repo,
|
|
208
249
|
...draft,
|
|
209
|
-
...(
|
|
210
|
-
...(!recall && { gitRev, client: state.client, operationId: state.operationId }),
|
|
211
|
-
...(refs && { refs }),
|
|
250
|
+
...(compact && { format: "compact-v1" }),
|
|
212
251
|
};
|
|
213
252
|
let result;
|
|
214
253
|
try {
|
|
215
254
|
result = await request(operation, payload);
|
|
216
255
|
} catch (error) {
|
|
217
|
-
store.transition(token, target, [
|
|
256
|
+
store.transition(token, target, ["recalling"], "failed");
|
|
218
257
|
throw error;
|
|
219
258
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
)
|
|
228
|
-
throw new PilotError("This project memory turn ended before recall returned.");
|
|
229
|
-
}
|
|
230
|
-
} else {
|
|
231
|
-
try {
|
|
232
|
-
store.saveReceipt(token, target, result.record);
|
|
233
|
-
store.transition(token, target, [running], "acknowledged");
|
|
234
|
-
} catch {
|
|
235
|
-
// The service already committed. Local disk or a stale turn cannot turn a confirmed record into a failed write.
|
|
236
|
-
return { ...result, localReceiptUnavailable: true };
|
|
237
|
-
}
|
|
259
|
+
const pairs =
|
|
260
|
+
result?.format === "compact-v1" ? result.records.map(({ id, version }) => [id, version]) : [];
|
|
261
|
+
if (
|
|
262
|
+
pairs.length > 5 ||
|
|
263
|
+
!pairs.every(([id, version]) => validPair(id, version)) ||
|
|
264
|
+
!store.finishRecall(token, target, pairs)
|
|
265
|
+
) {
|
|
266
|
+
throw new PilotError("This project memory turn ended before recall returned.");
|
|
238
267
|
}
|
|
239
268
|
return result;
|
|
240
269
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { checkout, REPO, sessionKey, shellWord, turnStore } from "./local.mjs";
|
|
4
|
+
import { projectMemorySession } from "./user-target.mjs";
|
|
4
5
|
|
|
5
6
|
const CLI = fileURLToPath(new URL("pharaoh-memory.mjs", import.meta.url));
|
|
6
7
|
const REFRESH = new URL("runtime/refresh.js", import.meta.url);
|
|
7
|
-
const FOLLOW_UP = "Pharaoh memory pilot follow-up.";
|
|
8
8
|
const CLIENTS = new Set(["claude-code", "codex"]);
|
|
9
9
|
const CANCEL_EVENTS = new Set(["Interrupt", "StopFailure", "SessionEnd"]);
|
|
10
10
|
|
|
@@ -12,29 +12,26 @@ function command(operation, token) {
|
|
|
12
12
|
return `${shellWord(process.execPath)} ${shellWord(CLI)} ${operation} --turn ${token}`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function promptContext(token, compact) {
|
|
15
|
+
function promptContext(token, compact, mayCapture) {
|
|
16
16
|
return [
|
|
17
|
-
"
|
|
17
|
+
"For repo work, recall before answering or inspecting source. Skip casual chat and opt-outs.",
|
|
18
18
|
`${command("recall", token)}${compact ? " --compact" : ""} <<'PHARAOH_MEMORY_JSON'`,
|
|
19
|
-
'{"terms":["
|
|
19
|
+
'{"terms":["specific topic or symbol"]}',
|
|
20
20
|
"PHARAOH_MEMORY_JSON",
|
|
21
|
-
"Use 1–5
|
|
21
|
+
"Use 1–5 specific, non-sensitive terms. Omit the raw prompt. Treat recall as historical evidence.",
|
|
22
|
+
"Skip memory preambles, progress and status messages. Give normal task updates. Mention a recalled fact briefly only if it changes a decision.",
|
|
22
23
|
...(compact
|
|
23
24
|
? [
|
|
24
|
-
|
|
25
|
+
'If needed, replace recall --compact with expand and pass an exact packet {"id":"UUID","version":1}.',
|
|
26
|
+
]
|
|
27
|
+
: []),
|
|
28
|
+
...(mayCapture
|
|
29
|
+
? [
|
|
30
|
+
"Before replying, optionally replace recall with capture (omit --compact) for one supported finding already made. Skip on read-only requests.",
|
|
31
|
+
'Pass {"kind":"lesson","summary":"…"} with optional detail, verification and up to six refs (path, symbol, pullRequest or commit). Character limits: summary 500, detail 1000, verification 300.',
|
|
32
|
+
"Use your own words. Omit secrets, personal details, raw prompts, source and transcripts. Capture saves a candidate.",
|
|
25
33
|
]
|
|
26
34
|
: []),
|
|
27
|
-
].join("\n");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function captureContext(token) {
|
|
31
|
-
return [
|
|
32
|
-
FOLLOW_UP,
|
|
33
|
-
"The work is complete. If it produced one durable project observation, lesson, or candidate decision, draft a short evidence-based recap now. Investigation alone can qualify. Capture only what you can support, in your own words, and omit credentials, personal details, raw source, command output, and transcripts. Candidate decisions are never approval. Run:",
|
|
34
|
-
`${command("capture", token)} <<'PHARAOH_MEMORY_JSON'`,
|
|
35
|
-
'{"kind":"lesson","summary":"Concise finding in your own words.","detail":"Optional context or outcome.","refs":[{"path":"path/to/file","symbol":"symbolName"}],"verification":"What was checked and what remains unverified."}',
|
|
36
|
-
"PHARAOH_MEMORY_JSON",
|
|
37
|
-
`Use real repo paths, symbols, pullRequest numbers, or full commit hashes for refs, and omit fields you do not have. If there is nothing worth retaining or the user opted out, run ${command("nothing-material", token)} instead. Report an accepted record ID or an unavailable status briefly. Do not repeat the work or attempt the write again.`,
|
|
38
35
|
].join("\n");
|
|
39
36
|
}
|
|
40
37
|
|
|
@@ -42,6 +39,7 @@ function skipPrompt(value) {
|
|
|
42
39
|
const prompt = typeof value === "string" ? value.trim() : "";
|
|
43
40
|
return (
|
|
44
41
|
!prompt ||
|
|
42
|
+
prompt.startsWith("Pharaoh memory pilot follow-up.") ||
|
|
45
43
|
/^(?:\/(?:clear|compact|exit|help|hooks|quit|settings)\b|(?:cancel|stop|never mind)[.!]?\s*$)/i.test(
|
|
46
44
|
prompt,
|
|
47
45
|
) ||
|
|
@@ -58,8 +56,10 @@ export function handleEvent(
|
|
|
58
56
|
repo,
|
|
59
57
|
runtime,
|
|
60
58
|
home,
|
|
59
|
+
credentials,
|
|
60
|
+
deadline = Infinity,
|
|
61
61
|
hasBuild = existsSync(REFRESH),
|
|
62
|
-
compact = process.env.PHARAOH_PROJECT_MEMORY_COMPACT
|
|
62
|
+
compact = process.env.PHARAOH_PROJECT_MEMORY_COMPACT !== "false",
|
|
63
63
|
},
|
|
64
64
|
) {
|
|
65
65
|
if (!input || !CLIENTS.has(client) || !REPO.test(repo)) return;
|
|
@@ -67,7 +67,7 @@ export function handleEvent(
|
|
|
67
67
|
if (event !== "UserPromptSubmit" && event !== "Stop" && !CANCEL_EVENTS.has(event)) return;
|
|
68
68
|
const key = sessionKey(client, input.session_id);
|
|
69
69
|
if (!key) return;
|
|
70
|
-
const checkoutTarget = runtime ? { root, repo } : checkout(input.cwd);
|
|
70
|
+
const checkoutTarget = runtime ? { root, repo } : checkout(input.cwd, deadline);
|
|
71
71
|
if (checkoutTarget?.root !== root || checkoutTarget.repo.toLowerCase() !== repo.toLowerCase())
|
|
72
72
|
return;
|
|
73
73
|
const target = runtime ? { ...checkoutTarget, runtime } : checkoutTarget;
|
|
@@ -77,65 +77,41 @@ export function handleEvent(
|
|
|
77
77
|
: null;
|
|
78
78
|
if (client === "codex" && !nativeTurn && event !== "SessionEnd") return;
|
|
79
79
|
const isPrompt = event === "UserPromptSubmit";
|
|
80
|
-
const skip = isPrompt && skipPrompt(input.prompt);
|
|
81
|
-
const continuation =
|
|
82
|
-
isPrompt && typeof input.prompt === "string" && input.prompt.startsWith(FOLLOW_UP);
|
|
83
|
-
const reportUnavailable = isPrompt && !skip && !continuation;
|
|
84
80
|
// A legacy checkout hook can still be registered globally during upgrade.
|
|
85
81
|
// Without its pinned runtime it must never touch the active client's turn.
|
|
86
|
-
if (!hasBuild)
|
|
87
|
-
if (reportUnavailable)
|
|
88
|
-
return {
|
|
89
|
-
systemMessage:
|
|
90
|
-
"Pharaoh project memory is unavailable. Rerun normal Pharaoh setup to repair the local runtime.",
|
|
91
|
-
};
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
82
|
+
if (!hasBuild || performance.now() >= deadline) return;
|
|
94
83
|
const readOnly = input.permission_mode === "plan" || input.permission_mode === "read-only";
|
|
95
|
-
if (isPrompt && !continuation) {
|
|
96
|
-
try {
|
|
97
|
-
const opened = turnStore(home).begin(key, target, client, nativeTurn, { skip, readOnly });
|
|
98
|
-
if (opened?.degraded && !skip)
|
|
99
|
-
return {
|
|
100
|
-
systemMessage:
|
|
101
|
-
client === "codex"
|
|
102
|
-
? "Pharaoh project memory skipped this overlapping turn. Continue normally; the next distinct turn can try again."
|
|
103
|
-
: "Pharaoh project memory skipped this overlapping turn. Continue normally or start a fresh Claude session to restore memory.",
|
|
104
|
-
};
|
|
105
|
-
if (opened?.token)
|
|
106
|
-
return {
|
|
107
|
-
hookSpecificOutput: {
|
|
108
|
-
hookEventName: event,
|
|
109
|
-
additionalContext: promptContext(opened.token, compact),
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
} catch {
|
|
113
|
-
if (reportUnavailable)
|
|
114
|
-
return { systemMessage: "Pharaoh project memory is unavailable. Continue without memory." };
|
|
115
|
-
}
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
84
|
const store = turnStore(home);
|
|
119
|
-
if (
|
|
120
|
-
const
|
|
121
|
-
|
|
85
|
+
if (isPrompt) {
|
|
86
|
+
const memorySession = projectMemorySession(credentials);
|
|
87
|
+
const noCapture =
|
|
88
|
+
readOnly ||
|
|
89
|
+
/\b(?:read[- ]only (?:\w+ )?(?:check|plan|pass|exercise|investigation)|keep (?:this|it) read[- ]only)\b/i.test(
|
|
90
|
+
input.prompt,
|
|
91
|
+
);
|
|
92
|
+
const opened = store.begin(key, target, client, nativeTurn, {
|
|
93
|
+
skip: skipPrompt(input.prompt),
|
|
94
|
+
readOnly: noCapture,
|
|
95
|
+
memorySession,
|
|
96
|
+
});
|
|
97
|
+
if (opened?.token)
|
|
98
|
+
return {
|
|
99
|
+
hookSpecificOutput: {
|
|
100
|
+
hookEventName: event,
|
|
101
|
+
additionalContext: promptContext(
|
|
102
|
+
opened.token,
|
|
103
|
+
compact,
|
|
104
|
+
!noCapture && Boolean(memorySession),
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
122
108
|
return;
|
|
123
109
|
}
|
|
124
110
|
if (CANCEL_EVENTS.has(event)) {
|
|
125
111
|
store.cancel(key, target, event === "SessionEnd" ? null : nativeTurn);
|
|
126
112
|
return;
|
|
127
113
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
Boolean(input.last_assistant_message.trim());
|
|
132
|
-
const token = store.stop(
|
|
133
|
-
key,
|
|
134
|
-
target,
|
|
135
|
-
nativeTurn,
|
|
136
|
-
!readOnly && normalStop,
|
|
137
|
-
input.stop_hook_active === true,
|
|
138
|
-
);
|
|
139
|
-
if (!token) return;
|
|
140
|
-
return { decision: "block", reason: captureContext(token) };
|
|
114
|
+
// A sibling Stop hook can continue the task. This event only closes
|
|
115
|
+
// eligibility for the current turn and never dispatches a hosted write.
|
|
116
|
+
store.stop(key, target, nativeTurn);
|
|
141
117
|
}
|
package/project-memory/local.mjs
CHANGED
|
@@ -53,12 +53,19 @@ function gitProcess(cwd, args) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export function git(cwd, ...command) {
|
|
56
|
+
return gitBefore(cwd, Infinity, ...command);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function gitBefore(cwd, deadline, ...command) {
|
|
56
60
|
const { args, ...options } = gitProcess(cwd, command);
|
|
61
|
+
const timeout = Math.min(GIT_TIMEOUT_MS, Math.floor(deadline - performance.now()));
|
|
62
|
+
if (timeout < 1) throw new Error("Project memory Git deadline expired.");
|
|
57
63
|
return execFileSync(GIT, args, {
|
|
58
64
|
...options,
|
|
59
65
|
encoding: "utf8",
|
|
60
66
|
maxBuffer: 8192,
|
|
61
|
-
timeout
|
|
67
|
+
timeout,
|
|
68
|
+
killSignal: "SIGKILL",
|
|
62
69
|
}).trim();
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -118,11 +125,11 @@ export async function visibleGitIndex(cwd, path) {
|
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
/** Accept only an unambiguous GitHub origin, independent of the branch name. */
|
|
121
|
-
export function checkout(cwd) {
|
|
128
|
+
export function checkout(cwd, deadline = Infinity) {
|
|
122
129
|
if (typeof cwd !== "string" || !isAbsolute(cwd)) return;
|
|
123
130
|
try {
|
|
124
|
-
const root = realpathSync(
|
|
125
|
-
const origin =
|
|
131
|
+
const root = realpathSync(gitBefore(cwd, deadline, "rev-parse", "--show-toplevel"));
|
|
132
|
+
const origin = gitBefore(root, deadline, "config", "--local", "--get-all", "remote.origin.url");
|
|
126
133
|
const repo = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^\n]+)$/i
|
|
127
134
|
.exec(origin)?.[1]
|
|
128
135
|
.replace(/\.git$/i, "");
|
|
@@ -291,7 +298,7 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
291
298
|
return (
|
|
292
299
|
state?.root === target.root &&
|
|
293
300
|
state.repo.toLowerCase() === target.repo.toLowerCase() &&
|
|
294
|
-
|
|
301
|
+
state.runtime === target.runtime
|
|
295
302
|
);
|
|
296
303
|
}
|
|
297
304
|
function withTurn(token, target, fn) {
|
|
@@ -302,8 +309,25 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
302
309
|
if (sameCheckout(state, target) && state.token === token) return fn(key, state);
|
|
303
310
|
});
|
|
304
311
|
}
|
|
312
|
+
function close(key, state) {
|
|
313
|
+
if (!state.nativeTurn) return remove(key, state);
|
|
314
|
+
if (TOKEN.test(state.token)) rmSync(indexPath(state.token), { force: true });
|
|
315
|
+
put(key, { ...state, token: undefined, phase: "closed" });
|
|
316
|
+
}
|
|
317
|
+
function saveReceipt(token, target, outcome, record) {
|
|
318
|
+
if (!TOKEN.test(token)) return;
|
|
319
|
+
atomicWrite(
|
|
320
|
+
receiptPath(token),
|
|
321
|
+
JSON.stringify({
|
|
322
|
+
...target,
|
|
323
|
+
outcome,
|
|
324
|
+
record: record && { id: record.id, status: record.status },
|
|
325
|
+
expires: now() + TURN_TTL,
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
305
329
|
return {
|
|
306
|
-
begin(key, target, client, nativeTurn, { skip, readOnly }) {
|
|
330
|
+
begin(key, target, client, nativeTurn, { skip, readOnly, memorySession }) {
|
|
307
331
|
return lock(
|
|
308
332
|
key,
|
|
309
333
|
(previous) => {
|
|
@@ -311,7 +335,7 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
311
335
|
if (nativeTurn && previous.nativeTurn === nativeTurn) {
|
|
312
336
|
// Duplicate native events belong to the same task. Keep its
|
|
313
337
|
// token unless the user expressly opted out of this turn.
|
|
314
|
-
if (skip)
|
|
338
|
+
if (skip) close(key, previous);
|
|
315
339
|
return;
|
|
316
340
|
}
|
|
317
341
|
if (nativeTurn) {
|
|
@@ -334,6 +358,7 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
334
358
|
expires: now() + TURN_TTL,
|
|
335
359
|
phase: "pending",
|
|
336
360
|
readOnly,
|
|
361
|
+
memorySession,
|
|
337
362
|
};
|
|
338
363
|
atomicWrite(indexPath(token), JSON.stringify(key));
|
|
339
364
|
put(key, state);
|
|
@@ -349,52 +374,50 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
349
374
|
},
|
|
350
375
|
);
|
|
351
376
|
},
|
|
352
|
-
|
|
353
|
-
return
|
|
377
|
+
startCapture(token, target, memorySession, send) {
|
|
378
|
+
return withTurn(token, target, (key, state) => {
|
|
379
|
+
if (
|
|
380
|
+
state.phase !== "recalled" ||
|
|
381
|
+
state.readOnly ||
|
|
382
|
+
!memorySession ||
|
|
383
|
+
state.memorySession !== memorySession
|
|
384
|
+
)
|
|
385
|
+
return;
|
|
386
|
+
put(key, { ...state, phase: "capturing" });
|
|
387
|
+
saveReceipt(token, target, "uncertain");
|
|
388
|
+
if (readJson(overlapPath(key))?.expires > now()) {
|
|
389
|
+
rmSync(receiptPath(token), { force: true });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// Begin dispatch under the turn lease. Await the response outside it.
|
|
393
|
+
return send({ repo: state.repo, client: state.client, operationId: state.operationId });
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
stop(key, target, nativeTurn) {
|
|
397
|
+
lock(
|
|
354
398
|
key,
|
|
355
|
-
(state
|
|
356
|
-
if (!sameCheckout(state, target)) return;
|
|
357
|
-
if (fenced) {
|
|
358
|
-
if (state.nativeTurn === nativeTurn) remove(key, state);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
399
|
+
(state) => {
|
|
361
400
|
if (
|
|
362
|
-
|
|
363
|
-
state.nativeTurn
|
|
364
|
-
|
|
401
|
+
sameCheckout(state, target) &&
|
|
402
|
+
state.nativeTurn === nativeTurn &&
|
|
403
|
+
(state.phase !== "ambiguous" || nativeTurn)
|
|
365
404
|
)
|
|
366
|
-
|
|
367
|
-
if (state.phase === "ambiguous" && !nativeTurn) return;
|
|
368
|
-
if (!mayNudge || state.readOnly || !["recalled", "expanding"].includes(state.phase)) {
|
|
369
|
-
remove(key, state);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
put(key, { ...state, phase: "nudged" });
|
|
373
|
-
return state.token;
|
|
405
|
+
close(key, state);
|
|
374
406
|
},
|
|
375
407
|
{
|
|
408
|
+
onBusy: () => fence(key, nativeTurn),
|
|
376
409
|
canClearFence: (entry) => Boolean(nativeTurn && nativeTurn === entry.nativeTurn),
|
|
377
410
|
},
|
|
378
411
|
);
|
|
379
412
|
},
|
|
380
|
-
registerContinuation(key, target, nativeTurn, token) {
|
|
381
|
-
lock(key, (state) => {
|
|
382
|
-
if (sameCheckout(state, target) && state.token === token && state.phase === "nudged") {
|
|
383
|
-
put(key, { ...state, continuationTurn: nativeTurn });
|
|
384
|
-
}
|
|
385
|
-
});
|
|
386
|
-
},
|
|
387
413
|
cancel(key, target, nativeTurn) {
|
|
388
414
|
lock(
|
|
389
415
|
key,
|
|
390
416
|
(state) => {
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
(
|
|
394
|
-
|
|
395
|
-
state.continuationTurn === nativeTurn)
|
|
396
|
-
)
|
|
397
|
-
remove(key, state);
|
|
417
|
+
if (sameCheckout(state, target) && (!nativeTurn || state.nativeTurn === nativeTurn)) {
|
|
418
|
+
if (nativeTurn) close(key, state);
|
|
419
|
+
else remove(key, state);
|
|
420
|
+
}
|
|
398
421
|
},
|
|
399
422
|
{
|
|
400
423
|
onBusy: () => fence(key, nativeTurn),
|
|
@@ -438,17 +461,7 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
438
461
|
return true;
|
|
439
462
|
});
|
|
440
463
|
},
|
|
441
|
-
saveReceipt
|
|
442
|
-
if (!TOKEN.test(token)) return;
|
|
443
|
-
atomicWrite(
|
|
444
|
-
receiptPath(token),
|
|
445
|
-
JSON.stringify({
|
|
446
|
-
...target,
|
|
447
|
-
record: { id: record.id, status: record.status },
|
|
448
|
-
expires: now() + TURN_TTL,
|
|
449
|
-
}),
|
|
450
|
-
);
|
|
451
|
-
},
|
|
464
|
+
saveReceipt,
|
|
452
465
|
readReceipt(token, target) {
|
|
453
466
|
if (!TOKEN.test(token)) return;
|
|
454
467
|
const path = receiptPath(token);
|
|
@@ -457,7 +470,11 @@ export function turnStore(home = homedir(), now = Date.now) {
|
|
|
457
470
|
rmSync(path, { force: true });
|
|
458
471
|
return;
|
|
459
472
|
}
|
|
460
|
-
if (sameCheckout(receipt, target))
|
|
473
|
+
if (sameCheckout(receipt, target))
|
|
474
|
+
return {
|
|
475
|
+
outcome: receipt.outcome ?? "confirmed",
|
|
476
|
+
...(receipt.record && { record: receipt.record }),
|
|
477
|
+
};
|
|
461
478
|
},
|
|
462
479
|
};
|
|
463
480
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
export const PROJECT_MEMORY_ORIGIN = "https://mcp.pharaoh.so";
|
|
2
2
|
const READY_TIMEOUT_MS = 1_200;
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 12_000;
|
|
3
4
|
const BUILD = new URL("runtime/refresh.js", import.meta.url);
|
|
4
5
|
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
|
5
6
|
const STATUS = new Set(["candidate", "approved", "superseded", "retracted"]);
|
|
6
7
|
|
|
7
|
-
export class PilotError extends Error {
|
|
8
|
+
export class PilotError extends Error {
|
|
9
|
+
constructor(message, rejected = false) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.rejected = rejected;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
async function responseJson(response) {
|
|
10
16
|
if (!response.body) throw new PilotError("Pharaoh returned an incomplete response.");
|
|
@@ -32,9 +38,11 @@ export async function refreshHelper() {
|
|
|
32
38
|
|
|
33
39
|
/** Check only mapping metadata using the current device session. Normal proxy
|
|
34
40
|
* startup handles token refresh; the prompt hook never waits on a refresh lock. */
|
|
35
|
-
export async function requestMemoryReadiness(repo, credentials) {
|
|
41
|
+
export async function requestMemoryReadiness(repo, credentials, deadline = Infinity) {
|
|
36
42
|
const expiresAt = Date.parse(credentials?.expires_at);
|
|
37
43
|
if (typeof credentials?.access_token !== "string" || !(expiresAt > Date.now())) return;
|
|
44
|
+
const timeout = Math.min(READY_TIMEOUT_MS, Math.floor(deadline - performance.now()));
|
|
45
|
+
if (timeout < 1) return;
|
|
38
46
|
try {
|
|
39
47
|
if (new URL(credentials.sse_url).origin !== PROJECT_MEMORY_ORIGIN) return;
|
|
40
48
|
const response = await fetch(`${PROJECT_MEMORY_ORIGIN}/api/project-memory/readiness`, {
|
|
@@ -45,7 +53,7 @@ export async function requestMemoryReadiness(repo, credentials) {
|
|
|
45
53
|
Authorization: `Bearer ${credentials.access_token}`,
|
|
46
54
|
},
|
|
47
55
|
body: JSON.stringify({ repo }),
|
|
48
|
-
signal: AbortSignal.timeout(
|
|
56
|
+
signal: AbortSignal.timeout(timeout),
|
|
49
57
|
});
|
|
50
58
|
if (!response.ok) {
|
|
51
59
|
await response.body?.cancel();
|
|
@@ -58,61 +66,71 @@ export async function requestMemoryReadiness(repo, credentials) {
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
export async function requestMemory(
|
|
69
|
+
export async function requestMemory(
|
|
70
|
+
operation,
|
|
71
|
+
payload,
|
|
72
|
+
{ fetcher = fetch, helper, credentials } = {},
|
|
73
|
+
) {
|
|
62
74
|
if (!["recall", "capture", "expand"].includes(operation))
|
|
63
75
|
throw new PilotError("Unknown memory request.");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
// Capture supplies its session snapshot so dispatch cannot adopt a different sign-in.
|
|
77
|
+
if (!credentials) {
|
|
78
|
+
const { originBoundRefresh } = helper ?? (await refreshHelper());
|
|
79
|
+
const outcome = await originBoundRefresh(PROJECT_MEMORY_ORIGIN).load();
|
|
80
|
+
if (outcome?.ok) credentials = outcome.creds;
|
|
81
|
+
}
|
|
82
|
+
if (!credentials)
|
|
68
83
|
throw new PilotError(
|
|
69
84
|
"Connect to Pharaoh with its device sign-in, or retry if sign-in is temporarily unavailable.",
|
|
70
85
|
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
};
|
|
91
|
-
const response = await send(outcome.creds);
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await fetcher(`${PROJECT_MEMORY_ORIGIN}/api/project-memory/${operation}`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
redirect: "error",
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
Authorization: `Bearer ${credentials.access_token}`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify(payload),
|
|
96
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
throw new PilotError(
|
|
100
|
+
operation === "capture"
|
|
101
|
+
? "Pharaoh did not confirm whether this capture was saved. Check the project memory inspector before retrying."
|
|
102
|
+
: "Pharaoh project memory could not respond before the request ended.",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
92
105
|
if (!response.ok) {
|
|
93
106
|
await response.body?.cancel();
|
|
94
107
|
// The shared credential file can now name a different OAuth session. Do
|
|
95
108
|
// not reuse an already prepared operation under newly loaded credentials.
|
|
96
109
|
if (response.status === 401)
|
|
97
|
-
throw new PilotError("Pharaoh rejected this sign-in. Reconnect before trying again.");
|
|
110
|
+
throw new PilotError("Pharaoh rejected this sign-in. Reconnect before trying again.", true);
|
|
98
111
|
if (response.status === 404)
|
|
99
|
-
throw new PilotError("Project memory is unavailable for this repository or account.");
|
|
112
|
+
throw new PilotError("Project memory is unavailable for this repository or account.", true);
|
|
100
113
|
if (response.status === 400 || response.status === 413)
|
|
101
114
|
throw new PilotError(
|
|
102
115
|
"Pharaoh rejected the memory payload. Check its shape, size, and sensitive content.",
|
|
116
|
+
true,
|
|
103
117
|
);
|
|
104
118
|
if (response.status === 409)
|
|
105
119
|
throw new PilotError(
|
|
106
120
|
"Pharaoh could not save this candidate because the operation conflicts or the project log is full.",
|
|
121
|
+
true,
|
|
107
122
|
);
|
|
108
123
|
if (response.status === 429)
|
|
109
|
-
throw new PilotError("Pharaoh project memory reached its request limit.");
|
|
124
|
+
throw new PilotError("Pharaoh project memory reached its request limit.", true);
|
|
110
125
|
if (operation === "capture" && response.status >= 500) {
|
|
111
126
|
throw new PilotError(
|
|
112
127
|
"Pharaoh did not confirm whether this capture was saved. Check the project memory inspector before retrying.",
|
|
113
128
|
);
|
|
114
129
|
}
|
|
115
|
-
throw new PilotError(
|
|
130
|
+
throw new PilotError(
|
|
131
|
+
"Pharaoh project memory is temporarily unavailable.",
|
|
132
|
+
response.status >= 400 && response.status < 500,
|
|
133
|
+
);
|
|
116
134
|
}
|
|
117
135
|
let result;
|
|
118
136
|
try {
|
|
@@ -1,33 +1,47 @@
|
|
|
1
1
|
import { dirname } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { handleEvent } from "./hook-events.mjs";
|
|
4
|
-
import { readStdin } from "./local.mjs";
|
|
4
|
+
import { checkout, readStdin } from "./local.mjs";
|
|
5
5
|
import {
|
|
6
6
|
activeMemoryRuntime,
|
|
7
7
|
readMemoryCredentials,
|
|
8
8
|
resolveMemoryHookTarget,
|
|
9
9
|
} from "./user-target.mjs";
|
|
10
10
|
|
|
11
|
+
// Leave margin inside the client's three-second hook timeout. The process
|
|
12
|
+
// clock includes startup and imports, and no late network request can start.
|
|
13
|
+
const deadline = 2_400;
|
|
14
|
+
const exitQuietly = setTimeout(() => process.exit(0), Math.max(0, deadline - performance.now()));
|
|
11
15
|
try {
|
|
12
16
|
const [client, root, repo] = process.argv.slice(2);
|
|
13
17
|
const input = await readStdin(1024 * 1024);
|
|
18
|
+
const credentials = await readMemoryCredentials();
|
|
14
19
|
let target = { root, repo };
|
|
15
20
|
if (root === "--user") {
|
|
16
21
|
const runtime = dirname(fileURLToPath(import.meta.url));
|
|
17
22
|
if (!activeMemoryRuntime(client, runtime)) process.exit(0);
|
|
23
|
+
const isPrompt = input?.hook_event_name === "UserPromptSubmit";
|
|
18
24
|
const eligible = await resolveMemoryHookTarget(
|
|
19
25
|
client,
|
|
20
26
|
input?.cwd,
|
|
21
|
-
|
|
22
|
-
input?.
|
|
23
|
-
|
|
24
|
-
Boolean(input.prompt.trim()),
|
|
27
|
+
credentials,
|
|
28
|
+
isPrompt && typeof input?.prompt === "string" && Boolean(input.prompt.trim()),
|
|
29
|
+
deadline,
|
|
25
30
|
);
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
// Cleanup must still close the turn after sign-out or loss of eligibility.
|
|
32
|
+
const resolved = eligible ?? (!isPrompt && checkout(input?.cwd, deadline));
|
|
33
|
+
if (!resolved) process.exit(0);
|
|
34
|
+
target = { ...resolved, runtime };
|
|
28
35
|
}
|
|
29
|
-
const result = handleEvent(input, {
|
|
36
|
+
const result = handleEvent(input, {
|
|
37
|
+
client,
|
|
38
|
+
...target,
|
|
39
|
+
credentials,
|
|
40
|
+
deadline,
|
|
41
|
+
});
|
|
30
42
|
if (result) process.stdout.write(JSON.stringify(result));
|
|
31
43
|
} catch {
|
|
32
|
-
//
|
|
44
|
+
// Project memory never blocks ordinary work or opens a continuation.
|
|
45
|
+
} finally {
|
|
46
|
+
clearTimeout(exitQuietly);
|
|
33
47
|
}
|
|
@@ -21,26 +21,31 @@ async function main(args) {
|
|
|
21
21
|
!/^[a-f0-9]{64}$/.test(token) ||
|
|
22
22
|
extra.length > 0 ||
|
|
23
23
|
(compactFlag !== undefined && (compactFlag !== "--compact" || operation !== "recall")) ||
|
|
24
|
-
!["recall", "expand", "capture", "
|
|
24
|
+
!["recall", "expand", "capture", "receipt"].includes(operation)
|
|
25
25
|
) {
|
|
26
26
|
throw new PilotError(
|
|
27
|
-
"Use doctor, or recall, expand, capture,
|
|
27
|
+
"Use doctor, or recall, expand, capture, or receipt with the active hook's --turn token.",
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
const cwd = process.cwd();
|
|
31
31
|
const target = checkout(cwd);
|
|
32
32
|
if (!target) throw new PilotError("Run this inside the enrolled GitHub checkout.");
|
|
33
33
|
const runtime = dirname(fileURLToPath(import.meta.url));
|
|
34
|
-
const
|
|
34
|
+
const isActive = () =>
|
|
35
35
|
activeMemoryRuntime("claude-code", runtime) || activeMemoryRuntime("codex", runtime);
|
|
36
|
-
const
|
|
36
|
+
const credentials = await readMemoryCredentials();
|
|
37
|
+
const eligible = isActive() && (await cachedMemoryCheckout(cwd, credentials));
|
|
37
38
|
const result = await executeTurn(operation, token, {
|
|
38
39
|
target: eligible ? { ...target, runtime } : target,
|
|
39
40
|
store: turnStore(),
|
|
40
41
|
read: () => readStdin(8_192),
|
|
41
|
-
compact: compactFlag === "--compact" || process.env.PHARAOH_PROJECT_MEMORY_COMPACT
|
|
42
|
+
compact: compactFlag === "--compact" || process.env.PHARAOH_PROJECT_MEMORY_COMPACT !== "false",
|
|
43
|
+
loadCaptureCredentials: async () => {
|
|
44
|
+
const current = await readMemoryCredentials();
|
|
45
|
+
if (eligible && (await cachedMemoryCheckout(cwd, current)) && isActive()) return current;
|
|
46
|
+
},
|
|
42
47
|
});
|
|
43
|
-
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
48
|
+
if (result !== undefined) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
try {
|
|
@@ -3,7 +3,7 @@ import { lstatSync, readFileSync, realpathSync, rmSync, writeFileSync } from "no
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { hasLegacyHooks } from "./hook-config.mjs";
|
|
6
|
-
import { atomicWrite, checkout,
|
|
6
|
+
import { atomicWrite, checkout, gitBefore } from "./local.mjs";
|
|
7
7
|
import { PROJECT_MEMORY_ORIGIN, requestMemoryReadiness } from "./memory-api.mjs";
|
|
8
8
|
|
|
9
9
|
const REVISION = /^(?!0{40}$)[a-f0-9]{40}$/;
|
|
@@ -27,6 +27,12 @@ export function projectMemoryHint(credentials) {
|
|
|
27
27
|
return credentials.project_memory;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export function projectMemorySession(credentials) {
|
|
31
|
+
const session = credentials?.project_memory_session;
|
|
32
|
+
if (typeof session === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(session))
|
|
33
|
+
return session;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
function matchingHint(target, hints) {
|
|
31
37
|
return hints.find(
|
|
32
38
|
(entry) =>
|
|
@@ -34,12 +40,12 @@ function matchingHint(target, hints) {
|
|
|
34
40
|
);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
function hasMappedCommit(target, revision) {
|
|
43
|
+
function hasMappedCommit(target, revision, deadline = Infinity) {
|
|
38
44
|
if (typeof revision !== "string" || !REVISION.test(revision)) return false;
|
|
39
45
|
try {
|
|
40
46
|
// An origin URL is editable. Require an actual GitHub-mapped commit
|
|
41
47
|
// locally, without fetching, consulting replace refs, or running hooks.
|
|
42
|
-
return
|
|
48
|
+
return gitBefore(target.root, deadline, "cat-file", "-t", revision) === "commit";
|
|
43
49
|
} catch {
|
|
44
50
|
return false;
|
|
45
51
|
}
|
|
@@ -95,9 +101,9 @@ function tryReadinessLease(path, now) {
|
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
async function resolveRevision(repo, credentials, probe) {
|
|
99
|
-
const session = credentials
|
|
100
|
-
if (
|
|
104
|
+
async function resolveRevision(repo, credentials, probe, deadline) {
|
|
105
|
+
const session = projectMemorySession(credentials);
|
|
106
|
+
if (!session) return;
|
|
101
107
|
const binding = hash(session);
|
|
102
108
|
const path = join(memoryClientRoot(), `ready-${hash(repo.toLowerCase())}.json`);
|
|
103
109
|
const saved = readReadiness(path, binding);
|
|
@@ -112,7 +118,7 @@ async function resolveRevision(repo, credentials, probe) {
|
|
|
112
118
|
return latest.revision;
|
|
113
119
|
if (now - (latest?.checkedAt ?? 0) < READINESS_RETRY_MS) return;
|
|
114
120
|
atomicWrite(path, JSON.stringify({ binding, checkedAt: now }));
|
|
115
|
-
const revision = await requestMemoryReadiness(repo, credentials);
|
|
121
|
+
const revision = await requestMemoryReadiness(repo, credentials, deadline);
|
|
116
122
|
if (typeof revision === "string" && REVISION.test(revision)) {
|
|
117
123
|
atomicWrite(path, JSON.stringify({ binding, checkedAt: now, revision }));
|
|
118
124
|
return revision;
|
|
@@ -124,10 +130,12 @@ async function resolveRevision(repo, credentials, probe) {
|
|
|
124
130
|
}
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
async function resolveMappedCheckout(target, credentials, hint, probe) {
|
|
133
|
+
async function resolveMappedCheckout(target, credentials, hint, probe, deadline = Infinity) {
|
|
128
134
|
const revision =
|
|
129
|
-
hint.revision === null
|
|
130
|
-
|
|
135
|
+
hint.revision === null
|
|
136
|
+
? await resolveRevision(hint.repo, credentials, probe, deadline)
|
|
137
|
+
: hint.revision;
|
|
138
|
+
if (hasMappedCommit(target, revision, deadline)) return target;
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
export async function cachedMemoryCheckout(cwd, credentials) {
|
|
@@ -150,11 +158,17 @@ export function readHookSettings(path) {
|
|
|
150
158
|
}
|
|
151
159
|
}
|
|
152
160
|
|
|
153
|
-
export async function resolveMemoryHookTarget(
|
|
161
|
+
export async function resolveMemoryHookTarget(
|
|
162
|
+
client,
|
|
163
|
+
cwd,
|
|
164
|
+
credentials,
|
|
165
|
+
probe = false,
|
|
166
|
+
deadline = Infinity,
|
|
167
|
+
) {
|
|
154
168
|
if (!Object.hasOwn(LEGACY_SETTINGS, client)) return;
|
|
155
169
|
const hints = projectMemoryHint(credentials);
|
|
156
170
|
if (!hints.length) return;
|
|
157
|
-
const target = checkout(cwd);
|
|
171
|
+
const target = checkout(cwd, deadline);
|
|
158
172
|
const hint = target && matchingHint(target, hints);
|
|
159
173
|
if (!hint) return;
|
|
160
174
|
try {
|
|
@@ -168,7 +182,7 @@ export async function resolveMemoryHookTarget(client, cwd, credentials, probe =
|
|
|
168
182
|
// A client settings file we cannot inspect is not safe to double-enroll.
|
|
169
183
|
return;
|
|
170
184
|
}
|
|
171
|
-
return resolveMappedCheckout(target, credentials, hint, probe);
|
|
185
|
+
return resolveMappedCheckout(target, credentials, hint, probe, deadline);
|
|
172
186
|
}
|
|
173
187
|
|
|
174
188
|
export function memoryClientRoot(home = homedir()) {
|