prism-mcp-server 20.3.1 → 20.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,69 @@ features.
61
61
  <details>
62
62
  <summary>Release history (optional)</summary>
63
63
 
64
+ ## What's New in v20.4.0
65
+
66
+ ### An Explicitly Named Cloud Backend Fails Loud
67
+
68
+ Setting `PRISM_STORAGE=synalux` or `=supabase` with incomplete credentials used
69
+ to downgrade silently to local SQLite. The switch was logged to stderr, which
70
+ MCP hosts discard, so nothing surfaced it: sessions kept serving stale local
71
+ context while the cloud held newer history, and `context_source` read `local`
72
+ rather than any kind of warning. A session could run that way for weeks.
73
+
74
+ Naming a backend outright is a strong statement of intent, so it now throws —
75
+ naming the missing variables and the `PRISM_STORAGE=local` opt-out — instead of
76
+ quietly splitting your session history. `auto` is unchanged: it keeps its
77
+ documented `synalux > supabase > local` degradation, pinned by a test.
78
+
79
+ **Upgrade note:** if you explicitly set `PRISM_STORAGE=synalux|supabase` and
80
+ your credentials are incomplete, startup now fails with a named error instead
81
+ of silently using local data. That error is the fix — set the missing variable,
82
+ or choose `PRISM_STORAGE=local` deliberately. Default (`auto`) configs are
83
+ unaffected.
84
+
85
+ The throw is deliberately not treated as a recoverable startup fault: that path
86
+ exists for transient errors (rate limits, 5xx, DNS), which may degrade behind a
87
+ visible notice. A missing credential is a configuration fault and must not be
88
+ papered over.
89
+
90
+ Also: the skill block is now budgeted by default rather than only on request,
91
+ so a large skill payload cannot crowd out briefing and history.
92
+
93
+ ## What's New in v20.3.2
94
+
95
+ ### Web Scholar: SSRF Hardening
96
+
97
+ Security release. Web Scholar scrapes article URLs that come from
98
+ search-engine output, so the target is attacker-influenceable through SEO
99
+ poisoning — and because what it scrapes is written into the memory corpus and
100
+ passed to the configured LLM, a redirection to a local address meant reading an
101
+ internal service *and* sending the result onward.
102
+
103
+ The host guard matched string prefixes instead of parsing the address, and six
104
+ spellings of a local address got through: `[::1]` (`URL.hostname` keeps the
105
+ brackets), `127.0.0.2` (only `.1` was enumerated, not all of `127.0.0.0/8`),
106
+ `0.0.0.0`, `[::ffff:127.0.0.1]`, `localhost.` (a trailing dot defeated every
107
+ suffix check at once), and `[64:ff9b::7f00:1]` (NAT64 embeds IPv4 in its low
108
+ bits). Host classification now parses addresses and also covers CGNAT,
109
+ benchmarking, multicast, reserved, and IPv6 unique-local and link-local ranges.
110
+
111
+ DNS rebinding is closed too. Every check read the URL string, so a hostname the
112
+ attacker controls passed all of them and could still resolve to `127.0.0.1`.
113
+ Targets are now resolved first, every returned address is validated, and the
114
+ connection is pinned to those addresses so the name is never resolved a second
115
+ time — which also shuts the window between the check and the connect.
116
+
117
+ Scrape failures no longer vanish into a bare `catch {}`, a run is bounded by
118
+ `PRISM_SCHOLAR_SCRAPE_BUDGET_MS` (default 60s) instead of stalling on a raised
119
+ article count, and responses are capped at 8 MiB.
120
+
121
+ This is reachable only when scholar actually runs — `scholar_research`, or the
122
+ background loop under `PRISM_SCHOLAR_ENABLED=true` — and when the attacker also
123
+ controls DNS or a search result. Upgrade if you use Web Scholar.
124
+
125
+ ---
126
+
64
127
  ## What's New in v20.3.1
65
128
 
66
129
  ### Prism Browser Reports Real Failures
package/dist/config.js CHANGED
@@ -202,6 +202,10 @@ if (PRISM_SCHOLAR_ENABLED && !FIRECRAWL_API_KEY) {
202
202
  export const PRISM_SCHOLAR_INTERVAL_MS = parseInt(process.env.PRISM_SCHOLAR_INTERVAL_MS || "0", 10 // Default manual-only
203
203
  );
204
204
  export const PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN = parseInt(process.env.PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN || "3", 10);
205
+ // Wall-clock ceiling for a run's scrape loop. Scrapes are sequential with a
206
+ // 15s per-fetch timeout, so without this a raised article count turns into a
207
+ // multi-minute stall.
208
+ export const PRISM_SCHOLAR_SCRAPE_BUDGET_MS = parseInt(process.env.PRISM_SCHOLAR_SCRAPE_BUDGET_MS || "60000", 10);
205
209
  export const PRISM_SCHOLAR_TOPICS = (process.env.PRISM_SCHOLAR_TOPICS || "ai,agents")
206
210
  .split(",")
207
211
  .map(t => t.trim());
@@ -2,6 +2,12 @@ import * as cheerio from 'cheerio';
2
2
  import { JSDOM } from 'jsdom';
3
3
  import { Readability } from '@mozilla/readability';
4
4
  import TurndownService from 'turndown';
5
+ import { lookup as dnsLookupCb } from 'node:dns';
6
+ import http from 'node:http';
7
+ import https from 'node:https';
8
+ import net from 'node:net';
9
+ import { promisify } from 'node:util';
10
+ const dnsLookup = promisify(dnsLookupCb);
5
11
  /**
6
12
  * Searches Yahoo Web Search and parses the HTML results using Cheerio.
7
13
  * Yahoo provides a reliable HTML fallback that does not block basic automated browser requests.
@@ -42,47 +48,244 @@ export async function searchYahooFree(query, limit = 5) {
42
48
  });
43
49
  return results;
44
50
  }
51
+ function classifyIPv4(octets) {
52
+ const [a, b] = octets;
53
+ if (a === 127)
54
+ return 'loopback'; // 127.0.0.0/8 — not just 127.0.0.1
55
+ if (a === 0)
56
+ return 'loopback'; // 0.0.0.0/8 routes to the local host
57
+ if (a === 10)
58
+ return 'private'; // RFC1918
59
+ if (a === 172 && b >= 16 && b <= 31)
60
+ return 'private';
61
+ if (a === 192 && b === 168)
62
+ return 'private';
63
+ if (a === 169 && b === 254)
64
+ return 'private'; // link-local + cloud metadata
65
+ if (a === 100 && b >= 64 && b <= 127)
66
+ return 'private'; // CGNAT
67
+ if (a === 192 && b === 0)
68
+ return 'private'; // IETF protocol assignments
69
+ if (a === 198 && (b === 18 || b === 19))
70
+ return 'private'; // benchmarking
71
+ if (a >= 224)
72
+ return 'private'; // multicast + reserved
73
+ return 'public';
74
+ }
75
+ function parseIPv4(host) {
76
+ const parts = host.split('.');
77
+ if (parts.length !== 4)
78
+ return null;
79
+ const octets = [];
80
+ for (const part of parts) {
81
+ if (!/^\d{1,3}$/.test(part))
82
+ return null;
83
+ const value = Number(part);
84
+ if (value > 255)
85
+ return null;
86
+ octets.push(value);
87
+ }
88
+ return octets;
89
+ }
45
90
  /**
46
- * Fetches an article's HTML, extracts clean content via Readability,
47
- * and converts it to Markdown using Turndown.
91
+ * Classify a URL's host.
92
+ *
93
+ * Exported so the bypass table can be asserted directly. String-prefix checks
94
+ * are not sufficient here: `URL.hostname` keeps the brackets on IPv6 literals,
95
+ * 127.0.0.0/8 is far wider than 127.0.0.1, and 0.0.0.0 and IPv4-mapped IPv6
96
+ * both reach the local host.
48
97
  */
49
- export async function scrapeArticleLocal(url) {
50
- // SSRF protection: reject private/internal URLs.
51
- // Set PRISM_DEV_MODE=1 to allow loopback/private hosts during local dev
52
- // (testing against a local docs server, internal wiki, etc.). The flag
53
- // is intentionally OFF in production deploys.
54
- const devMode = process.env.PRISM_DEV_MODE === '1' || process.env.NODE_ENV === 'development';
98
+ export function classifyScrapeHost(rawHost) {
99
+ // URL.hostname returns IPv6 literals bracketed — strip the brackets. Node
100
+ // keeps a trailing '.' on named hosts (localhost. still resolves to
101
+ // 127.0.0.1), and that dot otherwise defeats every suffix check below.
102
+ const host = rawHost.toLowerCase()
103
+ .replace(/^\[|\]$/g, '')
104
+ .replace(/\.+$/, '');
105
+ if (host === 'localhost' || host.endsWith('.localhost'))
106
+ return 'loopback';
107
+ if (host.endsWith('.internal') || host.endsWith('.local'))
108
+ return 'private';
109
+ const ipv4 = parseIPv4(host);
110
+ if (ipv4)
111
+ return classifyIPv4(ipv4);
112
+ if (host.includes(':')) {
113
+ if (host === '::1')
114
+ return 'loopback';
115
+ if (host === '::')
116
+ return 'loopback';
117
+ // Prefixes that carry an IPv4 address in their low 32 bits. Node
118
+ // rewrites ::ffff:127.0.0.1 to ::ffff:7f00:1, so both the dotted and
119
+ // hex-pair spellings have to decode.
120
+ const embedding = /^(?:::ffff|64:ff9b(?::1)?::?)[:]?(.+)$/.exec(host);
121
+ if (embedding) {
122
+ const suffix = embedding[1];
123
+ const dotted = parseIPv4(suffix);
124
+ if (dotted)
125
+ return classifyIPv4(dotted);
126
+ const hexPair = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(suffix);
127
+ if (hexPair) {
128
+ const high = parseInt(hexPair[1], 16);
129
+ const low = parseInt(hexPair[2], 16);
130
+ return classifyIPv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);
131
+ }
132
+ // Reserved translation prefix with an undecodable tail — not public.
133
+ return 'private';
134
+ }
135
+ if (/^64:ff9b:/.test(host))
136
+ return 'private'; // NAT64 (RFC 6052/8215)
137
+ if (/^f[cd][0-9a-f]{2}:/.test(host))
138
+ return 'private'; // fc00::/7 unique-local
139
+ if (/^fe[89ab][0-9a-f]:/.test(host))
140
+ return 'private'; // fe80::/10 link-local
141
+ return 'public';
142
+ }
143
+ return 'public';
144
+ }
145
+ /**
146
+ * Reject scrape targets that point at the local host or an internal network.
147
+ *
148
+ * The URL reaching here comes from search-engine output, so it is
149
+ * attacker-influenceable via SEO poisoning. A successful fetch would be
150
+ * persisted into the memory corpus, so this fails closed.
151
+ */
152
+ export function assertSafeScrapeTarget(url, devMode) {
153
+ let parsed;
154
+ try {
155
+ parsed = new URL(url);
156
+ }
157
+ catch {
158
+ throw new Error('Invalid URL: could not be parsed');
159
+ }
160
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
161
+ throw new Error(`Invalid URL: protocol ${parsed.protocol} is not allowed`);
162
+ }
163
+ const hostClass = classifyScrapeHost(parsed.hostname);
164
+ if (hostClass === 'private') {
165
+ // Never allowed, even in dev mode: on a shared LAN these can reach
166
+ // other machines, and 169.254.169.254 is cloud metadata.
167
+ throw new Error('Invalid URL: private network URLs not allowed');
168
+ }
169
+ if (hostClass === 'loopback' && !devMode) {
170
+ throw new Error('Invalid URL: loopback URLs not allowed in production (set PRISM_DEV_MODE=1 to allow)');
171
+ }
172
+ }
173
+ /** Max article HTML accepted, so a hostile endpoint cannot stream us to death. */
174
+ const MAX_ARTICLE_BYTES = 8 * 1024 * 1024;
175
+ /**
176
+ * Resolve a hostname and validate every address it answers with.
177
+ *
178
+ * The string checks above only see the hostname, so a name the attacker
179
+ * controls can pass them and still resolve to 127.0.0.1 (DNS rebinding).
180
+ * Returns the address to connect to, which the caller pins so the name is
181
+ * never resolved a second time.
182
+ */
183
+ export async function resolveAndValidateHost(hostname, devMode) {
184
+ const bracketless = hostname.replace(/^\[|\]$/g, '');
185
+ // A literal address needs no lookup; assertSafeScrapeTarget already ruled on it.
186
+ if (net.isIP(bracketless) !== 0) {
187
+ return [{ address: bracketless, family: net.isIP(bracketless) }];
188
+ }
189
+ let resolved;
55
190
  try {
56
- const parsed = new URL(url);
57
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
58
- throw new Error('Invalid protocol');
59
- const host = parsed.hostname.toLowerCase();
60
- const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
61
- const isPrivate = host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('169.254.') ||
62
- /^172\.(1[6-9]|2\d|3[01])\./.test(host) || host.endsWith('.internal') || host.endsWith('.local');
63
- if (isLoopback && !devMode) {
64
- throw new Error('Loopback URLs not allowed in production (set PRISM_DEV_MODE=1 to allow)');
191
+ resolved = await dnsLookup(hostname, { all: true, verbatim: true });
192
+ }
193
+ catch (err) {
194
+ throw new Error(`Invalid URL: could not resolve ${hostname}`);
195
+ }
196
+ if (resolved.length === 0) {
197
+ throw new Error(`Invalid URL: ${hostname} resolved to no addresses`);
198
+ }
199
+ // Reject if ANY answer is non-public. A name that resolves to both a
200
+ // public and a private address is the rebinding shape itself, so picking
201
+ // the "good" one would just make the attack racy instead of blocked.
202
+ for (const entry of resolved) {
203
+ const hostClass = classifyScrapeHost(entry.address);
204
+ if (hostClass === 'private') {
205
+ throw new Error(`Invalid URL: ${hostname} resolves to a private address (${entry.address})`);
65
206
  }
66
- if (isPrivate) {
67
- // Private RFC1918 ranges are never allowed even in dev mode they
68
- // can cross into other tenants' machines on a shared LAN.
69
- throw new Error('Private network URLs not allowed');
207
+ if (hostClass === 'loopback' && !devMode) {
208
+ throw new Error(`Invalid URL: ${hostname} resolves to a loopback address (${entry.address})`);
70
209
  }
71
210
  }
72
- catch (e) {
73
- throw new Error(`Invalid URL: ${e instanceof Error ? e.message : 'unknown'}`);
74
- }
75
- const response = await fetch(url, {
76
- headers: {
77
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
78
- },
79
- redirect: 'error',
80
- signal: AbortSignal.timeout(15_000),
211
+ // Every answer is validated, so returning all of them keeps Node's
212
+ // dual-stack fallback working. Pinning only the first would fail whenever
213
+ // a host's leading AAAA is unreachable, which plain fetch handles.
214
+ return resolved;
215
+ }
216
+ /**
217
+ * GET an article over a connection pinned to a pre-validated address.
218
+ *
219
+ * Node's fetch would resolve the name again inside the request, which
220
+ * reopens the rebinding window between our check and the connection
221
+ * (TOCTOU). Supplying `lookup` guarantees the socket goes to the address we
222
+ * validated, while the URL still supplies the Host header and TLS SNI.
223
+ */
224
+ function fetchPinned(url, addresses) {
225
+ return new Promise((resolve, reject) => {
226
+ const transport = url.protocol === 'https:' ? https : http;
227
+ const req = transport.request(url, {
228
+ headers: {
229
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
230
+ },
231
+ // Node calls this in two shapes: with { all: true } it wants an
232
+ // array of { address, family } (autoSelectFamily, the default
233
+ // since Node 20), otherwise the (err, address, family) form.
234
+ // Answering the wrong one yields "Invalid IP address: undefined".
235
+ lookup: (_hostname, options, callback) => {
236
+ if (options && options.all) {
237
+ callback(null, addresses);
238
+ }
239
+ else {
240
+ callback(null, addresses[0].address, addresses[0].family);
241
+ }
242
+ },
243
+ }, (res) => {
244
+ const status = res.statusCode ?? 0;
245
+ if (status >= 300 && status < 400) {
246
+ // Matches the previous redirect: 'error' behaviour — a
247
+ // redirect is a second target that was never validated.
248
+ res.destroy();
249
+ reject(new Error(`Failed to fetch article HTML: refusing redirect (${status})`));
250
+ return;
251
+ }
252
+ if (status < 200 || status >= 300) {
253
+ res.destroy();
254
+ reject(new Error(`Failed to fetch article HTML: ${status} ${res.statusMessage ?? ''}`.trim()));
255
+ return;
256
+ }
257
+ let size = 0;
258
+ const chunks = [];
259
+ res.on('data', (chunk) => {
260
+ size += chunk.length;
261
+ if (size > MAX_ARTICLE_BYTES) {
262
+ res.destroy();
263
+ reject(new Error(`Failed to fetch article HTML: exceeded ${MAX_ARTICLE_BYTES} bytes`));
264
+ return;
265
+ }
266
+ chunks.push(chunk);
267
+ });
268
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
269
+ res.on('error', reject);
270
+ });
271
+ req.setTimeout(15_000, () => {
272
+ req.destroy(new Error('Failed to fetch article HTML: timed out after 15000ms'));
273
+ });
274
+ req.on('error', reject);
275
+ req.end();
81
276
  });
82
- if (!response.ok) {
83
- throw new Error(`Failed to fetch article HTML: ${response.statusText}`);
84
- }
85
- const html = await response.text();
277
+ }
278
+ export async function scrapeArticleLocal(url) {
279
+ // SSRF protection: reject private/internal URLs.
280
+ // Set PRISM_DEV_MODE=1 to allow loopback hosts during local dev (testing
281
+ // against a local docs server, internal wiki, etc.). The flag is
282
+ // intentionally OFF in production deploys.
283
+ const devMode = process.env.PRISM_DEV_MODE === '1' || process.env.NODE_ENV === 'development';
284
+ assertSafeScrapeTarget(url, devMode);
285
+ // Then resolve, validate every answer, and pin the connection to it.
286
+ const parsed = new URL(url);
287
+ const addresses = await resolveAndValidateHost(parsed.hostname, devMode);
288
+ const html = await fetchPinned(parsed, addresses);
86
289
  // Create a virtual DOM for Readability to traverse
87
290
  const doc = new JSDOM(html, { url });
88
291
  // Extract the article content like Firefox Reader View
@@ -1,4 +1,4 @@
1
- import { BRAVE_API_KEY, FIRECRAWL_API_KEY, GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_CX, SEMANTIC_SCHOLAR_API_KEY, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN, PRISM_USER_ID, PRISM_SCHOLAR_TOPICS, PRISM_ENABLE_HIVEMIND, } from "../config.js";
1
+ import { BRAVE_API_KEY, FIRECRAWL_API_KEY, GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_CX, SEMANTIC_SCHOLAR_API_KEY, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN, PRISM_USER_ID, PRISM_SCHOLAR_TOPICS, PRISM_ENABLE_HIVEMIND, PRISM_SCHOLAR_SCRAPE_BUDGET_MS, } from "../config.js";
2
2
  import { getStorage } from "../storage/index.js";
3
3
  import { debugLog } from "../utils/logger.js";
4
4
  import { getLLMProvider } from "../utils/llm/factory.js";
@@ -238,15 +238,41 @@ export async function runWebScholar(overrideTopic, overrideProject) {
238
238
  return `No articles found for "${topic}"`;
239
239
  await hivemindHeartbeat(`Scraping ${urls.length} articles on: ${topic}`);
240
240
  const scrapedTexts = [];
241
+ const scrapeFailures = [];
242
+ // Scrapes run sequentially with a 15s per-fetch timeout, so N URLs cost
243
+ // up to N * 15s. PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN is env-overridable,
244
+ // so bound the whole loop rather than trusting the item count.
245
+ // A non-finite budget (bad env value, or a test that stubs the config
246
+ // module wholesale) must not silently disable scraping or the budget.
247
+ const budgetMs = Number.isFinite(PRISM_SCHOLAR_SCRAPE_BUDGET_MS) && PRISM_SCHOLAR_SCRAPE_BUDGET_MS > 0
248
+ ? PRISM_SCHOLAR_SCRAPE_BUDGET_MS
249
+ : 60_000;
250
+ const scrapeDeadline = Date.now() + budgetMs;
241
251
  for (const url of urls) {
252
+ if (Date.now() >= scrapeDeadline) {
253
+ scrapeFailures.push(`${url} — skipped: ${budgetMs}ms scrape budget exhausted`);
254
+ continue;
255
+ }
242
256
  try {
243
257
  const article = await scrapeArticleLocal(url);
244
258
  scrapedTexts.push(`Source: ${url}\nTitle: ${article.title}\n\n${article.content.slice(0, 15_000)}`);
245
259
  }
246
- catch { }
260
+ catch (err) {
261
+ // A swallowed failure makes an empty run look identical to a good one.
262
+ const reason = err instanceof Error ? err.message : String(err);
263
+ scrapeFailures.push(`${url} — ${reason}`);
264
+ console.error(`[Scholar] scrape failed: ${url} — ${reason}`);
265
+ }
266
+ }
267
+ if (scrapedTexts.length === 0) {
268
+ const detail = scrapeFailures.length
269
+ ? `\n${scrapeFailures.join('\n')}`
270
+ : ' (no URLs attempted)';
271
+ return `All ${urls.length} scrape(s) failed for "${topic}":${detail}`;
272
+ }
273
+ if (scrapeFailures.length > 0) {
274
+ console.error(`[Scholar] ${scrapeFailures.length}/${urls.length} scrapes failed for "${topic}"`);
247
275
  }
248
- if (scrapedTexts.length === 0)
249
- return "All scrapes failed";
250
276
  await hivemindHeartbeat(`Synthesizing ${scrapedTexts.length} articles on: ${topic}`);
251
277
  const prompt = `You are an AI research assistant. Topic: "${topic}". Read these articles and write a comprehensive report.\n\n${scrapedTexts.join("\n---\n")}`;
252
278
  const llm = getLLMProvider();
@@ -92,13 +92,34 @@ export async function getStorage() {
92
92
  debugLog(`[Prism Storage] Auto-resolved: ${requested}`);
93
93
  }
94
94
  // ─── Validate explicit backend has credentials ────────────────
95
+ // An explicitly requested cloud backend with missing credentials must fail
96
+ // loud. Silently serving local SQLite splits session history: the caller
97
+ // keeps working against a stale local copy while believing it is on the
98
+ // cloud, and console.error goes to stderr, which MCP hosts discard. "auto"
99
+ // already refuses to fall back for this exact reason (see above); naming a
100
+ // backend outright is a stronger statement of intent, so it must not be
101
+ // weaker about protecting history.
102
+ //
103
+ // Observed in the field: a base URL present without its API key (a
104
+ // `prism connect` run from a shell that never exported the key strips it)
105
+ // downgraded every subsequent session to local storage for weeks. The local
106
+ // copy kept serving months-old context while the cloud held current history,
107
+ // and nothing in-band surfaced the downgrade.
108
+ //
109
+ // This throw is deliberately NOT matched by isRecoverableStartupStorageError
110
+ // (startupRecovery.ts): a missing credential is a configuration fault, not a
111
+ // transient one, so startup must not paper over it with last-good context.
95
112
  if (requested === "synalux" && !(await ensureSynaluxCredentials())) {
96
- console.error("[Prism Storage] Synalux backend requested but PRISM_SYNALUX_BASE_URL/PRISM_SYNALUX_API_KEY are missing or invalid. Falling back to local storage.");
97
- requested = "local";
113
+ throw new Error("[Prism Storage] PRISM_STORAGE=synalux but Synalux credentials are missing or invalid " +
114
+ "(need PRISM_SYNALUX_BASE_URL and PRISM_SYNALUX_API_KEY). " +
115
+ "Refusing to fall back to local storage because that silently splits session history. " +
116
+ "Set PRISM_STORAGE=local explicitly if local-only storage is intended.");
98
117
  }
99
118
  if (requested === "supabase" && !(await ensureSupabaseCredentials())) {
100
- console.error("[Prism Storage] Supabase backend requested but SUPABASE_URL/SUPABASE_KEY are missing or invalid. Falling back to local storage.");
101
- requested = "local";
119
+ throw new Error("[Prism Storage] PRISM_STORAGE=supabase but Supabase credentials are missing or invalid " +
120
+ "(need SUPABASE_URL and SUPABASE_KEY). " +
121
+ "Refusing to fall back to local storage because that silently splits session history. " +
122
+ "Set PRISM_STORAGE=local explicitly if local-only storage is intended.");
102
123
  }
103
124
  // ─── Initialize ───────────────────────────────────────────────
104
125
  activeStorageBackend = requested;
@@ -1344,8 +1344,12 @@ export async function sessionLoadContextHandler(args, options = {}) {
1344
1344
  // exists to deliver. The protected floor may still exceed this tranche
1345
1345
  // (always inlined); the reserved 40% keeps history alive whenever the
1346
1346
  // caller's budget covers the floor at all.
1347
- const skillBudgetChars = maxTokens && maxTokens > 0 ? Math.floor(maxTokens * 3.5 * 0.6) : Number.POSITIVE_INFINITY;
1348
- const { assembleSkillBlock } = await import("../utils/skillBudget.js");
1347
+ // Armed by DEFAULT, not only when the caller passes max_tokens see
1348
+ // resolveSkillBudgetChars for why an unbudgeted default cost the agent its
1349
+ // entire response on 2026-08-01. `level` scales the tranche so `quick`
1350
+ // actually means quick.
1351
+ const { assembleSkillBlock, resolveSkillBudgetChars } = await import("../utils/skillBudget.js");
1352
+ const skillBudgetChars = resolveSkillBudgetChars(maxTokens, level);
1349
1353
  const budgeted = assembleSkillBlock(skillEntries, skillBudgetChars);
1350
1354
  skillBlock = budgeted.block;
1351
1355
  loadedSkills.push(...budgeted.inlined);
@@ -27,6 +27,51 @@ function render(e) {
27
27
  const label = e.category === "role" ? "ROLE SKILL" : "SKILL";
28
28
  return `\n\n[📜 ${label}: ${e.name}]\n${e.content.trim()}`;
29
29
  }
30
+ /**
31
+ * Skill tranche used when the caller sets no `max_tokens`.
32
+ *
33
+ * Sized against the ~25k-token host tool-result cap: at the 3.5 chars/token
34
+ * heuristic that is ~87k chars for the WHOLE response, so the skill block has
35
+ * to leave room for briefing, handoff, and history.
36
+ *
37
+ * These are ADDITIVE on top of the protected floor, not a total. Protected
38
+ * skills inline even when the budget is already blown (assembleSkillBlock), and
39
+ * the repo-measured v26 floor is ~39k chars on its own — so the ceiling here is
40
+ * roughly 87k - 39k - memory. `standard` matches the 8,400-char tranche the
41
+ * existing v26 shape test already treats as the standard budget (60% of 14k
42
+ * tokens); `deep` doubles it and still leaves headroom for deep history.
43
+ *
44
+ * `quick` is deliberately near-nothing — it is the setting a caller picks to
45
+ * minimize context, and before this it still inlined the full skill payload,
46
+ * because `level` gated only the memory portion, which is the small part.
47
+ *
48
+ * Every value is finite and > 0 on purpose: assembleSkillBlock treats ≤ 0 and
49
+ * non-finite as "unbudgeted", so a zero here would silently restore the very
50
+ * bug this table exists to fix.
51
+ */
52
+ export const DEFAULT_SKILL_BUDGET_CHARS = {
53
+ quick: 2_000,
54
+ standard: 8_400,
55
+ deep: 16_000,
56
+ };
57
+ /**
58
+ * Resolve the skill-block budget for one call.
59
+ *
60
+ * 2026-08-01: this previously evaluated to POSITIVE_INFINITY whenever
61
+ * `max_tokens` was absent — which is the documented default and therefore the
62
+ * common call shape. Routing v25 (76 -> 95 skills, 19 moved to auto-load) then
63
+ * pushed the unbudgeted block to 91,578 chars, past the host cap, and the host
64
+ * diverted the ENTIRE response to a file: the agent received no context at all.
65
+ * The budget must be armed by default, not only when a caller opts in.
66
+ */
67
+ export function resolveSkillBudgetChars(maxTokens, level) {
68
+ // 60% of the response allowance: skills must not saturate it, or the
69
+ // briefing and history this tool exists to deliver get truncated away.
70
+ if (typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0) {
71
+ return Math.max(1, Math.floor(maxTokens * 3.5 * 0.6));
72
+ }
73
+ return DEFAULT_SKILL_BUDGET_CHARS[level] ?? DEFAULT_SKILL_BUDGET_CHARS.standard;
74
+ }
30
75
  /**
31
76
  * Assemble the skill block within `budgetChars`. `budgetChars` ≤ 0 or
32
77
  * non-finite means unbudgeted (legacy behavior: inline everything).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.3.1",
3
+ "version": "20.4.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",