@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/README.i18n.yaml +2 -2
- package/README.md +246 -194
- package/README.zh.md +237 -104
- package/lib/index.d.ts +437 -0
- package/lib/index.js +509 -236
- package/package.json +33 -12
- package/lib/types/index.d.ts +0 -70
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 =
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 ===
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 ??
|
|
454
|
+
const baseURL = section.baseURL ?? "https://api.exa.ai";
|
|
228
455
|
return {
|
|
229
|
-
providerId: section.providerId ??
|
|
456
|
+
providerId: section.providerId ?? "exa",
|
|
230
457
|
apiKey: section.apiKey ?? "",
|
|
231
|
-
apiKeyEnv: section.apiKeyEnv ??
|
|
458
|
+
apiKeyEnv: section.apiKeyEnv ?? "EXA_API_KEY",
|
|
232
459
|
baseURL,
|
|
233
460
|
apiURL: section.apiURL ?? `${baseURL.replace(/\/+$/, "")}/search`,
|
|
234
|
-
mcpURL: section.mcpURL ??
|
|
235
|
-
|
|
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 ??
|
|
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
|
-
|
|
245
|
-
return `${baseURL.replace(/\/+$/, "")}/search`;
|
|
471
|
+
return `${(options.baseURL ?? "https://api.exa.ai").replace(/\/+$/, "")}/search`;
|
|
246
472
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
279
|
-
const apiKey = this
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
548
|
+
authorization: `Bearer ${apiKey}`,
|
|
297
549
|
"content-type": "application/json",
|
|
298
|
-
|
|
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 !==
|
|
557
|
+
...numResults !== void 0 ? { numResults } : {}
|
|
306
558
|
}),
|
|
307
|
-
...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 !==
|
|
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
|
-
|
|
333
|
-
|
|
583
|
+
return {
|
|
584
|
+
sources: (parsed.results ?? []).map(mapRestResult).filter((source) => source !== void 0),
|
|
585
|
+
truncated: false
|
|
586
|
+
};
|
|
334
587
|
}
|
|
335
|
-
|
|
336
588
|
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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 !==
|
|
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
|
-
|
|
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: {
|
|
618
|
+
params: {
|
|
619
|
+
name: tool,
|
|
620
|
+
arguments: args
|
|
621
|
+
}
|
|
361
622
|
}),
|
|
362
|
-
...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
|
|
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
|
-
|
|
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
|
|
634
|
+
let text;
|
|
378
635
|
try {
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
providerId: z.string().default(
|
|
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
|
-
|
|
425
|
-
|
|
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([
|
|
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(
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
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 };
|