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.
- package/README.md +111 -2
- package/dist/cli.js +2 -1
- package/dist/config.js +9 -9
- package/dist/dashboard/server.js +1 -1
- package/dist/localFirstPolicy.js +2 -0
- package/dist/modelConvergeRunner.js +14 -0
- package/dist/scholar/webScholar.js +47 -14
- package/dist/tools/definitions.js +1 -1
- package/dist/tools/ledgerHandlers.js +20 -1
- package/dist/tools/prismInferHandler.js +578 -82
- package/dist/tools/skillRouting.js +6 -6
- package/dist/tools/taskRouterHandler.js +47 -0
- package/dist/utils/braveApi.js +53 -11
- package/dist/utils/entitlements.js +32 -0
- package/dist/utils/layer1.js +38 -16
- package/dist/utils/modelConverge.js +47 -2
- package/dist/utils/portalError.js +41 -0
- package/dist/utils/safetyGate.js +11 -1
- package/dist/utils/synaluxSearch.js +4 -1
- package/package.json +1 -1
- package/dist/utils/googleSearchApi.js +0 -42
|
@@ -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
|
-
* `
|
|
317
|
-
* `\
|
|
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: "
|
|
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
|
|
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 _ (
|
|
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
|
-
// `
|
|
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
|
}],
|
package/dist/utils/braveApi.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/utils/layer1.js
CHANGED
|
@@ -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
|
-
*
|
|
7
|
+
* Contract (as the handler applies it):
|
|
8
8
|
* OBVIOUS_NOT_RESERVED → permits local routing
|
|
9
|
-
* OBVIOUS_RESERVED →
|
|
10
|
-
*
|
|
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 →
|
|
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
|
-
*
|
|
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
|
-
|
|
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 (
|
|
204
|
-
|
|
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.
|
|
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
|
-
|
|
79
|
-
|
|
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
|
+
}
|
package/dist/utils/safetyGate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|