@tonydua/dsh-web-search-exa 0.1.5 → 0.2.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/README.md CHANGED
@@ -3,310 +3,211 @@
3
3
  **English** | [简体中文](README.zh.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@tonydua/dsh-web-search-exa?label=npm)](https://www.npmjs.com/package/@tonydua/dsh-web-search-exa)
6
- [![GitHub release](https://img.shields.io/github/v/release/TonyDua/dsh-web-search-exa?label=release)](https://github.com/TonyDua/dsh-web-search-exa/releases/latest)
6
+ [![GitHub release](https://img.shields.io/github/release/TonyDua/dsh-web-search-exa?label=release)](https://github.com/TonyDua/dsh-web-search-exa/releases/latest)
7
7
  [![npm downloads](https://img.shields.io/npm/dm/@tonydua/dsh-web-search-exa)](https://www.npmjs.com/package/@tonydua/dsh-web-search-exa)
8
8
  [![License](https://img.shields.io/npm/l/@tonydua/dsh-web-search-exa)](LICENSE)
9
- [![dsh](https://img.shields.io/badge/dsh-0.1.2--alpha.2%20%E2%80%93%200.1.7--alpha.1-4c6?logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
9
+ [![dsh](https://img.shields.io/badge/dsh%20tested-0.1.2--alpha.2%20%E2%80%93%200.2.1--alpha.1-4c6?logo=deepseek&logoColor=white)](#version-compatibility)
10
10
  [![Node](https://img.shields.io/badge/node-%3E%3D22.19.0-339933?logo=node.js&logoColor=white)](package.json)
11
11
  [![GitHub stars](https://img.shields.io/github/stars/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
12
12
  [![GitHub issues](https://img.shields.io/github/issues/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
13
13
 
14
- > Zero-config [Exa](https://exa.ai) web search for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh):
15
- > **no API key required** — a `WebSearchProvider` for the `ctx.web` seam with an
16
- > anonymous MCP fallback plus a keyed REST path.
14
+ Adds [Exa](https://exa.ai) web search to [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh).
15
+
16
+ ```powershell
17
+ dsh plugin --profile web add @tonydua/dsh-web-search-exa
18
+ ```
19
+
20
+ Restart `dsh web` and it works. No API key, no config edits, no provider to select.
21
+
22
+ Background, for reference only:
23
+
24
+ - **Exa** is a search API. It retrieves pages by keyword or by meaning and returns citable sources with excerpts. It does not generate answers. Exa offers a REST API and also runs an unauthenticated public MCP server.
25
+ - **The official [`dsh-web-search-exa`](https://github.com/deepseek-ai/deepseek-harness/blob/HEAD/packages/web/web-search-exa/README.md)** is dsh's Exa search provider. It uses Exa's REST API and is only useful once you configure an API key.
26
+ - **This package is a modified copy of the official one.** The REST path works the same. What we added is a keyless channel: with no key it uses Exa's public MCP server, and with a key it still uses REST. The anonymous approach follows the oh-my-pi project, see [Acknowledgements](#acknowledgements).
27
+
28
+ You can ignore all of this by default. Read [Selecting a provider](#selecting-a-provider) only if you also run the official package, or if dsh reports an ambiguous provider.
17
29
 
18
30
  Built with [deepseek-v4-flash](https://api-docs.deepseek.com) inside DeepSeek Harness (dsh).
19
31
 
20
- ## Supported versions
32
+ ## Features
33
+
34
+ - Works without a key. Searches go through Exa's public MCP server (`mcp.exa.ai/mcp`) and carry no credentials.
35
+ - Returns structured results on that keyless path. It calls `web_search_advanced_exa`, whose output is JSON in the REST field vocabulary, so sources carry real highlight snippets without text parsing.
36
+ - Upgrades itself when you add a key. Setting `EXA_API_KEY` switches to Exa's `POST /search` REST API for higher limits, with no behavior change.
37
+ - Drop-in. It registers into the dsh `ctx.web` seam; the model-facing `web_search` and `web_fetch` tools, their prompt sections, and the result cards all stay as they are.
38
+ - Works out of the box. With no official package installed there is no provider to select.
39
+ - Backs off when it fails. After repeated failures on the anonymous channel the plugin marks itself unavailable so dsh can pick another provider, instead of failing every search. See [What happens when a search fails](#what-happens-when-a-search-fails).
21
40
 
22
- **Every published dsh version from `0.1.2-alpha.2` to `0.1.7-alpha.1` is
23
- verified**, not merely declared: each one is installed in isolation, the plugin
24
- is typechecked against that version's own declarations, and the test suite runs
25
- against it. Reproduce with `bash scripts/compat-matrix.sh`.
41
+ ## Installation
26
42
 
27
- | dsh line | Verified | Notes |
28
- |---|---|---|
29
- | `0.1.2-alpha.2` … `0.1.2-alpha.5` | ✅ | oldest supported |
30
- | `0.1.2-rc.1` | ✅ | |
31
- | `0.1.3-alpha.2` | ✅ | |
32
- | `0.1.5-alpha.1`, `0.1.5-alpha.2` | ✅ | |
33
- | `0.1.5-rc.1`, `0.1.5-rc.2`, `0.1.5-rc.3` | ✅ | `0.1.5-rc.2` also verified end to end: a real `dsh --profile headless` task searched through the anonymous MCP path with no API key present |
34
- | `0.1.6-alpha.1`, `0.1.6-alpha.2` | ✅ | |
35
- | `0.1.7-alpha.1` | ✅ | settings service changed shape — see below |
43
+ Pick one of three. The choice only decides where the code comes from; all three end up the same.
36
44
 
37
- ### Why the peer range looks like that
45
+ **From npm.** The `dsh.bundle` manifest ships the bundle patch, so the provider row is inserted for you and you do not edit any patch by hand.
38
46
 
39
- ```jsonc
40
- "@deepseek-ai/dsh-web": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8"
47
+ ```powershell
48
+ dsh plugin --profile web add @tonydua/dsh-web-search-exa
41
49
  ```
42
50
 
43
- That enumeration is not decoration — it is the only form that installs on **every**
44
- published version under **both** pnpm and npm. The rule that forces it:
51
+ **From the GitHub Release.** The same tarball, for when npm is unreachable.
45
52
 
46
- > A pre-release version satisfies a range only if some comparator in that range
47
- > carries a pre-release **on the same `major.minor.patch` tuple**.
53
+ ```powershell
54
+ dsh plugin --profile web add https://github.com/TonyDua/dsh-web-search-exa/releases/latest/download/dsh-web-search-exa.tgz
55
+ ```
48
56
 
49
- So `>=0.1.2-rc.1` does **not** match `0.1.5-rc.2` — the tuples differ. A single
50
- open-ended lower bound therefore cannot cover a project published as a series of
51
- prereleases, and `*` would accept even a breaking `1.0`. Each `0.1.x` line that
52
- ever shipped a prerelease needs its own comparator; `>=0.1.8` then carries every
53
- future stable release, so the list only needs a new entry when dsh opens a new
54
- `0.1.x` prerelease line.
57
+ **From the repository.** Tracks `main`, including work not yet released.
55
58
 
56
- Measured, on the real published tarball:
59
+ ```powershell
60
+ dsh plugin --profile web add github:TonyDua/dsh-web-search-exa
61
+ ```
57
62
 
58
- | range | npm installs | pnpm |
59
- |---|---|---|
60
- | `>=0.1.2-rc.1` (the earlier attempt) | **1 / 14** versions | 14 / 14 |
61
- | enumerated (current) | **14 / 14** versions | 14 / 14 |
63
+ For a local development checkout, use the same command with a path instead of a package name: `dsh plugin --profile web add ../plugins/dsh-web-search-exa`.
62
64
 
63
- This was found by testing rather than reasoning: the open-ended range is fine on
64
- pnpm, which is what `dsh plugin add` uses, and fails on npm for 13 of the 14
65
- versions with `ERESOLVE`. If you install with npm and hit that on an older
66
- release of this package, either upgrade, or pass `--legacy-peer-deps`.
65
+ Restart `dsh web` afterwards. That is the whole procedure in most cases.
67
66
 
68
- ### What differs across versions
67
+ ### Selecting a provider
68
+
69
+ **Skip this section unless you install the official package too.**
69
70
 
70
- Auditing the real export surfaces of all 14 versions found the `ctx.web` seam
71
- completely stable — `WebError` is exported from `dsh-web` and still extends
72
- `HarnessError`, `launchEnvironmentOf` is present, and the settings service is
73
- mounted at `ctx.settings` in every version. Two things do differ:
74
-
75
- 1. **`0.1.7-alpha.1` replaced the settings API.** `SettingsProvider.installSection`
76
- is gone; the service is now `SettingsForms`, which derives a configuration
77
- page from the Config schema the Loader already holds for the entry
78
- (`SettingsDescriptor.schema`, `autoGenerate`). Calling the old method
79
- unconditionally threw a `TypeError` on that host, so the plugin loaded but
80
- failed. It now probes for the method, calls it only when present, and
81
- otherwise does nothing — on `0.1.7+` the Loader's schema is what feeds the
82
- form, so there is nothing to register.
83
- 2. **`0.1.7-alpha.1` peers `@deepseek-ai/cordis` `^4.0.3`** while the cordis
84
- `latest` dist-tag still points at `4.0.2`. `4.0.3` is published; the tag is
85
- simply behind. Install `@deepseek-ai/cordis@4.0.3` alongside a `0.1.7` host.
86
- The matrix script pins this per version.
87
-
88
- Also supported with: `@deepseek-ai/dsh-web`, `dsh-settings` (optional),
89
- `dsh-launch-environment` across that whole range, and Node.js `>=22.19.0` (the
90
- harness's own floor).
91
-
92
- ### Profile-install note
93
-
94
- dsh profiles set `autoInstallPeers: false`, and the harness's own services are
95
- supplied at runtime by the dsh host instead of being resolved by pnpm. Add this
96
- to the profile's `pnpm-workspace.yaml` so `dsh plugin add` stays warning-free:
71
+ Before each search, the dsh seam picks an available provider. If exactly one is available it is selected automatically. If more than one is available the seam raises `WEB_PROVIDER_AMBIGUOUS` and asks you to name one. So there are only two cases where you have to act:
72
+
73
+ - **You installed the official package as well.** Both packages register the provider id `exa`, so `dsh web` fails at startup with `WEB_DUPLICATE_PROVIDER`. You must give this package a different id first, see [Coexistence with the official package](#coexistence-with-the-official-package).
74
+ - **You see `WEB_PROVIDER_AMBIGUOUS`.** Another provider is available. Name the one you want.
75
+
76
+ Two ways to name it:
97
77
 
98
78
  ```yaml
99
- peerDependencyRules:
100
- ignoreMissing:
101
- - '@deepseek-ai/cordis'
102
- - '@deepseek-ai/dsh-*'
79
+ # $DSH_HOME/profiles/web/cordis.patch.yml
80
+ - id: web
81
+ name: '@deepseek-ai/dsh-web'
82
+ config:
83
+ searchProvider: exa
103
84
  ```
104
85
 
105
- ### Degradation and failover
86
+ Or set `$DSH_WEB_SEARCH_PROVIDER=exa` at runtime.
106
87
 
107
- The keyless channel is a shared, best-effort endpoint. The provider reports its
108
- own health rather than pretending to always work:
88
+ Restart `dsh web` after the change. The model-facing `web_search` tool then uses the selected provider; no tool configuration changes.
109
89
 
110
- - 3 consecutive transient failures (5xx, 429, network, unparseable body) open a
111
- circuit breaker for 5 minutes, during which `available()` returns `false`. One
112
- successful search closes it again.
113
- - A 4xx other than 429 does not trip it — that failure would repeat forever, so
114
- hiding it would only delay the same error.
115
- - Anonymous 429s raise `WEB_RATE_LIMITED` (not a generic `WEB_PROVIDER_ERROR`)
116
- with a message naming `EXA_API_KEY`.
117
- - The keyed REST path ignores the breaker: a paid endpoint's failures are yours
118
- to see.
90
+ <details>
91
+ <summary>Release artifacts and install warnings (usually not needed)</summary>
119
92
 
120
- **Whether that turns into automatic failover is a harness-side decision.** The
121
- seam picks exactly one usable provider and has no priority chain — with two
122
- usable providers it raises `WEB_PROVIDER_AMBIGUOUS`. So:
93
+ **Release artifacts.** CI packs this version's tarball, verifies it against every supported dsh version, attaches it to the GitHub Release, and publishes that same artifact to npm. So the release asset and the npm tarball are one file, not two builds that happen to match.
123
94
 
124
- - Pinning `searchProvider: exa` gives deterministic selection but *no* fallback:
125
- when the breaker opens you get `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
126
- - Leaving `searchProvider` unset gives up determinism: a degraded Exa stops
127
- being a candidate, but if another provider (say `deepseek-official` with a
128
- valid `DEEPSEEK_API_KEY`) is also usable, the seam reports ambiguity instead
129
- of choosing it.
95
+ **Profile install warnings.** dsh profiles default to `autoInstallPeers: false`, and the harness's own services are provided at runtime by the dsh host rather than resolved by pnpm. If `dsh plugin add` reports peer warnings, add this to the profile's `pnpm-workspace.yaml`:
130
96
 
131
- Pick whichever failure mode you prefer; the plugin cannot choose for you.
97
+ ```yaml
98
+ peerDependencyRules:
99
+ ignoreMissing:
100
+ - '@deepseek-ai/cordis'
101
+ - '@deepseek-ai/dsh-*'
102
+ ```
132
103
 
133
- ### Building from source
104
+ </details>
134
105
 
135
- ```sh
136
- pnpm install
137
- pnpm run build # tsdown -> lib/index.js + lib/index.d.ts
138
- pnpm run typecheck # tsc --noEmit
139
- pnpm test # builds, then runs the node:test suite against lib/
140
- ```
106
+ ## Configuration
141
107
 
142
- `src/` is the source of truth; `lib/` is committed because both the published
143
- tarball and git-based installs consume it.
108
+ | Key | Default | Meaning |
109
+ |---|---|---|
110
+ | `apiKey` | unset | Literal Exa API key. Empty or missing enables the anonymous MCP path. |
111
+ | `apiKeyEnv` | `EXA_API_KEY` | Environment variable read when no literal `apiKey` is set. |
112
+ | `baseURL` | `https://api.exa.ai` | Exa API base URL. The keyed REST path appends `/search`, matching the official dsh provider. |
113
+ | `apiURL` | unset | Deprecated full REST endpoint alias. Takes precedence over `baseURL` when set. |
114
+ | `mcpURL` | `https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa` | Exa hosted MCP endpoint, used by the anonymous path. The `tools` query is part of the default because the structured tool is not servable without it. Leave it out of your own URL and the plugin adds it for you. |
115
+ | `mcpTool` | `web_search_advanced_exa` | Which MCP tool the anonymous path calls: the structured one, or `web_search_exa` for the older `Title:`-section text blob. See [How it works](#how-it-works). |
116
+ | `searchType` | `auto` | REST retrieval mode: `auto`, `keyword`, or `neural`. Read on the REST path only. |
117
+ | `numResults` | unset | Default result count when a request carries no `maxResults`. |
118
+ | `highlightsPerResult` | `1` | Highlight sentences requested per result on the REST path. |
119
+ | `providerId` | `exa` | Provider id registered into `ctx.web`. Change it only when this package and the official one are installed together, see [Coexistence with the official package](#coexistence-with-the-official-package). |
144
120
 
145
- ## Features
121
+ Where to put the config: edit this plugin's `config` in `$DSH_HOME/profiles/web/cordis.patch.yml`, then restart `dsh web`. The environment variables `EXA_API_KEY` and `$DSH_WEB_SEARCH_PROVIDER` work too. `apiKey` is marked `role('secret')`, so no `describe()` response exposes its value.
146
122
 
147
- - 🆓 **Zero-config, keyless by default** — searches route through Exa's hosted MCP
148
- server (`mcp.exa.ai/mcp`) with **no credentials at all** (Exa's documented
149
- unauthenticated public MCP, rate-limited).
150
- - 🔑 **Keyed REST upgrade** — set `EXA_API_KEY` and it automatically switches to
151
- Exa's `POST /search` REST API (higher limits, no behavior change).
152
- - 🔌 **Drop-in provider** — registers into the dsh `ctx.web` seam; the existing
153
- model-facing `web_search` / `web_fetch` tools, prompt sections, and result
154
- cards work unchanged.
155
- - 🎛️ **`providerId` switch** — can coexist with the official
156
- `@deepseek-ai/dsh-web-search-exa` package in one profile (no duplicate-id
157
- collisions, no silent overrides).
158
- - 📦 **npm-publishable** — MIT, ESM, bundled types, `files` limited to `lib/`.
159
-
160
- ## Why this package exists (vs. the official one)
161
-
162
- The DeepSeek Harness ships an official Exa provider,
163
- [`@deepseek-ai/dsh-web-search-exa`](https://www.npmjs.com/package/@deepseek-ai/dsh-web-search-exa).
164
- This package is its **zero-config variant**: it adds the anonymous MCP fallback
165
- the official one does not have, and keeps the same keyed REST behavior.
123
+ ### In the Web panel
166
124
 
167
- | | Official `@deepseek-ai/dsh-web-search-exa` | This package `@tonydua/dsh-web-search-exa` |
168
- |---|---|---|
169
- | REST path (`POST /search`) | ✅ only path | ✅ used when a key is configured |
170
- | Requires an API key | ✅ **yes — empty key makes it unavailable** | ❌ no — keyless anonymous MCP fallback |
171
- | Anonymous MCP (`mcp.exa.ai/mcp`) | ❌ not implemented | ✅ default when no key |
172
- | Zero-config install | ❌ | ✅ |
173
- | Provider id | `exa` (fixed) | `exa` by default, **configurable via `providerId`** |
174
- | Cordis plugin name | `web-search-exa` | `web-search-exa` |
175
- | Config keys | `apiKey`, `baseURL`, `searchType`, `numResults`, `highlightsPerResult` | `apiKey`, `apiKeyEnv`, `baseURL`, `apiURL` (legacy), `mcpURL`, `searchType`, `numResults`, `highlightsPerResult`, `providerId` |
125
+ In this version the config lives in the profile patch layer, not the Web UI, and there is no editable form. The Settings UI only renders cards that client plugins register by hand for fixed namespaces (`shell`, `agent-loop`, `web-search-deepseek`); it has no generic form for an arbitrary plugin namespace. The current state:
176
126
 
177
- ## Which one should I use?
127
+ - **Plugin inventory** (Settings → Plugins): a `web-search-exa` entry appears automatically once the plugin is enabled. The inventory reads live entries from the Cordis loader, so no extra code is involved.
128
+ - **Settings namespace** (server side): the plugin registers a `web-search-exa` section through the `ctx.settings.installSection` API, and the data layer accepts writes. But no client card binds to it, so the UI does not show it. The built-in Web search card edits the official `web-search-deepseek` namespace, which is unrelated to this plugin.
129
+ - **Search result cards**: `web_search` calls render the usual `web` result cards through `dsh-tool-web` (sources, excerpts, dates), regardless of provider. Anonymous Exa results look identical to DeepSeek search results.
178
130
 
179
- - **You have an `EXA_API_KEY` and want the officially maintained package** →
180
- use `@deepseek-ai/dsh-web-search-exa`. It is the canonical implementation.
181
- - **You want to try Exa search with zero setup, no key, no cost commitment** →
182
- use this package. It degrades gracefully: anonymous MCP by default, REST
183
- automatically when a key appears.
184
- - **You want both** → install both and use the `providerId` switch (see
185
- [Coexistence](#coexistence-with-the-official-package)).
131
+ Roadmap: the next version adds a client card registered into the `settings.plugin.item` slot and bound to the `web-search-exa` namespace, so every field in the table above becomes editable in Settings → Plugins.
186
132
 
187
133
  ## How it works
188
134
 
189
135
  | Condition | Path | Endpoint |
190
136
  |---|---|---|
191
- | `apiKey` / `EXA_API_KEY` set | REST `POST /search` with `Authorization: Bearer` | `https://api.exa.ai/search` (`baseURL` configurable) |
192
- | No key configured | Anonymous MCP `tools/call web_search_exa` (JSON-RPC 2.0, no credentials) | `https://mcp.exa.ai/mcp` (configurable) |
137
+ | `apiKey` / `EXA_API_KEY` configured | REST `POST /search` with `Authorization: Bearer` | `https://api.exa.ai/search` (configurable via `baseURL`) |
138
+ | No key configured | Anonymous MCP `tools/call web_search_advanced_exa` (JSON-RPC 2.0, no credentials) | `https://mcp.exa.ai/mcp?tools=…` (configurable) |
193
139
 
194
- The anonymous MCP path sends no credentials; attribution rides the
195
- `x-exa-source: dsh-anything` header. Results are normalized to the seam's
196
- `WebSearchSource` shape (`url`, `title`, `snippet`, `publishedAt`) and the seam
197
- enforces `maxResults` on the way back. Anonymous usage is rate-limited by Exa:
198
- an HTTP 429 surfaces as a distinct `WEB_RATE_LIMITED` code — not a generic
199
- provider failure — with a hint to configure an API key (which also switches to
200
- the REST path automatically).
140
+ The anonymous MCP path sends no credentials; attribution rides the `x-exa-source: dsh-anything` header. Results are normalized to the seam's `WebSearchSource` shape (`url`, `title`, `snippet`, `publishedAt`), and the seam enforces `maxResults` on the way back.
201
141
 
202
- ## Installation (into a dsh profile)
142
+ The anonymous path calls `web_search_advanced_exa` by default. Its text content is a sanitized JSON search response whose entries use the same field names as the REST API, so a result maps to a source directly and no `Title:`-section text parsing is involved. Two consequences worth knowing:
203
143
 
204
- > **One artifact, two doors.** CI packs this version's tarball, verifies it
205
- > against every supported dsh version, attaches it to the GitHub Release, and
206
- > publishes **that artifact** to npm — so the release asset and the npm tarball
207
- > are one file, not two builds that happen to match.
144
+ - **The structured tool is only served when the URL carries `?tools=…`.** The bare endpoint answers `MCP error -32602: Tool web_search_advanced_exa not found`, which is why that query is part of the default `mcpURL` and why the plugin splices it into any `mcpURL` that lacks it.
145
+ - **It returns whole-page text for every hit, and we ask for highlights separately.** Without `enableHighlights` the endpoint returns text-only entries, every result would lack a snippet, and the search would come back empty. The full text is discarded: a snippet is always a real highlight sentence, never generated and never lifted from the page body.
208
146
 
209
- **From npm** (v0.1.4+ ships the `dsh.bundle` manifest, so the bundle patch
210
- inserts the provider row with no manual patch editing):
147
+ `searchType` is not forwarded here. The plugin's setting uses the REST vocabulary (`auto`, `keyword`, `neural`) while the tool accepts its own (`auto`, `fast`, `instant`), so forwarding it would make a configured `keyword` or `neural` fail argument validation and take the whole anonymous path down. The tool's default is what `auto` asks for anyway.
211
148
 
212
- ```powershell
213
- dsh plugin --profile web add @tonydua/dsh-web-search-exa
214
- ```
215
-
216
- **From the GitHub Release** — the same tarball, for when npm is unreachable:
149
+ Pinning `mcpTool: web_search_exa` restores the older text-blob path, where the same results arrive as `Title:`-led sections. Use it if Exa changes the structured tool's shape; a body that is not the expected JSON already falls back to section parsing on its own.
217
150
 
218
- ```powershell
219
- dsh plugin --profile web add https://github.com/TonyDua/dsh-web-search-exa/releases/latest/download/dsh-web-search-exa.tgz
220
- ```
151
+ Because the structured tool returns a whole page of text per hit, a response can be large. Anonymous responses are capped at 256 KiB: the declared `content-length` is checked before the body is read, and a body that keeps growing is aborted mid-transfer. Over-limit responses fail as transient errors rather than being truncated or silently parsed.
221
152
 
222
- **From the repository** (tracks `main`, includes work not yet released):
153
+ ### Rate limits
223
154
 
224
- ```powershell
225
- dsh plugin --profile web add github:TonyDua/dsh-web-search-exa
226
- ```
155
+ The anonymous channel is a public endpoint run by Exa, and it is rate-limited. When you hit the limit the search fails with the code `WEB_RATE_LIMITED` and a message telling you to configure `EXA_API_KEY`. That code is ours, so that you and the model can tell throttling apart from a broken network.
227
156
 
228
- Restart `dsh web`. **Without an API key** the official DeepSeek search
229
- provider is unavailable, so the seam auto-selects this provider — fully
230
- zero-config. **With a key configured**, select Exa explicitly in your own
231
- `$DSH_HOME/profiles/web/cordis.patch.yml` (applied after bundle patches):
157
+ With a key configured, searches use the REST path and are not subject to this limit.
232
158
 
233
- ```yaml
234
- - id: web
235
- name: '@deepseek-ai/dsh-web'
236
- config:
237
- searchProvider: exa
238
- ```
159
+ ### What happens when a search fails
239
160
 
240
- …or at runtime with the environment variable `$DSH_WEB_SEARCH_PROVIDER=exa`.
161
+ **What you will see:**
241
162
 
242
- **Local development checkout:**
163
+ - The anonymous channel is rate-limited: error code `WEB_RATE_LIMITED`, telling you to configure a key.
164
+ - The anonymous channel fails 3 times in a row: this plugin marks itself unavailable for a 5-minute cooldown. During that window `available()` returns `false`.
165
+ - You pinned `searchProvider: exa` and the cooldown is active: the search reports `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
166
+ - You did not pin `searchProvider` and the cooldown is active: the seam skips this plugin and looks for another provider. With no other provider available it reports `WEB_PROVIDER_UNAVAILABLE`.
167
+ - You configured a key, so searches use REST: none of the above applies, and failures surface as usual.
243
168
 
244
- ```powershell
245
- dsh plugin --profile web add ../plugins/dsh-web-search-exa
246
- ```
169
+ **Why it works this way.** Before each search the seam calls `available()` to decide which provider to use. If this plugin always answered "available", a dead endpoint would make every search fail hard, and what the user sees is a broken dsh. So the plugin adds a circuit breaker: after 3 consecutive transient failures it admits it is temporarily unavailable, giving the seam a chance to choose someone else. That breaker is this plugin's design; Exa has no such mechanism.
247
170
 
248
- Then enable the provider and select it. Either merge into
249
- `$DSH_HOME/profiles/web/cordis.patch.yml` (persistent):
171
+ **How failures are counted.** Only failures that a retry could fix: 5xx, 429, network errors, and unparseable response bodies. Three of them start a 5-minute cooldown, and any successful search clears the count immediately.
250
172
 
251
- ```yaml
252
- - id: web-search-exa
253
- name: '@tonydua/dsh-web-search-exa'
254
- config:
255
- apiKeyEnv: EXA_API_KEY
256
- - id: web
257
- name: '@deepseek-ai/dsh-web'
258
- config:
259
- searchProvider: exa
260
- ```
173
+ A 4xx other than 429 does not count. That is a configuration error and would fail identically on every retry, so hiding it behind a cooldown would only delay the same error by 5 minutes.
261
174
 
262
- Alternatively, select the provider at runtime with the environment variable
263
- `$DSH_WEB_SEARCH_PROVIDER=exa` (no config edit needed).
175
+ **This trade-off has a cost.** While Exa is down for those 5 minutes, a profile with a pinned `searchProvider: exa` reports an error instead of trying something else. The plugin cannot choose for you:
264
176
 
265
- Restart `dsh web` for changes to take effect. The existing model-facing
266
- `web_search` tool then routes through this provider — no tool config changes.
177
+ - Pinning `searchProvider: exa`: predictable behavior normally, but no fallback once the breaker opens.
178
+ - Leaving `searchProvider` unset: it can fall back when the breaker opens, at the cost that the seam raises `WEB_PROVIDER_AMBIGUOUS` whenever several providers are usable, and you have to name one.
267
179
 
268
- ### Runtime singleton compatibility
180
+ Choose the second if you want fallback, and install only one alternative provider.
269
181
 
270
- `@deepseek-ai/dsh-tools` is a dsh runtime singleton and must resolve to one
271
- physical package instance in a profile. This provider does not depend on it;
272
- the requirement belongs to the host profile. If another third-party plugin
273
- installs `@deepseek-ai/dsh-tools` as a nested regular dependency instead of a
274
- peer dependency, fix that plugin's dependency declaration or make the profile
275
- package manager resolve the shared instance before debugging search errors.
276
- Otherwise dsh's agent loop can fail before the provider is called with an
277
- error such as `Cannot read properties of undefined (reading 'prepare')`.
182
+ ## Compared with the official package
278
183
 
279
- ## Configuration
184
+ DeepSeek Harness has an official Exa provider, [`@deepseek-ai/dsh-web-search-exa`](https://www.npmjs.com/package/@deepseek-ai/dsh-web-search-exa), which you install separately; dsh does not include it by default. This package is its zero-config variant: it adds the anonymous MCP fallback the official one lacks and keeps the same REST behavior once you configure a key.
280
185
 
281
- | Key | Default | Meaning |
186
+ | | Official `@deepseek-ai/dsh-web-search-exa` | This package `@tonydua/dsh-web-search-exa` |
282
187
  |---|---|---|
283
- | `providerId` | `exa` | Provider id registered into `ctx.web`. Only change it when both this and the official package are installed (see next section). |
284
- | `apiKey` | unset | Literal Exa API key. Empty/missing enables the anonymous MCP path. |
285
- | `apiKeyEnv` | `EXA_API_KEY` | Environment variable consulted when no literal `apiKey` is set. |
286
- | `baseURL` | `https://api.exa.ai` | Exa API base URL; `/search` is appended for the keyed REST path. Matches the official dsh provider. |
287
- | `apiURL` | unset | Deprecated full REST endpoint alias. If set, it takes precedence over `baseURL`. |
288
- | `mcpURL` | `https://mcp.exa.ai/mcp` | Exa hosted MCP endpoint (anonymous path). |
289
- | `searchType` | `auto` | REST retrieval mode: `auto` / `keyword` / `neural`. |
290
- | `numResults` | unset | Default result count when the request carries no `maxResults`. |
291
- | `highlightsPerResult` | `1` | Highlight sentences requested per result on the REST path. |
188
+ | REST path (`POST /search`) | ✅ the only path | ✅ used when a key is configured |
189
+ | Requires an API key | ✅ yes, an empty key makes it unavailable | ❌ no, with no key it uses the anonymous MCP fallback |
190
+ | Anonymous MCP (`mcp.exa.ai/mcp`) | ❌ not implemented | ✅ the default path with no key |
191
+ | Zero-config install | ❌ | ✅ |
192
+ | Provider id | `exa` (fixed) | `exa` by default, configurable via `providerId` |
193
+ | Cordis plugin name | `web-search-exa` | `web-search-exa` |
194
+ | Config keys | `apiKey`, `baseURL`, `searchType`, `numResults`, `highlightsPerResult` | `apiKey`, `apiKeyEnv`, `baseURL`, `apiURL` (legacy), `mcpURL`, `mcpTool`, `searchType`, `numResults`, `highlightsPerResult`, `providerId` |
195
+
196
+ Which to use:
197
+
198
+ - You have an `EXA_API_KEY` and want the officially maintained package: use the official one, it is the canonical implementation.
199
+ - You want to try Exa search with no configuration, no key, and no cost commitment: use this package. It defaults to the anonymous MCP path and switches to REST once a key appears.
200
+ - You want both: install both and separate them with `providerId`, see the next section.
292
201
 
293
202
  ## Coexistence with the official package
294
203
 
295
- Both packages register their provider under the **same default provider id
296
- (`exa`)** and the same cordis plugin name (`web-search-exa`). The seam rejects
297
- duplicate ids with `WEB_DUPLICATE_PROVIDER`, so **installing both into one
298
- profile without changes breaks at startup**.
204
+ Both packages register the same provider id (`exa`) under `ctx.web`, and both use the cordis plugin name `web-search-exa`. The seam rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, so installing both into one profile without changing the config fails at startup.
299
205
 
300
- There is **no silent override** — coexistence is explicit, via the `providerId`
301
- switch:
206
+ Coexistence requires explicit configuration through the `providerId` switch:
302
207
 
303
- 1. Keep the official package on `exa` (its id is fixed).
304
- 2. Give this package a distinct id — set `providerId: exa-anon` (any unique
305
- string) in this plugin's `config`.
306
- 3. Select the anonymous variant explicitly with
307
- `searchProvider: exa-anon` on the `web` seam (or
308
- `$DSH_WEB_SEARCH_PROVIDER=exa-anon`), and keep
309
- `searchProvider: exa` → the official one if you want it selectable too.
208
+ 1. The official package keeps `exa`; its id is fixed.
209
+ 2. Give this package a different id. Set `providerId: exa-anon` in this plugin's `config`; any unique string works.
210
+ 3. Name one of them on the `web` seam. Use `searchProvider: exa-anon` for the anonymous variant, or `searchProvider: exa` for the official package. `$DSH_WEB_SEARCH_PROVIDER` works too.
310
211
 
311
212
  ```yaml
312
213
  - insert:
@@ -320,80 +221,98 @@ switch:
320
221
  searchProvider: exa-anon
321
222
  ```
322
223
 
323
- Simplest alternative: install only one of the two packages per profile — the
324
- defaults then work as-is.
325
-
326
- ## In the Web panel
327
-
328
- **Status: configuration is done in the profile patch layer, not the Web UI —
329
- this version ships no editable UI entry.** The Settings UI only renders cards
330
- that are hand-registered by client plugins for fixed namespaces (`shell`,
331
- `agent-loop`, `web-search-deepseek`); it has no generic form for arbitrary
332
- plugin namespaces. What is true today:
333
-
334
- - **Plugin inventory** (Settings → Plugins): the entry appears automatically
335
- as `web-search-exa` (`@tonydua/dsh-web-search-exa`) once enabled — the
336
- inventory reads the live Cordis loader, no extra code needed.
337
- - **Settings namespace** (server-side): the plugin registers the
338
- `web-search-exa` section via the current `ctx.settings.installSection` API, so the data layer is
339
- writable — but **no client card binds to it**, so nothing shows in the UI.
340
- The built-in "Web search" card edits the official
341
- `web-search-deepseek` namespace, not this plugin.
342
- - **Changing configuration today**: edit the plugin's `config` in
343
- `$DSH_HOME/profiles/web/cordis.patch.yml` (fields and defaults in the table
344
- above) and restart `dsh web`; or set `EXA_API_KEY` / `$DSH_WEB_SEARCH_PROVIDER`
345
- as environment variables. The `apiKey` field is `role('secret')`: it never
346
- appears in `describe()` responses.
347
- - **Search result cards**: `web_search` calls render the usual `web` cards
348
- (sources, snippets, dates) through `dsh-tool-web`, independent of the
349
- provider — anonymous Exa results display exactly like DeepSeek ones.
350
-
351
- **Roadmap (next version)**: a client-side card registered into the
352
- `settings.plugin.item` slot bound to the `web-search-exa` namespace, so all
353
- fields above become editable live in Settings → Plugins (mirroring how the
354
- official cards work).
355
-
356
- ## FAQ
357
-
358
- **Q: Do I need an Exa API key?**
359
- No. Without a key the provider uses Exa's free anonymous hosted MCP. With a key
360
- it uses the REST API for higher limits.
361
-
362
- **Q: I got HTTP 429 / rate limited.**
363
- That's Exa's anonymous-MCP rate limit. Configure `EXA_API_KEY` (or the
364
- `apiKey` field) and the provider switches to the REST path automatically.
365
-
366
- **Q: Can I run this alongside the official Exa provider?**
367
- Yes — give this package a distinct `providerId` and select it explicitly
368
- (see [Coexistence](#coexistence-with-the-official-package)).
369
-
370
- **Q: Why don't I see a settings entry in the Web UI?**
371
- This version registers the `web-search-exa` settings namespace server-side
372
- only; a UI card is planned for the next version. Configure through
373
- `cordis.patch.yml` or environment variables for now (see
374
- [In the Web panel](#in-the-web-panel)).
375
-
376
- **Q: Which dsh versions are supported?**
377
- Every published dsh version from `0.1.2-alpha.2` to `0.1.7-alpha.1`, plus future
378
- `0.1.8+` stable releases. Each version is installed in isolation, typechecked
379
- against its own declarations, and run through this package's test suite in CI —
380
- see [Why the peer range looks like that](#why-the-peer-range-looks-like-that).
224
+ The simplest alternative is to install only one of the two packages per profile, which works with the default config.
225
+
226
+ ## Troubleshooting
227
+
228
+ **`dsh web` fails at startup with `duplicate loader entry id: web`.** This is a 0.1.2 bug, fixed in 0.1.4, so upgrading the plugin resolves it. If you already run 0.1.4 or later, please open an issue with `dsh --version` and your `cordis.patch.yml`, because a user patch that inserts a `web` row produces the same error.
229
+
230
+ **Startup fails with `Cannot read properties of undefined (reading 'prepare')`.** `@deepseek-ai/dsh-tools` is a dsh runtime singleton and must resolve to one physical package instance per profile. This plugin does not depend on it. The usual cause is another third-party plugin in the profile declaring it as an ordinary nested dependency rather than a peer dependency. Fix that plugin's dependency declaration, or have the profile's package manager resolve one shared instance, and only then investigate search errors.
231
+
232
+ **A search reports `WEB_PROVIDER_AMBIGUOUS`.** More than one provider is available. Name one explicitly as described in [Selecting a provider](#selecting-a-provider).
233
+
234
+ **A search reports `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.** The provider you pinned is currently unavailable. This happens when the keyless channel's breaker is open, see [What happens when a search fails](#what-happens-when-a-search-fails).
235
+
236
+ **There is no settings entry in the Web UI.** This version has no UI card; configure through `cordis.patch.yml` or environment variables, see [In the Web panel](#in-the-web-panel).
237
+
238
+ ## Version compatibility
239
+
240
+ Every published dsh version from `0.1.2-alpha.2` to `0.2.1-alpha.1` has been tested. Testing means three things: installing that version in isolation, typechecking against its own declarations, and installing this plugin with npm under strict peer resolution. The last step is the one that fails most easily, because npm's peer rules are stricter than pnpm's. To reproduce: `bash scripts/compat-matrix.sh`.
241
+
242
+ | dsh line | Tested | Notes |
243
+ |---|---|---|
244
+ | `0.1.2-alpha.2` … `0.1.2-alpha.5` | ✅ | the oldest supported baseline |
245
+ | `0.1.2-rc.1` | ✅ | |
246
+ | `0.1.3-alpha.2` | ✅ | |
247
+ | `0.1.5-alpha.1`, `0.1.5-alpha.2` | ✅ | |
248
+ | `0.1.5-rc.1`, `0.1.5-rc.2`, `0.1.5-rc.3` | ✅ | `0.1.5-rc.2` is also verified end to end: a real `dsh --profile headless` task searched through the anonymous MCP path with no API key |
249
+ | `0.1.6-alpha.1`, `0.1.6-alpha.2` | ✅ | |
250
+ | `0.1.7-alpha.1` | ✅ | the settings service changed shape, see below |
251
+ | `0.2.0-rc.1`, `0.2.0-rc.2` | ✅ | strict npm install beside the host, then a live keyless search |
252
+ | `0.2.1-alpha.1` | ✅ | same, and the only version that needs `@deepseek-ai/cordis@4.0.5-alpha.1` |
253
+
254
+ Versions from the `0.2.0-rc.1` line onward are additionally declared, one full version at a time, under `dsh.compatibility.dshReleases` in `package.json`. That key is catalog metadata: the dsh runtime never reads it, and it does not affect resolution — registries such as [DSH STORE](https://dsh.store/) require an exact per-version record, and a peer range is not installable evidence. The declarations cover `0.2.0-rc.1`, `0.2.0-rc.2`, and `0.2.1-alpha.1`, all `compatible`.
255
+
256
+ ### What differs across versions
257
+
258
+ Probing the real export surface of every version, the `ctx.web` seam turns out to be completely stable: `WebError` is always exported from `dsh-web` and extends `HarnessError`, `launchEnvironmentOf` is always present, and `ctx.settings` is mounted in every version. Only two things differ.
259
+
260
+ First, `0.1.7-alpha.1` replaced the settings API. `SettingsProvider.installSection` is gone, and the service became `SettingsForms`, which derives a config page from the Config schema the Loader already holds (`SettingsDescriptor.schema`, `autoGenerate`). Code that called that method unconditionally throws a `TypeError` there: the plugin loads but fails. It now probes for the method, calls it only when present, and does nothing otherwise. On `0.1.7+` the Loader's schema drives the form and the plugin has nothing to register.
261
+
262
+ Second, the `@deepseek-ai/cordis` line moves with dsh, and it moves through pre-releases: `0.1.5`/`0.1.6` peer it at `^4.0.2` (exactly `4.0.2` for `0.1.5-rc.3`), `0.1.7` at `^4.0.3`, `0.2.0` at `~4.0.4`, and `0.2.1-alpha.1` at `~4.0.5-alpha.1`. Install the version the host asks for, and be careful how you pin it: `^4.0.2` resolves to `4.0.4`, which the `0.1.5` releases were not published against, and this plugin then fails to install strictly beside them. The matrix script reads that range and pins its floor as a concrete version. This plugin's own peer range also needed a `>=4.0.5-alpha.1` comparator, without which `0.2.1-alpha.1` would not install at all.
263
+
264
+ Also supported across that whole range: `@deepseek-ai/dsh-web`, `dsh-settings` (optional), and `dsh-launch-environment`. Node.js needs `>=22.19.0`, matching the harness's own floor.
265
+
266
+ <details>
267
+ <summary>Why the peer range looks like that</summary>
268
+
269
+ ```jsonc
270
+ "@deepseek-ai/dsh-web": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8 || >=0.2.0-rc.1 || >=0.2.1-alpha.1"
271
+ ```
272
+
273
+ That enumeration is the only form that installs across every published version under both pnpm and npm. The reason is one semver rule:
274
+
275
+ > A prerelease version satisfies a range only if some comparator in that range carries a prerelease on the same `major.minor.patch` triple.
276
+
277
+ So `>=0.1.2-rc.1` does not match `0.1.5-rc.2`; the triples differ. A single open-ended lower bound cannot cover a project published as a series of prereleases, and `*` would also admit a future breaking `1.0`.
278
+
279
+ Two things about the shape are easy to get wrong, and both were:
280
+
281
+ - **`||` here does not widen the range, it picks a lower bound.** Every comparator is an open-ended `>=`, so the whole expression is the union of "at or above X" for each X — which is simply "at or above the highest X". A comparator added for a *lower* release is dead weight: appending `|| >=0.2.0-rc.1` to a range ending in `>=0.1.8` drops `0.1.8` and everything above it up to `0.2.0-rc.1`, because the tuple rule above then has no 0.1.8 comparator to match against. This was caught by testing the range against the real published list rather than by reading it.
282
+ - **A prerelease line needs a comparator on its own tuple.** `0.2.0-rc.2` matches `>=0.2.0-rc.1`, but not `>=0.2.1-alpha.1`. `0.2.1-alpha.1` needs the second entry. The same rule applies to `@deepseek-ai/cordis`, whose own line moved through `4.0.5-alpha.1`: `>=4.0.2` excludes it, so the shipped range is `>=4.0.2 || >=4.0.5-alpha.1`. That one is not cosmetic — without it `npm install` of this plugin beside a `0.2.1-alpha.1` host fails with `ERESOLVE`.
283
+
284
+ Measured against the real published artifacts:
285
+
286
+ | Range | Installs with npm |
287
+ |---|---|
288
+ | `>=0.1.2-rc.1` (the earliest form) | **1** of the 14 releases it was meant to cover |
289
+ | The `0.1.8`-terminated enumeration | **14 / 14** |
290
+ | The current enumeration | **20 / 20** of every published release from `0.1.2-alpha.2` to `0.2.1-alpha.1` |
291
+
292
+ This was measured, not reasoned. The open-ended range is fine on pnpm, which is what `dsh plugin add` uses. On npm it made 13 of the first 14 versions fail with `ERESOLVE`. If you hit that error installing an older release of this plugin with npm, upgrade, or pass `--legacy-peer-deps` temporarily.
293
+
294
+ A range that *resolves* is not the same as a version that was *tested*: the table above is 17 rows, while the enumeration resolves on 20 published releases. `0.1.7-alpha.2`, `0.1.7-rc.1`, and `0.1.7-rc.2` are the difference — they install, and are expected to work, but only `0.1.7-alpha.1` was actually run.
295
+
296
+ </details>
297
+
298
+ ### Building from source
299
+
300
+ ```sh
301
+ pnpm install
302
+ pnpm run build # tsdown -> lib/index.js + lib/index.d.ts
303
+ pnpm run typecheck # tsc --noEmit
304
+ pnpm test # builds, then runs the node:test suite against lib/
305
+ ```
306
+
307
+ `src/` is the only source directory. `lib/` is still committed, because both the npm package and git-based installs consume it.
381
308
 
382
309
  ## Acknowledgements
383
310
 
384
- The anonymous MCP integration follows the `web_search` implementation in
385
- [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) (`packages/coding-agent/src/web/search/providers/exa.ts`
386
- and `src/exa/mcp-client.ts`) and the
387
- [`@oh-my-pi/exa`](https://www.npmjs.com/package/@oh-my-pi/exa) plugin: same
388
- "REST when a key exists, credential-free `mcp.exa.ai/mcp` otherwise" strategy,
389
- same `x-exa-source` attribution header, same `Title:`-section response parsing.
390
- Thanks to the oh-my-pi (omp) project for pioneering the zero-config Exa
391
- integration.
311
+ The anonymous MCP integration follows the `web_search` implementation in [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) (`packages/coding-agent/src/web/search/providers/exa.ts` and `src/exa/mcp-client.ts`) and the [`@oh-my-pi/exa`](https://www.npmjs.com/package/@oh-my-pi/exa) plugin: the same "REST when a key exists, credential-free `mcp.exa.ai/mcp` otherwise" strategy, the same `x-exa-source` attribution header, and the same `Title:`-section response parsing. Thanks to the oh-my-pi (omp) project for building the zero-config Exa integration first.
312
+
313
+ Thanks also to **[Exa](https://exa.ai)** for providing and operating the free, unauthenticated hosted MCP server (`mcp.exa.ai/mcp`) that makes this package's zero-config default possible. Exa's hosted MCP is an official Exa product, and anonymous usage is rate-limited, see [Rate limits](#rate-limits).
392
314
 
393
- Thanks also to **[Exa](https://exa.ai)** for providing and operating the
394
- **free, unauthenticated hosted MCP server** (`mcp.exa.ai/mcp`) that makes this
395
- package's zero-config default possible. Exa's hosted MCP is an official Exa
396
- product; anonymous usage is rate-limited (see FAQ).
315
+ Thanks to **[@kahlos](https://github.com/kahlos)** ([PR #1](https://github.com/TonyDua/dsh-web-search-exa/pull/1)), who found that `web_search_advanced_exa` returns a sanitized structured response and that the endpoint does not serve it without a `?tools=` query. Neither is documented; both were established by measurement. The anonymous path now uses that tool by default, and the older `Title:`-section path remains as the fallback.
397
316
 
398
317
  ## Changelog
399
318
 
@@ -401,4 +320,4 @@ See [CHANGELOG.md](CHANGELOG.md) for all notable changes.
401
320
 
402
321
  ## License
403
322
 
404
- MIT — see [LICENSE](LICENSE).
323
+ MIT, see [LICENSE](LICENSE).