prism-mcp-server 20.18.2 → 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 CHANGED
@@ -75,12 +75,6 @@ if (!BRAVE_ANSWERS_API_KEY && process.env.PRISM_DEBUG_LOGGING === "true") {
75
75
  // ─── Optional: Voyage AI Embeddings ──────────────────────────
76
76
  // Set embedding_provider=voyage to enable. Requires VOYAGE_API_KEY.
77
77
  export const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY; // embedding_provider=voyage
78
- // ─── Optional: Google Search (Scholar Pipeline Fallback) ──────
79
- // Used when Brave or Tavily keys are missing.
80
- // Requires: Google Custom Search API Key + Search Engine ID (CX).
81
- // Get yours at: https://developers.google.com/custom-search/v1/overview
82
- export const GOOGLE_SEARCH_API_KEY = process.env.GOOGLE_SEARCH_API_KEY;
83
- export const GOOGLE_SEARCH_CX = process.env.GOOGLE_SEARCH_CX;
84
78
  // ─── v2.0 / v12.1 / v13: Storage Backend Selection ──────────
85
79
  // Three backends are implemented:
86
80
  // "local" — SQLite, fully offline. Free-tier default.
@@ -202,11 +196,17 @@ export const PRISM_SCHEDULER_ENABLED = process.env.PRISM_SCHEDULER_ENABLED !== "
202
196
  export const PRISM_SCHEDULER_INTERVAL_MS = parseInt(process.env.PRISM_SCHEDULER_INTERVAL_MS || "43200000", 10 // 12 hours
203
197
  );
204
198
  // ─── v5.4: Autonomous Web Scholar ─────────────────────────────
205
- // Background LLM research pipeline powered by Brave Search + Firecrawl.
199
+ // Background LLM research pipeline: web search or free academic discovery,
200
+ // local scrape, LLM synthesis.
206
201
  export const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY;
207
202
  export const PRISM_SCHOLAR_ENABLED = process.env.PRISM_SCHOLAR_ENABLED === "true";
208
- if (PRISM_SCHOLAR_ENABLED && !FIRECRAWL_API_KEY) {
209
- console.error("Warning: FIRECRAWL_API_KEY not set. Web Scholar will fall back to free search.");
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.");
210
210
  }
211
211
  export const PRISM_SCHOLAR_INTERVAL_MS = parseInt(process.env.PRISM_SCHOLAR_INTERVAL_MS || "0", 10 // Default manual-only
212
212
  );
@@ -350,7 +350,7 @@ return false;}
350
350
  firecrawlApiKey: {
351
351
  type: "string",
352
352
  title: "Firecrawl API Key",
353
- description: "Optional: API key for Firecrawl (enables Web Scholar pipeline). Get one at https://www.firecrawl.dev/"
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, 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";
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
- const useGoogle = !!(GOOGLE_SEARCH_API_KEY && GOOGLE_SEARCH_CX);
196
- const useBraveFirecrawl = !useGoogle && !!(BRAVE_API_KEY && FIRECRAWL_API_KEY);
197
- const useFreeFallback = !useGoogle && !useBraveFirecrawl;
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
- if (useGoogle) {
214
- const googleResults = await performGoogleSearch(GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_CX, topic, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN);
215
- urls = googleResults.map(r => r.url).filter(Boolean);
216
- }
217
- else if (useBraveFirecrawl) {
218
- const braveResponse = await performWebSearchRaw(topic, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN);
219
- const braveData = JSON.parse(braveResponse);
220
- urls = (braveData.web?.results || []).map((r) => r.url).filter(Boolean);
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
- else {
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) {
@@ -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
- "Uses Brave/Firecrawl to discover scientific papers and journals, " +
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.",
@@ -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
- * `fusa-bss-billing` inside it satisfied that skill's own trigger
317
- * `\bfusa\b.{0,20}\b(billing|invoice)\b`. Two unrelated private skills loaded
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: "fusa billing invoice"
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 fusa-bss-billing's invoice rate") no longer trigger-routes that
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 _ (fusa-bss-billing, training-results-gate). Round-3 review
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
- // `fusa-bss-billing-worker` container name survived intact, and because
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
@@ -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
- const textData = await performWebSearchRaw(query, count, offset);
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
- const fallback = await performWebSearch(query, count);
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
- const rawData = await performLocalSearchRaw(query, count);
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
- throw new Error(`[synaluxSearch] ${path} HTTP ${res.status}: ${text}`);
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.18.2",
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
- }