dsh-web-tavily 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## 0.4.0 (2026-09-13)
6
+
7
+ ### Changed
8
+
9
+ - **Renamed again, to `dsh-web-tavily`.** The 0.3.0 name `dsh-tavily-search`
10
+ turned out to be taken on npm as well (`ouones`, v0.1.2) — as are
11
+ `dsh-tavily-provider`, `dsh-search-tavily`, `dsh-tavily` and
12
+ `dsh-tavily-web-search`; `dsh-web-tavily` is the free one. The package name,
13
+ the manifest `id`, the bundle patch's row id and module name, the Cordis name,
14
+ the settings namespace and the log file all follow it. 0.3.0 was never
15
+ published, so nobody has to migrate twice.
16
+ - **Settings section and log file are now `dsh-web-tavily`**: the section in
17
+ `~/.dsh/settings.yaml` is `dsh-web-tavily:` and the lifecycle log is
18
+ `~/.dsh-tui/dsh-web-tavily.log`.
19
+
20
+ ## 0.3.0 (2026-09-13)
21
+
22
+ ### Changed
23
+
24
+ - **Renamed from `dsh-web-search-tavily` to `dsh-tavily-search`.** The npm name
25
+ `dsh-web-search-tavily` is taken by an unrelated package, so the package name,
26
+ the manifest `id`, the bundle patch's row id and module name, and the plugin's
27
+ Cordis name all moved to `dsh-tavily-search`. The provider id (`tavily`),
28
+ the registered fetch provider (`http-trusted`) and every seam contract are
29
+ unchanged, so a profile patch needs no edit.
30
+ - **The settings namespace and the log file follow the package name**: the
31
+ section in `~/.dsh/settings.yaml` is now `dsh-tavily-search:` (rename the old
32
+ `web-search-tavily:` key to keep your values) and the lifecycle log is now
33
+ `~/.dsh-tui/dsh-tavily-search.log`.
34
+
35
+ ### Added
36
+
37
+ - The `repository` field in `package.json` and `source.repository` in
38
+ `dsh-plugin.json`, both pointing at the public repository.
39
+
40
+ ## 0.2.0 (2026-09-13)
41
+
42
+ ### Added
43
+
44
+ - **A localized settings card** (`lib/settings-card.js`). `settings.installSection`
45
+ registers the namespace and its schema, and the host can always render a generic
46
+ card from that — but a generic card shows raw field keys (`apiKeyEnv`,
47
+ `searchDepth`, …) with no translation. The card registered here is a
48
+ `tuiSettingsSections` section, the same shape every other plugin in this
49
+ ecosystem ships, with Chinese and English descriptions for the credential
50
+ reference, the API base URL, search depth, topic, the AI-answer switch and the
51
+ fallback result cap. `apiKey` is deliberately left out: a literal secret in a
52
+ settings document is the one thing this plugin tells users not to do.
53
+ - The card registration tolerates the first tick: the seam refuses with
54
+ `requires a live Cordis activation context` while the activation is not live
55
+ yet, so the registration retries for ~12 s and logs the refusal once. Verified
56
+ against the headless probe: the card lands on the retry.
57
+
58
+ ## 0.1.1 (2026-09-13)
59
+
60
+ ### Added
61
+
62
+ - **A lifecycle log** (`lib/diag.js` → `~/.dsh-tui/dsh-web-search-tavily.log`, the
63
+ host logger when there is one, 128 KiB cap, silent under `node --test`). The
64
+ plugin declares `inject = ["web"]`, so on a host without that seam `apply()`
65
+ stays parked — and with no logging at all it looked exactly like a plugin that
66
+ was never loaded. There is now an import-time breadcrumb, an `apply started`
67
+ line, the settings-namespace and provider-registration results, and an unload
68
+ line.
69
+ - Both provider registrations are wrapped in a `try`/`catch` that logs, instead
70
+ of letting a refusing seam take the boot down.
71
+
72
+ ### Changed
73
+
74
+ - Documentation: the README is split into an English `README.md` and a Chinese
75
+ `README.zh.md` with reciprocal links, the internal workspace path is gone from
76
+ the install steps, and the published `files` list now carries `README.zh.md`
77
+ and this changelog.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VviLliAm-qwq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # dsh-web-tavily
2
+
3
+ **English** · [中文](README.zh.md)
4
+
5
+ A host-plane Cordis plugin for dsh: it registers the **`tavily`** search provider
6
+ on dsh's web seam (`ctx.web`), so the `web_search` tool goes to the **Tavily
7
+ Search API** (`POST https://api.tavily.com/search`).
8
+
9
+ - It does **not** replace the `web_search` tool itself: the tool, its system
10
+ prompt and its result card are untouched — only the search backend changes.
11
+ - DeepSeek's native search (`web-search-deepseek`) stays available as a fallback
12
+ you can switch to; set `web.searchProvider` back to `deepseek-official` (or
13
+ delete the key) to do so.
14
+ - The key is always resolved as a **reference** (default `TAVILY_API_KEY`): the
15
+ credentials service (`refs:` in `~/.dsh/.credentials.yaml`) first, then the
16
+ process environment, and only then a literal `apiKey` in the configuration. The
17
+ provider never stores a key.
18
+
19
+ ## How it works
20
+
21
+ 1. The provider (id `tavily`) registers on `ctx.web`; `web.searchProvider: tavily`
22
+ makes it the default choice.
23
+ 2. When `web_search` runs, the seam hands `{ query, maxResults }` to this provider.
24
+ 3. The provider resolves the key (a snapshot per operation, so one search never
25
+ mixes two configuration versions) and calls `{baseURL}/search`.
26
+ 4. The response is mapped: `results[] → sources[]` (`title` / `url` /
27
+ `content→snippet`, plus `published_date → publishedAt` when present), and a
28
+ top-level `answer` (Tavily's AI summary) becomes the tool's optional `content`.
29
+ 5. Cancellation (signal) → `WEB_ABORTED`; an HTTP failure or an unparsable body →
30
+ `WEB_PROVIDER_ERROR`; a missing key → `WEB_PROVIDER_CREDENTIAL_MISSING` (the
31
+ message names the configuration step).
32
+
33
+ ## Defaults
34
+
35
+ | Field | Default | Meaning |
36
+ | --- | --- | --- |
37
+ | `apiKeyEnv` | `TAVILY_API_KEY` | Credential reference (environment-variable name). |
38
+ | `apiKey` | (none) | A literal key, only when you do not want a reference. |
39
+ | `baseURL` | `https://api.tavily.com` | Overridable with `TAVILY_BASE_URL`. |
40
+ | `searchDepth` | `basic` | `basic` / `advanced` (deeper, higher credit use). |
41
+ | `topic` | `general` | `general` / `news`. |
42
+ | `includeAnswer` | `true` | Ask for Tavily's AI summary (mapped to `content`). |
43
+ | `maxResults` | `10` | Fallback cap; `web_search` passes 8 per call, Tavily's ceiling is 20. |
44
+
45
+ ## Install
46
+
47
+ 1. Copy the package into
48
+ `~/.dsh/profiles/dsh-tui/node_modules/dsh-web-tavily/`.
49
+ 2. Append `"dsh-web-tavily"` to `dsh.profile.bundles` in
50
+ `~/.dsh/profiles/dsh-tui/package.json`.
51
+ 3. **Make tavily the default search backend.** This has to be written in the
52
+ profile's user patch layer (the `web:` section of `settings.yaml` does not
53
+ affect service configuration) — edit
54
+ `~/.dsh/profiles/dsh-tui/cordis.patch.yml` and add:
55
+
56
+ ```yaml
57
+ - id: web
58
+ name: '@deepseek-ai/dsh-web'
59
+ config:
60
+ searchProvider: tavily
61
+ fetchProvider: http-trusted
62
+ ```
63
+
64
+ The `web` row belongs to the `dsh-base` layer, so **do not add a new row with
65
+ `insert:`** — the duplicate id crashes the boot with
66
+ `duplicate loader entry id: web`. Override the existing row by id instead, and
67
+ note that an override **replaces the whole config**, which is why
68
+ `fetchProvider` is restated above.
69
+ 4. Restart dsh-tui (`/restart`).
70
+
71
+ ## Configuration
72
+
73
+ The plugin's own section lives in `~/.dsh/settings.yaml` (it also appears in the
74
+ settings screen under “Web search Tavily”):
75
+
76
+ ```yaml
77
+ dsh-web-tavily:
78
+ apiKeyEnv: TAVILY_API_KEY
79
+ searchDepth: basic
80
+ topic: general
81
+ includeAnswer: true
82
+ maxResults: 10
83
+ ```
84
+
85
+ `TAVILY_BASE_URL` overrides the API origin; the credential reference defaults to
86
+ `TAVILY_API_KEY`.
87
+
88
+ The settings screen renders this namespace as a bilingual card (**Web search
89
+ (Tavily)** / 联网搜索(Tavily)) with a description for every field, so the
90
+ reference is the one control you normally touch. The key itself never enters the
91
+ settings document.
92
+
93
+ ## Where the key lives (local only — never in a repository)
94
+
95
+ Add it under `refs:` in `~/.dsh/.credentials.yaml` (that file holds machine-local
96
+ credentials; do not commit or share it):
97
+
98
+ ```yaml
99
+ refs:
100
+ TAVILY_API_KEY: 'tvly-...'
101
+ ```
102
+
103
+ ## Switching back to DeepSeek's native search
104
+
105
+ Set `web.searchProvider` to `deepseek-official` (or delete the key and let the
106
+ seam use the only provider available). The seam does **not** fall back
107
+ automatically: a broken Tavily configuration fails loudly rather than quietly
108
+ using DeepSeek.
109
+
110
+ ## Proxy-compatible fetch (`http-trusted`)
111
+
112
+ The plugin also registers an `http-trusted` fetch provider, which works around
113
+ `web_fetch` being refused by the official `http` provider's safety preflight in
114
+ **fake-IP proxy environments** (Clash and friends resolving domains to
115
+ `198.18.0.0/15`): `WEB_BLOCKED_URL: resolves to a non-public IP address`.
116
+
117
+ - It **reuses the official `@deepseek-ai/dsh-web-fetch-http` transport** and
118
+ relaxes exactly one check — the “resolved address must be public” preflight
119
+ additionally allows `198.18.0.0/15`, the proxy fake-IP range. Everything else
120
+ is unchanged: private, loopback and reserved ranges are still refused
121
+ (`WEB_BLOCKED_URL`), IPv6 answers are still dropped, and address pinning,
122
+ same-origin redirects, the content-type allowlist, byte/character caps and the
123
+ no-credentials cookie rule all stay in place.
124
+ - ⚠️ **Security boundary:** SSRF protection moves from “enforced inside the host”
125
+ to “trust the local proxy's DNS decision”, so the last word on reachability
126
+ belongs to the proxy's routing rules. **Intended for a single-user machine with
127
+ a trusted self-hosted proxy**; for shared or public deployments go back to the
128
+ official `http` provider (below).
129
+ - Addresses that are special by construction (`http://192.168.x.x/` and friends)
130
+ are still refused.
131
+
132
+ ```yaml
133
+ # the web row in ~/.dsh/profiles/dsh-tui/cordis.patch.yml
134
+ config:
135
+ searchProvider: tavily
136
+ fetchProvider: http-trusted
137
+ ```
138
+
139
+ **Rolling back to the official `http`:** set `fetchProvider` back to `http`. The
140
+ cost is that `web_fetch` is refused again in a fake-IP proxy environment, in
141
+ exchange for the complete safety preflight.
142
+
143
+ ## Troubleshooting
144
+
145
+ - `duplicate loader entry id: web` on boot — the profile's `cordis.patch.yml`
146
+ used `insert:` for a row that already exists; rewrite it as the by-id override
147
+ shown above.
148
+ - `web_search` answers `WEB_PROVIDER_CONFIGURED_MISSING` — the plugin did not
149
+ load: check the bundles list and the patch above.
150
+ - `web_fetch` answers `WEB_BLOCKED_URL ... non-public IP` — the proxy fake-IP
151
+ preflight refused it: switch to `http-trusted`, or turn the proxy's fake-IP
152
+ mode off.
153
+
154
+ ## Limitations
155
+
156
+ - A missing or invalid Tavily key makes `web_search` fail
157
+ (`WEB_PROVIDER_CREDENTIAL_MISSING` / `WEB_PROVIDER_ERROR`); the message names
158
+ what to configure.
159
+ - `max_results` is clamped to Tavily's ceiling of 20.
160
+ - An invalid `searchDepth` / `topic` is an error, never a silent fallback to the
161
+ default.
162
+
163
+ ## Publishing
164
+
165
+ - **Repository**: <https://github.com/VviLliAm-qwq/dsh-web-tavily> (public)
166
+
167
+ ## Usage
168
+
169
+ Once installed and configured, let the model call `web_search` as usual: the
170
+ `sources` list it returns (and the optional summary) now comes from Tavily.
171
+
172
+ ## License
173
+
174
+ MIT — see [LICENSE](LICENSE).
package/README.zh.md ADDED
@@ -0,0 +1,121 @@
1
+ # dsh-web-tavily
2
+
3
+ **中文** · [English](README.md)
4
+
5
+ dsh host 平面 cordis 插件:为 dsh 的 web 能力 seam(`ctx.web`)注册搜索提供商 **`tavily`**,让 `web_search` 工具默认走 **Tavily Search API**(`POST https://api.tavily.com/search`)。
6
+
7
+ - 不替换 `web_search` 工具本身:工具、系统提示、展示卡片均保持原样,只有搜索后端换成 Tavily。
8
+ - 保留 DeepSeek 原生搜索(`web-search-deepseek`)为可切换备选;想切回只需把 `web.searchProvider` 改为 `deepseek-official` 或删掉该键。
9
+ - key 一律按**引用**解析(默认 `TAVILY_API_KEY`):优先凭证服务(`~/.dsh/.credentials.yaml` 的 `refs:`),其次启动环境变量,最后才是配置里的字面 `apiKey`。provider 不保留 key。
10
+
11
+ ## 工作原理
12
+
13
+ 1. 注册 provider(id = `tavily`)到 `ctx.web`;`web.searchProvider: tavily` 使其成为默认选择。
14
+ 2. `web_search` 被调用时,seam 把 `{ query, maxResults }` 交给本 provider。
15
+ 3. provider 解析 key(每次操作时快照,一次搜索不会混用两个配置版本)→ 请求 `{baseURL}/search`。
16
+ 4. 响应映射:`results[] → sources[]`(`title` / `url` / `content→snippet`,若有 `published_date → publishedAt`),顶层 `answer`(Tavily 的 AI 总结)→ 工具的可选 `content` 摘要。
17
+ 5. 取消(信号)→ `WEB_ABORTED`;HTTP 失败 / 不可解析 → `WEB_PROVIDER_ERROR`;缺 key → `WEB_PROVIDER_CREDENTIAL_MISSING`(错误信息带配置指引)。
18
+
19
+ ## 默认配置
20
+
21
+ | 字段 | 默认值 | 说明 |
22
+ | --- | --- | --- |
23
+ | `apiKeyEnv` | `TAVILY_API_KEY` | 凭证引用(环境变量名) |
24
+ | `apiKey` | (无) | 字面 key,仅当不想走引用时使用 |
25
+ | `baseURL` | `https://api.tavily.com` | 可用环境变量 `TAVILY_BASE_URL` 覆盖 |
26
+ | `searchDepth` | `basic` | `basic` / `advanced`(advanced 更深度、额度消耗更高) |
27
+ | `topic` | `general` | `general` / `news` |
28
+ | `includeAnswer` | `true` | 是否请求 Tavily 的 AI 总结(映射为 `content`) |
29
+ | `maxResults` | `10` | 兜底结果数上限(`web_search` 每次实际传 8,Tavily 上限 20) |
30
+
31
+ ## 安装
32
+
33
+ 1. 把整个包复制到
34
+ `~/.dsh/profiles/dsh-tui/node_modules/dsh-web-tavily/`。
35
+ 2. 在 `~/.dsh/profiles/dsh-tui/package.json` 的 `dsh.profile.bundles` 追加 `"dsh-web-tavily"`。
36
+ 3. **把 web 服务默认搜索改为 tavily**(必须写在 profile 用户补丁层,`settings.yaml` 的 `web:` 段对服务配置无效)——
37
+ 编辑 `~/.dsh/profiles/dsh-tui/cordis.patch.yml` 追加:
38
+
39
+ ```yaml
40
+ - id: web
41
+ name: '@deepseek-ai/dsh-web'
42
+ config:
43
+ searchProvider: tavily
44
+ fetchProvider: http-trusted
45
+ ```
46
+
47
+ `web` 行由基座 dsh-base 提供,**不要用 `insert:` 新增同 id 行**——那会撞 id 直接崩
48
+ `duplicate loader entry id: web`;这里要按 id 覆盖既有行,且覆盖是**整行重写,需写全
49
+ `fetchProvider`**。
50
+ 4. 重启 dsh-tui(`/restart`)生效。
51
+
52
+ ## 配置
53
+
54
+ 插件自身的设置段放 `~/.dsh/settings.yaml`(设置页「Plugin configuration」里也会出现「Web search Tavily」类):
55
+
56
+ ```yaml
57
+ dsh-web-tavily:
58
+ apiKeyEnv: TAVILY_API_KEY
59
+ searchDepth: basic
60
+ topic: general
61
+ includeAnswer: true
62
+ maxResults: 10
63
+ ```
64
+
65
+ 可用环境变量覆盖:`TAVILY_BASE_URL`(API 源),凭证引用键默认 `TAVILY_API_KEY`。
66
+
67
+ 设置页把这个命名空间渲染成一张**中英双语卡片**(「联网搜索(Tavily)」/ Web search (Tavily)),每个字段都有说明;日常只需要改「凭证引用」那一项。密钥本身永远不进设置文档。
68
+
69
+ ## key 存放(本机,不进代码库)
70
+
71
+ 在 `~/.dsh/.credentials.yaml` 的 `refs:` 下添加(该文件属于本机凭证,不要提交/外发):
72
+
73
+ ```yaml
74
+ refs:
75
+ TAVILY_API_KEY: 'tvly-...'
76
+ ```
77
+
78
+ ## 切回 DeepSeek 原生搜索
79
+
80
+ 把 `web.searchProvider` 改为 `deepseek-official`(或删除该键、让 seam 用唯一可用 provider)。注意 seam 不支持自动回退:Tavily 配置失效时搜索会明确报错,而**不会**悄悄走 DeepSeek。
81
+
82
+ ## 代理兼容 fetch(`http-trusted`)
83
+
84
+ 本插件同时注册 `http-trusted` fetch provider,解决 **fake-IP 代理环境**(Clash 等把域名解析为 `198.18.0.0/15` 假地址)下 `web_fetch` 被官方 `http` provider 的安全预检拒绝的问题(`WEB_BLOCKED_URL: resolves to a non-public IP address`)。
85
+
86
+ - **复用官方 `@deepseek-ai/dsh-web-fetch-http` 传输层**:仅放宽「解析结果必须是公网 IP」这一预检——额外放行 `198.18.0.0/15`(代理 fake-IP 段),**其余全部照旧**:内网/环回/保留段照常拒绝(`WEB_BLOCKED_URL`)、IPv6 答案一律丢弃、地址 pinning、同源重定向、内容类型白名单、字节/字符上限、无凭证 cookie 等防护原封未动。
87
+ - ⚠️ **安全边界**:SSRF 防护从「宿主内强校验」退化为「信任本机代理的 DNS 决策」,网络的最终裁决权移交代理分流规则。**仅适用于单用户本机 + 自制可信代理**;多人共用/公网服务请回退官方 `http`(见下)。
88
+ - IP 来源特殊(`http://192.168.x.x/` 之类)仍会被拒绝。
89
+
90
+ ```yaml
91
+ # ~/.dsh/profiles/dsh-tui/cordis.patch.yml 的 web 行
92
+ config:
93
+ searchProvider: tavily
94
+ fetchProvider: http-trusted
95
+ ```
96
+
97
+ **回退官方 http**:把 `fetchProvider` 改回 `http`(代价:fake-IP 代理环境下 `web_fetch` 将再次被拒,但安全预检完整)。
98
+
99
+ ## 加载失败排查
100
+
101
+ - 插件启动报 `duplicate loader entry id: web`:profile `cordis.patch.yml` 误用 `insert:` 新增同 id 行,按上文改写为直接行。
102
+ - `web_search` 报 `WEB_PROVIDER_CONFIGURED_MISSING`:插件未加载(检查 bundles 列表与上述补丁)。
103
+ - `web_fetch` 报 `WEB_BLOCKED_URL ... non-public IP`:代理假 IP 预检拒绝,改用 `http-trusted` 或关闭代理 fake-IP 模式。
104
+
105
+ ## 限制说明
106
+
107
+ - Tavily key 缺失或失效 → `web_search` 报错(`WEB_PROVIDER_CREDENTIAL_MISSING` / `WEB_PROVIDER_ERROR`),按错误信息指引配置即可。
108
+ - `max_results` 会被钳制到 Tavily 上限 20。
109
+ - `searchDepth` / `topic` 非法值会直接报错,不会悄悄回退默认。
110
+
111
+ ## 发布
112
+
113
+ - **仓库**:<https://github.com/VviLliAm-qwq/dsh-web-tavily>(公开)
114
+
115
+ ## 使用
116
+
117
+ 装好并配置 key 后,照常让模型调用 `web_search` 即可;工具返回的 `Sources:` 列表与可选总结即来自 Tavily。
118
+
119
+ ## 许可
120
+
121
+ MIT — 见 [LICENSE](LICENSE)。
@@ -0,0 +1,4 @@
1
+ # dsh-web-tavily bundle patch: mounts the Tavily search provider plugin.
2
+ - insert:
3
+ - id: dsh-web-tavily
4
+ name: 'dsh-web-tavily'
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://dsh.community/schemas/dsh-plugin-0.15.json",
3
+ "manifestVersion": "0.15",
4
+ "id": "com.dsh-tui.dsh-web-tavily",
5
+ "name": "dsh-web-tavily",
6
+ "version": "0.4.0",
7
+ "facets": {
8
+ "host": {
9
+ "entry": "lib/index.js",
10
+ "apiVersion": "v1alpha1"
11
+ }
12
+ },
13
+ "requires": {
14
+ "contracts": [],
15
+ "services": []
16
+ },
17
+ "permissions": [],
18
+ "contributes": {
19
+ "commands": []
20
+ },
21
+ "subscriptions": [],
22
+ "license": "MIT",
23
+ "source": {
24
+ "repository": "https://github.com/VviLliAm-qwq/dsh-web-tavily"
25
+ }
26
+ }
package/lib/diag.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Bounded lifecycle log for dsh-web-tavily.
3
+ *
4
+ * The plugin declares `inject = ["web"]`, so on a host that never mounts the web
5
+ * seam `apply()` stays parked — and a parked plugin is indistinguishable from
6
+ * one that was never loaded unless the module says something at import time.
7
+ * That is the whole reason this file exists.
8
+ *
9
+ * Same conventions as the other plugins in this ecosystem: one line per
10
+ * lifecycle event, the host logger when there is one, a plugin-owned file under
11
+ * `~/.dsh-tui/` capped at 128 KiB, and no file writes under `node --test`.
12
+ */
13
+
14
+ import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ /** Plugin-owned log path. */
19
+ export const DIAG_LOG = join(homedir(), '.dsh-tui', 'dsh-web-tavily.log');
20
+
21
+ /** Above this size the file is trimmed to its newest half. */
22
+ export const MAX_LOG_BYTES = 128 * 1024;
23
+
24
+ /** Append one line, trimming the file when it has grown past the cap. */
25
+ function appendLogLine(path, line) {
26
+ try {
27
+ if (statSync(path).size > MAX_LOG_BYTES) {
28
+ writeFileSync(path, readFileSync(path, 'utf8').slice(-Math.floor(MAX_LOG_BYTES / 2)));
29
+ }
30
+ }
31
+ catch {
32
+ // Missing or unreadable: the append below recreates it.
33
+ }
34
+ appendFileSync(path, line);
35
+ }
36
+
37
+ /**
38
+ * Write one lifecycle line.
39
+ *
40
+ * @param ctx - Cordis context when there is one (`ctx.logger` is used if the
41
+ * host offers it); `undefined` at import time.
42
+ * @param message - the line, without timestamp or level.
43
+ * @param options.env - environment override (tests).
44
+ */
45
+ export function diag(ctx, message, options = {}) {
46
+ try {
47
+ ctx?.logger?.info?.(`dsh-web-tavily: ${message}`);
48
+ }
49
+ catch {
50
+ // Observability only; never let logging break the plugin.
51
+ }
52
+ const env = options.env ?? process.env;
53
+ if (typeof env?.NODE_TEST_CONTEXT === 'string') return;
54
+ try {
55
+ appendLogLine(DIAG_LOG, `${new Date().toISOString()} info ${message}\n`);
56
+ }
57
+ catch {
58
+ // An unwritable log path is not worth surfacing.
59
+ }
60
+ }
package/lib/index.js ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * dsh-web-tavily: a Tavily-backed search provider for the dsh web
3
+ * capability seam (ctx.web). Registers the `tavily` provider so the
4
+ * model-facing web_search tool runs against the Tavily Search API
5
+ * (POST https://api.tavily.com/search). The provider holds no credential
6
+ * value: the key is resolved per operation through the credentials service
7
+ * (ref, default TAVILY_API_KEY), the launch environment, or a literal
8
+ * `apiKey` config, in that order.
9
+ *
10
+ * The bundle additionally registers `http-trusted`, a proxy-friendly HTTP(S)
11
+ * fetch provider: it reuses the official @deepseek-ai/dsh-web-fetch-http
12
+ * transport (same-origin redirects, content-type allowlist, byte caps,
13
+ * timeout, no credentials, address pinning) but relaxes the public-IP precheck
14
+ * to also admit the proxy fake-IP range 198.18.0.0/15 (Clash-style DNS), which
15
+ * the stock provider rejects as non-public and which breaks web_fetch under a
16
+ * fake-IP DNS proxy. IPv6 answers are dropped entirely (never trust a
17
+ * link-local/ULA answer for routing), and every other non-public IPv4
18
+ * destination — loopback, RFC1918, link-local, CGNAT, multicast, reserved —
19
+ * is still rejected with WEB_BLOCKED_URL.
20
+ *
21
+ * @module dsh-web-tavily
22
+ */
23
+ import z from "@deepseek-ai/schemastery";
24
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
25
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
26
+ import { WebError } from "@deepseek-ai/dsh-web";
27
+ import { HttpFetchProvider } from "@deepseek-ai/dsh-web-fetch-http";
28
+ import { lookup } from "node:dns/promises";
29
+ import { isIP } from "node:net";
30
+ import { diag } from "./diag.js";
31
+ import { applySettingsCard } from "./settings-card.js";
32
+ import ipaddr from "ipaddr.js";
33
+
34
+ // Import-time breadcrumb: the only evidence a host that never mounts `ctx.web`
35
+ // can give that this file was read at all (see ./diag.js).
36
+ try {
37
+ diag(undefined, `module imported pid=${process.pid} node=${process.version}`);
38
+ }
39
+ catch {
40
+ // Diagnostics must never be the reason a module fails to load.
41
+ }
42
+
43
+ //#region identity & defaults
44
+ /** Cordis plugin name used by loader diagnostics. */
45
+ const name = "dsh-web-tavily";
46
+ /** The web seam this provider registers into. */
47
+ const inject = ["web"];
48
+ /** Stable id this provider registers under. */
49
+ const TAVILY_PROVIDER_ID = "tavily";
50
+ /** Default Tavily API origin; the `/search` path is appended per call. */
51
+ const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
52
+ /** Default environment variable naming the credential reference. */
53
+ const DEFAULT_API_KEY_ENV = "TAVILY_API_KEY";
54
+ /** Environment variable that overrides the API origin. */
55
+ const BASE_URL_ENV = "TAVILY_BASE_URL";
56
+ /** Settings namespace carrying this provider's endpoint and behavior options. */
57
+ const SETTINGS_NAMESPACE = "dsh-web-tavily";
58
+ /** Attribution header sent on every request. Bump with the package version. */
59
+ const USER_AGENT = "dsh-web-tavily/0.4.0";
60
+ /** Fallback result cap when the caller sends no maxResults. */
61
+ const DEFAULT_MAX_RESULTS = 10;
62
+ /** Tavily's documented per-request result cap. */
63
+ const TAVILY_MAX_RESULTS_CAP = 20;
64
+ /** Accepted search_depth values. */
65
+ const SEARCH_DEPTHS = ["basic", "advanced"];
66
+ /** Accepted topic values. */
67
+ const TOPICS = ["general", "news"];
68
+ /** Stable id this bundle's proxy-friendly fetch provider registers under. */
69
+ const TRUSTED_FETCH_PROVIDER_ID = "http-trusted";
70
+ /** Resource limits for the trusted fetch provider (mirror dsh-web-fetch-http defaults). */
71
+ const TRUSTED_FETCH_LIMITS = {
72
+ maxResponseBytes: 5e6,
73
+ maxBodyChars: 1e5,
74
+ timeoutMs: 3e4,
75
+ maxRedirects: 5,
76
+ userAgent: USER_AGENT
77
+ };
78
+ //#endregion
79
+
80
+ //#region config
81
+ const Config = z.object({
82
+ apiKey: z.string().role("secret"),
83
+ apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
84
+ baseURL: z.string(),
85
+ searchDepth: z.string().default("basic"),
86
+ topic: z.string().default("general"),
87
+ includeAnswer: z.boolean().default(true),
88
+ maxResults: z.number().step(1).min(1).default(DEFAULT_MAX_RESULTS)
89
+ });
90
+ //#endregion
91
+
92
+ //#region abort & error helpers
93
+ /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
94
+ function isAbortError(error) {
95
+ return error instanceof DOMException && error.name === "AbortError";
96
+ }
97
+ /** Build the provider's stable cancellation error while retaining the caller's reason. */
98
+ function searchAborted(signal, fallback) {
99
+ return new WebError("Tavily search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
100
+ }
101
+ /** Throw the provider's stable cancellation error when the caller already aborted. */
102
+ function throwIfAborted(signal) {
103
+ if (signal?.aborted === true) throw searchAborted(signal);
104
+ }
105
+ /**
106
+ * Race a same-process asynchronous preflight against caller cancellation. The
107
+ * attached settlement handlers keep observing an uncooperative operation after
108
+ * abort so a later rejection cannot become unhandled.
109
+ */
110
+ function abortable(operation, signal) {
111
+ if (signal === undefined) return operation;
112
+ if (signal.aborted) return Promise.reject(searchAborted(signal));
113
+ return new Promise((resolve, reject) => {
114
+ const onAbort = () => {
115
+ reject(searchAborted(signal));
116
+ };
117
+ signal.addEventListener("abort", onAbort, { once: true });
118
+ operation.then((value) => {
119
+ signal.removeEventListener("abort", onAbort);
120
+ resolve(value);
121
+ }, (error) => {
122
+ signal.removeEventListener("abort", onAbort);
123
+ reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
124
+ });
125
+ });
126
+ }
127
+ /** Race a non-cancellable OS lookup without letting it delay tool cancellation. */
128
+ function raceWithSignal(promise, signal) {
129
+ const abortError = () => new Error("web fetch aborted during hostname resolution", { cause: signal.reason });
130
+ if (signal.aborted) return Promise.reject(abortError());
131
+ return new Promise((resolve, reject) => {
132
+ const abort = () => {
133
+ reject(abortError());
134
+ };
135
+ signal.addEventListener("abort", abort, { once: true });
136
+ promise.then(resolve, reject).finally(() => {
137
+ signal.removeEventListener("abort", abort);
138
+ });
139
+ });
140
+ }
141
+ /** Collapse newlines in a text field so a snippet never breaks the markdown list. */
142
+ function flattenInline(text) {
143
+ return text.replace(/\s*\n+\s*/g, " ");
144
+ }
145
+ /** Extract a human message from a Tavily error body (several documented shapes). */
146
+ function parseErrorDetail(parsed) {
147
+ if (typeof parsed === "string" && parsed.length > 0) return parsed;
148
+ if (parsed == null || typeof parsed !== "object") return undefined;
149
+ if (typeof parsed.error === "string" && parsed.error.length > 0) return parsed.error;
150
+ if (typeof parsed.error?.message === "string" && parsed.error.message.length > 0) return parsed.error.message;
151
+ if (typeof parsed.detail === "string" && parsed.detail.length > 0) return parsed.detail;
152
+ if (parsed.detail != null && typeof parsed.detail === "object" && typeof parsed.detail.error === "string" && parsed.detail.error.length > 0) return parsed.detail.error;
153
+ if (typeof parsed.message === "string" && parsed.message.length > 0) return parsed.message;
154
+ return undefined;
155
+ }
156
+ //#endregion
157
+
158
+ //#region trusted fetch resolver
159
+ /**
160
+ * Whether an IPv4 address is inside the proxy fake-IP range 198.18.0.0/15
161
+ * (RFC 2544 benchmarking block commonly used by Clash-style DNS proxies as
162
+ * fake answers). This is the ONLY non-public range the trusted fetcher admits.
163
+ */
164
+ function isFakeIpRange(address) {
165
+ try {
166
+ const parsed = ipaddr.parse(address);
167
+ if (!(parsed instanceof ipaddr.IPv4)) return false;
168
+ const [a, b] = parsed.toByteArray();
169
+ return a === 198 && (b === 18 || b === 19);
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+ /** Whether an address is a globally reachable public unicast (mirrors dsh-web-fetch-http). */
175
+ function isPublicUnicast(address) {
176
+ try {
177
+ const parsed = ipaddr.parse(address);
178
+ if (parsed instanceof ipaddr.IPv4) return parsed.range() === "unicast";
179
+ if (parsed.isIPv4MappedAddress()) return parsed.toIPv4Address().range() === "unicast";
180
+ return parsed.range() === "unicast";
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+ /** WHATWG URL retains brackets around IPv6 hostnames; IP parsers do not. */
186
+ function stripIpv6Brackets(hostname) {
187
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
188
+ }
189
+ /**
190
+ * Proxy-friendly destination resolution: accepts public IPv4 unicasts plus the
191
+ * fake-IP range 198.18.0.0/15, drops every IPv6 answer (a ULA/link-local answer
192
+ * is never trusted for routing), and rejects any other IPv4 answer set with
193
+ * `WEB_BLOCKED_URL`. The returned addresses are the only ones the transport may
194
+ * use (pinned connection, same as the official provider).
195
+ *
196
+ * @param hostname - URL hostname, including brackets when it is an IPv6 literal.
197
+ * @param signal - aborts the wait for system resolution.
198
+ * @returns the validated, non-empty address set.
199
+ */
200
+ async function resolveProxyFriendly(hostname, signal) {
201
+ const unbracketed = stripIpv6Brackets(hostname);
202
+ const literalFamily = isIP(unbracketed);
203
+ const resolved = literalFamily === 0
204
+ ? await raceWithSignal(lookup(unbracketed, { all: true, order: "verbatim" }), signal)
205
+ : [{ address: unbracketed, family: literalFamily }];
206
+ if (resolved.length === 0) throw new WebError(`hostname "${hostname}" resolved to no addresses`, "WEB_PROVIDER_ERROR");
207
+ const addresses = [];
208
+ for (const entry of resolved) {
209
+ if (entry.family !== 4 && entry.family !== 6 || isIP(entry.address) !== entry.family) continue;
210
+ if (entry.family === 6) continue;
211
+ if (!isPublicUnicast(entry.address) && !isFakeIpRange(entry.address)) {
212
+ throw new WebError(`URL hostname "${hostname}" resolves to a non-public IP address`, "WEB_BLOCKED_URL");
213
+ }
214
+ addresses.push({ address: entry.address, family: 4 });
215
+ }
216
+ if (addresses.length === 0) throw new WebError(`hostname "${hostname}" resolved to no usable addresses`, "WEB_PROVIDER_ERROR");
217
+ return addresses;
218
+ }
219
+ /**
220
+ * The proxy-friendly fetch provider: identical transport to
221
+ * @deepseek-ai/dsh-web-fetch-http with permissive destination resolution.
222
+ */
223
+ class TrustedHttpFetchProvider extends HttpFetchProvider {
224
+ id = TRUSTED_FETCH_PROVIDER_ID;
225
+ }
226
+ //#endregion
227
+
228
+ //#region provider
229
+ /**
230
+ * The Tavily-backed search provider. HTTP redirects fail as `WEB_PROVIDER_ERROR`;
231
+ * failures after dispatch name the endpoint so the user can fix configuration.
232
+ */
233
+ class TavilySearchProvider {
234
+ constructor(resolveOptions) {
235
+ this.resolveOptions = resolveOptions;
236
+ }
237
+ id = TAVILY_PROVIDER_ID;
238
+ /** True when a key can be resolved and the endpoint parses. */
239
+ available() {
240
+ const options = this.resolveOptions();
241
+ return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined) && URL.canParse(options.baseURL);
242
+ }
243
+ async search(request, signal) {
244
+ const options = this.resolveOptions();
245
+ const apiKey = await this.apiKey(options, signal);
246
+ throwIfAborted(signal);
247
+ assertEnum("searchDepth", options.searchDepth, SEARCH_DEPTHS);
248
+ assertEnum("topic", options.topic, TOPICS);
249
+ const endpoint = `${options.baseURL}/search`;
250
+ const requested = request.maxResults ?? options.maxResults;
251
+ const maxResults = Math.min(Number.isInteger(requested) && requested > 0 ? requested : options.maxResults, TAVILY_MAX_RESULTS_CAP);
252
+ const body = {
253
+ query: request.query,
254
+ max_results: maxResults,
255
+ search_depth: options.searchDepth,
256
+ topic: options.topic,
257
+ include_answer: options.includeAnswer
258
+ };
259
+ let response;
260
+ try {
261
+ response = await fetch(endpoint, {
262
+ method: "POST",
263
+ redirect: "error",
264
+ headers: {
265
+ authorization: `Bearer ${apiKey}`,
266
+ "content-type": "application/json",
267
+ accept: "application/json",
268
+ "user-agent": USER_AGENT
269
+ },
270
+ body: JSON.stringify(body),
271
+ ...(signal !== undefined ? { signal } : {})
272
+ });
273
+ } catch (error) {
274
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
275
+ throw new WebError(`Tavily search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
276
+ }
277
+ if (!response.ok) {
278
+ let message = `Tavily API error (HTTP ${response.status})`;
279
+ try {
280
+ const detail = parseErrorDetail(await response.json());
281
+ if (detail !== undefined && detail.length > 0) message += `: ${detail}`;
282
+ } catch (error) {
283
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
284
+ }
285
+ throw new WebError(`${message}\n\nThe web search request used endpoint ${JSON.stringify(endpoint)}. Only the user should choose or change the endpoint (Settings > Plugins > Plugin configuration > Web search Tavily, or the ${BASE_URL_ENV} environment variable).`, "WEB_PROVIDER_ERROR");
286
+ }
287
+ try {
288
+ const data = await response.json();
289
+ const sources = (Array.isArray(data.results) ? data.results : [])
290
+ .filter((item) => item?.url != null && item.url.length > 0)
291
+ .map((item) => ({
292
+ url: item.url,
293
+ ...(item.title != null && item.title.length > 0 ? { title: flattenInline(item.title) } : {}),
294
+ ...(item.content != null && item.content.length > 0 ? { snippet: flattenInline(item.content) } : {}),
295
+ ...(item.published_date != null && item.published_date.length > 0 ? { publishedAt: item.published_date } : {})
296
+ }));
297
+ return {
298
+ ...(typeof data.answer === "string" && data.answer.length > 0 ? { content: data.answer } : {}),
299
+ sources,
300
+ truncated: false
301
+ };
302
+ } catch (error) {
303
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
304
+ throw new WebError(error instanceof WebError ? error.message : `Tavily returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
305
+ }
306
+ }
307
+ /**
308
+ * Resolve one operation's credential without retaining it on the provider.
309
+ * @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section.
310
+ * @param signal - abort signal for the surrounding search.
311
+ * @returns the resolved key.
312
+ */
313
+ async apiKey(options, signal) {
314
+ throwIfAborted(signal);
315
+ if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey;
316
+ let resolved;
317
+ try {
318
+ resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(undefined), signal);
319
+ } catch (error) {
320
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
321
+ throw new WebError(`Tavily search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
322
+ }
323
+ if (resolved !== undefined && resolved.length > 0) return resolved;
324
+ throw new WebError(`Tavily search has no API key for "${options.apiKeyEnv ?? DEFAULT_API_KEY_ENV}"; store it through the credentials service (refs: TAVILY_API_KEY in ~/.dsh/.credentials.yaml), export it in the launching environment, or set a literal "apiKey" in the dsh-web-tavily config`, "WEB_PROVIDER_CREDENTIAL_MISSING");
325
+ }
326
+ }
327
+ /** Reject a config value outside the provider's accepted set with a friendly message. */
328
+ function assertEnum(field, value, accepted) {
329
+ if (!accepted.includes(value)) throw new WebError(`Tavily search config "${field}" must be one of ${accepted.map((item) => JSON.stringify(item)).join(", ")} (got ${JSON.stringify(value)})`, "WEB_PROVIDER_ERROR");
330
+ }
331
+ //#endregion
332
+
333
+ //#region registration
334
+ /**
335
+ * Project one resolved section into the options the provider serves its next
336
+ * search with. Environment fallbacks stay here rather than in the provider:
337
+ * every value it reads is already fully defaulted.
338
+ */
339
+ function resolveOptions(ctx, config) {
340
+ const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
341
+ const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 ? config.apiKey : undefined;
342
+ return {
343
+ ...(literalApiKey === undefined ? {} : { apiKey: literalApiKey }),
344
+ resolveApiKey: async () => {
345
+ const credentials = ctx.get("credentials");
346
+ if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value;
347
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
348
+ return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined;
349
+ },
350
+ apiKeyEnv,
351
+ baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
352
+ searchDepth: config.searchDepth ?? "basic",
353
+ topic: config.topic ?? "general",
354
+ includeAnswer: config.includeAnswer ?? true,
355
+ maxResults: config.maxResults ?? DEFAULT_MAX_RESULTS
356
+ };
357
+ }
358
+ /** Register the Tavily search provider and the proxy-friendly fetch provider. */
359
+ function apply(ctx, config) {
360
+ diag(ctx, `apply started pid=${process.pid} file=${import.meta.url}`);
361
+ // The localized card lives on the optional TUI seam (see ./settings-card.js);
362
+ // the namespace/schema registration above stays authoritative for storage.
363
+ applySettingsCard(ctx, diag);
364
+ let current = () => config;
365
+ ctx.inject(["settings"], (settingsCtx) => {
366
+ settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
367
+ setSource: (source) => {
368
+ current = source;
369
+ },
370
+ onChange: () => {}
371
+ });
372
+ diag(ctx, "settings namespace registered");
373
+ });
374
+ // The web seam is the one thing this plugin cannot work without. It is
375
+ // injected (see `inject`), but a host that answers with a shadow instance or
376
+ // refuses the registration must degrade to a logged warning instead of taking
377
+ // the boot down.
378
+ try {
379
+ ctx.web.registerSearchProvider(new TavilySearchProvider(() => resolveOptions(ctx, current())));
380
+ ctx.web.registerFetchProvider(new TrustedHttpFetchProvider(TRUSTED_FETCH_LIMITS, resolveProxyFriendly));
381
+ diag(ctx, `providers registered search=${TAVILY_PROVIDER_ID} fetch=${TRUSTED_FETCH_PROVIDER_ID}`);
382
+ }
383
+ catch (error) {
384
+ diag(ctx, `provider registration failed: ${error instanceof Error ? error.message : String(error)}`);
385
+ }
386
+ ctx.effect(() => () => {
387
+ diag(ctx, "unloaded");
388
+ });
389
+ }
390
+ //#endregion
391
+
392
+ export { Config, DEFAULT_API_KEY_ENV, SETTINGS_NAMESPACE, TAVILY_DEFAULT_BASE_URL, TAVILY_PROVIDER_ID, TRUSTED_FETCH_LIMITS, TRUSTED_FETCH_PROVIDER_ID, TavilySearchProvider, TrustedHttpFetchProvider, apply, inject, isFakeIpRange, name, resolveProxyFriendly };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The localized settings card for dsh-web-tavily.
3
+ *
4
+ * The harness-side `settings.installSection` registers the namespace and its
5
+ * schema, and the host can always render a generic card from that schema — but a
6
+ * generic card shows raw field keys (`apiKeyEnv`, `searchDepth`, …) with no
7
+ * translation. A localized card is a `tuiSettingsSections` registration, the
8
+ * same shape every other plugin in this ecosystem ships, and a plugin may
9
+ * (and normally does) register both.
10
+ *
11
+ * The seam is optional and may still be activating on the first tick, so the
12
+ * registration is probed and retried instead of being attempted once.
13
+ */
14
+
15
+ /** Section namespace; the same one `installSection` registers. */
16
+ const NS = 'dsh-web-tavily';
17
+
18
+ /** How often the optional seam is re-probed while it has not appeared yet. */
19
+ const RETRY_MS = 400;
20
+
21
+ /** Give up after this many attempts (~12 s) and stay silent. */
22
+ const RETRY_LIMIT = 30;
23
+
24
+ /**
25
+ * The card.
26
+ *
27
+ * `apiKey` is deliberately absent: a literal secret in a settings document is
28
+ * the one thing this plugin tells users not to do — the key belongs in the
29
+ * credentials store under the reference named below.
30
+ */
31
+ export const SETTINGS_SECTION = Object.freeze({
32
+ ns: NS,
33
+ title: 'Web search (Tavily)',
34
+ descriptions: { zh: '联网搜索(Tavily)', en: 'Web search (Tavily)' },
35
+ fields: [
36
+ {
37
+ path: ['apiKeyEnv'],
38
+ label: 'Credential reference',
39
+ descriptions: { zh: '凭证引用', en: 'Credential reference' },
40
+ hint: 'Environment-variable-style reference for the Tavily key: the credentials service first, then the launch environment.',
41
+ hintDescriptions: {
42
+ zh: 'Tavily 密钥的引用名(环境变量形式):先查凭证服务,再查启动环境。',
43
+ en: 'Environment-variable-style reference for the Tavily key: the credentials service first, then the launch environment.',
44
+ },
45
+ kind: 'text',
46
+ },
47
+ {
48
+ path: ['baseURL'],
49
+ label: 'API base URL',
50
+ descriptions: { zh: 'API 地址', en: 'API base URL' },
51
+ hint: 'Tavily API origin; the TAVILY_BASE_URL environment variable overrides it.',
52
+ hintDescriptions: {
53
+ zh: 'Tavily API 源地址;环境变量 TAVILY_BASE_URL 可覆盖。',
54
+ en: 'Tavily API origin; the TAVILY_BASE_URL environment variable overrides it.',
55
+ },
56
+ kind: 'text',
57
+ },
58
+ {
59
+ path: ['searchDepth'],
60
+ label: 'Search depth',
61
+ descriptions: { zh: '检索深度', en: 'Search depth' },
62
+ hint: '“advanced” searches further and consumes more credits.',
63
+ hintDescriptions: {
64
+ zh: '「advanced」检索更深入,也消耗更多额度。',
65
+ en: '“advanced” searches further and consumes more credits.',
66
+ },
67
+ kind: 'select',
68
+ options: [
69
+ { value: 'basic', label: 'basic', descriptions: { zh: '基础', en: 'basic' } },
70
+ { value: 'advanced', label: 'advanced', descriptions: { zh: '深入', en: 'advanced' } },
71
+ ],
72
+ },
73
+ {
74
+ path: ['topic'],
75
+ label: 'Topic',
76
+ descriptions: { zh: '检索主题', en: 'Topic' },
77
+ hint: '“news” biases the index towards recent coverage.',
78
+ hintDescriptions: {
79
+ zh: '「news」偏向最近的新闻报道。',
80
+ en: '“news” biases the index towards recent coverage.',
81
+ },
82
+ kind: 'select',
83
+ options: [
84
+ { value: 'general', label: 'general', descriptions: { zh: '通用', en: 'general' } },
85
+ { value: 'news', label: 'news', descriptions: { zh: '新闻', en: 'news' } },
86
+ ],
87
+ },
88
+ {
89
+ path: ['includeAnswer'],
90
+ label: 'Include AI answer',
91
+ descriptions: { zh: '附带 AI 总结', en: 'Include AI answer' },
92
+ hint: 'Ask Tavily for its short AI summary and pass it to the tool as optional content.',
93
+ hintDescriptions: {
94
+ zh: '请求 Tavily 的简短 AI 总结,作为工具的可选内容返回。',
95
+ en: 'Ask Tavily for its short AI summary and pass it to the tool as optional content.',
96
+ },
97
+ kind: 'boolean',
98
+ },
99
+ {
100
+ path: ['maxResults'],
101
+ label: 'Fallback result cap',
102
+ descriptions: { zh: '兜底结果上限', en: 'Fallback result cap' },
103
+ hint: 'Used when a call passes no limit; Tavily itself caps at 20.',
104
+ hintDescriptions: {
105
+ zh: '调用未指定数量时使用;Tavily 自身上限为 20。',
106
+ en: 'Used when a call passes no limit; Tavily itself caps at 20.',
107
+ },
108
+ kind: 'number',
109
+ },
110
+ ],
111
+ });
112
+
113
+ /**
114
+ * Register the card through the optional TUI seam.
115
+ *
116
+ * @param ctx - Cordis context.
117
+ * @param diag - lifecycle logger from `./diag.js`.
118
+ */
119
+ export function applySettingsCard(ctx, diag) {
120
+ let attempts = 0;
121
+ let refusalLogged = false;
122
+ /** Consecutive refusals before the card is considered unavailable. */
123
+ let refusals = 0;
124
+ const tryOnce = () => {
125
+ let sections;
126
+ try {
127
+ sections = ctx.get('tuiSettingsSections', false);
128
+ }
129
+ catch {
130
+ sections = undefined;
131
+ }
132
+ if (sections !== undefined && sections !== null && typeof sections.register === 'function') {
133
+ try {
134
+ const dispose = sections.register(SETTINGS_SECTION);
135
+ diag(ctx, 'settings section registered');
136
+ ctx.effect(() => () => {
137
+ try {
138
+ if (typeof dispose === 'function') dispose();
139
+ }
140
+ catch {
141
+ // Best-effort teardown.
142
+ }
143
+ });
144
+ return;
145
+ }
146
+ catch (error) {
147
+ // THE FIRST REFUSAL IS NOT A VERDICT. The activation context is
148
+ // not live during the first tick, so `register` answers with
149
+ // "requires a live Cordis activation context" and starts working
150
+ // a few ticks later — the same behaviour every plugin in this
151
+ // ecosystem has to tolerate. Log once, keep trying, and only give
152
+ // up after a long run of refusals.
153
+ refusals += 1;
154
+ if (!refusalLogged) {
155
+ refusalLogged = true;
156
+ diag(ctx, `settings section refused (will retry): ${error instanceof Error ? error.message : String(error)}`);
157
+ }
158
+ if (refusals > RETRY_LIMIT) {
159
+ diag(ctx, `settings section unavailable after ${refusals} refusals; the namespace card stays generic`);
160
+ return;
161
+ }
162
+ }
163
+ }
164
+ attempts += 1;
165
+ if (attempts > RETRY_LIMIT) return;
166
+ const timer = setTimeout(tryOnce, RETRY_MS);
167
+ if (typeof timer.unref === 'function') timer.unref();
168
+ };
169
+ try {
170
+ tryOnce();
171
+ }
172
+ catch (error) {
173
+ diag(ctx, `settings card setup failed: ${error instanceof Error ? error.message : String(error)}`);
174
+ }
175
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "dsh-web-tavily",
3
+ "version": "0.4.0",
4
+ "description": "Tavily-backed search provider for dsh: registers the `tavily` provider on ctx.web so the web_search tool runs against the Tavily Search API",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/VviLliAm-qwq/dsh-web-tavily.git"
9
+ },
10
+ "type": "module",
11
+ "main": "lib/index.js",
12
+ "files": [
13
+ "lib",
14
+ "dsh-plugin.json",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.zh.md",
18
+ "CHANGELOG.md",
19
+ "LICENSE"
20
+ ],
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ }
25
+ },
26
+ "engines": {
27
+ "node": "^22.19 || >=24"
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/cordis": "^4.0.2",
31
+ "@deepseek-ai/schemastery": "^3.18.2",
32
+ "@deepseek-ai/dsh-web": "^0.1.2-rc.1",
33
+ "@deepseek-ai/dsh-web-fetch-http": "^0.1.2-rc.1",
34
+ "@deepseek-ai/dsh-credentials": "^0.1.2-rc.1",
35
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-rc.1",
36
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1"
37
+ }
38
+ }