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 {
|
|
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
|
|
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
|
|
55
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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",
|