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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts ADDED
@@ -0,0 +1,437 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { WebError, WebSearchProvider, WebSearchRequest, WebSearchResult } from "@deepseek-ai/dsh-web";
3
+ import { Context } from "@deepseek-ai/cordis";
4
+ //#region src/types.d.ts
5
+ /**
6
+ * Wire and configuration types for `@tonydua/dsh-web-search-exa`.
7
+ *
8
+ * Two independent wire shapes live here: Exa's REST `POST /search` response
9
+ * (used when an API key is configured) and the normalized MCP payload returned
10
+ * by Exa's hosted MCP server (the anonymous, keyless path). Neither is exported
11
+ * to consumers — the provider normalizes both into the seam's
12
+ * `WebSearchResult` vocabulary.
13
+ *
14
+ * @module @tonydua/dsh-web-search-exa/types
15
+ */
16
+ /** Retrieval mode sent to Exa's REST `type` field. */
17
+ type ExaSearchType = 'auto' | 'keyword' | 'neural';
18
+ /** One `results[]` entry of Exa's REST response (fields we actually read). */
19
+ interface ExaRestResult {
20
+ readonly url: string;
21
+ readonly title?: string | null;
22
+ /** Exa's per-result highlight sentences; the only portable snippet source. */
23
+ readonly highlights?: readonly string[] | null;
24
+ /** Exa's publication/crawl timestamp, passed through as `publishedAt`. */
25
+ readonly publishedDate?: string | null;
26
+ }
27
+ /** Exa's REST `POST /search` response body. */
28
+ interface ExaRestResponse {
29
+ readonly results?: readonly ExaRestResult[] | null;
30
+ }
31
+ /**
32
+ * One `content[]` block of a normalized MCP result payload.
33
+ * Non-text blocks (images, resources) are skipped by the collector.
34
+ */
35
+ interface McpContentItem {
36
+ readonly type?: string;
37
+ readonly text?: string;
38
+ }
39
+ /** The `result` member of a JSON-RPC 2.0 response to a `tools/call`. */
40
+ interface McpToolResult {
41
+ readonly content?: readonly McpContentItem[] | null;
42
+ /** True when the tool itself reported a failure (as opposed to a transport error). */
43
+ readonly isError?: boolean;
44
+ }
45
+ /** The `error` member of a JSON-RPC 2.0 response. */
46
+ interface McpJsonRpcError {
47
+ readonly code?: number;
48
+ readonly message?: string;
49
+ }
50
+ /** A JSON-RPC 2.0 envelope as returned by Exa's hosted MCP server. */
51
+ interface McpPayload {
52
+ readonly result?: McpToolResult | null;
53
+ readonly error?: McpJsonRpcError | null;
54
+ }
55
+ /**
56
+ * One parsed `Title:`-led section of Exa MCP text output. Every field is
57
+ * best-effort: Exa omits or emits `N/A` for several of them.
58
+ */
59
+ interface ExaMcpSection {
60
+ url?: string;
61
+ title?: string;
62
+ publishedAt?: string;
63
+ author?: string;
64
+ highlights?: string[];
65
+ /** Full-page text, used only as a snippet fallback when no highlight exists. */
66
+ text?: string;
67
+ }
68
+ //#endregion
69
+ //#region src/provider.d.ts
70
+ /** MCP tool names the anonymous path can call. */
71
+ type ExaMcpTool = 'web_search_exa' | 'web_search_advanced_exa';
72
+ /**
73
+ * Fully resolved options the provider serves one search with. Produced by
74
+ * {@link resolveOptions} from the current Settings section, so every field is
75
+ * already defaulted by the time the provider reads it.
76
+ *
77
+ * The optional members are declared `| undefined` rather than merely optional:
78
+ * `resolveOptions` always sets them (possibly to `undefined`), and this
79
+ * package type-checks under `exactOptionalPropertyTypes`.
80
+ */
81
+ interface ExaSearchProviderOptions {
82
+ readonly providerId?: string | undefined;
83
+ readonly apiKey: string;
84
+ readonly apiKeyEnv: string;
85
+ readonly baseURL: string;
86
+ readonly apiURL?: string | undefined;
87
+ readonly mcpURL: string;
88
+ readonly mcpTool: ExaMcpTool;
89
+ readonly searchType: ExaSearchType;
90
+ readonly numResults?: number | undefined;
91
+ readonly highlightsPerResult: number;
92
+ }
93
+ /** The dsh Settings section shape (fields optional at the load boundary). */
94
+ interface ExaSearchProviderConfig {
95
+ providerId?: string;
96
+ apiKey?: string;
97
+ apiKeyEnv?: string;
98
+ baseURL?: string;
99
+ /** @deprecated Use `baseURL`; this full endpoint remains supported for compatibility. */
100
+ apiURL?: string;
101
+ mcpURL?: string;
102
+ mcpTool?: ExaMcpTool;
103
+ searchType?: ExaSearchType;
104
+ numResults?: number;
105
+ highlightsPerResult?: number;
106
+ }
107
+ /**
108
+ * The launch-environment snapshot the provider resolves credentials against.
109
+ * Structural rather than the concrete dsh type so direct library use can pass
110
+ * any `get`-shaped source.
111
+ */
112
+ interface ExaKeyEnvironment {
113
+ get(name: string): {
114
+ value: string;
115
+ } | undefined;
116
+ }
117
+ /** Resolve one search's API key; `undefined` selects the anonymous MCP path. */
118
+ type ExaApiKeyResolver = (options: ExaSearchProviderOptions) => string | undefined;
119
+ /** Options thunk: called per operation so live Settings edits take effect next search. */
120
+ type ExaOptionsResolver = () => ExaSearchProviderOptions;
121
+ /**
122
+ * Resolve the API key: literal config first, then the environment variable.
123
+ * `undefined` means the anonymous MCP path is used.
124
+ */
125
+ export declare function resolveApiKeyFromProcess(options: ExaSearchProviderOptions): string | undefined;
126
+ /**
127
+ * Resolve a key against dsh's immutable launch-environment snapshot. The
128
+ * process fallback keeps direct library use and older hosts working.
129
+ */
130
+ export declare function resolveApiKey(options: ExaSearchProviderOptions, environment?: ExaKeyEnvironment): string | undefined;
131
+ /**
132
+ * How long a tripped breaker keeps `available()` false before the next search
133
+ * is allowed to probe the anonymous endpoint again.
134
+ */
135
+ export declare const DEFAULT_BREAKER_COOLDOWN_MS = 300000;
136
+ /**
137
+ * Consecutive transient failures that trip the breaker.
138
+ *
139
+ * Only 5xx, 429, and network-level failures count: they say the anonymous
140
+ * endpoint is having a bad time, not that the request was wrong. A 4xx (other
141
+ * than 429) is a configuration error and would fail identically forever, so it
142
+ * deliberately does NOT trip the breaker — hiding a bad endpoint behind a
143
+ * cooldown would just delay the same error.
144
+ */
145
+ export declare const DEFAULT_BREAKER_THRESHOLD = 3;
146
+ /** A failure worth retrying later, as opposed to a permanent configuration error. */
147
+ export declare class ExaTransientError extends WebError {
148
+ constructor(message: string, cause?: unknown);
149
+ }
150
+ /**
151
+ * A rate limit, kept distinct from a generic provider failure so the model (and
152
+ * a human reading the transcript) can tell "Exa is throttling the keyless
153
+ * channel, configure a key" apart from "the network is broken".
154
+ *
155
+ * `code` is an open string in the seam's vocabulary, so a plugin-specific code
156
+ * is the supported way to route this; consumers must tolerate unknown codes.
157
+ */
158
+ export declare class ExaRateLimitError extends WebError {
159
+ constructor(message: string);
160
+ }
161
+ /**
162
+ * An anonymous response body that exceeded {@link MAX_MCP_RESPONSE_BYTES}.
163
+ *
164
+ * Extends {@link ExaTransientError} because the endpoint, not the caller's
165
+ * configuration, produced it: a body this size is a bad day on Exa's side, and
166
+ * the breaker should get to count it.
167
+ */
168
+ export declare class ExaResponseTooLargeError extends ExaTransientError {
169
+ /** The number of bytes observed, or `undefined` when the server declared the size. */
170
+ readonly observedBytes: number | undefined;
171
+ constructor(limit: number, observedBytes?: number);
172
+ }
173
+ /**
174
+ * Consecutive-transient-failure breaker for the keyless path.
175
+ *
176
+ * The public MCP endpoint is best-effort: when it is throttling or down, every
177
+ * search would otherwise fail. Reporting that state through {@link
178
+ * ExaSearchProvider.available} is what lets a deployment recover — the seam
179
+ * skips an unavailable provider, so an unconfigured profile falls back to
180
+ * another registered provider instead of surfacing a hard error.
181
+ *
182
+ * State is per provider instance (one per plugin mount) and never persisted:
183
+ * a restart is a fresh chance, and one successful search resets the count.
184
+ */
185
+ export declare class ExaAvailabilityBreaker {
186
+ #private;
187
+ constructor(threshold?: number, cooldownMs?: number);
188
+ /** True while the breaker is open and the cooldown has not elapsed. */
189
+ get blocked(): boolean;
190
+ /** Record one successful operation: the endpoint is healthy again. */
191
+ succeeded(): void;
192
+ /** Record one transient failure, opening the breaker at the threshold. */
193
+ failed(): void;
194
+ }
195
+ /**
196
+ * Project one resolved configuration section into the options the provider
197
+ * serves its next search with. Called per operation so live Settings edits
198
+ * take effect on the next search.
199
+ */
200
+ export declare function resolveOptions(section: ExaSearchProviderConfig): ExaSearchProviderOptions;
201
+ /**
202
+ * Exa-backed search with an anonymous fallback.
203
+ *
204
+ * Path selection is per search, not per install: a key appearing later (for
205
+ * example after a Settings edit) upgrades the next search to REST without a
206
+ * restart, and removing it falls back to the anonymous endpoint.
207
+ */
208
+ export declare class ExaSearchProvider implements WebSearchProvider {
209
+ #private;
210
+ /**
211
+ * @param resolveOptions - thunk returning the options for the NEXT
212
+ * operation, snapshotted once at each operation's entry so one search
213
+ * never mixes two settings sections (same pattern as the official
214
+ * DeepSeek provider).
215
+ * @param resolveApiKey - optional key resolver; dsh hosts pass their
216
+ * launch-environment snapshot while direct users retain process.env fallback.
217
+ * @param breaker - health tracker for the keyless path; injectable so tests
218
+ * need no clock control.
219
+ */
220
+ constructor(resolveOptions: ExaOptionsResolver, resolveApiKey?: ExaApiKeyResolver, breaker?: ExaAvailabilityBreaker);
221
+ /**
222
+ * Read per operation rather than frozen at construction: a Settings edit to
223
+ * `providerId` must not leave the provider reporting an id the registry does
224
+ * not key it under. Registering under the new id is the user's job (the
225
+ * loader re-reads config on reload), but the reported value stays honest.
226
+ */
227
+ get id(): string;
228
+ /**
229
+ * Cheap local usability check — no network call, per the seam contract.
230
+ *
231
+ * False when the local options are unusable, or while the keyless channel's
232
+ * breaker is open after repeated transient failures. Reporting that honestly
233
+ * is what lets the seam fall back to another provider instead of failing the
234
+ * search: a pinned `searchProvider` surfaces
235
+ * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, an unpinned one simply selects
236
+ * another registered provider.
237
+ */
238
+ available(): boolean;
239
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
240
+ }
241
+ //#endregion
242
+ //#region src/constants.d.ts
243
+ /**
244
+ * Defaults and stable identifiers for `@tonydua/dsh-web-search-exa`.
245
+ *
246
+ * Every value here is also restated in the Settings schema with the same
247
+ * default, so a reader can see the effective value without following the
248
+ * import. Keep the two in sync: the schema is the user-facing contract, these
249
+ * are the values the provider falls back to when a field is absent.
250
+ *
251
+ * @module @tonydua/dsh-web-search-exa/constants
252
+ */
253
+ /** Default provider id this provider registers under (`ctx.web` registry key). */
254
+ export declare const DEFAULT_PROVIDER_ID = "exa";
255
+ /** Backward-compatible alias for the default provider id. */
256
+ export declare const PROVIDER_ID = "exa";
257
+ /** Exa REST search endpoint; used only when an API key is configured. */
258
+ export declare const DEFAULT_BASE_URL = "https://api.exa.ai";
259
+ /** Legacy full REST endpoint; `baseURL` is the canonical dsh-compatible option. */
260
+ export declare const DEFAULT_API_URL = "https://api.exa.ai/search";
261
+ /**
262
+ * Exa hosted MCP endpoint; the anonymous fallback path.
263
+ *
264
+ * The `tools` query is part of the default because `web_search_advanced_exa`
265
+ * is not servable without it — a request naming it against the bare endpoint
266
+ * fails with `MCP error -32602: Tool web_search_advanced_exa not found`.
267
+ * A configured `mcpURL` that omits `tools` gets the query spliced in at request
268
+ * time, so existing configurations keep working.
269
+ */
270
+ export declare const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa";
271
+ /** Environment variable consulted when no literal `apiKey` is configured. */
272
+ export declare const DEFAULT_API_KEY_ENV = "EXA_API_KEY";
273
+ /** Default retrieval mode for the REST path: let Exa pick. */
274
+ export declare const DEFAULT_SEARCH_TYPE = "auto";
275
+ /** Default number of highlight sentences requested per result (REST path). */
276
+ export declare const DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
277
+ /** MCP tool name for plain web search on Exa's hosted server (text-blob output). */
278
+ export declare const MCP_TOOL = "web_search_exa";
279
+ /** MCP tool whose text content is a sanitized structured search response. */
280
+ export declare const MCP_TOOL_ADVANCED = "web_search_advanced_exa";
281
+ /** The tool the anonymous path calls by default. */
282
+ export declare const DEFAULT_MCP_TOOL = "web_search_advanced_exa";
283
+ /** Query parameter enabling both MCP tools when a configured URL omits it. */
284
+ export declare const MCP_TOOLS_QUERY = "tools=web_search_exa,web_search_advanced_exa";
285
+ /**
286
+ * Reject anonymous MCP responses larger than this.
287
+ *
288
+ * Structured results are kilobytes; anything past this is a malformed or
289
+ * hostile body and parsing it would only burn memory before failing anyway.
290
+ */
291
+ export declare const MAX_MCP_RESPONSE_BYTES: number;
292
+ /**
293
+ * Attribution header sent on anonymous MCP requests. This is the only signal
294
+ * Exa's public endpoint receives about the caller, so it is deliberately a
295
+ * product-level name rather than a per-install identifier.
296
+ */
297
+ export declare const MCP_SOURCE = "dsh-anything";
298
+ /**
299
+ * User agent for REST requests.
300
+ *
301
+ * Annotated `: string` rather than left inferred: this constant is re-exported
302
+ * from the package root, and a `const` string infers a LITERAL type, which
303
+ * would bake today's version number into every consumer's type-checking.
304
+ */
305
+ export declare const USER_AGENT: string;
306
+ /** Snippet cap for text-derived snippets (matching oh-my-pi's choice). */
307
+ export declare const MAX_SNIPPET_CHARS = 500;
308
+ /** Settings namespace carrying this provider's configuration. */
309
+ export declare const SETTINGS_NAMESPACE = "web-search-exa";
310
+ //#endregion
311
+ //#region src/index.d.ts
312
+ declare const Config: z<Schemastery.ObjectS<{
313
+ /**
314
+ * Provider id registered into `ctx.web`. Defaults to `exa` (same as the
315
+ * official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
316
+ * packages are installed in one profile — the seam rejects duplicate ids
317
+ * with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
318
+ * distinct id here (e.g. `exa-anon`) and select it explicitly with
319
+ * `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
320
+ */
321
+ providerId: z<string, string>;
322
+ /** Literal Exa API key; an empty/missing value enables the anonymous MCP path. */
323
+ apiKey: z<string, string>;
324
+ /** Environment variable consulted when no literal `apiKey` is configured. */
325
+ apiKeyEnv: z<string, string>;
326
+ /** Exa API base URL; `/search` is appended for the keyed REST path. */
327
+ baseURL: z<string, string>;
328
+ /**
329
+ * Legacy full REST endpoint. When set, it takes precedence over `baseURL`;
330
+ * new configurations should use `baseURL` to match the official provider.
331
+ */
332
+ apiURL: z<string, string>;
333
+ /** Exa hosted MCP endpoint, used by the anonymous fallback. */
334
+ mcpURL: z<string, string>;
335
+ /**
336
+ * MCP tool the anonymous path calls. `web_search_advanced_exa` returns a
337
+ * sanitized structured JSON response; `web_search_exa` returns the
338
+ * `Title:`-section text blob. The structured tool is the default because it
339
+ * needs no text parsing, and the text path stays available as a fallback.
340
+ */
341
+ mcpTool: z<"web_search_exa" | "web_search_advanced_exa", "web_search_exa" | "web_search_advanced_exa">;
342
+ /** REST retrieval mode: `auto`, `keyword`, or `neural`. */
343
+ searchType: z<"auto" | "keyword" | "neural", "auto" | "keyword" | "neural">;
344
+ /** Default result count when the request carries no `maxResults`. */
345
+ numResults: z<number, number>;
346
+ /** Highlight sentences requested per result on the REST path. */
347
+ highlightsPerResult: z<number, number>;
348
+ }>, Schemastery.ObjectT<{
349
+ /**
350
+ * Provider id registered into `ctx.web`. Defaults to `exa` (same as the
351
+ * official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
352
+ * packages are installed in one profile — the seam rejects duplicate ids
353
+ * with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
354
+ * distinct id here (e.g. `exa-anon`) and select it explicitly with
355
+ * `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
356
+ */
357
+ providerId: z<string, string>;
358
+ /** Literal Exa API key; an empty/missing value enables the anonymous MCP path. */
359
+ apiKey: z<string, string>;
360
+ /** Environment variable consulted when no literal `apiKey` is configured. */
361
+ apiKeyEnv: z<string, string>;
362
+ /** Exa API base URL; `/search` is appended for the keyed REST path. */
363
+ baseURL: z<string, string>;
364
+ /**
365
+ * Legacy full REST endpoint. When set, it takes precedence over `baseURL`;
366
+ * new configurations should use `baseURL` to match the official provider.
367
+ */
368
+ apiURL: z<string, string>;
369
+ /** Exa hosted MCP endpoint, used by the anonymous fallback. */
370
+ mcpURL: z<string, string>;
371
+ /**
372
+ * MCP tool the anonymous path calls. `web_search_advanced_exa` returns a
373
+ * sanitized structured JSON response; `web_search_exa` returns the
374
+ * `Title:`-section text blob. The structured tool is the default because it
375
+ * needs no text parsing, and the text path stays available as a fallback.
376
+ */
377
+ mcpTool: z<"web_search_exa" | "web_search_advanced_exa", "web_search_exa" | "web_search_advanced_exa">;
378
+ /** REST retrieval mode: `auto`, `keyword`, or `neural`. */
379
+ searchType: z<"auto" | "keyword" | "neural", "auto" | "keyword" | "neural">;
380
+ /** Default result count when the request carries no `maxResults`. */
381
+ numResults: z<number, number>;
382
+ /** Highlight sentences requested per result on the REST path. */
383
+ highlightsPerResult: z<number, number>;
384
+ }>>;
385
+ /** Cordis plugin name used by loader diagnostics. */
386
+ export declare const name = "web-search-exa";
387
+ /** The web seam this provider registers into. */
388
+ export declare const inject: readonly ["web"];
389
+ /**
390
+ * The subset of the settings service this plugin uses, described structurally
391
+ * rather than by the concrete `dsh-settings` class, because that API changed
392
+ * shape and the plugin must work on both sides of the change.
393
+ *
394
+ * - **dsh ≤ 0.1.6** exposes `SettingsProvider.installSection`, which registered
395
+ * a namespace and — the part the provider actually depends on — handed back
396
+ * the authoritative section thunk through `setSource`.
397
+ * - **dsh ≥ 0.1.7** replaces that with `SettingsForms`, which derives a page
398
+ * from the Config schema the Loader already holds for this entry
399
+ * (`SettingsDescriptor.schema`, `autoGenerate`). There is no namespace to
400
+ * install, so this plugin has nothing to register and must simply not
401
+ * crash.
402
+ *
403
+ * Both members are optional: a host with neither still gets a working provider,
404
+ * only without live Settings-driven reconfiguration.
405
+ */
406
+ interface SettingsServiceLike {
407
+ installSection?: (owner: unknown, ns: string, schema: unknown, entry: unknown, hooks: {
408
+ setSource: (source: () => ExaSearchProviderConfig) => void;
409
+ onChange: () => void;
410
+ }) => void;
411
+ }
412
+ /**
413
+ * Wire the settings service when the host exposes the pre-0.1.7 registration
414
+ * API; do nothing (rather than throw) on hosts that do not.
415
+ *
416
+ * @param settings - the mounted settings service, of either generation.
417
+ * @param owner - the consuming context `installSection` attributes the section to.
418
+ * @param config - the composition entry, used as the section's base value.
419
+ * @param adopt - receives the authoritative section thunk so later searches read
420
+ * live edits instead of the boot-time config.
421
+ * @returns true when a section was installed.
422
+ */
423
+ declare function installSettingsSection(settings: SettingsServiceLike, owner: unknown, config: ExaSearchProviderConfig, adopt: (source: () => ExaSearchProviderConfig) => void): boolean;
424
+ /**
425
+ * Register the Exa search provider with `ctx.web` and, when the settings
426
+ * service is mounted *and* exposes the pre-0.1.7 registration API, install its
427
+ * Settings section.
428
+ *
429
+ * The settings work is deliberately inside `ctx.inject`, so a profile that
430
+ * omits `dsh-settings` still mounts the provider — keyless search must not
431
+ * depend on the Settings UI being present. The provider registration happens
432
+ * outside it for the same reason, and is never conditional on the settings
433
+ * generation.
434
+ */
435
+ export declare function apply(ctx: Context, config: ExaSearchProviderConfig): void;
436
+ //#endregion
437
+ export { Config, type ExaApiKeyResolver, type ExaKeyEnvironment, type ExaMcpSection, type ExaMcpTool, type ExaOptionsResolver, type ExaRestResponse, type ExaRestResult, type ExaSearchProviderConfig, type ExaSearchProviderOptions, type ExaSearchType, type McpContentItem, type McpJsonRpcError, type McpPayload, type McpToolResult, installSettingsSection };