prism-mcp-server 20.20.0 → 20.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -12
- package/dist/cli.js +11 -0
- package/dist/connect.js +3 -0
- package/dist/localFirstPolicy.js +2 -1
- package/dist/scholar/webScholar.js +2 -2
- package/dist/server.js +15 -2
- package/dist/storage/index.js +2 -17
- package/dist/storage/inferMetricsLedger.js +18 -9
- package/dist/tools/prismInferHandler.js +63 -21
- package/dist/tools/taskRouterHandler.js +4 -1
- package/dist/utils/braveApi.js +28 -19
- package/dist/utils/entitlements.js +9 -5
- package/dist/utils/inferenceMetrics.js +2 -0
- package/dist/utils/secureUrl.js +30 -0
- package/dist/utils/synaluxSearch.js +96 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,8 +32,11 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
|
|
|
32
32
|
undercounts inline — a number you can check, not marketing.
|
|
33
33
|
- **Route-output enforcement** — route mode returns only well-formed calls to
|
|
34
34
|
tools the host actually advertised. Standard and higher plans can add
|
|
35
|
-
authenticated deterministic correction; `route_guard: "local"`
|
|
36
|
-
|
|
35
|
+
authenticated deterministic correction; `route_guard: "local"` disables that
|
|
36
|
+
correction only. `cloud_fallback: false` forbids cloud inference fallback and
|
|
37
|
+
`verify: false` (with no `evidence`) disables the grounding verifier. With all
|
|
38
|
+
three off, no request carries your prompt, draft or evidence; the per-call
|
|
39
|
+
entitlement check and telemetry still contact the portal and carry neither.
|
|
37
40
|
- **One setup for every agent** — `prism connect` configures Claude Code,
|
|
38
41
|
Claude Desktop, Cursor, Gemini CLI, and Codex while preserving unrelated
|
|
39
42
|
settings.
|
|
@@ -1034,8 +1037,10 @@ The free tier runs entirely on your machine. Paid tiers add cloud sync through t
|
|
|
1034
1037
|
redaction (SSNs, dates of birth, medical record numbers, phone numbers, emails,
|
|
1035
1038
|
and clinical identifiers are stripped before storage). Cloud inference and
|
|
1036
1039
|
route correction send the request over TLS for processing and do not store it
|
|
1037
|
-
as Prism memory
|
|
1038
|
-
|
|
1040
|
+
as Prism memory. The **local tier** (no Synalux key) is the air-gap;
|
|
1041
|
+
`route_guard: "local"` only skips the route correction, and `cloud_fallback`
|
|
1042
|
+
and `verify` govern the other two channels. **Enterprise** includes a HIPAA
|
|
1043
|
+
Business Associate Agreement.
|
|
1039
1044
|
|
|
1040
1045
|
---
|
|
1041
1046
|
|
|
@@ -1050,8 +1055,12 @@ before it reaches the host. Malformed or unadvertised calls become `NO_TOOL`.
|
|
|
1050
1055
|
With `route_guard: "auto"` (the default), Standard and higher plans also send
|
|
1051
1056
|
a well-formed draft for one of Prism's seven trained tools—or an unadvertised
|
|
1052
1057
|
draft that may need correction—to Synalux for authenticated deterministic
|
|
1053
|
-
correction. Advertised custom host tools remain local.
|
|
1054
|
-
|
|
1058
|
+
correction. Advertised custom host tools remain local. `route_guard: "local"`
|
|
1059
|
+
disables that correction only: cloud inference fallback is governed by
|
|
1060
|
+
`cloud_fallback`, and the grounding verifier is a separate channel with its
|
|
1061
|
+
own switch (`verify`, on by default when `evidence` is given). With all three
|
|
1062
|
+
off, no request carries your prompt, draft or evidence; the entitlement check
|
|
1063
|
+
and telemetry still contact the portal and carry neither.
|
|
1055
1064
|
|
|
1056
1065
|
| Model | Ollama tag | Size | Vision | Routing accuracy¹ | Role | Automatic routing tier |
|
|
1057
1066
|
|---|---|---|---|---|---|---|
|
|
@@ -1152,12 +1161,19 @@ check, the local 9B passed 2/3 tasks; the local 27B and Gemini 3.6 Flash each
|
|
|
1152
1161
|
passed 3/3. This is a self-published regression signal, not an independent
|
|
1153
1162
|
leaderboard or a claim of broad model equivalence.
|
|
1154
1163
|
|
|
1155
|
-
### Cloud Escalation (`cloud_fallback
|
|
1164
|
+
### Cloud Escalation (`cloud_fallback`)
|
|
1156
1165
|
|
|
1157
1166
|
Prism always tries an eligible local model first. If the quality gate detects
|
|
1158
|
-
an empty, truncated, think-only, or looping response,
|
|
1159
|
-
|
|
1160
|
-
|
|
1167
|
+
an empty, truncated, think-only, or looping response, or the safety screen
|
|
1168
|
+
finds the request uncertain or reserved, a paid plan escalates the request
|
|
1169
|
+
through Gemini 3.6 Flash. Free-tier routing stays local and reports the
|
|
1170
|
+
outcome without making a cloud call.
|
|
1171
|
+
|
|
1172
|
+
The flag follows the plan. Leave it unset and your plan decides: paid plans
|
|
1173
|
+
escalate, free plans never do. Pass `false` to forbid cloud inference fallback
|
|
1174
|
+
for a call, which the clinical delegation rules do for drafting; pass `true`
|
|
1175
|
+
to ask for it, which still requires a plan with cloud. A request that carries
|
|
1176
|
+
an image is never escalated; screenshots stay on this device.
|
|
1161
1177
|
|
|
1162
1178
|
---
|
|
1163
1179
|
|
|
@@ -1361,8 +1377,8 @@ prism_infer({
|
|
|
1361
1377
|
})
|
|
1362
1378
|
// → "n = countActiveUsers(data)" (local 9b, $0)
|
|
1363
1379
|
|
|
1364
|
-
// A turn the on-device screen finds uncertain
|
|
1365
|
-
// locally: it goes to Synalux cloud on a paid plan, or is refused with
|
|
1380
|
+
// A turn the on-device screen finds uncertain, alone or in context, is not
|
|
1381
|
+
// served locally: it goes to Synalux cloud on a paid plan, or is refused with
|
|
1366
1382
|
// cloud_fallback: false. The result names the reason (layer1_uncertain) so
|
|
1367
1383
|
// the host can decide what to do with the thread.
|
|
1368
1384
|
prism_infer({
|
package/dist/cli.js
CHANGED
|
@@ -223,6 +223,17 @@ program
|
|
|
223
223
|
.option('--no-models', 'Skip model convergence; keep whatever model versions are installed')
|
|
224
224
|
.action(async (options) => {
|
|
225
225
|
try {
|
|
226
|
+
// Take the subscription key from the settings store when the environment
|
|
227
|
+
// does not carry it. connect writes into each host's MCP env block only
|
|
228
|
+
// what it can see, so without this a machine that logged in once lost its
|
|
229
|
+
// subscription on the next re-registration and every search fell through
|
|
230
|
+
// to a Brave key in the server's own environment, which a host launched
|
|
231
|
+
// from the graphical shell does not carry.
|
|
232
|
+
{
|
|
233
|
+
const { hydrateSynaluxCredentials } = await import('./utils/synaluxSearch.js');
|
|
234
|
+
const { getSetting } = await import('./storage/configStorage.js');
|
|
235
|
+
await hydrateSynaluxCredentials(getSetting);
|
|
236
|
+
}
|
|
226
237
|
// ── Converge the PACKAGE first, then the configs ──────────────
|
|
227
238
|
// connect is the one command the operator runs to make a machine
|
|
228
239
|
// current; leaving it configuring with stale code produced the
|
package/dist/connect.js
CHANGED
|
@@ -1163,6 +1163,9 @@ function buildMcpEntry(nodePath, serverPath, env) {
|
|
|
1163
1163
|
PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
|
|
1164
1164
|
PRISM_STORAGE: storage,
|
|
1165
1165
|
};
|
|
1166
|
+
// process.env is hydrated from the settings store before connect runs, so a
|
|
1167
|
+
// machine that logged in once keeps its subscription across re-registration
|
|
1168
|
+
// instead of silently dropping to unauthenticated search.
|
|
1166
1169
|
if (env.PRISM_SYNALUX_API_KEY) {
|
|
1167
1170
|
serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
|
|
1168
1171
|
}
|
package/dist/localFirstPolicy.js
CHANGED
|
@@ -9,7 +9,8 @@ export const LOCAL_FIRST_POLICY_LINES = [
|
|
|
9
9
|
"## Prism local-first orchestration",
|
|
10
10
|
"For bounded, verifiable delegated work, use Prism's local worker before any host-native or background subagent.",
|
|
11
11
|
"When session_task_route is available, call it first. If it returns target `claw`, call `prism_infer` with the",
|
|
12
|
-
"recommended arguments
|
|
12
|
+
"recommended arguments and the `project` and `conversation_id` from this session when known. Leave `cloud_fallback`",
|
|
13
|
+
"unset so the plan decides it; pass `false` to forbid cloud inference fallback.",
|
|
13
14
|
"If the route says `needs_history`, or the task follows up an earlier `prism_infer` answer, pass the accepted prior",
|
|
14
15
|
"turns as `messages` (paid plans); never send a follow-up without them, and never re-send a turn you rejected.",
|
|
15
16
|
"Prism loads that project's dashboard-configured quick, standard, or deep memory and selects a RAM-safe local model.",
|
|
@@ -12,7 +12,7 @@ import { searchYahooFree, scrapeArticleLocal } from "./freeSearch.js";
|
|
|
12
12
|
// The same capability flag performWebSearchRaw itself branches on, imported
|
|
13
13
|
// from the same module so the gate and the transport cannot disagree about
|
|
14
14
|
// whether a web search is possible.
|
|
15
|
-
import {
|
|
15
|
+
import { synaluxSearchAvailable } from "../utils/synaluxSearch.js";
|
|
16
16
|
// ─── Hivemind Integration Helpers ────────────────────────────
|
|
17
17
|
const SCHOLAR_PROJECT = "prism-scholar";
|
|
18
18
|
const SCHOLAR_ROLE = "scholar";
|
|
@@ -208,7 +208,7 @@ export async function runWebScholar(overrideTopic, overrideProject) {
|
|
|
208
208
|
//
|
|
209
209
|
// Google Custom Search used to take priority over both; now removed
|
|
210
210
|
// because Google discontinues that API on 2027-01-01.
|
|
211
|
-
const useWebSearch =
|
|
211
|
+
const useWebSearch = synaluxSearchAvailable() || !!BRAVE_API_KEY;
|
|
212
212
|
const topic = overrideTopic || await selectTopic();
|
|
213
213
|
const project = overrideProject || SCHOLAR_PROJECT;
|
|
214
214
|
if (!topic) {
|
package/dist/server.js
CHANGED
|
@@ -76,9 +76,10 @@ import { SKILL_SAVE_TOOL, SKILL_MANAGE_TOOL, skillSaveHandler, skillManageHandle
|
|
|
76
76
|
// error wrapper. Now uses getStorage() which routes through the
|
|
77
77
|
// correct backend (Supabase or SQLite) with proper error handling.
|
|
78
78
|
import { getStorage } from "./storage/index.js";
|
|
79
|
-
import { getSettingSync, initConfigStorage } from "./storage/configStorage.js";
|
|
79
|
+
import { getSetting, getSettingSync, initConfigStorage } from "./storage/configStorage.js";
|
|
80
80
|
import { sanitizeMcpOutput } from "./utils/sanitizer.js";
|
|
81
|
-
import { sanitizeForLog } from "./utils/logger.js";
|
|
81
|
+
import { debugLog, sanitizeForLog } from "./utils/logger.js";
|
|
82
|
+
import { hydrateSynaluxCredentials } from "./utils/synaluxSearch.js";
|
|
82
83
|
import { getTracer, initTelemetry } from "./utils/telemetry.js";
|
|
83
84
|
import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/api";
|
|
84
85
|
import { ddInfo, ddError as ddLogError } from "./utils/ddLogger.js";
|
|
@@ -1290,6 +1291,18 @@ export async function startServer() {
|
|
|
1290
1291
|
// synchronously. This is a synchronous call — no await needed.
|
|
1291
1292
|
// No-op when otel_enabled=false (the default).
|
|
1292
1293
|
initTelemetry();
|
|
1294
|
+
// Put the subscription key in process.env before anything asks whether the
|
|
1295
|
+
// portal is reachable. `prism connect` copies it into the host's MCP env
|
|
1296
|
+
// block only if it was in the environment when connect ran; otherwise it is
|
|
1297
|
+
// in the settings store, and a paid subscriber's searches were failing on a
|
|
1298
|
+
// Brave key in the server's own environment, which a host launched from the
|
|
1299
|
+
// graphical shell does not carry.
|
|
1300
|
+
// getSetting reads the cache initConfigStorage() just warmed, so this adds
|
|
1301
|
+
// no I/O to the path the Initialize handshake waits on.
|
|
1302
|
+
const portalSearchReady = await hydrateSynaluxCredentials(getSetting);
|
|
1303
|
+
if (!portalSearchReady) {
|
|
1304
|
+
debugLog("[Prism] no Synalux subscription key in the environment or the settings store — search falls back to BRAVE_API_KEY");
|
|
1305
|
+
}
|
|
1293
1306
|
const server = createServer();
|
|
1294
1307
|
const transport = new StdioServerTransport();
|
|
1295
1308
|
await server.connect(transport);
|
package/dist/storage/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { PRISM_STORAGE as ENV_PRISM_STORAGE, SUPABASE_CONFIGURED, SYNALUX_CONFIG
|
|
|
2
2
|
import { debugLog } from "../utils/logger.js";
|
|
3
3
|
import { SupabaseStorage } from "./supabase.js";
|
|
4
4
|
import { getSetting } from "./configStorage.js";
|
|
5
|
+
import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
|
|
5
6
|
export function isValidHttpUrl(url) {
|
|
6
7
|
try {
|
|
7
8
|
const parsed = new URL(url);
|
|
@@ -42,23 +43,7 @@ export function isValidHttpUrl(url) {
|
|
|
42
43
|
* listener the connection fails afterwards, and the log line is what explains
|
|
43
44
|
* why.
|
|
44
45
|
*/
|
|
45
|
-
export
|
|
46
|
-
try {
|
|
47
|
-
const parsed = new URL(raw);
|
|
48
|
-
if (parsed.protocol !== "http:")
|
|
49
|
-
return raw;
|
|
50
|
-
const host = parsed.hostname;
|
|
51
|
-
if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]")
|
|
52
|
-
return raw;
|
|
53
|
-
parsed.protocol = "https:";
|
|
54
|
-
const upgraded = parsed.toString().replace(/\/+$/, "");
|
|
55
|
-
debugLog(`[Prism Storage] Upgraded ${raw} to ${upgraded}: session content is never sent over plaintext to a remote host.`);
|
|
56
|
-
return upgraded;
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return raw;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
46
|
+
export { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
|
|
62
47
|
/**
|
|
63
48
|
* Probe for synalux credentials: env vars first, then config DB.
|
|
64
49
|
* Returns true if usable credentials are now in process.env.
|
|
@@ -42,8 +42,8 @@ const LEDGER_UNAVAILABLE_ERROR = "Inference metrics ledger is unavailable";
|
|
|
42
42
|
const INSERT_METRIC_SQL = `INSERT OR IGNORE INTO infer_metrics
|
|
43
43
|
(ts, caller, mode, backend, model, used_cloud, gate_outcome,
|
|
44
44
|
refusal_reason, prompt_tokens, completion_tokens, latency_ms, ram_free_mb,
|
|
45
|
-
source_event_id)
|
|
46
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
45
|
+
source_event_id, history_turns, refusal_layer)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
47
47
|
function closeClient(context) {
|
|
48
48
|
const activeClient = client;
|
|
49
49
|
client = null;
|
|
@@ -83,17 +83,25 @@ function ensureTable() {
|
|
|
83
83
|
completion_tokens INTEGER,
|
|
84
84
|
latency_ms INTEGER,
|
|
85
85
|
ram_free_mb INTEGER,
|
|
86
|
-
source_event_id TEXT
|
|
86
|
+
source_event_id TEXT,
|
|
87
|
+
history_turns INTEGER,
|
|
88
|
+
refusal_layer TEXT
|
|
87
89
|
)`);
|
|
88
90
|
// Existing ledgers predate external panel-spool ingestion. SQLite has
|
|
89
91
|
// no ADD COLUMN IF NOT EXISTS, so use the repository's established
|
|
90
92
|
// idempotent migration pattern and reject only unexpected failures.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
for (const column of [
|
|
94
|
+
"source_event_id TEXT",
|
|
95
|
+
"history_turns INTEGER",
|
|
96
|
+
"refusal_layer TEXT",
|
|
97
|
+
]) {
|
|
98
|
+
try {
|
|
99
|
+
await client.execute(`ALTER TABLE infer_metrics ADD COLUMN ${column}`);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (!(e instanceof Error) || !e.message.includes("duplicate column name"))
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
97
105
|
}
|
|
98
106
|
await client.execute(`CREATE INDEX IF NOT EXISTS idx_infer_metrics_ts ON infer_metrics (ts)`);
|
|
99
107
|
await client.execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_infer_metrics_source_event
|
|
@@ -149,6 +157,7 @@ function metricArgs(row) {
|
|
|
149
157
|
row.refusal_reason ?? null, row.prompt_tokens ?? null,
|
|
150
158
|
row.completion_tokens ?? null, row.latency_ms ?? null,
|
|
151
159
|
row.ram_free_mb ?? null, row.source_event_id ?? null,
|
|
160
|
+
row.history_turns ?? null, row.refusal_layer ?? null,
|
|
152
161
|
];
|
|
153
162
|
}
|
|
154
163
|
/** Aggregate all persisted rows (optionally since a timestamp). */
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 1. Probe Ollama, list tags
|
|
10
10
|
* 2. Pick largest viable local tier (pickLocalModel)
|
|
11
11
|
* 3. Call /api/generate locally — return on success
|
|
12
|
-
* 4. On local fail, if cloud_fallback
|
|
12
|
+
* 4. On local fail, if the plan allows cloud (explicit cloud_fallback:false forbids it):
|
|
13
13
|
* - exchange synalux_sk_ → JWT (cached)
|
|
14
14
|
* - POST synalux portal /api/v1/prism/inference
|
|
15
15
|
* - portal serves Gemini 3.6 Flash according to the user's tier
|
|
@@ -259,6 +259,11 @@ async function classifyHistoryWindow(l1fn, window, ollamaUrl, model, budget) {
|
|
|
259
259
|
budget.tripped = true;
|
|
260
260
|
return "UNCERTAIN";
|
|
261
261
|
}
|
|
262
|
+
// deterministic:false is deliberate for a JOINT window and must stay so:
|
|
263
|
+
// the co-occurrence rules see words from different turns as one clause
|
|
264
|
+
// (the 2026-09-16 field refusal's joined window fires them; every turn
|
|
265
|
+
// alone is clean). The rules run per turn in proximity slices upstream;
|
|
266
|
+
// here only the model reads, and only to RAISE.
|
|
262
267
|
const verdict = await l1fn(window, ollamaUrl, model, undefined, undefined, { deterministic: false });
|
|
263
268
|
if (budget) {
|
|
264
269
|
budget.consecutiveErrors = verdict === "ERROR" ? budget.consecutiveErrors + 1 : 0;
|
|
@@ -389,7 +394,7 @@ export const PRISM_INFER_TOOL = {
|
|
|
389
394
|
"the caller's `task_complexity`, then validates loaded memory size, model context, " +
|
|
390
395
|
"entitlements, installed models, and free RAM at call time. " +
|
|
391
396
|
"Falls through to the Synalux portal Gemini 3.6 Flash cloud fallback " +
|
|
392
|
-
"only when local is unviable
|
|
397
|
+
"only when local is unviable or refused and the plan allows cloud; `cloud_fallback: false` forbids it. " +
|
|
393
398
|
"When `project` is provided, loads the dashboard-configured quick/standard/deep handoff and bounded history " +
|
|
394
399
|
"as untrusted historical context for a memory-aware local worker. " +
|
|
395
400
|
"Use this for code generation, summarisation, classification, or any synth task you would " +
|
|
@@ -474,8 +479,7 @@ export const PRISM_INFER_TOOL = {
|
|
|
474
479
|
},
|
|
475
480
|
cloud_fallback: {
|
|
476
481
|
type: "boolean",
|
|
477
|
-
description: "
|
|
478
|
-
default: false,
|
|
482
|
+
description: "Synalux portal cascade when local is unviable or refused. Omitted: the plan decides; false forbids it.",
|
|
479
483
|
},
|
|
480
484
|
timeout_ms: {
|
|
481
485
|
type: "number",
|
|
@@ -529,7 +533,7 @@ export const PRISM_INFER_TOOL = {
|
|
|
529
533
|
type: "string",
|
|
530
534
|
enum: ["auto", "local"],
|
|
531
535
|
description: "'auto' (default): local advertised-tool contract plus, on paid plans, the private " +
|
|
532
|
-
"Synalux deterministic route correction. 'local':
|
|
536
|
+
"Synalux deterministic route correction. 'local': skips that correction only.",
|
|
533
537
|
default: "auto",
|
|
534
538
|
},
|
|
535
539
|
think: {
|
|
@@ -1051,8 +1055,9 @@ export class ReservedRefusalError extends Error {
|
|
|
1051
1055
|
const what = category ? `category="${category}"` : "matched the semantic classifier";
|
|
1052
1056
|
const remedy = cloudWasAllowed
|
|
1053
1057
|
? "Cloud escalation was permitted and did not produce an answer; see attempts."
|
|
1054
|
-
: "Reserved content is never answered by a local model.
|
|
1055
|
-
+ "
|
|
1058
|
+
: "Reserved content is never answered by a local model. This call had no cloud: either it "
|
|
1059
|
+
+ "passed cloud_fallback: false, or the plan has none. Pass cloud_fallback: true (or omit "
|
|
1060
|
+
+ "it, on a paid plan) to escalate to a stronger model, or answer it in the host thread instead.";
|
|
1056
1061
|
super(`prism_infer: Layer 1 verdict=${verdict}, ${what} — reserved content refused. `
|
|
1057
1062
|
+ `${remedy} attempts=${JSON.stringify(attempts)}`);
|
|
1058
1063
|
this.attempts = attempts;
|
|
@@ -1060,7 +1065,7 @@ export class ReservedRefusalError extends Error {
|
|
|
1060
1065
|
this.name = "ReservedRefusalError";
|
|
1061
1066
|
}
|
|
1062
1067
|
}
|
|
1063
|
-
function makeReservedRefusal(verdict, attempts, category = null, cloudWasAllowed = false) {
|
|
1068
|
+
function makeReservedRefusal(verdict, attempts, category = null, cloudWasAllowed = false, ledger = {}) {
|
|
1064
1069
|
// Ledger the refusal (fire-and-forget). No prompt content is persisted —
|
|
1065
1070
|
// same HIPAA posture as the safety_gate exclusion. gate_outcome mirrors
|
|
1066
1071
|
// the §5.2 report-mode row so refusal queries see both modes.
|
|
@@ -1068,6 +1073,8 @@ function makeReservedRefusal(verdict, attempts, category = null, cloudWasAllowed
|
|
|
1068
1073
|
backend: "refused", model: null, used_cloud: false,
|
|
1069
1074
|
gate_outcome: "refused",
|
|
1070
1075
|
refusal_reason: "layer1_reserved",
|
|
1076
|
+
history_turns: ledger.history_turns,
|
|
1077
|
+
refusal_layer: ledger.refusal_layer,
|
|
1071
1078
|
});
|
|
1072
1079
|
return new ReservedRefusalError(verdict, attempts, category, cloudWasAllowed);
|
|
1073
1080
|
}
|
|
@@ -1400,10 +1407,25 @@ export async function runInfer(args, deps) {
|
|
|
1400
1407
|
// Retained for the log line, which describes the request rather than a
|
|
1401
1408
|
// specific backend.
|
|
1402
1409
|
const maxTokens = cloudMaxTokens;
|
|
1403
|
-
// Cloud fallback
|
|
1410
|
+
// Cloud fallback is the PLAN's to give. An omitted flag means "whatever my
|
|
1411
|
+
// plan entitles me to": paid plans escalate, free plans do not. Explicit
|
|
1412
|
+
// false still forbids cloud INFERENCE fallback — the clinical delegation
|
|
1413
|
+
// rules and token-saving callers depend on that; the route guard and the
|
|
1414
|
+
// grounding verifier keep their own switches (route_guard, verify) — and
|
|
1415
|
+
// explicit true still needs a
|
|
1416
|
+
// plan with cloud. Until 2026-09-16 an omitted flag meant "no cloud", so a
|
|
1417
|
+
// paid, portal-ruled entitlement sat unused and an UNCERTAIN verdict
|
|
1418
|
+
// dead-ended instead of escalating; measured in production the day
|
|
1419
|
+
// multi-turn shipped, on a host that simply did not pass the argument.
|
|
1404
1420
|
// let, not const: the reserved-image branch pins this off mid-call so no
|
|
1405
1421
|
// later escalation path can carry even the prompt text off-device.
|
|
1406
|
-
|
|
1422
|
+
// The default is image-aware: cloud can never serve an image request
|
|
1423
|
+
// (screenshots stay on this device), so defaulting it ON for one would only
|
|
1424
|
+
// convert a gate-failed-but-usable local answer into a hard failure — an
|
|
1425
|
+
// explicit request still behaves as before and is refused at the point of
|
|
1426
|
+
// use with its attempt named.
|
|
1427
|
+
const planDefault = ent.features.cloud_fallback && !(args.images?.length);
|
|
1428
|
+
let allowCloud = (args.cloud_fallback ?? planDefault) && ent.features.cloud_fallback;
|
|
1407
1429
|
// Verification only for paid plans (free users skip L3 grounding)
|
|
1408
1430
|
const canVerify = ent.features.grounding_verifier;
|
|
1409
1431
|
// The portal entitlement is authoritative. A paid plan alone must not
|
|
@@ -1438,6 +1460,20 @@ export async function runInfer(args, deps) {
|
|
|
1438
1460
|
multi_turn: multiTurnPolicy(ent),
|
|
1439
1461
|
history_turns: args.messages?.length ?? 0,
|
|
1440
1462
|
};
|
|
1463
|
+
// Which screen layer decided the call; ledgered on a refusal. Bookkeeping
|
|
1464
|
+
// only: raise() is worseLayer1Verdict with a label and the assignment stays
|
|
1465
|
+
// at the call site, so it changes no outcome. Declared here because
|
|
1466
|
+
// refusedResult() can run before the Layer 1 block. Without it, "which
|
|
1467
|
+
// layer refused this" needs a transcript replay — exactly what a benign
|
|
1468
|
+
// production refusal cost on 2026-09-16.
|
|
1469
|
+
let l1Layer = null;
|
|
1470
|
+
const ledgerMeta = () => ({ history_turns: args.messages?.length ?? 0, refusal_layer: l1Layer ?? undefined });
|
|
1471
|
+
const raise = (cur, next, source) => {
|
|
1472
|
+
const merged = worseLayer1Verdict(cur, next);
|
|
1473
|
+
if (merged !== cur)
|
|
1474
|
+
l1Layer = source;
|
|
1475
|
+
return merged;
|
|
1476
|
+
};
|
|
1441
1477
|
const refusedResult = (reason) => ({
|
|
1442
1478
|
output: "",
|
|
1443
1479
|
backend: "refused",
|
|
@@ -1448,6 +1484,7 @@ export async function runInfer(args, deps) {
|
|
|
1448
1484
|
attempts,
|
|
1449
1485
|
...entMeta,
|
|
1450
1486
|
gate_outcome: { status: "refused", reason, served_anyway: false },
|
|
1487
|
+
refusal_layer: l1Layer ?? undefined,
|
|
1451
1488
|
});
|
|
1452
1489
|
debugLog(`[prism_infer] plan=${ent.plan} ceiling=${effectiveCeiling} max_tokens=${maxTokens} ` +
|
|
1453
1490
|
`cloud=${allowCloud} verify=${canVerify} route_guard=${canUsePrivateRouteGuard}`);
|
|
@@ -1593,6 +1630,8 @@ export async function runInfer(args, deps) {
|
|
|
1593
1630
|
if (!args.messages?.length) {
|
|
1594
1631
|
// Single turn: the exact call it always was.
|
|
1595
1632
|
l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages);
|
|
1633
|
+
if (l1 !== "OBVIOUS_NOT_RESERVED")
|
|
1634
|
+
l1Layer = "prompt";
|
|
1596
1635
|
}
|
|
1597
1636
|
else {
|
|
1598
1637
|
l1 = "OBVIOUS_NOT_RESERVED";
|
|
@@ -1621,7 +1660,7 @@ export async function runInfer(args, deps) {
|
|
|
1621
1660
|
for (const slice of windowsOf(turn.content, DETERMINISTIC_FLOOR_WINDOW_CHARS, DETERMINISTIC_FLOOR_WINDOW_OVERLAP)) {
|
|
1622
1661
|
const det = classifyDeterministicLayer1(slice, { operational: isUser });
|
|
1623
1662
|
if (det)
|
|
1624
|
-
l1 =
|
|
1663
|
+
l1 = raise(l1, det, "rules");
|
|
1625
1664
|
}
|
|
1626
1665
|
}
|
|
1627
1666
|
// 2. Semantic floor, per TURN in isolation; every verdict read
|
|
@@ -1651,10 +1690,10 @@ export async function runInfer(args, deps) {
|
|
|
1651
1690
|
continue;
|
|
1652
1691
|
const alone = await classifyHistoryWindow(l1fn, window, deps.ollamaUrl, l1Model, budget);
|
|
1653
1692
|
if (alone === "OBVIOUS_RESERVED") {
|
|
1654
|
-
l1 = "OBVIOUS_RESERVED";
|
|
1693
|
+
l1 = raise(l1, "OBVIOUS_RESERVED", "isolated");
|
|
1655
1694
|
break history;
|
|
1656
1695
|
}
|
|
1657
|
-
l1 =
|
|
1696
|
+
l1 = raise(l1, alone, "isolated");
|
|
1658
1697
|
}
|
|
1659
1698
|
}
|
|
1660
1699
|
// The current prompt is a request: its deterministic floor runs
|
|
@@ -1667,7 +1706,7 @@ export async function runInfer(args, deps) {
|
|
|
1667
1706
|
for (const slice of windowsOf(args.prompt, DETERMINISTIC_FLOOR_WINDOW_CHARS, DETERMINISTIC_FLOOR_WINDOW_OVERLAP)) {
|
|
1668
1707
|
const promptDet = classifyDeterministicLayer1(slice);
|
|
1669
1708
|
if (promptDet)
|
|
1670
|
-
l1 =
|
|
1709
|
+
l1 = raise(l1, promptDet, "rules");
|
|
1671
1710
|
if (promptDet !== "OBVIOUS_NOT_RESERVED")
|
|
1672
1711
|
promptRoutine = false;
|
|
1673
1712
|
}
|
|
@@ -1681,7 +1720,7 @@ export async function runInfer(args, deps) {
|
|
|
1681
1720
|
// (review round 19: skipping it there bypassed that floor).
|
|
1682
1721
|
const promptFastPath = promptRoutine && args.prompt.length <= MAX_CLASSIFIER_PROMPT_LENGTH && (resolvedImages?.length ?? 0) === 0;
|
|
1683
1722
|
if (l1 !== "OBVIOUS_RESERVED" && !promptFastPath) {
|
|
1684
|
-
l1 =
|
|
1723
|
+
l1 = raise(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false }), "prompt");
|
|
1685
1724
|
}
|
|
1686
1725
|
// 3. Context, raise only: one window per turn and one for the
|
|
1687
1726
|
// prompt (see contextWindows), cached like any window. Skipped
|
|
@@ -1697,7 +1736,7 @@ export async function runInfer(args, deps) {
|
|
|
1697
1736
|
for (const window of contextWindows(args)) {
|
|
1698
1737
|
if (!window.trim())
|
|
1699
1738
|
continue;
|
|
1700
|
-
l1 =
|
|
1739
|
+
l1 = raise(l1, await classifyHistoryWindow(l1fn, window, deps.ollamaUrl, l1Model, budget), "context");
|
|
1701
1740
|
if (l1 === "UNCERTAIN" || l1 === "OBVIOUS_RESERVED")
|
|
1702
1741
|
break;
|
|
1703
1742
|
}
|
|
@@ -1705,7 +1744,7 @@ export async function runInfer(args, deps) {
|
|
|
1705
1744
|
// A budget or breaker trip raises to UNCERTAIN whatever the cache
|
|
1706
1745
|
// held (text: cloud or refused; with an image: local only).
|
|
1707
1746
|
if (budget.tripped)
|
|
1708
|
-
l1 =
|
|
1747
|
+
l1 = raise(l1, "UNCERTAIN", "budget");
|
|
1709
1748
|
if (budget.calls > LAYER1_SCREEN_CALL_BUDGET) {
|
|
1710
1749
|
attempts.push({ tier: "layer1", reason: `layer1_screen_over_budget:${LAYER1_SCREEN_CALL_BUDGET}` });
|
|
1711
1750
|
}
|
|
@@ -1753,7 +1792,7 @@ export async function runInfer(args, deps) {
|
|
|
1753
1792
|
attempts.push({ tier: "synalux", reason: "reserved_escalation_refused_images_stay_local" });
|
|
1754
1793
|
if (wantReport)
|
|
1755
1794
|
return refusedResult("layer1_reserved");
|
|
1756
|
-
throw makeReservedRefusal(l1, attempts, reservedCat, true);
|
|
1795
|
+
throw makeReservedRefusal(l1, attempts, reservedCat, true, ledgerMeta());
|
|
1757
1796
|
}
|
|
1758
1797
|
if (allowCloud) {
|
|
1759
1798
|
const cloudTimeout = args.timeout_ms ?? 90_000;
|
|
@@ -1769,7 +1808,7 @@ export async function runInfer(args, deps) {
|
|
|
1769
1808
|
attempts.push({ tier: "synalux", reason: `reserved_weak_backend:${cloud.backend}` });
|
|
1770
1809
|
if (wantReport)
|
|
1771
1810
|
return refusedResult("layer1_reserved");
|
|
1772
|
-
throw makeReservedRefusal(l1, attempts, reservedCat, true);
|
|
1811
|
+
throw makeReservedRefusal(l1, attempts, reservedCat, true, ledgerMeta());
|
|
1773
1812
|
}
|
|
1774
1813
|
return await applyVerification(cloud.output, gatedArgs, deps, {
|
|
1775
1814
|
backend: cloud.backend ?? "synalux",
|
|
@@ -1787,7 +1826,7 @@ export async function runInfer(args, deps) {
|
|
|
1787
1826
|
}
|
|
1788
1827
|
if (wantReport)
|
|
1789
1828
|
return refusedResult("layer1_reserved");
|
|
1790
|
-
throw makeReservedRefusal(l1, attempts, reservedCat, allowCloud);
|
|
1829
|
+
throw makeReservedRefusal(l1, attempts, reservedCat, allowCloud, ledgerMeta());
|
|
1791
1830
|
}
|
|
1792
1831
|
if (l1 === "UNCERTAIN_LENGTH") {
|
|
1793
1832
|
// §5.3: prompt too long to classify in full, but the full-text
|
|
@@ -1818,7 +1857,7 @@ export async function runInfer(args, deps) {
|
|
|
1818
1857
|
attempts.push({ tier: "synalux", reason: "error_escalation_refused_images_stay_local" });
|
|
1819
1858
|
if (wantReport)
|
|
1820
1859
|
return refusedResult("layer1_error");
|
|
1821
|
-
throw makeReservedRefusal(l1, attempts);
|
|
1860
|
+
throw makeReservedRefusal(l1, attempts, null, false, ledgerMeta());
|
|
1822
1861
|
}
|
|
1823
1862
|
if (allowCloud) {
|
|
1824
1863
|
const cloudTimeout = args.timeout_ms ?? 90_000;
|
|
@@ -1842,6 +1881,7 @@ export async function runInfer(args, deps) {
|
|
|
1842
1881
|
debugLog(`[prism_infer] keyword backstop verdict=${backstop}`);
|
|
1843
1882
|
attempts.push({ tier: "keyword_backstop", reason: `backstop_${backstop.toLowerCase()}` });
|
|
1844
1883
|
if (backstop === "OBVIOUS_RESERVED") {
|
|
1884
|
+
l1Layer = "backstop"; // the regex net refused, whatever raised the verdict before it
|
|
1845
1885
|
if (wantReport)
|
|
1846
1886
|
return refusedResult("keyword_backstop_reserved");
|
|
1847
1887
|
// Serve-mode backstop refusal previously wrote NO ledger row —
|
|
@@ -1850,6 +1890,8 @@ export async function runInfer(args, deps) {
|
|
|
1850
1890
|
backend: "refused", model: null, used_cloud: false,
|
|
1851
1891
|
gate_outcome: "refused",
|
|
1852
1892
|
refusal_reason: "keyword_backstop_reserved",
|
|
1893
|
+
history_turns: args.messages?.length ?? 0,
|
|
1894
|
+
refusal_layer: "backstop",
|
|
1853
1895
|
});
|
|
1854
1896
|
throw new Error(`prism_infer: classifier failed + keyword backstop caught reserved content. attempts=${JSON.stringify(attempts)}`);
|
|
1855
1897
|
}
|
|
@@ -347,7 +347,10 @@ function buildRecommendedArgs(args, complexityScore) {
|
|
|
347
347
|
...(args.project ? { project: args.project } : {}),
|
|
348
348
|
mode: "code",
|
|
349
349
|
task_complexity: complexityScore,
|
|
350
|
-
cloud_fallback:
|
|
350
|
+
// cloud_fallback is deliberately absent: prism_infer resolves it from the
|
|
351
|
+
// plan's entitlements, which the router does not read. Pinning it false
|
|
352
|
+
// here made a paid plan's escalation unreachable for any host that copied
|
|
353
|
+
// these arguments verbatim (2026-09-16).
|
|
351
354
|
escalation: "report",
|
|
352
355
|
};
|
|
353
356
|
}
|
package/dist/utils/braveApi.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { BRAVE_API_KEY, BRAVE_ANSWERS_API_KEY } from "../config.js";
|
|
32
32
|
import { debugLog } from "./logger.js";
|
|
33
33
|
import { isPortalPlanRefusal } from "./portalError.js";
|
|
34
|
-
import {
|
|
34
|
+
import { synaluxSearchAvailable, synaluxWebSearch, synaluxWebSearchRaw, synaluxLocalSearch, synaluxLocalSearchRaw, synaluxBraveAnswers, } from "./synaluxSearch.js";
|
|
35
35
|
const BRAVE_API_KEY_MISSING_ERROR = "BRAVE_API_KEY is not configured";
|
|
36
36
|
const BRAVE_ANSWERS_API_KEY_MISSING_ERROR = "BRAVE_ANSWERS_API_KEY is not configured";
|
|
37
37
|
const SYNALUX_OFFSET_UNSUPPORTED_ERROR = "Configured Synalux search does not support search offsets";
|
|
@@ -65,7 +65,7 @@ async function portalFirst(viaPortal, ownKey, viaOwnKey) {
|
|
|
65
65
|
}
|
|
66
66
|
// Brave Answers API call (AI Grounding/OpenAI-compatible)
|
|
67
67
|
export async function performBraveAnswers(query, model = "brave") {
|
|
68
|
-
if (
|
|
68
|
+
if (synaluxSearchAvailable()) {
|
|
69
69
|
return portalFirst(() => synaluxBraveAnswers(query, model), BRAVE_ANSWERS_API_KEY, () => braveAnswersDirect(query, model));
|
|
70
70
|
}
|
|
71
71
|
return braveAnswersDirect(query, model);
|
|
@@ -102,7 +102,7 @@ async function braveAnswersDirect(query, model) {
|
|
|
102
102
|
}
|
|
103
103
|
// Raw web search API call
|
|
104
104
|
export async function performWebSearchRaw(query, count = 10, offset = 0) {
|
|
105
|
-
if (
|
|
105
|
+
if (synaluxSearchAvailable()) {
|
|
106
106
|
if (offset !== 0)
|
|
107
107
|
throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
|
|
108
108
|
return portalFirst(() => synaluxWebSearchRaw(query, count), BRAVE_API_KEY, () => braveWebSearchRaw(query, count, offset));
|
|
@@ -131,7 +131,7 @@ async function braveWebSearchRaw(query, count, offset) {
|
|
|
131
131
|
}
|
|
132
132
|
// Web search API call
|
|
133
133
|
export async function performWebSearch(query, count = 10, offset = 0) {
|
|
134
|
-
if (
|
|
134
|
+
if (synaluxSearchAvailable()) {
|
|
135
135
|
if (offset !== 0)
|
|
136
136
|
throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
|
|
137
137
|
return portalFirst(() => synaluxWebSearch(query, count), BRAVE_API_KEY, () => braveWebSearch(query, count, offset));
|
|
@@ -197,7 +197,7 @@ function chunkArray(arr, size) {
|
|
|
197
197
|
}
|
|
198
198
|
// Raw local search API call with poi/details payload
|
|
199
199
|
export async function performLocalSearchRaw(query, count = 5) {
|
|
200
|
-
if (
|
|
200
|
+
if (synaluxSearchAvailable()) {
|
|
201
201
|
return portalFirst(() => synaluxLocalSearchRaw(query, count), BRAVE_API_KEY, () => braveLocalSearchRaw(query, count));
|
|
202
202
|
}
|
|
203
203
|
return braveLocalSearchRaw(query, count);
|
|
@@ -260,7 +260,7 @@ async function braveLocalSearchRaw(query, count) {
|
|
|
260
260
|
}
|
|
261
261
|
// Local search API call with poi details
|
|
262
262
|
export async function performLocalSearch(query, count = 5) {
|
|
263
|
-
if (
|
|
263
|
+
if (synaluxSearchAvailable()) {
|
|
264
264
|
return portalFirst(() => synaluxLocalSearch(query, count), BRAVE_API_KEY, () => braveLocalSearch(query, count));
|
|
265
265
|
}
|
|
266
266
|
return braveLocalSearch(query, count);
|
|
@@ -278,20 +278,29 @@ async function braveLocalSearch(query, count) {
|
|
|
278
278
|
export function formatLocalResults(poisData, descData) {
|
|
279
279
|
return ((poisData.results || [])
|
|
280
280
|
.map((poi) => {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
]
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
281
|
+
// Brave sends postal_address.displayAddress, contact.telephone and
|
|
282
|
+
// opening_hours. The streetAddress/phone/openingHours shapes below have
|
|
283
|
+
// never appeared on a POI, which is why every venue came back N/A while
|
|
284
|
+
// the name resolved through the title fallback. Measured 2026-09-16.
|
|
285
|
+
const today = poi.opening_hours?.current_day?.[0];
|
|
286
|
+
const hours = (poi.openingHours || []).join(", ")
|
|
287
|
+
|| (today?.opens && today?.closes ? `${today.opens}-${today.closes}` : "")
|
|
288
|
+
|| "N/A";
|
|
289
|
+
const address = poi.postal_address?.displayAddress ||
|
|
290
|
+
[
|
|
291
|
+
poi.address?.streetAddress ?? "",
|
|
292
|
+
poi.address?.addressLocality ?? "",
|
|
293
|
+
poi.address?.addressRegion ?? "",
|
|
294
|
+
poi.address?.postalCode ?? "",
|
|
295
|
+
]
|
|
296
|
+
.filter((part) => part !== "")
|
|
297
|
+
.join(", ") || "N/A";
|
|
298
|
+
return `Name: ${poi.title || poi.name || "N/A"}
|
|
290
299
|
Address: ${address}
|
|
291
|
-
Phone: ${poi.phone || "N/A"}
|
|
292
|
-
Rating: ${poi.rating?.ratingValue ?? "N/A"} (${poi.rating?.ratingCount ?? 0} reviews)
|
|
293
|
-
Price Range: ${poi.priceRange || "N/A"}
|
|
294
|
-
Hours: ${
|
|
300
|
+
Phone: ${poi.contact?.telephone || poi.phone || "N/A"}
|
|
301
|
+
Rating: ${poi.rating?.ratingValue ?? "N/A"} (${poi.rating?.reviewCount ?? poi.rating?.ratingCount ?? 0} reviews)
|
|
302
|
+
Price Range: ${poi.price_range || poi.priceRange || "N/A"}
|
|
303
|
+
Hours: ${hours}
|
|
295
304
|
Description: ${descData.descriptions[poi.id] || "No description available"}
|
|
296
305
|
`;
|
|
297
306
|
})
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* Authenticated users get their plan from the portal (5-minute cache).
|
|
10
10
|
*/
|
|
11
11
|
import { getSynaluxJwt } from "./synaluxJwt.js";
|
|
12
|
-
import {
|
|
12
|
+
import { SYNALUX_CONFIGURED } from "../config.js";
|
|
13
13
|
import { debugLog } from "./logger.js";
|
|
14
|
+
import { resolvePortalBaseUrl, usablePortalKey } from "./synaluxSearch.js";
|
|
14
15
|
/** What a host with NO portal (unconfigured), a portal that says nothing
|
|
15
16
|
* (older deployment), or an assumed-free fallback gets: OFF. Multi-turn is
|
|
16
17
|
* a paid-plan feature (owner decision 2026-09-15); a client default that
|
|
@@ -101,10 +102,13 @@ export function clampCeiling(requested, planCeiling) {
|
|
|
101
102
|
}
|
|
102
103
|
// ── Fetch ─────────────────────────────────────────────────────────
|
|
103
104
|
async function fetchEntitlements() {
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Resolve through the SAME helper the search client uses. These two answered
|
|
106
|
+
// "is the portal usable" differently before: entitlements ignored the legacy
|
|
107
|
+
// SYNALUX_BASE_URL alias, accepted an unexpanded ${...} template, and never
|
|
108
|
+
// checked the value was a URL, so a host whose alias arrived after load had
|
|
109
|
+
// working search and a free-tier plan. Measured 2026-09-16.
|
|
110
|
+
const baseUrl = resolvePortalBaseUrl();
|
|
111
|
+
const apiKey = usablePortalKey();
|
|
108
112
|
if ((!SYNALUX_CONFIGURED && !apiKey) || !baseUrl) {
|
|
109
113
|
debugLog("[entitlements] no Synalux auth configured — free tier");
|
|
110
114
|
return { ...FREE_ENTITLEMENTS, source: "unconfigured" };
|
|
@@ -139,6 +139,8 @@ export function recordInference(result) {
|
|
|
139
139
|
completion_tokens: result.completion_tokens,
|
|
140
140
|
latency_ms: result.latency_ms,
|
|
141
141
|
ram_free_mb: result.ram_free_mb,
|
|
142
|
+
history_turns: result.history_turns,
|
|
143
|
+
refusal_layer: result.refusal_layer,
|
|
142
144
|
});
|
|
143
145
|
// §5.2: refused results (escalation:"report") get a ledger row above but
|
|
144
146
|
// must NOT touch the session accumulators — no model ran and nothing was
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "never send this over plaintext".
|
|
3
|
+
*
|
|
4
|
+
* This lived inside storage/index.ts, so only the storage path applied it. When
|
|
5
|
+
* startup hydration began publishing PRISM_SYNALUX_BASE_URL from the settings
|
|
6
|
+
* store, it bypassed the control entirely: a self-hosted portal stored as
|
|
7
|
+
* `http://portal.internal.example` was published unchanged, and the search
|
|
8
|
+
* client then POSTed the query plus `Authorization: Bearer <JWT>` to it in the
|
|
9
|
+
* clear. Measured 2026-09-16.
|
|
10
|
+
*
|
|
11
|
+
* Loopback is exempt because there is no network to intercept.
|
|
12
|
+
*/
|
|
13
|
+
import { debugLog } from "./logger.js";
|
|
14
|
+
export function upgradeInsecureCloudUrl(raw) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(raw);
|
|
17
|
+
if (parsed.protocol !== "http:")
|
|
18
|
+
return raw;
|
|
19
|
+
const host = parsed.hostname;
|
|
20
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]")
|
|
21
|
+
return raw;
|
|
22
|
+
parsed.protocol = "https:";
|
|
23
|
+
const upgraded = parsed.toString().replace(/\/+$/, "");
|
|
24
|
+
debugLog(`[Prism] Upgraded ${raw} to ${upgraded}: content is never sent over plaintext to a remote host.`);
|
|
25
|
+
return upgraded;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return raw;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -14,22 +14,113 @@
|
|
|
14
14
|
* Auth uses the shared JWT exchange from synaluxJwt.ts (same
|
|
15
15
|
* refresh-token dance as SynaluxStorage, but without requiring
|
|
16
16
|
* the full storage class). Falls back gracefully: callers check
|
|
17
|
-
*
|
|
17
|
+
* synaluxSearchAvailable() before calling.
|
|
18
18
|
*/
|
|
19
19
|
import { debugLog } from "./logger.js";
|
|
20
|
+
import { upgradeInsecureCloudUrl } from "./secureUrl.js";
|
|
20
21
|
import { PortalHttpError } from "./portalError.js";
|
|
21
22
|
import { getSynaluxJwt, invalidateSynaluxJwt } from "./synaluxJwt.js";
|
|
22
|
-
import {
|
|
23
|
+
import { PRISM_SYNALUX_API_KEY, PRISM_SYNALUX_BASE_URL, } from "../config.js";
|
|
23
24
|
// ─── Public availability flag ────────────────────────────────
|
|
24
|
-
/**
|
|
25
|
-
|
|
25
|
+
/** A `${...}` template the shell never expanded is not a credential. config.ts
|
|
26
|
+
* rejects those at load; the live read rejects them too, or a half-written host
|
|
27
|
+
* config sends every search to a portal that can only answer 401. */
|
|
28
|
+
function usableEnvValue(raw) {
|
|
29
|
+
const trimmed = raw?.trim();
|
|
30
|
+
if (!trimmed || trimmed.includes("${"))
|
|
31
|
+
return undefined;
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
function isHttpUrl(value) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(value);
|
|
37
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Whether the portal can serve a search, resolved at CALL time.
|
|
44
|
+
*
|
|
45
|
+
* This used to be a module-load constant taken from SYNALUX_CONFIGURED, and
|
|
46
|
+
* that was wrong for the most common installation. `prism connect` copies the
|
|
47
|
+
* subscription key into the host's MCP env block only when the key already
|
|
48
|
+
* happened to be in the environment; otherwise it lives in Prism's settings
|
|
49
|
+
* store and reaches process.env during startup. A constant read before that
|
|
50
|
+
* froze "unconfigured" for the life of the process, so a paying subscriber's
|
|
51
|
+
* every search skipped the portal and demanded a Brave key they had no reason
|
|
52
|
+
* to own — while entitlements, resolved later from the same hydrated key,
|
|
53
|
+
* correctly reported their paid plan. Measured 2026-09-16.
|
|
54
|
+
*
|
|
55
|
+
* The resolution order below is deliberately the same one fetchEntitlements()
|
|
56
|
+
* uses (live env first, module-load constant as the fallback). Search and
|
|
57
|
+
* entitlements answering "is the portal usable" differently IS the defect. */
|
|
58
|
+
export function resolvePortalBaseUrl() {
|
|
59
|
+
// Each candidate is validated in turn: a typo in one host config must fall
|
|
60
|
+
// through to the settings store rather than disabling portal search outright.
|
|
61
|
+
for (const candidate of [
|
|
62
|
+
usableEnvValue(process.env.PRISM_SYNALUX_BASE_URL),
|
|
63
|
+
usableEnvValue(process.env.SYNALUX_BASE_URL),
|
|
64
|
+
PRISM_SYNALUX_BASE_URL,
|
|
65
|
+
]) {
|
|
66
|
+
if (!candidate || !isHttpUrl(candidate))
|
|
67
|
+
continue;
|
|
68
|
+
// Never hand back a cleartext transport URL for a remote host: this client
|
|
69
|
+
// sends the query and a bearer JWT over it.
|
|
70
|
+
return upgradeInsecureCloudUrl(candidate).replace(/\/+$/, "");
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/** The subscription key, resolved the one way every portal client resolves it. */
|
|
75
|
+
export function usablePortalKey() {
|
|
76
|
+
return usableEnvValue(process.env.PRISM_SYNALUX_API_KEY) ?? PRISM_SYNALUX_API_KEY;
|
|
77
|
+
}
|
|
78
|
+
export function synaluxSearchAvailable() {
|
|
79
|
+
return !!resolvePortalBaseUrl() && !!usablePortalKey();
|
|
80
|
+
}
|
|
81
|
+
/** Put the subscription key where every consumer reads it — process.env —
|
|
82
|
+
* taking the settings store as the fallback source. Idempotent, and it never
|
|
83
|
+
* overwrites a value the environment already supplied. */
|
|
84
|
+
export async function hydrateSynaluxCredentials(getSetting) {
|
|
85
|
+
try {
|
|
86
|
+
const baseUrl = usableEnvValue(process.env.PRISM_SYNALUX_BASE_URL)
|
|
87
|
+
?? usableEnvValue(process.env.SYNALUX_BASE_URL)
|
|
88
|
+
?? usableEnvValue(await getSetting("PRISM_SYNALUX_BASE_URL", ""))
|
|
89
|
+
?? usableEnvValue(await getSetting("SYNALUX_BASE_URL", ""))
|
|
90
|
+
?? PRISM_SYNALUX_BASE_URL;
|
|
91
|
+
// Validate AND secure before publishing. process.env is shared with storage
|
|
92
|
+
// and entitlements, so a settings row holding a non-URL would poison every
|
|
93
|
+
// portal client in the process — and a cleartext http:// row would put the
|
|
94
|
+
// bearer JWT of every one of them on the wire in the clear.
|
|
95
|
+
if (baseUrl && isHttpUrl(baseUrl)) {
|
|
96
|
+
process.env.PRISM_SYNALUX_BASE_URL =
|
|
97
|
+
upgradeInsecureCloudUrl(baseUrl).replace(/\/+$/, "");
|
|
98
|
+
}
|
|
99
|
+
const apiKey = usableEnvValue(process.env.PRISM_SYNALUX_API_KEY)
|
|
100
|
+
?? usableEnvValue(await getSetting("PRISM_SYNALUX_API_KEY", ""))
|
|
101
|
+
?? PRISM_SYNALUX_API_KEY;
|
|
102
|
+
if (apiKey)
|
|
103
|
+
process.env.PRISM_SYNALUX_API_KEY = apiKey;
|
|
104
|
+
return synaluxSearchAvailable();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return synaluxSearchAvailable(); // a settings read must never break startup
|
|
108
|
+
}
|
|
109
|
+
}
|
|
26
110
|
// ─── Internal helpers ────────────────────────────────────────
|
|
27
111
|
/**
|
|
28
112
|
* POST to a portal endpoint with JWT auth. Retries once on 401
|
|
29
113
|
* (JWT may have just expired). Throws on network or HTTP errors.
|
|
30
114
|
*/
|
|
31
115
|
async function portalPost(path, body, timeoutMs = 15_000) {
|
|
32
|
-
|
|
116
|
+
// Resolved, never the module-load constant: availability is now decided from
|
|
117
|
+
// the live environment, so a portal that is reachable must also be addressable.
|
|
118
|
+
// Reading a frozen constant here would throw on the very installation this
|
|
119
|
+
// routing exists to serve.
|
|
120
|
+
const baseUrl = resolvePortalBaseUrl();
|
|
121
|
+
if (!baseUrl) {
|
|
122
|
+
throw new Error("[synaluxSearch] no Synalux portal base URL is configured");
|
|
123
|
+
}
|
|
33
124
|
const url = `${baseUrl}${path}`;
|
|
34
125
|
const send = async (jwt) => {
|
|
35
126
|
return fetch(url, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.21.2",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
|
|
6
6
|
"module": "index.ts",
|