prism-mcp-server 20.17.2 → 20.18.0

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.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * prism-route — self-installing UserPromptSubmit hook for Claude Code + Codex.
2
+ * prism-route — self-installing UserPromptSubmit + SessionStart(compact) hook
3
+ * for Claude Code + Codex.
3
4
  *
4
5
  * WHY A HOST HOOK. An MCP server never sees the user's prompt; the protocol
5
6
  * carries only what a tool call carries. session_route_prompt (the MCP tool)
@@ -8,6 +9,17 @@
8
9
  * prompt regardless of model behaviour, on both hosts, which is what the
9
10
  * operator requires ("i need automatic").
10
11
  *
12
+ * WHY A SECOND EVENT. Compaction discards the bootstrap with the rest of the
13
+ * transcript, and nothing the MCP server can do brings it back — the server
14
+ * is never told a compaction happened. Both hosts run SessionStart hooks
15
+ * with source "compact" before the next model request (Claude Code:
16
+ * matcher "compact"; Codex hooks reference, verified 2026-09-01: "SessionStart
17
+ * hooks that match source: compact run before the next model request"). The
18
+ * same script answers that event with `prism floor-digest` — the protected
19
+ * floor, one line per rule — so the model does not spend the rest of the
20
+ * session re-reading SKILL.md files it can no longer see. Once per
21
+ * compaction, zero per-prompt cost.
22
+ *
11
23
  * WHY SELF-INSTALLING. The previous generation of prism hooks was provisioned
12
24
  * by a bootstrap script once, then hand-maintained per machine — which is why
13
25
  * this machine has them and the other team machines do not. This module is
@@ -28,7 +40,11 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
28
40
  import { homedir } from "node:os";
29
41
  import { dirname, join, resolve } from "node:path";
30
42
  /** Bump to force the on-disk script to be rewritten on the next ensure. */
31
- export const PROMPT_ROUTE_HOOK_VERSION = "3";
43
+ export const PROMPT_ROUTE_HOOK_VERSION = "4";
44
+ /** SessionStart matcher: only the post-compaction fire carries the digest.
45
+ * The script re-checks `source` itself, so a host that ignores matchers on
46
+ * this event still injects nothing at startup/resume. */
47
+ const SESSION_START_MATCHER = "compact";
32
48
  const MARKER_FILE = ".prism-managed.json";
33
49
  const SCRIPT_FILE = "on_prompt.py";
34
50
  const HOOK_DIR = "prism-route";
@@ -66,6 +82,9 @@ export const PROMPT_ROUTE_HOOK_SCRIPT = `#!/usr/bin/env python3
66
82
 
67
83
  Routes every user prompt through the on-device skill matcher via
68
84
  'prism route-prompt'. Injects newly matched skill bodies as context.
85
+ On SessionStart with source "compact" (the host just discarded the
86
+ transcript, bootstrap included) it re-injects the protected-floor digest
87
+ via 'prism floor-digest' instead.
69
88
  Managed by prism; edits are overwritten on version bumps.
70
89
  """
71
90
  import json
@@ -76,16 +95,106 @@ import subprocess
76
95
  import sys
77
96
 
78
97
 
79
- def emit(extra=None):
98
+ def emit(extra=None, event="UserPromptSubmit"):
80
99
  out = {"continue": True, "suppressOutput": True}
81
100
  if extra:
82
101
  out["hookSpecificOutput"] = {
83
- "hookEventName": "UserPromptSubmit",
102
+ "hookEventName": event,
84
103
  "additionalContext": extra,
85
104
  }
86
105
  print(json.dumps(out))
87
106
 
88
107
 
108
+ def run_cli_json(cli, args, stdin_text=""):
109
+ """Run a prism CLI subcommand; return its last JSON stdout line or None."""
110
+ try:
111
+ result = subprocess.run(
112
+ [cli] + args,
113
+ input=stdin_text,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=10,
117
+ )
118
+ except Exception:
119
+ return None
120
+ if result.returncode != 0:
121
+ return None
122
+ # Parse the LAST line that is JSON: wrappers hooked into node via
123
+ # NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
124
+ # CLI's own output, and one polluted line must not kill routing.
125
+ for line in reversed(result.stdout.strip().splitlines()):
126
+ line = line.strip()
127
+ if line.startswith("{"):
128
+ try:
129
+ data = json.loads(line)
130
+ except Exception:
131
+ continue
132
+ return data if isinstance(data, dict) else None
133
+ return None
134
+
135
+
136
+ def session_state_path(payload):
137
+ session = str(
138
+ payload.get("session_id")
139
+ or payload.get("sessionId")
140
+ or payload.get("conversation_id")
141
+ or "default"
142
+ )
143
+ session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
144
+ state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
145
+ return state_dir, os.path.join(state_dir, session + ".json")
146
+
147
+
148
+ def payload_field(payload, *keys):
149
+ """First non-empty string among alternative spellings of one field.
150
+
151
+ Claude Code documents snake_case keys; Codex's payloads are described as
152
+ Claude-compatible, not Claude-identical, which is why the prompt lookup
153
+ below already hedges three ways. The SessionStart detection used to read
154
+ exactly one spelling, so a Codex payload spelled hookEventName/trigger
155
+ would fall to the prompt path and pass through with no digest and no
156
+ signal (round-11 review).
157
+ """
158
+ for key in keys:
159
+ value = payload.get(key)
160
+ if isinstance(value, str) and value.strip():
161
+ return value.strip()
162
+ return ""
163
+
164
+
165
+ def event_name(payload):
166
+ name = payload_field(payload, "hook_event_name", "hookEventName", "event_name", "eventName", "event")
167
+ return re.sub(r"[^a-z]", "", name.lower())
168
+
169
+
170
+ def on_session_start(payload):
171
+ # Only the post-compaction fire: at startup/resume the bootstrap carries
172
+ # the digest itself, and a second copy would be pure cost. "compact",
173
+ # "compaction", "compacted" all mean the transcript was just discarded.
174
+ source = payload_field(payload, "source", "trigger", "reason").lower()
175
+ if not source.startswith("compact"):
176
+ emit(event="SessionStart")
177
+ return
178
+ # The skills injected before compaction are gone with it; forget the
179
+ # dedupe list so the next matching prompt can bring them back.
180
+ state_dir, state_path = session_state_path(payload)
181
+ try:
182
+ os.remove(state_path)
183
+ except Exception:
184
+ pass
185
+ cli = find_cli()
186
+ if not cli:
187
+ emit(event="SessionStart")
188
+ return
189
+ data = run_cli_json(cli, ["floor-digest"])
190
+ text = (data or {}).get("text") or ""
191
+ names = [n for n in ((data or {}).get("names") or []) if isinstance(n, str)]
192
+ if not names or not isinstance(text, str) or not text:
193
+ emit(event="SessionStart")
194
+ return
195
+ emit(text, event="SessionStart")
196
+
197
+
89
198
  def find_cli():
90
199
  override = os.environ.get("PRISM_ROUTE_CLI")
91
200
  if override and os.path.exists(override):
@@ -111,6 +220,12 @@ def main():
111
220
  payload = json.loads(raw) if raw.strip() else {}
112
221
  except Exception:
113
222
  payload = {}
223
+ if not isinstance(payload, dict):
224
+ payload = {}
225
+
226
+ if event_name(payload) == "sessionstart":
227
+ on_session_start(payload)
228
+ return
114
229
 
115
230
  prompt = str(
116
231
  payload.get("prompt")
@@ -127,16 +242,7 @@ def main():
127
242
  # stretch, and the CLI caps identically on its side.
128
243
  prompt = prompt[:100_000]
129
244
 
130
- session = str(
131
- payload.get("session_id")
132
- or payload.get("sessionId")
133
- or payload.get("conversation_id")
134
- or "default"
135
- )
136
- session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
137
-
138
- state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
139
- state_path = os.path.join(state_dir, session + ".json")
245
+ state_dir, state_path = session_state_path(payload)
140
246
  loaded = []
141
247
  try:
142
248
  with open(state_path) as fh:
@@ -151,34 +257,8 @@ def main():
151
257
  emit()
152
258
  return
153
259
 
154
- try:
155
- result = subprocess.run(
156
- [cli, "route-prompt", "--loaded", ",".join(loaded)],
157
- input=prompt,
158
- capture_output=True,
159
- text=True,
160
- timeout=10,
161
- )
162
- except Exception:
163
- emit()
164
- return
165
- if result.returncode != 0:
166
- emit()
167
- return
168
-
169
- # Parse the LAST line that is JSON: wrappers hooked into node via
170
- # NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
171
- # CLI's own output, and one polluted line must not kill routing.
172
- data = None
173
- for line in reversed(result.stdout.strip().splitlines()):
174
- line = line.strip()
175
- if line.startswith("{"):
176
- try:
177
- data = json.loads(line)
178
- break
179
- except Exception:
180
- continue
181
- if not isinstance(data, dict):
260
+ data = run_cli_json(cli, ["route-prompt", "--loaded", ",".join(loaded)], prompt)
261
+ if data is None:
182
262
  emit()
183
263
  return
184
264
  names = [n for n in (data.get("names") or []) if isinstance(n, str)]
@@ -205,21 +285,10 @@ if __name__ == "__main__":
205
285
  print(json.dumps({"continue": True, "suppressOutput": True}))
206
286
  sys.exit(0)
207
287
  `;
208
- /** Evidence that this host was already prism-integrated by explicit action. */
209
- function hostShowsPriorConsent(spec, homeDir) {
210
- if (existsSync(join(spec.root, "hooks", HOOK_DIR, MARKER_FILE)))
211
- return true;
212
- const evidenceFiles = spec.host === "claude"
213
- ? [join(homeDir, ".claude.json"), spec.configPath]
214
- : [join(spec.root, "config.toml"), spec.configPath];
215
- for (const file of evidenceFiles) {
216
- try {
217
- if (/prism/i.test(readFileSync(file, "utf8")))
218
- return true;
219
- }
220
- catch { /* unreadable = no evidence */ }
221
- }
222
- return false;
288
+ /** A prior EXPLICIT install left our managed marker; only that authorizes
289
+ * the auto paths to touch this host again (refresh/upgrade). */
290
+ function hostHasManagedHook(spec) {
291
+ return existsSync(join(spec.root, "hooks", HOOK_DIR, MARKER_FILE));
223
292
  }
224
293
  function hostSpecs(homeDir, env) {
225
294
  const codexHome = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(homeDir, ".codex");
@@ -232,22 +301,37 @@ function hostSpecs(homeDir, env) {
232
301
  /**
233
302
  * Coarse Codex approval detection. Codex persists hook approvals as a
234
303
  * [hooks.state] table in config.toml keyed by definition hash; the hashing
235
- * algorithm is not public, so the only honest signals are "a state section
236
- * exists and mentions our hook path" (detected) or anything else
237
- * (pending-or-unknown). Never treat unknown as approved.
304
+ * algorithm is not public, so the only honest signal is the state section
305
+ * naming the EXACT command we register — `… --v<version>` — once per event
306
+ * (two hooks, two approvals). The version-agnostic path alone is not
307
+ * evidence: a v3 approval names the same script, and Codex will skip the
308
+ * v4 definition it never saw. Matching the bare path let a stale approval
309
+ * read as "detected" from the second `prism connect` after every hook
310
+ * bump (round-8 review). Never treat unknown as approved.
238
311
  */
239
- function detectCodexApproval(codexRoot) {
312
+ function detectCodexApproval(codexRoot, wantedCommand) {
240
313
  try {
241
- const toml = readFileSync(join(codexRoot, "config.toml"), "utf8");
242
- const hasState = /\[hooks\.state/.test(toml);
243
- if (hasState && toml.includes(COMMAND_SIGNATURE))
314
+ // A Windows path lands in a TOML basic string with its backslashes
315
+ // ESCAPED (`C:\\Users\\…`); one-for-one replacement turned that into
316
+ // `C://Users//…` and the approval never matched (round-10 review). A
317
+ // run of backslashes is one separator.
318
+ const toml = readFileSync(join(codexRoot, "config.toml"), "utf8").replace(/\\+/g, "/");
319
+ if (!/\[hooks\.state/.test(toml))
320
+ return "pending-or-unknown";
321
+ const wanted = wantedCommand.replace(/\\+/g, "/");
322
+ const exact = toml.split(wanted).length - 1;
323
+ if (exact >= 2)
244
324
  return "detected";
245
- // Approvals are keyed by definition hash (algorithm not public). Once ANY
246
- // trust state exists we cannot distinguish ours from here — and claiming
247
- // AWAITING TRUST after the operator pressed t would be a false alarm
248
- // against their own action. Distinct state, distinct wording.
249
- if (hasState)
250
- return "state-present-unverifiable";
325
+ // Our script is named, but not under the current definition: positive
326
+ // evidence that the trust on file is for an OLDER hook version, which
327
+ // Codex will not honour for this one.
328
+ if (exact === 0 && toml.includes(COMMAND_SIGNATURE))
329
+ return "pending-or-unknown";
330
+ // Trust state exists (one of our two entries, or hash-only entries that
331
+ // never name a command) and we cannot distinguish ours from here.
332
+ // Claiming AWAITING TRUST after the operator pressed t would be a false
333
+ // alarm against their own action. Distinct state, distinct wording.
334
+ return "state-present-unverifiable";
251
335
  }
252
336
  catch { /* unreadable = no evidence */ }
253
337
  return "pending-or-unknown";
@@ -299,60 +383,78 @@ function ensureRegistered(configPath, scriptPath, host) {
299
383
  // alone, not stripped).
300
384
  const wantsLimit = host === "codex";
301
385
  let config = {};
302
- let originalText;
303
- try {
304
- originalText = readFileSync(configPath, "utf8");
305
- const parsed = JSON.parse(originalText);
306
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
386
+ if (existsSync(configPath)) {
387
+ // An existing file we cannot parse is the operator's file with a syntax
388
+ // slip in it (model, env, permissions…) — "replace with {hooks}" would
389
+ // be a silent wipe. Register nothing; the caller reports it.
390
+ try {
391
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
392
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
393
+ return "skipped-unparseable";
307
394
  config = parsed;
308
395
  }
309
- }
310
- catch {
311
- /* missing or unreadable — create minimal */
396
+ catch {
397
+ return "skipped-unparseable";
398
+ }
312
399
  }
313
400
  const hooks = (config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks)
314
401
  ? config.hooks
315
402
  : {});
316
- const entries = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
317
403
  const wanted = hookCommand(scriptPath);
318
- let stale = false;
319
- for (const entry of entries) {
320
- if (!entry || typeof entry !== "object")
321
- continue;
322
- const inner = entry.hooks;
323
- if (!Array.isArray(inner))
324
- continue;
325
- for (const h of inner) {
326
- if (!h || typeof h !== "object")
404
+ let updated = false;
405
+ let registered = false;
406
+ // One script, two events. Each event is converged independently so a
407
+ // machine registered under an older release (UserPromptSubmit only) gains
408
+ // the SessionStart entry on refresh, and a hand-removed entry is not
409
+ // resurrected by the other event still being current — the disabled marker
410
+ // is the opt-out for both.
411
+ const converge = (event, matcher) => {
412
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
413
+ let found = false;
414
+ for (const entry of entries) {
415
+ if (!entry || typeof entry !== "object")
327
416
  continue;
328
- // Normalize separators: on Windows join() registers a backslash path,
329
- // and a forward-slash signature would never match — so every ensure
330
- // would re-register a duplicate entry.
331
- const command = String(h.command ?? "");
332
- if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
417
+ const inner = entry.hooks;
418
+ if (!Array.isArray(inner))
333
419
  continue;
334
- const limitCurrent = !wantsLimit || h.additionalContextLimit === 0;
335
- if (command === wanted && limitCurrent)
336
- return "unchanged";
337
- // Same hook, older definition: UPDATE it in place. This is what makes a
338
- // refresh visible to Codex's definition-hash — and on Claude it is a
339
- // harmless argv change.
340
- h.command = wanted;
341
- if (wantsLimit)
342
- h.additionalContextLimit = 0;
343
- stale = true;
420
+ for (const h of inner) {
421
+ if (!h || typeof h !== "object")
422
+ continue;
423
+ // Normalize separators: on Windows join() registers a backslash path,
424
+ // and a forward-slash signature would never match — so every ensure
425
+ // would re-register a duplicate entry.
426
+ const command = String(h.command ?? "");
427
+ if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
428
+ continue;
429
+ found = true;
430
+ const limitCurrent = !wantsLimit || h.additionalContextLimit === 0;
431
+ if (command === wanted && limitCurrent)
432
+ continue;
433
+ // Same hook, older definition: UPDATE it in place. This is what makes a
434
+ // refresh visible to Codex's definition-hash — and on Claude it is a
435
+ // harmless argv change.
436
+ h.command = wanted;
437
+ if (wantsLimit)
438
+ h.additionalContextLimit = 0;
439
+ updated = true;
440
+ }
344
441
  }
345
- }
346
- if (!stale) {
347
- entries.push({
348
- matcher: "*",
349
- hooks: [{ type: "command", command: wanted, timeout: 15, ...(wantsLimit ? { additionalContextLimit: 0 } : {}) }],
350
- });
351
- }
352
- hooks.UserPromptSubmit = entries;
442
+ if (!found) {
443
+ entries.push({
444
+ matcher,
445
+ hooks: [{ type: "command", command: wanted, timeout: 15, ...(wantsLimit ? { additionalContextLimit: 0 } : {}) }],
446
+ });
447
+ registered = true;
448
+ }
449
+ hooks[event] = entries;
450
+ };
451
+ converge("UserPromptSubmit", "*");
452
+ converge("SessionStart", SESSION_START_MATCHER);
453
+ if (!updated && !registered)
454
+ return "unchanged";
353
455
  config.hooks = hooks;
354
456
  writeAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
355
- return stale ? "updated" : "registered";
457
+ return registered ? "registered" : "updated";
356
458
  }
357
459
  /**
358
460
  * Idempotently install the prism-route hook for both hosts.
@@ -369,7 +471,7 @@ export function ensurePromptRouteHook(options = {}) {
369
471
  continue;
370
472
  if (onlyExisting && !existsSync(spec.root))
371
473
  continue;
372
- if ((options.mode ?? "explicit") === "auto" && !hostShowsPriorConsent(spec, homeDir))
474
+ if ((options.mode ?? "explicit") === "auto" && !hostHasManagedHook(spec))
373
475
  continue;
374
476
  try {
375
477
  const hookDir = join(spec.root, "hooks", HOOK_DIR);
@@ -382,7 +484,14 @@ export function ensurePromptRouteHook(options = {}) {
382
484
  // Never report a green "registered" as if it were active: Codex
383
485
  // SILENTLY SKIPS untrusted hooks, and "installed but inert" is the
384
486
  // exact failure class this feature exists to end.
385
- result.codexApproval = detectCodexApproval(spec.root);
487
+ //
488
+ // Approvals are keyed by definition hash, so a hooks.json we just
489
+ // rewrote (new entry, changed command) carries definitions the
490
+ // operator has never trusted — whatever config.toml says about the
491
+ // OLD ones. Only an untouched config can inherit prior trust.
492
+ result.codexApproval = config === "unchanged"
493
+ ? detectCodexApproval(spec.root, hookCommand(result.scriptPath))
494
+ : "pending-or-unknown";
386
495
  }
387
496
  results.push(result);
388
497
  }
package/dist/server.js CHANGED
@@ -265,9 +265,11 @@ let storageIsReady = false;
265
265
  // native host complies with the hook-free first-turn instruction.
266
266
  let contextLoadedByClient = false;
267
267
  // ─── prism-route hook self-heal ──────────────────────────────
268
- // The safety net of the three install paths (connect, postinstall, here):
269
- // covers installs that skipped npm scripts and machines that never re-ran
270
- // connect. Idempotent and version-marked, so the steady-state cost is two
268
+ // REFRESH-ONLY safety net: repairs/upgrades a hook that a prior EXPLICIT
269
+ // `prism connect` installed (managed marker present). It never performs a
270
+ // first install — a machine that merely installed this package (or the
271
+ // Claude Code plugin, which registers prism-mcp in host config) is not
272
+ // touched. Idempotent and version-marked, so the steady-state cost is two
271
273
  // stat calls a few seconds after boot. Opt out: PRISM_DISABLE_HOOK_AUTOINSTALL=1.
272
274
  // Guarded against test runners: five suites import this module, and a
273
275
  // module-scope timer would otherwise rewrite the DEVELOPER'S real host