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 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 sync enable` (paid,
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 sync pull <project>`) and opens it locally.
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 sync status|devices` to inspect; revoke a lost machine from the portal.
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, mkdirSync, readdirSync, statSync, rmSync, writeFileSync } from 'node:fs';
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
- function writeRouteOffload(fullText) {
500
- try {
501
- const dir = path.join(homedir(), '.prism-mcp', 'route-context');
502
- mkdirSync(dir, { recursive: true });
503
- try {
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 sync ───────────────────────────────────────────────
1138
- // Cross-machine handoff sync controls. Push happens automatically on
1139
- // session_save_handoff once enabled; everything E2E lives in src/crypto/.
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('sync <action> [project]')
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 sync disable'
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 sync pull <project>');
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');
@@ -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 sync devices`);
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 sync enable",
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
- const SYMPTOM_SKILL_INLINE_MAX = 1_800;
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
- function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
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
- const keepChars = Math.max(0, maxChars - marker.length - suffix.length);
451
- return text.slice(0, keepChars).trimEnd() + marker + suffix;
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
- const cap = Math.min(SYMPTOM_SKILL_INLINE_MAX, Math.floor(effectiveNativeBudget(level, options.nativeMaxChars) * SYMPTOM_SKILL_BUDGET_SHARE));
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
- const clipped = body.length > cap
1477
- ? `${body.slice(0, cap).trimEnd()}\n… (rule truncated to fit the startup budget)`
1478
- : body;
1479
- symptomSkillSuffix += `\n--- ${shown[0]} ---\n${clipped}\n`;
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
- const merged = {};
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
- const result = { triggers: {}, errors: [] };
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: {}, errors: [], names: [] };
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: {}, errors: [] };
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 sync enable), a paid plan, and a signed-in " +
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
- kwCache = { table: parsed, at: Date.now() };
226
- return parsed;
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
- kwCache = { table: parsed, at: 0 }; // at:0 → retry live on next call
266
- return parsed;
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
- const combined = { ...publicKeywords };
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
- combined[pattern] = [...(combined[pattern] ?? []), ...names];
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
- const keywordMentions = {};
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.0",
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",