@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/README.i18n.yaml +2 -2
- package/README.md +175 -14
- package/README.zh.md +146 -10
- package/cordis.patch.yml +9 -10
- package/lib/index.d.ts +386 -0
- package/lib/index.js +341 -212
- package/package.json +28 -12
- package/lib/types/index.d.ts +0 -54
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 =
|
|
18
|
+
const PROVIDER_ID = "exa";
|
|
30
19
|
/** Exa REST search endpoint; used only when an API key is configured. */
|
|
31
|
-
const
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
function
|
|
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
|
-
|
|
83
|
-
|
|
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 ===
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 ??
|
|
289
|
+
providerId: section.providerId ?? "exa",
|
|
215
290
|
apiKey: section.apiKey ?? "",
|
|
216
|
-
apiKeyEnv: section.apiKeyEnv ??
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 ??
|
|
297
|
+
highlightsPerResult: section.highlightsPerResult ?? 1
|
|
222
298
|
};
|
|
223
299
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
249
|
-
const apiKey = resolveApiKey(options);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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(
|
|
376
|
+
response = await fetch(apiURL, {
|
|
262
377
|
method: "POST",
|
|
263
378
|
redirect: "error",
|
|
264
379
|
headers: {
|
|
265
|
-
|
|
380
|
+
authorization: `Bearer ${apiKey}`,
|
|
266
381
|
"content-type": "application/json",
|
|
267
|
-
|
|
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 !==
|
|
389
|
+
...numResults !== void 0 ? { numResults } : {}
|
|
275
390
|
}),
|
|
276
|
-
...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 !==
|
|
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
|
-
|
|
302
|
-
|
|
415
|
+
return {
|
|
416
|
+
sources: (parsed.results ?? []).map(mapRestResult).filter((source) => source !== void 0),
|
|
417
|
+
truncated: false
|
|
418
|
+
};
|
|
303
419
|
}
|
|
304
|
-
|
|
305
420
|
/**
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 !==
|
|
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
|
-
|
|
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: {
|
|
444
|
+
params: {
|
|
445
|
+
name: MCP_TOOL,
|
|
446
|
+
arguments: args
|
|
447
|
+
}
|
|
330
448
|
}),
|
|
331
|
-
...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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
473
|
+
return {
|
|
474
|
+
sources: mapMcpSections(splitExaSections(collectMcpText(payload).join("\n\n"))),
|
|
475
|
+
truncated: false
|
|
476
|
+
};
|
|
366
477
|
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
providerId: z.string().default(
|
|
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
|
-
/**
|
|
391
|
-
|
|
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([
|
|
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(
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
551
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
552
|
+
installSettingsSection(settingsCtx.settings, ctx, config, (source) => {
|
|
410
553
|
current = source;
|
|
411
|
-
}
|
|
412
|
-
onChange: () => {},
|
|
554
|
+
});
|
|
413
555
|
});
|
|
414
|
-
|
|
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 };
|