# qp-exa-dynamic

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

[![npm](https://img.shields.io/npm/v/qp-exa-dynamic)](https://www.npmjs.com/package/qp-exa-dynamic)

[Exa](https://exa.ai) 支撑的 `WebSearchProvider`，接入
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 `ctx.web` seam。
**默认开启 Exa Dynamic Highlights**，提供 `/exa` 命令在运行时改高亮、检索类型和返回条数，以及一个
`exa_search` 工具让你**按需**走 Exa——而不改变 profile 里其它搜索用哪个提供方。

```sh
dsh plugin --profile web add qp-exa-dynamic
```

## 为什么需要它

官方的 `@deepseek-ai/dsh-web-search-exa` 够不到 Dynamic Highlights，是两处**互相独立**的硬伤：

1. **请求体写死。** 它发的是 `contents.highlights.highlightsPerUrl`，配置里没有任何字段能影响到
   `dynamic`。
2. **Dynamic Highlights 是 beta 能力，需要请求头。** 每个设了 `dynamic: true` 的请求都必须同时带上
   `Exa-Beta: dynamic-highlights-2026-08-28`。那个提供方只发 `authorization`、`content-type`、
   `accept`、`user-agent`——没有 `Exa-Beta`。缺了它 Exa 直接回 HTTP 400（实测）：

   ```json
   {"error":"'highlights.dynamic' is in beta. Send the 'Exa-Beta: dynamic-highlights-2026-08-28' request header to use it.","tag":"INVALID_REQUEST"}
   ```

本插件两样都发，并且彻底去掉了 `highlightsPerUrl`——对着真实 API 实测，Exa 已经忽略这个参数，
传 1 和传 5 返回**逐字节相同**的结果。改用 `maxCharacters`，那才是关掉动态高亮时真正生效的旋钮。

## 实测数据

真实 `/search` 调用，同一个查询，8 条结果：

| 配置 | 高亮字符数 |
| --- | --- |
| 官方提供方的默认值（无可用旋钮） | 51,152 |
| 本插件 `dynamicHighlights: false` + `highlightsMaxCharacters: 1500` | 10,973 |
| 本插件 `dynamicHighlights: true`（默认） | 12,716 |

Dynamic Highlights 不是一刀切截断：它把所有召回文档拼成一条输入、只做一次前向，在**全局范围**分配
共享预算——好内容多给，冗余的不给。

## 安装

```sh
dsh plugin --profile web add qp-exa-dynamic
```

包自带 `dsh.bundle` 清单，安装后 bundle patch 会**自动插入 provider 行**，不需要手写。

**这一步是可选的。** `exa_search` 工具按需走 Exa，完全不需要它；只有想让 `web_search` 本身也走 Exa
时才需要。在 `$DSH_HOME/profiles/web/cordis.patch.yml` 里覆盖 `web` 那一行：

```yaml
- id: web
  name: '@deepseek-ai/dsh-web'
  config:
    searchProvider: exa
    fetchProvider: http
```

> patch 是**整块替换**目标行的 `config`、不做合并，所以 `fetchProvider: http` 必须一起重写，
> 否则抓取提供方会丢。

然后给密钥，两种方式。写进插件配置：

```yaml
- id: qp-exa-dynamic
  name: qp-exa-dynamic
  config:
    apiKey: '你的 Exa 密钥'
```

或者走环境变量。`apiKey` 标了 `role('secret')`，不会出现在任何 `describe()` 响应里——但明文配置文件
终究是明文配置文件，能用环境变量就尽量用。

> **密钥按这个顺序读取：** 配置里的字面 `apiKey` → harness **凭据库**里的 `apiKeyEnv`（把
> `EXA_API_KEY` 加进去，用 GUI 的凭据卡片或 `$DSH_HOME/.credentials.yaml`）→ 启动环境快照（继承环境、
> 调用目录的 `.env`、Harness 主目录的 `.env`）。
>
> 凭据库排在快照前面，是因为快照在某些 Windows 安装上按文档就会漏掉变量——本插件正是跑在这类部署上。
> 如果全局选中了 `exa` 却报 `registered but unavailable`，先查凭据库，再考虑退回明文配置里的字面
> `apiKey`。

改环境变量后要重启 `dsh web`。`cordis.patch.yml` 本身是热加载的，所以配置改动不用重启。

## 配置字段

全部有安全默认值，通常只需要提供密钥。

| 字段 | 默认值 | 含义 |
| --- | --- | --- |
| `providerId` | `exa` | 注册 id。只在需要与另一个 Exa 提供方共存时改。 |
| `apiKey` | 未设置 | 字面密钥；不设则回退到 `apiKeyEnv`。 |
| `apiKeyEnv` | `EXA_API_KEY` | `apiKey` 未设时读取的环境变量名。 |
| `baseURL` | `https://api.exa.ai` | Exa 端点；会追加 `/search`。 |
| `searchType` | `auto` | 检索类型，见下。可用 `/exa type` 运行时改。 |
| `numResults` | `8` | 来源上限。可用 `/exa results` 运行时改。 |
| `dynamicHighlights` | `true` | 默认开；开启时自动带上必需的 `Exa-Beta` 头。可用 `/exa` 运行时改。 |
| `highlightsMaxCharacters` | 未设置 | 每页高亮上限，**仅**在 `dynamicHighlights` 为 false 时生效。 |

`dynamicHighlights` **不会**和 `highlightsMaxCharacters` 同时发——动态开启时共享预算由 Exa 自行分配，
官方文档也明确警告两者不要并用。

## `/exa` 命令

敲在输入框里。它直接对界面执行，**不产生模型消息**。

| 命令 | 作用 |
| --- | --- |
| `/exa` | 切换 Dynamic Highlights |
| `/exa on` / `/exa off` | 明确设置 |
| `/exa type` | 列出可用的检索类型 |
| `/exa type deep` | 设置检索类型 |
| `/exa results` | 报告来源上限 |
| `/exa results 3` | 设置来源上限 |
| `/exa status` | 一次报全：当前状态、**可用的 type 列表**、真实天花板 |

命令声明了 `input.hint`（`on|off|status|type deep|results 3`）——**这是 Web 输入框把参数交给处理函数的
前提**，否则整行会被当成普通聊天消息发给模型。方括号和引号都会被当作透明字符，所以那个提示模板依然可
以就地编辑。`/exa status` 顺带把检索类型列出来，想不起来有哪些 type 时不用再敲第二条；参数打错时会回
一行可以直接照抄的示例。

写入走 settings 服务，命名空间是 Loader 的**配置项 id** `qp-exa-dynamic`，因此落在 profile patch 的
**用户层**，跨重启保留。清掉那一节即回到插件配置的默认值。命令会写的每个字段都在 schema 里标了
`.volatile()` —— 写入路径和自动生成的配置卡片都要求这一点。

实测同一个 provider 实例、同一个查询：关掉动态高亮让同一次搜索从 12,716 字符变成 57,958 字符——
差 4.6 倍，**下一次搜索即刻生效**。

## `exa_search` 工具

插件还在 `web_search` 旁边注册了第二个面向模型的工具：

```
exa_search(query: string, maxResults?: integer)   // maxResults 1-50
```

它存在的目的是让 Exa **按需可达**，而不改变 profile 里其它搜索用谁。seam 对每个 profile 只选**一个**
提供方——不是每次调用选，而且 `searchProvider` 在服务构造时就固化了——所以一个钉死官方搜索的
profile，只能靠一个**绕过该选择**的工具才够得着 Exa。这个工具就是直接调插件自己的 provider，不走
`ctx.web.search`。

路由就这么简单：

| 工具 | 路径 | 走谁 |
| --- | --- | --- |
| `web_search` | `ctx.web` | profile 钉死的那个（**不受影响**） |
| `exa_search` | 直连 | Exa |

**用哪个工具，就是选哪个提供方。** 不改任何全局设置、不用重启，Exa 就在一次调用之外，而 `web_search`
照旧用默认提供方。

直连还顺带让这个工具拥有自己的 `request.maxResults`。`dsh-tool-web` 每次走 seam 都传自己的
`searchMaxResults`，所以**任何经 seam 的提供方都不可能超过那个上限**，要抬它就得 fork agent preset。
这个工具拥有自己的上限，于是条数从**部署级天花板**变成了**每次调用的模型参数**：

| 想要 | 怎么做 | 需要 fork preset 吗 |
| --- | --- | --- |
| 某次要 20 条 | `exa_search(query, maxResults: 20)`——直接用话问就行 | **不需要** |
| 让 `/exa results 20` 生效 | `/exa` 命令 | 需要 |

因为不再经过 seam，截断这份契约改由工具自己履行（`capToolResult`）：Exa 如果对有界请求超量返回，
`sources` 会被裁掉并置 `truncated`。它 50 的上限是自己的——动态高亮实测每条约 1.6k 字符，50 条已经是
约 2 万 token 的上下文。

**没有密钥时会明确报错**（`no Exa API key: …`），而不是悄悄回落到部署的默认提供方替你查。

`web_search` 旁边会多一段提示告诉模型什么时候该用它（日常检索仍走 `web_search`）。如果组合里没有
`tools` 注册表，provider 照常挂载，只是没有这个工具。

## 检索类型

Exa 的 `type` 就是延迟／质量的旋钮。**8 种全部对着真实 API 验证过**；同一查询、8 条结果的实测耗时：

| 类型 | 实测 | 用途 |
| --- | --- | --- |
| `keyword` | 464 ms | 纯关键词，最快 |
| `neural` | 737 ms | 语义检索 |
| `fast` | 798 ms | 快，质量损失极小 |
| `instant` | 856 ms | 实时场景（对话、语音） |
| `auto` | 1,914 ms | **默认** |
| `deep-lite` | 3,116 ms | 轻量综合输出 |
| `deep` | 5,282 ms | 多步推理 |
| `deep-reasoning` | 18,278 ms | 最难的研究任务 |

官方提供方的 schema 只列了 `auto`、`keyword`、`neural` 三种——那套已经过时。本插件全部开放。

### `deep*` 在这个 seam 下有折扣

用本插件自己的类实测，同一查询，动态高亮开启：

| 类型 | 耗时 | 返回来源数 |
| --- | --- | --- |
| `fast` | 718 ms | 8 |
| `auto` | 215 ms | 8 |
| `deep` | 6,891 ms | 3 |
| `deep-reasoning` | 14,776 ms | 4 |

原始 API 对 `deep` 是返回 8 条的；其余几条**没有非空白高亮，被整个丢弃了**——seam 没有别的字段能当
snippet，编造一个会让 seam 说谎。而 `deep*` 真正值钱的是跨来源综合出的 `output`，`WebSearchSource`
里没有字段承载它。所以在这个 seam 下，实用区间是 `keyword`、`neural`、`fast`、`instant`、`auto`。

## 返回条数归 `dsh-tool-web` 管

这一条容易误解，说清楚：

- 模型侧 `web_search` 工具的参数**只有 `queries`**——模型无法要求条数。
- 天花板归 `dsh-tool-web`：`searchMaxResults`，默认 8。它自己的注释：
  *"The consumer owns the returned-context limit; providers and models do not."*
- 工具**每次调用都会传 `maxResults`**，seam 再按它截断结果——所以**任何提供方都不可能超过天花板**。

于是本插件的 `numResults` 是**单向的**：能拉低，拉不高。

| 你设的 | 工具天花板 | 实际发给 Exa |
| --- | --- | --- |
| 3 | 8 | 3 |
| 12 | 8 | 8（夹取） |
| 20 | 8 | 8（夹取） |

`/exa status` 和 `/exa results` 会报出 provider **观测到的真实天花板**，所以被夹取时是明说而不是
静默生效。

**在 Web 端，抬天花板不是改 profile patch 能解决的。** `dsh-web-app` 把宿主那行 `tool-web` 设为
`disabled: true`——因为宿主侧只有 `web` 服务和它的搜索提供方，面向模型的**工具是每会话一份**的，
真正生效的那份来自 **agent preset**。随附的 `standard` preset 里那行只有 `fetch` 和
`searchTimeoutMs`，没有 `searchMaxResults`，于是取 schema 默认值 8。**往 profile patch 里写
`tool-web` 只会落在被禁用的宿主行上，什么都不做。**

所以要改就得 fork preset：把 `standard` 复制到 `$DSH_HOME/.agent-presets/`，在它的 `tool-web` 行加
`searchMaxResults`，再选为默认。这是笔真实的账——副本不会跟随随附 preset 的上游更新，而默认值是在
**创建会话时**读取的，运行中的会话仍停在它们当初组装的 preset 上。多数情况下留在 8 更划算：`/exa
results` 仍然能把条数**往下调**，而那个方向才是省 token 的。

## 已知限制

- **Exa 的 beta 接口可能变。** `dynamic-highlights-2026-08-28` 是 research preview，Exa 改版后需要
  更新 `DYNAMIC_BETA_VALUE`。
- **没有高亮的结果会被整条丢弃**，这是 seam 的规则。动态高亮下实测 8/8 条都带高亮，所以很少触发。
- **没有 `category`、域名/日期过滤，也没有全文。** 这些 Exa 能力本插件尚未暴露。
- **本插件与官方 Exa 提供方每个 profile 只能选一个。** 两者默认都注册 provider id `exa`；要共存需给
  其中一个设不同的 `providerId`。
- **`exa_search` 需要 Exa 密钥，`web_search` 不需要。** 这个工具直连 Exa，没密钥时会**明确报错**而不是
  回落——这正是"选择是真的"的代价。`web_search` 两边都不受影响。
- **仅测过 dsh `0.2.0-rc.2`**，peer 范围也锁在这条线上（`>=0.2.0-rc.2 <0.3.0-0`）。
- **没有 settings 服务时 `/exa` 只改内存。** provider 本身照常工作；没有命令注册表时就没有 `/exa`。
  settings 服务拒绝写入时（没有这个 entry id、字段不是 volatile、profile 只读）会如实回
  `could not save: …`，不会静默吞掉。

## 开发

```sh
node test/index.test.js    # 44 个单元测试，不需要密钥
EXA_API_KEY=... node test/live.mjs   # 打真 API，会消耗额度
```

请**直接运行测试文件**，不要用 `node --test`：后者的 runner 会为每个文件 spawn 子进程，在受限沙箱下
会以 `spawn EPERM` 失败。

### 从本地检出安装

`dsh plugin --profile web add <路径>` 记下的是一个 `link:` 依赖，profile 按**包名**链接它。而 Node 解析
被链接模块自身的 import 时，用的是它的**真实路径**、不是 profile 里那个链接——所以放在
`$DSH_HOME/profiles/` 之外的检出**没有能到达 harness 各包的 `node_modules` 祖先目录**，每一个
`@deepseek-ai/dsh-*` 导入都会以 `ERR_MODULE_NOT_FOUND` 失败：插件挂载上了，然后在第一个 import 上炸。

给它配一份指向**运行时同一批实例**的 `node_modules`：

```powershell
$plugin = "<这个检出的路径>"
$hoist  = "$env:USERPROFILE\.dsh\profiles\node_modules\@deepseek-ai"
New-Item -ItemType Directory -Force "$plugin\node_modules\@deepseek-ai" | Out-Null
foreach ($pkg in @("dsh-web", "dsh-tools", "dsh-launch-environment", "schemastery")) {
  cmd /c mklink /J "$plugin\node_modules\@deepseek-ai\$pkg" "$hoist\$pkg"
}
```

用 junction 而不是再跑一次 `pnpm install`，是**刻意的**：`@deepseek-ai/dsh-tools` 是运行时单例，
嵌套一份副本会让 agent loop 在 provider 被调用之前就崩。全部指向 hoist 目录，才能保证每个包只有
一个物理实例。

## 卸载

```sh
dsh plugin --profile web remove qp-exa-dynamic
```

把 `cordis.patch.yml` 里的 `web` 覆盖删掉即回到内置的 DeepSeek 搜索。那个改动是热加载的，立刻生效。

## 许可

MIT
