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 +135 -0
- package/dist/postinstall.js +30 -0
- package/dist/promptRouteHostHook.js +394 -0
- package/dist/selfUpdate.js +109 -0
- package/dist/server.js +27 -2
- package/dist/tools/index.js +2 -2
- package/dist/tools/ledgerHandlers.js +68 -3
- package/dist/tools/promptRouteHandler.js +208 -0
- package/dist/tools/sessionMemoryDefinitions.js +44 -0
- package/dist/tools/skillRouting.js +18 -0
- package/dist/tools/skillScopeHandlers.js +9 -1
- package/package.json +4 -3
- package/scripts/dev/browse.py +45 -0
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.");
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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",
|
package/scripts/dev/browse.py
CHANGED
|
@@ -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")
|