prism-mcp-server 20.10.0 → 20.11.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.
package/dist/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync, mkdirSync, readdirSync, statSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
3
6
  import { SqliteStorage } from './storage/sqlite.js';
4
7
  import { handleVerifyStatus, handleGenerateHarness } from './verification/cliHandler.js';
5
8
  import * as path from 'path';
@@ -172,8 +175,31 @@ program
172
175
  .option('--all', 'Target all supported hosts instead of auto-detecting installed hosts')
173
176
  .option('--dry-run', 'Preview configuration changes without writing files')
174
177
  .option('--refresh', 'Refresh only entries previously created by Prism; custom entries stay untouched')
178
+ .option('--no-self-update', 'Skip the npm self-update check; configure with the currently installed version')
175
179
  .action(async (options) => {
176
180
  try {
181
+ // ── Converge the PACKAGE first, then the configs ──────────────
182
+ // connect is the one command the operator runs to make a machine
183
+ // current; leaving it configuring with stale code produced the
184
+ // "fresh hook, stale CLI" state observed live on 2026-08-13. After a
185
+ // successful update we RE-EXEC the new binary so the remainder of
186
+ // connect runs the code it just installed. Dry runs never update.
187
+ if (options.selfUpdate !== false && !options.dryRun) {
188
+ const { maybeSelfUpdate } = await import('./selfUpdate.js');
189
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
190
+ const upd = maybeSelfUpdate({ currentVersion: pkg.version, invokedFrom: process.argv[1], log: (l) => console.log(l) });
191
+ if (upd.action === 'updated') {
192
+ console.log(`✓ prism updated to ${upd.latest}; re-running connect with the new version`);
193
+ const rerun = spawnSync(process.execPath, [process.argv[1], 'connect', ...process.argv.slice(3), '--no-self-update'], { stdio: 'inherit' });
194
+ process.exit(rerun.status ?? 0);
195
+ }
196
+ else if (upd.action === 'failed') {
197
+ console.log(`⚠ self-update: ${upd.detail}`);
198
+ }
199
+ else if (upd.action === 'skipped') {
200
+ console.log(`− self-update skipped: ${upd.detail}`);
201
+ }
202
+ }
177
203
  if (!options.dryRun) {
178
204
  console.log('Close target MCP hosts before registration so they cannot edit configuration concurrently.');
179
205
  }
@@ -275,6 +301,43 @@ program
275
301
  if (skillSync.status !== 'disabled') {
276
302
  const changed = skillSync.installed.length + skillSync.updated.length + skillSync.pruned.length;
277
303
  console.log(`✓ Synalux skills: ${skillSync.tier || 'free'} tier (${changed} changed)`);
304
+ // prism-route hook: AFTER sync success on purpose — a connect that
305
+ // fails must leave the machine untouched (pinned by the
306
+ // "keeps legacy Claude hooks when the snapshot fails" test).
307
+ {
308
+ const hookHosts = [];
309
+ if (summary.results.some((r) => (r.host === 'claude-code' || r.host === 'claude-desktop') && r.status !== 'error'))
310
+ hookHosts.push('claude');
311
+ if (summary.results.some((r) => r.host === 'codex' && r.status !== 'error'))
312
+ hookHosts.push('codex');
313
+ if (hookHosts.length > 0) {
314
+ try {
315
+ const { ensurePromptRouteHook } = await import('./promptRouteHostHook.js');
316
+ for (const r of ensurePromptRouteHook({ hosts: hookHosts, mode: 'explicit' })) {
317
+ const state = r.script === 'unchanged' && r.config === 'unchanged' ? 'up to date' : 'installed';
318
+ if (r.host === 'codex' && r.codexApproval === 'pending-or-unknown') {
319
+ // Codex silently skips untrusted hooks — a green "installed"
320
+ // here would be the "configured and inert" lie.
321
+ console.log(`⚠ codex: prism-route hook ${state}, AWAITING TRUST — run codex, then /hooks, and trust the entry ending prism-route/on_prompt.py`);
322
+ }
323
+ else if (r.host === 'codex' && r.codexApproval === 'state-present-unverifiable') {
324
+ // Approvals are keyed by definition hash, whose algorithm is
325
+ // not public — once ANY trust state exists we cannot tell
326
+ // ours apart from here. Say exactly that; asserting AWAITING
327
+ // after the operator pressed t reads as "the trust didn't
328
+ // take", which is a false alarm against their own action.
329
+ console.log(`− codex: prism-route hook ${state}; trust state exists but is not verifiable from here — confirm once in /hooks`);
330
+ }
331
+ else {
332
+ console.log(`✓ ${r.host}: prism-route prompt hook ${state} (${r.scriptPath})`);
333
+ }
334
+ }
335
+ }
336
+ catch {
337
+ console.error('⚠ prism-route hook installation failed — skills still route at session start');
338
+ }
339
+ }
340
+ }
278
341
  if (skillSync.conflicts.length > 0) {
279
342
  console.error(`⚠ Preserved locally modified skill conflicts: ${skillSync.conflicts.join(', ')}`);
280
343
  }
@@ -380,6 +443,78 @@ program
380
443
  //
381
444
  // JSON MODE: Structured envelope for programmatic consumption
382
445
  // (session loader scripts, CI/CD pipelines, etc.).
446
+ // ── route-prompt ──────────────────────────────────────────────
447
+ // Called by the prism-route UserPromptSubmit hook on EVERY prompt in both
448
+ // Claude Code and Codex, so the contract is: always exit 0, always print one
449
+ // JSON object, and stay off the network (cached settings DB only). A hook
450
+ // that can fail a turn gets uninstalled; a hook that is slow gets noticed.
451
+ /** Deliberate offload for payloads over the host inline cap. The host's own
452
+ * overflow path swaps the payload for a 2KB preview with no instruction to
453
+ * read the rest; this file plus the inline pointer is the recoverable form. */
454
+ function writeRouteOffload(fullText) {
455
+ try {
456
+ const dir = path.join(homedir(), '.prism-mcp', 'route-context');
457
+ mkdirSync(dir, { recursive: true });
458
+ try {
459
+ // Best-effort prune: one file per over-budget routed prompt, kept a week.
460
+ for (const f of readdirSync(dir)) {
461
+ const p = path.join(dir, f);
462
+ try {
463
+ if (Date.now() - statSync(p).mtimeMs > 7 * 86_400_000)
464
+ rmSync(p);
465
+ }
466
+ catch { /* skip unstat-able entries */ }
467
+ }
468
+ }
469
+ catch { /* prune failure never blocks the write */ }
470
+ const target = path.join(dir, `route-${Date.now()}-${process.pid}.md`);
471
+ writeFileSync(target, fullText);
472
+ return target;
473
+ }
474
+ catch {
475
+ return undefined; // reshape degrades to the loud in-band fallback
476
+ }
477
+ }
478
+ program
479
+ .command('route-prompt')
480
+ .description('Match a prompt (stdin) against skill triggers; prints {names, text} JSON. Used by the prism-route host hook.')
481
+ .option('--loaded <names>', 'Comma-separated skill names already active in the session')
482
+ .action(async (options) => {
483
+ try {
484
+ const chunks = [];
485
+ for await (const chunk of process.stdin)
486
+ chunks.push(chunk);
487
+ // A pasted log can be megabytes; triggers live in the first human-sized
488
+ // stretch of a prompt, and unbounded input is regex food.
489
+ const prompt = Buffer.concat(chunks).toString('utf8').slice(0, 100_000);
490
+ const loaded = (options.loaded ?? '')
491
+ .split(',')
492
+ .map((n) => n.trim())
493
+ .filter(Boolean);
494
+ const { runPromptRouteFromCache } = await import('./tools/ledgerHandlers.js');
495
+ const { reshapeForInlineBudget, HOOK_INLINE_SAFE_CHARS } = await import('./tools/promptRouteHandler.js');
496
+ const result = await runPromptRouteFromCache(prompt, loaded);
497
+ // The hook path must fit the host's inline cap; the MCP tool path
498
+ // (session_route_prompt) keeps the full 30k — tool results inline far
499
+ // higher than hook context does.
500
+ const shaped = result.names.length > 0
501
+ ? reshapeForInlineBudget(result, HOOK_INLINE_SAFE_CHARS, writeRouteOffload)
502
+ : { text: '' };
503
+ const payload = JSON.stringify({ names: result.names, text: result.names.length > 0 ? shaped.text : '' });
504
+ await new Promise((resolveWrite) => process.stdout.write(payload + '\n', () => resolveWrite()));
505
+ }
506
+ catch {
507
+ // Never break the hook: an empty result is a routing miss, not an error.
508
+ await new Promise((resolveWrite) => process.stdout.write('{"names":[],"text":""}\n', () => resolveWrite()));
509
+ }
510
+ finally {
511
+ try {
512
+ await closeStorage();
513
+ }
514
+ catch { /* exit anyway */ }
515
+ process.exit(0);
516
+ }
517
+ });
383
518
  program
384
519
  .command('load <project>')
385
520
  .description('Load session context for a project (same output as session_load_context MCP tool)')
@@ -0,0 +1,30 @@
1
+ /**
2
+ * npm postinstall — the upgrade path for the prism-route hook.
3
+ *
4
+ * `prism connect` is only typed once per machine, so an upgrade that adds or
5
+ * fixes the hook would otherwise reach no one until they reconnect. Silent
6
+ * and always-exit-0: a hook installer must never break `npm install`.
7
+ */
8
+ import { ensurePromptRouteHook } from "./promptRouteHostHook.js";
9
+ try {
10
+ const results = ensurePromptRouteHook({ mode: "auto" });
11
+ if (process.env.PRISM_DEBUG) {
12
+ for (const r of results)
13
+ console.error(`[prism postinstall] ${r.host}: script=${r.script} config=${r.config}`);
14
+ }
15
+ // The ONE step install cannot do for the operator, said at the only moment
16
+ // they are certainly watching. Codex's hook-trust gate exists so software
17
+ // cannot approve its own execution — prism will never write that trust
18
+ // state (a compromised release would otherwise gain silent
19
+ // execute-on-every-prompt), so the honest maximum is to make the pending
20
+ // approval impossible to miss. Approval is per hook-version, not per
21
+ // release: it recurs only when the hook script itself changes.
22
+ const codex = results.find((r) => r.host === "codex");
23
+ if (codex && codex.codexApproval === "pending-or-unknown") {
24
+ console.error("\n[prism] Codex hook installed but NOT yet trusted — Codex silently skips it until you approve it once:\n" +
25
+ "[prism] codex -> /hooks -> entry ending prism-route/on_prompt.py -> press t\n");
26
+ }
27
+ }
28
+ catch {
29
+ /* never fail an install */
30
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * prism-route — self-installing UserPromptSubmit hook for Claude Code + Codex.
3
+ *
4
+ * WHY A HOST HOOK. An MCP server never sees the user's prompt; the protocol
5
+ * carries only what a tool call carries. session_route_prompt (the MCP tool)
6
+ * therefore depends on the model deciding to call it — near-automatic at
7
+ * best. A UserPromptSubmit hook is the only mechanism that fires on EVERY
8
+ * prompt regardless of model behaviour, on both hosts, which is what the
9
+ * operator requires ("i need automatic").
10
+ *
11
+ * WHY SELF-INSTALLING. The previous generation of prism hooks was provisioned
12
+ * by a bootstrap script once, then hand-maintained per machine — which is why
13
+ * this machine has them and the other team machines do not. This module is
14
+ * called from three places so no machine can miss it:
15
+ * 1. `prism connect` — the explicit path,
16
+ * 2. npm postinstall — the upgrade path,
17
+ * 3. MCP server startup — the safety net for installs that skip scripts.
18
+ * All three converge here and the operation is idempotent: same version →
19
+ * no writes; registered → not re-registered; other people's hooks untouched.
20
+ *
21
+ * WHY THE HOOK SHELLS OUT TO `prism route-prompt` instead of matching in
22
+ * Python: the trigger table, scoped-frontmatter triggers, entitlement and
23
+ * caps live in the TypeScript matcher. A Python reimplementation would drift,
24
+ * and a table that matches differently in the hook than in the server is
25
+ * worse than no hook at all.
26
+ */
27
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join, resolve } from "node:path";
30
+ /** Bump to force the on-disk script to be rewritten on the next ensure. */
31
+ export const PROMPT_ROUTE_HOOK_VERSION = "3";
32
+ const MARKER_FILE = ".prism-managed.json";
33
+ const SCRIPT_FILE = "on_prompt.py";
34
+ const HOOK_DIR = "prism-route";
35
+ /** Substring that identifies our entry inside a host hooks config. */
36
+ const COMMAND_SIGNATURE = `${HOOK_DIR}/${SCRIPT_FILE}`;
37
+ /**
38
+ * The command registered in the host config carries the version as an
39
+ * argument (the script ignores argv — it reads stdin). This is a SECURITY
40
+ * property, found by an external probe of Codex 0.146: Codex's hook-trust
41
+ * hash covers the CONFIGURED DEFINITION, not the file the command points at.
42
+ * With a stable command and a version-refreshed script, every prism upgrade
43
+ * would silently swap the executable content behind an already-trusted hash —
44
+ * exactly what the trust gate exists to prevent. Versioning the command
45
+ * changes the definition on every script change, forcing Codex to re-prompt.
46
+ * Cost: one approval per release, which is Codex's consent model working.
47
+ */
48
+ function hookCommand(scriptPath) {
49
+ return `python3 ${scriptPath} --v${PROMPT_ROUTE_HOOK_VERSION}`;
50
+ }
51
+ /**
52
+ * The hook script. Python because both hosts' existing hook fleets are
53
+ * Python and the runtime is guaranteed present on macOS.
54
+ *
55
+ * Contract notes:
56
+ * - stdin carries the host's JSON payload; `prompt` is the Claude Code key
57
+ * and the fallbacks cover Codex's Claude-compatible hook payloads.
58
+ * - It must NEVER fail the turn: every path ends in continue:true, and an
59
+ * unexpected exception exits 0 with a pass-through.
60
+ * - Per-session dedupe lives HERE (state/<session>.json), because the hook
61
+ * is the only party that knows what it already injected. `loaded` is
62
+ * passed to the CLI so the matcher never returns the same skill twice.
63
+ */
64
+ export const PROMPT_ROUTE_HOOK_SCRIPT = `#!/usr/bin/env python3
65
+ """Prism-managed hook (prism-route v${PROMPT_ROUTE_HOOK_VERSION}).
66
+
67
+ Routes every user prompt through the on-device skill matcher via
68
+ 'prism route-prompt'. Injects newly matched skill bodies as context.
69
+ Managed by prism; edits are overwritten on version bumps.
70
+ """
71
+ import json
72
+ import os
73
+ import re
74
+ import shutil
75
+ import subprocess
76
+ import sys
77
+
78
+
79
+ def emit(extra=None):
80
+ out = {"continue": True, "suppressOutput": True}
81
+ if extra:
82
+ out["hookSpecificOutput"] = {
83
+ "hookEventName": "UserPromptSubmit",
84
+ "additionalContext": extra,
85
+ }
86
+ print(json.dumps(out))
87
+
88
+
89
+ def find_cli():
90
+ override = os.environ.get("PRISM_ROUTE_CLI")
91
+ if override and os.path.exists(override):
92
+ return override
93
+ found = shutil.which("prism")
94
+ if found:
95
+ return found
96
+ home = os.path.expanduser("~")
97
+ for candidate in (
98
+ os.path.join(home, ".npm-global", "bin", "prism"),
99
+ "/opt/homebrew/bin/prism",
100
+ "/usr/local/bin/prism",
101
+ os.path.join(home, "bin", "prism"),
102
+ ):
103
+ if os.path.exists(candidate):
104
+ return candidate
105
+ return None
106
+
107
+
108
+ def main():
109
+ try:
110
+ raw = sys.stdin.read()
111
+ payload = json.loads(raw) if raw.strip() else {}
112
+ except Exception:
113
+ payload = {}
114
+
115
+ prompt = str(
116
+ payload.get("prompt")
117
+ or payload.get("message")
118
+ or payload.get("user_prompt")
119
+ or ""
120
+ ).strip()
121
+ # Slash commands and micro-prompts ("ok", "merge") never route; skipping
122
+ # them keeps the common turn free.
123
+ if len(prompt) < 6 or prompt.startswith("/"):
124
+ emit()
125
+ return
126
+ # A pasted log can be megabytes; triggers live in the first human-sized
127
+ # stretch, and the CLI caps identically on its side.
128
+ prompt = prompt[:100_000]
129
+
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")
140
+ loaded = []
141
+ try:
142
+ with open(state_path) as fh:
143
+ data = json.load(fh)
144
+ if isinstance(data, list):
145
+ loaded = [n for n in data if isinstance(n, str)]
146
+ except Exception:
147
+ pass
148
+
149
+ cli = find_cli()
150
+ if not cli:
151
+ emit()
152
+ return
153
+
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):
182
+ emit()
183
+ return
184
+ names = [n for n in (data.get("names") or []) if isinstance(n, str)]
185
+ text = data.get("text") or ""
186
+ if not names or not text:
187
+ emit()
188
+ return
189
+
190
+ try:
191
+ os.makedirs(state_dir, exist_ok=True)
192
+ merged = loaded + [n for n in names if n not in loaded]
193
+ with open(state_path, "w") as fh:
194
+ json.dump(merged, fh)
195
+ except Exception:
196
+ pass # dedupe degrades, injection still happens
197
+
198
+ emit(text)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ try:
203
+ main()
204
+ except Exception:
205
+ print(json.dumps({"continue": True, "suppressOutput": True}))
206
+ sys.exit(0)
207
+ `;
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;
223
+ }
224
+ function hostSpecs(homeDir, env) {
225
+ const codexHome = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(homeDir, ".codex");
226
+ return [
227
+ { host: "claude", root: join(homeDir, ".claude"), configPath: join(homeDir, ".claude", "settings.json") },
228
+ // Codex keeps hooks in hooks.json, not settings.json — same schema.
229
+ { host: "codex", root: codexHome, configPath: join(codexHome, "hooks.json") },
230
+ ];
231
+ }
232
+ /**
233
+ * Coarse Codex approval detection. Codex persists hook approvals as a
234
+ * [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.
238
+ */
239
+ function detectCodexApproval(codexRoot) {
240
+ try {
241
+ const toml = readFileSync(join(codexRoot, "config.toml"), "utf8");
242
+ const hasState = /\[hooks\.state/.test(toml);
243
+ if (hasState && toml.includes(COMMAND_SIGNATURE))
244
+ 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";
251
+ }
252
+ catch { /* unreadable = no evidence */ }
253
+ return "pending-or-unknown";
254
+ }
255
+ function writeAtomically(path, content) {
256
+ mkdirSync(dirname(path), { recursive: true });
257
+ const tmp = `${path}.prism-tmp-${process.pid}`;
258
+ writeFileSync(tmp, content);
259
+ renameSync(tmp, path);
260
+ }
261
+ function ensureScript(hookDir) {
262
+ const markerPath = join(hookDir, MARKER_FILE);
263
+ const scriptPath = join(hookDir, SCRIPT_FILE);
264
+ let existingVersion;
265
+ try {
266
+ const marker = JSON.parse(readFileSync(markerPath, "utf8"));
267
+ // The durable off switch. Without it, an operator who deletes the entry
268
+ // or edits the script gets silently re-enabled by the next upgrade —
269
+ // self-healing becomes self-reinfecting. {"disabled": true} in the
270
+ // marker survives every ensure path, including version bumps.
271
+ if (marker.disabled === true)
272
+ return "disabled";
273
+ existingVersion = marker.version;
274
+ }
275
+ catch {
276
+ /* no marker — install */
277
+ }
278
+ const scriptExists = existsSync(scriptPath);
279
+ if (scriptExists && existingVersion === PROMPT_ROUTE_HOOK_VERSION)
280
+ return "unchanged";
281
+ writeAtomically(scriptPath, PROMPT_ROUTE_HOOK_SCRIPT);
282
+ chmodSync(scriptPath, 0o755);
283
+ mkdirSync(join(hookDir, "state"), { recursive: true });
284
+ writeAtomically(markerPath, `${JSON.stringify({ managedBy: "prism", feature: "prism-route", version: PROMPT_ROUTE_HOOK_VERSION }, null, 2)}\n`);
285
+ return scriptExists ? "refreshed" : "installed";
286
+ }
287
+ function ensureRegistered(configPath, scriptPath, host) {
288
+ // Codex truncates hook additionalContext at ~2,500 tokens by default —
289
+ // a head-and-tail preview of our payload, which defeats the injection.
290
+ // additionalContextLimit: 0 passes the full context through — per the Codex
291
+ // hooks reference (learn.chatgpt.com/docs/hooks, verified 2026-08-13):
292
+ // "Setting to 0 passes full context directly to the model". NOT an in-repo
293
+ // guarantee: if Codex ever re-reads 0 as a literal zero cap, injection dies
294
+ // silently there — re-verify with a live codex probe after any Codex
295
+ // upgrade. The payload is already bounded by HOOK_INLINE_SAFE_CHARS on the
296
+ // emitting side, so the pass-through is not unbounded. Claude Code has no
297
+ // such field (its 10k-char cap is not configurable) — never write unknown
298
+ // keys into settings.json (a manually-added stray field there is left
299
+ // alone, not stripped).
300
+ const wantsLimit = host === "codex";
301
+ 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)) {
307
+ config = parsed;
308
+ }
309
+ }
310
+ catch {
311
+ /* missing or unreadable — create minimal */
312
+ }
313
+ const hooks = (config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks)
314
+ ? config.hooks
315
+ : {});
316
+ const entries = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
317
+ 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")
327
+ 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))
333
+ 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;
344
+ }
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;
353
+ config.hooks = hooks;
354
+ writeAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
355
+ return stale ? "updated" : "registered";
356
+ }
357
+ /**
358
+ * Idempotently install the prism-route hook for both hosts.
359
+ * Never throws for a single host's failure — the other host still gets it.
360
+ */
361
+ export function ensurePromptRouteHook(options = {}) {
362
+ const homeDir = options.homeDir ?? homedir();
363
+ const env = options.env ?? process.env;
364
+ const wanted = new Set(options.hosts ?? ["claude", "codex"]);
365
+ const onlyExisting = options.onlyExistingRoots ?? true;
366
+ const results = [];
367
+ for (const spec of hostSpecs(homeDir, env)) {
368
+ if (!wanted.has(spec.host))
369
+ continue;
370
+ if (onlyExisting && !existsSync(spec.root))
371
+ continue;
372
+ if ((options.mode ?? "explicit") === "auto" && !hostShowsPriorConsent(spec, homeDir))
373
+ continue;
374
+ try {
375
+ const hookDir = join(spec.root, "hooks", HOOK_DIR);
376
+ const script = ensureScript(hookDir);
377
+ if (script === "disabled")
378
+ continue; // operator opt-out — do not re-register either
379
+ const config = ensureRegistered(spec.configPath, join(hookDir, SCRIPT_FILE), spec.host);
380
+ const result = { host: spec.host, script, config, scriptPath: join(hookDir, SCRIPT_FILE), configPath: spec.configPath };
381
+ if (spec.host === "codex") {
382
+ // Never report a green "registered" as if it were active: Codex
383
+ // SILENTLY SKIPS untrusted hooks, and "installed but inert" is the
384
+ // exact failure class this feature exists to end.
385
+ result.codexApproval = detectCodexApproval(spec.root);
386
+ }
387
+ results.push(result);
388
+ }
389
+ catch {
390
+ // One host failing (permissions, odd config) must not block the other.
391
+ }
392
+ }
393
+ return results;
394
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Self-update for `prism connect` — the converge command.
3
+ *
4
+ * The operator's model, which this implements: connect re-checks EVERYTHING —
5
+ * package version, host configs, hooks, skills — and applies what's needed.
6
+ * Before this, `npm i -g` and `prism connect` were two separate steps, and a
7
+ * machine that ran only the second stayed on old code with fresh config: the
8
+ * "hook exists but the CLI behind it is stale" state observed live on
9
+ * 2026-08-13, where a rebuilt hook called a pre-fix global CLI and quietly
10
+ * injected two skills instead of three.
11
+ *
12
+ * After a successful update, the caller RE-EXECS the new binary so the rest
13
+ * of connect runs with the code it just installed — reconciliation logic from
14
+ * the new version, not the old one.
15
+ *
16
+ * What this deliberately does NOT do: touch Codex hook trust. Convergence
17
+ * covers everything software may legitimately converge; approving our own
18
+ * execution is not in that set.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { realpathSync } from "node:fs";
22
+ const PACKAGE = "prism-mcp-server";
23
+ function defaultFetchLatest() {
24
+ return execFileSync("npm", ["view", PACKAGE, "version"], {
25
+ encoding: "utf8",
26
+ timeout: 15_000,
27
+ }).trim();
28
+ }
29
+ function defaultInstall(version) {
30
+ // Inherits the operator's npm prefix (.npmrc / NPM_CONFIG_PREFIX), so the
31
+ // update lands where their `prism` actually resolves from.
32
+ execFileSync("npm", ["install", "-g", `${PACKAGE}@${version}`], {
33
+ stdio: "inherit",
34
+ timeout: 300_000,
35
+ });
36
+ }
37
+ /** Plain numeric semver compare; returns true when b is newer than a. */
38
+ export function isNewer(a, b) {
39
+ const pa = a.split(".").map((n) => parseInt(n, 10));
40
+ const pb = b.split(".").map((n) => parseInt(n, 10));
41
+ for (let i = 0; i < 3; i++) {
42
+ const x = pa[i] ?? 0;
43
+ const y = pb[i] ?? 0;
44
+ if (y > x)
45
+ return true;
46
+ if (y < x)
47
+ return false;
48
+ }
49
+ return false;
50
+ }
51
+ export function maybeSelfUpdate(deps) {
52
+ const env = deps.env ?? process.env;
53
+ const log = deps.log ?? (() => { });
54
+ const fetchLatest = deps.fetchLatest ?? defaultFetchLatest;
55
+ const install = deps.install ?? defaultInstall;
56
+ if (env.PRISM_NO_SELF_UPDATE === "1") {
57
+ return { action: "skipped", detail: "PRISM_NO_SELF_UPDATE=1" };
58
+ }
59
+ // Test runners must never reach the network or npm -g.
60
+ if (env.VITEST || env.NODE_ENV === "test") {
61
+ return { action: "skipped", detail: "test environment" };
62
+ }
63
+ // A -local.N or any prerelease build is a developer's hand-installed
64
+ // artifact; "updating" it to the registry release would be a DOWNGRADE of
65
+ // intent. Converging dev builds is the developer's call, not ours.
66
+ if (deps.currentVersion.includes("-")) {
67
+ return { action: "skipped", detail: `dev build ${deps.currentVersion} — not touching it` };
68
+ }
69
+ // A CLI running from a source checkout (repo dist, not node_modules) must
70
+ // never self-update: `npm i -g` would update the GLOBAL install while the
71
+ // re-exec re-ran THIS checkout's old code under a "updated" banner — the
72
+ // exact stale-code-fresh-claim confusion connect exists to end.
73
+ if (deps.invokedFrom) {
74
+ // argv[1] for a global install is the BIN SYMLINK (~/.npm-global/bin/prism),
75
+ // whose path contains no node_modules — judging the raw path would disable
76
+ // self-update for every normal install (caught empirically in review).
77
+ // The realpath resolves through the symlink into lib/node_modules/…
78
+ let resolved = deps.invokedFrom;
79
+ try {
80
+ resolved = realpathSync(deps.invokedFrom);
81
+ }
82
+ catch { /* keep raw */ }
83
+ if (!resolved.includes("node_modules")) {
84
+ return { action: "skipped", detail: `running from a checkout (${resolved}) — update the checkout with git, not npm` };
85
+ }
86
+ }
87
+ let latest;
88
+ try {
89
+ latest = fetchLatest();
90
+ }
91
+ catch (error) {
92
+ // Offline connect must still converge configs with the code it has.
93
+ return { action: "failed", detail: `registry unreachable (${error instanceof Error ? error.message.split("\n")[0] : String(error)}) — continuing with ${deps.currentVersion}` };
94
+ }
95
+ if (!/^\d+\.\d+\.\d+$/.test(latest)) {
96
+ return { action: "failed", detail: `registry returned unexpected version "${latest}" — continuing` };
97
+ }
98
+ if (!isNewer(deps.currentVersion, latest)) {
99
+ return { action: "current", detail: `${deps.currentVersion} is current`, latest };
100
+ }
101
+ log(`prism ${deps.currentVersion} → ${latest}: updating before configuring …`);
102
+ try {
103
+ install(latest);
104
+ }
105
+ catch (error) {
106
+ return { action: "failed", detail: `npm install -g failed (${error instanceof Error ? error.message.split("\n")[0] : String(error)}) — continuing with ${deps.currentVersion}`, latest };
107
+ }
108
+ return { action: "updated", detail: `now on ${latest}`, latest };
109
+ }
package/dist/server.js CHANGED
@@ -91,7 +91,7 @@ import { LOCAL_FIRST_POLICY_TEXT } from "./localFirstPolicy.js";
91
91
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
92
92
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
93
93
  // Session memory tools — only used if Supabase is configured
94
- import { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, SESSION_BOOTSTRAP_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL,
94
+ import { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, SESSION_BOOTSTRAP_TOOL, SESSION_ROUTE_PROMPT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL,
95
95
  // ─── v0.4.0: New tool definitions (Enhancements #2 and #4) ───
96
96
  SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL,
97
97
  // ─── v2.0: Time Travel tool definitions ───
@@ -123,7 +123,7 @@ ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABA
123
123
  // v15.5: Knowledge Ingestion
124
124
  KNOWLEDGE_INGEST_TOOL,
125
125
  // v19.2: Inference Metrics
126
- INFERENCE_METRICS_TOOL, sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, knowledgeSearchHandler, knowledgeForgetHandler,
126
+ INFERENCE_METRICS_TOOL, sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, sessionRoutePromptHandler, knowledgeSearchHandler, knowledgeForgetHandler,
127
127
  // ─── v0.4.0: New tool handlers ───
128
128
  compactLedgerHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, sessionBackfillLinksHandler, sessionSynthesizeEdgesHandler, sessionCognitiveRouteHandler,
129
129
  // ─── v2.0: Time Travel handlers ───
@@ -186,6 +186,7 @@ const BASE_TOOLS = [
186
186
  function buildSessionMemoryTools() {
187
187
  return [
188
188
  SESSION_BOOTSTRAP_TOOL, // session_bootstrap — hook-free configured first-turn greeting + context
189
+ SESSION_ROUTE_PROMPT_TOOL, // session_route_prompt — routes EVERY turn after the first
189
190
  SKILL_SAVE_TOOL, // skill_save — save a skill at local/user/team scope
190
191
  SKILL_MANAGE_TOOL, // skill_manage — list/delete scoped skills, release/restore platform skills
191
192
  SESSION_SAVE_LEDGER_TOOL, // session_save_ledger — append immutable session log
@@ -259,6 +260,23 @@ let storageIsReady = false;
259
260
  // session_load_context. Used by deferred auto-push to avoid duplicates when a
260
261
  // native host complies with the hook-free first-turn instruction.
261
262
  let contextLoadedByClient = false;
263
+ // ─── prism-route hook self-heal ──────────────────────────────
264
+ // The safety net of the three install paths (connect, postinstall, here):
265
+ // covers installs that skipped npm scripts and machines that never re-ran
266
+ // connect. Idempotent and version-marked, so the steady-state cost is two
267
+ // stat calls a few seconds after boot. Opt out: PRISM_DISABLE_HOOK_AUTOINSTALL=1.
268
+ // Guarded against test runners: five suites import this module, and a
269
+ // module-scope timer would otherwise rewrite the DEVELOPER'S real host
270
+ // configs five seconds into every vitest run.
271
+ if (process.env.PRISM_DISABLE_HOOK_AUTOINSTALL !== "1" &&
272
+ !process.env.VITEST &&
273
+ process.env.NODE_ENV !== "test") {
274
+ setTimeout(() => {
275
+ import("./promptRouteHostHook.js")
276
+ .then((m) => m.ensurePromptRouteHook({ mode: "auto" }))
277
+ .catch(() => { });
278
+ }, 5000).unref();
279
+ }
262
280
  /**
263
281
  * Notifies subscribed clients that a resource has changed.
264
282
  *
@@ -330,6 +348,7 @@ export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI A
330
348
  `trigger rules — read each before proposing any change. Surfacing a name is not loading it: hosts that ` +
331
349
  `do not auto-load skill files must fetch each body with knowledge_search, passing the skill name ` +
332
350
  `exactly as listed. ` +
351
+ `AFTER that first turn, routing does not stop: call session_route_prompt at the start of any turn where the user states a new task, changes the kind of work, or reports a defect, passing their verbatim message as {prompt} and the skills you already hold as {loaded}. session_bootstrap routes only turn one, so without this a long session keeps working with whatever it happened to load hours earlier. The call is cheap — a single line when nothing new matches, and never the same skill twice. When it returns skills, read and follow them before doing the work. ` +
333
352
  `Emit no preamble. Print the complete tool result verbatim as the entire first-turn startup display, before any optional ` +
334
353
  `answer. Do not summarize, paraphrase, rename headings, reformat, or omit any returned section. Preserve its order and ` +
335
354
  `line content. For a greeting-only prompt, stop after the verbatim startup display. ` +
@@ -765,6 +784,12 @@ export function createServer() {
765
784
  contextLoadedByClient = true;
766
785
  result = await sessionBootstrapHandler(args);
767
786
  break;
787
+ case "session_route_prompt":
788
+ // Deliberately NOT gated on SESSION_MEMORY_ENABLED: routing is
789
+ // on-device against a cached table, so it must keep working when
790
+ // the memory backend is unconfigured or unreachable.
791
+ result = await sessionRoutePromptHandler(args);
792
+ break;
768
793
  case "knowledge_search":
769
794
  if (!SESSION_MEMORY_ENABLED)
770
795
  throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
@@ -26,9 +26,9 @@ export { webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, br
26
26
  // This file always exports them — server.ts decides whether to include them in the tool list.
27
27
  //
28
28
  // v0.4.0: Added SESSION_COMPACT_LEDGER_TOOL and SESSION_SEARCH_MEMORY_TOOL
29
- export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, SESSION_BOOTSTRAP_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_BACKFILL_EMBEDDINGS_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, SESSION_BACKFILL_LINKS_TOOL, MAINTENANCE_VACUUM_TOOL, isDeepStoragePurgeArgs, SESSION_SYNTHESIZE_EDGES_TOOL, isSessionSynthesizeEdgesArgs, SESSION_COGNITIVE_ROUTE_TOOL, isSessionCognitiveRouteArgs, SESSION_TASK_ROUTE_TOOL, isSessionTaskRouteArgs, ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABASE_TOOL, CONFIGURE_NOTIFICATIONS_TOOL, QUERY_MEMORY_NATURAL_TOOL } from "./sessionMemoryDefinitions.js";
29
+ export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, SESSION_BOOTSTRAP_TOOL, SESSION_ROUTE_PROMPT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_BACKFILL_EMBEDDINGS_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, SESSION_BACKFILL_LINKS_TOOL, MAINTENANCE_VACUUM_TOOL, isDeepStoragePurgeArgs, SESSION_SYNTHESIZE_EDGES_TOOL, isSessionSynthesizeEdgesArgs, SESSION_COGNITIVE_ROUTE_TOOL, isSessionCognitiveRouteArgs, SESSION_TASK_ROUTE_TOOL, isSessionTaskRouteArgs, ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABASE_TOOL, CONFIGURE_NOTIFICATIONS_TOOL, QUERY_MEMORY_NATURAL_TOOL } from "./sessionMemoryDefinitions.js";
30
30
  // 1. Ledger (Core CRUD & State)
31
- export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, sessionSaveExperienceHandler, sessionSaveImageHandler, sessionViewImageHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler } from "./ledgerHandlers.js";
31
+ export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, sessionRoutePromptHandler, runPromptRouteFromCache, sessionSaveExperienceHandler, sessionSaveImageHandler, sessionViewImageHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler } from "./ledgerHandlers.js";
32
32
  // 2. Graph (Semantic Search & Weighting)
33
33
  export { sessionSearchMemoryHandler, knowledgeSearchHandler, sessionIntuitiveRecallHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler, knowledgeForgetHandler, knowledgeSyncRulesHandler, sessionSynthesizeEdgesHandler, sessionCognitiveRouteHandler } from "./graphHandlers.js";
34
34
  // 3. Hygiene (Maintenance & Integrity)
@@ -1793,14 +1793,21 @@ export async function seedAndRecallDemoMemory(conversationId) {
1793
1793
  limit: "1",
1794
1794
  }));
1795
1795
  const recalled = rows[0];
1796
- if (!recalled?.summary)
1796
+ if (!recalled?.summary) {
1797
+ console.error(`[first-run-demo] read-back returned ${Array.isArray(rows) ? rows.length : "non-array"} rows — seed row not visible`);
1797
1798
  return null;
1799
+ }
1798
1800
  const todo = Array.isArray(recalled.todos) && recalled.todos[0] ? `\n - TODO it carried: ${recalled.todos[0]}` : "";
1799
1801
  return (`- 🧠 **Watch this — Prism just saved a memory and recalled it from disk:**\n` +
1800
1802
  ` - "${recalled.summary}"${todo}\n` +
1801
1803
  ` - This round-trip is what every future session gets: your decisions, TODOs, and changed files, back the moment you return. (Demo lives in the \`${DEMO_PROJECT}\` project — delete it anytime.)`);
1802
1804
  }
1803
- catch {
1805
+ catch (error) {
1806
+ // Still swallow — a first run must never break on a demo — but say WHY on
1807
+ // stderr. This block went missing intermittently on one CI leg
1808
+ // (ubuntu/node 20) and the silent catch made every investigation start
1809
+ // from nothing: the failure was only ever visible as an ABSENT paragraph.
1810
+ console.error(`[first-run-demo] seed/recall failed: ${error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error)}`);
1804
1811
  return null;
1805
1812
  }
1806
1813
  }
@@ -1851,7 +1858,7 @@ export function buildSessionFactsLine(facts) {
1851
1858
  * Failures are swallowed — a broken trigger degrades to "the public table
1852
1859
  * only", never takes down startup.
1853
1860
  */
1854
- async function collectSkillTriggersOnThisMachine() {
1861
+ export async function collectSkillTriggersOnThisMachine() {
1855
1862
  try {
1856
1863
  const { collectScopedTriggers, collectLocalSkillTriggers } = await import("./scopedSkillTriggers.js");
1857
1864
  const merged = {};
@@ -1906,6 +1913,64 @@ async function collectSkillTriggersOnThisMachine() {
1906
1913
  return undefined;
1907
1914
  }
1908
1915
  }
1916
+ /**
1917
+ * session_route_prompt — the mid-session half of skill routing.
1918
+ *
1919
+ * Everything here is deliberately borrowed from the first-turn path rather
1920
+ * than reimplemented: the same on-device matcher, the same scoped-frontmatter
1921
+ * triggers, the same entitlement set and the same local-skill bypass. A second
1922
+ * implementation would drift, and a routing table that behaves differently at
1923
+ * turn 500 than at turn 1 is worse than no routing at all.
1924
+ */
1925
+ /**
1926
+ * Prompt routing from CACHED state only — no network, no manifest sync.
1927
+ *
1928
+ * Shared by the MCP tool and the `prism route-prompt` CLI (which the
1929
+ * prism-route host hook shells out to on every prompt). Entitlement comes
1930
+ * from the last-synced manifest in the settings DB; the server refreshes it
1931
+ * at startup, and a per-prompt path must never trigger a portal round-trip.
1932
+ * One implementation on purpose: a table that matches differently in the
1933
+ * hook than in the server is worse than no hook.
1934
+ */
1935
+ export async function runPromptRouteFromCache(prompt, loaded) {
1936
+ const { routePrompt } = await import("./promptRouteHandler.js");
1937
+ const { resolvePromptSkillNames, _setStorage } = await import("./skillRouting.js");
1938
+ // The CLI is a fresh process per prompt: without storage wiring the keyword
1939
+ // table can neither be read from disk (offline = dead routing) nor
1940
+ // persisted after a fetch (every prompt = a network GET). The server paths
1941
+ // wire this at bootstrap; the CLI must do it itself.
1942
+ _setStorage(async (key, value) => { await setSetting(key, value); }, async (key) => getSetting(key, ""));
1943
+ return routePrompt(prompt, loaded, {
1944
+ resolvePromptSkillNames,
1945
+ collectTriggers: collectSkillTriggersOnThisMachine,
1946
+ entitledNames: async () => {
1947
+ try {
1948
+ const parsed = JSON.parse(await getSetting("skill_manifest:names", "[]"));
1949
+ return new Set(Array.isArray(parsed) ? parsed.filter((n) => typeof n === "string") : []);
1950
+ }
1951
+ catch {
1952
+ return new Set();
1953
+ }
1954
+ },
1955
+ getBody: (name) => getSetting(`skill:${name}`, ""),
1956
+ manifestVersion: async () => {
1957
+ const v = Number(await getSetting("skill_manifest:routing_version", ""));
1958
+ return Number.isFinite(v) && v > 0 ? v : undefined;
1959
+ },
1960
+ });
1961
+ }
1962
+ export async function sessionRoutePromptHandler(args) {
1963
+ const input = (typeof args === "object" && args !== null && !Array.isArray(args) ? args : {});
1964
+ const prompt = typeof input.prompt === "string" ? input.prompt : "";
1965
+ const loaded = Array.isArray(input.loaded)
1966
+ ? input.loaded.filter((n) => typeof n === "string")
1967
+ : [];
1968
+ const result = await runPromptRouteFromCache(prompt, loaded);
1969
+ // Text only, never structuredContent. A result carrying both lets a host
1970
+ // surface just the structured half — which is exactly how Claude Code
1971
+ // silently dropped the bootstrap payload for three weeks.
1972
+ return { content: [{ type: "text", text: result.text }] };
1973
+ }
1909
1974
  export async function sessionBootstrapHandler(args = {}, options = {}) {
1910
1975
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
1911
1976
  throw new Error("Invalid arguments for session_bootstrap");
@@ -0,0 +1,208 @@
1
+ /**
2
+ * session_route_prompt — mid-session skill routing.
3
+ *
4
+ * THE GAP THIS CLOSES (2026-08-12). Skill routing runs at session_bootstrap,
5
+ * which by instruction happens on the first turn. Long sessions are where the
6
+ * work actually is: an eight-hour session was asked six times for a UI/UX
7
+ * review, and even after the routing table learned those phrasings, nothing
8
+ * could deliver the skill — the ask arrived at turn ~530 and routing had run
9
+ * at turn 1. The agent substituted source-grep assertions for rendered
10
+ * evidence and shipped six reactive patches.
11
+ *
12
+ * WHY THIS IS A TOOL AND NOT AUTOMATIC. An MCP server never sees the user's
13
+ * prompt; the protocol only carries what a tool call carries. Truly automatic
14
+ * per-prompt injection needs a HOST hook, which the operator has ruled out.
15
+ * This is the honest approximation: the model passes the prompt, we match it
16
+ * on-device and return only what is genuinely new.
17
+ *
18
+ * CHEAP BY CONSTRUCTION, because an instruction to call it every turn is only
19
+ * affordable if the common answer is nearly free:
20
+ * - the overwhelmingly common result is "no new skills", a one-line reply;
21
+ * - `loaded` lets the caller declare what it already has, so a skill is
22
+ * never re-injected and a repeated call costs nothing;
23
+ * - bodies are capped, so a pathological match cannot dump the budget.
24
+ *
25
+ * The prompt is matched ON DEVICE against the same table and the same scoped
26
+ * frontmatter triggers session_bootstrap uses. It is never transmitted.
27
+ */
28
+ import { debugLog } from "../utils/logger.js";
29
+ /** Never return more than this many bodies in one call. */
30
+ export const MAX_ROUTED_SKILLS = 3;
31
+ /** Hard ceiling on returned characters, so one call cannot flood the window.
32
+ * Sized from measurement, not taste: the UI-review bundle
33
+ * (visual-screenshot-verification + playwright-screenshot-discipline +
34
+ * verified-shipping) is ~24.5k, and at 24k the third — the EVIDENCE-CLAIM
35
+ * rules, arguably the one that matters most at the merge moment — was
36
+ * reported "matched, not injected" on the first UI turn of a live session.
37
+ * A cap that trims the bundle it was built for is mis-sized. */
38
+ export const MAX_ROUTED_CHARS = 30_000;
39
+ /** What a HOST will actually hand the model inline from a hook. Claude Code
40
+ * hard-caps hook additionalContext at 10,000 chars — over that, the full text
41
+ * goes to a file and the model gets a 2KB preview (three live instances
42
+ * observed 2026-08-13: 13.2/18/18.9KB injections, all offloaded). Codex
43
+ * truncates at ~2,500 tokens by default. MAX_ROUTED_CHARS bounds the PAYLOAD;
44
+ * this bounds what may ride INLINE through a hook — anything larger must be
45
+ * our own offload file with an imperative pointer, not the host's silent one. */
46
+ export const HOOK_INLINE_SAFE_CHARS = 9_800;
47
+ /**
48
+ * Match a prompt and return ONLY skills the caller does not already have.
49
+ *
50
+ * Pure over its deps so the tests exercise real matching rather than mocks of
51
+ * the thing under test.
52
+ */
53
+ export async function routePrompt(prompt, loaded, deps) {
54
+ const trimmed = (prompt || "").trim();
55
+ if (!trimmed) {
56
+ return { names: [], alreadyLoaded: [], overflow: [], text: "No prompt supplied — nothing to route." };
57
+ }
58
+ const scoped = await deps.collectTriggers().catch(() => undefined);
59
+ const version = await deps.manifestVersion().catch(() => undefined);
60
+ let matched = [];
61
+ try {
62
+ matched = await deps.resolvePromptSkillNames(trimmed, version, scoped?.triggers);
63
+ }
64
+ catch (error) {
65
+ // Routing must never take down the turn that asked for it.
66
+ debugLog(`[session_route_prompt] match failed: ${error instanceof Error ? error.message : String(error)}`);
67
+ return { names: [], alreadyLoaded: [], overflow: [], text: "No new skills for this prompt." };
68
+ }
69
+ const entitled = await deps.entitledNames().catch(() => new Set());
70
+ // A local skill is on disk and not in the delivery manifest, so entitlement
71
+ // cannot see it — the same bypass session_bootstrap applies.
72
+ const permitted = matched.filter((n) => entitled.has(n) || scoped?.localNames.has(n));
73
+ const have = new Set(loaded.map((n) => n.trim()).filter(Boolean));
74
+ const alreadyLoaded = permitted.filter((n) => have.has(n));
75
+ const fresh = permitted.filter((n) => !have.has(n));
76
+ if (fresh.length === 0) {
77
+ // The common case, and it must stay one cheap line: an instruction to call
78
+ // this every turn is only reasonable if silence is nearly free.
79
+ return {
80
+ names: [], alreadyLoaded, overflow: [],
81
+ text: "No new skills for this prompt.",
82
+ };
83
+ }
84
+ const selected = fresh.slice(0, MAX_ROUTED_SKILLS);
85
+ const overflow = fresh.slice(MAX_ROUTED_SKILLS);
86
+ const blocks = [];
87
+ const delivered = [];
88
+ let budget = MAX_ROUTED_CHARS;
89
+ for (const name of selected) {
90
+ const body = (await deps.getBody(name).catch(() => "")).trim();
91
+ if (!body) {
92
+ // Routed but undeliverable is the exact defect this feature exists to
93
+ // surface. Say so rather than returning a name with nothing behind it.
94
+ blocks.push(`### ${name}\n(no content on this machine — run skill sync)`);
95
+ delivered.push(name);
96
+ continue;
97
+ }
98
+ if (body.length > budget) {
99
+ overflow.push(name);
100
+ continue;
101
+ }
102
+ budget -= body.length;
103
+ blocks.push(`### ${name}\n${body}`);
104
+ delivered.push(name);
105
+ }
106
+ if (delivered.length === 0) {
107
+ return { names: [], alreadyLoaded, overflow, text: "No new skills for this prompt." };
108
+ }
109
+ // Imperative, not a label. A bare list is decorative; nothing else in this
110
+ // path tells the agent these rules bind the work it is about to do.
111
+ //
112
+ // The overflow list is CAPPED: the header feeds reshapeForInlineBudget's
113
+ // base text, whose budget guarantee (and the pointer's first-2KB placement)
114
+ // holds only if the header is bounded. An unbounded list of matched names
115
+ // was measured at 12,950 chars for a 400-skill match — over the host cap
116
+ // before a single body was added.
117
+ const overflowShown = overflow.slice(0, 8);
118
+ const overflowNote = overflow.length > 0
119
+ ? `\n\nAlso matched, not injected: ${overflowShown.join(", ")}${overflow.length > overflowShown.length ? ` (+${overflow.length - overflowShown.length} more)` : ""}.`
120
+ : "";
121
+ const header = `**Skills now active for this task:** ${delivered.join(", ")}\n\n` +
122
+ `These apply to the work you are about to do. Read and follow them before proceeding.` +
123
+ overflowNote;
124
+ return { names: delivered, alreadyLoaded, overflow, header, blocks, text: `${header}\n\n${blocks.join("\n\n")}` };
125
+ }
126
+ /**
127
+ * Fit a routed payload under a host's inline hook cap.
128
+ *
129
+ * The host's own overflow handling is the failure mode, not the fallback:
130
+ * Claude Code silently swaps anything over 10k chars for a 2KB preview, and
131
+ * nothing tells the model to go read the rest. So when the payload is over
132
+ * budget WE offload it — to a file we name, behind an imperative that sits in
133
+ * the first 2KB where every host preview window can still deliver it — and
134
+ * inline as many whole priority bodies as fit.
135
+ *
136
+ * Pure over its writer so tests exercise the real budgeting.
137
+ */
138
+ export function reshapeForInlineBudget(result, budgetChars, writeOffload) {
139
+ const full = result.text;
140
+ if (full.length <= budgetChars || !result.header || !result.blocks || result.blocks.length === 0) {
141
+ return { text: full, offloaded: false };
142
+ }
143
+ let offloadPath;
144
+ try {
145
+ offloadPath = writeOffload(full);
146
+ }
147
+ catch {
148
+ offloadPath = undefined;
149
+ }
150
+ // POINTER FIRST. Recoverability outranks prose order: skill names are
151
+ // unbounded (portal manifest, unvalidated), so any text placed before the
152
+ // pointer can push it past the ~2KB preview a host shows for offloaded
153
+ // context — or under the final clamp, slice it off entirely. Round-2 review
154
+ // measured both: 200-char names put the pointer at offset ~2,374 with the
155
+ // clamp never firing. With the pointer leading, its path sits within the
156
+ // first ~120 chars no matter what the header does.
157
+ const pieces = [];
158
+ if (offloadPath) {
159
+ pieces.push(`**Host hook context is size-capped — the full text of all ${result.names.length} skill(s) is saved at: ${offloadPath}**\n` +
160
+ `**Read that file now and follow those skills before proceeding. If the host shows "Full output saved to" with another path above, Read that file instead.**`);
161
+ }
162
+ pieces.push(result.header);
163
+ // Loud-failure footer when there is no offload file to point at — a
164
+ // silently dropped skill is the defect this feature exists for. The reserve
165
+ // is an EXACT FIXPOINT over the skipped set, not a bound: reserving for all
166
+ // delivered names was measured skipping a body that previously fit (188
167
+ // wasted chars), and a guessed constant was measured overrunning by 99.
168
+ // The skipped set only grows as the reserve grows, so this converges in at
169
+ // most blocks+1 rounds.
170
+ const footerFor = (names) => `\n\n**Not inlined (host size cap, offload unavailable): ${names.join(", ")} — fetch each with knowledge_search and follow it before proceeding.**`;
171
+ const base = pieces.join("\n\n");
172
+ const blocks = result.blocks; // narrowed once — the closure below defeats TS narrowing on result
173
+ const fill = (reserve) => {
174
+ let filled = base;
175
+ const skipped = [];
176
+ for (let i = 0; i < blocks.length; i++) {
177
+ const candidate = `${filled}\n\n${blocks[i]}`;
178
+ if (candidate.length <= budgetChars - reserve) {
179
+ filled = candidate;
180
+ }
181
+ else {
182
+ skipped.push(result.names[i] ?? `skill ${i + 1}`);
183
+ }
184
+ }
185
+ return { filled, skipped };
186
+ };
187
+ let attempt = fill(0);
188
+ if (!offloadPath) {
189
+ for (let round = 0; round <= blocks.length && attempt.skipped.length > 0; round++) {
190
+ const next = fill(footerFor(attempt.skipped).length);
191
+ const converged = next.skipped.length === attempt.skipped.length;
192
+ attempt = next;
193
+ if (converged)
194
+ break;
195
+ }
196
+ }
197
+ let inline = attempt.filled;
198
+ if (!offloadPath && attempt.skipped.length > 0) {
199
+ inline += footerFor(attempt.skipped);
200
+ }
201
+ // Belt over the construction: the budget is a HOST hard cap, and "the data
202
+ // stayed small" is not an invariant. The pointer leads, so a slice keeps
203
+ // the recoverable part.
204
+ if (inline.length > budgetChars) {
205
+ inline = inline.slice(0, budgetChars);
206
+ }
207
+ return { text: inline, offloaded: true, offloadPath };
208
+ }
@@ -159,6 +159,50 @@ export const SESSION_LOAD_CONTEXT_TOOL = {
159
159
  },
160
160
  };
161
161
  // ─── Hook-free Session Bootstrap ──────────────────────────────
162
+ /**
163
+ * Mid-session counterpart to session_bootstrap.
164
+ *
165
+ * session_bootstrap routes the FIRST prompt. This routes every prompt after
166
+ * it, which is where long sessions actually do their work — the 2026-08-12
167
+ * incident asked for a UI/UX review at turn ~530 and no skill could reach it.
168
+ */
169
+ export const SESSION_ROUTE_PROMPT_TOOL = {
170
+ name: "session_route_prompt",
171
+ description: "Call this at the START of any turn where the user states a NEW task, changes the kind of work, or reports a " +
172
+ "defect \u2014 passing their verbatim message as {prompt: \"...\"} and the skills you already have as {loaded: [...]}. " +
173
+ "session_bootstrap routes only the first turn of a conversation; this routes every turn after it, so a long " +
174
+ "session still picks up the skills its current work requires. The prompt is matched ON-DEVICE against the same " +
175
+ "table and the same account-scoped frontmatter triggers; it never leaves the machine. Calling it is cheap: when " +
176
+ "nothing new matches it returns a single line, and anything named in `loaded` is never returned again. When it " +
177
+ "does return skills, read and follow them before doing the work \u2014 they are the rules for the task at hand, " +
178
+ "not background. Skip it only for follow-ups within work you have already routed.",
179
+ annotations: {
180
+ readOnlyHint: true,
181
+ destructiveHint: false,
182
+ idempotentHint: true,
183
+ openWorldHint: false,
184
+ },
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ prompt: {
189
+ type: "string",
190
+ description: "The user's verbatim message for this turn. Matched on-device; never transmitted.",
191
+ },
192
+ loaded: {
193
+ type: "array",
194
+ items: { type: "string" },
195
+ description: "Skill names already active in this conversation, including those from the first-turn startup display. " +
196
+ "Anything listed here is never returned again, which is what keeps repeat calls free.",
197
+ },
198
+ project: {
199
+ type: "string",
200
+ description: "Optional project override. Omit to use the session's project.",
201
+ },
202
+ },
203
+ required: ["prompt"],
204
+ },
205
+ };
162
206
  export const SESSION_BOOTSTRAP_TOOL = {
163
207
  name: "session_bootstrap",
164
208
  description: "IMPORTANT: On the first user turn of every conversation, including a greeting, call this tool exactly once " +
@@ -210,6 +210,24 @@ async function fetchKeywordTable(expectVersion) {
210
210
  }
211
211
  if (kwCache && Date.now() - kwCache.at < TABLE_TTL)
212
212
  return kwCache.table;
213
+ // Persisted-first when the version is KNOWN-GOOD. The in-memory cache is
214
+ // per-process, and the route-prompt CLI is a fresh process on EVERY user
215
+ // prompt — without this, each prompt made a network GET (measured: a
216
+ // captive-portal network stalled every prompt 5.5s, and offline routed
217
+ // nothing because a fresh process had no fallback wired). A persisted
218
+ // table whose version equals what the manifest sync last reported is
219
+ // exactly as fresh as a re-download; version bumps still refetch below.
220
+ if (expectVersion !== undefined && readFn) {
221
+ try {
222
+ const stored = await readFn(TABLE_STORAGE_KEY);
223
+ const parsed = stored ? JSON.parse(stored) : null;
224
+ if (isKeywordTable(parsed) && parsed.version === expectVersion) {
225
+ kwCache = { table: parsed, at: Date.now() };
226
+ return parsed;
227
+ }
228
+ }
229
+ catch { /* fall through to network */ }
230
+ }
213
231
  if (!kwInflight) {
214
232
  kwInflight = (async () => {
215
233
  try {
@@ -231,7 +231,15 @@ export async function skillSaveHandler(args) {
231
231
  const where = effectiveScope === "user"
232
232
  ? `saved as YOUR account skill (version ${String(body.version)}) — say "make it a team skill" to share it with a workspace`
233
233
  : `saved as a TEAM skill for workspace ${String(workspaceId)} (version ${String(body.version)})${Array.isArray(assignTo) && assignTo.length > 0 ? `, targeted to ${assignTo.length} member(s)` : ", delivered to all members"}`;
234
- return text(`${where}. Delivery: ${delivery}.`, { scope: effectiveScope, name: skillName, version: body.version });
234
+ // A skill without triggers is discoverable but never auto-loads on every
235
+ // machine it reaches, it behaves exactly like the delivered-but-inert defect
236
+ // prompt_triggers was built to end. Every pre-trigger scoped skill shipped
237
+ // this way and each needed a manual backfill; say it at save time, the one
238
+ // moment the author is present.
239
+ const routingNote = Object.keys(extractSkillTriggers(skillName, skillContent).triggers).length === 0
240
+ ? " NOTE: no prompt_triggers declared — this skill appears in host catalogs but will NOT auto-load on matching prompts. Add a prompt_triggers list to its frontmatter to route it."
241
+ : "";
242
+ return text(`${where}. Delivery: ${delivery}.${routingNote}`, { scope: effectiveScope, name: skillName, version: body.version });
235
243
  }
236
244
  export const SKILL_MANAGE_TOOL = {
237
245
  name: "skill_manage",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.10.0",
3
+ "version": "20.11.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
- "description": "Persistent session memory for AI coding agents that never leaves your machine \u2014 including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
5
+ "description": "Persistent session memory for AI coding agents that never leaves your machine including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",
7
7
  "type": "module",
8
8
  "main": "dist/server.js",
@@ -32,7 +32,8 @@
32
32
  "test:routing:live": "npm run build && node scripts/prism-infer-live-test.mjs",
33
33
  "test:routing:models": "npm run build && node scripts/prism-infer-live-test.mjs --infer",
34
34
  "test:mcp": "node ./test_cross_mcp.js",
35
- "import": "node dist/utils/universalImporter.js"
35
+ "import": "node dist/utils/universalImporter.js",
36
+ "postinstall": "node -e \"import('./dist/postinstall.js').catch(()=>{})\""
36
37
  },
37
38
  "keywords": [
38
39
  "mcp-server",
@@ -1249,6 +1249,37 @@ def cmd_open(session, url):
1249
1249
  return result
1250
1250
 
1251
1251
 
1252
+ def _enforce_max_edge(path, max_edge):
1253
+ """Downscale an image in place so its longest edge is <= max_edge.
1254
+
1255
+ Returns a warning string when the image remains oversized (no scaler
1256
+ available), else None. Never raises: a failed downscale must not turn a
1257
+ real capture into a failure — but it must not stay silent either.
1258
+ """
1259
+ try:
1260
+ import shutil as _shutil, subprocess as _subprocess
1261
+ if _shutil.which("sips"):
1262
+ _subprocess.run(["sips", "-Z", str(max_edge), str(path)],
1263
+ capture_output=True, timeout=30, check=True)
1264
+ return None
1265
+ try:
1266
+ from PIL import Image # type: ignore
1267
+ with Image.open(path) as im:
1268
+ w, h = im.size
1269
+ if max(w, h) <= max_edge:
1270
+ return None
1271
+ scale = max_edge / max(w, h)
1272
+ im.resize((int(w * scale), int(h * scale))).save(path)
1273
+ return None
1274
+ except ImportError:
1275
+ pass
1276
+ return (f"image may exceed {max_edge}px and no scaler is available "
1277
+ f"(sips/Pillow) — large images poison Claude image attach "
1278
+ f"after ~20 images per conversation")
1279
+ except Exception as error: # noqa: BLE001 — never fail a capture on scaling
1280
+ return f"downscale failed ({error}); image may exceed {max_edge}px"
1281
+
1282
+
1252
1283
  def cmd_screenshot(session, output=None, cleanup=False, full_page=True, selector=None):
1253
1284
  """Capture a screenshot and validate that it is not an empty frame."""
1254
1285
  session.screenshot_counter += 1
@@ -1269,6 +1300,18 @@ def cmd_screenshot(session, output=None, cleanup=False, full_page=True, selector
1269
1300
  else:
1270
1301
  session.page.screenshot(path=str(path), full_page=full_page)
1271
1302
 
1303
+ # ── Anthropic many-image rule (learned live, 2026-08-13) ─────────────
1304
+ # Past ~20 images in a conversation, the API caps every image at 2000px
1305
+ # per dimension and re-validates the WHOLE history on each request. One
1306
+ # oversized capture early in a session poisons every later attach — the
1307
+ # agent that must LOOK at screenshots loses the ability to see them,
1308
+ # mid-conversation, permanently. Full-page captures routinely exceed
1309
+ # 2000px in height, so every capture is normalized to a 1900px long edge
1310
+ # here, at the source. sips is macOS-only; elsewhere we fall back to
1311
+ # Pillow if present and otherwise WARN LOUDLY rather than emit poison
1312
+ # silently.
1313
+ _downscale_warning = _enforce_max_edge(path, 1900)
1314
+
1272
1315
  size = path.stat().st_size
1273
1316
  audit_log("screenshot", str(path), f"size={size},ephemeral={cleanup}")
1274
1317
  result = {
@@ -1281,6 +1324,8 @@ def cmd_screenshot(session, output=None, cleanup=False, full_page=True, selector
1281
1324
  # A blank or error frame must not report success: a screenshot is evidence
1282
1325
  # only if something actually rendered.
1283
1326
  warnings = []
1327
+ if _downscale_warning:
1328
+ warnings.append(_downscale_warning)
1284
1329
  if size < MIN_SCREENSHOT_BYTES:
1285
1330
  result["status"] = "failed"
1286
1331
  warnings.append(f"image is {size} bytes, below the {MIN_SCREENSHOT_BYTES}-byte floor")