prism-mcp-server 20.21.0 → 20.21.3
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/dist/cli.js +11 -0
- package/dist/connect.js +3 -0
- package/dist/scholar/webScholar.js +2 -2
- package/dist/server.js +15 -2
- package/dist/storage/index.js +2 -17
- package/dist/tools/prismInferHandler.js +70 -25
- package/dist/utils/braveApi.js +28 -19
- package/dist/utils/clinicalQualityPolicy.js +152 -0
- package/dist/utils/entitlements.js +9 -5
- package/dist/utils/secureUrl.js +30 -0
- package/dist/utils/synaluxSearch.js +96 -5
- package/package.json +1 -1
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
|
}
|
|
@@ -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.
|
|
@@ -30,6 +30,7 @@ import { getEntitlements, clampCeiling, multiTurnPolicy, ABSOLUTE_MULTI_TURN } f
|
|
|
30
30
|
import { ddLog } from "../utils/ddLogger.js";
|
|
31
31
|
import { stripThink } from "../utils/thinkStrip.js";
|
|
32
32
|
import { passesQualityGate } from "../utils/qualityGate.js";
|
|
33
|
+
import { passesClinicalQualityGate, formatClinicalSections, } from "../utils/clinicalQualityPolicy.js";
|
|
33
34
|
import { applyDeterministicCodingRepairs, buildCodingRepairPrompt, passesCodingQualityGate, } from "../utils/codingQualityPolicy.js";
|
|
34
35
|
import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
|
|
35
36
|
import { callLayer1 as defaultCallLayer1, classifyDeterministicLayer1, keywordBackstop, reservedCategory, MAX_CLASSIFIER_PROMPT_LENGTH } from "../utils/layer1.js";
|
|
@@ -402,7 +403,9 @@ export const PRISM_INFER_TOOL = {
|
|
|
402
403
|
"For a FOLLOW-UP to an earlier prism_infer answer, pass the accepted prior turns as `messages` " +
|
|
403
404
|
"(paid plans): without them the worker answers the follow-up from nothing and fabricates. " +
|
|
404
405
|
"Every entitlement-resolved result reports `multi_turn` (your plan's caps) and `history_turns` (what was sent); " +
|
|
405
|
-
"the crisis intercept reports only `history_turns`. "
|
|
406
|
+
"the crisis intercept reports only `history_turns`. "
|
|
407
|
+
+
|
|
408
|
+
"A behaviour-plan request also reports `clinical_sections` — how many required sections were found and which were not. That is a structural census, never a clinical endorsement: a section can be present and still be wrong, and a credentialed BCBA decides whether a plan is adequate. " +
|
|
406
409
|
"History over the plan's caps is refused (history_over_plan_cap), never trimmed; a free plan " +
|
|
407
410
|
"or a host with no portal is refused (multi_turn_not_in_plan). Hosts that compact large " +
|
|
408
411
|
"schemas may drop parameter text, so the contract lives here.",
|
|
@@ -2239,6 +2242,21 @@ export async function runInfer(args, deps) {
|
|
|
2239
2242
|
if (gate.pass && mode === "code") {
|
|
2240
2243
|
gate = passesCodingQualityGate(args.prompt, output);
|
|
2241
2244
|
}
|
|
2245
|
+
// Clinical structural check runs in EVERY mode. A behaviour plan
|
|
2246
|
+
// arrives as chat as readily as code, and the mode a caller picked
|
|
2247
|
+
// must not decide whether clinical output is inspected. The gate
|
|
2248
|
+
// self-gates on the prompt, so it is a no-op for everything else.
|
|
2249
|
+
// A clinical reason does not match the repair loop's code_/python_
|
|
2250
|
+
// prefixes, so it escalates instead of being locally patched —
|
|
2251
|
+
// deliberate: a local model inventing a missing decision-rules
|
|
2252
|
+
// section produces plausible unratified clinical text.
|
|
2253
|
+
let clinicalSections;
|
|
2254
|
+
if (gate.pass) {
|
|
2255
|
+
const clinical = passesClinicalQualityGate(args.prompt, output);
|
|
2256
|
+
clinicalSections = clinical.sections;
|
|
2257
|
+
if (!clinical.pass)
|
|
2258
|
+
gate = { pass: false, reason: clinical.reason };
|
|
2259
|
+
}
|
|
2242
2260
|
// Hard-truncation retry: the budget went on <think> and the answer
|
|
2243
2261
|
// was cut mid-emission. Previously this only escalated to cloud, or
|
|
2244
2262
|
// served the truncated text when no cloud was available — neither
|
|
@@ -2377,6 +2395,7 @@ export async function runInfer(args, deps) {
|
|
|
2377
2395
|
prompt_tokens: result.promptTokens,
|
|
2378
2396
|
completion_tokens: result.completionTokens,
|
|
2379
2397
|
quality_gate_failed: gate.pass ? undefined : true,
|
|
2398
|
+
clinical_sections: clinicalSections,
|
|
2380
2399
|
gate_outcome: gate.pass
|
|
2381
2400
|
? { status: "success", served_anyway: false }
|
|
2382
2401
|
: { status: "degraded", reason: gate.reason, served_anyway: true },
|
|
@@ -2609,6 +2628,55 @@ export async function inferText(prompt, opts = {}) {
|
|
|
2609
2628
|
return null;
|
|
2610
2629
|
}
|
|
2611
2630
|
}
|
|
2631
|
+
/** The one-line header the host sees above the model output.
|
|
2632
|
+
*
|
|
2633
|
+
* Pure and exported so the reporting contract in PRISM_INFER_TOOL.description
|
|
2634
|
+
* ("every entitlement-resolved result reports multi_turn and history_turns")
|
|
2635
|
+
* is assertable without standing up Ollama.
|
|
2636
|
+
*
|
|
2637
|
+
* Both fields were set on the result and written to the ledger for a release
|
|
2638
|
+
* before anything rendered them here, so the only way to learn what a call
|
|
2639
|
+
* carried was to open the SQLite ledger. An agent benchmarking multi-turn
|
|
2640
|
+
* sent no `messages` across three turns, saw nothing in the response saying
|
|
2641
|
+
* so, and published the resulting degradation as a model defect. */
|
|
2642
|
+
export function inferResponseHeader(result, memory) {
|
|
2643
|
+
const tokenStr = result.prompt_tokens != null || result.completion_tokens != null
|
|
2644
|
+
? ` tokens=${result.prompt_tokens ?? "?"}in/${result.completion_tokens ?? "?"}out`
|
|
2645
|
+
: "";
|
|
2646
|
+
return (`[prism_infer] backend=${result.backend}` +
|
|
2647
|
+
` model=${result.model_picked ?? "n/a"}` +
|
|
2648
|
+
` plan=${result.plan ?? "unknown"}` +
|
|
2649
|
+
` free_ram=${result.ram_free_mb}MB` +
|
|
2650
|
+
` latency=${result.latency_ms}ms` +
|
|
2651
|
+
` used_cloud=${result.used_cloud}` +
|
|
2652
|
+
tokenStr +
|
|
2653
|
+
// What this call actually carried, on every response including zero.
|
|
2654
|
+
// A caller that meant to send history and did not must be able to see
|
|
2655
|
+
// that here; omitting the zero is what made the failure silent.
|
|
2656
|
+
(result.history_turns != null ? ` history_turns=${result.history_turns}` : "") +
|
|
2657
|
+
(result.multi_turn
|
|
2658
|
+
? ` multi_turn=${result.multi_turn.enabled
|
|
2659
|
+
? `${result.multi_turn.max_turns}/${result.multi_turn.max_chars}`
|
|
2660
|
+
: "off"}`
|
|
2661
|
+
: "") +
|
|
2662
|
+
// Raise-only: a count of the sections found, and the names of those that
|
|
2663
|
+
// were not. Never a pass/fail word — presence is not clinical soundness.
|
|
2664
|
+
(result.clinical_sections ? ` ${formatClinicalSections(result.clinical_sections)}` : "") +
|
|
2665
|
+
(result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
|
|
2666
|
+
(result.gate_outcome && result.gate_outcome.status !== "success"
|
|
2667
|
+
? ` gate=${result.gate_outcome.status}${result.gate_outcome.reason ? `:${result.gate_outcome.reason}` : ""}`
|
|
2668
|
+
: "") +
|
|
2669
|
+
(result.entitlements_source && result.entitlements_source !== "portal"
|
|
2670
|
+
? ` ent_source=${result.entitlements_source}`
|
|
2671
|
+
: "") +
|
|
2672
|
+
(result.verification ? ` verify=${result.verification.action}` : "") +
|
|
2673
|
+
(result.route_guard
|
|
2674
|
+
? ` route_guard=${result.route_guard.source}:${result.route_guard.action}` +
|
|
2675
|
+
(result.route_guard.reason ? `:${result.route_guard.reason}` : "")
|
|
2676
|
+
: "") +
|
|
2677
|
+
(memory ? ` memory=${memory.project}:${memory.depth}` : "") +
|
|
2678
|
+
(result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : ""));
|
|
2679
|
+
}
|
|
2612
2680
|
export async function prismInferHandler(args) {
|
|
2613
2681
|
if (!isPrismInferArgs(args)) {
|
|
2614
2682
|
const raw = typeof args === "object" && args !== null ? args : {};
|
|
@@ -2653,30 +2721,7 @@ export async function prismInferHandler(args) {
|
|
|
2653
2721
|
latency_ms: result.latency_ms,
|
|
2654
2722
|
});
|
|
2655
2723
|
}
|
|
2656
|
-
const
|
|
2657
|
-
? ` tokens=${result.prompt_tokens ?? "?"}in/${result.completion_tokens ?? "?"}out`
|
|
2658
|
-
: "";
|
|
2659
|
-
const headerBase = `[prism_infer] backend=${result.backend}` +
|
|
2660
|
-
` model=${result.model_picked ?? "n/a"}` +
|
|
2661
|
-
` plan=${result.plan ?? "unknown"}` +
|
|
2662
|
-
` free_ram=${result.ram_free_mb}MB` +
|
|
2663
|
-
` latency=${result.latency_ms}ms` +
|
|
2664
|
-
` used_cloud=${result.used_cloud}` +
|
|
2665
|
-
tokenStr +
|
|
2666
|
-
(result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
|
|
2667
|
-
(result.gate_outcome && result.gate_outcome.status !== "success"
|
|
2668
|
-
? ` gate=${result.gate_outcome.status}${result.gate_outcome.reason ? `:${result.gate_outcome.reason}` : ""}`
|
|
2669
|
-
: "") +
|
|
2670
|
-
(result.entitlements_source && result.entitlements_source !== "portal"
|
|
2671
|
-
? ` ent_source=${result.entitlements_source}`
|
|
2672
|
-
: "") +
|
|
2673
|
-
(result.verification ? ` verify=${result.verification.action}` : "") +
|
|
2674
|
-
(result.route_guard
|
|
2675
|
-
? ` route_guard=${result.route_guard.source}:${result.route_guard.action}` +
|
|
2676
|
-
(result.route_guard.reason ? `:${result.route_guard.reason}` : "")
|
|
2677
|
-
: "") +
|
|
2678
|
-
(prepared.memory ? ` memory=${prepared.memory.project}:${prepared.memory.depth}` : "") +
|
|
2679
|
-
(result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
|
|
2724
|
+
const headerBase = inferResponseHeader(result, prepared.memory);
|
|
2680
2725
|
// Append periodic session-level stats to the header line.
|
|
2681
2726
|
// compact=true is threshold-gated (PRISM_METRICS_EVERY, default every 5 calls)
|
|
2682
2727
|
// so it doesn't appear on every response — only as a rolling summary.
|
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
|
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural gate for clinical behaviour-analytic output.
|
|
3
|
+
*
|
|
4
|
+
* The coding gate already proves the shape of this idea: a deterministic check
|
|
5
|
+
* names a concrete defect, and the named reason drives what happens next.
|
|
6
|
+
* Python has three static passes behind it. Clinical output had none, so a
|
|
7
|
+
* behaviour plan missing its decision rules or its data-collection procedure
|
|
8
|
+
* was served exactly like a complete one.
|
|
9
|
+
*
|
|
10
|
+
* Two hard constraints, both deliberate:
|
|
11
|
+
*
|
|
12
|
+
* 1. RAISE ONLY — it never certifies. A full section count is a statement
|
|
13
|
+
* about presence, not about clinical soundness: a section can be present
|
|
14
|
+
* and wrong. Nothing here may be read as "this plan is safe to implement".
|
|
15
|
+
* The `bcba_ai_assistant` standard is that the checklist reports what is
|
|
16
|
+
* present; a credentialed BCBA decides whether the plan is adequate.
|
|
17
|
+
*
|
|
18
|
+
* 2. IT DOES NOT TOUCH THE RESERVED LIST — crisis de-escalation, restraint,
|
|
19
|
+
* SIB with injury history and risk assessment never reach a local model at
|
|
20
|
+
* all; that boundary is enforced upstream in the Layer 1 screen and is not
|
|
21
|
+
* relaxed, widened or re-implemented here. This gate governs the routine
|
|
22
|
+
* band that is already local-eligible: operational definitions,
|
|
23
|
+
* measurement, antecedent strategies, caregiver training.
|
|
24
|
+
*
|
|
25
|
+
* A failure is NOT auto-repaired. The coding repair loop re-prompts the same
|
|
26
|
+
* tier to fix a syntax defect, which is safe for code; asking a local model to
|
|
27
|
+
* invent a missing decision-rules section produces plausible unratified
|
|
28
|
+
* clinical text, which is worse than a visibly incomplete draft. A clinical
|
|
29
|
+
* reason therefore falls out of the repair loop and escalates instead.
|
|
30
|
+
*/
|
|
31
|
+
/** A full written plan was asked for — the whole section list applies. */
|
|
32
|
+
const CLINICAL_PLAN_REQUEST_RE = /\b(bip\b|behavi(?:o|ou)r(?:al)?[ -](?:intervention|support|management)[ -]plan|behavi(?:o|ou)r plan|treatment plan|intervention plan)\b/i;
|
|
33
|
+
/** An operational definition specifically was asked for. */
|
|
34
|
+
const OPERATIONAL_DEFINITION_REQUEST_RE = /\boperational(?:ly)?[ -]?(?:defin\w*)|\bdefine the (?:target )?behaviou?r\b/i;
|
|
35
|
+
/** Any behaviour-analytic context at all — gates the AAC safety check. */
|
|
36
|
+
const CLINICAL_CONTEXT_RE = /\b(aba\b|bcba\b|behaviou?r analyst|functional behaviou?r assessment|\bfba\b|\bbip\b|replacement behaviou?r|target behaviou?r|reinforcement schedule|\bfct\b|\bdro\b|\bdra\b|\bncr\b)/i;
|
|
37
|
+
/** Ordered so the report reads the way a plan is written. */
|
|
38
|
+
const PLAN_SECTIONS = [
|
|
39
|
+
{ name: "operational_definition", pattern: /operational(?:ly)?[ -]?defin|\bdefinition\b[\s\S]{0,80}\b(observable|measurable)\b/i },
|
|
40
|
+
{ name: "function_hypothesis", pattern: /\b(hypothesi[sz]ed function|function of the behaviou?r|maintained by|\ba-?b-?c\b|antecedent[\s\S]{0,40}consequence)\b/i },
|
|
41
|
+
{ name: "antecedent_strategies", pattern: /\b(antecedent (?:strateg|modificat|intervention)|prevention strateg|setting event|environmental modificat)/i },
|
|
42
|
+
{ name: "replacement_behaviour", pattern: /\b(replacement behaviou?r|functional communication training|\bfct\b|alternative behaviou?r|\bdra\b)/i },
|
|
43
|
+
{ name: "consequence_strategies", pattern: /\b(consequence (?:strateg|procedure)|reinforcement (?:schedule|procedure|strateg)|\bdro\b|\bncr\b|extinction)/i },
|
|
44
|
+
{ name: "data_collection", pattern: /\b(data collection|data sheet|measurement (?:system|procedure)|frequency count|partial interval|momentary time sampling|\bioa\b|interobserver)/i },
|
|
45
|
+
{ name: "decision_rules", pattern: /\b(decision rule|mastery criteri|criteri\w+ for (?:change|modificat|advancement)|review (?:schedule|trigger)|plan review)/i },
|
|
46
|
+
{ name: "generalisation_maintenance", pattern: /\b(generali[sz]|maintenance)\b/i },
|
|
47
|
+
{ name: "caregiver_training", pattern: /\b((?:caregiver|staff|parent|family)[ -]?training|train(?:ing)? (?:the )?(?:caregivers?|staff|parents?))/i },
|
|
48
|
+
{ name: "bcba_review_disclaimer", pattern: /\b(reviewed and individuali[sz]ed|credentialed bcba|licensed behaviou?r analyst|must be reviewed)\b/i },
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* AAC access may never be removed, withheld or delayed as a consequence.
|
|
52
|
+
*
|
|
53
|
+
* A correct plan states this rule explicitly ("AAC access is never restricted"),
|
|
54
|
+
* so a bare co-occurrence of an AAC term and a restriction verb fires on GOOD
|
|
55
|
+
* text. The lookback suppresses a match when the clause is negated. It is
|
|
56
|
+
* approximate by construction, which is acceptable only because this raises and
|
|
57
|
+
* never clears: an escalation costs one call, and no output is marked safe here.
|
|
58
|
+
*/
|
|
59
|
+
const AAC_TERM = /\b(aac\b|speech[- ]generating device|\bsgd\b|communication device|communication board|\bpecs\b|talker\b)/i;
|
|
60
|
+
const RESTRICT_VERB = /\b(remov\w+|withh\w+|restrict\w+|tak\w+ away|deni\w+|deny|block\w*|delay\w*|confiscat\w+|limit\w*)\b/i;
|
|
61
|
+
const NEGATOR = /\b(never|not|n't|no|avoid\w*|prohibit\w*|must not|cannot|can't|without)\b/i;
|
|
62
|
+
// Asymmetric on purpose. "remove the AAC device" puts the verb BEFORE the term,
|
|
63
|
+
// so a forward-only window misses the most direct phrasing of the thing this
|
|
64
|
+
// check exists to catch. The backward reach is kept short because a removal
|
|
65
|
+
// sentence about something else ("remove the token board") sitting a paragraph
|
|
66
|
+
// above an AAC mention is not a restriction of AAC.
|
|
67
|
+
const AAC_WINDOW_AFTER = 120;
|
|
68
|
+
const AAC_WINDOW_BEFORE = 40;
|
|
69
|
+
const NEGATION_LOOKBACK = 60;
|
|
70
|
+
/** Clause boundaries. The verb must act on the AAC term, not merely sit near it:
|
|
71
|
+
* "AAC remains available at all times; remove the token board" removes a token
|
|
72
|
+
* board, and scanning past the semicolon read it as removing AAC. */
|
|
73
|
+
const CLAUSE_BREAK = /[.;:\n]|\bhowever\b|\bwhereas\b/i;
|
|
74
|
+
function clauseAfter(text, from, limit) {
|
|
75
|
+
const slice = text.slice(from, from + limit);
|
|
76
|
+
const brk = CLAUSE_BREAK.exec(slice);
|
|
77
|
+
return brk ? slice.slice(0, brk.index) : slice;
|
|
78
|
+
}
|
|
79
|
+
function clauseBefore(text, end, limit) {
|
|
80
|
+
const slice = text.slice(Math.max(0, end - limit), end);
|
|
81
|
+
let last = -1;
|
|
82
|
+
for (const m of slice.matchAll(new RegExp(CLAUSE_BREAK.source, "gi"))) {
|
|
83
|
+
last = (m.index ?? 0) + m[0].length;
|
|
84
|
+
}
|
|
85
|
+
return last >= 0 ? slice.slice(last) : slice;
|
|
86
|
+
}
|
|
87
|
+
function aacRestrictedAsConsequence(output) {
|
|
88
|
+
for (const m of output.matchAll(new RegExp(AAC_TERM.source, "gi"))) {
|
|
89
|
+
const start = m.index ?? 0;
|
|
90
|
+
const before = clauseBefore(output, start, AAC_WINDOW_BEFORE);
|
|
91
|
+
const after = clauseAfter(output, start, AAC_WINDOW_AFTER);
|
|
92
|
+
const from = start - before.length;
|
|
93
|
+
const window = before + after;
|
|
94
|
+
const verb = RESTRICT_VERB.exec(window);
|
|
95
|
+
if (!verb)
|
|
96
|
+
continue;
|
|
97
|
+
const absolute = from + (verb.index ?? 0);
|
|
98
|
+
const lookback = output.slice(Math.max(0, absolute - NEGATION_LOOKBACK), absolute);
|
|
99
|
+
if (NEGATOR.test(lookback))
|
|
100
|
+
continue; // "AAC access is never removed"
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Raise-only structural check. `pass: true` means nothing was detected as
|
|
107
|
+
* missing — it is not a clinical endorsement.
|
|
108
|
+
*/
|
|
109
|
+
export function passesClinicalQualityGate(prompt, output) {
|
|
110
|
+
// An operational-definition request is clinical on its own: "write an
|
|
111
|
+
// operational definition of elopement" names no ABA vocabulary the broad
|
|
112
|
+
// pattern looks for, and was silently skipped before.
|
|
113
|
+
const clinicalContext = CLINICAL_CONTEXT_RE.test(prompt)
|
|
114
|
+
|| CLINICAL_PLAN_REQUEST_RE.test(prompt)
|
|
115
|
+
|| OPERATIONAL_DEFINITION_REQUEST_RE.test(prompt);
|
|
116
|
+
if (!clinicalContext)
|
|
117
|
+
return { pass: true };
|
|
118
|
+
if (aacRestrictedAsConsequence(output)) {
|
|
119
|
+
return { pass: false, reason: "clinical_aac_restricted_as_consequence" };
|
|
120
|
+
}
|
|
121
|
+
if (OPERATIONAL_DEFINITION_REQUEST_RE.test(prompt)) {
|
|
122
|
+
const hasExamples = /\bexamples?\b/i.test(output);
|
|
123
|
+
const hasNonExamples = /\bnon-?examples?\b/i.test(output);
|
|
124
|
+
if (!hasExamples || !hasNonExamples) {
|
|
125
|
+
return { pass: false, reason: "clinical_operational_definition_incomplete" };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!CLINICAL_PLAN_REQUEST_RE.test(prompt))
|
|
129
|
+
return { pass: true };
|
|
130
|
+
const missing = PLAN_SECTIONS.filter(s => !s.pattern.test(output)).map(s => s.name);
|
|
131
|
+
const sections = {
|
|
132
|
+
required: PLAN_SECTIONS.length,
|
|
133
|
+
present: PLAN_SECTIONS.length - missing.length,
|
|
134
|
+
missing,
|
|
135
|
+
};
|
|
136
|
+
// An incomplete plan REPORTS; it does not fail. Failing the gate rejects the
|
|
137
|
+
// output, and when escalation is unavailable the caller receives nothing at
|
|
138
|
+
// all — measured in review: a 3-of-10 plan with cloud_fallback:true and an
|
|
139
|
+
// unreachable portal returned "no backend produced output". A draft labelled
|
|
140
|
+
// `clinical_sections=3/10 missing:...` is strictly more useful to a clinician
|
|
141
|
+
// than silence, and suppressing it contradicts the raise-only rule above.
|
|
142
|
+
//
|
|
143
|
+
// The two findings ABOVE do fail, because they are defects rather than
|
|
144
|
+
// incompleteness: AAC restricted as a consequence is a safety violation, and
|
|
145
|
+
// an operational definition without non-examples is wrong, not unfinished.
|
|
146
|
+
return { pass: true, sections };
|
|
147
|
+
}
|
|
148
|
+
/** Compact, raise-only header fragment. Counts only — never a verdict word. */
|
|
149
|
+
export function formatClinicalSections(s) {
|
|
150
|
+
const base = `clinical_sections=${s.present}/${s.required}`;
|
|
151
|
+
return s.missing.length ? `${base} missing:${s.missing.join(",")}` : base;
|
|
152
|
+
}
|
|
@@ -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" };
|
|
@@ -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.21.
|
|
3
|
+
"version": "20.21.3",
|
|
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",
|