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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record.
4
- README.md: 6d6ec687270295a227331625308475827065fe17
5
- README.zh.md: ca4354b5aa7710b68e1717f1bc0c6209e1914bc6
4
+ README.md: 9dc939e621c5ceacc4d0dffbc0eb920f039e2cf1
5
+ README.zh.md: 4caca6729a7fd25894ecd9ddc6c8c3565e4e8605
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  [![GitHub stars](https://img.shields.io/github/stars/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
9
9
  [![GitHub issues](https://img.shields.io/github/issues/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
10
10
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white)](package.json)
11
+ [![dsh](https://img.shields.io/badge/dsh-0.1.2--rc.1-4c6?logo=deepseek&logoColor=white)](https://www.npmjs.com/package/@deepseek-ai/dsh)
11
12
 
12
13
  > Zero-config [Exa](https://exa.ai) web search for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh):
13
14
  > **no API key required** — a `WebSearchProvider` for the `ctx.web` seam with an
@@ -15,6 +16,20 @@
15
16
 
16
17
  Built with [deepseek-v4-flash](https://api-docs.deepseek.com) inside DeepSeek Harness (dsh).
17
18
 
19
+ ## Supported versions
20
+
21
+ `@tonydua/dsh-web-search-exa@0.1.4` is tested and supported with:
22
+
23
+ - `@deepseek-ai/dsh` `0.1.2-rc.1` (the current npm `latest` release)
24
+ - `@deepseek-ai/dsh-web` `0.1.2-rc.1`
25
+ - `@deepseek-ai/dsh-settings` `0.1.2-rc.1` (optional; enables live Settings integration)
26
+ - `@deepseek-ai/dsh-launch-environment` `0.1.2-rc.1`
27
+ - `@deepseek-ai/cordis` `4.0.2`
28
+ - Node.js `>=18`
29
+
30
+ The dsh `0.1.2-rc.1` API is the compatibility baseline. The `0.1.5-alpha.1`
31
+ alpha line is not part of this release's tested support matrix.
32
+
18
33
  ## Features
19
34
 
20
35
  - 🆓 **Zero-config, keyless by default** — searches route through Exa's hosted MCP
@@ -45,7 +60,7 @@ the official one does not have, and keeps the same keyed REST behavior.
45
60
  | Zero-config install | ❌ | ✅ |
46
61
  | Provider id | `exa` (fixed) | `exa` by default, **configurable via `providerId`** |
47
62
  | Cordis plugin name | `web-search-exa` | `web-search-exa` |
48
- | Config keys | `apiKey`, `baseURL`, `searchType`, `numResults`, `highlightsPerResult` | `apiKey`, `apiKeyEnv`, `apiURL`, `mcpURL`, `searchType`, `numResults`, `highlightsPerResult`, `providerId` |
63
+ | Config keys | `apiKey`, `baseURL`, `searchType`, `numResults`, `highlightsPerResult` | `apiKey`, `apiKeyEnv`, `baseURL`, `apiURL` (legacy), `mcpURL`, `searchType`, `numResults`, `highlightsPerResult`, `providerId` |
49
64
 
50
65
  ## Which one should I use?
51
66
 
@@ -61,7 +76,7 @@ the official one does not have, and keeps the same keyed REST behavior.
61
76
 
62
77
  | Condition | Path | Endpoint |
63
78
  |---|---|---|
64
- | `apiKey` / `EXA_API_KEY` set | REST `POST /search` with `Authorization: Bearer` | `https://api.exa.ai/search` (configurable) |
79
+ | `apiKey` / `EXA_API_KEY` set | REST `POST /search` with `Authorization: Bearer` | `https://api.exa.ai/search` (`baseURL` configurable) |
65
80
  | No key configured | Anonymous MCP `tools/call web_search_exa` (JSON-RPC 2.0, no credentials) | `https://mcp.exa.ai/mcp` (configurable) |
66
81
 
67
82
  The anonymous MCP path sends no credentials; attribution rides the
@@ -73,6 +88,29 @@ key (which also switches to the REST path automatically).
73
88
 
74
89
  ## Installation (into a dsh profile)
75
90
 
91
+ **One command from npm** (v0.1.4+ ships the `dsh.bundle` manifest — the bundle
92
+ patch inserts the provider row, so no manual patch editing is needed):
93
+
94
+ ```powershell
95
+ dsh plugin --profile web add @tonydua/dsh-web-search-exa
96
+ ```
97
+
98
+ Restart `dsh web`. **Without an API key** the official DeepSeek search
99
+ provider is unavailable, so the seam auto-selects this provider — fully
100
+ zero-config. **With a key configured**, select Exa explicitly in your own
101
+ `$DSH_HOME/profiles/web/cordis.patch.yml` (applied after bundle patches):
102
+
103
+ ```yaml
104
+ - id: web
105
+ name: '@deepseek-ai/dsh-web'
106
+ config:
107
+ searchProvider: exa
108
+ ```
109
+
110
+ …or at runtime with the environment variable `$DSH_WEB_SEARCH_PROVIDER=exa`.
111
+
112
+ **Local development checkout:**
113
+
76
114
  ```powershell
77
115
  dsh plugin --profile web add ../plugins/dsh-web-search-exa
78
116
  ```
@@ -91,12 +129,6 @@ Then enable the provider and select it. Either merge into
91
129
  searchProvider: exa
92
130
  ```
93
131
 
94
- …or pass `patches/exa-anon-search.patch.yml` as an overlay:
95
-
96
- ```powershell
97
- dsh --profile web --patch patches/exa-anon-search.patch.yml
98
- ```
99
-
100
132
  Alternatively, select the provider at runtime with the environment variable
101
133
  `$DSH_WEB_SEARCH_PROVIDER=exa` (no config edit needed).
102
134
 
@@ -121,7 +153,8 @@ error such as `Cannot read properties of undefined (reading 'prepare')`.
121
153
  | `providerId` | `exa` | Provider id registered into `ctx.web`. Only change it when both this and the official package are installed (see next section). |
122
154
  | `apiKey` | unset | Literal Exa API key. Empty/missing enables the anonymous MCP path. |
123
155
  | `apiKeyEnv` | `EXA_API_KEY` | Environment variable consulted when no literal `apiKey` is set. |
124
- | `apiURL` | `https://api.exa.ai/search` | REST search endpoint (keyed path only). |
156
+ | `baseURL` | `https://api.exa.ai` | Exa API base URL; `/search` is appended for the keyed REST path. Matches the official dsh provider. |
157
+ | `apiURL` | unset | Deprecated full REST endpoint alias. If set, it takes precedence over `baseURL`. |
125
158
  | `mcpURL` | `https://mcp.exa.ai/mcp` | Exa hosted MCP endpoint (anonymous path). |
126
159
  | `searchType` | `auto` | REST retrieval mode: `auto` / `keyword` / `neural`. |
127
160
  | `numResults` | unset | Default result count when the request carries no `maxResults`. |
@@ -172,7 +205,7 @@ plugin namespaces. What is true today:
172
205
  as `web-search-exa` (`@tonydua/dsh-web-search-exa`) once enabled — the
173
206
  inventory reads the live Cordis loader, no extra code needed.
174
207
  - **Settings namespace** (server-side): the plugin registers the
175
- `web-search-exa` section via `installSettingsSection`, so the data layer is
208
+ `web-search-exa` section via the current `ctx.settings.installSection` API, so the data layer is
176
209
  writable — but **no client card binds to it**, so nothing shows in the UI.
177
210
  The built-in "Web search" card edits the official
178
211
  `web-search-deepseek` namespace, not this plugin.
@@ -210,6 +243,11 @@ only; a UI card is planned for the next version. Configure through
210
243
  `cordis.patch.yml` or environment variables for now (see
211
244
  [In the Web panel](#in-the-web-panel)).
212
245
 
246
+ **Q: Which dsh versions are supported?**
247
+ This release supports dsh `0.1.2-rc.1` and its matching `dsh-web`,
248
+ `dsh-settings`, and `dsh-launch-environment` packages. The `0.1.5-alpha.1`
249
+ line is not tested by this release.
250
+
213
251
  ## Acknowledgements
214
252
 
215
253
  The anonymous MCP integration follows the `web_search` implementation in
package/README.zh.md CHANGED
@@ -8,12 +8,26 @@
8
8
  [![GitHub stars](https://img.shields.io/github/stars/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
9
9
  [![GitHub issues](https://img.shields.io/github/issues/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
10
10
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white)](package.json)
11
+ [![dsh](https://img.shields.io/badge/dsh-0.1.2--rc.1-4c6?logo=deepseek&logoColor=white)](https://www.npmjs.com/package/@deepseek-ai/dsh)
11
12
 
12
13
  > 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)提供**零配置**的 [Exa](https://exa.ai) 网页搜索:
13
14
  > **无需 API key** —— 一个 `ctx.web` seam 的 `WebSearchProvider`,内置匿名 MCP 兜底 + 带 key 的 REST 路径。
14
15
 
15
16
  使用 [deepseek-v4-flash](https://api-docs.deepseek.com) 在 DeepSeek Harness(dsh)内开发。
16
17
 
18
+ ## 当前支持的版本
19
+
20
+ `@tonydua/dsh-web-search-exa@0.1.4` 已针对以下版本测试并支持:
21
+
22
+ - `@deepseek-ai/dsh` `0.1.2-rc.1`(当前 npm `latest` 发布线)
23
+ - `@deepseek-ai/dsh-web` `0.1.2-rc.1`
24
+ - `@deepseek-ai/dsh-settings` `0.1.2-rc.1`(可选;用于启用实时 Settings 集成)
25
+ - `@deepseek-ai/dsh-launch-environment` `0.1.2-rc.1`
26
+ - `@deepseek-ai/cordis` `4.0.2`
27
+ - Node.js `>=18`
28
+
29
+ dsh `0.1.2-rc.1` 是本版本的兼容基线;`0.1.5-alpha.1` alpha 线不在本版本的测试支持矩阵内。
30
+
17
31
  ## 特性
18
32
 
19
33
  - 🆓 **零配置、默认免 key** —— 搜索经由 Exa 官方托管的 MCP 服务器(`mcp.exa.ai/mcp`),**完全不携带凭据**(Exa 官方提供的免认证公共 MCP,有限流)。
@@ -34,7 +48,7 @@ DeepSeek Harness 自带官方 Exa 提供方 [`@deepseek-ai/dsh-web-search-exa`](
34
48
  | 零配置安装 | ❌ | ✅ |
35
49
  | Provider id | `exa`(固定) | 默认 `exa`,**可用 `providerId` 配置** |
36
50
  | Cordis 插件名 | `web-search-exa` | `web-search-exa` |
37
- | 配置键 | `apiKey`、`baseURL`、`searchType`、`numResults`、`highlightsPerResult` | `apiKey`、`apiKeyEnv`、`apiURL`、`mcpURL`、`searchType`、`numResults`、`highlightsPerResult`、`providerId` |
51
+ | 配置键 | `apiKey`、`baseURL`、`searchType`、`numResults`、`highlightsPerResult` | `apiKey`、`apiKeyEnv`、`baseURL`、`apiURL`(旧版)、`mcpURL`、`searchType`、`numResults`、`highlightsPerResult`、`providerId` |
38
52
 
39
53
  ## 我该用哪个?
40
54
 
@@ -46,13 +60,32 @@ DeepSeek Harness 自带官方 Exa 提供方 [`@deepseek-ai/dsh-web-search-exa`](
46
60
 
47
61
  | 条件 | 路径 | 端点 |
48
62
  |---|---|---|
49
- | 配置了 `apiKey` / `EXA_API_KEY` | REST `POST /search`,`Authorization: Bearer` | `https://api.exa.ai/search`(可配置) |
63
+ | 配置了 `apiKey` / `EXA_API_KEY` | REST `POST /search`,`Authorization: Bearer` | `https://api.exa.ai/search`(可用 `baseURL` 配置) |
50
64
  | 未配置任何 key | 匿名 MCP `tools/call web_search_exa`(JSON-RPC 2.0,无凭据) | `https://mcp.exa.ai/mcp`(可配置) |
51
65
 
52
66
  匿名 MCP 路径不发送任何凭据,来源标识通过 `x-exa-source: dsh-anything` 头携带。结果按 seam 的 `WebSearchSource` 形状规范化(`url`、`title`、`snippet`、`publishedAt`),`maxResults` 由 seam 在返回路径上强制执行。匿名使用受 Exa 限流:HTTP 429 会以 `WEB_PROVIDER_ERROR` 呈现,并提示配置 API key(配置后自动切换到 REST 路径)。
53
67
 
54
68
  ## 安装(装入 dsh profile)
55
69
 
70
+ **一条命令从 npm 安装**(v0.1.4+ 自带 `dsh.bundle` manifest——bundle patch 会自动插入 provider 行,无需手动改 patch):
71
+
72
+ ```powershell
73
+ dsh plugin --profile web add @tonydua/dsh-web-search-exa
74
+ ```
75
+
76
+ 重启 `dsh web` 生效。**无 API key 时**官方 DeepSeek 搜索提供方不可用,seam 会自动选中本插件——完全零配置。**配了 key 时**,需在你的 `$DSH_HOME/profiles/web/cordis.patch.yml`(在 bundle patch 之后应用)里显式选中 Exa:
77
+
78
+ ```yaml
79
+ - id: web
80
+ name: '@deepseek-ai/dsh-web'
81
+ config:
82
+ searchProvider: exa
83
+ ```
84
+
85
+ …或用环境变量 `$DSH_WEB_SEARCH_PROVIDER=exa` 在运行时选中。
86
+
87
+ **本地开发目录:**
88
+
56
89
  ```powershell
57
90
  dsh plugin --profile web add ../plugins/dsh-web-search-exa
58
91
  ```
@@ -70,12 +103,6 @@ dsh plugin --profile web add ../plugins/dsh-web-search-exa
70
103
  searchProvider: exa
71
104
  ```
72
105
 
73
- 或把 `patches/exa-anon-search.patch.yml` 作为覆盖层传入:
74
-
75
- ```powershell
76
- dsh --profile web --patch patches/exa-anon-search.patch.yml
77
- ```
78
-
79
106
  也可以不修改配置,直接用环境变量 `$DSH_WEB_SEARCH_PROVIDER=exa` 在运行时选中该提供方。
80
107
 
81
108
  重启 `dsh web` 生效。模型侧的 `web_search` 工具随即走该提供方,无需改任何工具配置。
@@ -91,7 +118,8 @@ dsh --profile web --patch patches/exa-anon-search.patch.yml
91
118
  | `providerId` | `exa` | 注册进 `ctx.web` 的提供方 id。仅当本包与官方包同时安装时才需要改(见下一节)。 |
92
119
  | `apiKey` | 未设置 | Exa API 密钥字面值。为空/缺失时启用匿名 MCP 路径。 |
93
120
  | `apiKeyEnv` | `EXA_API_KEY` | 未设置字面 `apiKey` 时读取的环境变量名。 |
94
- | `apiURL` | `https://api.exa.ai/search` | REST 搜索端点(仅带 key 的路径使用)。 |
121
+ | `baseURL` | `https://api.exa.ai` | Exa API 基础 URL;带 key 的 REST 路径会追加 `/search`,与官方 dsh 提供方一致。 |
122
+ | `apiURL` | 未设置 | 已弃用的完整 REST 端点别名;设置后优先于 `baseURL`。 |
95
123
  | `mcpURL` | `https://mcp.exa.ai/mcp` | Exa 托管 MCP 端点(匿名路径使用)。 |
96
124
  | `searchType` | `auto` | REST 检索模式:`auto` / `keyword` / `neural`。 |
97
125
  | `numResults` | 未设置 | 请求未携带 `maxResults` 时的默认结果数。 |
@@ -126,7 +154,7 @@ dsh --profile web --patch patches/exa-anon-search.patch.yml
126
154
  **状态:本版本的配置入口在 profile 补丁层,不在 Web UI —— 没有可编辑的界面入口。** Settings UI 只渲染客户端插件为固定命名空间(`shell`、`agent-loop`、`web-search-deepseek`)手工注册的卡片,对任意插件命名空间没有通用表单。当前实际情况:
127
155
 
128
156
  - **插件清单**(Settings → Plugins):启用后自动出现 `web-search-exa`(`@tonydua/dsh-web-search-exa`)条目 —— 清单直接读取 Cordis loader 的实时条目,无需额外代码。
129
- - **设置命名空间**(服务端):插件通过 `installSettingsSection` 注册了 `web-search-exa` 段,数据层可写——但**没有任何客户端卡片绑定它**,所以界面上不显示。内置的 "Web search" 卡片编辑的是官方 `web-search-deepseek` 命名空间,与本插件无关。
157
+ - **设置命名空间**(服务端):插件通过当前的 `ctx.settings.installSection` API 注册了 `web-search-exa` 段,数据层可写——但**没有任何客户端卡片绑定它**,所以界面上不显示。内置的 "Web search" 卡片编辑的是官方 `web-search-deepseek` 命名空间,与本插件无关。
130
158
  - **现在怎么改配置**:编辑 `$DSH_HOME/profiles/web/cordis.patch.yml` 里本插件的 `config`(字段与默认值见上方配置表),重启 `dsh web`;或用环境变量 `EXA_API_KEY` / `$DSH_WEB_SEARCH_PROVIDER`。`apiKey` 标记了 `role('secret')`,任何 `describe()` 响应都不会暴露其值。
131
159
  - **搜索结果卡片**:`web_search` 调用经 `dsh-tool-web` 照常渲染 `web` 结果卡片(来源、摘要、日期),与提供方无关 —— 匿名 Exa 的结果与 DeepSeek 搜索显示完全一致。
132
160
 
@@ -146,6 +174,9 @@ dsh --profile web --patch patches/exa-anon-search.patch.yml
146
174
  **Q: 为什么 Web UI 里没有设置入口?**
147
175
  本版本只在服务端注册了 `web-search-exa` 设置命名空间;UI 卡片计划在下一版本提供。现阶段通过 `cordis.patch.yml` 或环境变量配置(见[在 Web 面板中的呈现](#在-web-面板中的呈现))。
148
176
 
177
+ **Q: 支持哪些 dsh 版本?**
178
+ 本版本支持 dsh `0.1.2-rc.1` 及其匹配的 `dsh-web`、`dsh-settings`、`dsh-launch-environment` 包;`0.1.5-alpha.1` 线未经本版本测试。
179
+
149
180
  ## 致谢(Acknowledgements)
150
181
 
151
182
  匿名 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 接入。
@@ -0,0 +1,17 @@
1
+ # The dsh-web-search-exa bundle patch.
2
+ #
3
+ # Inserts the zero-config Exa search provider into the profile. It must NOT
4
+ # re-define rows owned by official bundle patches (e.g. dsh-base's `- id: web`
5
+ # row): duplicate ids inside the bundle group fail the loader, while the
6
+ # user's own profile cordis.patch.yml (applied last) may override rows by id.
7
+ #
8
+ # Selection: with no API key the official deepseek provider is unavailable, so
9
+ # the seam auto-selects this provider (zero-config). Keyed users who prefer Exa
10
+ # select it explicitly in their own profile patch or via
11
+ # DSH_WEB_SEARCH_PROVIDER=exa (see README).
12
+ - insert:
13
+ - id: web-search-exa
14
+ name: '@tonydua/dsh-web-search-exa'
15
+ config:
16
+ apiKeyEnv: EXA_API_KEY
17
+ # providerId: exa-anon # uncomment to coexist with the official package
package/lib/index.js CHANGED
@@ -6,7 +6,8 @@
6
6
  * search routes through Exa's hosted MCP server (`https://mcp.exa.ai/mcp`) via
7
7
  * JSON-RPC 2.0 with no credentials — Exa's documented unauthenticated public
8
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`.
9
+ * (`POST {baseURL}/search`) is used instead, mirroring
10
+ * `@deepseek-ai/dsh-web-search-exa`.
10
11
  *
11
12
  * The anonymous-MCP strategy and its response parsing follow the `web_search`
12
13
  * implementation in can1357/oh-my-pi (see README acknowledgements).
@@ -19,7 +20,7 @@
19
20
  * through the profile patch layer (see README "In the Web panel").
20
21
  */
21
22
 
22
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
23
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
23
24
  import { WebError } from "@deepseek-ai/dsh-web";
24
25
  import z from "@deepseek-ai/schemastery";
25
26
 
@@ -28,7 +29,9 @@ const DEFAULT_PROVIDER_ID = "exa";
28
29
  /** Backward-compatible alias for the default provider id. */
29
30
  const PROVIDER_ID = DEFAULT_PROVIDER_ID;
30
31
  /** Exa REST search endpoint; used only when an API key is configured. */
31
- const DEFAULT_API_URL = "https://api.exa.ai/search";
32
+ const DEFAULT_BASE_URL = "https://api.exa.ai";
33
+ /** Legacy full REST endpoint; `baseURL` is the canonical dsh-compatible option. */
34
+ const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/search`;
32
35
  /** Exa hosted MCP endpoint; the anonymous fallback path. */
33
36
  const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp";
34
37
  /** Environment variable consulted when no literal `apiKey` is configured. */
@@ -42,11 +45,11 @@ const MCP_TOOL = "web_search_exa";
42
45
  /** Attribution header sent on anonymous MCP requests. Bump with the version. */
43
46
  const MCP_SOURCE = "dsh-anything";
44
47
  /** User agent for REST requests. */
45
- const USER_AGENT = "deepseek-harness-exa/0.1.0";
48
+ const USER_AGENT = "deepseek-harness-exa/0.1.4";
46
49
  /** Snippet cap for text-derived snippets (matching oh-my-pi's choice). */
47
50
  const MAX_SNIPPET_CHARS = 500;
48
51
  /** Settings namespace carrying this provider's configuration. */
49
- const SETTINGS_NAMESPACE = settingsNamespace("web-search-exa");
52
+ const SETTINGS_NAMESPACE = "web-search-exa";
50
53
 
51
54
  /** True for a positive whole number (cheap local config check). */
52
55
  function isPositiveInteger(value) {
@@ -69,13 +72,24 @@ function throwIfAborted(signal) {
69
72
  * Resolve the API key: literal config first, then the environment variable.
70
73
  * `undefined` means the anonymous MCP path is used.
71
74
  */
72
- function resolveApiKey(options) {
75
+ function resolveApiKeyFromProcess(options) {
73
76
  if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
74
77
  const fromEnv = process.env[options.apiKeyEnv];
75
78
  if (fromEnv != null && fromEnv.length > 0) return fromEnv;
76
79
  return undefined;
77
80
  }
78
81
 
82
+ /**
83
+ * Resolve a key against dsh's immutable launch-environment snapshot. The
84
+ * process fallback keeps direct library use and older hosts working.
85
+ */
86
+ function resolveApiKey(options, environment) {
87
+ if (options.apiKey != null && options.apiKey.length > 0) return options.apiKey;
88
+ const fromEnvironment = environment?.get(options.apiKeyEnv)?.value;
89
+ if (fromEnvironment != null && fromEnvironment.length > 0) return fromEnvironment;
90
+ return resolveApiKeyFromProcess(options);
91
+ }
92
+
79
93
  // ── REST path (with API key) ────────────────────────────────────────────────
80
94
 
81
95
  /**
@@ -210,11 +224,13 @@ function mapMcpSections(sections) {
210
224
  * take effect on the next search.
211
225
  */
212
226
  function resolveOptions(section) {
227
+ const baseURL = section.baseURL ?? DEFAULT_BASE_URL;
213
228
  return {
214
229
  providerId: section.providerId ?? DEFAULT_PROVIDER_ID,
215
230
  apiKey: section.apiKey ?? "",
216
231
  apiKeyEnv: section.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
217
- apiURL: section.apiURL ?? DEFAULT_API_URL,
232
+ baseURL,
233
+ apiURL: section.apiURL ?? `${baseURL.replace(/\/+$/, "")}/search`,
218
234
  mcpURL: section.mcpURL ?? DEFAULT_MCP_URL,
219
235
  searchType: section.searchType ?? DEFAULT_SEARCH_TYPE,
220
236
  numResults: section.numResults,
@@ -222,8 +238,16 @@ function resolveOptions(section) {
222
238
  };
223
239
  }
224
240
 
241
+ /** Resolve the keyed REST endpoint from either the current or legacy option shape. */
242
+ function resolveSearchURL(options) {
243
+ if (options.apiURL != null) return options.apiURL;
244
+ const baseURL = options.baseURL ?? DEFAULT_BASE_URL;
245
+ return `${baseURL.replace(/\/+$/, "")}/search`;
246
+ }
247
+
225
248
  class ExaSearchProvider {
226
249
  resolveOptions;
250
+ resolveApiKey;
227
251
  id;
228
252
 
229
253
  /**
@@ -231,22 +255,28 @@ class ExaSearchProvider {
231
255
  * operation, snapshotted once at each operation's entry so one search
232
256
  * never mixes two settings sections (same pattern as the official
233
257
  * DeepSeek provider).
258
+ * @param resolveApiKey - optional key resolver; dsh hosts pass their
259
+ * launch-environment snapshot while direct users retain process.env fallback.
234
260
  */
235
- constructor(resolveOptions) {
261
+ constructor(resolveOptions, resolveApiKey = resolveApiKeyFromProcess) {
236
262
  this.resolveOptions = resolveOptions;
263
+ this.resolveApiKey = resolveApiKey;
237
264
  this.id = resolveOptions().providerId ?? PROVIDER_ID;
238
265
  }
239
266
 
240
- /** The anonymous MCP path needs no credentials, so the provider is always usable. */
267
+ /** The anonymous MCP path needs no credentials, so only local options gate use. */
241
268
  available() {
242
269
  const options = this.resolveOptions();
243
- return URL.canParse(options.apiURL) && URL.canParse(options.mcpURL);
270
+ return URL.canParse(resolveSearchURL(options))
271
+ && URL.canParse(options.mcpURL)
272
+ && isPositiveInteger(options.highlightsPerResult)
273
+ && (options.numResults === undefined || isPositiveInteger(options.numResults));
244
274
  }
245
275
 
246
276
  async search(request, signal) {
247
277
  throwIfAborted(signal);
248
278
  const options = this.resolveOptions();
249
- const apiKey = resolveApiKey(options);
279
+ const apiKey = this.resolveApiKey(options);
250
280
  return apiKey !== undefined
251
281
  ? await this.#restSearch(request, apiKey, options, signal)
252
282
  : await this.#anonymousMcpSearch(request, options, signal);
@@ -256,9 +286,10 @@ class ExaSearchProvider {
256
286
  async #restSearch(request, apiKey, options, signal) {
257
287
  throwIfAborted(signal);
258
288
  const numResults = request.maxResults ?? options.numResults;
289
+ const apiURL = resolveSearchURL(options);
259
290
  let response;
260
291
  try {
261
- response = await fetch(options.apiURL, {
292
+ response = await fetch(apiURL, {
262
293
  method: "POST",
263
294
  redirect: "error",
264
295
  headers: {
@@ -387,8 +418,13 @@ const Config = z.object({
387
418
  apiKey: z.string().role("secret"),
388
419
  /** Environment variable consulted when no literal `apiKey` is configured. */
389
420
  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),
421
+ /** Exa API base URL; `/search` is appended for the keyed REST path. */
422
+ baseURL: z.string().default(DEFAULT_BASE_URL),
423
+ /**
424
+ * Legacy full REST endpoint. When set, it takes precedence over `baseURL`;
425
+ * new configurations should use `baseURL` to match the official provider.
426
+ */
427
+ apiURL: z.string(),
392
428
  /** Exa hosted MCP endpoint, used by the anonymous fallback. */
393
429
  mcpURL: z.string().default(DEFAULT_MCP_URL),
394
430
  /** REST retrieval mode: `auto`, `keyword`, or `neural`. */
@@ -400,24 +436,32 @@ const Config = z.object({
400
436
  });
401
437
 
402
438
  /**
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.
439
+ * Register the Exa search provider with `ctx.web` and, when the optional
440
+ * settings service is mounted, install its Settings section using the dsh
441
+ * 0.1.2 API.
405
442
  */
406
443
  function apply(ctx, config) {
407
444
  let current = () => config;
408
- installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
409
- setSource: (source) => {
410
- current = source;
411
- },
412
- onChange: () => {},
445
+ ctx.inject(["settings"], (settingsCtx) => {
446
+ settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
447
+ setSource: (source) => {
448
+ current = source;
449
+ },
450
+ onChange: () => {},
451
+ });
413
452
  });
414
- ctx.web.registerSearchProvider(new ExaSearchProvider(() => resolveOptions(current())));
453
+ const environment = launchEnvironmentOf(ctx);
454
+ ctx.web.registerSearchProvider(new ExaSearchProvider(
455
+ () => resolveOptions(current()),
456
+ (options) => resolveApiKey(options, environment),
457
+ ));
415
458
  }
416
459
 
417
460
  export {
418
461
  Config,
419
462
  DEFAULT_API_KEY_ENV,
420
463
  DEFAULT_API_URL,
464
+ DEFAULT_BASE_URL,
421
465
  DEFAULT_HIGHLIGHTS_PER_RESULT,
422
466
  DEFAULT_MCP_URL,
423
467
  DEFAULT_PROVIDER_ID,
@@ -7,11 +7,15 @@ import type { WebSearchProvider } from '@deepseek-ai/dsh-web';
7
7
 
8
8
  /** Config schema type (fields are optional at the load boundary). */
9
9
  export interface ExaSearchProviderConfig {
10
+ /** Provider id registered into `ctx.web`; defaults to `exa`. */
11
+ providerId?: string;
10
12
  /** Literal Exa API key; empty/missing enables the anonymous MCP path. */
11
13
  apiKey?: string;
12
14
  /** Environment variable consulted when no literal `apiKey` is configured. */
13
15
  apiKeyEnv?: string;
14
- /** REST search endpoint, used only when an API key is available. */
16
+ /** Exa API base URL; `/search` is appended for the keyed REST path. */
17
+ baseURL?: string;
18
+ /** @deprecated Use `baseURL`; this full endpoint remains supported for compatibility. */
15
19
  apiURL?: string;
16
20
  /** Exa hosted MCP endpoint, used by the anonymous fallback. */
17
21
  mcpURL?: string;
@@ -23,18 +27,26 @@ export interface ExaSearchProviderConfig {
23
27
  highlightsPerResult?: number;
24
28
  }
25
29
 
30
+ /** Fully resolved options accepted by the provider constructor. */
31
+ export interface ExaSearchProviderOptions {
32
+ providerId?: string;
33
+ apiKey: string;
34
+ apiKeyEnv: string;
35
+ baseURL: string;
36
+ apiURL?: string;
37
+ mcpURL: string;
38
+ searchType: 'auto' | 'keyword' | 'neural';
39
+ numResults?: number;
40
+ highlightsPerResult: number;
41
+ }
42
+
26
43
  /** The Exa-backed provider with anonymous MCP fallback. */
27
44
  export class ExaSearchProvider implements WebSearchProvider {
28
45
  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
- });
46
+ constructor(
47
+ resolveOptions: () => ExaSearchProviderOptions,
48
+ resolveApiKey?: (options: ExaSearchProviderOptions) => string | undefined,
49
+ );
38
50
  available(): boolean;
39
51
  search(request: { query: string; maxResults?: number }, signal?: AbortSignal): Promise<{
40
52
  sources: ReadonlyArray<{ url: string; title?: string; snippet?: string; publishedAt?: string }>;
@@ -50,5 +62,9 @@ export const inject: readonly ['web'];
50
62
  export const Config: import('@deepseek-ai/schemastery').Schema<ExaSearchProviderConfig>;
51
63
  /** Settings namespace for the Web panel section. */
52
64
  export const SETTINGS_NAMESPACE: string;
65
+ /** Default Exa API base URL; `/search` is appended. */
66
+ export const DEFAULT_BASE_URL: string;
67
+ /** Legacy default full REST endpoint. */
68
+ export const DEFAULT_API_URL: string;
53
69
  /** Register the Exa search provider with `ctx.web` and install its Settings section. */
54
70
  export function apply(ctx: Context, config: ExaSearchProviderConfig): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tonydua/dsh-web-search-exa",
3
3
  "description": "Zero-config Exa web search provider for DeepSeek Harness (dsh): keyless anonymous MCP fallback (mcp.exa.ai/mcp) plus keyed REST search — a drop-in WebSearchProvider for the ctx.web seam, no API key required.",
4
- "version": "0.1.1",
4
+ "version": "0.1.4",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -36,8 +36,14 @@
36
36
  "./package.json": "./package.json"
37
37
  },
38
38
  "files": [
39
- "lib"
39
+ "lib",
40
+ "cordis.patch.yml"
40
41
  ],
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ }
46
+ },
41
47
  "publishConfig": {
42
48
  "access": "public"
43
49
  },
@@ -45,17 +51,19 @@
45
51
  "test": "node --test test/index.test.js"
46
52
  },
47
53
  "peerDependencies": {
48
- "@deepseek-ai/cordis": "^4.0.1-rc.1",
49
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
50
- "@deepseek-ai/dsh-web": "^0.1.0-rc.6"
54
+ "@deepseek-ai/cordis": "^4.0.2",
55
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-rc.1",
56
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
57
+ "@deepseek-ai/dsh-web": "^0.1.2-rc.1"
51
58
  },
52
59
  "dependencies": {
53
- "@deepseek-ai/schemastery": "^3.18.1"
60
+ "@deepseek-ai/schemastery": "^3.18.2"
54
61
  },
55
62
  "devDependencies": {
56
- "@deepseek-ai/cordis": "^4.0.1",
57
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-web": "^0.1.0-rc.6"
63
+ "@deepseek-ai/cordis": "^4.0.2",
64
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-rc.1",
65
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
66
+ "@deepseek-ai/dsh-web": "^0.1.2-rc.1"
59
67
  },
60
68
  "engines": {
61
69
  "node": ">=18"