@tonydua/dsh-web-search-exa 0.1.0
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 +21 -0
- package/README.i18n.yaml +5 -0
- package/README.md +212 -0
- package/README.zh.md +134 -0
- package/lib/index.js +431 -0
- package/lib/types/index.d.ts +54 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-anything contributors
|
|
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.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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.
|
|
4
|
+
README.md: 43f37d9ed00f718982fecc2684e40f2ce1ea997c
|
|
5
|
+
README.zh.md: c525e2ccac7569211e76321e43982bae1d38342c
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @tonydua/dsh-web-search-exa
|
|
2
|
+
|
|
3
|
+
> **Built with [deepseek-v4-flash](https://api-docs.deepseek.com) inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh).**
|
|
4
|
+
|
|
5
|
+
An Exa-backed `WebSearchProvider` for the DeepSeek Harness web capability seam
|
|
6
|
+
(`ctx.web`), with an **anonymous** fallback: when no API key is configured,
|
|
7
|
+
search routes through Exa's hosted MCP server (`https://mcp.exa.ai/mcp`) via
|
|
8
|
+
JSON-RPC 2.0 with **no credentials at all** — Exa's documented unauthenticated
|
|
9
|
+
public MCP fallback (rate-limited). With an `EXA_API_KEY`, the lighter REST
|
|
10
|
+
endpoint is used instead.
|
|
11
|
+
|
|
12
|
+
This is an implementation package: it registers a provider INTO `ctx.web`
|
|
13
|
+
(`inject: ['web']`) and owns no model-facing tools (those belong to
|
|
14
|
+
`@deepseek-ai/dsh-tool-web`). The model-facing `web_search` / `web_fetch` tools,
|
|
15
|
+
their prompt sections, and the Web result cards are unchanged.
|
|
16
|
+
|
|
17
|
+
## Why this package exists (difference from the official one)
|
|
18
|
+
|
|
19
|
+
The DeepSeek Harness ships an official Exa provider,
|
|
20
|
+
[`@deepseek-ai/dsh-web-search-exa`](https://www.npmjs.com/package/@deepseek-ai/dsh-web-search-exa).
|
|
21
|
+
This package is the **zero-config variant**: it adds the anonymous MCP fallback
|
|
22
|
+
the official one does not have, and keeps the same keyed REST behavior when an
|
|
23
|
+
API key is configured.
|
|
24
|
+
|
|
25
|
+
| | Official `@deepseek-ai/dsh-web-search-exa` | This package `@tonydua/dsh-web-search-exa` |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| REST path (`POST /search`) | ✅ only path | ✅ used when a key is configured |
|
|
28
|
+
| Requires an API key | ✅ **yes — empty key makes it unavailable** | ❌ no — keyless anonymous MCP fallback |
|
|
29
|
+
| Anonymous MCP (`mcp.exa.ai/mcp`) | ❌ not implemented | ✅ default when no key |
|
|
30
|
+
| Zero-config install | ❌ | ✅ |
|
|
31
|
+
| Provider id | `exa` (fixed) | `exa` by default, **configurable via `providerId`** |
|
|
32
|
+
| Cordis plugin name | `web-search-exa` | `web-search-exa` |
|
|
33
|
+
| Config keys | `apiKey`, `baseURL`, `searchType`, `numResults`, `highlightsPerResult` | `apiKey`, `apiKeyEnv`, `apiURL`, `mcpURL`, `searchType`, `numResults`, `highlightsPerResult`, `providerId` |
|
|
34
|
+
|
|
35
|
+
## Acknowledgements
|
|
36
|
+
|
|
37
|
+
The anonymous MCP integration follows the `web_search` implementation in
|
|
38
|
+
[can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) (`packages/coding-agent/src/web/search/providers/exa.ts`
|
|
39
|
+
and `src/exa/mcp-client.ts`) and the
|
|
40
|
+
[`@oh-my-pi/exa`](https://www.npmjs.com/package/@oh-my-pi/exa) plugin: same
|
|
41
|
+
"REST when a key exists, credential-free `mcp.exa.ai/mcp` otherwise" strategy,
|
|
42
|
+
same `x-exa-source` attribution header, same `Title:`-section response parsing.
|
|
43
|
+
Thanks to the oh-my-pi (omp) project for pioneering the zero-config Exa
|
|
44
|
+
integration.
|
|
45
|
+
|
|
46
|
+
Thanks also to **[Exa](https://exa.ai)** for providing and operating the
|
|
47
|
+
**free, unauthenticated hosted MCP server** (`mcp.exa.ai/mcp`) that makes this
|
|
48
|
+
package's zero-config default possible. Exa's hosted MCP is an official Exa
|
|
49
|
+
product; anonymous usage is rate-limited (see Known limitations).
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
| Condition | Path | Endpoint |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `apiKey` / `EXA_API_KEY` set | REST `POST /search` with `Authorization: Bearer` | `https://api.exa.ai/search` (configurable) |
|
|
56
|
+
| No key configured | Anonymous MCP `tools/call web_search_exa` (JSON-RPC 2.0, no credentials) | `https://mcp.exa.ai/mcp` (configurable) |
|
|
57
|
+
|
|
58
|
+
The anonymous MCP path sends no credentials; attribution rides the
|
|
59
|
+
`x-exa-source: dsh-anything` header. Results are normalized to the seam's
|
|
60
|
+
`WebSearchSource` shape (`url`, `title`, `snippet`, `publishedAt`) and the seam
|
|
61
|
+
enforces `maxResults` on the way back. Anonymous usage is rate-limited by Exa:
|
|
62
|
+
an HTTP 429 surfaces as a `WEB_PROVIDER_ERROR` with a hint to configure an API
|
|
63
|
+
key (which also switches to the REST path automatically).
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
| Key | Default | Meaning |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `providerId` | `exa` | Provider id registered into `ctx.web`. Only change it when both this and the official package are installed (see next section). |
|
|
70
|
+
| `apiKey` | unset | Literal Exa API key. Empty/missing enables the anonymous MCP path. |
|
|
71
|
+
| `apiKeyEnv` | `EXA_API_KEY` | Environment variable consulted when no literal `apiKey` is set. |
|
|
72
|
+
| `apiURL` | `https://api.exa.ai/search` | REST search endpoint (keyed path only). |
|
|
73
|
+
| `mcpURL` | `https://mcp.exa.ai/mcp` | Exa hosted MCP endpoint (anonymous path). |
|
|
74
|
+
| `searchType` | `auto` | REST retrieval mode: `auto` / `keyword` / `neural`. |
|
|
75
|
+
| `numResults` | unset | Default result count when the request carries no `maxResults`. |
|
|
76
|
+
| `highlightsPerResult` | `1` | Highlight sentences requested per result on the REST path. |
|
|
77
|
+
|
|
78
|
+
## Coexistence with the official package
|
|
79
|
+
|
|
80
|
+
Both packages register their provider under the **same default provider id
|
|
81
|
+
(`exa`)** and the same cordis plugin name (`web-search-exa`). The seam rejects
|
|
82
|
+
duplicate ids with `WEB_DUPLICATE_PROVIDER`, so **installing both into one
|
|
83
|
+
profile without changes breaks at startup**.
|
|
84
|
+
|
|
85
|
+
There is **no silent override** — coexistence is explicit, via the `providerId`
|
|
86
|
+
switch:
|
|
87
|
+
|
|
88
|
+
1. Keep the official package on `exa` (its id is fixed).
|
|
89
|
+
2. Give this package a distinct id — set `providerId: exa-anon` (any unique
|
|
90
|
+
string) in this plugin's `config`.
|
|
91
|
+
3. Select the anonymous variant explicitly with
|
|
92
|
+
`searchProvider: exa-anon` on the `web` seam (or
|
|
93
|
+
`$DSH_WEB_SEARCH_PROVIDER=exa-anon`), and keep
|
|
94
|
+
`searchProvider: exa` → the official one if you want it selectable too.
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
- insert:
|
|
98
|
+
- id: web-search-exa
|
|
99
|
+
name: '@tonydua/dsh-web-search-exa'
|
|
100
|
+
config:
|
|
101
|
+
providerId: exa-anon
|
|
102
|
+
- id: web
|
|
103
|
+
name: '@deepseek-ai/dsh-web'
|
|
104
|
+
config:
|
|
105
|
+
searchProvider: exa-anon
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Simplest alternative: install only one of the two packages per profile — the
|
|
109
|
+
defaults then work as-is.
|
|
110
|
+
|
|
111
|
+
## Installation (into a dsh profile)
|
|
112
|
+
|
|
113
|
+
From this repository:
|
|
114
|
+
|
|
115
|
+
```powershell
|
|
116
|
+
dsh plugin --profile web add ../plugins/dsh-web-search-exa
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Then enable the provider and select it. Either merge into
|
|
120
|
+
`$DSH_HOME/profiles/web/cordis.patch.yml` (persistent):
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
- id: web-search-exa
|
|
124
|
+
name: '@tonydua/dsh-web-search-exa'
|
|
125
|
+
config:
|
|
126
|
+
apiKeyEnv: EXA_API_KEY
|
|
127
|
+
- id: web
|
|
128
|
+
name: '@deepseek-ai/dsh-web'
|
|
129
|
+
config:
|
|
130
|
+
searchProvider: exa
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
…or pass `patches/exa-anon-search.patch.yml` as an overlay:
|
|
134
|
+
|
|
135
|
+
```powershell
|
|
136
|
+
dsh --profile web --patch patches/exa-anon-search.patch.yml
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Alternatively, select the provider at runtime with the environment variable
|
|
140
|
+
`$DSH_WEB_SEARCH_PROVIDER=exa` (no config edit needed).
|
|
141
|
+
|
|
142
|
+
Restart `dsh web` for changes to take effect. The existing model-facing
|
|
143
|
+
`web_search` tool then routes through this provider — no tool config changes.
|
|
144
|
+
|
|
145
|
+
### Runtime singleton compatibility
|
|
146
|
+
|
|
147
|
+
`@deepseek-ai/dsh-tools` is a dsh runtime singleton and must resolve to one
|
|
148
|
+
physical package instance in a profile. This provider does not depend on it;
|
|
149
|
+
the requirement belongs to the host profile. If another third-party plugin
|
|
150
|
+
installs `@deepseek-ai/dsh-tools` as a nested regular dependency instead of a
|
|
151
|
+
peer dependency, fix that plugin's dependency declaration or make the profile
|
|
152
|
+
package manager resolve the shared instance before debugging search errors.
|
|
153
|
+
Otherwise dsh's agent loop can fail before the provider is called with an
|
|
154
|
+
error such as `Cannot read properties of undefined (reading 'prepare')`.
|
|
155
|
+
|
|
156
|
+
## In the Web panel
|
|
157
|
+
|
|
158
|
+
**Status: configuration is done in the profile patch layer, not the Web UI —
|
|
159
|
+
this version ships no editable UI entry.** The Settings UI only renders cards
|
|
160
|
+
that are hand-registered by client plugins for fixed namespaces (`shell`,
|
|
161
|
+
`agent-loop`, `web-search-deepseek`); it has no generic form for arbitrary
|
|
162
|
+
plugin namespaces. What is true today:
|
|
163
|
+
|
|
164
|
+
- **Plugin inventory** (Settings → Plugins): the entry appears automatically
|
|
165
|
+
as `web-search-exa` (`@tonydua/dsh-web-search-exa`) once enabled — the
|
|
166
|
+
inventory reads the live Cordis loader, no extra code needed.
|
|
167
|
+
- **Settings namespace** (server-side): the plugin registers the
|
|
168
|
+
`web-search-exa` section via `installSettingsSection`, so the data layer is
|
|
169
|
+
writable — but **no client card binds to it**, so nothing shows in the UI.
|
|
170
|
+
The built-in "Web search" card edits the official
|
|
171
|
+
`web-search-deepseek` namespace, not this plugin.
|
|
172
|
+
- **Changing configuration today**: edit the plugin's `config` in
|
|
173
|
+
`$DSH_HOME/profiles/web/cordis.patch.yml` (fields and defaults in the table
|
|
174
|
+
above) and restart `dsh web`; or set `EXA_API_KEY` / `$DSH_WEB_SEARCH_PROVIDER`
|
|
175
|
+
as environment variables. The `apiKey` field is `role('secret')`: it never
|
|
176
|
+
appears in `describe()` responses.
|
|
177
|
+
- **Search result cards**: `web_search` calls render the usual `web` cards
|
|
178
|
+
(sources, snippets, dates) through `dsh-tool-web`, independent of the
|
|
179
|
+
provider — anonymous Exa results display exactly like DeepSeek ones.
|
|
180
|
+
|
|
181
|
+
**Roadmap (next version)**: a client-side card registered into the
|
|
182
|
+
`settings.plugin.item` slot bound to the `web-search-exa` namespace, so all
|
|
183
|
+
fields above become editable live in Settings → Plugins (mirroring how the
|
|
184
|
+
official cards work).
|
|
185
|
+
|
|
186
|
+
## Open-source notes
|
|
187
|
+
|
|
188
|
+
- License: MIT (see `LICENSE`).
|
|
189
|
+
- Repository: <https://github.com/TonyDua/dsh-web-search-exa>.
|
|
190
|
+
- The package is npm-packable: `publishConfig.access: public`, `files` limited
|
|
191
|
+
to `lib/`, ESM with an `exports` map and bundled type declarations. Run
|
|
192
|
+
`npm test` and `npm pack --dry-run` before publishing.
|
|
193
|
+
- To publish: `npm login` (against `registry.npmjs.org`, not the npmmirror
|
|
194
|
+
default in your `.npmrc`), then
|
|
195
|
+
`npm publish --registry https://registry.npmjs.org --cache <writable-cache>`.
|
|
196
|
+
The `@tonydua` scope is claimed on first publish by the publishing account.
|
|
197
|
+
|
|
198
|
+
## Known limitations
|
|
199
|
+
|
|
200
|
+
- **Anonymous MCP is rate-limited** (HTTP 429 → `WEB_PROVIDER_ERROR` with a
|
|
201
|
+
hint). Configure `EXA_API_KEY` for higher limits; the provider then uses the
|
|
202
|
+
REST path automatically.
|
|
203
|
+
- **Snippet-less results are dropped** (seam rule: no portable snippet).
|
|
204
|
+
Anonymous MCP usually returns full `Text` blocks, which are truncated to
|
|
205
|
+
`MAX_SNIPPET_CHARS` (500) for the snippet.
|
|
206
|
+
- **Only `query` + result-count controls are exposed**; recency, domain
|
|
207
|
+
filters, and deep-search modes are not mapped (they can be added later, as
|
|
208
|
+
the seam's provider-neutral fields evolve).
|
|
209
|
+
- Anonymous MCP responses are parsed from the `Title:`-section text format;
|
|
210
|
+
structural changes to Exa's hosted MCP output may require a parser update.
|
|
211
|
+
- **No Web UI settings entry yet** — configuration goes through the profile
|
|
212
|
+
patch layer (see "In the Web panel").
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @tonydua/dsh-web-search-exa
|
|
2
|
+
|
|
3
|
+
> **使用 [deepseek-v4-flash](https://api-docs.deepseek.com) 与 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)开发。**
|
|
4
|
+
|
|
5
|
+
为 DeepSeek Harness web 能力 seam(`ctx.web`)提供的 Exa 搜索提供方(`WebSearchProvider`),内置**匿名兜底**:未配置 API key 时,搜索经由 Exa 官方托管的 MCP 服务器(`https://mcp.exa.ai/mcp`)以 JSON-RPC 2.0 发起,**完全不携带任何凭据** —— 即 Exa 官方提供的免认证公共 MCP fallback(有限流)。配置了 `EXA_API_KEY` 时则改用更轻量的 REST 端点。
|
|
6
|
+
|
|
7
|
+
这是一个**实现**包:它向 `ctx.web` 注册提供方(`inject: ['web']`),不拥有面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。模型侧的 `web_search` / `web_fetch` 工具、提示词区段与 Web 结果卡片均不受影响。
|
|
8
|
+
|
|
9
|
+
## 为什么有这个包(与官方包的差异)
|
|
10
|
+
|
|
11
|
+
DeepSeek Harness 自带官方 Exa 提供方 [`@deepseek-ai/dsh-web-search-exa`](https://www.npmjs.com/package/@deepseek-ai/dsh-web-search-exa)。本包是它的**零配置变体**:补上了官方没有的匿名 MCP 兜底,同时保留配置 key 后的相同 REST 行为。
|
|
12
|
+
|
|
13
|
+
| | 官方 `@deepseek-ai/dsh-web-search-exa` | 本包 `@tonydua/dsh-web-search-exa` |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| REST 路径(`POST /search`) | ✅ 唯一路径 | ✅ 配置 key 时使用 |
|
|
16
|
+
| 必须有 API key | ✅ **是——key 为空则不可用** | ❌ 不需要——无 key 走匿名 MCP 兜底 |
|
|
17
|
+
| 匿名 MCP(`mcp.exa.ai/mcp`) | ❌ 未实现 | ✅ 无 key 时默认路径 |
|
|
18
|
+
| 零配置安装 | ❌ | ✅ |
|
|
19
|
+
| Provider id | `exa`(固定) | 默认 `exa`,**可用 `providerId` 配置** |
|
|
20
|
+
| Cordis 插件名 | `web-search-exa` | `web-search-exa` |
|
|
21
|
+
| 配置键 | `apiKey`、`baseURL`、`searchType`、`numResults`、`highlightsPerResult` | `apiKey`、`apiKeyEnv`、`apiURL`、`mcpURL`、`searchType`、`numResults`、`highlightsPerResult`、`providerId` |
|
|
22
|
+
|
|
23
|
+
## 致谢(Acknowledgements)
|
|
24
|
+
|
|
25
|
+
匿名 MCP 接入方式参考了 [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) 的 `web_search` 实现(`packages/coding-agent/src/web/search/providers/exa.ts` 与 `src/exa/mcp-client.ts`)以及 [`@oh-my-pi/exa`](https://www.npmjs.com/package/@oh-my-pi/exa) 插件:同样的"有 key 走 REST、无 key 走免凭据 `mcp.exa.ai/mcp`"策略、同样的 `x-exa-source` 来源头、同样的 `Title:` 分节响应解析。感谢 oh-my-pi(omp)项目率先打通了零配置的 Exa 接入。
|
|
26
|
+
|
|
27
|
+
同时感谢 **[Exa](https://exa.ai)** 提供并运营这个**免费、免认证的托管 MCP 服务器**(`mcp.exa.ai/mcp`)——正是它让本包的零配置默认路径成为可能。Exa 托管 MCP 是 Exa 的官方产品;匿名使用有限流(见"已知限制")。
|
|
28
|
+
|
|
29
|
+
## 工作原理
|
|
30
|
+
|
|
31
|
+
| 条件 | 路径 | 端点 |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| 配置了 `apiKey` / `EXA_API_KEY` | REST `POST /search`,`Authorization: Bearer` | `https://api.exa.ai/search`(可配置) |
|
|
34
|
+
| 未配置任何 key | 匿名 MCP `tools/call web_search_exa`(JSON-RPC 2.0,无凭据) | `https://mcp.exa.ai/mcp`(可配置) |
|
|
35
|
+
|
|
36
|
+
匿名 MCP 路径不发送任何凭据,来源标识通过 `x-exa-source: dsh-anything` 头携带。结果按 seam 的 `WebSearchSource` 形状规范化(`url`、`title`、`snippet`、`publishedAt`),`maxResults` 由 seam 在返回路径上强制执行。匿名使用受 Exa 限流:HTTP 429 会以 `WEB_PROVIDER_ERROR` 呈现,并提示配置 API key(配置后自动切换到 REST 路径)。
|
|
37
|
+
|
|
38
|
+
## 配置
|
|
39
|
+
|
|
40
|
+
| 配置键 | 默认值 | 含义 |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `providerId` | `exa` | 注册进 `ctx.web` 的提供方 id。仅当本包与官方包同时安装时才需要改(见下一节)。 |
|
|
43
|
+
| `apiKey` | 未设置 | Exa API 密钥字面值。为空/缺失时启用匿名 MCP 路径。 |
|
|
44
|
+
| `apiKeyEnv` | `EXA_API_KEY` | 未设置字面 `apiKey` 时读取的环境变量名。 |
|
|
45
|
+
| `apiURL` | `https://api.exa.ai/search` | REST 搜索端点(仅带 key 的路径使用)。 |
|
|
46
|
+
| `mcpURL` | `https://mcp.exa.ai/mcp` | Exa 托管 MCP 端点(匿名路径使用)。 |
|
|
47
|
+
| `searchType` | `auto` | REST 检索模式:`auto` / `keyword` / `neural`。 |
|
|
48
|
+
| `numResults` | 未设置 | 请求未携带 `maxResults` 时的默认结果数。 |
|
|
49
|
+
| `highlightsPerResult` | `1` | REST 路径每个结果请求的 highlight 句子数。 |
|
|
50
|
+
|
|
51
|
+
## 与官方包共存
|
|
52
|
+
|
|
53
|
+
两个包默认在 `ctx.web` 下注册**相同的 provider id(`exa`)**,cordis 插件名也都是 `web-search-exa`。seam 会拒绝重复 id(`WEB_DUPLICATE_PROVIDER`),所以**不改配置就把两个包装进同一个 profile 会在启动时报错**。
|
|
54
|
+
|
|
55
|
+
**没有黑箱覆盖**——共存必须显式配置,通过 `providerId` 开关完成:
|
|
56
|
+
|
|
57
|
+
1. 官方包保持 `exa`(它的 id 固定)。
|
|
58
|
+
2. 给本包一个不同 id——在本插件 `config` 里设 `providerId: exa-anon`(任意唯一字符串)。
|
|
59
|
+
3. 在 `web` seam 上显式选中匿名变体:`searchProvider: exa-anon`(或用环境变量 `$DSH_WEB_SEARCH_PROVIDER=exa-anon`);若还想用官方包,再配 `searchProvider: exa` 切换。
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- insert:
|
|
63
|
+
- id: web-search-exa
|
|
64
|
+
name: '@tonydua/dsh-web-search-exa'
|
|
65
|
+
config:
|
|
66
|
+
providerId: exa-anon
|
|
67
|
+
- id: web
|
|
68
|
+
name: '@deepseek-ai/dsh-web'
|
|
69
|
+
config:
|
|
70
|
+
searchProvider: exa-anon
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
最简单的替代方案:每个 profile 只装其中一个包,默认配置即可直接使用。
|
|
74
|
+
|
|
75
|
+
## 安装(装入 dsh profile)
|
|
76
|
+
|
|
77
|
+
在本仓库目录下:
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
dsh plugin --profile web add ../plugins/dsh-web-search-exa
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
然后启用并选中该提供方。合并进 `$DSH_HOME/profiles/web/cordis.patch.yml`(持久生效):
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
- id: web-search-exa
|
|
87
|
+
name: '@tonydua/dsh-web-search-exa'
|
|
88
|
+
config:
|
|
89
|
+
apiKeyEnv: EXA_API_KEY
|
|
90
|
+
- id: web
|
|
91
|
+
name: '@deepseek-ai/dsh-web'
|
|
92
|
+
config:
|
|
93
|
+
searchProvider: exa
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
或把 `patches/exa-anon-search.patch.yml` 作为覆盖层传入:
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
dsh --profile web --patch patches/exa-anon-search.patch.yml
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
也可以不修改配置,直接用环境变量 `$DSH_WEB_SEARCH_PROVIDER=exa` 在运行时选中该提供方。
|
|
103
|
+
|
|
104
|
+
重启 `dsh web` 生效。模型侧的 `web_search` 工具随即走该提供方,无需改任何工具配置。
|
|
105
|
+
|
|
106
|
+
### 运行时单例兼容性
|
|
107
|
+
|
|
108
|
+
`@deepseek-ai/dsh-tools` 是 dsh 的运行时单例包,一个 profile 中必须解析到同一份物理包实例。本插件本身不依赖它;这是宿主 profile 的依赖约束。如果 profile 中的其他第三方插件把 `@deepseek-ai/dsh-tools` 错误声明成普通嵌套依赖,而不是 peer dependency,应先修正该插件的依赖声明,或让 profile 的包管理器统一解析到共享实例,再排查搜索错误。否则 dsh agent loop 可能在 provider 被调用前就因 `Cannot read properties of undefined (reading 'prepare')` 失败。
|
|
109
|
+
|
|
110
|
+
## 在 Web 面板中的呈现
|
|
111
|
+
|
|
112
|
+
**状态:本版本的配置入口在 profile 补丁层,不在 Web UI —— 没有可编辑的界面入口。** Settings UI 只渲染客户端插件为固定命名空间(`shell`、`agent-loop`、`web-search-deepseek`)手工注册的卡片,对任意插件命名空间没有通用表单。当前实际情况:
|
|
113
|
+
|
|
114
|
+
- **插件清单**(Settings → Plugins):启用后自动出现 `web-search-exa`(`@tonydua/dsh-web-search-exa`)条目 —— 清单直接读取 Cordis loader 的实时条目,无需额外代码。
|
|
115
|
+
- **设置命名空间**(服务端):插件通过 `installSettingsSection` 注册了 `web-search-exa` 段,数据层可写——但**没有任何客户端卡片绑定它**,所以界面上不显示。内置的 "Web search" 卡片编辑的是官方 `web-search-deepseek` 命名空间,与本插件无关。
|
|
116
|
+
- **现在怎么改配置**:编辑 `$DSH_HOME/profiles/web/cordis.patch.yml` 里本插件的 `config`(字段与默认值见上方配置表),重启 `dsh web`;或用环境变量 `EXA_API_KEY` / `$DSH_WEB_SEARCH_PROVIDER`。`apiKey` 标记了 `role('secret')`,任何 `describe()` 响应都不会暴露其值。
|
|
117
|
+
- **搜索结果卡片**:`web_search` 调用经 `dsh-tool-web` 照常渲染 `web` 结果卡片(来源、摘要、日期),与提供方无关 —— 匿名 Exa 的结果与 DeepSeek 搜索显示完全一致。
|
|
118
|
+
|
|
119
|
+
**路线图(下一版本)**:新增注册到 `settings.plugin.item` slot 的客户端卡片,绑定 `web-search-exa` 命名空间,让上表所有字段可以在 Settings → Plugins 里实时编辑(与官方卡片同机制)。
|
|
120
|
+
|
|
121
|
+
## 开源说明
|
|
122
|
+
|
|
123
|
+
- 许可证:MIT(见 `LICENSE`)。
|
|
124
|
+
- 仓库:<https://github.com/TonyDua/dsh-web-search-exa>。
|
|
125
|
+
- 包已可执行 npm 打包:`publishConfig.access: public`、`files` 仅含 `lib/`、ESM 且带 `exports` 映射与类型声明。发布前请执行 `npm test` 和 `npm pack --dry-run`。
|
|
126
|
+
- 发布:先 `npm login`(对着 `registry.npmjs.org`,不是 `.npmrc` 里的 npmmirror 默认源),再 `npm publish --registry https://registry.npmjs.org --cache <可写缓存目录>`。`@tonydua` scope 会在首次发布时自动归属到发布账号。
|
|
127
|
+
|
|
128
|
+
## 已知限制
|
|
129
|
+
|
|
130
|
+
- **匿名 MCP 有限流**(HTTP 429 → `WEB_PROVIDER_ERROR` 并附提示)。配置 `EXA_API_KEY` 可提额,提供方会自动切到 REST 路径。
|
|
131
|
+
- **无 snippet 的结果会被丢弃**(seam 规则:没有可移植的摘要)。匿名 MCP 通常返回完整 `Text` 块,会截断到 `MAX_SNIPPET_CHARS`(500 字符)作为 snippet。
|
|
132
|
+
- **只暴露查询与结果数控制**:新近程度、域名过滤、深度搜索等尚未映射(可等 seam 的提供方无关字段扩展后补充)。
|
|
133
|
+
- 匿名 MCP 响应按 `Title:` 分节文本格式解析;Exa 托管 MCP 输出结构变化时可能需要更新解析器。
|
|
134
|
+
- **暂无 Web UI 设置入口**——配置走 profile 补丁层(见"在 Web 面板中的呈现")。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
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";
|
|
24
|
+
import z from "@deepseek-ai/schemastery";
|
|
25
|
+
|
|
26
|
+
/** Default provider id this provider registers under (`ctx.web` registry key). */
|
|
27
|
+
const DEFAULT_PROVIDER_ID = "exa";
|
|
28
|
+
/** Backward-compatible alias for the default provider id. */
|
|
29
|
+
const PROVIDER_ID = DEFAULT_PROVIDER_ID;
|
|
30
|
+
/** Exa REST search endpoint; used only when an API key is configured. */
|
|
31
|
+
const DEFAULT_API_URL = "https://api.exa.ai/search";
|
|
32
|
+
/** Exa hosted MCP endpoint; the anonymous fallback path. */
|
|
33
|
+
const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
34
|
+
/** Environment variable consulted when no literal `apiKey` is configured. */
|
|
35
|
+
const DEFAULT_API_KEY_ENV = "EXA_API_KEY";
|
|
36
|
+
/** Default retrieval mode for the REST path: let Exa pick. */
|
|
37
|
+
const DEFAULT_SEARCH_TYPE = "auto";
|
|
38
|
+
/** Default number of highlight sentences requested per result (REST path). */
|
|
39
|
+
const DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
|
|
40
|
+
/** MCP tool name for plain web search on Exa's hosted server. */
|
|
41
|
+
const MCP_TOOL = "web_search_exa";
|
|
42
|
+
/** Attribution header sent on anonymous MCP requests. Bump with the version. */
|
|
43
|
+
const MCP_SOURCE = "dsh-anything";
|
|
44
|
+
/** User agent for REST requests. */
|
|
45
|
+
const USER_AGENT = "deepseek-harness-exa/0.1.0";
|
|
46
|
+
/** Snippet cap for text-derived snippets (matching oh-my-pi's choice). */
|
|
47
|
+
const MAX_SNIPPET_CHARS = 500;
|
|
48
|
+
/** Settings namespace carrying this provider's configuration. */
|
|
49
|
+
const SETTINGS_NAMESPACE = settingsNamespace("web-search-exa");
|
|
50
|
+
|
|
51
|
+
/** True for a positive whole number (cheap local config check). */
|
|
52
|
+
function isPositiveInteger(value) {
|
|
53
|
+
return Number.isInteger(value) && value > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
57
|
+
function isAbortError(error) {
|
|
58
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Throw the seam's stable cancellation error when the caller is already aborted. */
|
|
62
|
+
function throwIfAborted(signal) {
|
|
63
|
+
if (signal?.aborted === true) {
|
|
64
|
+
throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal.reason });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the API key: literal config first, then the environment variable.
|
|
70
|
+
* `undefined` means the anonymous MCP path is used.
|
|
71
|
+
*/
|
|
72
|
+
function resolveApiKey(options) {
|
|
73
|
+
if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
|
|
74
|
+
const fromEnv = process.env[options.apiKeyEnv];
|
|
75
|
+
if (fromEnv != null && fromEnv.length > 0) return fromEnv;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── REST path (with API key) ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map one Exa REST result to a normalized source, or `undefined` when it has
|
|
83
|
+
* no portable snippet (same rule as the official provider).
|
|
84
|
+
*/
|
|
85
|
+
function mapRestResult(result) {
|
|
86
|
+
const snippet = result.highlights?.find((highlight) => highlight.trim().length > 0);
|
|
87
|
+
if (snippet === undefined) return undefined;
|
|
88
|
+
return {
|
|
89
|
+
url: result.url,
|
|
90
|
+
...result.title != null && result.title.length > 0 ? { title: result.title } : {},
|
|
91
|
+
snippet,
|
|
92
|
+
...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Anonymous MCP path (no API key) ─────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse an SSE (`text/event-stream`) response body into its first `data:`
|
|
100
|
+
* payload, falling back to plain JSON. Returns `null` when neither parses.
|
|
101
|
+
*/
|
|
102
|
+
function parseSsePayload(text) {
|
|
103
|
+
const dataLines = text.split(/\r?\n/)
|
|
104
|
+
.filter((line) => line.startsWith("data:"))
|
|
105
|
+
.map((line) => line.slice(5).replace(/^\s/, ""));
|
|
106
|
+
if (dataLines.length > 0) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(dataLines.join("\n"));
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(text);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Collect non-blank `content[].text` blocks from a normalized MCP result
|
|
122
|
+
* payload, joined with blank lines.
|
|
123
|
+
*/
|
|
124
|
+
function collectMcpText(payload) {
|
|
125
|
+
const content = payload?.result?.content;
|
|
126
|
+
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);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse one `Title:`-led section of Exa MCP text output into a partial source.
|
|
134
|
+
* Handles both `Published:` and `Published Date:` field spellings.
|
|
135
|
+
*/
|
|
136
|
+
function parseExaSection(section) {
|
|
137
|
+
const out = {};
|
|
138
|
+
let field = null;
|
|
139
|
+
let textLines = null;
|
|
140
|
+
for (const line of section.split("\n")) {
|
|
141
|
+
const title = line.match(/^Title:\s*(.*)$/);
|
|
142
|
+
const url = line.match(/^URL:\s*(.*)$/);
|
|
143
|
+
const published = line.match(/^Published(?: Date)?:\s*(.*)$/);
|
|
144
|
+
const author = line.match(/^Author:\s*(.*)$/);
|
|
145
|
+
if (title) {
|
|
146
|
+
out.title = title[1].trim();
|
|
147
|
+
field = null;
|
|
148
|
+
} else if (url) {
|
|
149
|
+
out.url = url[1].trim();
|
|
150
|
+
field = null;
|
|
151
|
+
} else if (published) {
|
|
152
|
+
out.publishedAt = published[1].trim();
|
|
153
|
+
field = null;
|
|
154
|
+
} else if (author) {
|
|
155
|
+
out.author = author[1].trim();
|
|
156
|
+
field = null;
|
|
157
|
+
} else if (/^Highlights:\s*$/.test(line)) {
|
|
158
|
+
field = "highlights";
|
|
159
|
+
} else if (/^Text:\s*$/.test(line)) {
|
|
160
|
+
field = "text";
|
|
161
|
+
textLines = [];
|
|
162
|
+
} else if (field === "highlights") {
|
|
163
|
+
const trimmed = line.trim();
|
|
164
|
+
if (trimmed.length > 0) {
|
|
165
|
+
out.highlights ??= [];
|
|
166
|
+
out.highlights.push(trimmed.replace(/^[-•]\s*/, ""));
|
|
167
|
+
}
|
|
168
|
+
} else if (field === "text" && textLines !== null) {
|
|
169
|
+
textLines.push(line);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (textLines !== null) out.text = textLines.join("\n").trim();
|
|
173
|
+
if (out.publishedAt === "N/A") delete out.publishedAt;
|
|
174
|
+
if (out.author === "N/A") delete out.author;
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Split joined MCP text into per-result sections, each starting with `Title:`. */
|
|
179
|
+
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:"));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Map parsed Exa MCP sections to normalized sources (snippet-less entries dropped). */
|
|
187
|
+
function mapMcpSections(sections) {
|
|
188
|
+
const sources = [];
|
|
189
|
+
for (const section of sections) {
|
|
190
|
+
const parsed = parseExaSection(section);
|
|
191
|
+
if (!parsed.url || parsed.url.length === 0) continue;
|
|
192
|
+
const highlight = parsed.highlights?.find((item) => item.trim().length > 0);
|
|
193
|
+
const snippet = highlight ?? (parsed.text ? parsed.text.slice(0, MAX_SNIPPET_CHARS) : undefined);
|
|
194
|
+
if (snippet === undefined) continue;
|
|
195
|
+
sources.push({
|
|
196
|
+
url: parsed.url,
|
|
197
|
+
...parsed.title != null && parsed.title.length > 0 ? { title: parsed.title } : {},
|
|
198
|
+
snippet,
|
|
199
|
+
...parsed.publishedAt != null && parsed.publishedAt.length > 0 ? { publishedAt: parsed.publishedAt } : {},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return sources;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Provider ────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Project one resolved configuration section into the options the provider
|
|
209
|
+
* serves its next search with. Called per operation so live Settings edits
|
|
210
|
+
* take effect on the next search.
|
|
211
|
+
*/
|
|
212
|
+
function resolveOptions(section) {
|
|
213
|
+
return {
|
|
214
|
+
providerId: section.providerId ?? DEFAULT_PROVIDER_ID,
|
|
215
|
+
apiKey: section.apiKey ?? "",
|
|
216
|
+
apiKeyEnv: section.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
|
|
217
|
+
apiURL: section.apiURL ?? DEFAULT_API_URL,
|
|
218
|
+
mcpURL: section.mcpURL ?? DEFAULT_MCP_URL,
|
|
219
|
+
searchType: section.searchType ?? DEFAULT_SEARCH_TYPE,
|
|
220
|
+
numResults: section.numResults,
|
|
221
|
+
highlightsPerResult: section.highlightsPerResult ?? DEFAULT_HIGHLIGHTS_PER_RESULT,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
class ExaSearchProvider {
|
|
226
|
+
resolveOptions;
|
|
227
|
+
id;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param resolveOptions - thunk returning the options for the NEXT
|
|
231
|
+
* operation, snapshotted once at each operation's entry so one search
|
|
232
|
+
* never mixes two settings sections (same pattern as the official
|
|
233
|
+
* DeepSeek provider).
|
|
234
|
+
*/
|
|
235
|
+
constructor(resolveOptions) {
|
|
236
|
+
this.resolveOptions = resolveOptions;
|
|
237
|
+
this.id = resolveOptions().providerId ?? PROVIDER_ID;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The anonymous MCP path needs no credentials, so the provider is always usable. */
|
|
241
|
+
available() {
|
|
242
|
+
const options = this.resolveOptions();
|
|
243
|
+
return URL.canParse(options.apiURL) && URL.canParse(options.mcpURL);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async search(request, signal) {
|
|
247
|
+
throwIfAborted(signal);
|
|
248
|
+
const options = this.resolveOptions();
|
|
249
|
+
const apiKey = resolveApiKey(options);
|
|
250
|
+
return apiKey !== undefined
|
|
251
|
+
? await this.#restSearch(request, apiKey, options, signal)
|
|
252
|
+
: await this.#anonymousMcpSearch(request, options, signal);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** REST search with an API key: `POST {apiURL}` with Bearer auth. */
|
|
256
|
+
async #restSearch(request, apiKey, options, signal) {
|
|
257
|
+
throwIfAborted(signal);
|
|
258
|
+
const numResults = request.maxResults ?? options.numResults;
|
|
259
|
+
let response;
|
|
260
|
+
try {
|
|
261
|
+
response = await fetch(options.apiURL, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
redirect: "error",
|
|
264
|
+
headers: {
|
|
265
|
+
"authorization": `Bearer ${apiKey}`,
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
"accept": "application/json",
|
|
268
|
+
"user-agent": USER_AGENT,
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
query: request.query,
|
|
272
|
+
type: options.searchType,
|
|
273
|
+
contents: { highlights: { highlightsPerUrl: options.highlightsPerResult } },
|
|
274
|
+
...numResults !== undefined ? { numResults } : {},
|
|
275
|
+
}),
|
|
276
|
+
...signal !== undefined ? { signal } : {},
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
|
|
280
|
+
throw new WebError(`Exa search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
281
|
+
}
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
let message = `Exa API error (HTTP ${response.status})`;
|
|
284
|
+
try {
|
|
285
|
+
const parsed = await response.json();
|
|
286
|
+
const detail = parsed.error ?? parsed.message;
|
|
287
|
+
if (detail !== undefined && detail.length > 0) message = detail;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
|
|
290
|
+
// keep the generic message
|
|
291
|
+
}
|
|
292
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
293
|
+
}
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = await response.json();
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
|
|
299
|
+
throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
300
|
+
}
|
|
301
|
+
const sources = (parsed.results ?? []).map(mapRestResult).filter((source) => source !== undefined);
|
|
302
|
+
return { sources, truncated: false };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Anonymous search through Exa's hosted MCP server. No credentials are
|
|
307
|
+
* sent; the `x-exa-source` header carries attribution. Rate-limited by Exa
|
|
308
|
+
* (HTTP 429) — configuring an API key lifts the limit via the REST path.
|
|
309
|
+
*/
|
|
310
|
+
async #anonymousMcpSearch(request, options, signal) {
|
|
311
|
+
throwIfAborted(signal);
|
|
312
|
+
const args = { query: request.query };
|
|
313
|
+
const numResults = request.maxResults ?? options.numResults;
|
|
314
|
+
if (numResults !== undefined) args.numResults = numResults;
|
|
315
|
+
let response;
|
|
316
|
+
try {
|
|
317
|
+
response = await fetch(options.mcpURL, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
redirect: "error",
|
|
320
|
+
headers: {
|
|
321
|
+
"content-type": "application/json",
|
|
322
|
+
"accept": "application/json, text/event-stream",
|
|
323
|
+
"x-exa-source": MCP_SOURCE,
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify({
|
|
326
|
+
jsonrpc: "2.0",
|
|
327
|
+
id: Math.random().toString(36).slice(2),
|
|
328
|
+
method: "tools/call",
|
|
329
|
+
params: { name: MCP_TOOL, arguments: args },
|
|
330
|
+
}),
|
|
331
|
+
...signal !== undefined ? { signal } : {},
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
|
|
335
|
+
throw new WebError(`Exa anonymous search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
336
|
+
}
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
if (response.status === 429) {
|
|
339
|
+
throw new WebError(
|
|
340
|
+
"Exa anonymous MCP rate limit reached (HTTP 429); configure an EXA_API_KEY for higher limits",
|
|
341
|
+
"WEB_PROVIDER_ERROR",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
throw new WebError(`Exa anonymous MCP error (HTTP ${response.status})`, "WEB_PROVIDER_ERROR");
|
|
345
|
+
}
|
|
346
|
+
let payload;
|
|
347
|
+
try {
|
|
348
|
+
payload = parseSsePayload(await response.text());
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
|
|
351
|
+
throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
352
|
+
}
|
|
353
|
+
if (payload === null) {
|
|
354
|
+
throw new WebError("Exa anonymous MCP returned an unprocessable response body", "WEB_PROVIDER_ERROR");
|
|
355
|
+
}
|
|
356
|
+
if (payload.error != null) {
|
|
357
|
+
throw new WebError(`Exa MCP error: ${String(payload.error.message ?? JSON.stringify(payload.error))}`, "WEB_PROVIDER_ERROR");
|
|
358
|
+
}
|
|
359
|
+
if (payload.result?.isError === true) {
|
|
360
|
+
const detail = collectMcpText(payload).join("\n").trim();
|
|
361
|
+
throw new WebError(`Exa MCP tool error${detail.length > 0 ? `: ${detail}` : ""}`, "WEB_PROVIDER_ERROR");
|
|
362
|
+
}
|
|
363
|
+
const sections = splitExaSections(collectMcpText(payload).join("\n\n"));
|
|
364
|
+
const sources = mapMcpSections(sections);
|
|
365
|
+
return { sources, truncated: false };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Cordis plugin wiring ────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
372
|
+
const name = "web-search-exa";
|
|
373
|
+
/** The web seam this provider registers into. */
|
|
374
|
+
const inject = ["web"];
|
|
375
|
+
|
|
376
|
+
const Config = z.object({
|
|
377
|
+
/**
|
|
378
|
+
* Provider id registered into `ctx.web`. Defaults to `exa` (same as the
|
|
379
|
+
* official `@deepseek-ai/dsh-web-search-exa`). Change it only when BOTH
|
|
380
|
+
* packages are installed in one profile — the seam rejects duplicate ids
|
|
381
|
+
* with `WEB_DUPLICATE_PROVIDER`. There is no silent override: pick a
|
|
382
|
+
* distinct id here (e.g. `exa-anon`) and select it explicitly with
|
|
383
|
+
* `searchProvider` / `$DSH_WEB_SEARCH_PROVIDER`.
|
|
384
|
+
*/
|
|
385
|
+
providerId: z.string().default(DEFAULT_PROVIDER_ID),
|
|
386
|
+
/** Literal Exa API key; an empty/missing value enables the anonymous MCP path. */
|
|
387
|
+
apiKey: z.string().role("secret"),
|
|
388
|
+
/** Environment variable consulted when no literal `apiKey` is configured. */
|
|
389
|
+
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
390
|
+
/** REST search endpoint, used only when an API key is available. */
|
|
391
|
+
apiURL: z.string().default(DEFAULT_API_URL),
|
|
392
|
+
/** Exa hosted MCP endpoint, used by the anonymous fallback. */
|
|
393
|
+
mcpURL: z.string().default(DEFAULT_MCP_URL),
|
|
394
|
+
/** REST retrieval mode: `auto`, `keyword`, or `neural`. */
|
|
395
|
+
searchType: z.union(["auto", "keyword", "neural"]).default(DEFAULT_SEARCH_TYPE),
|
|
396
|
+
/** Default result count when the request carries no `maxResults`. */
|
|
397
|
+
numResults: z.number().step(1).min(1),
|
|
398
|
+
/** Highlight sentences requested per result on the REST path. */
|
|
399
|
+
highlightsPerResult: z.number().step(1).min(1).default(DEFAULT_HIGHLIGHTS_PER_RESULT),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Register the Exa search provider with `ctx.web` and install its Settings
|
|
404
|
+
* section, so the Web panel edits the same config the provider serves.
|
|
405
|
+
*/
|
|
406
|
+
function apply(ctx, config) {
|
|
407
|
+
let current = () => config;
|
|
408
|
+
installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
409
|
+
setSource: (source) => {
|
|
410
|
+
current = source;
|
|
411
|
+
},
|
|
412
|
+
onChange: () => {},
|
|
413
|
+
});
|
|
414
|
+
ctx.web.registerSearchProvider(new ExaSearchProvider(() => resolveOptions(current())));
|
|
415
|
+
}
|
|
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
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@tonydua/dsh-web-search-exa`.
|
|
3
|
+
* @module @tonydua/dsh-web-search-exa
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import type { WebSearchProvider } from '@deepseek-ai/dsh-web';
|
|
7
|
+
|
|
8
|
+
/** Config schema type (fields are optional at the load boundary). */
|
|
9
|
+
export interface ExaSearchProviderConfig {
|
|
10
|
+
/** Literal Exa API key; empty/missing enables the anonymous MCP path. */
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
/** Environment variable consulted when no literal `apiKey` is configured. */
|
|
13
|
+
apiKeyEnv?: string;
|
|
14
|
+
/** REST search endpoint, used only when an API key is available. */
|
|
15
|
+
apiURL?: string;
|
|
16
|
+
/** Exa hosted MCP endpoint, used by the anonymous fallback. */
|
|
17
|
+
mcpURL?: string;
|
|
18
|
+
/** REST retrieval mode: `auto`, `keyword`, or `neural`. */
|
|
19
|
+
searchType?: 'auto' | 'keyword' | 'neural';
|
|
20
|
+
/** Default result count when the request carries no `maxResults`. */
|
|
21
|
+
numResults?: number;
|
|
22
|
+
/** Highlight sentences requested per result on the REST path. */
|
|
23
|
+
highlightsPerResult?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The Exa-backed provider with anonymous MCP fallback. */
|
|
27
|
+
export class ExaSearchProvider implements WebSearchProvider {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
constructor(resolveOptions: () => {
|
|
30
|
+
apiKey: string;
|
|
31
|
+
apiKeyEnv: string;
|
|
32
|
+
apiURL: string;
|
|
33
|
+
mcpURL: string;
|
|
34
|
+
searchType: 'auto' | 'keyword' | 'neural';
|
|
35
|
+
numResults?: number;
|
|
36
|
+
highlightsPerResult: number;
|
|
37
|
+
});
|
|
38
|
+
available(): boolean;
|
|
39
|
+
search(request: { query: string; maxResults?: number }, signal?: AbortSignal): Promise<{
|
|
40
|
+
sources: ReadonlyArray<{ url: string; title?: string; snippet?: string; publishedAt?: string }>;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
46
|
+
export const name: string;
|
|
47
|
+
/** The web seam this provider registers into. */
|
|
48
|
+
export const inject: readonly ['web'];
|
|
49
|
+
/** Config schema (schemastery). */
|
|
50
|
+
export const Config: import('@deepseek-ai/schemastery').Schema<ExaSearchProviderConfig>;
|
|
51
|
+
/** Settings namespace for the Web panel section. */
|
|
52
|
+
export const SETTINGS_NAMESPACE: string;
|
|
53
|
+
/** Register the Exa search provider with `ctx.web` and install its Settings section. */
|
|
54
|
+
export function apply(ctx: Context, config: ExaSearchProviderConfig): void;
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tonydua/dsh-web-search-exa",
|
|
3
|
+
"description": "Exa-backed WebSearchProvider for the DeepSeek Harness web capability seam (ctx.web), with an anonymous MCP fallback — no API key required",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"cordis",
|
|
9
|
+
"exa",
|
|
10
|
+
"web-search",
|
|
11
|
+
"anonymous"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/TonyDua/dsh-web-search-exa.git"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/index.test.js"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
40
|
+
"@deepseek-ai/dsh-web": "^0.1.0-rc.6"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
48
|
+
"@deepseek-ai/dsh-web": "^0.1.0-rc.6"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
}
|
|
53
|
+
}
|