prism-mcp-server 20.17.0 → 20.17.2
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/README.md +27 -3
- package/dist/cli.js +16 -34
- package/dist/sync/handoffSync.js +2 -2
- package/dist/tools/ledgerHandlers.js +94 -10
- package/dist/tools/scopedSkillTriggers.js +13 -3
- package/dist/tools/sessionMemoryDefinitions.js +1 -1
- package/dist/tools/skillRouting.js +166 -9
- package/dist/utils/routeOffload.js +59 -0
- package/dist/utils/vaultExporter.js +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,21 +138,45 @@ or by re-enabling after each run.
|
|
|
138
138
|
<details>
|
|
139
139
|
<summary>Release history (optional)</summary>
|
|
140
140
|
|
|
141
|
+
## What's New in v20.17.2
|
|
142
|
+
|
|
143
|
+
- **Pasted logs can no longer falsely activate skills.** Symptom-triggered
|
|
144
|
+
routing now strips fenced blocks and routable skill-name mentions (including
|
|
145
|
+
inside compound identifiers like container/pod names) from the routing view
|
|
146
|
+
of a prompt, so quoting an agent log or a skill list doesn't load skills the
|
|
147
|
+
text merely mentions. Skills named by ordinary words keep routing normally.
|
|
148
|
+
- **Symptom-routed skills now arrive whole.** Startup budgets deliver the full
|
|
149
|
+
rule text at every depth that fits; when a rule genuinely cannot fit, the
|
|
150
|
+
display says exactly how much is missing and how to load the rest, instead
|
|
151
|
+
of silently truncating.
|
|
152
|
+
- **Routing can no longer be silently disabled by one bad skill.** Corrupt or
|
|
153
|
+
hostile trigger tables — wrong value shapes, patterns named after object
|
|
154
|
+
prototype properties — are skipped per entry instead of taking down all
|
|
155
|
+
prompt routing (or, in one case, the vault export) for the session.
|
|
156
|
+
|
|
157
|
+
## What's New in v20.17.1
|
|
158
|
+
|
|
159
|
+
- **Fixes a broken CLI in 20.17.0** — a command-name collision made every
|
|
160
|
+
`prism` CLI invocation exit with a commander error at startup (the MCP
|
|
161
|
+
server was unaffected). The handoff-sync command is `prism handoff …`;
|
|
162
|
+
`prism sync` remains cross-backend data synchronization. If you installed
|
|
163
|
+
20.17.0, update.
|
|
164
|
+
|
|
141
165
|
## What's New in v20.17.0
|
|
142
166
|
|
|
143
167
|
### Cross-Machine Session Handoff — End-to-End Encrypted
|
|
144
168
|
|
|
145
|
-
- **Resume a session on any of your machines.** With `prism
|
|
169
|
+
- **Resume a session on any of your machines.** With `prism handoff enable` (paid,
|
|
146
170
|
off by default), each `session_save_handoff` seals the handoff to all your
|
|
147
171
|
account's device keys and relays the CIPHERTEXT; another machine pulls with
|
|
148
|
-
`sync_pull_handoff` (or `prism
|
|
172
|
+
`sync_pull_handoff` (or `prism handoff pull <project>`) and opens it locally.
|
|
149
173
|
- **The relay stores ciphertext only** — X25519 + AES-256-GCM sealed
|
|
150
174
|
envelopes. No key that opens a handoff ever exists server-side. The channel
|
|
151
175
|
is deliberately separate from savings sync, which carries counters only.
|
|
152
176
|
- **TOFU device pinning** surfaces a compromised relay: sealing to a key this
|
|
153
177
|
machine has never seen warns loudly, keyed on the client-derived recipient
|
|
154
178
|
id so a swapped key can't hide behind a familiar device name.
|
|
155
|
-
- `prism
|
|
179
|
+
- `prism handoff status|devices` to inspect; revoke a lost machine from the portal.
|
|
156
180
|
|
|
157
181
|
## What's New in v20.16.0
|
|
158
182
|
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync
|
|
5
|
-
import { homedir } from 'node:os';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
6
5
|
import { SqliteStorage } from './storage/sqlite.js';
|
|
7
6
|
import { handleVerifyStatus, handleGenerateHarness } from './verification/cliHandler.js';
|
|
8
7
|
import * as path from 'path';
|
|
@@ -495,31 +494,12 @@ program
|
|
|
495
494
|
// that can fail a turn gets uninstalled; a hook that is slow gets noticed.
|
|
496
495
|
/** Deliberate offload for payloads over the host inline cap. The host's own
|
|
497
496
|
* overflow path swaps the payload for a 2KB preview with no instruction to
|
|
498
|
-
* read the rest; this file plus the inline pointer is the recoverable form.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
// Best-effort prune: one file per over-budget routed prompt, kept a week.
|
|
505
|
-
for (const f of readdirSync(dir)) {
|
|
506
|
-
const p = path.join(dir, f);
|
|
507
|
-
try {
|
|
508
|
-
if (Date.now() - statSync(p).mtimeMs > 7 * 86_400_000)
|
|
509
|
-
rmSync(p);
|
|
510
|
-
}
|
|
511
|
-
catch { /* skip unstat-able entries */ }
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
catch { /* prune failure never blocks the write */ }
|
|
515
|
-
const target = path.join(dir, `route-${Date.now()}-${process.pid}.md`);
|
|
516
|
-
writeFileSync(target, fullText);
|
|
517
|
-
return target;
|
|
518
|
-
}
|
|
519
|
-
catch {
|
|
520
|
-
return undefined; // reshape degrades to the loud in-band fallback
|
|
521
|
-
}
|
|
522
|
-
}
|
|
497
|
+
* read the rest; this file plus the inline pointer is the recoverable form.
|
|
498
|
+
* Round-2 review: this file kept its own UNBOUNDED copy after the shared
|
|
499
|
+
* extraction — two implementations, one fixed, the hook path (the hotter
|
|
500
|
+
* one) still stat-ing every entry. One shared implementation serves both. */
|
|
501
|
+
import { writeRouteOffload as sharedWriteRouteOffload } from './utils/routeOffload.js';
|
|
502
|
+
const writeRouteOffload = (fullText) => sharedWriteRouteOffload(fullText, 'route');
|
|
523
503
|
program
|
|
524
504
|
.command('route-prompt')
|
|
525
505
|
.description('Match a prompt (stdin) against skill triggers; prints {names, text} JSON. Used by the prism-route host hook.')
|
|
@@ -1134,12 +1114,14 @@ program
|
|
|
1134
1114
|
process.exit(1);
|
|
1135
1115
|
}
|
|
1136
1116
|
});
|
|
1137
|
-
// ─── prism
|
|
1138
|
-
// Cross-machine handoff sync controls
|
|
1139
|
-
//
|
|
1117
|
+
// ─── prism handoff ────────────────────────────────────────────
|
|
1118
|
+
// Cross-machine handoff sync controls ('sync' was taken by cross-backend
|
|
1119
|
+
// data synchronization above — a collision the stale local dist hid until
|
|
1120
|
+
// CI built fresh). Push happens automatically on session_save_handoff once
|
|
1121
|
+
// enabled; everything E2E lives in src/crypto/.
|
|
1140
1122
|
program
|
|
1141
|
-
.command('
|
|
1142
|
-
.description('Cross-machine handoff sync: enable | disable | status | pull <project> | devices')
|
|
1123
|
+
.command('handoff <action> [project]')
|
|
1124
|
+
.description('Cross-machine handoff sync (E2E): enable | disable | status | pull <project> | devices')
|
|
1143
1125
|
.action(async (action, project) => {
|
|
1144
1126
|
try {
|
|
1145
1127
|
switch (action) {
|
|
@@ -1150,7 +1132,7 @@ program
|
|
|
1150
1132
|
console.log(action === 'enable'
|
|
1151
1133
|
? 'Handoff sync enabled. On each session_save_handoff, the handoff is sealed to your '
|
|
1152
1134
|
+ 'account devices (end-to-end encrypted — the relay stores ciphertext only) and uploaded. '
|
|
1153
|
-
+ 'Paid plans; disable anytime with: prism
|
|
1135
|
+
+ 'Paid plans; disable anytime with: prism handoff disable'
|
|
1154
1136
|
: 'Handoff sync disabled. Nothing further leaves this machine on this channel.');
|
|
1155
1137
|
return;
|
|
1156
1138
|
}
|
|
@@ -1166,7 +1148,7 @@ program
|
|
|
1166
1148
|
}
|
|
1167
1149
|
case 'pull': {
|
|
1168
1150
|
if (!project) {
|
|
1169
|
-
console.error('Usage: prism
|
|
1151
|
+
console.error('Usage: prism handoff pull <project>');
|
|
1170
1152
|
process.exit(1);
|
|
1171
1153
|
}
|
|
1172
1154
|
const { pullHandoff, renderPulledHandoff } = await import('./sync/handoffSync.js');
|
package/dist/sync/handoffSync.js
CHANGED
|
@@ -232,7 +232,7 @@ export async function pushHandoffFromArgs(args) {
|
|
|
232
232
|
const result = await pushHandoff(project, o);
|
|
233
233
|
if (result.pushed && result.new_devices?.length) {
|
|
234
234
|
console.error(`[handoff-sync] ⚠ NEW sync device(s) on your account: ${result.new_devices.join(", ")}. ` +
|
|
235
|
-
`If you did not add a machine, revoke it: prism
|
|
235
|
+
`If you did not add a machine, revoke it: prism handoff devices`);
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
export async function pullHandoff(project, fetchImpl = fetch) {
|
|
@@ -289,7 +289,7 @@ export async function pullHandoff(project, fetchImpl = fetch) {
|
|
|
289
289
|
export function renderPulledHandoff(r) {
|
|
290
290
|
if (!r.ok || !r.payload) {
|
|
291
291
|
const why = {
|
|
292
|
-
disabled: "Handoff sync is off on this machine. Enable: prism
|
|
292
|
+
disabled: "Handoff sync is off on this machine. Enable: prism handoff enable",
|
|
293
293
|
no_blob: "No synced handoff exists for this project yet.",
|
|
294
294
|
not_recipient: "A handoff exists but was sealed before this device joined — it will include this machine after the next save elsewhere.",
|
|
295
295
|
not_entitled: "Cross-machine sync needs a paid plan and a signed-in account.",
|
|
@@ -109,10 +109,61 @@ const MAX_SYMPTOM_SKILLS = 5;
|
|
|
109
109
|
* instruction rewrites failed for that reason before it was found. Inlining
|
|
110
110
|
* removes the indirection entirely: the rule is simply in context.
|
|
111
111
|
*/
|
|
112
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Ceiling on an inlined rule — deliberately ABOVE every depth's budget share so
|
|
114
|
+
* the SHARE governs, not this constant.
|
|
115
|
+
*
|
|
116
|
+
* It was 1_800, which made it the binding constraint at every depth that has
|
|
117
|
+
* room: standard allots 8_000*0.4 = 3_200 and deep allots 30_000*0.4 = 12_000,
|
|
118
|
+
* so deep discarded 10_200 chars of already-budgeted space. Every super-skill
|
|
119
|
+
* is 8-15 KB, so a symptom-routed super-skill always arrived at 12-23% of its
|
|
120
|
+
* text while the same display instructed the agent to "follow them before
|
|
121
|
+
* proposing any change" — compliance demanded against a rule mostly absent.
|
|
122
|
+
* At 12_000 a full dev-engineering-super-skill (8_863) now fits whole at deep
|
|
123
|
+
* depth; quick is unchanged because its share (1_600) is still the smaller.
|
|
124
|
+
*/
|
|
125
|
+
const SYMPTOM_SKILL_INLINE_MAX = 12_000;
|
|
113
126
|
const SYMPTOM_SKILL_BUDGET_SHARE = 0.4;
|
|
114
127
|
/** Below this the inlined rule is too clipped to be worth the space it costs. */
|
|
115
128
|
const SYMPTOM_SKILL_INLINE_MIN = 400;
|
|
129
|
+
/**
|
|
130
|
+
* Upper bound on the suffix chrome beyond the body for ONE inlined skill:
|
|
131
|
+
* names line + imperative (~230), truncation notice (~200 + the name twice),
|
|
132
|
+
* offload pointer (~30 + path, ≤ ~180 under $HOME). Round-2 review measured
|
|
133
|
+
* the old flat 600 undercounting by 2-3x for long names — this is a FUNCTION
|
|
134
|
+
* of the name so the reserve is honest for every name the table allows
|
|
135
|
+
* (≤128 chars), and a property test pins estimate ≥ measured chrome. The
|
|
136
|
+
* final cap guarantee in capNativeStartupText remains the hard backstop.
|
|
137
|
+
*/
|
|
138
|
+
export function symptomChromeUpperBound(name) {
|
|
139
|
+
return 480 + 2 * name.length + 220; // imperative+notice + 2×name + path slack
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Render one symptom-routed skill body for the startup display. EXPORTED and
|
|
143
|
+
* pure so tests execute the REAL rendering: the first test suite asserted the
|
|
144
|
+
* notice strings existed in source text, which a dead-code wrap around the
|
|
145
|
+
* call site would have satisfied while shipping nothing (adversarial review,
|
|
146
|
+
* confirmed). A test that calls this function fails the moment the strings —
|
|
147
|
+
* or the branch — stop being live.
|
|
148
|
+
*
|
|
149
|
+
* When the body exceeds `cap`, the excerpt says how much is missing, names
|
|
150
|
+
* the skill so ANY host can load it by name (hosts without hooks — Codex —
|
|
151
|
+
* or without filesystem tools cannot follow a path), and forbids treating
|
|
152
|
+
* the excerpt as the whole rule. The offload path is an extra route only.
|
|
153
|
+
*/
|
|
154
|
+
export function formatSymptomSkillInline(name, body, cap, offloadPath) {
|
|
155
|
+
if (body.length <= cap) {
|
|
156
|
+
return `\n--- ${name} ---\n${body}\n`;
|
|
157
|
+
}
|
|
158
|
+
const missing = body.length - cap;
|
|
159
|
+
return (`\n--- ${name} (showing ${cap} of ${body.length} chars) ---\n` +
|
|
160
|
+
`${sliceCodepointSafe(body, cap).trimEnd()}\n` +
|
|
161
|
+
`… TRUNCATED — ${missing} chars of this rule are NOT shown above. ` +
|
|
162
|
+
`Load the full skill by name (\`${name}\`) before acting on it; ` +
|
|
163
|
+
`do not treat the excerpt as the whole rule.` +
|
|
164
|
+
(offloadPath ? ` Complete text also saved at: ${offloadPath}` : "") +
|
|
165
|
+
`\n`);
|
|
166
|
+
}
|
|
116
167
|
/**
|
|
117
168
|
* The character budget a native startup display will actually be capped to.
|
|
118
169
|
*
|
|
@@ -442,13 +493,36 @@ async function readDashboardUrl() {
|
|
|
442
493
|
});
|
|
443
494
|
return healthy ? `http://localhost:${port}` : null;
|
|
444
495
|
}
|
|
445
|
-
|
|
496
|
+
/** slice() that never ends on a lone high surrogate — round-2 review showed
|
|
497
|
+
* UTF-16 slicing emitting broken pairs that corrupt downstream rendering. */
|
|
498
|
+
export function sliceCodepointSafe(text, end) {
|
|
499
|
+
const cut = text.slice(0, Math.max(0, end));
|
|
500
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
501
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
502
|
+
}
|
|
503
|
+
export function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
|
|
446
504
|
const maxChars = effectiveNativeBudget(level, requestedMaxChars);
|
|
447
505
|
if (text.length + suffix.length <= maxChars)
|
|
448
506
|
return text + suffix;
|
|
449
507
|
const marker = `\n\n… Additional ${level} context omitted to keep native startup within its display budget.`;
|
|
450
|
-
|
|
451
|
-
|
|
508
|
+
// FINAL guarantee, not best-effort. The old keepChars=max(0, …) silently
|
|
509
|
+
// returned marker+suffix UNCHECKED when the suffix alone exceeded the
|
|
510
|
+
// budget — at heavily-divided multi-project bootstrap budgets the response
|
|
511
|
+
// then blew past the per-project cap, recreating at the host layer exactly
|
|
512
|
+
// the arbitrary truncation this display exists to prevent (adversarial
|
|
513
|
+
// review, reproduced). If the suffix cannot fit beside the marker, the
|
|
514
|
+
// suffix itself is trimmed with its own honest marker; the return is never
|
|
515
|
+
// longer than maxChars.
|
|
516
|
+
let cappedSuffix = suffix;
|
|
517
|
+
const suffixBudget = maxChars - marker.length;
|
|
518
|
+
if (cappedSuffix.length > suffixBudget) {
|
|
519
|
+
const suffixMarker = `\n… symptom-skill excerpt shortened to fit this project's startup budget; load the skill by name for the full rule.`;
|
|
520
|
+
cappedSuffix = sliceCodepointSafe(cappedSuffix, suffixBudget - suffixMarker.length).trimEnd() + suffixMarker;
|
|
521
|
+
if (cappedSuffix.length > suffixBudget)
|
|
522
|
+
cappedSuffix = sliceCodepointSafe(cappedSuffix, suffixBudget);
|
|
523
|
+
}
|
|
524
|
+
const keepChars = Math.max(0, maxChars - marker.length - cappedSuffix.length);
|
|
525
|
+
return sliceCodepointSafe(text, keepChars).trimEnd() + marker + cappedSuffix;
|
|
452
526
|
}
|
|
453
527
|
function compactWithOmissionCount(value, maxChars) {
|
|
454
528
|
const text = typeof value === "string" ? value.trim() : String(value ?? "").trim();
|
|
@@ -1469,14 +1543,21 @@ export async function sessionLoadContextHandler(args, options = {}) {
|
|
|
1469
1543
|
const body = stripSkillFrontmatter(await readNativeSkillBody(shown[0]));
|
|
1470
1544
|
// Size against the budget this display will ACTUALLY be capped to,
|
|
1471
1545
|
// not the level constant — bootstrap divides it across projects.
|
|
1472
|
-
|
|
1546
|
+
// Reserve the suffix chrome (a function of the name — see
|
|
1547
|
+
// symptomChromeUpperBound) so body+chrome stay inside the share
|
|
1548
|
+
// instead of overflowing into the context's portion at small
|
|
1549
|
+
// divided budgets.
|
|
1550
|
+
const budgetForLevel = effectiveNativeBudget(level, options.nativeMaxChars);
|
|
1551
|
+
const cap = Math.min(SYMPTOM_SKILL_INLINE_MAX, Math.floor(budgetForLevel * SYMPTOM_SKILL_BUDGET_SHARE), Math.max(0, budgetForLevel - symptomChromeUpperBound(shown[0])));
|
|
1473
1552
|
// Too tight to carry a useful rule: keep the name line, which is
|
|
1474
1553
|
// small, and leave the remaining budget to the session context.
|
|
1475
1554
|
if (body && cap >= SYMPTOM_SKILL_INLINE_MIN) {
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1555
|
+
let offload;
|
|
1556
|
+
if (body.length > cap) {
|
|
1557
|
+
const { writeRouteOffload } = await import("../utils/routeOffload.js");
|
|
1558
|
+
offload = writeRouteOffload(`# ${shown[0]}\n\n${body}`, "skill");
|
|
1559
|
+
}
|
|
1560
|
+
symptomSkillSuffix += formatSymptomSkillInline(shown[0], body, cap, offload);
|
|
1480
1561
|
}
|
|
1481
1562
|
}
|
|
1482
1563
|
}
|
|
@@ -1867,7 +1948,10 @@ export function buildSessionFactsLine(facts) {
|
|
|
1867
1948
|
export async function collectSkillTriggersOnThisMachine() {
|
|
1868
1949
|
try {
|
|
1869
1950
|
const { collectScopedTriggers, collectLocalSkillTriggers } = await import("./scopedSkillTriggers.js");
|
|
1870
|
-
|
|
1951
|
+
// Null-prototype for the same reason as the accumulators in
|
|
1952
|
+
// scopedSkillTriggers.ts (round-5): a user-authored pattern named
|
|
1953
|
+
// __proto__/constructor must be a plain data key, not an inherited read.
|
|
1954
|
+
const merged = Object.create(null);
|
|
1871
1955
|
const localNames = new Set();
|
|
1872
1956
|
const errors = [];
|
|
1873
1957
|
const settings = await getAllSettings();
|
|
@@ -101,7 +101,17 @@ function unquote(value) {
|
|
|
101
101
|
* reader already used by skill_save so the two agree on what a skill file is.
|
|
102
102
|
*/
|
|
103
103
|
export function extractSkillTriggers(skillName, content) {
|
|
104
|
-
|
|
104
|
+
// NULL-PROTOTYPE accumulator (round-5 review): `prompt_triggers` is
|
|
105
|
+
// user-authored text, and a pattern whose TEXT is an inherited property
|
|
106
|
+
// name (__proto__, constructor, toString…) made the plain-object merge
|
|
107
|
+
// idiom `(triggers[pattern] ||= []).push(…)` read the inherited value —
|
|
108
|
+
// truthy, no .push → throw. That throw escaped collectScopedTriggers and
|
|
109
|
+
// was swallowed by collectSkillTriggersOnThisMachine's outer catch,
|
|
110
|
+
// silently discarding EVERY skill's triggers on this machine, not just
|
|
111
|
+
// the poisoned one. A null prototype has nothing to inherit, so the
|
|
112
|
+
// idiom is a plain data-property op for any key. Same treatment at every
|
|
113
|
+
// trigger accumulator in this file and in ledgerHandlers' merge.
|
|
114
|
+
const result = { triggers: Object.create(null), errors: [] };
|
|
105
115
|
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
|
106
116
|
if (!frontmatter)
|
|
107
117
|
return result;
|
|
@@ -173,7 +183,7 @@ export function extractSkillTriggers(skillName, content) {
|
|
|
173
183
|
const MAX_LOCAL_SKILLS = 300;
|
|
174
184
|
const MAX_LOCAL_FILE_BYTES = 64 * 1024;
|
|
175
185
|
export async function collectLocalSkillTriggers(roots, fs, join) {
|
|
176
|
-
const merged = { triggers:
|
|
186
|
+
const merged = { triggers: Object.create(null), errors: [], names: [] };
|
|
177
187
|
let budget = MAX_LOCAL_SKILLS;
|
|
178
188
|
const seen = new Set();
|
|
179
189
|
for (const root of roots) {
|
|
@@ -214,7 +224,7 @@ export async function collectLocalSkillTriggers(roots, fs, join) {
|
|
|
214
224
|
return merged;
|
|
215
225
|
}
|
|
216
226
|
export function collectScopedTriggers(skills) {
|
|
217
|
-
const merged = { triggers:
|
|
227
|
+
const merged = { triggers: Object.create(null), errors: [] };
|
|
218
228
|
for (const [name, content] of skills) {
|
|
219
229
|
if (!content || !content.includes("prompt_triggers"))
|
|
220
230
|
continue; // cheap pre-filter
|
|
@@ -1958,7 +1958,7 @@ export const SYNC_PULL_HANDOFF_TOOL = {
|
|
|
1958
1958
|
description: "Pulls this account's synced handoff for a project from the E2E relay and " +
|
|
1959
1959
|
"opens it with THIS machine's device key. The relay stores ciphertext only; " +
|
|
1960
1960
|
"a handoff is readable here only if it was sealed to this device. Requires " +
|
|
1961
|
-
"handoff sync enabled (prism
|
|
1961
|
+
"handoff sync enabled (prism handoff enable), a paid plan, and a signed-in " +
|
|
1962
1962
|
"account. Push happens automatically on session_save_handoff.",
|
|
1963
1963
|
inputSchema: {
|
|
1964
1964
|
type: "object",
|
|
@@ -109,6 +109,10 @@ export function _setStorage(persist, read) {
|
|
|
109
109
|
persistFn = persist;
|
|
110
110
|
readFn = read;
|
|
111
111
|
}
|
|
112
|
+
/** Test-only handle, matching this file's underscore convention for
|
|
113
|
+
* internals exposed to tests (round-2 review). Production callers use the
|
|
114
|
+
* module-internal function directly. */
|
|
115
|
+
export const _toResolvedSkillsWithPrompt = (...args) => toResolvedSkillsWithPrompt(...args);
|
|
112
116
|
function synaluxBase() {
|
|
113
117
|
return (process.env.PRISM_SYNALUX_BASE_URL?.trim() ||
|
|
114
118
|
process.env.SYNALUX_BASE_URL?.trim() || PRISM_SYNALUX_BASE_URL ||
|
|
@@ -199,6 +203,29 @@ function isKeywordTable(v) {
|
|
|
199
203
|
return !!t && typeof t.version === 'number' && !!t.prompt_keywords
|
|
200
204
|
&& typeof t.prompt_keywords === 'object';
|
|
201
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Enforce the table's value contract at INGEST, once, for every consumer.
|
|
208
|
+
* isKeywordTable only proves prompt_keywords is an object — round-4 review
|
|
209
|
+
* showed a string value ('notarray') sails through and the matcher's
|
|
210
|
+
* `for (const skillName of skills)` iterates it CHARACTER BY CHARACTER,
|
|
211
|
+
* synthesizing bogus one-letter "skill names". Non-array values are dropped,
|
|
212
|
+
* non-string members filtered, matching the scoped-trigger merge's policy.
|
|
213
|
+
* The result is NULL-PROTOTYPE so a hostile pattern key ('__proto__',
|
|
214
|
+
* 'constructor') can neither read an inherited value nor mutate a prototype
|
|
215
|
+
* downstream.
|
|
216
|
+
*/
|
|
217
|
+
function sanitizePromptKeywords(raw) {
|
|
218
|
+
const clean = Object.create(null);
|
|
219
|
+
for (const [pattern, names] of Object.entries(raw)) {
|
|
220
|
+
if (!Array.isArray(names))
|
|
221
|
+
continue;
|
|
222
|
+
const strings = names.filter((n) => typeof n === 'string');
|
|
223
|
+
if (strings.length === 0)
|
|
224
|
+
continue;
|
|
225
|
+
clean[pattern] = strings;
|
|
226
|
+
}
|
|
227
|
+
return clean;
|
|
228
|
+
}
|
|
202
229
|
/**
|
|
203
230
|
* @param expectVersion routing_version the portal just reported. A mismatch
|
|
204
231
|
* means our cached copy predates a routing deploy, so drop it and refetch
|
|
@@ -222,8 +249,9 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
222
249
|
const stored = await readFn(TABLE_STORAGE_KEY);
|
|
223
250
|
const parsed = stored ? JSON.parse(stored) : null;
|
|
224
251
|
if (isKeywordTable(parsed) && parsed.version === expectVersion) {
|
|
225
|
-
|
|
226
|
-
|
|
252
|
+
const table = { version: parsed.version, prompt_keywords: sanitizePromptKeywords(parsed.prompt_keywords) };
|
|
253
|
+
kwCache = { table, at: Date.now() };
|
|
254
|
+
return table;
|
|
227
255
|
}
|
|
228
256
|
}
|
|
229
257
|
catch { /* fall through to network */ }
|
|
@@ -242,7 +270,7 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
242
270
|
const raw = await res.json();
|
|
243
271
|
if (!isKeywordTable(raw))
|
|
244
272
|
throw new Error('malformed routing table');
|
|
245
|
-
const table = { version: raw.version, prompt_keywords: raw.prompt_keywords };
|
|
273
|
+
const table = { version: raw.version, prompt_keywords: sanitizePromptKeywords(raw.prompt_keywords) };
|
|
246
274
|
kwCache = { table, at: Date.now() };
|
|
247
275
|
if (persistFn) {
|
|
248
276
|
try {
|
|
@@ -262,8 +290,9 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
262
290
|
const stored = await readFn(TABLE_STORAGE_KEY);
|
|
263
291
|
const parsed = stored ? JSON.parse(stored) : null;
|
|
264
292
|
if (isKeywordTable(parsed)) {
|
|
265
|
-
|
|
266
|
-
|
|
293
|
+
const table = { version: parsed.version, prompt_keywords: sanitizePromptKeywords(parsed.prompt_keywords) };
|
|
294
|
+
kwCache = { table, at: 0 }; // at:0 → retry live on next call
|
|
295
|
+
return table;
|
|
267
296
|
}
|
|
268
297
|
}
|
|
269
298
|
catch { /* fall through to null */ }
|
|
@@ -277,6 +306,113 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
277
306
|
}
|
|
278
307
|
return kwInflight;
|
|
279
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Strip QUOTED EVIDENCE from a prompt before trigger matching.
|
|
311
|
+
*
|
|
312
|
+
* Triggers are meant to fire on a symptom the user is REPORTING, not on every
|
|
313
|
+
* string that happens to appear in material they pasted as evidence. Observed
|
|
314
|
+
* 2026-08-31: a user asked "what's going on with skill loading?" and pasted a
|
|
315
|
+
* startup log; the log listed installed skill names, and the literal token
|
|
316
|
+
* `fusa-bss-billing` inside it satisfied that skill's own trigger
|
|
317
|
+
* `\bfusa\b.{0,20}\b(billing|invoice)\b`. Two unrelated private skills loaded
|
|
318
|
+
* and were injected as binding rules for a debugging question — pasting a log
|
|
319
|
+
* that NAMES a skill should never activate it.
|
|
320
|
+
*
|
|
321
|
+
* Two removals, both conservative:
|
|
322
|
+
* 1. Fenced code blocks — pasted output, by convention.
|
|
323
|
+
* 2. Hyphenated skill-name tokens (`foo-bar-baz`). A bare skill name is
|
|
324
|
+
* metadata about the system, not a description of work. Removing only the
|
|
325
|
+
* NAME SPAN keeps its constituent words available: "fusa billing invoice"
|
|
326
|
+
* typed by the user still matches, because that text is not a name token.
|
|
327
|
+
*
|
|
328
|
+
* Deliberately NOT length-capped: a long prompt is not evidence of pasting,
|
|
329
|
+
* and truncating input would silently stop matching real symptoms stated late.
|
|
330
|
+
*/
|
|
331
|
+
export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
|
|
332
|
+
// Fences are LINE-ANCHORED: a pasted code block starts its ``` at the
|
|
333
|
+
// beginning of a line by convention. The first version paired ANY two
|
|
334
|
+
// occurrences of the marker anywhere in the string, so two incidental
|
|
335
|
+
// inline backtick-triples bracketing real user-typed symptom text ate that
|
|
336
|
+
// text (adversarial review, confirmed with a repro). Line-anchoring means
|
|
337
|
+
// eating text now requires two line-start fences — which IS a fenced block.
|
|
338
|
+
//
|
|
339
|
+
// Replacement must sever BOTH proximity-window classes in the real table:
|
|
340
|
+
// - `.{0,N}` windows: `.` does not cross \n (no pattern uses the s-flag),
|
|
341
|
+
// so a newline severs them.
|
|
342
|
+
// - `\s*`/`\s+`-glued windows (34 of 58 live patterns, e.g.
|
|
343
|
+
// `\bui\s*test\b`): \n IS \s, so a bare newline does NOT sever them —
|
|
344
|
+
// round-2 review reproduced `ui <stripped-name> test` routing
|
|
345
|
+
// xcuitest-ios-watch through exactly that gap. The separator therefore
|
|
346
|
+
// includes \x1F (unit separator): non-space (blocks \s runs), non-word
|
|
347
|
+
// (leaves \b semantics as a space would), and severed from dot-windows
|
|
348
|
+
// by the flanking newlines.
|
|
349
|
+
const SEVER = '\n\x1f\n';
|
|
350
|
+
let out = prompt
|
|
351
|
+
.replace(/^[ \t]*```[^\n]*\n[\s\S]*?\n[ \t]*```[ \t]*$/gm, SEVER)
|
|
352
|
+
.replace(/^[ \t]*~~~[^\n]*\n[\s\S]*?\n[ \t]*~~~[ \t]*$/gm, SEVER);
|
|
353
|
+
// Strip only the names of skills that this very table could route. A generic
|
|
354
|
+
// "identifier-shaped token" heuristic was tried first and rejected by test:
|
|
355
|
+
// it also ate ordinary hyphenated English ("end-to-end", "well-formed"),
|
|
356
|
+
// which risks silently SUPPRESSING a real symptom — a worse failure than the
|
|
357
|
+
// false positive being fixed. Using the actual routable names is exact.
|
|
358
|
+
//
|
|
359
|
+
// Deliberate consequence, not an oversight: a user who TYPES a skill's name
|
|
360
|
+
// ("update fusa-bss-billing's invoice rate") no longer trigger-routes that
|
|
361
|
+
// skill. That is acceptable because the agent reads the raw prompt and can
|
|
362
|
+
// invoke a literally-named skill directly — trigger routing exists for
|
|
363
|
+
// SYMPTOM text, where the name is absent. A pasted log naming skills must
|
|
364
|
+
// not route them; a typed name doesn't need routing to be honored.
|
|
365
|
+
const names = new Set();
|
|
366
|
+
// Non-string entries (a corrupted cache serializes undefined → null) must
|
|
367
|
+
// never reach the sort comparator below — round-2 review reproduced an
|
|
368
|
+
// uncaught TypeError from `.length` on null OUTSIDE the try/catch, killing
|
|
369
|
+
// routing for the whole prompt. Filter at collection, the only choke point.
|
|
370
|
+
for (const list of Object.values(promptKeywords)) {
|
|
371
|
+
if (!Array.isArray(list))
|
|
372
|
+
continue;
|
|
373
|
+
for (const n of list)
|
|
374
|
+
if (typeof n === "string")
|
|
375
|
+
names.add(n);
|
|
376
|
+
}
|
|
377
|
+
// Longest first, so a name that contains another is removed whole.
|
|
378
|
+
for (const name of [...names].sort((a, b) => b.length - a.length)) {
|
|
379
|
+
// Bounds mirror the routing table's own name policy (≤128 chars). An
|
|
380
|
+
// overlong or hostile name from a poisoned table must degrade to
|
|
381
|
+
// "not stripped", never to a thrown SyntaxError that kills routing for
|
|
382
|
+
// the whole prompt — the sibling _applyPromptRouting swallows bad
|
|
383
|
+
// patterns for the same reason (adversarial review, confirmed).
|
|
384
|
+
// Only IDENTIFIER-SHAPED names are stripped: at least two segments joined
|
|
385
|
+
// by - or _ (fusa-bss-billing, training-results-gate). Round-3 review
|
|
386
|
+
// proved the unconditional version was self-defeating for skills whose
|
|
387
|
+
// name is an ordinary word: stripping `sentry` from "check sentry for
|
|
388
|
+
// recent errors" killed that skill's OWN trigger (\bsentry\b) — 100% of
|
|
389
|
+
// its realistic phrasings routed nothing, same for linear/pdf/supabase.
|
|
390
|
+
// A single ordinary word cannot be distinguished from prose, so it is
|
|
391
|
+
// never stripped; the accepted residual is that a pasted skill LIST can
|
|
392
|
+
// still route single-word-named skills.
|
|
393
|
+
//
|
|
394
|
+
// Anchoring is SEGMENT-aligned, not token-aligned (round-4 review): the
|
|
395
|
+
// name must not butt directly against a letter/digit, but MAY butt
|
|
396
|
+
// against segment glue (-/_). Round 3's stricter (?<![\w-]) anchors
|
|
397
|
+
// refused to strip the name out of longer compounds — a pasted
|
|
398
|
+
// `fusa-bss-billing-worker` container name survived intact, and because
|
|
399
|
+
// \b fires at every internal hyphen, the skill's own trigger still
|
|
400
|
+
// matched inside it: the exact incident class this function exists to
|
|
401
|
+
// kill. Alignment on segment edges strips those compounds while still
|
|
402
|
+
// refusing mid-token overlaps (the name `fix-ci` never fires inside the
|
|
403
|
+
// unrelated word `prefix-ci`, whose own trigger must survive).
|
|
404
|
+
if (name.length < 3 || name.length > 128 || !/[a-z0-9]/i.test(name))
|
|
405
|
+
continue;
|
|
406
|
+
if (!/[-_]/.test(name))
|
|
407
|
+
continue;
|
|
408
|
+
try {
|
|
409
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
410
|
+
out = out.replace(new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`, 'gi'), SEVER);
|
|
411
|
+
}
|
|
412
|
+
catch { /* skip unbuildable names — same policy as the matcher */ }
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
280
416
|
/**
|
|
281
417
|
* Verbatim port of portal resolve/route.ts prompt-matching block + the sort
|
|
282
418
|
* that follows it. Parity is the whole point: any divergence silently changes
|
|
@@ -285,6 +421,11 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
285
421
|
*
|
|
286
422
|
* `priority: 200 + resolved.length` reads the length AT PUSH TIME, so it
|
|
287
423
|
* depends on how many skills precede it. Preserved exactly.
|
|
424
|
+
*
|
|
425
|
+
* NOTE: callers pass a prompt already run through
|
|
426
|
+
* `stripQuotedEvidenceForRouting`. The matching semantics here are untouched —
|
|
427
|
+
* only the INPUT is normalized — so the parity contract with the portal's
|
|
428
|
+
* reference implementation still holds for any given input string.
|
|
288
429
|
*/
|
|
289
430
|
export function _applyPromptRouting(base, prompt, promptKeywords) {
|
|
290
431
|
const resolved = base.map((s) => ({ ...s }));
|
|
@@ -339,11 +480,27 @@ export async function resolvePromptSkillNames(prompt, expectVersion, scopedTrigg
|
|
|
339
480
|
const publicKeywords = kw?.prompt_keywords ?? {};
|
|
340
481
|
if (!kw && !scopedTriggers)
|
|
341
482
|
return [];
|
|
342
|
-
|
|
483
|
+
// NULL-PROTOTYPE, not a literal (round-4 review): with a plain object, a
|
|
484
|
+
// scoped pattern whose TEXT is an inherited property name made both sides
|
|
485
|
+
// of the merge below misbehave — `combined['constructor'] ?? []` read the
|
|
486
|
+
// inherited constructor (truthy, not iterable → throw, blanking ALL
|
|
487
|
+
// routing for the turn), and `combined['__proto__'] = …` invoked the
|
|
488
|
+
// prototype setter instead of storing a pattern. A null prototype has
|
|
489
|
+
// nothing to inherit, so both operations are plain data-property access.
|
|
490
|
+
const combined = Object.assign(Object.create(null), publicKeywords);
|
|
343
491
|
for (const [pattern, names] of Object.entries(scopedTriggers ?? {})) {
|
|
344
|
-
|
|
492
|
+
// A malformed skill body can hand this merge null/42/{} for a pattern —
|
|
493
|
+
// spreading that threw "names is not iterable" here, silently blanking
|
|
494
|
+
// ALL routing for the turn at both callers (round-3 review). Degrade to
|
|
495
|
+
// skip, matching the never-throw policy everywhere else in this path.
|
|
496
|
+
if (!Array.isArray(names))
|
|
497
|
+
continue;
|
|
498
|
+
const clean = names.filter((n) => typeof n === "string");
|
|
499
|
+
if (clean.length === 0)
|
|
500
|
+
continue;
|
|
501
|
+
combined[pattern] = [...(combined[pattern] ?? []), ...clean];
|
|
345
502
|
}
|
|
346
|
-
return _applyPromptRouting([], prompt, combined).map((s) => s.name);
|
|
503
|
+
return _applyPromptRouting([], stripQuotedEvidenceForRouting(prompt, combined), combined).map((s) => s.name);
|
|
347
504
|
}
|
|
348
505
|
/**
|
|
349
506
|
* Free tier resolves to an empty set portal-side, so adding prompt-matched
|
|
@@ -366,7 +523,7 @@ async function toResolvedSkillsWithPrompt(resp, prompt, isOffline) {
|
|
|
366
523
|
`prompt-matched skills may lag a routing deploy`);
|
|
367
524
|
}
|
|
368
525
|
}
|
|
369
|
-
skills = _applyPromptRouting(skills, prompt, kw.prompt_keywords);
|
|
526
|
+
skills = _applyPromptRouting(skills, stripQuotedEvidenceForRouting(prompt, kw.prompt_keywords), kw.prompt_keywords);
|
|
370
527
|
}
|
|
371
528
|
}
|
|
372
529
|
return {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overflow sink for routed skill text that cannot fit an inline budget.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from cli.ts (the UserPromptSubmit hook path) so the BOOTSTRAP path
|
|
5
|
+
* can use the same mechanism. Before this was shared, bootstrap had no overflow
|
|
6
|
+
* at all: it clipped a routed rule at SYMPTOM_SKILL_INLINE_MAX (1,800 chars)
|
|
7
|
+
* and appended "… (rule truncated to fit the startup budget)". Every
|
|
8
|
+
* super-skill is 8-15 KB, so a symptom-routed super-skill always arrived at
|
|
9
|
+
* 12-23% of its text — while the same display told the agent to "follow them
|
|
10
|
+
* before proposing any change". An agent cannot comply with a rule it can only
|
|
11
|
+
* see a fifth of, and nothing pointed at the rest.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
/** One file per over-budget routed payload, pruned after a week. */
|
|
17
|
+
const RETENTION_MS = 7 * 86_400_000;
|
|
18
|
+
export function routeOffloadDir() {
|
|
19
|
+
return join(homedir(), ".prism-mcp", "route-context");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Write `fullText` to the offload directory and return its path.
|
|
23
|
+
*
|
|
24
|
+
* Returns undefined on any failure — callers MUST degrade loudly in-band
|
|
25
|
+
* rather than assume the text was delivered. Never throws: this runs inside
|
|
26
|
+
* startup, which must not fail over an unwritable disk.
|
|
27
|
+
*/
|
|
28
|
+
export function writeRouteOffload(fullText, prefix = "route") {
|
|
29
|
+
try {
|
|
30
|
+
const dir = routeOffloadDir();
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
try {
|
|
33
|
+
// Bounded: this runs synchronously on the STARTUP hot path (adversarial
|
|
34
|
+
// review). A directory that has somehow accumulated thousands of files
|
|
35
|
+
// must not turn bootstrap into an O(n) stat storm — cap the work and let
|
|
36
|
+
// later calls finish the prune incrementally.
|
|
37
|
+
// Sorted so the bounded scan hits OLDEST files first (names embed a
|
|
38
|
+
// millisecond timestamp, so lexicographic ≈ chronological): a busy dir
|
|
39
|
+
// cannot starve the prune by always presenting fresh entries first.
|
|
40
|
+
const entries = readdirSync(dir).sort();
|
|
41
|
+
const PRUNE_SCAN_LIMIT = 256;
|
|
42
|
+
for (const f of entries.slice(0, PRUNE_SCAN_LIMIT)) {
|
|
43
|
+
const p = join(dir, f);
|
|
44
|
+
try {
|
|
45
|
+
if (Date.now() - statSync(p).mtimeMs > RETENTION_MS)
|
|
46
|
+
rmSync(p);
|
|
47
|
+
}
|
|
48
|
+
catch { /* skip unstat-able entries */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { /* prune failure never blocks the write */ }
|
|
52
|
+
const target = join(dir, `${prefix}-${Date.now()}-${process.pid}.md`);
|
|
53
|
+
writeFileSync(target, fullText);
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -137,7 +137,14 @@ export function buildVaultDirectory(exportData, pkmFlavor = 'plain') {
|
|
|
137
137
|
addFile("Visual_Memory/Index.md", visualMd);
|
|
138
138
|
}
|
|
139
139
|
// 4. Ledger/ and Keywords/ processing
|
|
140
|
-
|
|
140
|
+
// NULL-PROTOTYPE (round-6 review): keywords are auto-extracted from the
|
|
141
|
+
// user's free-text summaries, so an ordinary word like "constructor"
|
|
142
|
+
// slugifies to an inherited Object.prototype property name. On a plain
|
|
143
|
+
// object the truthiness guard below then read the inherited function,
|
|
144
|
+
// skipped the [] init, and .push threw — and because session_export_memory
|
|
145
|
+
// wraps the whole multi-project loop in one try/catch, a single such
|
|
146
|
+
// keyword in ANY ledger entry aborted the ENTIRE vault export.
|
|
147
|
+
const keywordMentions = Object.create(null);
|
|
141
148
|
// O(1) filename collision counter: key = "YYYY-MM-DD_slug", value = next suffix number
|
|
142
149
|
const filenameCounters = new Map();
|
|
143
150
|
if (Array.isArray(d.ledger)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.17.
|
|
3
|
+
"version": "20.17.2",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
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",
|