@tonydua/dsh-web-search-exa 0.1.4 → 0.2.1

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/lib/index.js CHANGED
@@ -1,152 +1,295 @@
1
- /**
2
- * @tonydua/dsh-web-search-exa
3
- *
4
- * Exa-backed `WebSearchProvider` for the DeepSeek Harness web capability seam
5
- * (`ctx.web`), with an **anonymous** fallback: when no API key is configured,
6
- * search routes through Exa's hosted MCP server (`https://mcp.exa.ai/mcp`) via
7
- * JSON-RPC 2.0 with no credentials — Exa's documented unauthenticated public
8
- * MCP fallback (rate-limited). With a key, the lighter REST endpoint
9
- * (`POST {baseURL}/search`) is used instead, mirroring
10
- * `@deepseek-ai/dsh-web-search-exa`.
11
- *
12
- * The anonymous-MCP strategy and its response parsing follow the `web_search`
13
- * implementation in can1357/oh-my-pi (see README acknowledgements).
14
- *
15
- * This is an implementation package: it registers a provider INTO `ctx.web`
16
- * (`inject: ['web']`) and owns no model-facing tools (those belong to
17
- * `@deepseek-ai/dsh-tool-web`). It also installs a Settings section
18
- * (`web-search-exa`) into the settings service; editing it from the Web UI
19
- * needs a client card (planned for a later version) — today it is configured
20
- * through the profile patch layer (see README "In the Web panel").
21
- */
22
-
23
1
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
24
- import { WebError } from "@deepseek-ai/dsh-web";
25
2
  import z from "@deepseek-ai/schemastery";
26
-
3
+ import { WebError } from "@deepseek-ai/dsh-web";
4
+ //#region src/constants.ts
5
+ /**
6
+ * Defaults and stable identifiers for `@tonydua/dsh-web-search-exa`.
7
+ *
8
+ * Every value here is also restated in the Settings schema with the same
9
+ * default, so a reader can see the effective value without following the
10
+ * import. Keep the two in sync: the schema is the user-facing contract, these
11
+ * are the values the provider falls back to when a field is absent.
12
+ *
13
+ * @module @tonydua/dsh-web-search-exa/constants
14
+ */
27
15
  /** Default provider id this provider registers under (`ctx.web` registry key). */
28
16
  const DEFAULT_PROVIDER_ID = "exa";
29
17
  /** Backward-compatible alias for the default provider id. */
30
- const PROVIDER_ID = DEFAULT_PROVIDER_ID;
18
+ const PROVIDER_ID = "exa";
31
19
  /** Exa REST search endpoint; used only when an API key is configured. */
32
20
  const DEFAULT_BASE_URL = "https://api.exa.ai";
33
21
  /** Legacy full REST endpoint; `baseURL` is the canonical dsh-compatible option. */
34
22
  const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/search`;
35
- /** Exa hosted MCP endpoint; the anonymous fallback path. */
36
- const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp";
23
+ /**
24
+ * Exa hosted MCP endpoint; the anonymous fallback path.
25
+ *
26
+ * The `tools` query is part of the default because `web_search_advanced_exa`
27
+ * is not servable without it — a request naming it against the bare endpoint
28
+ * fails with `MCP error -32602: Tool web_search_advanced_exa not found`.
29
+ * A configured `mcpURL` that omits `tools` gets the query spliced in at request
30
+ * time, so existing configurations keep working.
31
+ */
32
+ const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa";
37
33
  /** Environment variable consulted when no literal `apiKey` is configured. */
38
34
  const DEFAULT_API_KEY_ENV = "EXA_API_KEY";
39
35
  /** Default retrieval mode for the REST path: let Exa pick. */
40
36
  const DEFAULT_SEARCH_TYPE = "auto";
41
37
  /** Default number of highlight sentences requested per result (REST path). */
42
38
  const DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
43
- /** MCP tool name for plain web search on Exa's hosted server. */
39
+ /** MCP tool name for plain web search on Exa's hosted server (text-blob output). */
44
40
  const MCP_TOOL = "web_search_exa";
45
- /** Attribution header sent on anonymous MCP requests. Bump with the version. */
41
+ /** MCP tool whose text content is a sanitized structured search response. */
42
+ const MCP_TOOL_ADVANCED = "web_search_advanced_exa";
43
+ /** The tool the anonymous path calls by default. */
44
+ const DEFAULT_MCP_TOOL = MCP_TOOL_ADVANCED;
45
+ /** Query parameter enabling both MCP tools when a configured URL omits it. */
46
+ const MCP_TOOLS_QUERY = "tools=web_search_exa,web_search_advanced_exa";
47
+ /**
48
+ * Reject anonymous MCP responses larger than this.
49
+ *
50
+ * Structured results are kilobytes; anything past this is a malformed or
51
+ * hostile body and parsing it would only burn memory before failing anyway.
52
+ */
53
+ const MAX_MCP_RESPONSE_BYTES = 262144;
54
+ /**
55
+ * Attribution header sent on anonymous MCP requests. This is the only signal
56
+ * Exa's public endpoint receives about the caller, so it is deliberately a
57
+ * product-level name rather than a per-install identifier.
58
+ */
46
59
  const MCP_SOURCE = "dsh-anything";
47
- /** User agent for REST requests. */
48
- const USER_AGENT = "deepseek-harness-exa/0.1.4";
60
+ /**
61
+ * User agent for REST requests.
62
+ *
63
+ * Annotated `: string` rather than left inferred: this constant is re-exported
64
+ * from the package root, and a `const` string infers a LITERAL type, which
65
+ * would bake today's version number into every consumer's type-checking.
66
+ */
67
+ const USER_AGENT = "deepseek-harness-exa/0.1.5";
49
68
  /** Snippet cap for text-derived snippets (matching oh-my-pi's choice). */
50
69
  const MAX_SNIPPET_CHARS = 500;
51
70
  /** Settings namespace carrying this provider's configuration. */
52
71
  const SETTINGS_NAMESPACE = "web-search-exa";
53
-
72
+ //#endregion
73
+ //#region src/provider.ts
74
+ /**
75
+ * The Exa-backed `WebSearchProvider`: REST with an API key, anonymous MCP
76
+ * without one.
77
+ *
78
+ * The provider owns no model-facing tool — `@deepseek-ai/dsh-tool-web` renders
79
+ * whatever this returns through the `ctx.web` seam. It also never invents a
80
+ * snippet: a result without a real highlight (REST) or a real highlight/text
81
+ * body (MCP) is dropped, because a fabricated snippet would make the seam lie.
82
+ *
83
+ * Anonymous-MCP request shape and response parsing follow the `web_search`
84
+ * implementation in can1357/oh-my-pi (see README acknowledgements).
85
+ *
86
+ * @module @tonydua/dsh-web-search-exa/provider
87
+ */
54
88
  /** True for a positive whole number (cheap local config check). */
55
89
  function isPositiveInteger(value) {
56
90
  return Number.isInteger(value) && value > 0;
57
91
  }
58
-
59
92
  /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
60
93
  function isAbortError(error) {
61
94
  return error instanceof DOMException && error.name === "AbortError";
62
95
  }
63
-
64
96
  /** Throw the seam's stable cancellation error when the caller is already aborted. */
65
97
  function throwIfAborted(signal) {
66
- if (signal?.aborted === true) {
67
- throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal.reason });
68
- }
98
+ if (signal?.aborted === true) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal.reason });
69
99
  }
70
-
71
100
  /**
72
- * Resolve the API key: literal config first, then the environment variable.
73
- * `undefined` means the anonymous MCP path is used.
74
- */
101
+ * Resolve the API key: literal config first, then the environment variable.
102
+ * `undefined` means the anonymous MCP path is used.
103
+ */
75
104
  function resolveApiKeyFromProcess(options) {
76
105
  if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
77
106
  const fromEnv = process.env[options.apiKeyEnv];
78
107
  if (fromEnv != null && fromEnv.length > 0) return fromEnv;
79
- return undefined;
80
108
  }
81
-
82
109
  /**
83
- * Resolve a key against dsh's immutable launch-environment snapshot. The
84
- * process fallback keeps direct library use and older hosts working.
85
- */
110
+ * Resolve a key against dsh's immutable launch-environment snapshot. The
111
+ * process fallback keeps direct library use and older hosts working.
112
+ */
86
113
  function resolveApiKey(options, environment) {
87
114
  if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
88
115
  const fromEnvironment = environment?.get(options.apiKeyEnv)?.value;
89
116
  if (fromEnvironment != null && fromEnvironment.length > 0) return fromEnvironment;
90
117
  return resolveApiKeyFromProcess(options);
91
118
  }
92
-
93
- // ── REST path (with API key) ────────────────────────────────────────────────
94
-
95
119
  /**
96
- * Map one Exa REST result to a normalized source, or `undefined` when it has
97
- * no portable snippet (same rule as the official provider).
98
- */
120
+ * Map one Exa REST result to a normalized source, or `undefined` when it has
121
+ * no portable snippet (same rule as the official provider).
122
+ */
99
123
  function mapRestResult(result) {
100
124
  const snippet = result.highlights?.find((highlight) => highlight.trim().length > 0);
101
- if (snippet === undefined) return undefined;
125
+ if (snippet === void 0) return void 0;
102
126
  return {
103
127
  url: result.url,
104
128
  ...result.title != null && result.title.length > 0 ? { title: result.title } : {},
105
129
  snippet,
106
- ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
130
+ ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}
107
131
  };
108
132
  }
109
-
110
- // ── Anonymous MCP path (no API key) ─────────────────────────────────────────
111
-
112
133
  /**
113
- * Parse an SSE (`text/event-stream`) response body into its first `data:`
114
- * payload, falling back to plain JSON. Returns `null` when neither parses.
115
- */
116
- function parseSsePayload(text) {
117
- const dataLines = text.split(/\r?\n/)
118
- .filter((line) => line.startsWith("data:"))
119
- .map((line) => line.slice(5).replace(/^\s/, ""));
120
- if (dataLines.length > 0) {
121
- try {
122
- return JSON.parse(dataLines.join("\n"));
123
- } catch {
124
- return null;
134
+ * Read an anonymous response body while refusing to buffer more than `limit`
135
+ * bytes.
136
+ *
137
+ * The cap has to be enforced *while reading*, not after: `await
138
+ * response.text()` materializes the whole body first, so a size check that
139
+ * follows it protects nothing — the memory has already been spent, and
140
+ * `new TextEncoder().encode(text)` spends a second copy of it just to measure
141
+ * the first. This helper instead:
142
+ *
143
+ * 1. rejects immediately when the server declares an over-limit
144
+ * `content-length`, so the body is never requested;
145
+ * 2. otherwise reads the stream chunk by chunk, aborting the transfer as soon
146
+ * as the running byte count passes `limit`.
147
+ *
148
+ * The limit is a memory boundary on the keyless path (a shared, unauthenticated
149
+ * endpoint), not a judgement about the result set: the structured tool returns
150
+ * whole-page text for every hit, so a large-but-legitimate response is possible
151
+ * and is reported as a transient failure rather than a silent empty result.
152
+ *
153
+ * @param response - the ok response whose body is to be read.
154
+ * @param limit - the maximum number of bytes to buffer.
155
+ * @returns the decoded body text.
156
+ * @throws {ExaResponseTooLargeError} when the body is, or grows, past `limit`.
157
+ */
158
+ async function readBoundedBody(response, limit) {
159
+ const declared = response.headers.get("content-length");
160
+ if (declared !== null) {
161
+ const declaredBytes = Number(declared);
162
+ if (Number.isFinite(declaredBytes) && declaredBytes > limit) {
163
+ await response.body?.cancel().catch(() => {});
164
+ throw new ExaResponseTooLargeError(limit, declaredBytes);
125
165
  }
126
166
  }
167
+ const body = response.body;
168
+ if (body === null) return await response.text();
169
+ const reader = body.getReader();
170
+ const chunks = [];
171
+ let received = 0;
172
+ try {
173
+ for (;;) {
174
+ const { done, value } = await reader.read();
175
+ if (done) break;
176
+ received += value.byteLength;
177
+ if (received > limit) {
178
+ await reader.cancel().catch(() => {});
179
+ throw new ExaResponseTooLargeError(limit, received);
180
+ }
181
+ chunks.push(value);
182
+ }
183
+ } finally {
184
+ reader.releaseLock();
185
+ }
186
+ const merged = new Uint8Array(received);
187
+ let offset = 0;
188
+ for (const chunk of chunks) {
189
+ merged.set(chunk, offset);
190
+ offset += chunk.byteLength;
191
+ }
192
+ return new TextDecoder().decode(merged);
193
+ }
194
+ /**
195
+ * Return the request URL for an anonymous advanced-tool search: when the
196
+ * configured MCP URL carries no `tools` parameter, splice in the query that
197
+ * enables both tools — the advanced tool is not servable otherwise. An existing
198
+ * query string is preserved.
199
+ *
200
+ * @param baseURL - the configured MCP endpoint.
201
+ * @returns the request URL with a non-empty `tools` query present.
202
+ */
203
+ function endpointFor(baseURL) {
204
+ const queryIndex = baseURL.indexOf("?");
205
+ if (queryIndex >= 0) {
206
+ const tools = new URLSearchParams(baseURL.slice(queryIndex + 1)).get("tools");
207
+ if (tools != null && tools.length > 0) return baseURL;
208
+ return `${baseURL}&${MCP_TOOLS_QUERY}`;
209
+ }
210
+ return `${baseURL}?${MCP_TOOLS_QUERY}`;
211
+ }
212
+ /**
213
+ * Map one sanitized advanced-tool result to a normalized source, or `undefined`
214
+ * when it has no portable snippet. The advanced tool returns the REST result
215
+ * vocabulary as JSON, so this mirrors {@link mapRestResult} — including its rule
216
+ * that a snippet must be a real highlight, never the long-form `text` field,
217
+ * because a fabricated snippet would make the seam lie.
218
+ *
219
+ * @param result - one structured entry from the sanitized response.
220
+ * @returns a normalized source, or `undefined` when the entry is unusable.
221
+ */
222
+ function mapAdvancedResult(result) {
223
+ if (typeof result !== "object" || result === null) return void 0;
224
+ const entry = result;
225
+ if (typeof entry.url !== "string" || entry.url.length === 0) return void 0;
226
+ const snippet = (Array.isArray(entry.highlights) ? entry.highlights : void 0)?.find((highlight) => typeof highlight === "string" && highlight.trim().length > 0);
227
+ if (snippet === void 0) return void 0;
228
+ const title = typeof entry.title === "string" ? entry.title : void 0;
229
+ const publishedDate = typeof entry.publishedDate === "string" ? entry.publishedDate : void 0;
230
+ return {
231
+ url: entry.url,
232
+ ...title != null && title.length > 0 ? { title } : {},
233
+ snippet,
234
+ ...publishedDate != null && publishedDate.length > 0 ? { publishedAt: publishedDate } : {}
235
+ };
236
+ }
237
+ /**
238
+ * Extract sources from a successful advanced-tool payload: the first text item
239
+ * is the sanitized search response JSON, in the REST envelope shape
240
+ * (`{ results: [...] }`).
241
+ *
242
+ * @param payload - the parsed JSON-RPC payload.
243
+ * @returns the normalized sources, or `null` when the body is not the expected
244
+ * shape — the caller then falls back to `Title:`-section parsing, so a future
245
+ * change to the tool's output degrades instead of breaking.
246
+ */
247
+ function parseAdvancedPayload(payload) {
248
+ const content = payload?.result?.content;
249
+ if (!Array.isArray(content)) return null;
250
+ const text = content.find((item) => typeof item?.text === "string")?.text;
251
+ if (text === void 0) return null;
252
+ let parsed;
253
+ try {
254
+ parsed = JSON.parse(text);
255
+ } catch {
256
+ return null;
257
+ }
258
+ if (typeof parsed !== "object" || parsed === null) return null;
259
+ if (parsed.results === void 0) return [];
260
+ if (!Array.isArray(parsed.results)) return null;
261
+ return parsed.results.map(mapAdvancedResult).filter((source) => source !== void 0);
262
+ }
263
+ /**
264
+ * Parse an SSE (`text/event-stream`) response body into its first `data:`
265
+ * payload, falling back to plain JSON. Returns `null` when neither parses.
266
+ */
267
+ function parseSsePayload(text) {
268
+ const dataLines = text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^\s/, ""));
269
+ if (dataLines.length > 0) try {
270
+ return JSON.parse(dataLines.join("\n"));
271
+ } catch {
272
+ return null;
273
+ }
127
274
  try {
128
275
  return JSON.parse(text);
129
276
  } catch {
130
277
  return null;
131
278
  }
132
279
  }
133
-
134
280
  /**
135
- * Collect non-blank `content[].text` blocks from a normalized MCP result
136
- * payload, joined with blank lines.
137
- */
281
+ * Collect non-blank `content[].text` blocks from a normalized MCP result
282
+ * payload, joined with blank lines.
283
+ */
138
284
  function collectMcpText(payload) {
139
285
  const content = payload?.result?.content;
140
286
  if (!Array.isArray(content)) return [];
141
- return content
142
- .map((item) => (typeof item?.text === "string" ? item.text.replace(/\r\n?/g, "\n").trim() : ""))
143
- .filter((text) => text.length > 0);
287
+ return content.map((item) => typeof item?.text === "string" ? item.text.replace(/\r\n?/g, "\n").trim() : "").filter((text) => text.length > 0);
144
288
  }
145
-
146
289
  /**
147
- * Parse one `Title:`-led section of Exa MCP text output into a partial source.
148
- * Handles both `Published:` and `Published Date:` field spellings.
149
- */
290
+ * Parse one `Title:`-led section of Exa MCP text output into a partial source.
291
+ * Handles both `Published:` and `Published Date:` field spellings.
292
+ */
150
293
  function parseExaSection(section) {
151
294
  const out = {};
152
295
  let field = null;
@@ -168,9 +311,8 @@ function parseExaSection(section) {
168
311
  } else if (author) {
169
312
  out.author = author[1].trim();
170
313
  field = null;
171
- } else if (/^Highlights:\s*$/.test(line)) {
172
- field = "highlights";
173
- } else if (/^Text:\s*$/.test(line)) {
314
+ } else if (/^Highlights:\s*$/.test(line)) field = "highlights";
315
+ else if (/^Text:\s*$/.test(line)) {
174
316
  field = "text";
175
317
  textLines = [];
176
318
  } else if (field === "highlights") {
@@ -179,109 +321,219 @@ function parseExaSection(section) {
179
321
  out.highlights ??= [];
180
322
  out.highlights.push(trimmed.replace(/^[-•]\s*/, ""));
181
323
  }
182
- } else if (field === "text" && textLines !== null) {
183
- textLines.push(line);
184
- }
324
+ } else if (field === "text" && textLines !== null) textLines.push(line);
185
325
  }
186
326
  if (textLines !== null) out.text = textLines.join("\n").trim();
187
327
  if (out.publishedAt === "N/A") delete out.publishedAt;
188
328
  if (out.author === "N/A") delete out.author;
189
329
  return out;
190
330
  }
191
-
192
331
  /** Split joined MCP text into per-result sections, each starting with `Title:`. */
193
332
  function splitExaSections(joined) {
194
- return joined
195
- .split(/\n{2,}(?=Title:\s*)/)
196
- .map((section) => section.trim())
197
- .filter((section) => section.length > 0 && section.startsWith("Title:"));
333
+ return joined.split(/\n{2,}(?=Title:\s*)/).map((section) => section.trim()).filter((section) => section.length > 0 && section.startsWith("Title:"));
198
334
  }
199
-
200
335
  /** Map parsed Exa MCP sections to normalized sources (snippet-less entries dropped). */
201
336
  function mapMcpSections(sections) {
202
337
  const sources = [];
203
338
  for (const section of sections) {
204
339
  const parsed = parseExaSection(section);
205
340
  if (!parsed.url || parsed.url.length === 0) continue;
206
- const highlight = parsed.highlights?.find((item) => item.trim().length > 0);
207
- const snippet = highlight ?? (parsed.text ? parsed.text.slice(0, MAX_SNIPPET_CHARS) : undefined);
208
- if (snippet === undefined) continue;
341
+ const snippet = parsed.highlights?.find((item) => item.trim().length > 0) ?? (parsed.text ? parsed.text.slice(0, 500) : void 0);
342
+ if (snippet === void 0) continue;
209
343
  sources.push({
210
344
  url: parsed.url,
211
345
  ...parsed.title != null && parsed.title.length > 0 ? { title: parsed.title } : {},
212
346
  snippet,
213
- ...parsed.publishedAt != null && parsed.publishedAt.length > 0 ? { publishedAt: parsed.publishedAt } : {},
347
+ ...parsed.publishedAt != null && parsed.publishedAt.length > 0 ? { publishedAt: parsed.publishedAt } : {}
214
348
  });
215
349
  }
216
350
  return sources;
217
351
  }
218
-
219
- // ── Provider ────────────────────────────────────────────────────────────────
220
-
221
352
  /**
222
- * Project one resolved configuration section into the options the provider
223
- * serves its next search with. Called per operation so live Settings edits
224
- * take effect on the next search.
225
- */
353
+ * How long a tripped breaker keeps `available()` false before the next search
354
+ * is allowed to probe the anonymous endpoint again.
355
+ */
356
+ const DEFAULT_BREAKER_COOLDOWN_MS = 3e5;
357
+ /**
358
+ * Consecutive transient failures that trip the breaker.
359
+ *
360
+ * Only 5xx, 429, and network-level failures count: they say the anonymous
361
+ * endpoint is having a bad time, not that the request was wrong. A 4xx (other
362
+ * than 429) is a configuration error and would fail identically forever, so it
363
+ * deliberately does NOT trip the breaker — hiding a bad endpoint behind a
364
+ * cooldown would just delay the same error.
365
+ */
366
+ const DEFAULT_BREAKER_THRESHOLD = 3;
367
+ /** A failure worth retrying later, as opposed to a permanent configuration error. */
368
+ var ExaTransientError = class extends WebError {
369
+ constructor(message, cause) {
370
+ super(message, "WEB_PROVIDER_ERROR", cause === void 0 ? void 0 : { cause });
371
+ this.name = "ExaTransientError";
372
+ }
373
+ };
374
+ /**
375
+ * A rate limit, kept distinct from a generic provider failure so the model (and
376
+ * a human reading the transcript) can tell "Exa is throttling the keyless
377
+ * channel, configure a key" apart from "the network is broken".
378
+ *
379
+ * `code` is an open string in the seam's vocabulary, so a plugin-specific code
380
+ * is the supported way to route this; consumers must tolerate unknown codes.
381
+ */
382
+ var ExaRateLimitError = class extends WebError {
383
+ constructor(message) {
384
+ super(message, "WEB_RATE_LIMITED");
385
+ this.name = "ExaRateLimitError";
386
+ }
387
+ };
388
+ /**
389
+ * An anonymous response body that exceeded {@link MAX_MCP_RESPONSE_BYTES}.
390
+ *
391
+ * Extends {@link ExaTransientError} because the endpoint, not the caller's
392
+ * configuration, produced it: a body this size is a bad day on Exa's side, and
393
+ * the breaker should get to count it.
394
+ */
395
+ var ExaResponseTooLargeError = class extends ExaTransientError {
396
+ /** The number of bytes observed, or `undefined` when the server declared the size. */
397
+ observedBytes;
398
+ constructor(limit, observedBytes) {
399
+ super(`Exa anonymous MCP response exceeded ${limit} bytes` + (observedBytes === void 0 ? "" : ` (received ${observedBytes})`));
400
+ this.name = "ExaResponseTooLargeError";
401
+ this.observedBytes = observedBytes;
402
+ }
403
+ };
404
+ /**
405
+ * Consecutive-transient-failure breaker for the keyless path.
406
+ *
407
+ * The public MCP endpoint is best-effort: when it is throttling or down, every
408
+ * search would otherwise fail. Reporting that state through {@link
409
+ * ExaSearchProvider.available} is what lets a deployment recover — the seam
410
+ * skips an unavailable provider, so an unconfigured profile falls back to
411
+ * another registered provider instead of surfacing a hard error.
412
+ *
413
+ * State is per provider instance (one per plugin mount) and never persisted:
414
+ * a restart is a fresh chance, and one successful search resets the count.
415
+ */
416
+ var ExaAvailabilityBreaker = class {
417
+ #threshold;
418
+ #cooldownMs;
419
+ #consecutiveFailures = 0;
420
+ #openedAt;
421
+ constructor(threshold = 3, cooldownMs = DEFAULT_BREAKER_COOLDOWN_MS) {
422
+ this.#threshold = threshold;
423
+ this.#cooldownMs = cooldownMs;
424
+ }
425
+ /** True while the breaker is open and the cooldown has not elapsed. */
426
+ get blocked() {
427
+ if (this.#openedAt === void 0) return false;
428
+ return Date.now() - this.#openedAt < this.#cooldownMs;
429
+ }
430
+ /** Record one successful operation: the endpoint is healthy again. */
431
+ succeeded() {
432
+ this.#consecutiveFailures = 0;
433
+ this.#openedAt = void 0;
434
+ }
435
+ /** Record one transient failure, opening the breaker at the threshold. */
436
+ failed() {
437
+ this.#consecutiveFailures += 1;
438
+ if (this.#consecutiveFailures >= this.#threshold) this.#openedAt = Date.now();
439
+ }
440
+ };
441
+ /**
442
+ * True for an HTTP status that will keep failing until the endpoint recovers.
443
+ * 429 is included: the keyless channel is throttled, not misconfigured.
444
+ */
445
+ function isTransientStatus(status) {
446
+ return status === 429 || status >= 500;
447
+ }
448
+ /**
449
+ * Project one resolved configuration section into the options the provider
450
+ * serves its next search with. Called per operation so live Settings edits
451
+ * take effect on the next search.
452
+ */
226
453
  function resolveOptions(section) {
227
- const baseURL = section.baseURL ?? DEFAULT_BASE_URL;
454
+ const baseURL = section.baseURL ?? "https://api.exa.ai";
228
455
  return {
229
- providerId: section.providerId ?? DEFAULT_PROVIDER_ID,
456
+ providerId: section.providerId ?? "exa",
230
457
  apiKey: section.apiKey ?? "",
231
- apiKeyEnv: section.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
458
+ apiKeyEnv: section.apiKeyEnv ?? "EXA_API_KEY",
232
459
  baseURL,
233
460
  apiURL: section.apiURL ?? `${baseURL.replace(/\/+$/, "")}/search`,
234
- mcpURL: section.mcpURL ?? DEFAULT_MCP_URL,
235
- searchType: section.searchType ?? DEFAULT_SEARCH_TYPE,
461
+ mcpURL: section.mcpURL ?? "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa",
462
+ mcpTool: section.mcpTool ?? "web_search_advanced_exa",
463
+ searchType: section.searchType ?? "auto",
236
464
  numResults: section.numResults,
237
- highlightsPerResult: section.highlightsPerResult ?? DEFAULT_HIGHLIGHTS_PER_RESULT,
465
+ highlightsPerResult: section.highlightsPerResult ?? 1
238
466
  };
239
467
  }
240
-
241
468
  /** Resolve the keyed REST endpoint from either the current or legacy option shape. */
242
469
  function resolveSearchURL(options) {
243
470
  if (options.apiURL != null) return options.apiURL;
244
- const baseURL = options.baseURL ?? DEFAULT_BASE_URL;
245
- return `${baseURL.replace(/\/+$/, "")}/search`;
471
+ return `${(options.baseURL ?? "https://api.exa.ai").replace(/\/+$/, "")}/search`;
246
472
  }
247
-
248
- class ExaSearchProvider {
249
- resolveOptions;
250
- resolveApiKey;
251
- id;
252
-
473
+ /**
474
+ * Exa-backed search with an anonymous fallback.
475
+ *
476
+ * Path selection is per search, not per install: a key appearing later (for
477
+ * example after a Settings edit) upgrades the next search to REST without a
478
+ * restart, and removing it falls back to the anonymous endpoint.
479
+ */
480
+ var ExaSearchProvider = class {
481
+ #resolveOptions;
482
+ #resolveApiKey;
483
+ #breaker;
484
+ /**
485
+ * @param resolveOptions - thunk returning the options for the NEXT
486
+ * operation, snapshotted once at each operation's entry so one search
487
+ * never mixes two settings sections (same pattern as the official
488
+ * DeepSeek provider).
489
+ * @param resolveApiKey - optional key resolver; dsh hosts pass their
490
+ * launch-environment snapshot while direct users retain process.env fallback.
491
+ * @param breaker - health tracker for the keyless path; injectable so tests
492
+ * need no clock control.
493
+ */
494
+ constructor(resolveOptions, resolveApiKey = resolveApiKeyFromProcess, breaker = new ExaAvailabilityBreaker()) {
495
+ this.#resolveOptions = resolveOptions;
496
+ this.#resolveApiKey = resolveApiKey;
497
+ this.#breaker = breaker;
498
+ }
253
499
  /**
254
- * @param resolveOptions - thunk returning the options for the NEXT
255
- * operation, snapshotted once at each operation's entry so one search
256
- * never mixes two settings sections (same pattern as the official
257
- * DeepSeek provider).
258
- * @param resolveApiKey - optional key resolver; dsh hosts pass their
259
- * launch-environment snapshot while direct users retain process.env fallback.
260
- */
261
- constructor(resolveOptions, resolveApiKey = resolveApiKeyFromProcess) {
262
- this.resolveOptions = resolveOptions;
263
- this.resolveApiKey = resolveApiKey;
264
- this.id = resolveOptions().providerId ?? PROVIDER_ID;
500
+ * Read per operation rather than frozen at construction: a Settings edit to
501
+ * `providerId` must not leave the provider reporting an id the registry does
502
+ * not key it under. Registering under the new id is the user's job (the
503
+ * loader re-reads config on reload), but the reported value stays honest.
504
+ */
505
+ get id() {
506
+ return this.#resolveOptions().providerId ?? "exa";
265
507
  }
266
-
267
- /** The anonymous MCP path needs no credentials, so only local options gate use. */
508
+ /**
509
+ * Cheap local usability check — no network call, per the seam contract.
510
+ *
511
+ * False when the local options are unusable, or while the keyless channel's
512
+ * breaker is open after repeated transient failures. Reporting that honestly
513
+ * is what lets the seam fall back to another provider instead of failing the
514
+ * search: a pinned `searchProvider` surfaces
515
+ * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, an unpinned one simply selects
516
+ * another registered provider.
517
+ */
268
518
  available() {
269
- const options = this.resolveOptions();
270
- return URL.canParse(resolveSearchURL(options))
271
- && URL.canParse(options.mcpURL)
272
- && isPositiveInteger(options.highlightsPerResult)
273
- && (options.numResults === undefined || isPositiveInteger(options.numResults));
519
+ if (this.#breaker.blocked) return false;
520
+ const options = this.#resolveOptions();
521
+ return URL.canParse(resolveSearchURL(options)) && URL.canParse(options.mcpURL) && isPositiveInteger(options.highlightsPerResult) && (options.numResults === void 0 || isPositiveInteger(options.numResults));
274
522
  }
275
-
276
523
  async search(request, signal) {
277
524
  throwIfAborted(signal);
278
- const options = this.resolveOptions();
279
- const apiKey = this.resolveApiKey(options);
280
- return apiKey !== undefined
281
- ? await this.#restSearch(request, apiKey, options, signal)
282
- : await this.#anonymousMcpSearch(request, options, signal);
525
+ const options = this.#resolveOptions();
526
+ const apiKey = this.#resolveApiKey(options);
527
+ if (apiKey !== void 0) return await this.#restSearch(request, apiKey, options, signal);
528
+ try {
529
+ const result = await this.#anonymousMcpSearch(request, options, signal);
530
+ this.#breaker.succeeded();
531
+ return result;
532
+ } catch (error) {
533
+ if (error instanceof ExaTransientError) this.#breaker.failed();
534
+ throw error;
535
+ }
283
536
  }
284
-
285
537
  /** REST search with an API key: `POST {apiURL}` with Bearer auth. */
286
538
  async #restSearch(request, apiKey, options, signal) {
287
539
  throwIfAborted(signal);
@@ -293,18 +545,18 @@ class ExaSearchProvider {
293
545
  method: "POST",
294
546
  redirect: "error",
295
547
  headers: {
296
- "authorization": `Bearer ${apiKey}`,
548
+ authorization: `Bearer ${apiKey}`,
297
549
  "content-type": "application/json",
298
- "accept": "application/json",
299
- "user-agent": USER_AGENT,
550
+ accept: "application/json",
551
+ "user-agent": USER_AGENT
300
552
  },
301
553
  body: JSON.stringify({
302
554
  query: request.query,
303
555
  type: options.searchType,
304
556
  contents: { highlights: { highlightsPerUrl: options.highlightsPerResult } },
305
- ...numResults !== undefined ? { numResults } : {},
557
+ ...numResults !== void 0 ? { numResults } : {}
306
558
  }),
307
- ...signal !== undefined ? { signal } : {},
559
+ ...signal !== void 0 ? { signal } : {}
308
560
  });
309
561
  } catch (error) {
310
562
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
@@ -315,10 +567,9 @@ class ExaSearchProvider {
315
567
  try {
316
568
  const parsed = await response.json();
317
569
  const detail = parsed.error ?? parsed.message;
318
- if (detail !== undefined && detail.length > 0) message = detail;
570
+ if (detail !== void 0 && detail.length > 0) message = detail;
319
571
  } catch (error) {
320
572
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
321
- // keep the generic message
322
573
  }
323
574
  throw new WebError(message, "WEB_PROVIDER_ERROR");
324
575
  }
@@ -329,91 +580,95 @@ class ExaSearchProvider {
329
580
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
330
581
  throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
331
582
  }
332
- const sources = (parsed.results ?? []).map(mapRestResult).filter((source) => source !== undefined);
333
- return { sources, truncated: false };
583
+ return {
584
+ sources: (parsed.results ?? []).map(mapRestResult).filter((source) => source !== void 0),
585
+ truncated: false
586
+ };
334
587
  }
335
-
336
588
  /**
337
- * Anonymous search through Exa's hosted MCP server. No credentials are
338
- * sent; the `x-exa-source` header carries attribution. Rate-limited by Exa
339
- * (HTTP 429) — configuring an API key lifts the limit via the REST path.
340
- */
589
+ * Anonymous search through Exa's hosted MCP server. No credentials are
590
+ * sent; the `x-exa-source` header carries attribution. Rate-limited by Exa
591
+ * (HTTP 429) — configuring an API key lifts the limit via the REST path.
592
+ */
341
593
  async #anonymousMcpSearch(request, options, signal) {
342
594
  throwIfAborted(signal);
595
+ const tool = options.mcpTool;
596
+ const isAdvanced = tool === "web_search_advanced_exa";
343
597
  const args = { query: request.query };
344
598
  const numResults = request.maxResults ?? options.numResults;
345
- if (numResults !== undefined) args.numResults = numResults;
599
+ if (numResults !== void 0) args.numResults = numResults;
600
+ if (isAdvanced) {
601
+ args.enableHighlights = true;
602
+ args.highlightsNumSentences = options.highlightsPerResult ?? 1;
603
+ }
346
604
  let response;
347
605
  try {
348
- response = await fetch(options.mcpURL, {
606
+ response = await fetch(isAdvanced ? endpointFor(options.mcpURL) : options.mcpURL, {
349
607
  method: "POST",
350
608
  redirect: "error",
351
609
  headers: {
352
610
  "content-type": "application/json",
353
- "accept": "application/json, text/event-stream",
354
- "x-exa-source": MCP_SOURCE,
611
+ accept: "application/json, text/event-stream",
612
+ "x-exa-source": MCP_SOURCE
355
613
  },
356
614
  body: JSON.stringify({
357
615
  jsonrpc: "2.0",
358
616
  id: Math.random().toString(36).slice(2),
359
617
  method: "tools/call",
360
- params: { name: MCP_TOOL, arguments: args },
618
+ params: {
619
+ name: tool,
620
+ arguments: args
621
+ }
361
622
  }),
362
- ...signal !== undefined ? { signal } : {},
623
+ ...signal !== void 0 ? { signal } : {}
363
624
  });
364
625
  } catch (error) {
365
626
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
366
- throw new WebError(`Exa anonymous search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
627
+ throw new ExaTransientError(`Exa anonymous search request failed: ${String(error)}`, error);
367
628
  }
368
629
  if (!response.ok) {
369
- if (response.status === 429) {
370
- throw new WebError(
371
- "Exa anonymous MCP rate limit reached (HTTP 429); configure an EXA_API_KEY for higher limits",
372
- "WEB_PROVIDER_ERROR",
373
- );
374
- }
630
+ if (response.status === 429) throw new ExaRateLimitError("Exa anonymous MCP rate limit reached (HTTP 429). The keyless channel is shared and throttled; set EXA_API_KEY (or a literal \"apiKey\" in the web-search-exa config) to use the keyed REST path.");
631
+ if (isTransientStatus(response.status)) throw new ExaTransientError(`Exa anonymous MCP error (HTTP ${response.status})`);
375
632
  throw new WebError(`Exa anonymous MCP error (HTTP ${response.status})`, "WEB_PROVIDER_ERROR");
376
633
  }
377
- let payload;
634
+ let text;
378
635
  try {
379
- payload = parseSsePayload(await response.text());
636
+ text = await readBoundedBody(response, MAX_MCP_RESPONSE_BYTES);
380
637
  } catch (error) {
381
638
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
382
- throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
383
- }
384
- if (payload === null) {
385
- throw new WebError("Exa anonymous MCP returned an unprocessable response body", "WEB_PROVIDER_ERROR");
386
- }
387
- if (payload.error != null) {
388
- throw new WebError(`Exa MCP error: ${String(payload.error.message ?? JSON.stringify(payload.error))}`, "WEB_PROVIDER_ERROR");
639
+ if (error instanceof ExaResponseTooLargeError) throw error;
640
+ throw new ExaTransientError(`Exa returned an unprocessable response body: ${String(error)}`, error);
389
641
  }
642
+ const payload = parseSsePayload(text);
643
+ if (payload === null) throw new ExaTransientError("Exa anonymous MCP returned an unprocessable response body");
644
+ if (payload.error != null) throw new WebError(`Exa MCP error: ${String(payload.error.message ?? JSON.stringify(payload.error))}`, "WEB_PROVIDER_ERROR");
390
645
  if (payload.result?.isError === true) {
391
646
  const detail = collectMcpText(payload).join("\n").trim();
392
647
  throw new WebError(`Exa MCP tool error${detail.length > 0 ? `: ${detail}` : ""}`, "WEB_PROVIDER_ERROR");
393
648
  }
394
- const sections = splitExaSections(collectMcpText(payload).join("\n\n"));
395
- const sources = mapMcpSections(sections);
396
- return { sources, truncated: false };
649
+ const structured = isAdvanced ? parseAdvancedPayload(payload) : null;
650
+ if (structured !== null) return {
651
+ sources: structured,
652
+ truncated: false
653
+ };
654
+ return {
655
+ sources: mapMcpSections(splitExaSections(collectMcpText(payload).join("\n\n"))),
656
+ truncated: false
657
+ };
397
658
  }
398
- }
399
-
400
- // ── Cordis plugin wiring ────────────────────────────────────────────────────
401
-
402
- /** Cordis plugin name used by loader diagnostics. */
403
- const name = "web-search-exa";
404
- /** The web seam this provider registers into. */
405
- const inject = ["web"];
406
-
659
+ };
660
+ //#endregion
661
+ //#region src/index.ts
407
662
  const Config = z.object({
408
663
  /**
409
- * Provider id registered into `ctx.web`. Defaults to `exa` (same as the
410
- * official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
411
- * packages are installed in one profile — the seam rejects duplicate ids
412
- * with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
413
- * distinct id here (e.g. `exa-anon`) and select it explicitly with
414
- * `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
415
- */
416
- providerId: z.string().default(DEFAULT_PROVIDER_ID),
664
+ * Provider id registered into `ctx.web`. Defaults to `exa` (same as the
665
+ * official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
666
+ * packages are installed in one profile — the seam rejects duplicate ids
667
+ * with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
668
+ * distinct id here (e.g. `exa-anon`) and select it explicitly with
669
+ * `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
670
+ */
671
+ providerId: z.string().default("exa"),
417
672
  /** Literal Exa API key; an empty/missing value enables the anonymous MCP path. */
418
673
  apiKey: z.string().role("secret"),
419
674
  /** Environment variable consulted when no literal `apiKey` is configured. */
@@ -421,55 +676,73 @@ const Config = z.object({
421
676
  /** Exa API base URL; `/search` is appended for the keyed REST path. */
422
677
  baseURL: z.string().default(DEFAULT_BASE_URL),
423
678
  /**
424
- * Legacy full REST endpoint. When set, it takes precedence over `baseURL`;
425
- * new configurations should use `baseURL` to match the official provider.
426
- */
679
+ * Legacy full REST endpoint. When set, it takes precedence over `baseURL`;
680
+ * new configurations should use `baseURL` to match the official provider.
681
+ */
427
682
  apiURL: z.string(),
428
683
  /** Exa hosted MCP endpoint, used by the anonymous fallback. */
429
684
  mcpURL: z.string().default(DEFAULT_MCP_URL),
685
+ /**
686
+ * MCP tool the anonymous path calls. `web_search_advanced_exa` returns a
687
+ * sanitized structured JSON response; `web_search_exa` returns the
688
+ * `Title:`-section text blob. The structured tool is the default because it
689
+ * needs no text parsing, and the text path stays available as a fallback.
690
+ */
691
+ mcpTool: z.union(["web_search_exa", "web_search_advanced_exa"]).default(DEFAULT_MCP_TOOL),
430
692
  /** REST retrieval mode: `auto`, `keyword`, or `neural`. */
431
- searchType: z.union(["auto", "keyword", "neural"]).default(DEFAULT_SEARCH_TYPE),
693
+ searchType: z.union([
694
+ "auto",
695
+ "keyword",
696
+ "neural"
697
+ ]).default(DEFAULT_SEARCH_TYPE),
432
698
  /** Default result count when the request carries no `maxResults`. */
433
699
  numResults: z.number().step(1).min(1),
434
700
  /** Highlight sentences requested per result on the REST path. */
435
- highlightsPerResult: z.number().step(1).min(1).default(DEFAULT_HIGHLIGHTS_PER_RESULT),
701
+ highlightsPerResult: z.number().step(1).min(1).default(1)
436
702
  });
437
-
703
+ /** Cordis plugin name used by loader diagnostics. */
704
+ const name = "web-search-exa";
705
+ /** The web seam this provider registers into. */
706
+ const inject = ["web"];
707
+ /**
708
+ * Wire the settings service when the host exposes the pre-0.1.7 registration
709
+ * API; do nothing (rather than throw) on hosts that do not.
710
+ *
711
+ * @param settings - the mounted settings service, of either generation.
712
+ * @param owner - the consuming context `installSection` attributes the section to.
713
+ * @param config - the composition entry, used as the section's base value.
714
+ * @param adopt - receives the authoritative section thunk so later searches read
715
+ * live edits instead of the boot-time config.
716
+ * @returns true when a section was installed.
717
+ */
718
+ function installSettingsSection(settings, owner, config, adopt) {
719
+ if (typeof settings.installSection !== "function") return false;
720
+ settings.installSection(owner, SETTINGS_NAMESPACE, Config, config, {
721
+ setSource: adopt,
722
+ onChange: () => {}
723
+ });
724
+ return true;
725
+ }
438
726
  /**
439
- * Register the Exa search provider with `ctx.web` and, when the optional
440
- * settings service is mounted, install its Settings section using the dsh
441
- * 0.1.2 API.
442
- */
727
+ * Register the Exa search provider with `ctx.web` and, when the settings
728
+ * service is mounted *and* exposes the pre-0.1.7 registration API, install its
729
+ * Settings section.
730
+ *
731
+ * The settings work is deliberately inside `ctx.inject`, so a profile that
732
+ * omits `dsh-settings` still mounts the provider — keyless search must not
733
+ * depend on the Settings UI being present. The provider registration happens
734
+ * outside it for the same reason, and is never conditional on the settings
735
+ * generation.
736
+ */
443
737
  function apply(ctx, config) {
444
738
  let current = () => config;
445
739
  ctx.inject(["settings"], (settingsCtx) => {
446
- settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
447
- setSource: (source) => {
448
- current = source;
449
- },
450
- onChange: () => {},
451
- });
740
+ installSettingsSection(settingsCtx.settings, ctx, config, (source) => {
741
+ current = source;
742
+ });
452
743
  });
453
744
  const environment = launchEnvironmentOf(ctx);
454
- ctx.web.registerSearchProvider(new ExaSearchProvider(
455
- () => resolveOptions(current()),
456
- (options) => resolveApiKey(options, environment),
457
- ));
745
+ ctx.web.registerSearchProvider(new ExaSearchProvider(() => resolveOptions(current()), (options) => resolveApiKey(options, environment)));
458
746
  }
459
-
460
- export {
461
- Config,
462
- DEFAULT_API_KEY_ENV,
463
- DEFAULT_API_URL,
464
- DEFAULT_BASE_URL,
465
- DEFAULT_HIGHLIGHTS_PER_RESULT,
466
- DEFAULT_MCP_URL,
467
- DEFAULT_PROVIDER_ID,
468
- DEFAULT_SEARCH_TYPE,
469
- PROVIDER_ID,
470
- SETTINGS_NAMESPACE,
471
- ExaSearchProvider,
472
- apply,
473
- inject,
474
- name,
475
- };
747
+ //#endregion
748
+ export { Config, DEFAULT_API_KEY_ENV, DEFAULT_API_URL, DEFAULT_BASE_URL, DEFAULT_BREAKER_COOLDOWN_MS, DEFAULT_BREAKER_THRESHOLD, DEFAULT_HIGHLIGHTS_PER_RESULT, DEFAULT_MCP_TOOL, DEFAULT_MCP_URL, DEFAULT_PROVIDER_ID, DEFAULT_SEARCH_TYPE, ExaAvailabilityBreaker, ExaRateLimitError, ExaResponseTooLargeError, ExaSearchProvider, ExaTransientError, MAX_MCP_RESPONSE_BYTES, MAX_SNIPPET_CHARS, MCP_SOURCE, MCP_TOOL, MCP_TOOLS_QUERY, MCP_TOOL_ADVANCED, PROVIDER_ID, SETTINGS_NAMESPACE, USER_AGENT, apply, inject, installSettingsSection, name, resolveApiKey, resolveApiKeyFromProcess, resolveOptions };