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 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"` keeps the
36
- prompt and draft entirely on-device.
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; use `route_guard: "local"` or the **local tier** for a full
1038
- air-gap. **Enterprise** includes a HIPAA Business Associate Agreement.
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. Set
1054
- `route_guard: "local"` for a fully on-device route path.
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: true`)
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, paid tiers can retry the
1159
- request through Gemini 3.6 Flash. Free-tier routing stays local and reports the
1160
- quality-gate outcome without making a cloud call.
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 when read alone is not served
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
  }
@@ -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, `cloud_fallback: false`, and the `project` and `conversation_id` from this session when known.",
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 { SYNALUX_SEARCH_AVAILABLE } from "../utils/synaluxSearch.js";
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 = SYNALUX_SEARCH_AVAILABLE || !!BRAVE_API_KEY;
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);
@@ -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 function upgradeInsecureCloudUrl(raw) {
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
- try {
92
- await client.execute(`ALTER TABLE infer_metrics ADD COLUMN source_event_id TEXT`);
93
- }
94
- catch (e) {
95
- if (!(e instanceof Error) || !e.message.includes("duplicate column name"))
96
- throw e;
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=true:
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 AND `cloud_fallback=true`. " +
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: "Fall through to the Synalux portal cascade on local failure. Default false: saving tokens is the point.",
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': prompt and draft stay on-device.",
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. Pass cloud_fallback: true "
1055
- + "to escalate to a stronger model, or answer it in the host thread instead.";
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 only for paid plans
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
- let allowCloud = args.cloud_fallback === true && ent.features.cloud_fallback;
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 = worseLayer1Verdict(l1, det);
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 = worseLayer1Verdict(l1, alone);
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 = worseLayer1Verdict(l1, promptDet);
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 = worseLayer1Verdict(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false }));
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 = worseLayer1Verdict(l1, await classifyHistoryWindow(l1fn, window, deps.ollamaUrl, l1Model, budget));
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 = worseLayer1Verdict(l1, "UNCERTAIN");
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: false,
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
  }
@@ -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 { SYNALUX_SEARCH_AVAILABLE, synaluxWebSearch, synaluxWebSearchRaw, synaluxLocalSearch, synaluxLocalSearchRaw, synaluxBraveAnswers, } from "./synaluxSearch.js";
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 (SYNALUX_SEARCH_AVAILABLE) {
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 (SYNALUX_SEARCH_AVAILABLE) {
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 (SYNALUX_SEARCH_AVAILABLE) {
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 (SYNALUX_SEARCH_AVAILABLE) {
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 (SYNALUX_SEARCH_AVAILABLE) {
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
- const address = [
282
- poi.address?.streetAddress ?? "",
283
- poi.address?.addressLocality ?? "",
284
- poi.address?.addressRegion ?? "",
285
- poi.address?.postalCode ?? "",
286
- ]
287
- .filter((part) => part !== "")
288
- .join(", ") || "N/A";
289
- return `Name: ${poi.name || poi.title || "N/A"}
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: ${(poi.openingHours || []).join(", ") || "N/A"}
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 { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED } from "../config.js";
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
- // Re-read process.env because dashboard/bootstrap configuration can inject
105
- // credentials after config.ts captured its module-load constants.
106
- const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || PRISM_SYNALUX_BASE_URL;
107
- const apiKey = process.env.PRISM_SYNALUX_API_KEY?.trim();
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
- * SYNALUX_SEARCH_AVAILABLE before calling.
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 { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED, } from "../config.js";
23
+ import { PRISM_SYNALUX_API_KEY, PRISM_SYNALUX_BASE_URL, } from "../config.js";
23
24
  // ─── Public availability flag ────────────────────────────────
24
- /** True when Synalux portal credentials are configured. */
25
- export const SYNALUX_SEARCH_AVAILABLE = SYNALUX_CONFIGURED;
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
- const baseUrl = PRISM_SYNALUX_BASE_URL.replace(/\/+$/, "");
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.20.0",
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",