dsh-web-search-plugin 0.1.1

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,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.1] - 2026-08-19
8
+
9
+ ### Added
10
+
11
+ - **GitHub Actions publishing** (`.github/workflows/publish.yml`): pushing a
12
+ `v*` tag publishes to npm with provenance; `ci.yml` runs the syntax checks
13
+ and a pack dry-run on every push/PR.
14
+
15
+ ### Fixed
16
+
17
+ - **Client bundle: missing `exports.inject`** (`lib/client.js`). The browser
18
+ plugin only exported `apply`, so cordis had an empty `fiber.inject` and any
19
+ `ctx.*` service access (e.g. `ctx.locale`) threw
20
+ `cannot get property "locale" without inject`. Now exports
21
+ `inject = ["slots", "locale", "connection", "remote", "settingsScope"]`.
22
+ - **Plugin-card slot registration** (`lib/client.js`). The card registered into
23
+ `settings.plugin.item` with the rc.6 `id`/`order` shape; the slot is keyed
24
+ since rc.6+ and requires `key: "dsh-web-search-plugin"`.
25
+ - **`CardForm.field("apiKey")` crash** (`lib/client.js`). The write-only
26
+ credential field has no section spec; `spec.format(...)` on `undefined`
27
+ threw `Cannot read properties of undefined (reading 'format')` during the
28
+ card projection. Added the secret-field branch (mirroring the official
29
+ `CardForm`).
30
+ - **`dsh.client.inject` now includes `@deepseek-ai/dsh-client-ui-slots`**
31
+ (`package.json`), matching the load set used by community plugins.
32
+
33
+ ### Changed
34
+
35
+ - No longer requires the `dsh-host-apiproxy` settings-namespace allowlist
36
+ patch that rc.6 needed: rc.7's `settings.describe()` exposes registered
37
+ namespaces dynamically.
38
+ - **Renamed to `dsh-web-search-plugin`**: the package name (matching the
39
+ repository name); the plugin id, settings namespace and card key were
40
+ renamed in lockstep so a single identity is used everywhere. No version
41
+ has shipped yet, so the rename carries no migration cost.
42
+
43
+ ## [0.1.0] - 2026-08-19
44
+
45
+ ### Added
46
+
47
+ - Initial release: `TavilySearchProvider` (`id: "tavily"`) registered into
48
+ `ctx.web`, with `dsh-web-search-plugin` settings section.
49
+ - Keyless mode (`X-Tavily-Access-Mode: keyless`) and keyed mode
50
+ (Bearer token via `TAVILY_API_KEY` credential reference).
51
+ - Browser configuration card for the DSH plugin configuration surface.
52
+
53
+ [0.1.1]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.0...v0.1.1
54
+ [0.1.0]: https://github.com/X-C1811/dsh-web-search-plugin/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-web-search-plugin contributors
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,119 @@
1
+ # dsh-web-search-plugin
2
+
3
+ A [Tavily](https://tavily.com)-backed web search provider for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web capability seam (`ctx.web`). It registers a `WebSearchProvider` under the stable id `tavily`, so the model-facing `web_search` tool runs against Tavily instead of the built-in DeepSeek search endpoint — with or without an API key.
4
+
5
+ - **Keyless mode (default)** — free and rate-limited; no account or API key required. Activated by a single `X-Tavily-Access-Mode: keyless` request header.
6
+ - **Keyed mode** — uses a Tavily API key resolved from the `TAVILY_API_KEY` credential / launch-environment reference (or a literal `apiKey`), sent as a Bearer token.
7
+ - **UI-configurable** — ships a configuration card in the DSH web settings (Settings → Plugins → Plugin configuration → Web search (Tavily)) to switch modes and edit options live.
8
+
9
+ ## Features
10
+
11
+ - Implements the same provider contract as the official `@deepseek-ai/dsh-web-search-deepseek` plugin: `inject: ['web']` + `installSettingsSection` + `ctx.web.registerSearchProvider`.
12
+ - Zero-config keyless search works out of the box; switch to an API key in one click for higher limits.
13
+ - Normalizes Tavily's `answer` into the result `content` and `results[]` into citeable `sources[]` (url, title, snippet, published date), deduplicated by URL.
14
+ - Settings section `dsh-web-search-plugin` is exposed dynamically through rc.7's `settings.describe()` — no host allowlist patch required.
15
+ - Maps provider errors and caller cancellation to the seam's `WEB_PROVIDER_ERROR` / `WEB_ABORTED` codes; credentials are resolved per search, so a key stored or rotated in the web credentials domain applies to the next search without a restart.
16
+
17
+ ## Requirements
18
+
19
+ - DeepSeek Harness `0.1.0-rc.7` or newer (keyed plugin slots and dynamic settings exposure)
20
+ - pnpm, for installing plugins into a profile via `dsh plugin`
21
+
22
+ ## Install
23
+
24
+ ### From npm
25
+
26
+ ```powershell
27
+ dsh plugin --profile web add dsh-web-search-plugin
28
+ ```
29
+
30
+ ### From a local checkout
31
+
32
+ ```powershell
33
+ dsh plugin --profile web add "path/to/dsh-web-search-plugin"
34
+ ```
35
+
36
+ Then route the web seam to the Tavily provider and enable the plugin in `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`:
37
+
38
+ ```yaml
39
+ # Replaces the base `web` config, which pins `searchProvider: deepseek-official`.
40
+ - id: web
41
+ name: '@deepseek-ai/dsh-web'
42
+ config:
43
+ searchProvider: tavily
44
+
45
+ - insert:
46
+ - id: dsh-web-search-plugin
47
+ name: dsh-web-search-plugin
48
+ config:
49
+ mode: keyless
50
+ ```
51
+
52
+ Restart the DSH web process; the browser picks up the plugin's client bundle on the next page refresh.
53
+
54
+ > **Note on `file:` (local) installs** — pnpm treats a `file:path` dependency as a snapshot: changes made to the source directory are **not** propagated into the profile's `node_modules` automatically. After editing the plugin, re-run `dsh plugin --profile web add "path/to/dsh-web-search-plugin"` (or copy the files over) and restart.
55
+
56
+ ## Configuration
57
+
58
+ The settings card (Settings → Plugins → Plugin configuration → Web search (Tavily)) edits the `dsh-web-search-plugin` namespace:
59
+
60
+ | Key | Default | Meaning |
61
+ |---|---|---|
62
+ | `mode` | `keyless` | `keyless` (free, rate-limited) or `keyed` (Tavily API key) |
63
+ | `apiKey` | — | Literal Tavily API key (never echoed back; stored via the credentials domain) |
64
+ | `apiKeyEnv` | `TAVILY_API_KEY` | Credential/env reference resolved on each keyed search |
65
+ | `baseURL` | `https://api.tavily.com` | Tavily REST base URL; `/search` is appended |
66
+ | `maxResults` | `8` | Sources per search (1–20) |
67
+ | `searchDepth` | `basic` | `basic` (faster) or `advanced` (deeper) |
68
+ | `includeAnswer` | `true` | Request Tavily's generated answer; surfaced as the result `content` |
69
+ | `topic` | `general` | `general` or `news` |
70
+
71
+ Environment overrides: `DSH_WEB_SEARCH_PROVIDER=tavily` selects this provider at boot; the plugin's base URL falls back to `$TAVILY_BASE_URL` when `baseURL` is unset.
72
+
73
+ ## Switching back to the built-in search
74
+
75
+ Change `searchProvider` back to `deepseek-official` in the profile patch. The plugin may stay registered; it is then simply not selected.
76
+
77
+ ## How it works
78
+
79
+ - One `POST https://api.tavily.com/search` per search; keyless requests send `x-tavily-access-mode: keyless`, keyed requests send `authorization: Bearer <key>`.
80
+ - Non-2xx responses become `WEB_PROVIDER_ERROR` (Tavily's `detail.error` text is preserved); caller cancellation becomes `WEB_ABORTED`.
81
+
82
+ ## Repository layout
83
+
84
+ ```
85
+ lib/index.js Host plugin: schemastery Config, TavilySearchProvider, settings section
86
+ lib/client.js Browser bundle (window.__ModuleLoader__.load): the configuration card
87
+ ```
88
+
89
+ ## Development
90
+
91
+ ```powershell
92
+ node --check lib/index.js
93
+ node --check lib/client.js
94
+ ```
95
+
96
+ The client bundle must stay in the `window.__ModuleLoader__.load({ id, factory })` wire format — it is loaded by `dsh-client-modules`, not by a bundler. It must export **both** `apply` and `inject` (the array of cordis service names it reads: `slots`, `locale`, `connection`, `remote`, `settingsScope`) and register its card into the keyed `settings.plugin.item` slot with a `key`.
97
+
98
+ ## Publishing
99
+
100
+ Releases are published automatically by GitHub Actions (`.github/workflows/publish.yml`): **pushing a `v*` tag** (e.g. `v0.1.1`) publishes the package to npm with provenance. Plain pushes to `main` never publish.
101
+
102
+ 1. Make sure `package.json` `version` matches the tag you are about to push (e.g. `0.1.1` → `v0.1.1`).
103
+ 2. On GitHub, set an npm automation token (publish permission) as the `NPM_TOKEN` repository secret under **Settings → Secrets and variables → Actions**, and allow workflow runs under **Settings → Actions**.
104
+ 3. Tag and push:
105
+
106
+ ```powershell
107
+ git tag v0.1.1
108
+ git push origin v0.1.1
109
+ ```
110
+
111
+ The workflow verifies the tag against `package.json` `version` before publishing (`npm publish --provenance --access public`), so an accidental mismatch fails fast instead of shipping the wrong version. Users can then install with `dsh plugin --profile web add dsh-web-search-plugin`.
112
+
113
+ ## Contributing
114
+
115
+ Issues and pull requests are welcome. See the [issue tracker](https://github.com/X-C1811/dsh-web-search-plugin/issues) for known limitations and the roadmap; the provider is shaped so additional search APIs can be added beside Tavily.
116
+
117
+ ## License
118
+
119
+ [MIT](LICENSE)
package/lib/client.js ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Browser half of `dsh-web-search-plugin`: a plugin card under the
3
+ * "settings → Plugins → Plugin configuration" surface that edits the
4
+ * `dsh-web-search-plugin` settings namespace and its `TAVILY_API_KEY` credential.
5
+ *
6
+ * This bundle is a self-contained hand-written module in the exact wire format
7
+ * the client module system expects: one `window.__ModuleLoader__.load` call
8
+ * whose factory registers a Cordis plugin through `exports.apply`. It mirrors
9
+ * the official `dsh-client-ui-settings-plugins` cards (staged edits, save
10
+ * writes once, credential written through the credentials domain) without
11
+ * depending on that package's internal `CardForm`.
12
+ *
13
+ * @module dsh-web-search-plugin/client
14
+ */
15
+ window.__ModuleLoader__.load({
16
+ id: "dsh-web-search-plugin",
17
+ factory: (require) => {
18
+ var module = { exports: {} };
19
+ var exports = module.exports;
20
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
21
+ let react = require("react");
22
+ let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
23
+
24
+ /** Settings namespace of the Tavily search provider. */
25
+ const NS = "dsh-web-search-plugin";
26
+ /** Credential reference the provider resolves when the section names none. */
27
+ const DEFAULT_API_KEY_REF = "TAVILY_API_KEY";
28
+ /** Form field the credential control stages under. */
29
+ const API_KEY_FIELD = "apiKey";
30
+
31
+ /** Locale bundles for the card. */
32
+ const en = {
33
+ nav: "Web search (Tavily)",
34
+ description: "The Tavily search provider. Keyless by default; switch to keyed and add a Tavily API key for higher limits.",
35
+ mode: "Auth mode",
36
+ modeKeyless: "Keyless (free, rate-limited)",
37
+ modeKeyed: "API key",
38
+ apiKey: "API key",
39
+ apiKeyHint: "Stored outside the settings file. Leave blank to keep the current key.",
40
+ apiKeySet: "A key is configured.",
41
+ apiKeyUnset: "No key is configured; keyed search is unavailable until one is.",
42
+ apiKeyEnv: "Credential reference",
43
+ apiKeyEnvHint: "Name of the credential/env var resolved for keyed search.",
44
+ baseURL: "Endpoint",
45
+ baseURLHint: "Leave blank to use the provider default.",
46
+ maxResults: "Max results",
47
+ maxResultsHint: "Sources returned per search (1-20).",
48
+ searchDepth: "Search depth",
49
+ searchDepthBasic: "Basic (faster)",
50
+ searchDepthAdvanced: "Advanced (slower, deeper)",
51
+ includeAnswer: "Include answer",
52
+ includeAnswerHint: "Request Tavily's generated answer text shown above the sources.",
53
+ topic: "Topic",
54
+ topicGeneral: "General",
55
+ topicNews: "News",
56
+ expand: "Show settings",
57
+ collapse: "Hide settings",
58
+ save: "Save",
59
+ saving: "Saving…",
60
+ discard: "Discard",
61
+ unsaved: "Unsaved",
62
+ saveFailed: "The deployment did not accept these values; they were left for you to correct.",
63
+ readOnly: "This deployment stores settings read-only.",
64
+ invalidNumber: "Enter a number, or leave blank to use the default."
65
+ };
66
+ /** Simplified Chinese copy. */
67
+ const zh = {
68
+ nav: "网页搜索(Tavily)",
69
+ description: "Tavily 搜索提供方。默认 keyless 免费使用;切换到 API key 并填入 Tavily key 可提高限额。",
70
+ mode: "认证模式",
71
+ modeKeyless: "Keyless(免费、限流)",
72
+ modeKeyed: "API key",
73
+ apiKey: "API Key",
74
+ apiKeyHint: "不写入设置文件。留空表示保持当前密钥。",
75
+ apiKeySet: "已配置密钥。",
76
+ apiKeyUnset: "未配置密钥;keyed 模式下搜索不可用。",
77
+ apiKeyEnv: "凭据引用名",
78
+ apiKeyEnvHint: "keyed 模式解析的凭据/环境变量名。",
79
+ baseURL: "接口地址",
80
+ baseURLHint: "留空则使用提供方默认地址。",
81
+ maxResults: "结果数量",
82
+ maxResultsHint: "每次搜索返回的源数量(1-20)。",
83
+ searchDepth: "搜索深度",
84
+ searchDepthBasic: "Basic(较快)",
85
+ searchDepthAdvanced: "Advanced(较慢、更深)",
86
+ includeAnswer: "包含摘要回答",
87
+ includeAnswerHint: "请求 Tavily 生成摘要回答,显示在结果上方。",
88
+ topic: "主题",
89
+ topicGeneral: "通用",
90
+ topicNews: "新闻",
91
+ expand: "展开设置",
92
+ collapse: "收起设置",
93
+ save: "保存",
94
+ saving: "保存中…",
95
+ discard: "放弃修改",
96
+ unsaved: "未保存",
97
+ saveFailed: "本部署没有接受这些值,已保留供你修改。",
98
+ readOnly: "本部署的设置为只读。",
99
+ invalidNumber: "请填数字;留空表示使用默认值。"
100
+ };
101
+
102
+ /** A whole-number field spec: empty draft clears, any other invalid draft blocks save. */
103
+ function numberField(field) {
104
+ return {
105
+ field,
106
+ format: (value) => typeof value === "number" ? String(value) : "",
107
+ parse: (text) => {
108
+ const trimmed = text.trim();
109
+ if (trimmed === "") return { kind: "clear" };
110
+ const parsed = Number(trimmed);
111
+ return Number.isFinite(parsed) ? { kind: "set", value: parsed } : void 0;
112
+ }
113
+ };
114
+ }
115
+ /** A free-text field spec: empty draft clears. */
116
+ function textField(field) {
117
+ return {
118
+ field,
119
+ format: (value) => typeof value === "string" ? value : "",
120
+ parse: (text) => {
121
+ const trimmed = text.trim();
122
+ return trimmed === "" ? { kind: "clear" } : { kind: "set", value: trimmed };
123
+ }
124
+ };
125
+ }
126
+ /** A choice field spec (mode/searchDepth/topic): staged as its raw string. */
127
+ function choiceField(field) {
128
+ return {
129
+ field,
130
+ format: (value) => typeof value === "string" ? value : "",
131
+ parse: (text) => text.trim() === "" ? { kind: "clear" } : { kind: "set", value: text.trim() }
132
+ };
133
+ }
134
+ /** A boolean field spec: a checkbox draft sets or clears. */
135
+ function booleanField(field) {
136
+ return {
137
+ field,
138
+ format: (value) => value === true ? "true" : "",
139
+ parse: (text) => text === "true" ? { kind: "set", value: true } : { kind: "clear" }
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Staged form over the `dsh-web-search-plugin` settings namespace. Modeled on
145
+ * the official cards' `CardForm` contract: a draft map, save writes every
146
+ * staged edit once, and the API key is written through the credentials
147
+ * domain addressed by the reference the section names.
148
+ */
149
+ var TavilyCardForm = class {
150
+ constructor(scope, api) {
151
+ this.scope = scope;
152
+ this.api = api;
153
+ this.specs = new Map([numberField("maxResults"), textField("apiKeyEnv"), textField("baseURL"), choiceField("mode"), choiceField("searchDepth"), choiceField("topic"), booleanField("includeAnswer")].map((spec) => [spec.field, spec]));
154
+ this.staged = new Map();
155
+ this.saving = false;
156
+ this.failed = false;
157
+ this.credential = { ref: "", configured: false, writable: true };
158
+ this.listeners = new Set();
159
+ scope.subscribe(() => {
160
+ this.publish();
161
+ });
162
+ this.readCredential();
163
+ }
164
+ bind(project) {
165
+ const store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(project());
166
+ this.listeners.add(() => {
167
+ store.set(project());
168
+ });
169
+ return store;
170
+ }
171
+ shell() {
172
+ const snapshot = this.scope.getSnapshot();
173
+ const plan = this.plan();
174
+ return {
175
+ available: snapshot.status === "ready",
176
+ writable: snapshot.writable,
177
+ dirty: plan.length > 0,
178
+ invalid: plan.some((item) => item.run === void 0),
179
+ saving: this.saving,
180
+ failed: this.failed
181
+ };
182
+ }
183
+ field(field) {
184
+ const staged = this.staged.get(field);
185
+ // Write-only credential controls have no section spec; they
186
+ // report only their staged draft (mirroring the official
187
+ // cards' secret-field branch in CardForm.field).
188
+ if (field === API_KEY_FIELD) return {
189
+ text: staged?.text ?? "",
190
+ overridden: false,
191
+ invalid: false
192
+ };
193
+ const spec = this.specs.get(field);
194
+ if (staged === void 0) return {
195
+ text: spec.format(this.sectionValue(field)),
196
+ overridden: this.stored(field),
197
+ invalid: false
198
+ };
199
+ const write = staged.clear ? { kind: "clear" } : spec.parse(staged.text);
200
+ return {
201
+ text: staged.text,
202
+ overridden: write?.kind === "set",
203
+ invalid: write === void 0
204
+ };
205
+ }
206
+ plan() {
207
+ const plan = [];
208
+ for (const [field, staged] of this.staged) {
209
+ if (field === API_KEY_FIELD) {
210
+ const value = staged.text.trim();
211
+ if (value !== "") plan.push({ field, run: () => this.writeKey(value) });
212
+ continue;
213
+ }
214
+ const spec = this.specs.get(field);
215
+ if (staged.clear) {
216
+ if (this.stored(field)) plan.push({ field, run: () => this.clear(field) });
217
+ continue;
218
+ }
219
+ if (staged.text === spec.format(this.sectionValue(field))) continue;
220
+ const write = spec.parse(staged.text);
221
+ if (write === void 0) plan.push({ field, run: void 0 });
222
+ else if (write.kind === "clear") plan.push({ field, run: () => this.clear(field) });
223
+ else plan.push({ field, run: () => this.store(field, write.value) });
224
+ }
225
+ return plan;
226
+ }
227
+ async save() {
228
+ const plan = this.plan();
229
+ const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
230
+ if (plan.length === 0 || this.saving || writes.length !== plan.length) return;
231
+ this.saving = true;
232
+ this.failed = false;
233
+ this.publish();
234
+ let landed = true;
235
+ for (const write of writes) landed = await write() && landed;
236
+ if (landed) this.staged.clear();
237
+ this.saving = false;
238
+ this.failed = !landed;
239
+ this.publish();
240
+ }
241
+ actions() {
242
+ return {
243
+ edit: (field, text) => {
244
+ this.stage(field, { text, clear: false });
245
+ },
246
+ resetField: (field) => {
247
+ this.stage(field, { text: this.baseText(field), clear: true });
248
+ },
249
+ save: () => {
250
+ this.save();
251
+ },
252
+ discard: () => {
253
+ if (this.staged.size === 0 && !this.failed) return;
254
+ this.staged.clear();
255
+ this.failed = false;
256
+ this.publish();
257
+ }
258
+ };
259
+ }
260
+ async clear(field) {
261
+ await this.scope.unset(field);
262
+ return !this.stored(field);
263
+ }
264
+ async store(field, value) {
265
+ await this.scope.set(field, value);
266
+ return this.userLayer()?.[field] === value;
267
+ }
268
+ stage(field, edit) {
269
+ this.staged.set(field, edit);
270
+ this.failed = false;
271
+ this.publish();
272
+ }
273
+ snapshotOf() {
274
+ return this.scope.getSnapshot();
275
+ }
276
+ sectionValue(field) {
277
+ return this.snapshotOf().value?.[field];
278
+ }
279
+ baseText(field) {
280
+ return this.specs.get(field).format(this.snapshotOf().base?.[field]);
281
+ }
282
+ userLayer() {
283
+ return this.snapshotOf().user;
284
+ }
285
+ stored(field) {
286
+ const user = this.userLayer();
287
+ return user !== void 0 && Object.hasOwn(user, field);
288
+ }
289
+ publish() {
290
+ for (const listener of this.listeners) listener();
291
+ }
292
+ /** The credential reference the section names, or the provider default. */
293
+ refOf() {
294
+ const declared = this.sectionValue("apiKeyEnv");
295
+ return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_API_KEY_REF;
296
+ }
297
+ /** Ask the credentials domain about the reference the section currently names. */
298
+ async readCredential() {
299
+ const ref = this.refOf();
300
+ if (ref !== this.credential.ref) {
301
+ this.credential = { ref, configured: false, writable: true };
302
+ this.publish();
303
+ }
304
+ let response;
305
+ try {
306
+ response = await this.api.credentials.describe({ refs: [ref] });
307
+ } catch (_credentialReadFailure) {
308
+ return;
309
+ }
310
+ if (!response.result.ok || ref !== this.refOf()) return;
311
+ const view = response.result.value.credentials[ref];
312
+ const next = {
313
+ ref,
314
+ configured: view?.configured ?? false,
315
+ writable: view?.writable ?? true
316
+ };
317
+ if (next.configured === this.credential.configured && next.writable === this.credential.writable) return;
318
+ this.credential = next;
319
+ this.publish();
320
+ }
321
+ /** Re-read after the Host reports a change to the reference this card watches. */
322
+ refreshCredential(ref) {
323
+ if (ref !== this.credential.ref) return;
324
+ this.readCredential();
325
+ }
326
+ /** Write the staged key, then re-read whether the Host now holds one. */
327
+ async writeKey(value) {
328
+ try {
329
+ await this.api.credentials.set({ ref: this.refOf(), value });
330
+ } catch (_credentialWriteFailure) {}
331
+ await this.readCredential();
332
+ return this.credential.configured;
333
+ }
334
+ };
335
+
336
+ /** Bridges the `dsh-web-search-plugin` scope and the credentials domain onto the card. */
337
+ var TavilyCardController = class {
338
+ constructor(scope, api) {
339
+ this.scope = scope;
340
+ this.api = api;
341
+ this.form = new TavilyCardForm(scope, api);
342
+ this.store = this.form.bind(() => this.projection());
343
+ scope.subscribe(() => {
344
+ this.form.readCredential();
345
+ });
346
+ }
347
+ projection() {
348
+ return {
349
+ ...this.form.shell(),
350
+ mode: this.form.field("mode"),
351
+ apiKey: this.form.field(API_KEY_FIELD),
352
+ apiKeyEnv: this.form.field("apiKeyEnv"),
353
+ baseURL: this.form.field("baseURL"),
354
+ maxResults: this.form.field("maxResults"),
355
+ searchDepth: this.form.field("searchDepth"),
356
+ includeAnswer: this.form.field("includeAnswer"),
357
+ topic: this.form.field("topic"),
358
+ apiKeyConfigured: this.form.credential.configured,
359
+ apiKeyWritable: this.form.credential.writable
360
+ };
361
+ }
362
+ inject() {
363
+ return {
364
+ hooks: { tavilySearchCard: this.store },
365
+ ...this.form.actions()
366
+ };
367
+ }
368
+ };
369
+
370
+ /** Row style: one labelled field with label, hint, and staged input. */
371
+ const fieldRow = (label, hint, children) => react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "4px", padding: "12px 0", borderTop: "1px solid var(--dsw-alias-border-l2)" } },
372
+ react.createElement("label", { style: { color: "var(--dsw-alias-label-primary)", fontSize: "13px", fontWeight: 500, lineHeight: "1.5" } }, label),
373
+ children,
374
+ hint ? react.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: 0, fontSize: "12px", lineHeight: "1.5" } }, hint) : null);
375
+ /** Text/number input. */
376
+ const textInput = (props) => react.createElement("input", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", height: "34px", font: "inherit", color: "var(--dsw-alias-label-primary)", borderRadius: "8px", padding: "0 12px", fontSize: "13px", lineHeight: "1.5" }, type: "text", ...props.numeric ? { inputMode: "numeric" } : {}, ...props });
377
+ /** Password input for the write-only credential. */
378
+ const secretInput = (props) => react.createElement("input", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", height: "34px", font: "inherit", color: "var(--dsw-alias-label-primary)", borderRadius: "8px", padding: "0 12px", fontSize: "13px", lineHeight: "1.5" }, type: "password", autoComplete: "off", ...props });
379
+ /** Choice select. */
380
+ const selectInput = (props) => react.createElement("select", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", height: "34px", font: "inherit", color: "var(--dsw-alias-label-primary)", borderRadius: "8px", padding: "0 12px", fontSize: "13px", lineHeight: "1.5" }, ...props });
381
+ /** Checkbox for booleans. */
382
+ const checkboxInput = (props) => react.createElement("input", { type: "checkbox", style: { width: "16px", height: "16px", accentColor: "var(--dsw-alias-brand-primary)" }, ...props });
383
+
384
+ /** Render one plugin card: header disclosure plus the staged form. */
385
+ function TavilyCard(props) {
386
+ const { t } = props;
387
+ const state = props.useTavilySearchCard((snapshot) => snapshot);
388
+ const [open, setOpen] = react.useState(false);
389
+ if (!state.available) return null;
390
+ const disabled = !state.writable;
391
+ const blocked = !state.dirty || state.invalid || state.saving;
392
+ return react.createElement("li", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", borderRadius: "12px", listStyle: "none" } },
393
+ react.createElement("button", { type: "button", "aria-expanded": open, onClick: () => setOpen(!open), style: { appearance: "none", width: "100%", font: "inherit", color: "inherit", textAlign: "left", cursor: "pointer", background: "0 0", border: "0", borderRadius: "12px", alignItems: "center", gap: "12px", padding: "14px 16px", display: "flex" } },
394
+ react.createElement("span", { style: { flexDirection: "column", flex: 1, gap: "4px", minWidth: 0, display: "flex" } },
395
+ react.createElement("span", { style: { color: "var(--dsw-alias-label-primary)", fontSize: "15px", fontWeight: 600, lineHeight: "1.4" } }, t("nav")),
396
+ react.createElement("span", { style: { color: "var(--dsw-alias-label-tertiary)", fontSize: "13px", lineHeight: "1.5" } }, t("description"))),
397
+ state.dirty ? react.createElement("span", { style: { whiteSpace: "nowrap", background: "var(--dsw-alias-bg-module-platform)", color: "var(--dsw-alias-label-secondary)", borderRadius: "999px", flex: "none", padding: "1px 8px", fontSize: "11px", fontWeight: 500, lineHeight: "17px" } }, t("unsaved")) : null,
398
+ react.createElement("span", { style: { color: "var(--dsw-alias-label-tertiary)", flex: "none" } }, open ? "▾" : "▸")),
399
+ open ? react.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", margin: "0 16px", paddingBottom: "8px" } },
400
+ !state.writable ? react.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: "12px 0 0", fontSize: "12px", lineHeight: "1.5" } }, t("readOnly")) : null,
401
+ fieldRow(t("mode"), null, selectInput({ id: "plugin-config-web-search-mode", value: state.mode.text, disabled, onChange: (e) => props.edit("mode", e.target.value), children: [
402
+ react.createElement("option", { value: "keyless", key: "keyless" }, t("modeKeyless")),
403
+ react.createElement("option", { value: "keyed", key: "keyed" }, t("modeKeyed"))
404
+ ] })),
405
+ state.mode.text === "keyed" ? fieldRow(t("apiKey"), t("apiKeyHint"), secretInput({ id: "plugin-config-web-search-key", value: state.apiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => props.edit("apiKey", e.target.value) })) : null,
406
+ state.mode.text === "keyed" ? fieldRow(t("apiKeyEnv"), t("apiKeyEnvHint"), textInput({ id: "plugin-config-web-search-keyref", value: state.apiKeyEnv.text, disabled, onChange: (e) => props.edit("apiKeyEnv", e.target.value), onReset: () => props.resetField("apiKeyEnv") })) : null,
407
+ fieldRow(t("baseURL"), t("baseURLHint"), textInput({ id: "plugin-config-web-search-base", value: state.baseURL.text, disabled, onChange: (e) => props.edit("baseURL", e.target.value), onReset: () => props.resetField("baseURL") })),
408
+ fieldRow(t("searchDepth"), null, selectInput({ id: "plugin-config-web-search-depth", value: state.searchDepth.text, disabled, onChange: (e) => props.edit("searchDepth", e.target.value), children: [
409
+ react.createElement("option", { value: "basic", key: "basic" }, t("searchDepthBasic")),
410
+ react.createElement("option", { value: "advanced", key: "advanced" }, t("searchDepthAdvanced"))
411
+ ] })),
412
+ fieldRow(t("topic"), null, selectInput({ id: "plugin-config-web-search-topic", value: state.topic.text, disabled, onChange: (e) => props.edit("topic", e.target.value), children: [
413
+ react.createElement("option", { value: "general", key: "general" }, t("topicGeneral")),
414
+ react.createElement("option", { value: "news", key: "news" }, t("topicNews"))
415
+ ] })),
416
+ fieldRow(t("includeAnswer"), t("includeAnswerHint"), checkboxInput({ id: "plugin-config-web-search-answer", checked: state.includeAnswer.text === "true", disabled, onChange: (e) => props.edit("includeAnswer", e.target.checked ? "true" : "") })),
417
+ fieldRow(t("maxResults"), t("maxResultsHint"), textInput({ id: "plugin-config-web-search-max", numeric: true, value: state.maxResults.text, disabled, onChange: (e) => props.edit("maxResults", e.target.value), onReset: () => props.resetField("maxResults") })),
418
+ react.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", justifyContent: "flex-end", alignItems: "center", gap: "8px", padding: "12px 0 4px", display: "flex" } },
419
+ state.failed ? react.createElement("p", { role: "status", style: { minWidth: 0, color: "var(--dsw-alias-label-error)", flex: 1, margin: 0, fontSize: "12px", lineHeight: "1.5" } }, t("saveFailed")) : null,
420
+ react.createElement("button", { type: "button", disabled: !state.dirty || state.saving, onClick: props.discard, style: { appearance: "none", font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", padding: "5px 14px", fontSize: "13px", lineHeight: "1.5", color: "var(--dsw-alias-label-secondary)", background: "0 0" } }, t("discard")),
421
+ react.createElement("button", { type: "button", disabled: blocked, onClick: props.save, style: { appearance: "none", font: "inherit", cursor: "pointer", border: "1px solid transparent", borderRadius: "8px", padding: "5px 14px", fontSize: "13px", lineHeight: "1.5", color: "var(--dsw-alias-label-on-brand)", background: "var(--dsw-alias-bg-brand-solid)" } }, t(state.saving ? "saving" : "save")))) : null);
422
+ }
423
+
424
+ /** Mount the Tavily card into the plugin configuration surface. */
425
+ function apply(ctx) {
426
+ const { api } = ctx.get("connection");
427
+ const t = ctx.locale.bind(NS);
428
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-web-search-plugin: card dictionaries");
429
+ const controller = new TavilyCardController(ctx.settingsScope.bind({ namespace: NS }), api);
430
+ ctx.effect(() => ctx.remote.$on("credentials/updated", (ref) => {
431
+ controller.form.refreshCredential(ref);
432
+ }), "dsh-web-search-plugin: credential invalidations");
433
+ ctx.slots.inject("settings.plugin.item", function* () {
434
+ yield ctx.slots.register({
435
+ name: "settings.plugin.item",
436
+ key: "dsh-web-search-plugin",
437
+ locale: NS,
438
+ inject: () => controller.inject()
439
+ }, TavilyCard);
440
+ });
441
+ }
442
+
443
+ /** Cordis service names this browser plugin's apply reads (fiber inject). */
444
+ const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
445
+
446
+ exports.apply = apply;
447
+ exports.inject = inject;
448
+ return module.exports;
449
+ }
450
+ });
451
+ //# sourceMappingURL=client.js.map
package/lib/index.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tavily-backed search provider for the DeepSeek Harness web capability seam
3
+ * (`ctx.web`). Registers a `WebSearchProvider` under the stable id `tavily`
4
+ * that calls the Tavily REST API (`POST {baseURL}/search`).
5
+ *
6
+ * Two authentication modes are supported, switchable through this plugin's
7
+ * settings section (`dsh-web-search-plugin`):
8
+ * - `keyless`: free rate-limited access, no account or key. A single
9
+ * `X-Tavily-Access-Mode: keyless` header activates it.
10
+ * - `keyed`: a Tavily API key resolved through the credentials service
11
+ * (`TAVILY_API_KEY` by default), the launching environment, or a literal
12
+ * `apiKey` in the config; sent as a Bearer token.
13
+ *
14
+ * Responses follow the standard Tavily schema and are normalized into the
15
+ * seam's `WebSearchResult` (`answer` -> `content`, `results[]` -> `sources[]`).
16
+ *
17
+ * The plugin mirrors the structure of `@deepseek-ai/dsh-web-search-deepseek`:
18
+ * a function/namespace Cordis plugin (`inject: ['web']`) that registers its
19
+ * own settings section and never registers a model-facing tool.
20
+ *
21
+ * @module dsh-web-search-plugin
22
+ */
23
+ import z from "@deepseek-ai/schemastery";
24
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
25
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
26
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
27
+ import { WebError } from "@deepseek-ai/dsh-web";
28
+
29
+ /** Stable id this provider registers under. */
30
+ const TAVILY_PROVIDER_ID = "tavily";
31
+ /** Default Tavily REST API base; `/search` is appended. */
32
+ const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
33
+ /** Default credential reference resolved for every keyed search. */
34
+ const DEFAULT_API_KEY_ENV = "TAVILY_API_KEY";
35
+ /** Attribution header sent on every request. */
36
+ const USER_AGENT = "dsh-web-search-plugin/0.1.1";
37
+ /** Tavily's hard cap on `max_results`. */
38
+ const TAVILY_MAX_RESULTS_CAP = 20;
39
+
40
+ /** Cordis plugin name used by loader diagnostics. */
41
+ const name = "dsh-web-search-plugin";
42
+ /** The web seam this provider registers into. */
43
+ const inject = ["web"];
44
+
45
+ /** Plugin config (all optional — `apply` fills defaults). */
46
+ const Config = z.object({
47
+ /** Authentication mode: `keyless` (default, free rate-limited) or `keyed`. */
48
+ mode: z.string().default("keyless"),
49
+ /** Literal Tavily API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
50
+ apiKey: z.string().role("secret").default(""),
51
+ /** Credential reference resolved for each keyed search; defaults to `TAVILY_API_KEY`. */
52
+ apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
53
+ /** Tavily REST API base. Defaults to `https://api.tavily.com`. */
54
+ baseURL: z.string().default(TAVILY_DEFAULT_BASE_URL),
55
+ /** Upper bound on returned sources; Tavily accepts 1..20. */
56
+ maxResults: z.number().step(1).min(1).max(TAVILY_MAX_RESULTS_CAP).default(8),
57
+ /** Search depth: `basic` (default) or `advanced`. */
58
+ searchDepth: z.string().default("basic"),
59
+ /** Request Tavily's generated answer text; surfaced as the result `content`. */
60
+ includeAnswer: z.boolean().default(true),
61
+ /** Search topic: `general` (default) or `news`. */
62
+ topic: z.string().default("general"),
63
+ /** Settings namespace carrying this provider's mode, endpoint, and key reference. */
64
+ });
65
+ /** Settings namespace carrying this provider's mode, endpoint, and key reference. */
66
+ const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = settingsNamespace("dsh-web-search-plugin");
67
+ /** Environment variable naming this provider's endpoint override. */
68
+ const TAVILY_BASE_URL_ENV = "TAVILY_BASE_URL";
69
+
70
+ /**
71
+ * Normalize a standard Tavily search response into the seam's
72
+ * `WebSearchResult`. Sources are deduped by `url` (Tavily can surface the same
73
+ * page twice); the provider-generated `answer` becomes `content` only when the
74
+ * `includeAnswer` option asked for it. The seam owns the final `maxResults`
75
+ * truncation, so `truncated` is always `false` here.
76
+ *
77
+ * @param json - the parsed Tavily response body.
78
+ * @param includeAnswer - whether the request requested `include_answer`.
79
+ * @returns the normalized search result.
80
+ * @throws {@link WebError} when the body is not a usable object.
81
+ */
82
+ function mapTavilyResponse(json, includeAnswer) {
83
+ if (json === null || typeof json !== "object") throw new WebError("Tavily returned a non-object response body", "WEB_PROVIDER_ERROR");
84
+ const results = Array.isArray(json.results) ? json.results : [];
85
+ if (results.length === 0) throw new WebError("Tavily returned no results", "WEB_PROVIDER_ERROR");
86
+ const seen = /* @__PURE__ */ new Set();
87
+ const sources = [];
88
+ for (const item of results) {
89
+ if (item === null || typeof item !== "object") continue;
90
+ if (typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
91
+ seen.add(item.url);
92
+ sources.push({
93
+ url: item.url,
94
+ ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
95
+ ...typeof item.content === "string" && item.content.length > 0 ? { snippet: item.content } : {},
96
+ ...typeof item.published_date === "string" && item.published_date.length > 0 ? { publishedAt: item.published_date } : {}
97
+ });
98
+ }
99
+ return {
100
+ ...includeAnswer === true && typeof json.answer === "string" && json.answer.length > 0 ? { content: json.answer } : {},
101
+ sources,
102
+ truncated: false
103
+ };
104
+ }
105
+
106
+ /** The Tavily-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
107
+ class TavilySearchProvider {
108
+ /**
109
+ * @param resolveOptions - the options for the NEXT operation, snapshotted
110
+ * once at each operation's entry so one search never mixes two sections.
111
+ */
112
+ constructor(resolveOptions) {
113
+ this.resolveOptions = resolveOptions;
114
+ }
115
+
116
+ id = TAVILY_PROVIDER_ID;
117
+
118
+ /** Cheap local usability check; must not make network calls. */
119
+ available() {
120
+ const options = this.resolveOptions();
121
+ if (!URL.canParse(options.baseURL)) return false;
122
+ if (options.mode !== "keyed") return true;
123
+ return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0);
124
+ }
125
+
126
+ /** Run one search through the Tavily REST API. */
127
+ async search(request, signal) {
128
+ const options = this.resolveOptions();
129
+ throwIfSearchAborted(signal);
130
+ let apiKey;
131
+ if (options.mode === "keyed") {
132
+ apiKey = await this.apiKey(options, signal);
133
+ throwIfSearchAborted(signal);
134
+ }
135
+ const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
136
+ const body = {
137
+ query: request.query,
138
+ max_results: Math.min(request.maxResults ?? options.maxResults, TAVILY_MAX_RESULTS_CAP),
139
+ search_depth: options.searchDepth,
140
+ topic: options.topic,
141
+ include_answer: options.includeAnswer === true,
142
+ include_images: false
143
+ };
144
+ const headers = {
145
+ "content-type": "application/json",
146
+ "accept": "application/json",
147
+ "user-agent": USER_AGENT,
148
+ ...options.mode === "keyless" ? { "x-tavily-access-mode": "keyless" } : { "authorization": `Bearer ${apiKey}` }
149
+ };
150
+ let response;
151
+ try {
152
+ response = await fetch(endpoint, {
153
+ method: "POST",
154
+ redirect: "error",
155
+ headers,
156
+ body: JSON.stringify(body),
157
+ ...signal !== void 0 ? { signal } : {}
158
+ });
159
+ } catch (error) {
160
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
161
+ throw new WebError(`Tavily search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
162
+ }
163
+ if (!response.ok) {
164
+ let message = `Tavily API error (HTTP ${response.status})`;
165
+ try {
166
+ const parsed = await response.json();
167
+ const detail = parsed?.detail?.error ?? parsed?.error ?? parsed?.message;
168
+ if (typeof detail === "string" && detail.length > 0) message = detail;
169
+ } catch {
170
+ /* non-JSON error body — keep the status-line message */
171
+ }
172
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
173
+ }
174
+ try {
175
+ return mapTavilyResponse(await response.json(), options.includeAnswer === true);
176
+ } catch (error) {
177
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
178
+ if (error instanceof WebError) throw error;
179
+ throw new WebError(`Tavily returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Resolve one operation's credential without retaining it on the provider.
185
+ * @param options - the caller's snapshot; the certificate and the endpoint it is sent to come from one section.
186
+ * @param signal - abort signal for the surrounding search.
187
+ * @returns the resolved key.
188
+ */
189
+ async apiKey(options, signal) {
190
+ throwIfSearchAborted(signal);
191
+ if (options.apiKey !== void 0 && options.apiKey.length > 0) return options.apiKey;
192
+ let resolved;
193
+ try {
194
+ resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(void 0), signal);
195
+ } catch (error) {
196
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
197
+ throw new WebError(`Tavily search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
198
+ }
199
+ if (resolved !== void 0 && resolved.length > 0) return resolved;
200
+ throw new WebError(`Tavily search has no API key for "${options.apiKeyEnv ?? DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "apiKey" in the dsh-web-search-plugin config`, "WEB_PROVIDER_CREDENTIAL_MISSING");
201
+ }
202
+ }
203
+
204
+ /** Race a same-process asynchronous preflight against caller cancellation. */
205
+ function abortable(operation, signal) {
206
+ if (signal === void 0) return operation;
207
+ if (signal.aborted) return Promise.reject(searchAborted(signal));
208
+ return new Promise((resolve, reject) => {
209
+ const onAbort = () => {
210
+ reject(searchAborted(signal));
211
+ };
212
+ signal.addEventListener("abort", onAbort, { once: true });
213
+ operation.then((value) => {
214
+ signal.removeEventListener("abort", onAbort);
215
+ resolve(value);
216
+ }, (error) => {
217
+ signal.removeEventListener("abort", onAbort);
218
+ reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
219
+ });
220
+ });
221
+ }
222
+
223
+ /** Throw the provider's stable cancellation error when the caller already aborted. */
224
+ function throwIfSearchAborted(signal) {
225
+ if (signal?.aborted === true) throw searchAborted(signal);
226
+ }
227
+
228
+ /** Build the provider's stable cancellation error while retaining the caller's reason. */
229
+ function searchAborted(signal, fallback) {
230
+ return new WebError("Tavily search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
231
+ }
232
+
233
+ /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
234
+ function isAbortError(error) {
235
+ return error instanceof DOMException && error.name === "AbortError";
236
+ }
237
+
238
+ /**
239
+ * Project one resolved section into the options the provider serves its next
240
+ * search with. Environment fallbacks stay here rather than in the provider:
241
+ * every value it reads is already fully defaulted.
242
+ * @param ctx - plugin context supplying the credential and environment planes.
243
+ * @param config - the currently authoritative section.
244
+ * @returns options for one search.
245
+ */
246
+ function resolveOptions(ctx, config) {
247
+ const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
248
+ const literalApiKey = config.apiKey !== void 0 && config.apiKey.length > 0 ? config.apiKey : void 0;
249
+ return {
250
+ mode: config.mode ?? "keyless",
251
+ ...literalApiKey === void 0 ? {} : { apiKey: literalApiKey },
252
+ resolveApiKey: async () => {
253
+ const credentials = ctx.get("credentials");
254
+ if (credentials !== void 0) return (await credentials.resolve(apiKeyEnv))?.value;
255
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
256
+ return ambient !== void 0 && ambient.value.length > 0 ? ambient.value : void 0;
257
+ },
258
+ apiKeyEnv,
259
+ baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(TAVILY_BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
260
+ maxResults: config.maxResults ?? 8,
261
+ searchDepth: config.searchDepth ?? "basic",
262
+ includeAnswer: config.includeAnswer ?? true,
263
+ topic: config.topic ?? "general"
264
+ };
265
+ }
266
+
267
+ /** Register the Tavily search provider with `ctx.web`. */
268
+ function apply(ctx, config) {
269
+ let current = () => config;
270
+ installSettingsSection(ctx, WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE, Config, config, {
271
+ setSource: (source) => {
272
+ current = source;
273
+ },
274
+ onChange: () => {}
275
+ });
276
+ ctx.web.registerSearchProvider(new TavilySearchProvider(() => resolveOptions(ctx, current())));
277
+ }
278
+
279
+ export { Config, TAVILY_DEFAULT_BASE_URL, TAVILY_PROVIDER_ID, TavilySearchProvider, WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE, apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "dsh-web-search-plugin",
3
+ "version": "0.1.1",
4
+ "description": "Tavily web-search provider for the DeepSeek Harness web capability seam (ctx.web): keyless or API-key modes with a UI config card; provider framework extensible to more search APIs",
5
+ "keywords": [
6
+ "dsh-plugin",
7
+ "deepseek-harness",
8
+ "dsh",
9
+ "tavily",
10
+ "web-search",
11
+ "websearch",
12
+ "search",
13
+ "plugin"
14
+ ],
15
+ "type": "module",
16
+ "main": "lib/index.js",
17
+ "files": [
18
+ "lib",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "default": "./lib/index.js"
27
+ },
28
+ "./client": {
29
+ "default": "./lib/client.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "dsh": {
34
+ "client": {
35
+ "platform": "web",
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-connection",
38
+ "@deepseek-ai/dsh-client-locale",
39
+ "@deepseek-ai/dsh-client-runtime",
40
+ "@deepseek-ai/dsh-client-ui-settings",
41
+ "@deepseek-ai/dsh-client-ui-slots",
42
+ "@deepseek-ai/dsh-api-remotes"
43
+ ]
44
+ }
45
+ },
46
+ "scripts": {
47
+ "check": "node --check lib/index.js && node --check lib/client.js"
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/dsh-web": "^0.1.0-rc.7",
51
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.7",
52
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
53
+ "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.7",
54
+ "@deepseek-ai/schemastery": "^3.18.1",
55
+ "@deepseek-ai/cordis": "^4.0.1"
56
+ },
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/X-C1811/dsh-web-search-plugin.git"
61
+ },
62
+ "homepage": "https://github.com/X-C1811/dsh-web-search-plugin",
63
+ "bugs": {
64
+ "url": "https://github.com/X-C1811/dsh-web-search-plugin/issues"
65
+ }
66
+ }