# @tonydua/dsh-web-search-exa

[English](README.md) | **简体中文**

[![npm 版本](https://img.shields.io/npm/v/@tonydua/dsh-web-search-exa?label=npm)](https://www.npmjs.com/package/@tonydua/dsh-web-search-exa)
[![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)
[![npm 下载量](https://img.shields.io/npm/dm/@tonydua/dsh-web-search-exa)](https://www.npmjs.com/package/@tonydua/dsh-web-search-exa)
[![License](https://img.shields.io/npm/l/@tonydua/dsh-web-search-exa)](LICENSE)
[![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)](#版本兼容性)
[![Node](https://img.shields.io/badge/node-%3E%3D22.19.0-339933?logo=node.js&logoColor=white)](package.json)
[![GitHub stars](https://img.shields.io/github/stars/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)
[![GitHub issues](https://img.shields.io/github/issues/TonyDua/dsh-web-search-exa)](https://github.com/TonyDua/dsh-web-search-exa)

给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)（dsh）加上 [Exa](https://exa.ai) 网页搜索（匿名、无需 key 版本）。

```powershell
dsh plugin --profile web add @tonydua/dsh-web-search-exa
```

重启 `dsh web` 就能用。不用配 API key，不用改配置，不用选 provider。

背景，了解即可：

- **Exa** 是一个搜索 API。它按关键词或语义检索网页，返回可引用的来源和摘要，不生成答案。它提供 REST API，也运营一个免认证的公共 MCP 服务器。
- **官方的 [`dsh-web-search-exa`](https://github.com/deepseek-ai/deepseek-harness/blob/HEAD/packages/web/web-search-exa/README.zh.md)** 是 dsh 的 Exa 搜索提供方。它走 Exa 的 REST API，必须配置 API key 才有用。
- **本包基于官方包改的。** REST 路径的实现与官方一致，补充了一条免 key 通道：没有 key 时改走 Exa 的公共 MCP 服务器，配了 key 仍走 REST。匿名接入方式参考了 oh-my-pi 项目，见[致谢](#致谢)。

默认情况下不用管这几件事。只有同时用官方包，或 dsh 报错说 provider 有歧义时，才需要看[选中提供方](#选中提供方)。

使用 [deepseek-v4-flash](https://api-docs.deepseek.com) 在 DeepSeek Harness（dsh）内开发。0.2.3 的实时配置部分由 [GLM-5.3-flash](https://docs.z.ai) 完成。

## 特性

- 免 key 可用。搜索经由 Exa 的公共 MCP 服务器（`mcp.exa.ai/mcp`），不携带任何凭据。
- 这条免 key 通道返回结构化结果。它调用 `web_search_advanced_exa`，输出是采用 REST 字段词表的 JSON，因此 source 直接带上真实的 highlight 摘要，无需文本解析。
- 配 key 自动升级。设置 `EXA_API_KEY` 后自动切到 Exa `POST /search` REST API，额度更高，行为不变。
- 即插即用。注册进 dsh `ctx.web` seam，模型侧的 `web_search` 和 `web_fetch` 工具、提示词区段、结果卡片都无需改动。
- 装上就能用。不装官方包时不需要选 provider，默认自动生效。
- 失败时能退让。匿名通道连续失败后，插件会把自己标记为不可用，让 dsh 有机会换别的 provider，而不是每次搜索都硬失败，详见[搜索失败时会发生什么](#搜索失败时会发生什么)。

## 安装

三种方式选一种。方式只决定代码从哪来，装完都一样。

**从 npm 安装。** `dsh.bundle` manifest 自带 bundle patch，会自动插入 provider 行，无需手动改 patch。

```powershell
dsh plugin --profile web add @tonydua/dsh-web-search-exa
```

**从 GitHub Release 安装。** 同一份 tarball，npm 不可达时用。

```powershell
dsh plugin --profile web add https://github.com/TonyDua/dsh-web-search-exa/releases/latest/download/dsh-web-search-exa.tgz
```

**从仓库安装。** 跟随 `main`，包含尚未发布的改动。

```powershell
dsh plugin --profile web add github:TonyDua/dsh-web-search-exa
```

本地开发目录的装法相同，把包名换成路径即可：`dsh plugin --profile web add ../plugins/dsh-web-search-exa`。

装完重启 `dsh web`。多数情况下这就是全部步骤。

### 选中提供方

**不装官方包时不用看这一节。**

dsh 的 seam 每次搜索前会挑一个可用 provider。只有一个可用时自动选中，多于一个时抛 `WEB_PROVIDER_AMBIGUOUS`，要求你指定。所以只有在下面两种情况才需要动手：

- **同时装了官方包**：两个包都注册 provider id `exa`，`dsh web` 启动就会报 `WEB_DUPLICATE_PROVIDER`。必须先给本包改一个 id，见[与官方包共存](#与官方包共存)。
- **报 `WEB_PROVIDER_AMBIGUOUS`**：说明有另一个可用 provider。指定一个即可。

指定方式二选一：

```yaml
# $DSH_HOME/profiles/web/cordis.patch.yml
- id: web
  name: '@deepseek-ai/dsh-web'
  config:
    searchProvider: exa
```

或用环境变量 `$DSH_WEB_SEARCH_PROVIDER=exa`。

改完重启 `dsh web`。模型侧的 `web_search` 工具会自动走选中的 provider，不用改工具配置。

<details>
<summary>发布产物与安装告警（一般不用看）</summary>

**发布产物。** CI 打包本版本的 tarball，在每一个受支持的 dsh 版本上验证，挂到 GitHub Release，并把这个产物本身发布到 npm。所以 Release 附件和 npm 上的 tarball 是同一个文件，而不是两次恰好一致的构建。

**profile 安装告警。** dsh profile 默认 `autoInstallPeers: false`，而 harness 自身的服务由 dsh 宿主在运行时提供，不经 pnpm 解析。如果 `dsh plugin add` 报 peer 警告，把下面这段加进 profile 的 `pnpm-workspace.yaml`：

```yaml
peerDependencyRules:
  ignoreMissing:
    - '@deepseek-ai/cordis'
    - '@deepseek-ai/dsh-*'
```

</details>

## 配置

| 配置键 | 默认值 | 含义 |
|---|---|---|
| `apiKey` | 未设置 | Exa API 密钥字面值。为空或缺失时启用匿名 MCP 路径。 |
| `apiKeyEnv` | `EXA_API_KEY` | 未设置字面 `apiKey` 时读取的环境变量名。 |
| `baseURL` | `https://api.exa.ai` | Exa API 基础 URL。带 key 的 REST 路径会追加 `/search`，与官方 dsh 提供方一致。 |
| `apiURL` | 未设置 | 已弃用的完整 REST 端点别名，设置后优先于 `baseURL`。 |
| `mcpURL` | `https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa` | Exa 托管 MCP 端点，匿名路径使用。默认值里的 `tools` 查询参数是必需的：结构化工具没有它就无法被调用。你自己的 URL 里不写这个参数，插件会补上。 |
| `mcpTool` | `web_search_advanced_exa` | 匿名路径调用哪个 MCP 工具：默认是返回结构化 JSON 的那个；填 `web_search_exa` 则回到旧的 `Title:` 分节文本。见[工作原理](#工作原理)。 |
| `searchType` | `auto` | REST 检索模式：`auto`、`keyword` 或 `neural`。只在 REST 路径上读取。 |
| `numResults` | 未设置 | 请求未携带 `maxResults` 时的默认结果数。 |
| `highlightsPerResult` | `1` | REST 路径每个结果请求的 highlight 句子数。 |
| `providerId` | `exa` | 注册进 `ctx.web` 的提供方 id。仅当本包与官方包同时安装时才需要改，见[与官方包共存](#与官方包共存)。 |

配置写在哪里：dsh 0.2.0 及之后，在 Settings → Plugins 里改，补丁文件由 dsh 代写。更早的版本编辑 `$DSH_HOME/profiles/web/cordis.patch.yml` 里本插件的 `config`，然后重启 `dsh web`。手工改这个文件在任何版本都要重启，dsh 不监听它。也可以用环境变量 `EXA_API_KEY` 和 `$DSH_WEB_SEARCH_PROVIDER`。`apiKey` 标记了 `role('secret')`，任何 `describe()` 响应都不会暴露它的值。

### 在 Web 面板中的呈现

Web UI 提供什么，取决于 dsh 版本：

- **dsh 0.2.0 及之后**：Settings → Plugins 会按本插件的 Config schema 生成配置页，保存后下一次搜索即生效，不用重启 `dsh web`。增删 key 切换匿名和带 key 两条路径，也是这样即时生效。这条需要 0.2.3 或更新：dsh 只为声明了 volatile 字段的插件生成配置页，0.2.1 和 0.2.2 能装上，但设置里根本没有这个页面。
- **dsh 0.1.2 – 0.1.6**：配置入口在 profile 补丁层。插件通过 `ctx.settings.installSection` API 注册了 `web-search-exa` 段，数据层可写，但这些版本没有通用设置表单，请直接改 `cordis.patch.yml` 或用环境变量。
- **插件清单**（Settings → Plugins）：启用后自动出现 `web-search-exa` 条目，所有受支持版本都如此。清单直接读取 Cordis loader 的实时条目，无需额外代码。
- **搜索结果卡片**：`web_search` 调用经 `dsh-tool-web` 照常渲染 `web` 结果卡片（来源、摘要、日期），与提供方无关。匿名 Exa 的结果和 DeepSeek 搜索显示一致。

## 工作原理

| 条件 | 路径 | 端点 |
|---|---|---|
| 配置了 `apiKey` / `EXA_API_KEY` | REST `POST /search`，`Authorization: Bearer` | `https://api.exa.ai/search`（可用 `baseURL` 配置） |
| 未配置任何 key | 匿名 MCP `tools/call web_search_advanced_exa`（JSON-RPC 2.0，无凭据） | `https://mcp.exa.ai/mcp?tools=…`（可配置） |

匿名 MCP 路径不发送任何凭据，来源标识通过 `x-exa-source: dsh-anything` 头携带。结果按 seam 的 `WebSearchSource` 形状规范化（`url`、`title`、`snippet`、`publishedAt`），`maxResults` 由 seam 在返回路径上强制执行。

匿名路径默认调用 `web_search_advanced_exa`。它的文本内容是一份结构化的 JSON 搜索结果，条目的字段名与 REST API 一致，因此可以直接映射成 source，全程不涉及 `Title:` 分节文本解析。有两点需要知道：

- **只有 URL 带上 `?tools=…` 时，这个结构化工具才会被服务。** 裸端点会返回 `MCP error -32602: Tool web_search_advanced_exa not found`，这就是该查询参数写进 `mcpURL` 默认值的原因，也是插件会把它补进任何缺少该参数的 `mcpURL` 的原因。
- **它每条结果都返回整页正文，而 highlights 需要我们单独索取。** 不传 `enableHighlights` 时端点只返回纯文本条目，每条结果都拿不到摘要，搜索会整体返回空。正文随后被丢弃：摘要永远是真实的 highlight 句子，既不生成、也不从正文里截取。

这里**不会**转发 `searchType`。本插件的这个配置用的是 REST 词表（`auto`、`keyword`、`neural`），而该工具只接受自己的词表（`auto`、`fast`、`instant`）；转发会让配置了 `keyword` 或 `neural` 的用户触发参数校验失败，把整条匿名路径一起打死。工具的默认行为本就等同于 `auto`。

把 `mcpTool` 固定为 `web_search_exa` 可以回到旧的文本块路径，同样结果以 `Title:` 开头的分节形式返回。若 Exa 改动了结构化工具的输出形状，可以这样切换；而返回体若不是预期的 JSON，插件本身就会自动回退到分节解析。

由于结构化工具每条结果都带整页正文，响应体可能很大。匿名响应上限为 256 KiB：会先检查声明的 `content-length` 再读取正文，正文若持续增长则在传输中途中断。超限响应按可重试错误失败，而不是被截断或静默解析。

### 限流

匿名通道是 Exa 提供的公共端点，有限流。触发时搜索会失败，错误码是 `WEB_RATE_LIMITED`，错误信息里写明要配 `EXA_API_KEY`。这个码是本插件定的，方便你和模型区分“被限流”和“网络坏了”。

配置 key 后走 REST 路径，不受这个限制。

### 搜索失败时会发生什么

**你会看到什么：**

- 匿名通道被限流：错误码 `WEB_RATE_LIMITED`，提示配置 key。
- 匿名通道连续失败 3 次：本插件会把自己标记为不可用，冷却 5 分钟。这期间 `available()` 返回 `false`。
- 冷却期内你写死了 `searchProvider: exa`：搜索报 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`。
- 冷却期内你没写 `searchProvider`：seam 跳过本插件，去找别的 provider。没有别的可用 provider 时，报 `WEB_PROVIDER_UNAVAILABLE`。
- 配了 key 走 REST 路径：不受上面任何一条影响，失败会照常抛给你。

**为什么会这样。** seam 每次搜索前会调用 `available()` 决定用哪个 provider。如果本插件永远回答“可用”，端点挂掉时每次搜索都会硬失败，用户看到的是一个坏掉的 dsh。所以本插件加了一个熔断器：连续 3 次瞬时失败就承认自己暂时不可用，让 seam 有机会选别人。这是本插件的设计，Exa 没有这个机制。

**计数规则。** 只统计重试可能成功的失败：5xx、429、网络错误、响应体无法解析。满 3 次后冷却 5 分钟，任意一次成功搜索立即清零。

429 以外的 4xx 不计入。那是配置错误，重试多少次都一样，藏进冷却期只会把同一个错误推迟 5 分钟再报给你。

**这是有代价的取舍。** Exa 挂掉的 5 分钟里，写死了 `searchProvider: exa` 的 profile 会直接报错，而不是继续尝试。插件无法替你选：

- 写死 `searchProvider: exa`：平时行为确定，但熔断打开时没有退路。
- 不写 `searchProvider`：熔断时能退到别的 provider，代价是多个 provider 同时可用时，seam 会报 `WEB_PROVIDER_AMBIGUOUS`，需要你再显式指定一个。

想要回退能力就选后者，并且只装一个备选 provider。

## 与官方包比较

DeepSeek Harness 有一个官方 Exa 提供方 [`@deepseek-ai/dsh-web-search-exa`](https://www.npmjs.com/package/@deepseek-ai/dsh-web-search-exa)，需要单独安装，dsh 默认不带。本包是它的零配置变体：补上了官方没有的匿名 MCP 兜底，同时保留配置 key 后的相同 REST 行为。

| | 官方 `@deepseek-ai/dsh-web-search-exa` | 本包 `@tonydua/dsh-web-search-exa` |
|---|---|---|
| REST 路径（`POST /search`） | ✅ 唯一路径 | ✅ 配置 key 时使用 |
| 必须有 API key | ✅ 是，key 为空则不可用 | ❌ 不需要，无 key 走匿名 MCP 兜底 |
| 匿名 MCP（`mcp.exa.ai/mcp`） | ❌ 未实现 | ✅ 无 key 时的默认路径 |
| 零配置安装 | ❌ | ✅ |
| Provider id | `exa`（固定） | 默认 `exa`，可用 `providerId` 配置 |
| Cordis 插件名 | `web-search-exa` | `web-search-exa` |
| 配置键 | `apiKey`、`baseURL`、`searchType`、`numResults`、`highlightsPerResult` | `apiKey`、`apiKeyEnv`、`baseURL`、`apiURL`（旧版）、`mcpURL`、`mcpTool`、`searchType`、`numResults`、`highlightsPerResult`、`providerId` |

该用哪个：

- 你有 `EXA_API_KEY`，且想用官方维护的包：用官方包，它是标准实现。
- 想零配置、免 key 试用 Exa 搜索：用本包。默认走匿名 MCP，出现 key 后自动走 REST。
- 两个都想要：一起装，用 `providerId` 区分，见下节。

## 与官方包共存

两个包默认在 `ctx.web` 下注册相同的 provider id（`exa`），cordis 插件名也都是 `web-search-exa`。seam 会拒绝重复 id，报 `WEB_DUPLICATE_PROVIDER`。所以不改配置就把两个包装进同一个 profile，会在启动时报错。

共存必须显式配置，通过 `providerId` 开关完成：

1. 官方包保持 `exa`，它的 id 固定。
2. 给本包一个不同 id。在本插件的 `config` 里设 `providerId: exa-anon`，任意唯一字符串即可。
3. 在 `web` seam 上显式选中一个。用 `searchProvider: exa-anon` 选匿名变体，或用 `searchProvider: exa` 选官方包。也可以用环境变量 `$DSH_WEB_SEARCH_PROVIDER`。

```yaml
- insert:
    - id: web-search-exa
      name: '@tonydua/dsh-web-search-exa'
      config:
        providerId: exa-anon
- id: web
  name: '@deepseek-ai/dsh-web'
  config:
    searchProvider: exa-anon
```

最简单的替代方案是每个 profile 只装其中一个包，默认配置即可直接用。

## 排查

**升级 dsh 到 0.2.0 及之后后，启动报 `Plugin @tonydua/dsh-web-search-exa@0.1.x is incompatible with dsh 0.2.0-rc.x ... skipping profile bundle`。** 0.2.1 之前的版本声明的 peer 范围以 `>=0.1.8` 结尾，匹配不到任何 dsh 0.2.x prerelease，dsh 为避免崩溃会拒绝加载并跳过这个 bundle，网页搜索此时就没有这个插件了。升级插件后重启：

```powershell
dsh plugin --profile web add @tonydua/dsh-web-search-exa
```

（`web` 换成你的 profile 名。）从 0.2.3 起，0.2.0 及之后的宿主上配置页也会回来，见[在 Web 面板中的呈现](#在-web-面板中的呈现)。

**`dsh web` 启动时报 `duplicate loader entry id: web`。** 这是 0.1.2 的 bug，0.1.4 起已修复，升级本插件即可。如果已在 0.1.4 或更新版本上遇到，请带上 `dsh --version` 和你的 `cordis.patch.yml` 提 issue，因为用户补丁里插入 `web` 行也会产生同样的错误。

**启动时报 `Cannot read properties of undefined (reading 'prepare')`。** `@deepseek-ai/dsh-tools` 是 dsh 的运行时单例包，一个 profile 中必须解析到同一份物理包实例。本插件不依赖它。常见原因是 profile 里其他第三方插件把它声明成了普通嵌套依赖，而不是 peer dependency。先修正那个插件的依赖声明，或让 profile 的包管理器统一解析到共享实例，再排查搜索错误。

**搜索报 `WEB_PROVIDER_AMBIGUOUS`。** 同时存在多个可用 provider。按[选中提供方](#选中提供方)显式指定一个。

**搜索报 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`。** 你写死的 provider 当前不可用。免 key 通道熔断时会这样，见[搜索失败时会发生什么](#搜索失败时会发生什么)。

**Web UI 里找不到插件的配置表单。** 原生的插件配置编辑器在 dsh 0.2.0 及之后才有；0.1.2 – 0.1.6 请用 `cordis.patch.yml` 或环境变量配置，见[在 Web 面板中的呈现](#在-web-面板中的呈现)。

## 版本兼容性

`0.1.2-alpha.2` 到 `0.2.1-alpha.1` 之间每一个已发布的 dsh 版本都实测过。实测包含三件事：独立安装该版本、用该版本自己的类型声明做类型检查、用 npm 严格安装一次本插件。最后一步最容易失败，因为 npm 的 peer 规则比 pnpm 严。复现命令：`bash scripts/compat-matrix.sh`。

| dsh 版本线 | 实测 | 说明 |
|---|---|---|
| `0.1.2-alpha.2` … `0.1.2-alpha.5` | ✅ | 最老的受支持基线 |
| `0.1.2-rc.1` | ✅ | |
| `0.1.3-alpha.2` | ✅ | |
| `0.1.5-alpha.1`、`0.1.5-alpha.2` | ✅ | |
| `0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.5-rc.3` | ✅ | `0.1.5-rc.2` 另有端到端验证：无 API key 时用真实的 `dsh --profile headless` 走通匿名 MCP |
| `0.1.6-alpha.1`、`0.1.6-alpha.2` | ✅ | |
| `0.1.7-alpha.1` | ✅ | settings 服务换了形态，见下 |
| `0.2.0-rc.1`、`0.2.0-rc.2` | ✅ | 在宿主旁做 npm 严格安装，再跑一次真实的免 key 搜索 |
| `0.2.1-alpha.1` | ✅ | 同上；也是唯一需要 `@deepseek-ai/cordis@4.0.5-alpha.1` 的版本 |

从 `0.2.0-rc.1` 这条线起，各版本还在 `package.json` 的 `dsh.compatibility.dshReleases` 里逐个完整版本号声明。这个键是**目录元数据**：dsh 运行期从不读取它，也不影响依赖解析。[DSH STORE](https://dsh.store/) 这类目录要求逐版本精确记录，光有 peer 范围不构成可安装证据。目前声明覆盖 `0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`，均为 `compatible`。

### 各版本之间差在哪

我逐个探测了各版本的真实导出面。结论是 `ctx.web` seam 完全稳定：`WebError` 始终由 `dsh-web` 导出且继承 `HarnessError`，`launchEnvironmentOf` 始终存在，`ctx.settings` 在每个版本都被挂载。真正有差异的有三处。

其一，`0.1.7-alpha.1` 换掉了 settings API。`SettingsProvider.installSection` 被移除，服务变成 `SettingsForms`，它直接从 Loader 已持有的 Config schema 派生配置页（`SettingsDescriptor.schema`、`autoGenerate`）。旧代码无条件调用该方法，会在这个版本上抛 `TypeError`：插件能加载，但会失败。现在改为先探测方法，存在才调用，不存在则什么都不做。在 `0.1.7+` 上由 Loader 的 schema 驱动表单，插件无需注册任何东西。

其二，从 `0.2.0-rc.1` 起 loader 支持运行中更新插件配置，而 `dsh-settings` 只为声明了 volatile 字段的插件生成配置页。Config schema 里声明了 `.volatile()` 的字段（需要 schemastery `>= 3.18.4`），传给 `apply` 时是一个活引用，保存配置后由 Loader 原地写入这个引用。本插件把所有字段都声明为 volatile，所以在 0.2.0+ 上配置页存在，Settings 里的一次保存下一次搜索就生效，无需重启。0.2.1 和 0.2.2 一个字段都没声明，`describe()` 会把条目丢掉，页面根本不存在。读取端做了结构化探测：普通值原样通过，活引用就地解引用，旧版本宿主的行为完全不变。

其三，`@deepseek-ai/cordis` 跟着 dsh 走，而且是一路穿过 prerelease 走的：`0.1.5`/`0.1.6` 是 `^4.0.2`（`0.1.5-rc.3` 为精确 `4.0.2`），`0.1.7` 是 `^4.0.3`，`0.2.0` 是 `~4.0.4`，`0.2.1-alpha.1` 是 `~4.0.5-alpha.1`。宿主要求哪个就装哪个，但钉法要当心：`^4.0.2` 会解析到 `4.0.4`，而 `0.1.5` 那几个版本并非针对它发布的，本插件随后就无法严格安装在它们旁边。矩阵脚本读取该范围后，把它的**下界**作为具体版本钉住。本插件自己的 peer 范围也需要补上 `>=4.0.5-alpha.1` 比较器，否则 `0.2.1-alpha.1` 根本装不上。

同样支持 `@deepseek-ai/dsh-web`、`dsh-settings`（可选）和 `dsh-launch-environment`，覆盖上述整个范围。Node.js 需要 `>=22.19.0`，与 harness 自身的下限一致。

<details>
<summary>为什么 peer 范围长这样</summary>

```jsonc
"@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"
```

这串枚举是**在 pnpm 和 npm 下都能装遍所有已发布版本**的唯一写法。原因是 semver 的一条规则：

> prerelease 版本要满足某个范围，该范围中必须有一个比较器，它的 prerelease 落在**相同的 `major.minor.patch` 三段**上。

所以 `>=0.1.2-rc.1` **匹配不到** `0.1.5-rc.2`，两者三段不同。单一开区间下界覆盖不了“以一串 prerelease 发布的项目”，而 `*` 会连未来的破坏性 `1.0` 一起放行。

这个写法有两点很容易搞错，而这两点我都踩过：

- **这里的 `||` 挑的是下界。** 每个比较器都是开区间的 `>=`，所以整个表达式等于“大于等于各自 X”的并集，也就是“大于等于最大的那个 X”。为**更低**版本追加比较器是白费：在一个以 `>=0.1.8` 结尾的范围后再接 `|| >=0.2.0-rc.1`，会把 `0.1.8` 以及它到 `0.2.0-rc.1` 之间的所有版本一起踢出去，因为按上面那条三段规则，此时已经没有 0.1.8 的比较器可匹配。这一点是拿范围去跑真实发布列表才发现的，读是读不出来的。
- **每条 prerelease 线都需要落在自己三段上的比较器。** `0.2.0-rc.2` 满足 `>=0.2.0-rc.1`，但不满足 `>=0.2.1-alpha.1`，所以 `0.2.1-alpha.1` 需要第二个条目。同一条规则也适用于 `@deepseek-ai/cordis`：它自己的线走到了 `4.0.5-alpha.1`，而 `>=4.0.2` 把它排除在外，所以实际发布的范围是 `>=4.0.2 || >=4.0.5-alpha.1`。这一条不是好看不好看的问题：没有它，在 `0.2.1-alpha.1` 宿主旁用 `npm install` 装本插件会直接 `ERESOLVE` 失败。

在真实发布物上实测的结果：

| 范围 | npm 可安装 |
|---|---|
| `>=0.1.2-rc.1`（最早的写法） | 它想覆盖的 14 个版本里只有 **1** 个 |
| 以 `0.1.8` 结尾的枚举 | **14 / 14** |
| 当前枚举 | 从 `0.1.2-alpha.2` 到 `0.2.1-alpha.1` 全部已发布版本 **20 / 20** |

这个结论是测出来的。开区间在 pnpm 下没问题，而 `dsh plugin add` 用的正是 pnpm。但在 npm 下，它会让最初那 14 个版本中的 13 个报 `ERESOLVE`。如果你用 npm 安装旧版本的本插件时遇到该错误，升级即可，或临时加 `--legacy-peer-deps`。

**能解析**不等于**被测过**：上面的表格是 17 行，而这串枚举能在 20 个已发布版本上解析成功。差值就是 `0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`。它们能装、也预期可用，但实际跑过的只有 `0.1.7-alpha.1`。

</details>

### 从源码构建

```sh
pnpm install
pnpm run build      # tsdown -> lib/index.js + lib/index.d.ts
pnpm run typecheck  # tsc --noEmit
pnpm test           # 先构建，再对 lib/ 跑 node:test 套件
```

`src/` 是唯一的源文件目录。`lib/` 仍然提交进仓库，因为 npm 发布包和基于 git 的安装都依赖它。

## 致谢

匿名 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 接入。

同时感谢 **[Exa](https://exa.ai)** 提供并运营这个免费、免认证的托管 MCP 服务器（`mcp.exa.ai/mcp`），正是它让本包的零配置默认路径成为可能。Exa 托管 MCP 是 Exa 的官方产品，匿名使用有限流，见[限流](#限流)。

感谢 **[@kahlos](https://github.com/kahlos)**（[PR #1](https://github.com/TonyDua/dsh-web-search-exa/pull/1)）：他发现 `web_search_advanced_exa` 返回的是结构化结果，并且端点在没有 `?tools=` 查询参数时根本不会服务这个工具。这两点都没有文档记载，只能靠实测得出。匿名路径现在默认走这个工具，旧的 `Title:` 分节路径保留为回退。

感谢我的好朋友 [vfxli](https://github.com/vfxli) 😊，让我免费使用他的 coding plan。

## 更新日志

所有变更见 [CHANGELOG.md](CHANGELOG.md)。

## 许可证

MIT，见 [LICENSE](LICENSE)。
