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 +63 -0
- package/dist/config.js +4 -0
- package/dist/scholar/freeSearch.js +237 -34
- package/dist/scholar/webScholar.js +30 -4
- package/dist/storage/index.js +25 -4
- package/dist/tools/ledgerHandlers.js +6 -2
- package/dist/utils/skillBudget.js +45 -0
- package/package.json +1 -1
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
|
-
*
|
|
47
|
-
*
|
|
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
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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();
|
package/dist/storage/index.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
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
|
+
"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",
|