@x1a0f3n9/dsh-web-search-exa 0.1.5-rc.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/web/web-search-exa/README.md
5
+ README.md: 214a8f8636cbdaddc01a234cc655e1296860e5a1
6
+ README.zh.md: a91a588a7160d25938769c8dc63dd66bc1820e8b
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ ---
2
+ description: "The Exa-backed search provider for ctx.web: how deployments mount vendor-native web search with portable snippets and publication dates."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @x1a0f3n9/dsh-web-search-exa
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ With `dsh-web-search-exa`, the harness searches the web through Exa and gets vendor-native results with portable snippets and publication dates. Choose it when a deployment has an Exa API key and wants Exa's keyword or neural search. Exa returns no generated answer, so results carry no `content` — only citeable sources. A result with no non-blank highlight is dropped, so a call can return fewer sources than requested. The model-facing `web_search` tool lives in `dsh-tool-web`.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount the provider in a composition that already loads the web service; it registers as the `exa` search provider, so `ctx.web.search()` resolves it automatically when it is the only usable search backend — or pin it with `searchProvider: exa`.
29
+
30
+ ### When to choose it
31
+
32
+ Choose this backend when a deployment holds an Exa API key and wants Exa's keyword or neural search with per-result highlight snippets and publication dates. The provider is unavailable — and every search call fails with a structured error — when the key is empty or the endpoint base does not parse.
33
+
34
+ ### Minimal configuration
35
+
36
+ Load the web service and the provider; the API key falls back to `$EXA_API_KEY` from the launch environment, and all other settings have safe defaults.
37
+
38
+ ```yaml
39
+ - name: '@x1a0f3n9/dsh-web'
40
+ - name: '@x1a0f3n9/dsh-web-search-exa'
41
+ config:
42
+ apiKey: !!js process.env.EXA_API_KEY
43
+ ```
44
+
45
+ | Field | Default | Meaning |
46
+ |---|---|---|
47
+ | `apiKey` | `$EXA_API_KEY` | Exa API key; empty or absent makes the provider unavailable |
48
+ | `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes the provider unavailable |
49
+ | `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto`, `keyword`, or `neural` |
50
+ | `numResults` | (unset) | Default result count when a request carries no `maxResults`; must be a positive integer |
51
+ | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`); must be a positive integer |
52
+
53
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-exa) is the exhaustive source for every accepted field and its JSDoc.
54
+
55
+ ### What a search returns
56
+
57
+ Each Exa result maps to a `WebSearchSource`: `url`, `title`, the first non-blank highlight as `snippet`, and `publishedDate` as `publishedAt`; a result with no highlight has no portable snippet and is dropped. A request's `maxResults` wins over the configured `numResults` default and is sent to Exa as a cost and latency optimization — the final bound is enforced by the service, which truncates and flags. Exa returns no generated answer, so the result carries no `content`.
58
+
59
+ ### Failures and recovery
60
+
61
+ Provider failures — HTTP errors, network failures, unparseable or wrong-shape bodies — surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Callers route on the code; the model-facing `web_search` tool surfaces failures to the model under its own error wrapper.
62
+
63
+ -----
64
+
65
+ <a id="understand-the-implementation"></a>
66
+ ## Understand the implementation
67
+
68
+ <details>
69
+ <summary>Implementation internals — click to expand</summary>
70
+
71
+ This section explains the design decisions behind the provider; the observable behavior is fully covered in [Use this package](#use-this-package).
72
+
73
+ ### Design philosophy
74
+
75
+ The provider is a thin adapter over Exa's API with two deliberate rules:
76
+
77
+ - **Portable snippets only.** A source gains a `snippet` only from a real highlight; inventing one from other fields would make the seam lie, so snippet-less results are dropped entirely.
78
+ - **No invented answers.** Exa returns no generated answer, so `content` is omitted rather than fabricating provider prose the model might trust.
79
+
80
+ ### Source map
81
+
82
+ | File | Role |
83
+ |---|---|
84
+ | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, environment fallback, provider registration |
85
+ | [`src/provider.ts`](src/provider.ts) | The `ExaSearchProvider`: request dispatch, abort classification, result mapping |
86
+ | [`src/types.ts`](src/types.ts) | Exa wire types: `ExaSearchResponse`, `ExaResult`, `ExaError` |
87
+ | — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. |
88
+
89
+ ### Request and mapping flow
90
+
91
+ `search()` posts the query, retrieval mode, highlight request, and optional result count to `{baseURL}/search` with `redirect: 'error'`, so a redirect fails the request without contacting the target. The parsed `results[]` are mapped one by one, snippet-less entries dropped, and the service applies the final `maxResults` bound on the way back. An abort — a `DOMException` named `AbortError` — becomes `WEB_ABORTED`; anything else becomes `WEB_PROVIDER_ERROR`.
92
+
93
+ </details>
94
+
95
+ -----
96
+
97
+ <a id="further-exploration"></a>
98
+ ## Further Exploration
99
+
100
+ Read these pages when the package-level contract is not enough. They move from the shared vocabulary to the service, the model-facing tools, and the design rationale.
101
+
102
+ - [Web subsystem](../../../docs/subsystems/web.md) — the exhaustive search request/result vocabulary and error codes.
103
+ - [Web package map](../README.md) — the seven-package family and each role.
104
+ - [dsh-web](../web/README.md) — the web service this provider registers into.
105
+ - [dsh-tool-web](../tool-web/README.md) — the model-facing `web_search` tool that renders this provider's sources.
106
+ - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-exa) — every accepted config field and its source declaration.
107
+ - [Web capability seam decision](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) — why search and fetch share one provider-selection service.
108
+
109
+ -----
110
+
111
+ <a id="model-experience"></a>
112
+ ## Model Experience
113
+
114
+ Indirectly, through `dsh-tool-web`, which retains this provider's `maxResults`-bounded URLs, titles, first highlights, and publication dates or its exact `Exa search aborted`, `Exa search request failed: <error>`, and `Exa returned an unprocessable response body: <error>` failures under the consumer's error wrapper.
115
+
116
+ #### KV Cache effect
117
+
118
+ No direct invalidation; the named consumer owns any request-prefix changes.
119
+
120
+ ## Known Limitations and Deferred Work
121
+
122
+ <a id="known-limitations-and-deferred-work"></a>
123
+
124
+
125
+ These limits define when the provider is a poor fit. They are current package constraints.
126
+
127
+ - **A result with no non-blank highlight is dropped entirely** — there is no portable snippet to map, so fewer sources than requested can return.
128
+ - **Only `searchType`/`numResults`/`highlightsPerResult` are exposed** — Exa's other controls (livecrawl, category, domain/date filters, full-text contents) wait on provider-neutral service fields ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)).
129
+ - **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (such as `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.
130
+
131
+ <a id="dev-note"></a>
132
+ ### Dev Note
133
+
134
+ <details>
135
+ <summary>Working context for maintainers — click to expand</summary>
136
+
137
+ This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior, limits, and rationale live in the sections above and the linked Agent Notes.
138
+
139
+ #### Future: wider Exa control surface
140
+
141
+ Exa's livecrawl, category, domain and date filters, and full-text contents stay unexposed. Exposing them needs provider-neutral service fields first, so the family adds one coordinated control rather than a vendor-specific argument.
142
+
143
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,143 @@
1
+ ---
2
+ description: "ctx.web 的 Exa 搜索提供方:部署方如何挂载厂商原生 web 搜索,获得可移植 snippet 与发布日期。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @x1a0f3n9/dsh-web-search-exa
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 有了 `dsh-web-search-exa`,harness 可以通过 Exa 搜索 web,获得带可移植 snippet 与发布日期的厂商原生结果。当部署持有 Exa API 密钥、并希望使用 Exa 的关键词或神经搜索时选择它。Exa 不返回生成答案,因此结果不携带 `content`——只产出可引用的来源。没有非空白高亮的来源会被丢弃,因此一次调用返回的来源可能少于请求数量。面向模型的 `web_search` 工具位于 `dsh-tool-web`。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 在已加载 web 服务的组合中挂载本提供方;它以 `exa` 搜索提供方身份注册,因此当它是唯一可用的搜索后端时,`ctx.web.search()` 会自动解析到它——也可以用 `searchProvider: exa` 固定。
29
+
30
+ ### 何时选择
31
+
32
+ 当部署持有 Exa API 密钥,并希望使用 Exa 的关键词或神经搜索、获得每项结果的高亮 snippet 与发布日期时,选择此后端。密钥为空或端点基址无法解析时,提供方不可用——每次搜索调用都会以结构化错误失败。
33
+
34
+ ### 最小配置
35
+
36
+ 加载 web 服务与本提供方;API 密钥回退到启动环境中的 `$EXA_API_KEY`,其余设置都有安全默认值。
37
+
38
+ ```yaml
39
+ - name: '@x1a0f3n9/dsh-web'
40
+ - name: '@x1a0f3n9/dsh-web-search-exa'
41
+ config:
42
+ apiKey: !!js process.env.EXA_API_KEY
43
+ ```
44
+
45
+ | 字段 | 默认值 | 含义 |
46
+ |---|---|---|
47
+ | `apiKey` | `$EXA_API_KEY` | Exa API 密钥;为空或缺失时提供方不可用 |
48
+ | `baseURL` | `https://api.exa.ai` | 端点基址;追加 `/search`。无法解析时提供方不可用 |
49
+ | `searchType` | `auto` | 以 Exa `type` 发送的检索模式:`auto`、`keyword` 或 `neural` |
50
+ | `numResults` | (未设置) | 请求不含 `maxResults` 时使用的默认结果数;必须是正整数 |
51
+ | `highlightsPerResult` | `1` | 每个结果请求的 highlight 句子数(Exa `highlightsPerUrl`);必须是正整数 |
52
+
53
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-exa)是每个受支持字段及其 JSDoc 的穷尽式真源。
54
+
55
+ ### 搜索返回什么
56
+
57
+ 每项 Exa 结果映射为 `WebSearchSource`:`url`、`title`、以首个非空白高亮作为 `snippet`、`publishedDate` 作为 `publishedAt`;没有高亮的来源缺少可移植的 snippet,会被丢弃。请求的 `maxResults` 优先于已配置的默认 `numResults`,并作为成本与延迟优化发送给 Exa——最终上限由服务强制执行:截断并标记。Exa 不返回生成答案,因此结果不携带 `content`。
58
+
59
+ ### 失败与恢复
60
+
61
+ 提供方失败——HTTP 错误、网络失败、响应体无法解析或结构不符——以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在访问 `Location` 指向的目标之前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。调用方根据错误码进行分流;面向模型的 `web_search` 工具会在自己的错误包装层内把失败呈现给模型。
62
+
63
+ -----
64
+
65
+ <a id="understand-the-implementation"></a>
66
+ ## 理解实现
67
+
68
+ <details>
69
+ <summary>实现细节——点击展开</summary>
70
+
71
+ 本节解释提供方背后的设计决策;可观察行为已在[使用本包](#use-this-package)中完整说明。
72
+
73
+ ### 设计理念
74
+
75
+ 该提供方是 Exa API 之上的薄适配器,遵循两条刻意的规则:
76
+
77
+ - **只取可移植的 snippet。** 来源只有在真实高亮存在时才获得 `snippet`;用其他字段捏造会让 seam 说谎,因此没有 snippet 的结果被整个丢弃。
78
+ - **不虚构答案。** Exa 不返回生成答案,因此省略 `content`,而不是编造模型可能信任的提供方文本。
79
+
80
+ ### 源码地图
81
+
82
+ | 文件 | 职责 |
83
+ |---|---|
84
+ | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、环境变量回退、提供方注册 |
85
+ | [`src/provider.ts`](src/provider.ts) | `ExaSearchProvider`:请求分发、中止分类、结果映射 |
86
+ | [`src/types.ts`](src/types.ts) | Exa 协议类型:`ExaSearchResponse`、`ExaResult`、`ExaError` |
87
+ | — | 不发布运行时不变量配套入口;除所属 seam 强制执行的约定外,本包没有独立的事件序列或可变数据关系。 |
88
+
89
+ ### 请求与映射流程
90
+
91
+ `search()` 以 `redirect: 'error'` 把查询、检索模式、高亮请求与可选结果数 POST 到 `{baseURL}/search`,因此重定向会在不接触目标的情况下使请求失败。解析后的 `results[]` 逐项映射,没有 snippet 的条目被丢弃,服务在返回路径上应用最终的 `maxResults` 上限。中止——名为 `AbortError` 的 `DOMException`——变为 `WEB_ABORTED`;其余情况变为 `WEB_PROVIDER_ERROR`。
92
+
93
+ </details>
94
+
95
+ -----
96
+
97
+ <a id="further-exploration"></a>
98
+ ## 进一步探索
99
+
100
+ 当包级约定不够用时阅读以下页面。它们从共享词汇逐步进入服务、面向模型的工具与设计依据。
101
+
102
+ - [web 子系统](../../../docs/subsystems/web.zh.md)——穷尽式的搜索请求/结果词汇与错误码。
103
+ - [web 包映射](../README.zh.md)——七包家族与各角色。
104
+ - [dsh-web](../web/README.zh.md)——本提供方注册进入的 web 服务。
105
+ - [dsh-tool-web](../tool-web/README.zh.md)——渲染本提供方来源的面向模型 `web_search` 工具。
106
+ - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-exa)——每个受支持配置字段及其源声明。
107
+ - [web 能力 seam 决策](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)——搜索与抓取为何共用一项提供方选择服务。
108
+
109
+ -----
110
+
111
+ <a id="model-experience"></a>
112
+ ## 模型体验
113
+
114
+ 通过 `dsh-tool-web` 间接影响模型体验。该工具保留本提供方经 `maxResults` 限制的 URL、标题、首条高亮与发布日期;如果发生失败,则会在消费方的错误包装层内保留原样错误消息 `Exa search aborted`、`Exa search request failed: <error>` 和 `Exa returned an unprocessable response body: <error>`。
115
+
116
+ #### KV Cache 影响
117
+
118
+ 不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
119
+
120
+ ## 已知限制与延期工作
121
+
122
+ <a id="known-limitations-and-deferred-work"></a>
123
+
124
+
125
+ 这些限制说明提供方在哪些情况下不合适。它们是当前包约束。
126
+
127
+ - **没有非空白高亮的来源会被整个丢弃**——没有可映射的可移植 snippet,因此返回来源可能少于请求数量。
128
+ - **只公开 `searchType`/`numResults`/`highlightsPerResult`**——Exa 的其他控制项(livecrawl、category、域名/日期过滤条件、全文内容)等待提供方无关的服务字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。
129
+ - **按错误形状分类中止**——只有名为 `AbortError` 的 `DOMException` 才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)呈现为 `WEB_PROVIDER_ERROR`。
130
+
131
+ <a id="dev-note"></a>
132
+ ### 开发备注
133
+
134
+ <details>
135
+ <summary>维护者的工作上下文——点击展开</summary>
136
+
137
+ 本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文和相关 Agent Note 为准。
138
+
139
+ #### 未来:更宽的 Exa 控制面
140
+
141
+ Exa 的 livecrawl、category、域名与日期过滤条件以及全文内容仍未公开。公开它们需要先有提供方无关的服务字段,让家族以一个协调一致的控制项、而非厂商专有参数的方式新增。
142
+
143
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,154 @@
1
+ import { launchEnvironmentOf } from "@x1a0f3n9/dsh-launch-environment";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { WebError } from "@x1a0f3n9/dsh-web";
4
+ //#region lib/types/provider.js
5
+ /**
6
+ * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with
7
+ * highlight contents). It maps the first non-blank highlight to `snippet`, maps
8
+ * `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content`
9
+ * because Exa returns no generated answer.
10
+ * @module @x1a0f3n9/dsh-web-search-exa/provider
11
+ */
12
+ /** Stable id this provider registers under. */
13
+ const EXA_PROVIDER_ID = "exa";
14
+ /** Default Exa search endpoint; `/search` is the operation. */
15
+ const EXA_DEFAULT_BASE_URL = "https://api.exa.ai";
16
+ /** Default retrieval mode: let Exa pick between keyword and neural search. */
17
+ const EXA_DEFAULT_SEARCH_TYPE = "auto";
18
+ /** Default number of highlight sentences requested per result. */
19
+ const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
20
+ /** Attribution header sent on every request. Bump with the package version. */
21
+ const USER_AGENT = "deepseek-harness/0.0.1";
22
+ /**
23
+ * Map one Exa result to a normalized source, or `undefined` when it carries no
24
+ * portable snippet (an entry with no highlight is dropped — the seam has no
25
+ * other field to derive a snippet from, and inventing one would lie).
26
+ *
27
+ * @param result - one entry of Exa's `results[]`.
28
+ * @returns the normalized source, or `undefined` when the entry has no
29
+ * non-blank highlight.
30
+ */
31
+ function mapExaResult(result) {
32
+ const snippet = result.highlights?.find((highlight) => highlight.trim().length > 0);
33
+ if (snippet === void 0) return void 0;
34
+ return {
35
+ url: result.url,
36
+ ...result.title != null && result.title.length > 0 ? { title: result.title } : {},
37
+ snippet,
38
+ ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}
39
+ };
40
+ }
41
+ /**
42
+ * Map an Exa response envelope to a normalized search result.
43
+ *
44
+ * @param response - the parsed `POST /search` response body.
45
+ * @returns the normalized result; snippet-less entries are dropped
46
+ * ({@link mapExaResult}).
47
+ */
48
+ function mapExaResponse(response) {
49
+ return {
50
+ sources: (response.results ?? []).map(mapExaResult).filter((source) => source !== void 0),
51
+ truncated: false
52
+ };
53
+ }
54
+ /** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
55
+ var ExaSearchProvider = class {
56
+ options;
57
+ id = "exa";
58
+ constructor(options) {
59
+ this.options = options;
60
+ }
61
+ available() {
62
+ return this.options.apiKey.length > 0 && isValidBaseUrl(this.options.baseURL) && isPositiveInteger(this.options.highlightsPerResult) && (this.options.numResults === void 0 || isPositiveInteger(this.options.numResults));
63
+ }
64
+ async search(request, signal) {
65
+ const numResults = request.maxResults ?? this.options.numResults;
66
+ let response;
67
+ try {
68
+ response = await fetch(`${this.options.baseURL}/search`, {
69
+ method: "POST",
70
+ redirect: "error",
71
+ headers: {
72
+ "authorization": `Bearer ${this.options.apiKey}`,
73
+ "content-type": "application/json",
74
+ "accept": "application/json",
75
+ "user-agent": USER_AGENT
76
+ },
77
+ body: JSON.stringify({
78
+ query: request.query,
79
+ type: this.options.searchType,
80
+ contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } },
81
+ ...numResults !== void 0 ? { numResults } : {}
82
+ }),
83
+ ...signal !== void 0 ? { signal } : {}
84
+ });
85
+ } catch (error) {
86
+ if (isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: error });
87
+ throw new WebError(`Exa search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
88
+ }
89
+ if (!response.ok) {
90
+ let message = `Exa API error (HTTP ${response.status})`;
91
+ try {
92
+ const parsed = await response.json();
93
+ const detail = parsed.error ?? parsed.message;
94
+ if (detail !== void 0 && detail.length > 0) message = detail;
95
+ } catch (error) {
96
+ if (isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: error });
97
+ }
98
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
99
+ }
100
+ try {
101
+ return mapExaResponse(await response.json());
102
+ } catch (error) {
103
+ if (isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: error });
104
+ throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
105
+ }
106
+ }
107
+ };
108
+ /** True when `baseURL` parses as an absolute URL (a cheap local config check). */
109
+ function isValidBaseUrl(baseURL) {
110
+ return URL.canParse(baseURL);
111
+ }
112
+ /** True for a request limit that can be sent to Exa (a positive whole number). */
113
+ function isPositiveInteger(value) {
114
+ return Number.isInteger(value) && value > 0;
115
+ }
116
+ /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
117
+ function isAbortError(error) {
118
+ return error instanceof DOMException && error.name === "AbortError";
119
+ }
120
+ //#endregion
121
+ //#region lib/types/index.js
122
+ /**
123
+ * Exa-backed `WebSearchProvider` plugin. It contributes to the `ctx.web`
124
+ * registry without owning the service.
125
+ *
126
+ * @module @x1a0f3n9/dsh-web-search-exa
127
+ */
128
+ /** Cordis plugin name used by loader diagnostics. */
129
+ const name = "web-search-exa";
130
+ /** The web seam this provider registers into. */
131
+ const inject = ["web"];
132
+ const Config = z.object({
133
+ apiKey: z.string(),
134
+ baseURL: z.string(),
135
+ searchType: z.union([
136
+ "auto",
137
+ "keyword",
138
+ "neural"
139
+ ]),
140
+ numResults: z.number().step(1).min(1),
141
+ highlightsPerResult: z.number().step(1).min(1)
142
+ });
143
+ /** Register the Exa search provider with `ctx.web`. */
144
+ function apply(ctx, config) {
145
+ ctx.web.registerSearchProvider(new ExaSearchProvider({
146
+ apiKey: config.apiKey ?? launchEnvironmentOf(ctx).get("EXA_API_KEY")?.value ?? "",
147
+ baseURL: config.baseURL ?? "https://api.exa.ai",
148
+ searchType: config.searchType ?? "auto",
149
+ highlightsPerResult: config.highlightsPerResult ?? 1,
150
+ ...config.numResults !== void 0 ? { numResults: config.numResults } : {}
151
+ }));
152
+ }
153
+ //#endregion
154
+ export { Config, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, EXA_DEFAULT_SEARCH_TYPE, EXA_PROVIDER_ID, ExaSearchProvider, apply, inject, name };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Exa-backed `WebSearchProvider` plugin. It contributes to the `ctx.web`
3
+ * registry without owning the service.
4
+ *
5
+ * @module @x1a0f3n9/dsh-web-search-exa
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import z from '@deepseek-ai/schemastery';
9
+ export { EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, EXA_DEFAULT_SEARCH_TYPE, EXA_PROVIDER_ID, ExaSearchProvider, } from './provider.ts';
10
+ export type { ExaSearchProviderOptions } from './provider.ts';
11
+ /** Cordis plugin name used by loader diagnostics. */
12
+ export declare const name = "web-search-exa";
13
+ /** The web seam this provider registers into. */
14
+ export declare const inject: string[];
15
+ /** Plugin config (all optional — `apply` fills env-var and constant defaults). */
16
+ export interface Config {
17
+ /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
18
+ apiKey?: string;
19
+ /** Endpoint base; `/search` is appended. Defaults to the public API. */
20
+ baseURL?: string;
21
+ /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */
22
+ searchType?: 'auto' | 'keyword' | 'neural';
23
+ /** Default result count when a request carries no `maxResults`. Omitted = none. */
24
+ numResults?: number;
25
+ /** Highlight sentences requested per result. Defaults to 1. */
26
+ highlightsPerResult?: number;
27
+ }
28
+ export declare const Config: z<Config>;
29
+ /** Register the Exa search provider with `ctx.web`. */
30
+ export declare function apply(ctx: Context, config: Config): void;
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with
3
+ * highlight contents). It maps the first non-blank highlight to `snippet`, maps
4
+ * `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content`
5
+ * because Exa returns no generated answer.
6
+ * @module @x1a0f3n9/dsh-web-search-exa/provider
7
+ */
8
+ import type { WebSearchProvider, WebSearchRequest, WebSearchResult, WebSearchSource } from '@x1a0f3n9/dsh-web';
9
+ import type { ExaResult, ExaSearchResponse } from './types.ts';
10
+ /** Stable id this provider registers under. */
11
+ export declare const EXA_PROVIDER_ID = "exa";
12
+ /** Default Exa search endpoint; `/search` is the operation. */
13
+ export declare const EXA_DEFAULT_BASE_URL = "https://api.exa.ai";
14
+ /** Default retrieval mode: let Exa pick between keyword and neural search. */
15
+ export declare const EXA_DEFAULT_SEARCH_TYPE = "auto";
16
+ /** Default number of highlight sentences requested per result. */
17
+ export declare const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
18
+ /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
19
+ export interface ExaSearchProviderOptions {
20
+ /** Exa API key. Empty/absent makes the provider unavailable. */
21
+ apiKey: string;
22
+ /** Endpoint base; `/search` is appended. */
23
+ baseURL: string;
24
+ /** Retrieval mode sent as Exa's `type`. */
25
+ searchType: 'auto' | 'keyword' | 'neural';
26
+ /** Default result count when a request carries no `maxResults`. */
27
+ numResults?: number;
28
+ /** Highlight sentences requested per result (Exa's `highlightsPerUrl`). */
29
+ highlightsPerResult: number;
30
+ }
31
+ /**
32
+ * Map one Exa result to a normalized source, or `undefined` when it carries no
33
+ * portable snippet (an entry with no highlight is dropped — the seam has no
34
+ * other field to derive a snippet from, and inventing one would lie).
35
+ *
36
+ * @param result - one entry of Exa's `results[]`.
37
+ * @returns the normalized source, or `undefined` when the entry has no
38
+ * non-blank highlight.
39
+ */
40
+ export declare function mapExaResult(result: ExaResult): WebSearchSource | undefined;
41
+ /**
42
+ * Map an Exa response envelope to a normalized search result.
43
+ *
44
+ * @param response - the parsed `POST /search` response body.
45
+ * @returns the normalized result; snippet-less entries are dropped
46
+ * ({@link mapExaResult}).
47
+ */
48
+ export declare function mapExaResponse(response: ExaSearchResponse): WebSearchResult;
49
+ /** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
50
+ export declare class ExaSearchProvider implements WebSearchProvider {
51
+ private readonly options;
52
+ readonly id = "exa";
53
+ constructor(options: ExaSearchProviderOptions);
54
+ available(): boolean;
55
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
56
+ }
57
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Wire types for the Exa search API (`POST https://api.exa.ai/search`). Types
3
+ * only — no runtime code. Exa returns a flat `results[]`; each entry carries a
4
+ * URL, optional title, optional `publishedDate`, and (when highlights are
5
+ * requested) a `highlights[]` array of salient sentences.
6
+ *
7
+ * @module @x1a0f3n9/dsh-web-search-exa/types
8
+ */
9
+ /** Request body sent to Exa's search endpoint. */
10
+ export interface ExaSearchRequest {
11
+ query: string;
12
+ /** Retrieval mode: keyword, neural (embeddings), or auto (Exa decides). */
13
+ type: 'auto' | 'keyword' | 'neural';
14
+ /** Exa's result-count control; the seam still enforces the bound on return. */
15
+ numResults?: number;
16
+ /** Ask Exa to return highlight sentences per result. */
17
+ contents: {
18
+ highlights: {
19
+ highlightsPerUrl: number;
20
+ };
21
+ };
22
+ }
23
+ /** One entry of Exa's flat `results[]`. */
24
+ export interface ExaResult {
25
+ url: string;
26
+ title?: string | null;
27
+ publishedDate?: string | null;
28
+ highlights?: string[];
29
+ }
30
+ /** Exa's search response envelope. */
31
+ export interface ExaSearchResponse {
32
+ results?: ExaResult[];
33
+ }
34
+ /** Exa's error response envelope (best-effort; fields vary by failure). */
35
+ export interface ExaError {
36
+ error?: string;
37
+ message?: string;
38
+ }
39
+ //# sourceMappingURL=types.d.ts.map
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@x1a0f3n9/dsh-web-search-exa",
3
+ "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)",
4
+ "version": "0.1.5-rc.2",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/web/web-search-exa"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/types/**/*.d.ts"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "@x1a0f3n9/dsh-launch-environment": "^0.1.5-rc.2",
31
+ "@x1a0f3n9/dsh-web": "^0.1.5-rc.2",
32
+ "@deepseek-ai/cordis": "^4.0.2"
33
+ },
34
+ "dependencies": {
35
+ "@deepseek-ai/schemastery": "^3.18.2"
36
+ },
37
+ "devDependencies": {
38
+ "@x1a0f3n9/dsh-launch-environment": "^0.1.5-rc.2",
39
+ "@x1a0f3n9/dsh-web": "^0.1.5-rc.2",
40
+ "@deepseek-ai/cordis": "^4.0.2",
41
+ "@x1a0f3n9/dsh-http-proxy": "^0.1.5-rc.2"
42
+ }
43
+ }