prism-mcp-server 20.21.13 → 20.21.14

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
@@ -158,6 +158,24 @@ or by re-enabling after each run.
158
158
  <details>
159
159
  <summary>Release history (optional)</summary>
160
160
 
161
+ ## What's New in v20.21.14
162
+
163
+ ### Skills load when they help, and you can see why they loaded
164
+
165
+ - Background task notifications, reports from other agents and continuation
166
+ summaries no longer load skills in the middle of a task, so a word inside an
167
+ agent's report cannot pull in unrelated rules.
168
+ - Pasting Prism's startup output into a prompt no longer loads skills that
169
+ happen to share a word with it.
170
+ - Tasks that say they need host tools or reserved judgment stay with the host
171
+ instead of failing on the local worker.
172
+ - Every routed-skills header ends with the routing table version (for example
173
+ "Routing table v41."), so a past skill load can be checked against the exact
174
+ rules that chose it.
175
+ - Skills with their own triggers now load even when their file uses Windows
176
+ line endings or mentions `prompt_triggers:` in its description; before, they
177
+ were delivered but silently never loaded.
178
+
161
179
  ## What's New in v20.21.13
162
180
 
163
181
  ### Paid plans and trials are clear in Account & Settings
@@ -1693,7 +1693,7 @@ export async function sessionLoadContextHandler(args, options = {}) {
1693
1693
  // skill budget.
1694
1694
  if (typeof prompt === "string" && prompt.trim()) {
1695
1695
  try {
1696
- const { resolvePromptSkillNames } = await import("./skillRouting.js");
1696
+ const { resolvePromptRouting } = await import("./skillRouting.js");
1697
1697
  // The manifest's routing_version is the only version signal available
1698
1698
  // here; without it a stale cached table would never be detected on
1699
1699
  // this path, since there is no portal response to compare against.
@@ -1704,7 +1704,8 @@ export async function sessionLoadContextHandler(args, options = {}) {
1704
1704
  // `prompt_triggers` in their own frontmatter and are matched here, on
1705
1705
  // device, from bodies already cached for injection.
1706
1706
  const scoped = await collectSkillTriggersOnThisMachine();
1707
- const matched = (await resolvePromptSkillNames(prompt, Number.isFinite(manifestVersion) && manifestVersion > 0 ? manifestVersion : undefined, scoped?.triggers)).filter((name) => entitledSkillNames.has(name) || scoped?.localNames.has(name));
1707
+ const routed = await resolvePromptRouting(prompt, Number.isFinite(manifestVersion) && manifestVersion > 0 ? manifestVersion : undefined, scoped?.triggers);
1708
+ const matched = routed.names.filter((name) => entitledSkillNames.has(name) || scoped?.localNames.has(name));
1708
1709
  if (matched.length > 0) {
1709
1710
  const shown = matched.slice(0, MAX_SYMPTOM_SKILLS);
1710
1711
  const overflow = matched.length - shown.length;
@@ -1717,7 +1718,8 @@ export async function sessionLoadContextHandler(args, options = {}) {
1717
1718
  symptomSkillSuffix = `\n\n**Symptom-triggered skills:** ${shown.join(", ")}` +
1718
1719
  (overflow > 0 ? `, … ${overflow} more` : "") +
1719
1720
  `\nThe first message matches these skills' trigger rules. Follow them before ` +
1720
- `proposing any change.\n`;
1721
+ `proposing any change.\n` +
1722
+ (typeof routed.tableVersion === "number" ? `Routing table v${routed.tableVersion}.\n` : "");
1721
1723
  // INLINE the top match's body rather than pointing at it. Naming a
1722
1724
  // skill is not delivering it: bodies reach agents only as files under
1723
1725
  // the canonical root, and hosts outside that mirror have no path to
@@ -2213,7 +2215,7 @@ export async function collectSkillTriggersOnThisMachine() {
2213
2215
  */
2214
2216
  export async function runPromptRouteFromCache(prompt, loaded) {
2215
2217
  const { routePrompt } = await import("./promptRouteHandler.js");
2216
- const { resolvePromptSkillNames, _setStorage } = await import("./skillRouting.js");
2218
+ const { resolvePromptSkillNames, resolvePromptRouting, _setStorage } = await import("./skillRouting.js");
2217
2219
  // The CLI is a fresh process per prompt: without storage wiring the keyword
2218
2220
  // table can neither be read from disk (offline = dead routing) nor
2219
2221
  // persisted after a fetch (every prompt = a network GET). The server paths
@@ -2221,6 +2223,7 @@ export async function runPromptRouteFromCache(prompt, loaded) {
2221
2223
  _setStorage(async (key, value) => { await setSetting(key, value); }, async (key) => getSetting(key, ""));
2222
2224
  return routePrompt(prompt, loaded, {
2223
2225
  resolvePromptSkillNames,
2226
+ resolvePromptRouting,
2224
2227
  collectTriggers: collectSkillTriggersOnThisMachine,
2225
2228
  entitledNames: async () => {
2226
2229
  try {
@@ -44,6 +44,44 @@ export const MAX_ROUTED_CHARS = 30_000;
44
44
  * this bounds what may ride INLINE through a hook — anything larger must be
45
45
  * our own offload file with an imperative pointer, not the host's silent one. */
46
46
  export const HOOK_INLINE_SAFE_CHARS = 9_800;
47
+ /**
48
+ * Turns a host delivers as if a person typed them, although no person wrote
49
+ * them. Measured 2026-09-23 over 30 days of Claude Code sessions: most of the
50
+ * prompt hook's skill loads came from these turns, and few of those loads
51
+ * helped the task. A reviewer agent's report that mentions Supabase is not a
52
+ * request for the Supabase skill.
53
+ *
54
+ * Matched at the START only, never as a substring: a person who pastes a
55
+ * notification after their own words still routes. Tags are matched without
56
+ * their closing ">" so attributes do not hide them, and the relay line needs
57
+ * its colon so a sentence that merely begins with the same words still
58
+ * routes. The known cost is the reverse: a message that BEGINS with one of
59
+ * these markers, such as a raw notification pasted with nothing before it, is
60
+ * treated as machine-written.
61
+ */
62
+ const MACHINE_TURN_PREFIXES = [
63
+ "<task-notification",
64
+ "<agent-message",
65
+ "Another Claude session sent a message:",
66
+ "<cross-session-message",
67
+ "This session is being continued from a previous conversation",
68
+ ];
69
+ /**
70
+ * The part of a turn that routing reads. A person's text passes through
71
+ * unchanged, and a machine-written turn yields "". The exception is a finished
72
+ * BACKGROUND COMMAND: its one-line summary names the command the agent itself
73
+ * chose to run ("Build the app for the simulator"), so the summary routes.
74
+ * Its output never does.
75
+ */
76
+ export function routableText(prompt) {
77
+ const text = (prompt || "").trimStart();
78
+ if (text.startsWith("<task-notification")) {
79
+ const head = text.split("<result>")[0];
80
+ const summary = /<summary>([\s\S]*?)<\/summary>/.exec(head)?.[1]?.trim() ?? "";
81
+ return summary.startsWith("Background command") ? summary : "";
82
+ }
83
+ return MACHINE_TURN_PREFIXES.some((marker) => text.startsWith(marker)) ? "" : prompt;
84
+ }
47
85
  /**
48
86
  * Match a prompt and return ONLY skills the caller does not already have.
49
87
  *
@@ -51,15 +89,27 @@ export const HOOK_INLINE_SAFE_CHARS = 9_800;
51
89
  * the thing under test.
52
90
  */
53
91
  export async function routePrompt(prompt, loaded, deps) {
54
- const trimmed = (prompt || "").trim();
55
- if (!trimmed) {
92
+ const supplied = (prompt || "").trim();
93
+ if (!supplied) {
56
94
  return { names: [], alreadyLoaded: [], overflow: [], text: "No prompt supplied — nothing to route." };
57
95
  }
96
+ const trimmed = routableText(supplied).trim();
97
+ if (!trimmed) {
98
+ return { names: [], alreadyLoaded: [], overflow: [], text: "Machine-written turn — not routed." };
99
+ }
58
100
  const scoped = await deps.collectTriggers().catch(() => undefined);
59
101
  const version = await deps.manifestVersion().catch(() => undefined);
60
102
  let matched = [];
103
+ let tableVersion;
61
104
  try {
62
- matched = await deps.resolvePromptSkillNames(trimmed, version, scoped?.triggers);
105
+ if (deps.resolvePromptRouting) {
106
+ const routed = await deps.resolvePromptRouting(trimmed, version, scoped?.triggers);
107
+ matched = routed.names;
108
+ tableVersion = routed.tableVersion;
109
+ }
110
+ else {
111
+ matched = await deps.resolvePromptSkillNames(trimmed, version, scoped?.triggers);
112
+ }
63
113
  }
64
114
  catch (error) {
65
115
  // Routing must never take down the turn that asked for it.
@@ -124,9 +174,12 @@ export async function routePrompt(prompt, loaded, deps) {
124
174
  const overflowNote = overflow.length > 0
125
175
  ? `\n\nAlso matched, not injected: ${overflowShown.join(", ")}${overflow.length > overflowShown.length ? ` (+${overflow.length - overflowShown.length} more)` : ""}.`
126
176
  : "";
177
+ // The table version makes a recorded load attributable after the table
178
+ // changes; without it, a transcript cannot say which rules produced a load.
179
+ const versionNote = typeof tableVersion === "number" ? `\n\nRouting table v${tableVersion}.` : "";
127
180
  const header = `**Skills now active for this task:** ${delivered.join(", ")}\n\n` +
128
181
  `These apply to the work you are about to do. Read and follow them before proceeding.` +
129
- overflowNote;
182
+ overflowNote + versionNote;
130
183
  return { names: delivered, alreadyLoaded, overflow, header, blocks, text: `${header}\n\n${blocks.join("\n\n")}` };
131
184
  }
132
185
  /**
@@ -112,7 +112,9 @@ export function extractSkillTriggers(skillName, content) {
112
112
  // idiom is a plain data-property op for any key. Same treatment at every
113
113
  // trigger accumulator in this file and in ledgerHandlers' merge.
114
114
  const result = { triggers: Object.create(null), errors: [] };
115
- const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
115
+ // A SKILL.md saved with Windows line endings failed the `^---\n` match and
116
+ // loaded with no triggers and no error. skillDigest already accepts \r\n.
117
+ const frontmatter = content.replace(/\r\n?/g, "\n").match(/^---\n([\s\S]*?)\n---/);
116
118
  if (!frontmatter)
117
119
  return result;
118
120
  const body = frontmatter[1];
@@ -128,7 +130,9 @@ export function extractSkillTriggers(skillName, content) {
128
130
  else {
129
131
  const blockStart = body.match(/^prompt_triggers:\s*$/m);
130
132
  if (blockStart) {
131
- const after = body.slice(body.indexOf(blockStart[0]) + blockStart[0].length);
133
+ // The match's own index, not indexOf: the key text can also end an
134
+ // earlier line (a description), and indexOf would start there.
135
+ const after = body.slice((blockStart.index ?? 0) + blockStart[0].length);
132
136
  for (const line of after.split("\n")) {
133
137
  // Stop at the next top-level key: an unterminated list must not swallow
134
138
  // the rest of the frontmatter and turn `description:` into a trigger.
@@ -336,7 +336,8 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
336
336
  // text (adversarial review, confirmed with a repro). Line-anchoring means
337
337
  // eating text now requires two line-start fences — which IS a fenced block.
338
338
  //
339
- // Replacement must sever BOTH proximity-window classes in the real table:
339
+ // A removed FENCED BLOCK must sever BOTH proximity-window classes in the
340
+ // real table:
340
341
  // - `.{0,N}` windows: `.` does not cross \n (no pattern uses the s-flag),
341
342
  // so a newline severs them.
342
343
  // - `\s*`/`\s+`-glued windows (34 of 58 live patterns, e.g.
@@ -346,7 +347,29 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
346
347
  // includes \x1F (unit separator): non-space (blocks \s runs), non-word
347
348
  // (leaves \b semantics as a space would), and severed from dot-windows
348
349
  // by the flanking newlines.
350
+ // A removed skill NAME is replaced differently — see neutralize below.
349
351
  const SEVER = '\n\x1f\n';
352
+ // A stripped name keeps its length and each character's kind: ASCII
353
+ // lowercase letters become "q", ASCII uppercase "Q", ASCII digits "0", and
354
+ // every other character (separators, non-ASCII letters) stays as it was. \b, \w, \d, \s, ., [a-z] and the like read the same at every
355
+ // position as on the raw text, so a trigger stops matching only if it needs
356
+ // the identity of the name's letters — which is what stripping exists to remove.
357
+ // Earlier masks leaked: a line break cut "Draft an ABA <name> plan" apart
358
+ // (review round 1), a non-word \x1F run cut [-\w] windows (round 3), and
359
+ // "_" cut [ a-z-] windows (round 4).
360
+ // Known limitation, stated as a class: the mask keeps each ASCII letter's
361
+ // and digit's kind but not its identity. A trigger that can tell one letter (or one
362
+ // digit) from another — a word, a letter range such as [n-s], the mask
363
+ // letters themselves, or a backreference such as (\w)\1 — may match a
364
+ // stripped name differently, in either direction. A trigger that cannot
365
+ // tell them apart matches exactly as on the raw text. No mask smaller than
366
+ // the alphabet avoids this; triggers come from the routing table and
367
+ // account owners, so it is disclosed and pinned in tests, not defended.
368
+ // Also disclosed: a name glued to a following letter or digit other than
369
+ // a plural "s" ("…-protocol7") is not recognized as the name, so its
370
+ // trigger words still route; the segment anchor below is what keeps
371
+ // "fix-ci" from firing inside "prefix-ci".
372
+ const neutralize = (name) => name.replace(/[a-z]/g, 'q').replace(/[A-Z]/g, 'Q').replace(/[0-9]/g, '0');
350
373
  let out = prompt
351
374
  .replace(/^[ \t]*```[^\n]*\n[\s\S]*?\n[ \t]*```[ \t]*$/gm, SEVER)
352
375
  .replace(/^[ \t]*~~~[^\n]*\n[\s\S]*?\n[ \t]*~~~[ \t]*$/gm, SEVER);
@@ -374,8 +397,20 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
374
397
  if (typeof n === "string")
375
398
  names.add(n);
376
399
  }
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)) {
400
+ // Protected skills are never prompt-routed, so the table above does not name
401
+ // them, but every pasted startup log lists them and a protected name can
402
+ // carry another skill's trigger word: "aba-precision-protocol" satisfied the
403
+ // clinical \baba\b trigger. They are exact names, not English.
404
+ for (const n of REQUIRED_PROTECTED_SKILL_NAMES)
405
+ names.add(n);
406
+ // Every name is matched against the SAME unmasked text and the union of
407
+ // the spans is masked once below. Masking name by name let a longer name
408
+ // consume the head of an overlapping one, which then no longer matched and
409
+ // left its tail — and its trigger words — unmasked (new review, cycle 1).
410
+ // A contained name is covered by the union, so order does not matter.
411
+ const source = out;
412
+ const masked = new Uint8Array(source.length);
413
+ for (const name of names) {
379
414
  // Bounds mirror the routing table's own name policy (≤128 chars). An
380
415
  // overlong or hostile name from a poisoned table must degrade to
381
416
  // "not stripped", never to a thrown SyntaxError that kills routing for
@@ -407,11 +442,26 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
407
442
  continue;
408
443
  try {
409
444
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
410
- out = out.replace(new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`, 'gi'), SEVER);
445
+ const re = new RegExp(`(?<![A-Za-z0-9])${escaped}s?(?![A-Za-z0-9])`, 'gi');
446
+ // exec, restarting one unit after each match START, so an occurrence
447
+ // that overlaps an earlier one of the same name ("aba-aba" twice in
448
+ // "aba-aba-aba") is found too; matchAll skips it. lastIndex strictly
449
+ // increases, so this stays linear for a bounded name length.
450
+ for (let m = re.exec(source); m; m = re.exec(source)) {
451
+ masked.fill(1, m.index, m.index + m[0].length);
452
+ re.lastIndex = m.index + 1;
453
+ }
411
454
  }
412
455
  catch { /* skip unbuildable names — same policy as the matcher */ }
413
456
  }
414
- return out;
457
+ if (!masked.includes(1))
458
+ return source;
459
+ // split('') indexes UTF-16 code units, the same units matchAll reports.
460
+ const units = source.split('');
461
+ for (let i = 0; i < units.length; i++)
462
+ if (masked[i])
463
+ units[i] = neutralize(units[i]);
464
+ return units.join('');
415
465
  }
416
466
  /**
417
467
  * Verbatim port of portal resolve/route.ts prompt-matching block + the sort
@@ -470,8 +520,16 @@ export function _applyPromptRouting(base, prompt, promptKeywords) {
470
520
  * indefinitely and never detect drift). The skill manifest carries one.
471
521
  */
472
522
  export async function resolvePromptSkillNames(prompt, expectVersion, scopedTriggers) {
523
+ return (await resolvePromptRouting(prompt, expectVersion, scopedTriggers)).names;
524
+ }
525
+ /**
526
+ * resolvePromptSkillNames, plus which routing table produced the match. A load
527
+ * recorded without its table version cannot be attributed after the table
528
+ * changes, so callers that show routed skills also show the version.
529
+ */
530
+ export async function resolvePromptRouting(prompt, expectVersion, scopedTriggers) {
473
531
  if (!prompt)
474
- return [];
532
+ return { names: [] };
475
533
  const kw = await fetchKeywordTable(expectVersion);
476
534
  // Scoped triggers must still route when the PUBLIC table is unavailable:
477
535
  // they are declared in skill bodies already on this machine and owe nothing
@@ -479,7 +537,7 @@ export async function resolvePromptSkillNames(prompt, expectVersion, scopedTrigg
479
537
  // depend on a public file it can never appear in.
480
538
  const publicKeywords = kw?.prompt_keywords ?? {};
481
539
  if (!kw && !scopedTriggers)
482
- return [];
540
+ return { names: [] };
483
541
  // NULL-PROTOTYPE, not a literal (round-4 review): with a plain object, a
484
542
  // scoped pattern whose TEXT is an inherited property name made both sides
485
543
  // of the merge below misbehave — `combined['constructor'] ?? []` read the
@@ -500,7 +558,10 @@ export async function resolvePromptSkillNames(prompt, expectVersion, scopedTrigg
500
558
  continue;
501
559
  combined[pattern] = [...(combined[pattern] ?? []), ...clean];
502
560
  }
503
- return _applyPromptRouting([], stripQuotedEvidenceForRouting(prompt, combined), combined).map((s) => s.name);
561
+ return {
562
+ names: _applyPromptRouting([], stripQuotedEvidenceForRouting(prompt, combined), combined).map((s) => s.name),
563
+ tableVersion: kw?.version,
564
+ };
504
565
  }
505
566
  /**
506
567
  * Free tier resolves to an empty set portal-side, so adding prompt-matched
@@ -89,6 +89,35 @@ const HOST_TOOL_ACTION_GROUPS = [
89
89
  ],
90
90
  },
91
91
  ];
92
+ /**
93
+ * Requirements the host states about its own task. Hosts write the task
94
+ * description, and many say outright that the work needs host tools or
95
+ * reserved judgment ("Needs host tools to inspect…", "Reserved security
96
+ * judgment: …"). The local worker cannot run tools, so these are hard host
97
+ * boundaries. The tool pattern must start at host/repository/repo/filesystem so
98
+ * "open-source tools" does not count.
99
+ *
100
+ * Any occurrence counts — negated, contrasted, or merely mentioned. Reading
101
+ * English negation with patterns kept misrouting ("do not skip host tools",
102
+ * "no host tools should be omitted"), and the costs are lopsided: a wrong
103
+ * host route costs one host turn, a wrong claw route a delegation that comes
104
+ * back refused or rejected. On two months of real routes, ignoring negation
105
+ * changed no decision.
106
+ */
107
+ // Modifier chains are bounded ({0,3}): an unbounded chain restarted at every
108
+ // "repository" and made a near-miss input quadratic. Modifiers and "reserved
109
+ // … judgment" qualifiers are the closed sets seen in two months of real task
110
+ // descriptions, so prose like "she reserved her judgment" does not match, and
111
+ // "and" counts only before another modifier ("host git and shell tools", not
112
+ // "the host and tools").
113
+ const SELF_DECLARED_HOST_REQUIREMENTS = [
114
+ /\b(?:host|repository|repo|filesystem)(?:[- /](?:and[- ])?(?:side|repository|filesystem|file|source|shell|git|browser|test|web|docker|documentation|ci|process|external|deployment)){0,3}[- ]tools?\b/i,
115
+ /\breserved(?:[- /](?:adversarial|security|compliance|release|review|clinical|product|host|auth|phi|safety|tenant|lifecycle|isolation)){0,3}[- ]judge?ment\b/i,
116
+ /\b(?:security|compliance|tenant[- ]isolation)[- ]judge?ment\b/i,
117
+ ];
118
+ function hasSelfDeclaredHostRequirement(description) {
119
+ return SELF_DECLARED_HOST_REQUIREMENTS.some((pattern) => pattern.test(description));
120
+ }
92
121
  /** Complexity signals that should select 27B when the task is bounded. */
93
122
  const HIGH_COMPLEXITY_KEYWORDS = [
94
123
  "complex logic", "algorithm", "dynamic programming", "constraint solver",
@@ -170,6 +199,8 @@ function assessDelegability(args) {
170
199
  reasons.push("reserved host judgment");
171
200
  if (toolWorkflowHits > 0)
172
201
  reasons.push("host tools or external state required");
202
+ if (hasSelfDeclaredHostRequirement(description))
203
+ reasons.push("host requirement stated in the task");
173
204
  if (toolActionGroups.length >= HOST_TOOL_ACTION_GROUP_THRESHOLD) {
174
205
  reasons.push(`host workflow actions: ${toolActionGroups.join(", ")}`);
175
206
  }
@@ -362,13 +393,19 @@ export function computeRoute(args) {
362
393
  const { task_description, files_involved, estimated_scope } = args;
363
394
  // ── Cold-start / edge case: insufficient input ──
364
395
  if (!task_description || task_description.trim().length < 10) {
396
+ // A stated host requirement is a hard boundary on this path too: the
397
+ // handler's experience bias and local tie-break both gate on the flag.
398
+ const hostRequirement = hasSelfDeclaredHostRequirement(task_description ?? "");
365
399
  return {
366
400
  target: "host",
367
- confidence: 0.5,
401
+ confidence: hostRequirement ? HARD_HOST_BOUNDARY_CONFIDENCE : 0.5,
368
402
  needs_history: false,
369
403
  complexity_score: 5,
370
- rationale: "Insufficient information for confident routing. Defaulting to host model.",
404
+ rationale: hostRequirement
405
+ ? "Host boundary: host requirement stated in the task."
406
+ : "Insufficient information for confident routing. Defaulting to host model.",
371
407
  recommended_tool: null,
408
+ ...(hostRequirement ? { _hardHostBoundary: true } : {}),
372
409
  };
373
410
  }
374
411
  // ── Compute individual signals ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.13",
3
+ "version": "20.21.14",
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",