prism-mcp-server 20.18.1 → 20.19.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/dist/config.js +22 -13
- package/dist/dashboard/server.js +1 -1
- package/dist/scholar/webScholar.js +47 -14
- package/dist/skillManifestSync.js +113 -0
- package/dist/tools/definitions.js +1 -1
- package/dist/tools/ledgerHandlers.js +23 -5
- package/dist/tools/skillRouting.js +6 -6
- package/dist/utils/braveApi.js +53 -11
- package/dist/utils/portalError.js +41 -0
- package/dist/utils/synaluxSearch.js +4 -1
- package/package.json +1 -1
- package/dist/utils/googleSearchApi.js +0 -42
package/dist/config.js
CHANGED
|
@@ -8,7 +8,10 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
* validates required ones, and exports them for use throughout the server.
|
|
9
9
|
*
|
|
10
10
|
* Environment variable guide:
|
|
11
|
-
* BRAVE_API_KEY — (required) API key for Brave Search Pro
|
|
11
|
+
* BRAVE_API_KEY — (required for search) API key for Brave Search Pro, and the only
|
|
12
|
+
* thing search needs in fully-local operation. A configured Synalux
|
|
13
|
+
* account supplies it portal-side instead, so exactly one of the two
|
|
14
|
+
* is needed. Get one at https://brave.com/search/api/
|
|
12
15
|
* GOOGLE_API_KEY — (optional) API key for Google AI Studio / Gemini. Enables paper analysis.
|
|
13
16
|
* BRAVE_ANSWERS_API_KEY — (optional) API key for Brave Answers (AI grounding). Enables brave_answers tool.
|
|
14
17
|
* SUPABASE_URL — (optional) Your Supabase project URL. Enables session memory tools.
|
|
@@ -18,9 +21,15 @@ import { fileURLToPath } from "node:url";
|
|
|
18
21
|
* VOYAGE_API_KEY — (optional) Voyage AI API key for embeddings.
|
|
19
22
|
* Set embedding_provider=voyage to use. https://dash.voyageai.com
|
|
20
23
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* No key is enforced at startup. Nothing here calls process.exit, so a missing
|
|
25
|
+
* key never stops the server — it starts with reduced functionality and the
|
|
26
|
+
* affected tools error when they are actually called. Missing keys log a
|
|
27
|
+
* warning, except BRAVE_API_KEY, which warns only under PRISM_DEBUG_LOGGING.
|
|
28
|
+
*
|
|
29
|
+
* Said plainly because the old wording ("the process exits immediately") was
|
|
30
|
+
* describing behaviour no key has ever had, and read as a hard startup
|
|
31
|
+
* dependency on a Brave + Firecrawl pair — see issue #188, filed on exactly
|
|
32
|
+
* that premise.
|
|
24
33
|
*/
|
|
25
34
|
// ─── Server Identity ──────────────────────────────────────────
|
|
26
35
|
function resolveServerVersion() {
|
|
@@ -66,12 +75,6 @@ if (!BRAVE_ANSWERS_API_KEY && process.env.PRISM_DEBUG_LOGGING === "true") {
|
|
|
66
75
|
// ─── Optional: Voyage AI Embeddings ──────────────────────────
|
|
67
76
|
// Set embedding_provider=voyage to enable. Requires VOYAGE_API_KEY.
|
|
68
77
|
export const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY; // embedding_provider=voyage
|
|
69
|
-
// ─── Optional: Google Search (Scholar Pipeline Fallback) ──────
|
|
70
|
-
// Used when Brave or Tavily keys are missing.
|
|
71
|
-
// Requires: Google Custom Search API Key + Search Engine ID (CX).
|
|
72
|
-
// Get yours at: https://developers.google.com/custom-search/v1/overview
|
|
73
|
-
export const GOOGLE_SEARCH_API_KEY = process.env.GOOGLE_SEARCH_API_KEY;
|
|
74
|
-
export const GOOGLE_SEARCH_CX = process.env.GOOGLE_SEARCH_CX;
|
|
75
78
|
// ─── v2.0 / v12.1 / v13: Storage Backend Selection ──────────
|
|
76
79
|
// Three backends are implemented:
|
|
77
80
|
// "local" — SQLite, fully offline. Free-tier default.
|
|
@@ -193,11 +196,17 @@ export const PRISM_SCHEDULER_ENABLED = process.env.PRISM_SCHEDULER_ENABLED !== "
|
|
|
193
196
|
export const PRISM_SCHEDULER_INTERVAL_MS = parseInt(process.env.PRISM_SCHEDULER_INTERVAL_MS || "43200000", 10 // 12 hours
|
|
194
197
|
);
|
|
195
198
|
// ─── v5.4: Autonomous Web Scholar ─────────────────────────────
|
|
196
|
-
// Background LLM research pipeline
|
|
199
|
+
// Background LLM research pipeline: web search or free academic discovery,
|
|
200
|
+
// local scrape, LLM synthesis.
|
|
197
201
|
export const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY;
|
|
198
202
|
export const PRISM_SCHOLAR_ENABLED = process.env.PRISM_SCHOLAR_ENABLED === "true";
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
// FIRECRAWL_API_KEY is currently unspent: Web Scholar scrapes with its own
|
|
204
|
+
// local scraper, and discovery is now selected by whether a web
|
|
205
|
+
// search is possible at all (portal credentials or BRAVE_API_KEY), not by the
|
|
206
|
+
// presence of this key. Kept exported so an existing .env does not break.
|
|
207
|
+
// The warning below is about the key Scholar actually needs.
|
|
208
|
+
if (PRISM_SCHOLAR_ENABLED && !BRAVE_API_KEY && !SYNALUX_CONFIGURED) {
|
|
209
|
+
console.error("Warning: no web search configured (BRAVE_API_KEY or a Synalux portal login). Web Scholar will use the free academic sources.");
|
|
201
210
|
}
|
|
202
211
|
export const PRISM_SCHOLAR_INTERVAL_MS = parseInt(process.env.PRISM_SCHOLAR_INTERVAL_MS || "0", 10 // Default manual-only
|
|
203
212
|
);
|
package/dist/dashboard/server.js
CHANGED
|
@@ -350,7 +350,7 @@ return false;}
|
|
|
350
350
|
firecrawlApiKey: {
|
|
351
351
|
type: "string",
|
|
352
352
|
title: "Firecrawl API Key",
|
|
353
|
-
description: "Optional:
|
|
353
|
+
description: "Optional and currently unused: Web Scholar scrapes locally, and its discovery is selected by BRAVE_API_KEY or a Synalux portal login. Get one at https://www.firecrawl.dev/"
|
|
354
354
|
},
|
|
355
355
|
braveAnswersApiKey: {
|
|
356
356
|
type: "string",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BRAVE_API_KEY,
|
|
1
|
+
import { BRAVE_API_KEY, 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";
|
|
@@ -7,9 +7,12 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync,
|
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { performWebSearchRaw } from "../utils/braveApi.js";
|
|
10
|
-
import { performGoogleSearch } from "../utils/googleSearchApi.js";
|
|
11
10
|
import { getTracer } from "../utils/telemetry.js";
|
|
12
11
|
import { searchYahooFree, scrapeArticleLocal } from "./freeSearch.js";
|
|
12
|
+
// The same capability flag performWebSearchRaw itself branches on, imported
|
|
13
|
+
// from the same module so the gate and the transport cannot disagree about
|
|
14
|
+
// whether a web search is possible.
|
|
15
|
+
import { SYNALUX_SEARCH_AVAILABLE } from "../utils/synaluxSearch.js";
|
|
13
16
|
// ─── Hivemind Integration Helpers ────────────────────────────
|
|
14
17
|
const SCHOLAR_PROJECT = "prism-scholar";
|
|
15
18
|
const SCHOLAR_ROLE = "scholar";
|
|
@@ -192,9 +195,20 @@ export async function runWebScholar(overrideTopic, overrideProject) {
|
|
|
192
195
|
const tracer = getTracer();
|
|
193
196
|
const span = tracer.startSpan("background.web_scholar");
|
|
194
197
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
// Discovery provider: ask whether a web search is POSSIBLE, not whether
|
|
199
|
+
// this machine happens to hold a key. performWebSearchRaw serves portal
|
|
200
|
+
// users from Synalux-side credentials and everyone else from their own
|
|
201
|
+
// BRAVE_API_KEY, so either one makes web discovery available.
|
|
202
|
+
//
|
|
203
|
+
// This used to read `BRAVE_API_KEY && FIRECRAWL_API_KEY`, which was wrong
|
|
204
|
+
// twice over: a portal-configured user holding no local key was demoted to
|
|
205
|
+
// the free academic path even though the portal would have served them,
|
|
206
|
+
// and a user who set only BRAVE_API_KEY was demoted for want of a Firecrawl
|
|
207
|
+
// key that nothing spends (scraping is always scrapeArticleLocal).
|
|
208
|
+
//
|
|
209
|
+
// Google Custom Search used to take priority over both; now removed
|
|
210
|
+
// because Google discontinues that API on 2027-01-01.
|
|
211
|
+
const useWebSearch = SYNALUX_SEARCH_AVAILABLE || !!BRAVE_API_KEY;
|
|
198
212
|
const topic = overrideTopic || await selectTopic();
|
|
199
213
|
const project = overrideProject || SCHOLAR_PROJECT;
|
|
200
214
|
if (!topic) {
|
|
@@ -210,16 +224,29 @@ export async function runWebScholar(overrideTopic, overrideProject) {
|
|
|
210
224
|
await hivemindRegister(topic);
|
|
211
225
|
await hivemindHeartbeat(`Searching for: ${topic}`);
|
|
212
226
|
let urls = [];
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
227
|
+
// Set when web search was selected but the request itself failed. The run
|
|
228
|
+
// then continues on the free path and says so at the top of its report.
|
|
229
|
+
let webSearchFailure = null;
|
|
230
|
+
if (useWebSearch) {
|
|
231
|
+
try {
|
|
232
|
+
const braveResponse = await performWebSearchRaw(topic, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN);
|
|
233
|
+
const braveData = JSON.parse(braveResponse);
|
|
234
|
+
urls = (braveData.web?.results || []).map((r) => r.url).filter(Boolean);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// The portal refuses web search for free plans (403) and for stale
|
|
238
|
+
// credentials (401), and a direct Brave key can be bad or rate-limited.
|
|
239
|
+
// Before the capability gate above, a signed-in free account never
|
|
240
|
+
// reached the portal from here — the gate looked only for a local key —
|
|
241
|
+
// so it took the free academic path below. Fall back to exactly that
|
|
242
|
+
// path. The transport (braveApi.ts portalFirst) has already applied the
|
|
243
|
+
// one permitted escape — the user's own key on a plan refusal — so
|
|
244
|
+
// Scholar adds no second attempt of its own.
|
|
245
|
+
webSearchFailure = err instanceof Error ? err.message : String(err);
|
|
246
|
+
console.error(`[WebScholar] Web search unavailable, continuing on free sources: ${webSearchFailure}`);
|
|
247
|
+
}
|
|
221
248
|
}
|
|
222
|
-
|
|
249
|
+
if (!useWebSearch || webSearchFailure !== null) {
|
|
223
250
|
// Parallel Academic Discovery (PubMed + ERIC + Semantic Scholar)
|
|
224
251
|
const academicCount = Math.ceil(PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN / 2);
|
|
225
252
|
const academicResults = await Promise.all([
|
|
@@ -291,6 +318,12 @@ export async function runWebScholar(overrideTopic, overrideProject) {
|
|
|
291
318
|
created_at: new Date().toISOString()
|
|
292
319
|
});
|
|
293
320
|
await hivemindBroadcast(topic, scrapedTexts.length);
|
|
321
|
+
// The ledger keeps the clean report; the caller (tool result, dashboard)
|
|
322
|
+
// is told when the sources were not the ones its credentials implied, so
|
|
323
|
+
// a paid account's portal outage is never a silent downgrade.
|
|
324
|
+
if (webSearchFailure !== null) {
|
|
325
|
+
return `Note: web search was unavailable (${webSearchFailure}); this report used the free academic sources.\n\n${summary}`;
|
|
326
|
+
}
|
|
294
327
|
return summary;
|
|
295
328
|
}
|
|
296
329
|
catch (err) {
|
|
@@ -256,6 +256,109 @@ async function listFiles(root, current = root) {
|
|
|
256
256
|
}
|
|
257
257
|
return files.sort();
|
|
258
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Artifacts nobody authors. ADMISSION RULE for these lists — an entry must be
|
|
261
|
+
* (1) machine-generated as a side effect of USING or BROWSING the directory,
|
|
262
|
+
* (2) regenerated on demand at no cost, and (3) never shippable content. Any
|
|
263
|
+
* other untracked file is a local edit and must keep conflicting. Do not widen
|
|
264
|
+
* this into "ignore files we do not recognise".
|
|
265
|
+
*/
|
|
266
|
+
const DERIVED_ARTIFACT_DIRS = new Set(["__pycache__"]);
|
|
267
|
+
/** Compared lowercased: Explorer writes both `Thumbs.db` and `thumbs.db`. */
|
|
268
|
+
const DERIVED_ARTIFACT_FILES = new Set([".ds_store", "thumbs.db", "desktop.ini"]);
|
|
269
|
+
const DERIVED_ARTIFACT_EXT = /\.py[co]$/;
|
|
270
|
+
function isDerivedArtifactFile(name) {
|
|
271
|
+
const lower = name.toLocaleLowerCase("en-US");
|
|
272
|
+
return DERIVED_ARTIFACT_FILES.has(lower) || DERIVED_ARTIFACT_EXT.test(lower);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Remove regenerable junk from a marker-owned skill directory.
|
|
276
|
+
*
|
|
277
|
+
* `isPristineMarkedSkill` compares the FULL recursive file list against the
|
|
278
|
+
* marker's key list, so ONE untracked file classifies the skill as a conflict —
|
|
279
|
+
* frozen, silently skipped by every later sync. Two ways that happens without
|
|
280
|
+
* anyone editing anything:
|
|
281
|
+
*
|
|
282
|
+
* - CPython writes `__pycache__/<mod>.cpython-XX.pyc` beside any module it
|
|
283
|
+
* IMPORTS (exec'ing a script does not). Observed 2026-09-14:
|
|
284
|
+
* dead-link-prevention froze after something imported check_links.py.
|
|
285
|
+
* - Finder drops `.DS_Store` into any directory a user OPENS, and Explorer
|
|
286
|
+
* drops `Thumbs.db`/`desktop.ini`. Reproduced 2026-09-14: browsing a
|
|
287
|
+
* managed skill folder is enough to stop it updating. Windows is a
|
|
288
|
+
* supported host, so both families belong here.
|
|
289
|
+
*
|
|
290
|
+
* PURGE, never ignore. Exempting these from the integrity walk would turn a
|
|
291
|
+
* noisy false conflict into a code-execution hole: CPython loads a cached .pyc
|
|
292
|
+
* in preference to its source whenever the pyc header's recorded source mtime
|
|
293
|
+
* and size match the .py, and both of those fields live in the attacker-written
|
|
294
|
+
* pyc. An ignored .pyc is arbitrary bytecode that executes while sync still
|
|
295
|
+
* reports the skill pristine and keeps updating it. Deleting restores integrity
|
|
296
|
+
* instead — the next import regenerates the cache from the .py the marker
|
|
297
|
+
* actually verifies, and any OTHER untracked file still conflicts as before.
|
|
298
|
+
*
|
|
299
|
+
* Touches nothing Prism does not own, nothing the manifest shipped, and never
|
|
300
|
+
* follows a symlink out of the skill root.
|
|
301
|
+
*/
|
|
302
|
+
async function purgeDerivedArtifacts(path) {
|
|
303
|
+
// Self-guarding, not caller-guarded. Three sites call this now and the
|
|
304
|
+
// symlink check is the only thing standing between a delete primitive and an
|
|
305
|
+
// arbitrary target, so it lives HERE — a fourth caller that forgets to lstat
|
|
306
|
+
// must not be able to reintroduce the escape.
|
|
307
|
+
let rootStat;
|
|
308
|
+
try {
|
|
309
|
+
rootStat = await lstat(path);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
315
|
+
return;
|
|
316
|
+
const marker = await readJson(join(path, MARKER));
|
|
317
|
+
if (!marker || marker.owner !== OWNER || !marker.files || typeof marker.files !== "object")
|
|
318
|
+
return;
|
|
319
|
+
const shipped = new Set(Object.keys(marker.files));
|
|
320
|
+
const shippedPaths = [...shipped];
|
|
321
|
+
// BEST EFFORT, always. This runs inside materializeNative's try block before
|
|
322
|
+
// anything is staged, so letting an EACCES escape would convert one skill's
|
|
323
|
+
// junk file into a `partial` sync that freezes EVERY other skill — trading a
|
|
324
|
+
// one-skill problem for the whole-root outage shape this file's header
|
|
325
|
+
// describes. A delete we cannot do just means the pristine check conflicts
|
|
326
|
+
// that one skill, exactly as it did before this function existed.
|
|
327
|
+
const discard = async (child, recursive) => {
|
|
328
|
+
try {
|
|
329
|
+
await rm(child, { recursive, force: true });
|
|
330
|
+
}
|
|
331
|
+
catch { /* leave it; it conflicts */ }
|
|
332
|
+
};
|
|
333
|
+
const walk = async (current) => {
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
const child = join(current, entry.name);
|
|
343
|
+
const rel = relative(path, child).split(sep).join("/");
|
|
344
|
+
// Never follow a symlink out of the skill root; listFiles still rejects it.
|
|
345
|
+
if (entry.isSymbolicLink())
|
|
346
|
+
continue;
|
|
347
|
+
if (entry.isDirectory()) {
|
|
348
|
+
const shippedUnder = shippedPaths.some((file) => file === rel || file.startsWith(`${rel}/`));
|
|
349
|
+
if (DERIVED_ARTIFACT_DIRS.has(entry.name) && !shippedUnder) {
|
|
350
|
+
await discard(child, true);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
await walk(child);
|
|
354
|
+
}
|
|
355
|
+
else if (entry.isFile() && isDerivedArtifactFile(entry.name) && !shipped.has(rel)) {
|
|
356
|
+
await discard(child, false);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
await walk(path);
|
|
361
|
+
}
|
|
259
362
|
async function isPristineMarkedSkill(path, generation) {
|
|
260
363
|
const marker = await readJson(join(path, MARKER));
|
|
261
364
|
if (!marker || marker.owner !== OWNER || (generation && marker.generation !== generation) ||
|
|
@@ -404,6 +507,12 @@ async function enforceNativeEntitlements(incomingNames, agentsSkillsDir) {
|
|
|
404
507
|
const target = join(agentsSkillsDir, name);
|
|
405
508
|
if (!(await exists(target)))
|
|
406
509
|
continue;
|
|
510
|
+
// Same reason as the materialize paths: junk nobody wrote must not make a
|
|
511
|
+
// clean skill look hand-edited. Here the misread is quieter but permanent —
|
|
512
|
+
// the skill leaves discovery either way, but a false "locally modified"
|
|
513
|
+
// parks it in quarantine forever instead of deleting it, and nothing ever
|
|
514
|
+
// collects that directory.
|
|
515
|
+
await purgeDerivedArtifacts(target);
|
|
407
516
|
if (await isPristineMarkedSkill(target)) {
|
|
408
517
|
await rm(target, { recursive: true, force: true });
|
|
409
518
|
continue;
|
|
@@ -604,6 +713,8 @@ async function materializeNative(manifest, agentsSkillsDir, hooks) {
|
|
|
604
713
|
if (!(await exists(target)))
|
|
605
714
|
continue;
|
|
606
715
|
const stat = await lstat(target);
|
|
716
|
+
if (stat.isDirectory() && !stat.isSymbolicLink())
|
|
717
|
+
await purgeDerivedArtifacts(target);
|
|
607
718
|
const pristine = stat.isDirectory() && !stat.isSymbolicLink() && await isPristineManagedSkill(target, true);
|
|
608
719
|
if (!pristine) {
|
|
609
720
|
// Preserve locally modified managed content, but quarantine it outside
|
|
@@ -659,6 +770,8 @@ async function materializeNative(manifest, agentsSkillsDir, hooks) {
|
|
|
659
770
|
}
|
|
660
771
|
const stat = await lstat(target);
|
|
661
772
|
const managedSkill = managedCandidates.has(skill.name);
|
|
773
|
+
if (stat.isDirectory() && !stat.isSymbolicLink())
|
|
774
|
+
await purgeDerivedArtifacts(target);
|
|
662
775
|
if (!stat.isDirectory() || stat.isSymbolicLink() || !(await isPristineManagedSkill(target, managedSkill))) {
|
|
663
776
|
conflicts.push(skill.name);
|
|
664
777
|
if (managedSkill)
|
|
@@ -261,7 +261,7 @@ export const RESEARCH_PAPER_ANALYSIS_TOOL = {
|
|
|
261
261
|
export const SCHOLAR_RESEARCH_TOOL = {
|
|
262
262
|
name: "scholar_research",
|
|
263
263
|
description: "Triggers an autonomous research pipeline on a specific topic. " +
|
|
264
|
-
"
|
|
264
|
+
"Discovers scientific papers and journals with web search (Synalux portal or your BRAVE_API_KEY) or free academic sources, " +
|
|
265
265
|
"extracts their content, synthesizes a comprehensive markdown report, " +
|
|
266
266
|
"and saves the result to the Mind Palace ledger. " +
|
|
267
267
|
"Best for deep clinical research, literature reviews, and evidence-based practice updates.",
|
|
@@ -496,13 +496,31 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
496
496
|
const conflictSuffix = snapshot.conflicts.length > 0
|
|
497
497
|
? ` · ${snapshot.conflicts.length} conflict${snapshot.conflicts.length === 1 ? "" : "s"} — see warning`
|
|
498
498
|
: "";
|
|
499
|
+
// Name the CONDITION, then hand over the procedure that reveals the cause.
|
|
500
|
+
// This used to read "local copy has no Prism ownership marker", which is one
|
|
501
|
+
// of several causes and not the common one. On 2026-09-14 dead-link-prevention
|
|
502
|
+
// tripped it with a marker that was present and digest-exact; the real cause
|
|
503
|
+
// was one extra untracked file. The sentence sent the operator to quarantine
|
|
504
|
+
// the directory and rerun `prism connect` (which rewrites host MCP config and
|
|
505
|
+
// wants every host closed) when the fix was deleting a regenerable cache dir.
|
|
506
|
+
// A wrong cause is worse than no cause: it buys a confident wrong repair.
|
|
507
|
+
//
|
|
508
|
+
// Deliberately a PROCEDURE, not an enumeration of causes. Listing all of them
|
|
509
|
+
// is both longer and weaker — a list still leaves the operator guessing, while
|
|
510
|
+
// the file-list-vs-marker diff names the offender outright. Length is not
|
|
511
|
+
// cosmetic here: this block renders in the HEAD, which capNativeStartupText
|
|
512
|
+
// keeps while cutting the tail, so every extra character evicts session
|
|
513
|
+
// context. An enumerated draft measured 858 chars — 21% of the whole 4,000
|
|
514
|
+
// quick budget, against 9% for the wrong-but-short original.
|
|
499
515
|
const conflictWarning = snapshot.conflicts.length > 0
|
|
500
|
-
? `\n> - ⚠️ **SKILLS NOT UPDATING (
|
|
516
|
+
? `\n> - ⚠️ **SKILLS NOT UPDATING (on-disk copy differs from the managed one):** ` +
|
|
501
517
|
`${formatBoundedSkillNames([...snapshot.conflicts].sort(), "blocked")}. ` +
|
|
502
|
-
`Each
|
|
503
|
-
`
|
|
504
|
-
`
|
|
505
|
-
`
|
|
518
|
+
`Each is frozen at whatever version is on disk — updates are withheld to ` +
|
|
519
|
+
`protect local edits. To see why, compare the directory's file list with ` +
|
|
520
|
+
`the \`files\` keys in its \`.prism-managed.json\`. An extra file you do ` +
|
|
521
|
+
`not need? Delete it; the next sync adopts the skill again. A local edit ` +
|
|
522
|
+
`you want to discard? Move the directory out of the native skills folder ` +
|
|
523
|
+
`and rerun \`prism connect\` (or a session bootstrap).`
|
|
506
524
|
: "";
|
|
507
525
|
// Durable, not per-run: a later sync reports "unchanged" while the roots stay
|
|
508
526
|
// frozen from an earlier failed materialization. This line is the difference
|
|
@@ -313,8 +313,8 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
313
313
|
* string that happens to appear in material they pasted as evidence. Observed
|
|
314
314
|
* 2026-08-31: a user asked "what's going on with skill loading?" and pasted a
|
|
315
315
|
* startup log; the log listed installed skill names, and the literal token
|
|
316
|
-
* `
|
|
317
|
-
* `\
|
|
316
|
+
* `acme-xyz-billing` inside it satisfied that skill's own trigger
|
|
317
|
+
* `\bacme\b.{0,20}\b(billing|invoice)\b`. Two unrelated private skills loaded
|
|
318
318
|
* and were injected as binding rules for a debugging question — pasting a log
|
|
319
319
|
* that NAMES a skill should never activate it.
|
|
320
320
|
*
|
|
@@ -322,7 +322,7 @@ async function fetchKeywordTable(expectVersion) {
|
|
|
322
322
|
* 1. Fenced code blocks — pasted output, by convention.
|
|
323
323
|
* 2. Hyphenated skill-name tokens (`foo-bar-baz`). A bare skill name is
|
|
324
324
|
* metadata about the system, not a description of work. Removing only the
|
|
325
|
-
* NAME SPAN keeps its constituent words available: "
|
|
325
|
+
* NAME SPAN keeps its constituent words available: "acme billing invoice"
|
|
326
326
|
* typed by the user still matches, because that text is not a name token.
|
|
327
327
|
*
|
|
328
328
|
* Deliberately NOT length-capped: a long prompt is not evidence of pasting,
|
|
@@ -357,7 +357,7 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
|
|
|
357
357
|
// false positive being fixed. Using the actual routable names is exact.
|
|
358
358
|
//
|
|
359
359
|
// Deliberate consequence, not an oversight: a user who TYPES a skill's name
|
|
360
|
-
// ("update
|
|
360
|
+
// ("update acme-xyz-billing's invoice rate") no longer trigger-routes that
|
|
361
361
|
// skill. That is acceptable because the agent reads the raw prompt and can
|
|
362
362
|
// invoke a literally-named skill directly — trigger routing exists for
|
|
363
363
|
// SYMPTOM text, where the name is absent. A pasted log naming skills must
|
|
@@ -382,7 +382,7 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
|
|
|
382
382
|
// the whole prompt — the sibling _applyPromptRouting swallows bad
|
|
383
383
|
// patterns for the same reason (adversarial review, confirmed).
|
|
384
384
|
// Only IDENTIFIER-SHAPED names are stripped: at least two segments joined
|
|
385
|
-
// by - or _ (
|
|
385
|
+
// by - or _ (acme-xyz-billing, training-results-gate). Round-3 review
|
|
386
386
|
// proved the unconditional version was self-defeating for skills whose
|
|
387
387
|
// name is an ordinary word: stripping `sentry` from "check sentry for
|
|
388
388
|
// recent errors" killed that skill's OWN trigger (\bsentry\b) — 100% of
|
|
@@ -395,7 +395,7 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
|
|
|
395
395
|
// name must not butt directly against a letter/digit, but MAY butt
|
|
396
396
|
// against segment glue (-/_). Round 3's stricter (?<![\w-]) anchors
|
|
397
397
|
// refused to strip the name out of longer compounds — a pasted
|
|
398
|
-
// `
|
|
398
|
+
// `acme-xyz-billing-worker` container name survived intact, and because
|
|
399
399
|
// \b fires at every internal hyphen, the skill's own trigger still
|
|
400
400
|
// matched inside it: the exact incident class this function exists to
|
|
401
401
|
// kill. Alignment on segment edges strips those compounds while still
|
package/dist/utils/braveApi.js
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* The Brave Answers endpoint uses a separate BRAVE_ANSWERS_API_KEY via Bearer token.
|
|
30
30
|
*/
|
|
31
31
|
import { BRAVE_API_KEY, BRAVE_ANSWERS_API_KEY } from "../config.js";
|
|
32
|
+
import { debugLog } from "./logger.js";
|
|
33
|
+
import { isPortalPlanRefusal } from "./portalError.js";
|
|
32
34
|
import { SYNALUX_SEARCH_AVAILABLE, synaluxWebSearch, synaluxWebSearchRaw, synaluxLocalSearch, synaluxLocalSearchRaw, synaluxBraveAnswers, } from "./synaluxSearch.js";
|
|
33
35
|
const BRAVE_API_KEY_MISSING_ERROR = "BRAVE_API_KEY is not configured";
|
|
34
36
|
const BRAVE_ANSWERS_API_KEY_MISSING_ERROR = "BRAVE_ANSWERS_API_KEY is not configured";
|
|
@@ -38,14 +40,37 @@ function requireBraveApiKey() {
|
|
|
38
40
|
throw new Error(BRAVE_API_KEY_MISSING_ERROR);
|
|
39
41
|
return BRAVE_API_KEY;
|
|
40
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Portal first. A configured Synalux account is a privacy boundary:
|
|
45
|
+
* provider credentials and query redaction stay portal-side, so an outage,
|
|
46
|
+
* a quota, or an expired login never turns into a direct provider call
|
|
47
|
+
* with the original query.
|
|
48
|
+
*
|
|
49
|
+
* The one exception is the portal's PLAN refusal (403 on a free plan). That
|
|
50
|
+
* account is not entitled to portal search at all, so a user who configured
|
|
51
|
+
* their own key gets it used — the same footing as a user who never signed
|
|
52
|
+
* in. Without an own key the refusal propagates unchanged.
|
|
53
|
+
*/
|
|
54
|
+
async function portalFirst(viaPortal, ownKey, viaOwnKey) {
|
|
55
|
+
try {
|
|
56
|
+
return await viaPortal();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (ownKey && isPortalPlanRefusal(err)) {
|
|
60
|
+
debugLog("[braveApi] portal refused this plan; using the locally configured key instead");
|
|
61
|
+
return viaOwnKey();
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
41
66
|
// Brave Answers API call (AI Grounding/OpenAI-compatible)
|
|
42
67
|
export async function performBraveAnswers(query, model = "brave") {
|
|
43
|
-
// A configured Synalux account is a privacy boundary: provider
|
|
44
|
-
// credentials and redaction stay portal-side. Never escape to a direct
|
|
45
|
-
// provider with the original query when that portal request fails.
|
|
46
68
|
if (SYNALUX_SEARCH_AVAILABLE) {
|
|
47
|
-
return synaluxBraveAnswers(query, model);
|
|
69
|
+
return portalFirst(() => synaluxBraveAnswers(query, model), BRAVE_ANSWERS_API_KEY, () => braveAnswersDirect(query, model));
|
|
48
70
|
}
|
|
71
|
+
return braveAnswersDirect(query, model);
|
|
72
|
+
}
|
|
73
|
+
async function braveAnswersDirect(query, model) {
|
|
49
74
|
if (!BRAVE_ANSWERS_API_KEY) {
|
|
50
75
|
throw new Error(BRAVE_ANSWERS_API_KEY_MISSING_ERROR);
|
|
51
76
|
}
|
|
@@ -80,8 +105,12 @@ export async function performWebSearchRaw(query, count = 10, offset = 0) {
|
|
|
80
105
|
if (SYNALUX_SEARCH_AVAILABLE) {
|
|
81
106
|
if (offset !== 0)
|
|
82
107
|
throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
|
|
83
|
-
return synaluxWebSearchRaw(query, count);
|
|
108
|
+
return portalFirst(() => synaluxWebSearchRaw(query, count), BRAVE_API_KEY, () => braveWebSearchRaw(query, count, offset));
|
|
84
109
|
}
|
|
110
|
+
return braveWebSearchRaw(query, count, offset);
|
|
111
|
+
}
|
|
112
|
+
// Direct Brave web search on the locally configured key
|
|
113
|
+
async function braveWebSearchRaw(query, count, offset) {
|
|
85
114
|
const braveApiKey = requireBraveApiKey();
|
|
86
115
|
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
87
116
|
url.searchParams.set("q", query);
|
|
@@ -105,9 +134,13 @@ export async function performWebSearch(query, count = 10, offset = 0) {
|
|
|
105
134
|
if (SYNALUX_SEARCH_AVAILABLE) {
|
|
106
135
|
if (offset !== 0)
|
|
107
136
|
throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
|
|
108
|
-
return synaluxWebSearch(query, count);
|
|
137
|
+
return portalFirst(() => synaluxWebSearch(query, count), BRAVE_API_KEY, () => braveWebSearch(query, count, offset));
|
|
109
138
|
}
|
|
110
|
-
|
|
139
|
+
return braveWebSearch(query, count, offset);
|
|
140
|
+
}
|
|
141
|
+
// Direct Brave web search, formatted
|
|
142
|
+
async function braveWebSearch(query, count, offset) {
|
|
143
|
+
const textData = await braveWebSearchRaw(query, count, offset);
|
|
111
144
|
const data = JSON.parse(textData);
|
|
112
145
|
// Extract just web results
|
|
113
146
|
const results = (data.web?.results || []).map((result) => ({
|
|
@@ -165,8 +198,12 @@ function chunkArray(arr, size) {
|
|
|
165
198
|
// Raw local search API call with poi/details payload
|
|
166
199
|
export async function performLocalSearchRaw(query, count = 5) {
|
|
167
200
|
if (SYNALUX_SEARCH_AVAILABLE) {
|
|
168
|
-
return synaluxLocalSearchRaw(query, count);
|
|
201
|
+
return portalFirst(() => synaluxLocalSearchRaw(query, count), BRAVE_API_KEY, () => braveLocalSearchRaw(query, count));
|
|
169
202
|
}
|
|
203
|
+
return braveLocalSearchRaw(query, count);
|
|
204
|
+
}
|
|
205
|
+
// Direct Brave local search on the locally configured key
|
|
206
|
+
async function braveLocalSearchRaw(query, count) {
|
|
170
207
|
const braveApiKey = requireBraveApiKey();
|
|
171
208
|
// Initial search to get location IDs
|
|
172
209
|
const webUrl = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
@@ -190,7 +227,8 @@ export async function performLocalSearchRaw(query, count = 5) {
|
|
|
190
227
|
?.filter((r) => r.id != null)
|
|
191
228
|
.map((r) => r.id) || [];
|
|
192
229
|
if (locationIds.length === 0) {
|
|
193
|
-
|
|
230
|
+
// Already on the direct path: stay on it rather than re-asking the portal.
|
|
231
|
+
const fallback = await braveWebSearch(query, count, 0);
|
|
194
232
|
return JSON.stringify({
|
|
195
233
|
source: "web_fallback",
|
|
196
234
|
query,
|
|
@@ -223,9 +261,13 @@ export async function performLocalSearchRaw(query, count = 5) {
|
|
|
223
261
|
// Local search API call with poi details
|
|
224
262
|
export async function performLocalSearch(query, count = 5) {
|
|
225
263
|
if (SYNALUX_SEARCH_AVAILABLE) {
|
|
226
|
-
return synaluxLocalSearch(query, count);
|
|
264
|
+
return portalFirst(() => synaluxLocalSearch(query, count), BRAVE_API_KEY, () => braveLocalSearch(query, count));
|
|
227
265
|
}
|
|
228
|
-
|
|
266
|
+
return braveLocalSearch(query, count);
|
|
267
|
+
}
|
|
268
|
+
// Direct Brave local search, formatted
|
|
269
|
+
async function braveLocalSearch(query, count) {
|
|
270
|
+
const rawData = await braveLocalSearchRaw(query, count);
|
|
229
271
|
const parsed = JSON.parse(rawData);
|
|
230
272
|
if (parsed.source === "web_fallback") {
|
|
231
273
|
return parsed.formattedText || "No local results found";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal HTTP errors carry their status and body so a caller can tell a
|
|
3
|
+
* PLAN refusal — the account is signed in, but its plan does not include
|
|
4
|
+
* the feature — from an outage, a quota, or an expired login. Only the plan
|
|
5
|
+
* refusal may be answered with the user's own provider key (braveApi.ts);
|
|
6
|
+
* everything else stays inside the portal's privacy boundary.
|
|
7
|
+
*/
|
|
8
|
+
export class PortalHttpError extends Error {
|
|
9
|
+
portalPath;
|
|
10
|
+
portalStatus;
|
|
11
|
+
portalBody;
|
|
12
|
+
constructor(path, status, body) {
|
|
13
|
+
super(`[synaluxSearch] ${path} HTTP ${status}: ${body}`);
|
|
14
|
+
this.name = "PortalHttpError";
|
|
15
|
+
this.portalPath = path;
|
|
16
|
+
this.portalStatus = status;
|
|
17
|
+
this.portalBody = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True when the portal refused because of the account's plan: the
|
|
22
|
+
* `auth.plan === 'free'` gate on the prism search routes answers 403 with
|
|
23
|
+
* `{ error: "... requires Standard plan or higher.", upgrade_url: "/pricing" }`.
|
|
24
|
+
* Either signal — the word "plan" in the body, or a structured
|
|
25
|
+
* `upgrade_url` — is accepted, so a reworded message does not silently take
|
|
26
|
+
* a free user's own key away again. A 403 with neither is not a plan
|
|
27
|
+
* refusal, whatever else it may be.
|
|
28
|
+
*/
|
|
29
|
+
export function isPortalPlanRefusal(err) {
|
|
30
|
+
if (!(err instanceof PortalHttpError) || err.portalStatus !== 403)
|
|
31
|
+
return false;
|
|
32
|
+
if (/\bplan\b/i.test(err.portalBody))
|
|
33
|
+
return true;
|
|
34
|
+
try {
|
|
35
|
+
const body = JSON.parse(err.portalBody);
|
|
36
|
+
return typeof body?.upgrade_url === "string";
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* SYNALUX_SEARCH_AVAILABLE before calling.
|
|
18
18
|
*/
|
|
19
19
|
import { debugLog } from "./logger.js";
|
|
20
|
+
import { PortalHttpError } from "./portalError.js";
|
|
20
21
|
import { getSynaluxJwt, invalidateSynaluxJwt } from "./synaluxJwt.js";
|
|
21
22
|
import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED, } from "../config.js";
|
|
22
23
|
// ─── Public availability flag ────────────────────────────────
|
|
@@ -59,7 +60,9 @@ async function portalPost(path, body, timeoutMs = 15_000) {
|
|
|
59
60
|
}
|
|
60
61
|
if (!res.ok) {
|
|
61
62
|
const text = await res.text().catch(() => "(no body)");
|
|
62
|
-
|
|
63
|
+
// Same message as before; the typed error lets braveApi.ts recognise a
|
|
64
|
+
// plan refusal without parsing the message.
|
|
65
|
+
throw new PortalHttpError(path, res.status, text);
|
|
63
66
|
}
|
|
64
67
|
return (await res.json());
|
|
65
68
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.19.0",
|
|
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",
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { debugLog } from "./logger.js";
|
|
2
|
-
/**
|
|
3
|
-
* Google Custom Search API Utility
|
|
4
|
-
* ===============================
|
|
5
|
-
*
|
|
6
|
-
* Performs a web search using the Google Custom Search JSON API.
|
|
7
|
-
* Requires: API Key and Search Engine ID (CX).
|
|
8
|
-
*
|
|
9
|
-
* @param apiKey - Google Search API Key
|
|
10
|
-
* @param cx - Search Engine ID (CX)
|
|
11
|
-
* @param query - The search query
|
|
12
|
-
* @param count - Number of results to return (max 10)
|
|
13
|
-
* @returns Array of result objects { title, url, snippet }
|
|
14
|
-
*/
|
|
15
|
-
export async function performGoogleSearch(apiKey, cx, query, count = 5) {
|
|
16
|
-
try {
|
|
17
|
-
const safeCount = Math.min(Math.max(1, count), 10);
|
|
18
|
-
const url = new URL("https://customsearch.googleapis.com/customsearch/v1");
|
|
19
|
-
url.searchParams.append("key", apiKey);
|
|
20
|
-
url.searchParams.append("cx", cx);
|
|
21
|
-
url.searchParams.append("q", query);
|
|
22
|
-
url.searchParams.append("num", safeCount.toString());
|
|
23
|
-
const response = await fetch(url.toString(), {
|
|
24
|
-
signal: AbortSignal.timeout(15_000),
|
|
25
|
-
});
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
const error = await response.json();
|
|
28
|
-
throw new Error(`Google Search API failed: ${JSON.stringify(error)}`);
|
|
29
|
-
}
|
|
30
|
-
const data = await response.json();
|
|
31
|
-
const items = data.items || [];
|
|
32
|
-
return items.map((item) => ({
|
|
33
|
-
title: item.title,
|
|
34
|
-
url: item.link,
|
|
35
|
-
snippet: item.snippet,
|
|
36
|
-
}));
|
|
37
|
-
}
|
|
38
|
-
catch (err) {
|
|
39
|
-
debugLog(`[GoogleSearch] Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
40
|
-
return [];
|
|
41
|
-
}
|
|
42
|
-
}
|