prism-mcp-server 20.18.2 → 20.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -313,8 +313,8 @@ async function fetchKeywordTable(expectVersion) {
313
313
  * string that happens to appear in material they pasted as evidence. Observed
314
314
  * 2026-08-31: a user asked "what's going on with skill loading?" and pasted a
315
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
316
+ * `acme-xyz-billing` inside it satisfied that skill's own trigger
317
+ * `\bacme\b.{0,20}\b(billing|invoice)\b`. Two unrelated private skills loaded
318
318
  * and were injected as binding rules for a debugging question — pasting a log
319
319
  * that NAMES a skill should never activate it.
320
320
  *
@@ -322,7 +322,7 @@ async function fetchKeywordTable(expectVersion) {
322
322
  * 1. Fenced code blocks — pasted output, by convention.
323
323
  * 2. Hyphenated skill-name tokens (`foo-bar-baz`). A bare skill name is
324
324
  * metadata about the system, not a description of work. Removing only the
325
- * NAME SPAN keeps its constituent words available: "fusa billing invoice"
325
+ * NAME SPAN keeps its constituent words available: "acme billing invoice"
326
326
  * typed by the user still matches, because that text is not a name token.
327
327
  *
328
328
  * Deliberately NOT length-capped: a long prompt is not evidence of pasting,
@@ -357,7 +357,7 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
357
357
  // false positive being fixed. Using the actual routable names is exact.
358
358
  //
359
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
360
+ // ("update acme-xyz-billing's invoice rate") no longer trigger-routes that
361
361
  // skill. That is acceptable because the agent reads the raw prompt and can
362
362
  // invoke a literally-named skill directly — trigger routing exists for
363
363
  // SYMPTOM text, where the name is absent. A pasted log naming skills must
@@ -382,7 +382,7 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
382
382
  // the whole prompt — the sibling _applyPromptRouting swallows bad
383
383
  // patterns for the same reason (adversarial review, confirmed).
384
384
  // Only IDENTIFIER-SHAPED names are stripped: at least two segments joined
385
- // by - or _ (fusa-bss-billing, training-results-gate). Round-3 review
385
+ // by - or _ (acme-xyz-billing, training-results-gate). Round-3 review
386
386
  // proved the unconditional version was self-defeating for skills whose
387
387
  // name is an ordinary word: stripping `sentry` from "check sentry for
388
388
  // recent errors" killed that skill's OWN trigger (\bsentry\b) — 100% of
@@ -395,7 +395,7 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
395
395
  // name must not butt directly against a letter/digit, but MAY butt
396
396
  // against segment glue (-/_). Round 3's stricter (?<![\w-]) anchors
397
397
  // refused to strip the name out of longer compounds — a pasted
398
- // `fusa-bss-billing-worker` container name survived intact, and because
398
+ // `acme-xyz-billing-worker` container name survived intact, and because
399
399
  // \b fires at every internal hyphen, the skill's own trigger still
400
400
  // matched inside it: the exact incident class this function exists to
401
401
  // kill. Alignment on segment edges strips those compounds while still
@@ -300,6 +300,47 @@ const WEIGHTS = {
300
300
  * memory size, installed models, live RAM, entitlements, and explicit caller
301
301
  * overrides.
302
302
  */
303
+ /**
304
+ * Follow-up cues. Deliberately narrow: leading connectives and explicit
305
+ * references to prior work. Bare pronouns ("fix it") are NOT cues — "fix
306
+ * the typo in it" is a normal standalone task and a false positive here
307
+ * would make the host attach history to everything.
308
+ */
309
+ const FOLLOW_UP_CUES = [
310
+ /^\s*(same as before|as before|like before)\b/i,
311
+ // "last" dropped: "restore the last version of the file from git" is a
312
+ // standalone task (review 2026-09-16).
313
+ /\b(the|that) (same|previous|earlier) (one|version|function|file|answer|approach|code|result|output|draft)\b/i,
314
+ /\byour (last|previous|earlier) (answer|version|output|draft|reply)\b/i,
315
+ /\b(as|what) (we|you) (just |already )?(did|discussed|agreed|wrote|said|made|decided)\b/i,
316
+ /\b(from|like) (before|last time|earlier)\b/i,
317
+ /\b(continue|carry on|keep going|pick up) (from )?(where|what)\b/i,
318
+ // A bare "continue" / "please continue" / "keep going" is a follow-up by
319
+ // definition (review 2026-09-16).
320
+ // The bare verb, optionally "from/where/with …", and nothing else:
321
+ // "Continue integration tests for the parser" and "Go on-call rotation
322
+ // doc" are standalone tasks (review round 3).
323
+ // "please/now/ok, continue <anything>" is conversational continuation
324
+ // (so "Please continue integration tests for the parser" IS a cue — the
325
+ // prefix is the signal, accepted false positives included); the bare verb
326
+ // counts only as the whole message (or "from/where/with…"), so "Continue
327
+ // integration tests for the parser" stays standalone (review rounds 3–18:
328
+ // the prefixed forms were dropped once and restored).
329
+ /^\s*(please|ok|now)(,\s*|\s+)(continue|carry on|keep going|go on)\b/i,
330
+ /^\s*(continue|carry on|keep going|go on)(\s+(from|where|with)\b.*)?\s*[.!?]?\s*$/i,
331
+ /\b(redo|repeat) (it|that)\b|\bdo (it|that) again\b/i,
332
+ ];
333
+ /** A leading connective alone is not a cue: "Next.js 15 migration plan",
334
+ * "Also fix the typo in README", "Now write a unit test for parseDate()" are
335
+ * standalone tasks (review 2026-09-16). It counts only with an anaphor that
336
+ * points at prior work. */
337
+ const LEADING_CONNECTIVE = /^\s*(now|also|then|next|again|and now|and then|after that)\b/i;
338
+ const ANAPHOR = /\b(it|that|this|those|these|them|the same)\b|\b(as|like) before\b/i;
339
+ export function looksLikeFollowUp(description) {
340
+ if (FOLLOW_UP_CUES.some((r) => r.test(description)))
341
+ return true;
342
+ return LEADING_CONNECTIVE.test(description) && ANAPHOR.test(description);
343
+ }
303
344
  function buildRecommendedArgs(args, complexityScore) {
304
345
  return {
305
346
  prompt: args.task_description,
@@ -321,6 +362,7 @@ export function computeRoute(args) {
321
362
  return {
322
363
  target: "host",
323
364
  confidence: 0.5,
365
+ needs_history: false,
324
366
  complexity_score: 5,
325
367
  rationale: "Insufficient information for confident routing. Defaulting to host model.",
326
368
  recommended_tool: null,
@@ -383,6 +425,9 @@ export function computeRoute(args) {
383
425
  if (delegability.hardHostBoundary) {
384
426
  signals.push(`host boundary: ${delegability.reasons.join(", ")}`);
385
427
  }
428
+ const needs_history = looksLikeFollowUp(task_description);
429
+ if (needs_history)
430
+ signals.push("follow-up to earlier work: pass the accepted prior turns as `messages`");
386
431
  const rationale = target === "claw"
387
432
  ? `Task is delegable to the local agent. Signals: ${signals.join("; ") || "neutral"}.`
388
433
  : `Task should remain with the host model. Signals: ${signals.join("; ") || "neutral"}.`;
@@ -391,6 +436,7 @@ export function computeRoute(args) {
391
436
  confidence,
392
437
  complexity_score,
393
438
  rationale,
439
+ needs_history,
394
440
  recommended_tool: target === "claw" ? "prism_infer" : null,
395
441
  ...(target === "claw" ? {
396
442
  recommended_args: buildRecommendedArgs(args, complexity_score),
@@ -433,6 +479,7 @@ export async function sessionTaskRouteHandler(args) {
433
479
  complexity_score: 5,
434
480
  rationale: "Local delegation was explicitly disabled in Prism settings.",
435
481
  recommended_tool: null,
482
+ needs_history: false,
436
483
  delegation_enabled: false,
437
484
  }),
438
485
  }],
@@ -29,6 +29,8 @@
29
29
  * The Brave Answers endpoint uses a separate BRAVE_ANSWERS_API_KEY via Bearer token.
30
30
  */
31
31
  import { BRAVE_API_KEY, BRAVE_ANSWERS_API_KEY } from "../config.js";
32
+ import { debugLog } from "./logger.js";
33
+ import { isPortalPlanRefusal } from "./portalError.js";
32
34
  import { SYNALUX_SEARCH_AVAILABLE, synaluxWebSearch, synaluxWebSearchRaw, synaluxLocalSearch, synaluxLocalSearchRaw, synaluxBraveAnswers, } from "./synaluxSearch.js";
33
35
  const BRAVE_API_KEY_MISSING_ERROR = "BRAVE_API_KEY is not configured";
34
36
  const BRAVE_ANSWERS_API_KEY_MISSING_ERROR = "BRAVE_ANSWERS_API_KEY is not configured";
@@ -38,14 +40,37 @@ function requireBraveApiKey() {
38
40
  throw new Error(BRAVE_API_KEY_MISSING_ERROR);
39
41
  return BRAVE_API_KEY;
40
42
  }
43
+ /**
44
+ * Portal first. A configured Synalux account is a privacy boundary:
45
+ * provider credentials and query redaction stay portal-side, so an outage,
46
+ * a quota, or an expired login never turns into a direct provider call
47
+ * with the original query.
48
+ *
49
+ * The one exception is the portal's PLAN refusal (403 on a free plan). That
50
+ * account is not entitled to portal search at all, so a user who configured
51
+ * their own key gets it used — the same footing as a user who never signed
52
+ * in. Without an own key the refusal propagates unchanged.
53
+ */
54
+ async function portalFirst(viaPortal, ownKey, viaOwnKey) {
55
+ try {
56
+ return await viaPortal();
57
+ }
58
+ catch (err) {
59
+ if (ownKey && isPortalPlanRefusal(err)) {
60
+ debugLog("[braveApi] portal refused this plan; using the locally configured key instead");
61
+ return viaOwnKey();
62
+ }
63
+ throw err;
64
+ }
65
+ }
41
66
  // Brave Answers API call (AI Grounding/OpenAI-compatible)
42
67
  export async function performBraveAnswers(query, model = "brave") {
43
- // A configured Synalux account is a privacy boundary: provider
44
- // credentials and redaction stay portal-side. Never escape to a direct
45
- // provider with the original query when that portal request fails.
46
68
  if (SYNALUX_SEARCH_AVAILABLE) {
47
- return synaluxBraveAnswers(query, model);
69
+ return portalFirst(() => synaluxBraveAnswers(query, model), BRAVE_ANSWERS_API_KEY, () => braveAnswersDirect(query, model));
48
70
  }
71
+ return braveAnswersDirect(query, model);
72
+ }
73
+ async function braveAnswersDirect(query, model) {
49
74
  if (!BRAVE_ANSWERS_API_KEY) {
50
75
  throw new Error(BRAVE_ANSWERS_API_KEY_MISSING_ERROR);
51
76
  }
@@ -80,8 +105,12 @@ export async function performWebSearchRaw(query, count = 10, offset = 0) {
80
105
  if (SYNALUX_SEARCH_AVAILABLE) {
81
106
  if (offset !== 0)
82
107
  throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
83
- return synaluxWebSearchRaw(query, count);
108
+ return portalFirst(() => synaluxWebSearchRaw(query, count), BRAVE_API_KEY, () => braveWebSearchRaw(query, count, offset));
84
109
  }
110
+ return braveWebSearchRaw(query, count, offset);
111
+ }
112
+ // Direct Brave web search on the locally configured key
113
+ async function braveWebSearchRaw(query, count, offset) {
85
114
  const braveApiKey = requireBraveApiKey();
86
115
  const url = new URL("https://api.search.brave.com/res/v1/web/search");
87
116
  url.searchParams.set("q", query);
@@ -105,9 +134,13 @@ export async function performWebSearch(query, count = 10, offset = 0) {
105
134
  if (SYNALUX_SEARCH_AVAILABLE) {
106
135
  if (offset !== 0)
107
136
  throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
108
- return synaluxWebSearch(query, count);
137
+ return portalFirst(() => synaluxWebSearch(query, count), BRAVE_API_KEY, () => braveWebSearch(query, count, offset));
109
138
  }
110
- const textData = await performWebSearchRaw(query, count, offset);
139
+ return braveWebSearch(query, count, offset);
140
+ }
141
+ // Direct Brave web search, formatted
142
+ async function braveWebSearch(query, count, offset) {
143
+ const textData = await braveWebSearchRaw(query, count, offset);
111
144
  const data = JSON.parse(textData);
112
145
  // Extract just web results
113
146
  const results = (data.web?.results || []).map((result) => ({
@@ -165,8 +198,12 @@ function chunkArray(arr, size) {
165
198
  // Raw local search API call with poi/details payload
166
199
  export async function performLocalSearchRaw(query, count = 5) {
167
200
  if (SYNALUX_SEARCH_AVAILABLE) {
168
- return synaluxLocalSearchRaw(query, count);
201
+ return portalFirst(() => synaluxLocalSearchRaw(query, count), BRAVE_API_KEY, () => braveLocalSearchRaw(query, count));
169
202
  }
203
+ return braveLocalSearchRaw(query, count);
204
+ }
205
+ // Direct Brave local search on the locally configured key
206
+ async function braveLocalSearchRaw(query, count) {
170
207
  const braveApiKey = requireBraveApiKey();
171
208
  // Initial search to get location IDs
172
209
  const webUrl = new URL("https://api.search.brave.com/res/v1/web/search");
@@ -190,7 +227,8 @@ export async function performLocalSearchRaw(query, count = 5) {
190
227
  ?.filter((r) => r.id != null)
191
228
  .map((r) => r.id) || [];
192
229
  if (locationIds.length === 0) {
193
- const fallback = await performWebSearch(query, count);
230
+ // Already on the direct path: stay on it rather than re-asking the portal.
231
+ const fallback = await braveWebSearch(query, count, 0);
194
232
  return JSON.stringify({
195
233
  source: "web_fallback",
196
234
  query,
@@ -223,9 +261,13 @@ export async function performLocalSearchRaw(query, count = 5) {
223
261
  // Local search API call with poi details
224
262
  export async function performLocalSearch(query, count = 5) {
225
263
  if (SYNALUX_SEARCH_AVAILABLE) {
226
- return synaluxLocalSearch(query, count);
264
+ return portalFirst(() => synaluxLocalSearch(query, count), BRAVE_API_KEY, () => braveLocalSearch(query, count));
227
265
  }
228
- const rawData = await performLocalSearchRaw(query, count);
266
+ return braveLocalSearch(query, count);
267
+ }
268
+ // Direct Brave local search, formatted
269
+ async function braveLocalSearch(query, count) {
270
+ const rawData = await braveLocalSearchRaw(query, count);
229
271
  const parsed = JSON.parse(rawData);
230
272
  if (parsed.source === "web_fallback") {
231
273
  return parsed.formattedText || "No local results found";
@@ -11,6 +11,30 @@
11
11
  import { getSynaluxJwt } from "./synaluxJwt.js";
12
12
  import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED } from "../config.js";
13
13
  import { debugLog } from "./logger.js";
14
+ /** What a host with NO portal (unconfigured), a portal that says nothing
15
+ * (older deployment), or an assumed-free fallback gets: OFF. Multi-turn is
16
+ * a paid-plan feature (owner decision 2026-09-15); a client default that
17
+ * enabled it would hand a paid feature to anyone without an account. The
18
+ * caps here are what a paid plan gets when the portal omits them. */
19
+ export const DEFAULT_MULTI_TURN = { enabled: false, max_turns: 12, max_chars: 32_000 };
20
+ /** Structural ceiling no plan can exceed: the portal's own inference route
21
+ * takes at most 50 messages INCLUDING the current turn appended on
22
+ * escalation, hence 49 prior turns; 128k chars ≈ 32k tokens — the largest
23
+ * local window. Above this the payload is malformed, not merely over plan. */
24
+ export const ABSOLUTE_MULTI_TURN = { enabled: true, max_turns: 49, max_chars: 128_000 };
25
+ /** The policy prism_infer enforces for these entitlements: portal values
26
+ * when present, clamped into the absolute ceiling; the default otherwise. */
27
+ export function multiTurnPolicy(ent) {
28
+ const raw = ent.multi_turn;
29
+ if (!raw || typeof raw !== "object")
30
+ return DEFAULT_MULTI_TURN;
31
+ const clampInt = (v, fallback, max) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.min(Math.floor(v), max) : fallback;
32
+ return {
33
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_MULTI_TURN.enabled,
34
+ max_turns: clampInt(raw.max_turns, DEFAULT_MULTI_TURN.max_turns, ABSOLUTE_MULTI_TURN.max_turns),
35
+ max_chars: clampInt(raw.max_chars, DEFAULT_MULTI_TURN.max_chars, ABSOLUTE_MULTI_TURN.max_chars),
36
+ };
37
+ }
14
38
  // ── Free-tier defaults (no auth) ──────────────────────────────────
15
39
  export const FREE_ENTITLEMENTS = {
16
40
  plan: "free",
@@ -191,6 +215,14 @@ export async function getEntitlements() {
191
215
  /**
192
216
  * Force cache invalidation (e.g. after plan upgrade).
193
217
  */
218
+ /** Cached entitlements WITHOUT a network round-trip, or null when cold.
219
+ * For display surfaces (the startup line) that must never add a portal
220
+ * fetch to startup; the first prism_infer result carries the policy anyway. */
221
+ export function peekEntitlements() {
222
+ // Honour the TTL: a stale plan's caps must not be advertised at startup
223
+ // (review 2026-09-16); an expired cache reads as cold.
224
+ return cache && cache.expiresAt > Date.now() ? cache.entitlements : null;
225
+ }
194
226
  export function invalidateEntitlements() {
195
227
  cache = null;
196
228
  negativeCache = null;
@@ -4,22 +4,34 @@
4
4
  * Calls dcostenco/prism-coder:4b via Ollama to classify whether
5
5
  * a prompt is OBVIOUS_RESERVED, OBVIOUS_NOT_RESERVED, or UNCERTAIN.
6
6
  *
7
- * Fail-closed contract:
7
+ * Contract (as the handler applies it):
8
8
  * OBVIOUS_NOT_RESERVED → permits local routing
9
- * OBVIOUS_RESERVED → escalate to cloud
10
- * UNCERTAIN → escalate to cloud (conservative)
9
+ * OBVIOUS_RESERVED → text: cloud when allowed, else refused; with a
10
+ * current image: local only, never cloud (ruling
11
+ * 2026-08-18, clinical images are processed)
12
+ * UNCERTAIN → the same as OBVIOUS_RESERVED (conservative)
11
13
  * UNCERTAIN_LENGTH → §5.3: prompt too long to classify in full, but the
12
- * full-text keyword floor is clean AND a head+tail
14
+ * full-text keyword floor is clean AND a head+middle+tail
13
15
  * excerpt classified clean — permits local routing
14
16
  * with a distinct audit marker ("too long to classify"
15
17
  * ≠ "semantically uncertain")
16
- * ERROR → escalate to cloud (never fail-open)
18
+ * ERROR → cloud when it is allowed and answers; otherwise
19
+ * the full-text keyword net decides and keyword-
20
+ * clean text is served locally (the one path that
21
+ * is not fail-closed: an availability policy; on a
22
+ * history screen three in a row become UNCERTAIN;
23
+ * with a current image callLayer1 maps ERROR to
24
+ * UNCERTAIN and the handler refuses one that
25
+ * reaches it, so an image ERROR is never served)
17
26
  *
18
27
  * The prompt below is VERBATIM from §E of prism-infer-boundaries/SKILL.md.
19
28
  * It is duplicated here (not imported) because prism is a thin client with no
20
29
  * access to the skills tree at runtime. A drift test asserts byte-for-byte match.
21
30
  *
22
- * Must not run when mode="route" and max_tokens<=16 — that IS a Layer 1 call.
31
+ * callLayer1 talks to Ollama directly and never re-enters prism_infer, so
32
+ * there is no call signature that must skip it (the old "mode=route +
33
+ * max_tokens<=16" skip was removed 2026-09-16: it was a caller-controlled
34
+ * bypass, never a recursion guard).
23
35
  */
24
36
  // VERBATIM — §E prism-infer-boundaries/SKILL.md.
25
37
  // Changes require the private Layer 1 evaluation gate before release.
@@ -74,7 +86,7 @@ const all = (...rules) => (prompt) => rules.every((rule) => rule(prompt));
74
86
  const CLINICAL_RESERVED_RULES = [
75
87
  all(has(/\b(?:de[- ]?escalat\w*|meltdown\w*|rage\s+episode|violent\w*)\b/i), has(/\b(?:draft|write|plan|procedure|protocol|manag\w*|what\s+(?:do|should)\b|respond\w*)\b/i)),
76
88
  all(has(/\b(?:physical\s+intervention|containment|hold\s+procedure|restrain\w*|seclu\w*|physical\s+management)\b/i), has(/\b(?:draft|write|document|procedure|protocol|use|implement|instruct\w*)\b/i)),
77
- all(has(/\b(?:self[- ]?(?:harm|injur\w*)|bites?\s+(?:him|her|them)self|scratches?\s+(?:him|her|them)self|bangs?\s+(?:his|her|their)\s+head|harm\s+(?:himself|herself|themselves))\b/i), has(/\b(?:bleed\w*|blood|concrete|head\s+impact|medical\s+attention|risk(?:\s+of\s+injury)?|causes?\s+injury|actual\s+injury|assess\w*|screen\w*|want\w*)\b/i)),
89
+ all(has(/\b(?:self[- ]?(?:harm|injur\w*)|bites?\s+(?:him|her|them)self|bites?\s+(?:his|her|their)\s+own\b|scratches?\s+(?:him|her|them)self|scratches?\s+(?:his|her|their)\s+own\b|bangs?\s+(?:his|her|their)\s+head|harm\s+(?:himself|herself|themselves))\b/i), has(/\b(?:bleed\w*|blood|concrete|head\s+impact|medical\s+attention|risk(?:\s+of\s+injury)?|causes?\s+injury|actual\s+injury|assess\w*|screen\w*|want\w*)\b/i)),
78
90
  (prompt) => (/\b(?:punch\w*|attack\w*|aggress\w*|hit\s+staff)\b/i.test(prompt)
79
91
  && /\b(?:last\s+(?:week|month|year)|prior|previous|history|injur\w*)\b/i.test(prompt)
80
92
  && !/\bno\s+injur(?:y|ies)\b/i.test(prompt)
@@ -182,7 +194,14 @@ const NON_OPERATIONAL_ARTIFACT_ACTION = /\b(?:review\b[\s\S]{0,120}\bhook\s+orde
182
194
  * clear routine BCBA work reaches local inference. Ambiguous prompts return
183
195
  * null and continue to the semantic classifier below.
184
196
  */
185
- export function classifyDeterministicLayer1(userPrompt) {
197
+ /** The non-operational artifact exemption over a text. classifyDeterministic
198
+ * Layer1 applies it to the text it is given; prism_infer's history screen
199
+ * passes 7,200-char proximity slices, so the exemption reaches the clause it
200
+ * sits in and nothing thousands of chars away (review rounds 4 and 10). */
201
+ export function isNonOperationalArtifact(text) {
202
+ return NON_OPERATIONAL_ARTIFACT_CONTEXT.test(text) && NON_OPERATIONAL_ARTIFACT_ACTION.test(text);
203
+ }
204
+ export function classifyDeterministicLayer1(userPrompt, opts) {
186
205
  // The artifact exemption is checked BEFORE the reserved rules, and only for
187
206
  // the non-clinical ones.
188
207
  //
@@ -200,8 +219,10 @@ export function classifyDeterministicLayer1(userPrompt) {
200
219
  const clinicalReserved = matchesAny(userPrompt, CLINICAL_RESERVED_RULES);
201
220
  if (clinicalReserved)
202
221
  return "OBVIOUS_RESERVED";
203
- if (NON_OPERATIONAL_ARTIFACT_CONTEXT.test(userPrompt) &&
204
- NON_OPERATIONAL_ARTIFACT_ACTION.test(userPrompt)) {
222
+ if (opts?.operational === false) {
223
+ return matchesAny(userPrompt, ROUTINE_BCBA_INTENT_RULES) ? "OBVIOUS_NOT_RESERVED" : null;
224
+ }
225
+ if (isNonOperationalArtifact(userPrompt)) {
205
226
  return "OBVIOUS_NOT_RESERVED";
206
227
  }
207
228
  if (matchesAny(userPrompt, RESERVED_INTENT_RULES))
@@ -290,10 +311,11 @@ const LAYER1_RETRY_TIMEOUT_MS = 5_000;
290
311
  // would otherwise smuggle through.
291
312
  const RESERVED_KEYWORDS = /\b(restrain\w*|seclu(?:sion|d\w*)|physical\s*holds?|(?:prone|supine|basket|therapeutic|manual|two[- ]?person)\s+holds?|hold(?:ing)?\s+(?:the\s+)?(?:client|child|student|patient)\s+down|containment|self[- ]?harm\w*|suicid\w*|overdos\w*|dos(?:age|ing)\s*(?:mg|schedule)|crisis\s*de[- ]?escalation|meltdown\s*management|rage\s+episode|elopement\s*incident)\b/i;
292
313
  /**
293
- * Deterministic keyword check — the ERROR-path floor.
314
+ * Deterministic keyword check — the ERROR-path floor, and the full-text
315
+ * floor for oversize input (over MAX_CLASSIFIER_PROMPT_LENGTH the classifier
316
+ * reads an excerpt, so this runs over the whole text first).
294
317
  * Returns OBVIOUS_RESERVED if reserved vocabulary is present,
295
- * OBVIOUS_NOT_RESERVED otherwise. Used only when the LLM classifier
296
- * fails (timeout, model not loaded, injection attack).
318
+ * OBVIOUS_NOT_RESERVED otherwise.
297
319
  */
298
320
  export function keywordBackstop(prompt) {
299
321
  return RESERVED_KEYWORDS.test(prompt) ? "OBVIOUS_RESERVED" : "OBVIOUS_NOT_RESERVED";
@@ -305,7 +327,7 @@ export function keywordBackstop(prompt) {
305
327
  // bounded head+tail excerpt. A clean floor + clean excerpt yields the
306
328
  // distinct UNCERTAIN_LENGTH verdict so callers can tell "too long to
307
329
  // classify" from "semantically uncertain".
308
- const MAX_CLASSIFIER_PROMPT_LENGTH = 4_000;
330
+ export const MAX_CLASSIFIER_PROMPT_LENGTH = 4_000;
309
331
  // Excerpt budget: head + middle + tail must stay under the classifier cap
310
332
  // with room for the LAYER1_PROMPT template. The sampled middle window
311
333
  // narrows the region an attacker can hide paraphrased reserved content in
@@ -340,12 +362,12 @@ export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch
340
362
  /** Images accompanying the request. The classifier MUST see them: a
341
363
  * screenshot of clinical material would otherwise pass a gate that only
342
364
  * ever read the text prompt. */
343
- images) {
365
+ images, opts) {
344
366
  if (!userPrompt || !userPrompt.trim())
345
367
  return "ERROR";
346
368
  const oversize = userPrompt.length > MAX_CLASSIFIER_PROMPT_LENGTH;
347
369
  const hasImages = !!images?.length;
348
- const deterministic = classifyDeterministicLayer1(userPrompt);
370
+ const deterministic = opts?.deterministic === false ? null : classifyDeterministicLayer1(userPrompt);
349
371
  if (deterministic === "OBVIOUS_RESERVED")
350
372
  return deterministic;
351
373
  // The routine-work fast path may only skip the model when there is nothing
@@ -34,6 +34,12 @@
34
34
  export const MODEL_NAMESPACE = "dcostenco/prism-coder";
35
35
  export const LOCAL_PREFIX = "prism-coder";
36
36
  export const CONVERGE_TIERS = ["2b", "4b", "9b", "27b"];
37
+ /** Exit rule for the standalone command: one installed tier that did not
38
+ * converge is a failure worth a non-zero exit, not only "every tier failed"
39
+ * (a one-tier host reports three skipped_not_installed and would exit 0). */
40
+ export function convergeFailed(outcomes) {
41
+ return outcomes.some(o => o.action === "failed");
42
+ }
37
43
  export async function convergeModels(deps) {
38
44
  let tags;
39
45
  try {
@@ -75,9 +81,48 @@ export async function convergeModels(deps) {
75
81
  continue;
76
82
  }
77
83
  const pulledNewBytes = digestBefore !== undefined && digestBefore !== sourceNow.digest;
78
- const aliasStale = !aliasNow || aliasNow.digest !== sourceNow.digest;
79
- if (aliasStale) {
84
+ let aliasStale = !aliasNow || aliasNow.digest !== sourceNow.digest;
85
+ // A digest mismatch is not always stale bytes. Adopting
86
+ // scripts/prism-coder-9b.Modelfile rebuilds the alias FROM the same
87
+ // weights with `PARAMETER num_ctx 32768`, which changes the manifest
88
+ // digest and nothing else — and this branch would have cp'd the
89
+ // unpinned upstream straight over it on the next converge, silently
90
+ // undoing the pin (found 2026-09-15 before it happened). Stale means
91
+ // OLD WEIGHTS: compare the blobs the two tags are built from.
92
+ let pinnedSameWeights = false;
93
+ let droppedPin = null;
94
+ if (aliasStale && aliasNow && deps.tagFacts) {
95
+ const [a, src] = await Promise.all([deps.tagFacts(alias), deps.tagFacts(source)]);
96
+ if (a && src) {
97
+ if (a.weightsBlob === src.weightsBlob) {
98
+ pinnedSameWeights = a.pinnedNumCtx !== null;
99
+ aliasStale = false;
100
+ }
101
+ else if (a.pinnedNumCtx !== null) {
102
+ droppedPin = a.pinnedNumCtx;
103
+ }
104
+ }
105
+ else {
106
+ // /api/show failed or had no FROM line: a local pin cannot
107
+ // be detected, so do NOT overwrite — a digest mismatch may
108
+ // be nothing but the pin (review 2026-09-16). Hosts without
109
+ // tagFacts (older callers) keep the digest rule.
110
+ outcomes.push({ tier, action: "failed", detail: "tag_facts_unavailable_pin_unknown" });
111
+ deps.log(`? ${alias}: /api/show facts unavailable — left alone (a local num_ctx pin cannot be told from stale weights). Re-run when Ollama answers /api/show, or rebuild deliberately with: ollama cp ${source} ${alias}`);
112
+ continue;
113
+ }
114
+ }
115
+ if (pinnedSameWeights) {
116
+ outcomes.push({ tier, action: "up_to_date", detail: "locally_pinned" });
117
+ deps.log(`= ${alias} is a local pin on the same weights as ${source} — left alone`);
118
+ }
119
+ else if (aliasStale) {
80
120
  await deps.copy(source, alias);
121
+ if (droppedPin !== null) {
122
+ outcomes.push({ tier, action: pulledNewBytes || !hadSource ? "pulled_and_aliased" : "aliased_only", detail: "pin_dropped_readopt" });
123
+ deps.log(`⚠ ${alias} rebuilt on new weights; its num_ctx ${droppedPin} pin is GONE — re-adopt with: ollama create ${alias} -f scripts/${alias.replace(":", "-")}.Modelfile`);
124
+ continue;
125
+ }
81
126
  const action = pulledNewBytes || !hadSource ? "pulled_and_aliased" : "aliased_only";
82
127
  outcomes.push({ tier, action });
83
128
  deps.log(`✓ ${alias} ${aliasNow ? "re-aliased (was a stale snapshot)" : "aliased"} → ${sourceNow.digest.slice(0, 12)}`);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Portal HTTP errors carry their status and body so a caller can tell a
3
+ * PLAN refusal — the account is signed in, but its plan does not include
4
+ * the feature — from an outage, a quota, or an expired login. Only the plan
5
+ * refusal may be answered with the user's own provider key (braveApi.ts);
6
+ * everything else stays inside the portal's privacy boundary.
7
+ */
8
+ export class PortalHttpError extends Error {
9
+ portalPath;
10
+ portalStatus;
11
+ portalBody;
12
+ constructor(path, status, body) {
13
+ super(`[synaluxSearch] ${path} HTTP ${status}: ${body}`);
14
+ this.name = "PortalHttpError";
15
+ this.portalPath = path;
16
+ this.portalStatus = status;
17
+ this.portalBody = body;
18
+ }
19
+ }
20
+ /**
21
+ * True when the portal refused because of the account's plan: the
22
+ * `auth.plan === 'free'` gate on the prism search routes answers 403 with
23
+ * `{ error: "... requires Standard plan or higher.", upgrade_url: "/pricing" }`.
24
+ * Either signal — the word "plan" in the body, or a structured
25
+ * `upgrade_url` — is accepted, so a reworded message does not silently take
26
+ * a free user's own key away again. A 403 with neither is not a plan
27
+ * refusal, whatever else it may be.
28
+ */
29
+ export function isPortalPlanRefusal(err) {
30
+ if (!(err instanceof PortalHttpError) || err.portalStatus !== 403)
31
+ return false;
32
+ if (/\bplan\b/i.test(err.portalBody))
33
+ return true;
34
+ try {
35
+ const body = JSON.parse(err.portalBody);
36
+ return typeof body?.upgrade_url === "string";
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
@@ -20,7 +20,17 @@ const CRISIS_INPUT_RE = [
20
20
  /want\s+to\s+(?:commit\s+)?suicide/i,
21
21
  /cut(?:ting)?\s+(?:my)?self/i,
22
22
  /(?:hang|hanging)\s+(?:my)?self/i,
23
- /jump(?:ing)?\s+off/i,
23
+ // "a jumping off point for the refactor" is engineering, not a disclosure
24
+ // (measured false positive, review round 3, 2026-09-16). Exempt ONLY the
25
+ // idiom as a noun phrase: a determiner + "jump(ing) off point(s)" ("a
26
+ // jumping off point for the refactor", "good jumping off points"). "I
27
+ // plan to jump off point of the roof" has no determiner and matches;
28
+ // "jumping off pointlessly", "jumping off a bridge" and the hyphenated
29
+ // "jump-off the roof" match; "a jumping-off point" is exempt. Source of
30
+ // truth: the private repo's safety/patterns.json (bounded lookbehind for the
31
+ // Swift copy); the generator's other patterns drift from this file —
32
+ // reported; apply single lines, and keep the portal copy in step.
33
+ /(?:(?<!\b(?:a|an|the|this|that|good|great|solid|useful|our|your|my|as)\s{1,4})\bjump(?:ing)?[\s-]+off\b|\bjump(?:ing)?[\s-]+off\b(?![\s-]+points?\b))/i,
24
34
  /how\s+(?:many|much|to).*(?:pills|overdose|die)/i,
25
35
  // Spanish — block hyperbole nouns only (NOT hunger/thirst/cold — may be literal for neglected child)
26
36
  /quiero\s+morir(?!\s+de\s+(?:risa|la\s+risa|vergüenza|ganas|envidia|aburrimiento)\b)/i,
@@ -17,6 +17,7 @@
17
17
  * SYNALUX_SEARCH_AVAILABLE before calling.
18
18
  */
19
19
  import { debugLog } from "./logger.js";
20
+ import { PortalHttpError } from "./portalError.js";
20
21
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./synaluxJwt.js";
21
22
  import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED, } from "../config.js";
22
23
  // ─── Public availability flag ────────────────────────────────
@@ -59,7 +60,9 @@ async function portalPost(path, body, timeoutMs = 15_000) {
59
60
  }
60
61
  if (!res.ok) {
61
62
  const text = await res.text().catch(() => "(no body)");
62
- throw new Error(`[synaluxSearch] ${path} HTTP ${res.status}: ${text}`);
63
+ // Same message as before; the typed error lets braveApi.ts recognise a
64
+ // plan refusal without parsing the message.
65
+ throw new PortalHttpError(path, res.status, text);
63
66
  }
64
67
  return (await res.json());
65
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.18.2",
3
+ "version": "20.20.0",
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",
@@ -1,42 +0,0 @@
1
- import { debugLog } from "./logger.js";
2
- /**
3
- * Google Custom Search API Utility
4
- * ===============================
5
- *
6
- * Performs a web search using the Google Custom Search JSON API.
7
- * Requires: API Key and Search Engine ID (CX).
8
- *
9
- * @param apiKey - Google Search API Key
10
- * @param cx - Search Engine ID (CX)
11
- * @param query - The search query
12
- * @param count - Number of results to return (max 10)
13
- * @returns Array of result objects { title, url, snippet }
14
- */
15
- export async function performGoogleSearch(apiKey, cx, query, count = 5) {
16
- try {
17
- const safeCount = Math.min(Math.max(1, count), 10);
18
- const url = new URL("https://customsearch.googleapis.com/customsearch/v1");
19
- url.searchParams.append("key", apiKey);
20
- url.searchParams.append("cx", cx);
21
- url.searchParams.append("q", query);
22
- url.searchParams.append("num", safeCount.toString());
23
- const response = await fetch(url.toString(), {
24
- signal: AbortSignal.timeout(15_000),
25
- });
26
- if (!response.ok) {
27
- const error = await response.json();
28
- throw new Error(`Google Search API failed: ${JSON.stringify(error)}`);
29
- }
30
- const data = await response.json();
31
- const items = data.items || [];
32
- return items.map((item) => ({
33
- title: item.title,
34
- url: item.link,
35
- snippet: item.snippet,
36
- }));
37
- }
38
- catch (err) {
39
- debugLog(`[GoogleSearch] Error: ${err instanceof Error ? err.message : String(err)}`);
40
- return [];
41
- }
42
- }