@tonydua/dsh-web-search-exa 0.1.2 → 0.1.5

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,34 +1,25 @@
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 {apiURL}`) is used instead, mirroring `@deepseek-ai/dsh-web-search-exa`.
10
- *
11
- * The anonymous-MCP strategy and its response parsing follow the `web_search`
12
- * implementation in can1357/oh-my-pi (see README acknowledgements).
13
- *
14
- * This is an implementation package: it registers a provider INTO `ctx.web`
15
- * (`inject: ['web']`) and owns no model-facing tools (those belong to
16
- * `@deepseek-ai/dsh-tool-web`). It also installs a Settings section
17
- * (`web-search-exa`) into the settings service; editing it from the Web UI
18
- * needs a client card (planned for a later version) — today it is configured
19
- * through the profile patch layer (see README "In the Web panel").
20
- */
21
-
22
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
23
- import { WebError } from "@deepseek-ai/dsh-web";
1
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
24
2
  import z from "@deepseek-ai/schemastery";
25
-
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
+ */
26
15
  /** Default provider id this provider registers under (`ctx.web` registry key). */
27
16
  const DEFAULT_PROVIDER_ID = "exa";
28
17
  /** Backward-compatible alias for the default provider id. */
29
- const PROVIDER_ID = DEFAULT_PROVIDER_ID;
18
+ const PROVIDER_ID = "exa";
30
19
  /** Exa REST search endpoint; used only when an API key is configured. */
31
- const DEFAULT_API_URL = "https://api.exa.ai/search";
20
+ const DEFAULT_BASE_URL = "https://api.exa.ai";
21
+ /** Legacy full REST endpoint; `baseURL` is the canonical dsh-compatible option. */
22
+ const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/search`;
32
23
  /** Exa hosted MCP endpoint; the anonymous fallback path. */
33
24
  const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp";
34
25
  /** Environment variable consulted when no literal `apiKey` is configured. */
@@ -39,76 +30,95 @@ const DEFAULT_SEARCH_TYPE = "auto";
39
30
  const DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
40
31
  /** MCP tool name for plain web search on Exa's hosted server. */
41
32
  const MCP_TOOL = "web_search_exa";
42
- /** Attribution header sent on anonymous MCP requests. Bump with the version. */
33
+ /**
34
+ * Attribution header sent on anonymous MCP requests. This is the only signal
35
+ * Exa's public endpoint receives about the caller, so it is deliberately a
36
+ * product-level name rather than a per-install identifier.
37
+ */
43
38
  const MCP_SOURCE = "dsh-anything";
44
- /** User agent for REST requests. */
45
- const USER_AGENT = "deepseek-harness-exa/0.1.0";
39
+ /**
40
+ * User agent for REST requests.
41
+ *
42
+ * Annotated `: string` rather than left inferred: this constant is re-exported
43
+ * from the package root, and a `const` string infers a LITERAL type, which
44
+ * would bake today's version number into every consumer's type-checking.
45
+ */
46
+ const USER_AGENT = "deepseek-harness-exa/0.1.5";
46
47
  /** Snippet cap for text-derived snippets (matching oh-my-pi's choice). */
47
48
  const MAX_SNIPPET_CHARS = 500;
48
49
  /** Settings namespace carrying this provider's configuration. */
49
- const SETTINGS_NAMESPACE = settingsNamespace("web-search-exa");
50
-
50
+ const SETTINGS_NAMESPACE = "web-search-exa";
51
+ //#endregion
52
+ //#region src/provider.ts
53
+ /**
54
+ * The Exa-backed `WebSearchProvider`: REST with an API key, anonymous MCP
55
+ * without one.
56
+ *
57
+ * The provider owns no model-facing tool — `@deepseek-ai/dsh-tool-web` renders
58
+ * whatever this returns through the `ctx.web` seam. It also never invents a
59
+ * snippet: a result without a real highlight (REST) or a real highlight/text
60
+ * body (MCP) is dropped, because a fabricated snippet would make the seam lie.
61
+ *
62
+ * Anonymous-MCP request shape and response parsing follow the `web_search`
63
+ * implementation in can1357/oh-my-pi (see README acknowledgements).
64
+ *
65
+ * @module @tonydua/dsh-web-search-exa/provider
66
+ */
51
67
  /** True for a positive whole number (cheap local config check). */
52
68
  function isPositiveInteger(value) {
53
69
  return Number.isInteger(value) && value > 0;
54
70
  }
55
-
56
71
  /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
57
72
  function isAbortError(error) {
58
73
  return error instanceof DOMException && error.name === "AbortError";
59
74
  }
60
-
61
75
  /** Throw the seam's stable cancellation error when the caller is already aborted. */
62
76
  function throwIfAborted(signal) {
63
- if (signal?.aborted === true) {
64
- throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal.reason });
65
- }
77
+ if (signal?.aborted === true) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal.reason });
66
78
  }
67
-
68
79
  /**
69
- * Resolve the API key: literal config first, then the environment variable.
70
- * `undefined` means the anonymous MCP path is used.
71
- */
72
- function resolveApiKey(options) {
80
+ * Resolve the API key: literal config first, then the environment variable.
81
+ * `undefined` means the anonymous MCP path is used.
82
+ */
83
+ function resolveApiKeyFromProcess(options) {
73
84
  if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
74
85
  const fromEnv = process.env[options.apiKeyEnv];
75
86
  if (fromEnv != null && fromEnv.length > 0) return fromEnv;
76
- return undefined;
77
87
  }
78
-
79
- // ── REST path (with API key) ────────────────────────────────────────────────
80
-
81
88
  /**
82
- * Map one Exa REST result to a normalized source, or `undefined` when it has
83
- * no portable snippet (same rule as the official provider).
84
- */
89
+ * Resolve a key against dsh's immutable launch-environment snapshot. The
90
+ * process fallback keeps direct library use and older hosts working.
91
+ */
92
+ function resolveApiKey(options, environment) {
93
+ if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
94
+ const fromEnvironment = environment?.get(options.apiKeyEnv)?.value;
95
+ if (fromEnvironment != null && fromEnvironment.length > 0) return fromEnvironment;
96
+ return resolveApiKeyFromProcess(options);
97
+ }
98
+ /**
99
+ * Map one Exa REST result to a normalized source, or `undefined` when it has
100
+ * no portable snippet (same rule as the official provider).
101
+ */
85
102
  function mapRestResult(result) {
86
103
  const snippet = result.highlights?.find((highlight) => highlight.trim().length > 0);
87
- if (snippet === undefined) return undefined;
104
+ if (snippet === void 0) return void 0;
88
105
  return {
89
106
  url: result.url,
90
107
  ...result.title != null && result.title.length > 0 ? { title: result.title } : {},
91
108
  snippet,
92
- ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
109
+ ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}
93
110
  };
94
111
  }
95
-
96
- // ── Anonymous MCP path (no API key) ─────────────────────────────────────────
97
-
98
112
  /**
99
- * Parse an SSE (`text/event-stream`) response body into its first `data:`
100
- * payload, falling back to plain JSON. Returns `null` when neither parses.
101
- */
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
+ */
102
116
  function parseSsePayload(text) {
103
- const dataLines = text.split(/\r?\n/)
104
- .filter((line) => line.startsWith("data:"))
105
- .map((line) => line.slice(5).replace(/^\s/, ""));
106
- if (dataLines.length > 0) {
107
- try {
108
- return JSON.parse(dataLines.join("\n"));
109
- } catch {
110
- return null;
111
- }
117
+ const dataLines = text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^\s/, ""));
118
+ if (dataLines.length > 0) try {
119
+ return JSON.parse(dataLines.join("\n"));
120
+ } catch {
121
+ return null;
112
122
  }
113
123
  try {
114
124
  return JSON.parse(text);
@@ -116,23 +126,19 @@ function parseSsePayload(text) {
116
126
  return null;
117
127
  }
118
128
  }
119
-
120
129
  /**
121
- * Collect non-blank `content[].text` blocks from a normalized MCP result
122
- * payload, joined with blank lines.
123
- */
130
+ * Collect non-blank `content[].text` blocks from a normalized MCP result
131
+ * payload, joined with blank lines.
132
+ */
124
133
  function collectMcpText(payload) {
125
134
  const content = payload?.result?.content;
126
135
  if (!Array.isArray(content)) return [];
127
- return content
128
- .map((item) => (typeof item?.text === "string" ? item.text.replace(/\r\n?/g, "\n").trim() : ""))
129
- .filter((text) => text.length > 0);
136
+ return content.map((item) => typeof item?.text === "string" ? item.text.replace(/\r\n?/g, "\n").trim() : "").filter((text) => text.length > 0);
130
137
  }
131
-
132
138
  /**
133
- * Parse one `Title:`-led section of Exa MCP text output into a partial source.
134
- * Handles both `Published:` and `Published Date:` field spellings.
135
- */
139
+ * Parse one `Title:`-led section of Exa MCP text output into a partial source.
140
+ * Handles both `Published:` and `Published Date:` field spellings.
141
+ */
136
142
  function parseExaSection(section) {
137
143
  const out = {};
138
144
  let field = null;
@@ -154,9 +160,8 @@ function parseExaSection(section) {
154
160
  } else if (author) {
155
161
  out.author = author[1].trim();
156
162
  field = null;
157
- } else if (/^Highlights:\s*$/.test(line)) {
158
- field = "highlights";
159
- } else if (/^Text:\s*$/.test(line)) {
163
+ } else if (/^Highlights:\s*$/.test(line)) field = "highlights";
164
+ else if (/^Text:\s*$/.test(line)) {
160
165
  field = "text";
161
166
  textLines = [];
162
167
  } else if (field === "highlights") {
@@ -165,115 +170,225 @@ function parseExaSection(section) {
165
170
  out.highlights ??= [];
166
171
  out.highlights.push(trimmed.replace(/^[-•]\s*/, ""));
167
172
  }
168
- } else if (field === "text" && textLines !== null) {
169
- textLines.push(line);
170
- }
173
+ } else if (field === "text" && textLines !== null) textLines.push(line);
171
174
  }
172
175
  if (textLines !== null) out.text = textLines.join("\n").trim();
173
176
  if (out.publishedAt === "N/A") delete out.publishedAt;
174
177
  if (out.author === "N/A") delete out.author;
175
178
  return out;
176
179
  }
177
-
178
180
  /** Split joined MCP text into per-result sections, each starting with `Title:`. */
179
181
  function splitExaSections(joined) {
180
- return joined
181
- .split(/\n{2,}(?=Title:\s*)/)
182
- .map((section) => section.trim())
183
- .filter((section) => section.length > 0 && section.startsWith("Title:"));
182
+ return joined.split(/\n{2,}(?=Title:\s*)/).map((section) => section.trim()).filter((section) => section.length > 0 && section.startsWith("Title:"));
184
183
  }
185
-
186
184
  /** Map parsed Exa MCP sections to normalized sources (snippet-less entries dropped). */
187
185
  function mapMcpSections(sections) {
188
186
  const sources = [];
189
187
  for (const section of sections) {
190
188
  const parsed = parseExaSection(section);
191
189
  if (!parsed.url || parsed.url.length === 0) continue;
192
- const highlight = parsed.highlights?.find((item) => item.trim().length > 0);
193
- const snippet = highlight ?? (parsed.text ? parsed.text.slice(0, MAX_SNIPPET_CHARS) : undefined);
194
- if (snippet === undefined) continue;
190
+ const snippet = parsed.highlights?.find((item) => item.trim().length > 0) ?? (parsed.text ? parsed.text.slice(0, 500) : void 0);
191
+ if (snippet === void 0) continue;
195
192
  sources.push({
196
193
  url: parsed.url,
197
194
  ...parsed.title != null && parsed.title.length > 0 ? { title: parsed.title } : {},
198
195
  snippet,
199
- ...parsed.publishedAt != null && parsed.publishedAt.length > 0 ? { publishedAt: parsed.publishedAt } : {},
196
+ ...parsed.publishedAt != null && parsed.publishedAt.length > 0 ? { publishedAt: parsed.publishedAt } : {}
200
197
  });
201
198
  }
202
199
  return sources;
203
200
  }
204
-
205
- // ── Provider ────────────────────────────────────────────────────────────────
206
-
207
201
  /**
208
- * Project one resolved configuration section into the options the provider
209
- * serves its next search with. Called per operation so live Settings edits
210
- * take effect on the next search.
211
- */
202
+ * How long a tripped breaker keeps `available()` false before the next search
203
+ * is allowed to probe the anonymous endpoint again.
204
+ */
205
+ const DEFAULT_BREAKER_COOLDOWN_MS = 3e5;
206
+ /**
207
+ * Consecutive transient failures that trip the breaker.
208
+ *
209
+ * Only 5xx, 429, and network-level failures count: they say the anonymous
210
+ * endpoint is having a bad time, not that the request was wrong. A 4xx (other
211
+ * than 429) is a configuration error and would fail identically forever, so it
212
+ * deliberately does NOT trip the breaker — hiding a bad endpoint behind a
213
+ * cooldown would just delay the same error.
214
+ */
215
+ const DEFAULT_BREAKER_THRESHOLD = 3;
216
+ /** A failure worth retrying later, as opposed to a permanent configuration error. */
217
+ var ExaTransientError = class extends WebError {
218
+ constructor(message, cause) {
219
+ super(message, "WEB_PROVIDER_ERROR", cause === void 0 ? void 0 : { cause });
220
+ this.name = "ExaTransientError";
221
+ }
222
+ };
223
+ /**
224
+ * A rate limit, kept distinct from a generic provider failure so the model (and
225
+ * a human reading the transcript) can tell "Exa is throttling the keyless
226
+ * channel, configure a key" apart from "the network is broken".
227
+ *
228
+ * `code` is an open string in the seam's vocabulary, so a plugin-specific code
229
+ * is the supported way to route this; consumers must tolerate unknown codes.
230
+ */
231
+ var ExaRateLimitError = class extends WebError {
232
+ constructor(message) {
233
+ super(message, "WEB_RATE_LIMITED");
234
+ this.name = "ExaRateLimitError";
235
+ }
236
+ };
237
+ /**
238
+ * Consecutive-transient-failure breaker for the keyless path.
239
+ *
240
+ * The public MCP endpoint is best-effort: when it is throttling or down, every
241
+ * search would otherwise fail. Reporting that state through {@link
242
+ * ExaSearchProvider.available} is what lets a deployment recover — the seam
243
+ * skips an unavailable provider, so an unconfigured profile falls back to
244
+ * another registered provider instead of surfacing a hard error.
245
+ *
246
+ * State is per provider instance (one per plugin mount) and never persisted:
247
+ * a restart is a fresh chance, and one successful search resets the count.
248
+ */
249
+ var ExaAvailabilityBreaker = class {
250
+ #threshold;
251
+ #cooldownMs;
252
+ #consecutiveFailures = 0;
253
+ #openedAt;
254
+ constructor(threshold = 3, cooldownMs = DEFAULT_BREAKER_COOLDOWN_MS) {
255
+ this.#threshold = threshold;
256
+ this.#cooldownMs = cooldownMs;
257
+ }
258
+ /** True while the breaker is open and the cooldown has not elapsed. */
259
+ get blocked() {
260
+ if (this.#openedAt === void 0) return false;
261
+ return Date.now() - this.#openedAt < this.#cooldownMs;
262
+ }
263
+ /** Record one successful operation: the endpoint is healthy again. */
264
+ succeeded() {
265
+ this.#consecutiveFailures = 0;
266
+ this.#openedAt = void 0;
267
+ }
268
+ /** Record one transient failure, opening the breaker at the threshold. */
269
+ failed() {
270
+ this.#consecutiveFailures += 1;
271
+ if (this.#consecutiveFailures >= this.#threshold) this.#openedAt = Date.now();
272
+ }
273
+ };
274
+ /**
275
+ * True for an HTTP status that will keep failing until the endpoint recovers.
276
+ * 429 is included: the keyless channel is throttled, not misconfigured.
277
+ */
278
+ function isTransientStatus(status) {
279
+ return status === 429 || status >= 500;
280
+ }
281
+ /**
282
+ * Project one resolved configuration section into the options the provider
283
+ * serves its next search with. Called per operation so live Settings edits
284
+ * take effect on the next search.
285
+ */
212
286
  function resolveOptions(section) {
287
+ const baseURL = section.baseURL ?? "https://api.exa.ai";
213
288
  return {
214
- providerId: section.providerId ?? DEFAULT_PROVIDER_ID,
289
+ providerId: section.providerId ?? "exa",
215
290
  apiKey: section.apiKey ?? "",
216
- apiKeyEnv: section.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
217
- apiURL: section.apiURL ?? DEFAULT_API_URL,
218
- mcpURL: section.mcpURL ?? DEFAULT_MCP_URL,
219
- searchType: section.searchType ?? DEFAULT_SEARCH_TYPE,
291
+ apiKeyEnv: section.apiKeyEnv ?? "EXA_API_KEY",
292
+ baseURL,
293
+ apiURL: section.apiURL ?? `${baseURL.replace(/\/+$/, "")}/search`,
294
+ mcpURL: section.mcpURL ?? "https://mcp.exa.ai/mcp",
295
+ searchType: section.searchType ?? "auto",
220
296
  numResults: section.numResults,
221
- highlightsPerResult: section.highlightsPerResult ?? DEFAULT_HIGHLIGHTS_PER_RESULT,
297
+ highlightsPerResult: section.highlightsPerResult ?? 1
222
298
  };
223
299
  }
224
-
225
- class ExaSearchProvider {
226
- resolveOptions;
227
- id;
228
-
300
+ /** Resolve the keyed REST endpoint from either the current or legacy option shape. */
301
+ function resolveSearchURL(options) {
302
+ if (options.apiURL != null) return options.apiURL;
303
+ return `${(options.baseURL ?? "https://api.exa.ai").replace(/\/+$/, "")}/search`;
304
+ }
305
+ /**
306
+ * Exa-backed search with an anonymous fallback.
307
+ *
308
+ * Path selection is per search, not per install: a key appearing later (for
309
+ * example after a Settings edit) upgrades the next search to REST without a
310
+ * restart, and removing it falls back to the anonymous endpoint.
311
+ */
312
+ var ExaSearchProvider = class {
313
+ #resolveOptions;
314
+ #resolveApiKey;
315
+ #breaker;
316
+ /**
317
+ * @param resolveOptions - thunk returning the options for the NEXT
318
+ * operation, snapshotted once at each operation's entry so one search
319
+ * never mixes two settings sections (same pattern as the official
320
+ * DeepSeek provider).
321
+ * @param resolveApiKey - optional key resolver; dsh hosts pass their
322
+ * launch-environment snapshot while direct users retain process.env fallback.
323
+ * @param breaker - health tracker for the keyless path; injectable so tests
324
+ * need no clock control.
325
+ */
326
+ constructor(resolveOptions, resolveApiKey = resolveApiKeyFromProcess, breaker = new ExaAvailabilityBreaker()) {
327
+ this.#resolveOptions = resolveOptions;
328
+ this.#resolveApiKey = resolveApiKey;
329
+ this.#breaker = breaker;
330
+ }
229
331
  /**
230
- * @param resolveOptions - thunk returning the options for the NEXT
231
- * operation, snapshotted once at each operation's entry so one search
232
- * never mixes two settings sections (same pattern as the official
233
- * DeepSeek provider).
234
- */
235
- constructor(resolveOptions) {
236
- this.resolveOptions = resolveOptions;
237
- this.id = resolveOptions().providerId ?? PROVIDER_ID;
332
+ * Read per operation rather than frozen at construction: a Settings edit to
333
+ * `providerId` must not leave the provider reporting an id the registry does
334
+ * not key it under. Registering under the new id is the user's job (the
335
+ * loader re-reads config on reload), but the reported value stays honest.
336
+ */
337
+ get id() {
338
+ return this.#resolveOptions().providerId ?? "exa";
238
339
  }
239
-
240
- /** The anonymous MCP path needs no credentials, so the provider is always usable. */
340
+ /**
341
+ * Cheap local usability check — no network call, per the seam contract.
342
+ *
343
+ * False when the local options are unusable, or while the keyless channel's
344
+ * breaker is open after repeated transient failures. Reporting that honestly
345
+ * is what lets the seam fall back to another provider instead of failing the
346
+ * search: a pinned `searchProvider` surfaces
347
+ * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, an unpinned one simply selects
348
+ * another registered provider.
349
+ */
241
350
  available() {
242
- const options = this.resolveOptions();
243
- return URL.canParse(options.apiURL) && URL.canParse(options.mcpURL);
351
+ if (this.#breaker.blocked) return false;
352
+ const options = this.#resolveOptions();
353
+ return URL.canParse(resolveSearchURL(options)) && URL.canParse(options.mcpURL) && isPositiveInteger(options.highlightsPerResult) && (options.numResults === void 0 || isPositiveInteger(options.numResults));
244
354
  }
245
-
246
355
  async search(request, signal) {
247
356
  throwIfAborted(signal);
248
- const options = this.resolveOptions();
249
- const apiKey = resolveApiKey(options);
250
- return apiKey !== undefined
251
- ? await this.#restSearch(request, apiKey, options, signal)
252
- : await this.#anonymousMcpSearch(request, options, signal);
357
+ const options = this.#resolveOptions();
358
+ const apiKey = this.#resolveApiKey(options);
359
+ if (apiKey !== void 0) return await this.#restSearch(request, apiKey, options, signal);
360
+ try {
361
+ const result = await this.#anonymousMcpSearch(request, options, signal);
362
+ this.#breaker.succeeded();
363
+ return result;
364
+ } catch (error) {
365
+ if (error instanceof ExaTransientError) this.#breaker.failed();
366
+ throw error;
367
+ }
253
368
  }
254
-
255
369
  /** REST search with an API key: `POST {apiURL}` with Bearer auth. */
256
370
  async #restSearch(request, apiKey, options, signal) {
257
371
  throwIfAborted(signal);
258
372
  const numResults = request.maxResults ?? options.numResults;
373
+ const apiURL = resolveSearchURL(options);
259
374
  let response;
260
375
  try {
261
- response = await fetch(options.apiURL, {
376
+ response = await fetch(apiURL, {
262
377
  method: "POST",
263
378
  redirect: "error",
264
379
  headers: {
265
- "authorization": `Bearer ${apiKey}`,
380
+ authorization: `Bearer ${apiKey}`,
266
381
  "content-type": "application/json",
267
- "accept": "application/json",
268
- "user-agent": USER_AGENT,
382
+ accept: "application/json",
383
+ "user-agent": USER_AGENT
269
384
  },
270
385
  body: JSON.stringify({
271
386
  query: request.query,
272
387
  type: options.searchType,
273
388
  contents: { highlights: { highlightsPerUrl: options.highlightsPerResult } },
274
- ...numResults !== undefined ? { numResults } : {},
389
+ ...numResults !== void 0 ? { numResults } : {}
275
390
  }),
276
- ...signal !== undefined ? { signal } : {},
391
+ ...signal !== void 0 ? { signal } : {}
277
392
  });
278
393
  } catch (error) {
279
394
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
@@ -284,10 +399,9 @@ class ExaSearchProvider {
284
399
  try {
285
400
  const parsed = await response.json();
286
401
  const detail = parsed.error ?? parsed.message;
287
- if (detail !== undefined && detail.length > 0) message = detail;
402
+ if (detail !== void 0 && detail.length > 0) message = detail;
288
403
  } catch (error) {
289
404
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
290
- // keep the generic message
291
405
  }
292
406
  throw new WebError(message, "WEB_PROVIDER_ERROR");
293
407
  }
@@ -298,20 +412,21 @@ class ExaSearchProvider {
298
412
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
299
413
  throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
300
414
  }
301
- const sources = (parsed.results ?? []).map(mapRestResult).filter((source) => source !== undefined);
302
- return { sources, truncated: false };
415
+ return {
416
+ sources: (parsed.results ?? []).map(mapRestResult).filter((source) => source !== void 0),
417
+ truncated: false
418
+ };
303
419
  }
304
-
305
420
  /**
306
- * Anonymous search through Exa's hosted MCP server. No credentials are
307
- * sent; the `x-exa-source` header carries attribution. Rate-limited by Exa
308
- * (HTTP 429) — configuring an API key lifts the limit via the REST path.
309
- */
421
+ * Anonymous search through Exa's hosted MCP server. No credentials are
422
+ * sent; the `x-exa-source` header carries attribution. Rate-limited by Exa
423
+ * (HTTP 429) — configuring an API key lifts the limit via the REST path.
424
+ */
310
425
  async #anonymousMcpSearch(request, options, signal) {
311
426
  throwIfAborted(signal);
312
427
  const args = { query: request.query };
313
428
  const numResults = request.maxResults ?? options.numResults;
314
- if (numResults !== undefined) args.numResults = numResults;
429
+ if (numResults !== void 0) args.numResults = numResults;
315
430
  let response;
316
431
  try {
317
432
  response = await fetch(options.mcpURL, {
@@ -319,28 +434,27 @@ class ExaSearchProvider {
319
434
  redirect: "error",
320
435
  headers: {
321
436
  "content-type": "application/json",
322
- "accept": "application/json, text/event-stream",
323
- "x-exa-source": MCP_SOURCE,
437
+ accept: "application/json, text/event-stream",
438
+ "x-exa-source": MCP_SOURCE
324
439
  },
325
440
  body: JSON.stringify({
326
441
  jsonrpc: "2.0",
327
442
  id: Math.random().toString(36).slice(2),
328
443
  method: "tools/call",
329
- params: { name: MCP_TOOL, arguments: args },
444
+ params: {
445
+ name: MCP_TOOL,
446
+ arguments: args
447
+ }
330
448
  }),
331
- ...signal !== undefined ? { signal } : {},
449
+ ...signal !== void 0 ? { signal } : {}
332
450
  });
333
451
  } catch (error) {
334
452
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
335
- throw new WebError(`Exa anonymous search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
453
+ throw new ExaTransientError(`Exa anonymous search request failed: ${String(error)}`, error);
336
454
  }
337
455
  if (!response.ok) {
338
- if (response.status === 429) {
339
- throw new WebError(
340
- "Exa anonymous MCP rate limit reached (HTTP 429); configure an EXA_API_KEY for higher limits",
341
- "WEB_PROVIDER_ERROR",
342
- );
343
- }
456
+ 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.");
457
+ if (isTransientStatus(response.status)) throw new ExaTransientError(`Exa anonymous MCP error (HTTP ${response.status})`);
344
458
  throw new WebError(`Exa anonymous MCP error (HTTP ${response.status})`, "WEB_PROVIDER_ERROR");
345
459
  }
346
460
  let payload;
@@ -348,84 +462,99 @@ class ExaSearchProvider {
348
462
  payload = parseSsePayload(await response.text());
349
463
  } catch (error) {
350
464
  if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
351
- throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
352
- }
353
- if (payload === null) {
354
- throw new WebError("Exa anonymous MCP returned an unprocessable response body", "WEB_PROVIDER_ERROR");
355
- }
356
- if (payload.error != null) {
357
- throw new WebError(`Exa MCP error: ${String(payload.error.message ?? JSON.stringify(payload.error))}`, "WEB_PROVIDER_ERROR");
465
+ throw new ExaTransientError(`Exa returned an unprocessable response body: ${String(error)}`, error);
358
466
  }
467
+ if (payload === null) throw new ExaTransientError("Exa anonymous MCP returned an unprocessable response body");
468
+ if (payload.error != null) throw new WebError(`Exa MCP error: ${String(payload.error.message ?? JSON.stringify(payload.error))}`, "WEB_PROVIDER_ERROR");
359
469
  if (payload.result?.isError === true) {
360
470
  const detail = collectMcpText(payload).join("\n").trim();
361
471
  throw new WebError(`Exa MCP tool error${detail.length > 0 ? `: ${detail}` : ""}`, "WEB_PROVIDER_ERROR");
362
472
  }
363
- const sections = splitExaSections(collectMcpText(payload).join("\n\n"));
364
- const sources = mapMcpSections(sections);
365
- return { sources, truncated: false };
473
+ return {
474
+ sources: mapMcpSections(splitExaSections(collectMcpText(payload).join("\n\n"))),
475
+ truncated: false
476
+ };
366
477
  }
367
- }
368
-
369
- // ── Cordis plugin wiring ────────────────────────────────────────────────────
370
-
371
- /** Cordis plugin name used by loader diagnostics. */
372
- const name = "web-search-exa";
373
- /** The web seam this provider registers into. */
374
- const inject = ["web"];
375
-
478
+ };
479
+ //#endregion
480
+ //#region src/index.ts
376
481
  const Config = z.object({
377
482
  /**
378
- * Provider id registered into `ctx.web`. Defaults to `exa` (same as the
379
- * official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
380
- * packages are installed in one profile — the seam rejects duplicate ids
381
- * with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
382
- * distinct id here (e.g. `exa-anon`) and select it explicitly with
383
- * `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
384
- */
385
- providerId: z.string().default(DEFAULT_PROVIDER_ID),
483
+ * Provider id registered into `ctx.web`. Defaults to `exa` (same as the
484
+ * official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
485
+ * packages are installed in one profile — the seam rejects duplicate ids
486
+ * with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
487
+ * distinct id here (e.g. `exa-anon`) and select it explicitly with
488
+ * `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
489
+ */
490
+ providerId: z.string().default("exa"),
386
491
  /** Literal Exa API key; an empty/missing value enables the anonymous MCP path. */
387
492
  apiKey: z.string().role("secret"),
388
493
  /** Environment variable consulted when no literal `apiKey` is configured. */
389
494
  apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
390
- /** REST search endpoint, used only when an API key is available. */
391
- apiURL: z.string().default(DEFAULT_API_URL),
495
+ /** Exa API base URL; `/search` is appended for the keyed REST path. */
496
+ baseURL: z.string().default(DEFAULT_BASE_URL),
497
+ /**
498
+ * Legacy full REST endpoint. When set, it takes precedence over `baseURL`;
499
+ * new configurations should use `baseURL` to match the official provider.
500
+ */
501
+ apiURL: z.string(),
392
502
  /** Exa hosted MCP endpoint, used by the anonymous fallback. */
393
503
  mcpURL: z.string().default(DEFAULT_MCP_URL),
394
504
  /** REST retrieval mode: `auto`, `keyword`, or `neural`. */
395
- searchType: z.union(["auto", "keyword", "neural"]).default(DEFAULT_SEARCH_TYPE),
505
+ searchType: z.union([
506
+ "auto",
507
+ "keyword",
508
+ "neural"
509
+ ]).default(DEFAULT_SEARCH_TYPE),
396
510
  /** Default result count when the request carries no `maxResults`. */
397
511
  numResults: z.number().step(1).min(1),
398
512
  /** Highlight sentences requested per result on the REST path. */
399
- highlightsPerResult: z.number().step(1).min(1).default(DEFAULT_HIGHLIGHTS_PER_RESULT),
513
+ highlightsPerResult: z.number().step(1).min(1).default(1)
400
514
  });
401
-
515
+ /** Cordis plugin name used by loader diagnostics. */
516
+ const name = "web-search-exa";
517
+ /** The web seam this provider registers into. */
518
+ const inject = ["web"];
402
519
  /**
403
- * Register the Exa search provider with `ctx.web` and install its Settings
404
- * section, so the Web panel edits the same config the provider serves.
405
- */
520
+ * Wire the settings service when the host exposes the pre-0.1.7 registration
521
+ * API; do nothing (rather than throw) on hosts that do not.
522
+ *
523
+ * @param settings - the mounted settings service, of either generation.
524
+ * @param owner - the consuming context `installSection` attributes the section to.
525
+ * @param config - the composition entry, used as the section's base value.
526
+ * @param adopt - receives the authoritative section thunk so later searches read
527
+ * live edits instead of the boot-time config.
528
+ * @returns true when a section was installed.
529
+ */
530
+ function installSettingsSection(settings, owner, config, adopt) {
531
+ if (typeof settings.installSection !== "function") return false;
532
+ settings.installSection(owner, SETTINGS_NAMESPACE, Config, config, {
533
+ setSource: adopt,
534
+ onChange: () => {}
535
+ });
536
+ return true;
537
+ }
538
+ /**
539
+ * Register the Exa search provider with `ctx.web` and, when the settings
540
+ * service is mounted *and* exposes the pre-0.1.7 registration API, install its
541
+ * Settings section.
542
+ *
543
+ * The settings work is deliberately inside `ctx.inject`, so a profile that
544
+ * omits `dsh-settings` still mounts the provider — keyless search must not
545
+ * depend on the Settings UI being present. The provider registration happens
546
+ * outside it for the same reason, and is never conditional on the settings
547
+ * generation.
548
+ */
406
549
  function apply(ctx, config) {
407
550
  let current = () => config;
408
- installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
409
- setSource: (source) => {
551
+ ctx.inject(["settings"], (settingsCtx) => {
552
+ installSettingsSection(settingsCtx.settings, ctx, config, (source) => {
410
553
  current = source;
411
- },
412
- onChange: () => {},
554
+ });
413
555
  });
414
- ctx.web.registerSearchProvider(new ExaSearchProvider(() => resolveOptions(current())));
556
+ const environment = launchEnvironmentOf(ctx);
557
+ ctx.web.registerSearchProvider(new ExaSearchProvider(() => resolveOptions(current()), (options) => resolveApiKey(options, environment)));
415
558
  }
416
-
417
- export {
418
- Config,
419
- DEFAULT_API_KEY_ENV,
420
- DEFAULT_API_URL,
421
- DEFAULT_HIGHLIGHTS_PER_RESULT,
422
- DEFAULT_MCP_URL,
423
- DEFAULT_PROVIDER_ID,
424
- DEFAULT_SEARCH_TYPE,
425
- PROVIDER_ID,
426
- SETTINGS_NAMESPACE,
427
- ExaSearchProvider,
428
- apply,
429
- inject,
430
- name,
431
- };
559
+ //#endregion
560
+ 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_URL, DEFAULT_PROVIDER_ID, DEFAULT_SEARCH_TYPE, ExaAvailabilityBreaker, ExaRateLimitError, ExaSearchProvider, ExaTransientError, MAX_SNIPPET_CHARS, MCP_SOURCE, MCP_TOOL, PROVIDER_ID, SETTINGS_NAMESPACE, USER_AGENT, apply, inject, installSettingsSection, name, resolveApiKey, resolveApiKeyFromProcess, resolveOptions };