dsh-exa-web-search 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.en.md ADDED
@@ -0,0 +1,62 @@
1
+ # dsh-exa-web-search
2
+
3
+ ![Plugin icon](icon.png)
4
+
5
+ [中文说明](README.md)
6
+
7
+ [![npm](https://img.shields.io/npm/v/dsh-exa-web-search)](https://www.npmjs.com/package/dsh-exa-web-search)
8
+ [![downloads](https://img.shields.io/npm/dm/dsh-exa-web-search)](https://www.npmjs.com/package/dsh-exa-web-search)
9
+ [![Check](https://github.com/Windsland52/dsh-exa-web-search/actions/workflows/check.yml/badge.svg)](https://github.com/Windsland52/dsh-exa-web-search/actions/workflows/check.yml)
10
+
11
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
12
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.19.0-339933?logo=node.js&logoColor=white)](package.json)
13
+ [![dsh](https://img.shields.io/badge/dsh-0.2.x-4c6)](package.json)
14
+
15
+ Wires Exa into DeepSeek Harness's built-in web search: keep using the `web_search` tool as usual, but the results now come from Exa. **No extra tools, no system-prompt changes.**
16
+
17
+ ## Two channels, switched automatically
18
+
19
+ | Mode | When | Route |
20
+ | --- | --- | --- |
21
+ | `auto` (default) | a key is available (configured, or in an environment variable) | official Exa API |
22
+ | `auto` | no key | keyless public channel |
23
+ | `anonymous` | chosen manually | force the public channel; any key is ignored |
24
+ | `keyed` | chosen manually | force the official API; unavailable without a key |
25
+
26
+ The public channel (labelled "anonymous" in the UI) is a free shared service: at most 2 queries run at once, the rest queue and retry once, and heavy use may be rate-limited. Use a key on the official API when you need reliability.
27
+
28
+ A rejected key (401) reports `INVALID_CREDENTIAL` instead of switching to the public channel.
29
+
30
+ ## Install
31
+
32
+ **Desktop app**: open **Plugins** → **+ Add plugin** (top right) → enter the package name `dsh-exa-web-search` → install.
33
+
34
+ **Command line**:
35
+
36
+ ```bash
37
+ dsh plugin add dsh-exa-web-search
38
+ ```
39
+
40
+ No settings to change afterwards: web search goes through Exa by default. Uninstalling restores everything and leaves no trace in your config. To switch to another search source, change it in your own settings.
41
+
42
+ ## Configuration (optional)
43
+
44
+ | Field | Default | Meaning |
45
+ | --- | --- | --- |
46
+ | `mode` | `auto` | `auto` / `anonymous` / `keyed`; behaviour in the table above |
47
+ | `apiKey` | — | the key typed in directly; when empty, read from the variable named by `apiKeyEnv` |
48
+ | `apiKeyEnv` | `EXA_API_KEY` | which environment variable holds the key |
49
+ | `baseURL` | `https://api.exa.ai` | official API address (keyed channel) |
50
+ | `mcpURL` | `https://mcp.exa.ai/mcp?tools=web_search_advanced_exa,web_search_exa` | public channel address (keyless) |
51
+ | `searchType` | `auto` | Exa search type (keyed only): `auto` / `keyword` / `neural` / `lite` / `deep-lite` / `deep` / `deep-reasoning` |
52
+
53
+ You normally don't edit config files by hand: open **Plugins**, find `dsh-exa-web-search`, and type the key into the **Exa API Key** card (masked, and never echoed back after saving). The card carries only the key; **open the plugin's row** for the rest — mode, endpoint addresses, and search type.
54
+
55
+ ## How it differs from the official Exa plugin
56
+
57
+ The official Exa plugin only registers the search source; you still have to make it the default yourself.
58
+
59
+ This plugin registers as the `exa` search source itself (replacing the official implementation), so it is the default on install, is undone on uninstall, and leaves your other config alone:
60
+
61
+ - **Search even without a key**: with a key it uses the official API, without one the keyless public channel, and `auto` switches for you.
62
+ - **Zero intrusion**: no new tools, no system-prompt changes; keep using `web_search` as usual.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # dsh-exa-web-search
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![插件图标](icon.png)
4
+
5
+ [English](README.en.md)
6
+
7
+ [![npm](https://img.shields.io/npm/v/dsh-exa-web-search)](https://www.npmjs.com/package/dsh-exa-web-search)
8
+ [![downloads](https://img.shields.io/npm/dm/dsh-exa-web-search)](https://www.npmjs.com/package/dsh-exa-web-search)
9
+ [![Check](https://github.com/Windsland52/dsh-exa-web-search/actions/workflows/check.yml/badge.svg)](https://github.com/Windsland52/dsh-exa-web-search/actions/workflows/check.yml)
10
+
11
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
12
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.19.0-339933?logo=node.js&logoColor=white)](package.json)
13
+ [![dsh](https://img.shields.io/badge/dsh-0.2.x-4c6)](package.json)
14
+
15
+ 把 Exa 接入 DeepSeek Harness 的内置联网搜索:`web_search` 工具照常用,只是搜索结果改由 Exa 提供。**没有额外工具,不改系统提示词。**
16
+
17
+ ## 两个通道,自动切换
18
+
19
+ | 模式 | 什么情况 | 走哪条路 |
20
+ | --- | --- | --- |
21
+ | `auto`(默认) | 有 Key(填在配置里,或环境变量里) | Exa 官方 API |
22
+ | `auto` | 没有 Key | 免 Key 的公共通道 |
23
+ | `anonymous` | 手动选择 | 强制公共通道,忽略 Key |
24
+ | `keyed` | 手动选择 | 强制官方 API;没有 Key 时不可用 |
25
+
26
+ 公共通道(界面里显示为「匿名」)是免费的共享服务,同时只跑 2 个查询,超出的排队等待并重试一次;量大时可能被限流。要求稳定就用 Key 走官方 API。
27
+
28
+ Key 被拒(401)时直接报 `INVALID_CREDENTIAL`,不会改用公共通道。
29
+
30
+ ## 安装
31
+
32
+ **桌面版**:打开 **插件** 页 → 右上角 **+ 添加插件** → 输入包名 `dsh-exa-web-search` → 安装。
33
+
34
+ **命令行**:
35
+
36
+ ```bash
37
+ dsh plugin add dsh-exa-web-search
38
+ ```
39
+
40
+ 装好后**不用改任何设置**:联网搜索默认就走 Exa。卸载时自动还原,不会在你的配置里留下痕迹。想换成别的搜索源,在你自己的设置里改即可。
41
+
42
+ ## 配置(可选)
43
+
44
+ | 字段 | 默认 | 说明 |
45
+ | --- | --- | --- |
46
+ | `mode` | `auto` | `auto` / `anonymous` / `keyed`,行为见上表 |
47
+ | `apiKey` | — | 直接填入的 Key;留空则从 `apiKeyEnv` 指定的环境变量读取 |
48
+ | `apiKeyEnv` | `EXA_API_KEY` | 从哪个环境变量读 Key |
49
+ | `baseURL` | `https://api.exa.ai` | 官方 API 地址(带 Key 通道) |
50
+ | `mcpURL` | `https://mcp.exa.ai/mcp?tools=web_search_advanced_exa,web_search_exa` | 公共通道地址(免 Key) |
51
+ | `searchType` | `auto` | Exa 检索类型(仅带 Key 时生效):`auto` / `keyword` / `neural` / `lite` / `deep-lite` / `deep` / `deep-reasoning` |
52
+
53
+ 平时不用手改配置文件:在**插件**页找到 `dsh-exa-web-search`,往 **Exa API Key** 卡片里填 Key 就行(界面自动打码,保存后不回显)。插件卡片上只放 Key;**点开这个插件的那一行**,行详情页还有模式、接口地址、检索类型等完整配置。
54
+
55
+ ## 和官方 Exa 插件的区别
56
+
57
+ 官方 Exa 插件只负责注册搜索源,装完还得自己把它设成默认。
58
+
59
+ 本插件直接以 `exa` 作为搜索源(顶替官方实现),装上即为默认,卸载即还原,不动你其它配置:
60
+
61
+ - **没 Key 也能搜**:有 Key 走官方接口,没 Key 走免 Key 的公共通道,`auto` 自动切。
62
+ - **零打扰**:不加新工具、不改系统提示词,`web_search` 照常用。
@@ -0,0 +1,24 @@
1
+ # dsh-exa-web-search bundle patch.
2
+ #
3
+ # Two patches in this layer:
4
+ #
5
+ # 1. Insert this plugin's own row (registers provider id `exa`).
6
+ # 2. Override the base `web` row to select it. A non-insert patch that names
7
+ # an existing row id replaces that row's fields — this is the same idiom
8
+ # official bundle layers use (`dsh-web-app` overrides base's `tool-web`
9
+ # and others). `config` is replaced wholesale, never merged; the `web`
10
+ # row only defines `searchProvider`/`fetchProvider`, so restating both is
11
+ # complete.
12
+ #
13
+ # Because selection lives in THIS bundle's patch — not a runtime write and
14
+ # not the user's own cordis.patch.yml — uninstalling the bundle removes the
15
+ # pin automatically. No `searchProvider`/`fetchProvider` residue is left
16
+ # behind in the user layer the way dsh-free-search's runtime takeover did.
17
+ - insert:
18
+ - id: exa-web-search
19
+ name: dsh-exa-web-search
20
+
21
+ - id: web
22
+ config:
23
+ searchProvider: exa
24
+ fetchProvider: http
package/icon.png ADDED
Binary file
package/lib/client.js ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Client half of dsh-exa-web-search: two Plugins-page configuration cards.
3
+ * The bundle page carries just the API key; the row page carries the whole
4
+ * deployment section — mode, URLs, retrieval knobs — so the unobtrusive page
5
+ * is the one that has everything. `apiKey` is a `role('secret')` config
6
+ * field: the card learns only whether one is set (from the describe mirror's
7
+ * sidecar) and writes it like any other field; the literal never rides back.
8
+ * The card dictionaries live in the on-demand `client.locales.js` chunk.
9
+ */
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-exa-web-search',
13
+ factory(require) {
14
+ const React = require('react');
15
+ const {
16
+ SettingsForm, SettingsSecretField, SettingsValueField,
17
+ SettingsFormModel, settingsTextField,
18
+ Menu, IconChevronDownOutlineRegular,
19
+ } = require('@deepseek-ai/dsh-client-ui-primitives');
20
+
21
+ const h = React.createElement;
22
+ const NS = 'exaWebSearch';
23
+ /** The row id the bundle patch declares; also its settings namespace. */
24
+ const ENTRY_NS = 'exa-web-search';
25
+ /** The package-level slot key the bundle page dispatches under. */
26
+ const BUNDLE_KEY = 'dsh-exa-web-search';
27
+ /** `<package>#<row>` key the Plugins page dispatches row config slots by. */
28
+ const SLOT_KEY = `${BUNDLE_KEY}#exa-web-search`;
29
+ /** Exa search reference, linked from the retrieval-mode hint. */
30
+ const DOCS_URL = 'https://exa.ai/docs/reference/search';
31
+ /** On-demand dictionaries chunk; see client.locales.js. */
32
+ const loadLocales = () => require.async('./client.locales.js');
33
+
34
+ installStyles();
35
+
36
+ /** One-time styling for the enum dropdown; the module system owns and removes this tag. */
37
+ function installStyles() {
38
+ if (typeof document === 'undefined' || document.querySelector('style[data-exa-styles="1"]') !== null) return;
39
+ const el = document.createElement('style');
40
+ el.setAttribute('data-exa-styles', '1');
41
+ el.textContent = [
42
+ '.exa-field{margin:14px 0;display:flex;flex-direction:column;gap:6px}',
43
+ '.exa-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
44
+ '.exa-label{font-size:13px;font-weight:600;line-height:1.3}',
45
+ '.exa-reset{background:none;border:none;padding:0;color:inherit;opacity:.65;font:inherit;font-size:12px;cursor:pointer}',
46
+ '.exa-reset:hover{opacity:1}',
47
+ '.exa-trigger{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;box-sizing:border-box;padding:7px 10px;border:1px solid rgba(128,128,128,.35);border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
48
+ '.exa-trigger:focus-visible{outline:2px solid rgba(80,140,255,.6);outline-offset:1px}',
49
+ '.exa-trigger:disabled{opacity:.5;cursor:default}',
50
+ '.exa-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
51
+ '.exa-hint{font-size:12px;line-height:1.4;opacity:.7;margin:0}',
52
+ '.exa-doclink{margin-left:6px;color:inherit;text-decoration:underline;text-underline-offset:2px}',
53
+ ].join('\n');
54
+ document.head.appendChild(el);
55
+ }
56
+
57
+ /**
58
+ * Whether the row's `apiKey` currently holds a value, read from the shared
59
+ * describe mirror's secret sidecar (`path` + `set`), never from the section.
60
+ * @param view - the shared describe snapshot's namespaces list.
61
+ * @returns true when the effective section reports a stored key.
62
+ */
63
+ function apiKeyConfigured(view) {
64
+ const entry = (view?.namespaces ?? []).find(item => item.ns === ENTRY_NS);
65
+ return (entry?.secrets ?? []).some(secret => secret.path.length === 1 && secret.path[0] === 'apiKey' && secret.set) === true;
66
+ }
67
+
68
+ /**
69
+ * A field accepting one of a fixed set of strings; anything else is an
70
+ * invalid draft that blocks the save. An empty draft clears the override.
71
+ * @param field - section field name.
72
+ * @param values - the accepted values.
73
+ * @returns the field's conversion spec.
74
+ */
75
+ function enumField(field, values) {
76
+ return {
77
+ field,
78
+ format: value => typeof value === 'string' ? value : '',
79
+ parse: (text) => {
80
+ const trimmed = text.trim();
81
+ if (trimmed === '') return { kind: 'clear' };
82
+ return values.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined;
83
+ },
84
+ };
85
+ }
86
+
87
+ /** Form frame copy, read from this page's dictionary. */
88
+ function formLabels(t) {
89
+ return {
90
+ unavailable: t('unavailable'), readOnly: t('readOnly'),
91
+ saveFailed: t('saveFailed'), save: t('save'), saving: t('saving'),
92
+ };
93
+ }
94
+
95
+ /** One editable section field control bound to the shared form. */
96
+ function valueField(t, state, actions, field, id, label, hint, extra) {
97
+ return h(SettingsValueField, {
98
+ id, label, hint,
99
+ overriddenLabel: t('overridden'),
100
+ resetLabel: t('reset'),
101
+ invalidLabel: t('invalidNumber'),
102
+ disabled: !state.writable || state.saving,
103
+ ...state[field],
104
+ ...(extra === 'numeric' ? { numeric: true } : {}),
105
+ onEdit: (text) => { actions.edit(field, text) },
106
+ onReset: () => { actions.resetField(field) },
107
+ });
108
+ }
109
+
110
+ /**
111
+ * A dropdown for one enumerated section field, matching the Plugins page's
112
+ * own provider menu (a value button that opens a Menu). Picking a value
113
+ * stages it; the reset affordance clears the override back to the default.
114
+ * The hint explains the selected value and links its documentation.
115
+ */
116
+ function enumControl(t, state, actions, field, id, label, choices, hints, link) {
117
+ const current = state[field];
118
+ const value = current.text === '' ? choices[0] : current.text;
119
+ return h(EnumDropdown, {
120
+ id, label, choices, value,
121
+ disabled: !state.writable || state.saving,
122
+ overridden: current.overridden === true,
123
+ invalid: current.invalid === true,
124
+ resetLabel: t('reset'),
125
+ invalidLabel: t('invalidEnum'),
126
+ hint: hints?.[value],
127
+ link,
128
+ onSelect: (next) => { actions.edit(field, next) },
129
+ onReset: () => { actions.resetField(field) },
130
+ });
131
+ }
132
+
133
+ /** The enum dropdown body: a controlled Menu anchored to its value button. */
134
+ function EnumDropdown(props) {
135
+ const [open, setOpen] = React.useState(false);
136
+ const trigger = React.useRef(null);
137
+ const shown = open && !props.disabled;
138
+ const items = props.choices.map(choice => ({ id: choice, label: choice }));
139
+ const anchor = h('button', {
140
+ ref: trigger, id: props.id, type: 'button', className: 'exa-trigger', disabled: props.disabled,
141
+ 'aria-haspopup': 'menu', 'aria-expanded': shown,
142
+ onClick: () => { setOpen(value => !value) },
143
+ },
144
+ h('span', { className: 'exa-value' }, props.value),
145
+ h(IconChevronDownOutlineRegular, { size: 12 }),
146
+ );
147
+ return h('div', { className: 'exa-field' },
148
+ h('div', { className: 'exa-head' },
149
+ h('label', { htmlFor: props.id, className: 'exa-label' }, props.label),
150
+ props.overridden
151
+ ? h('button', { type: 'button', className: 'exa-reset', disabled: props.disabled, onClick: props.onReset }, props.resetLabel)
152
+ : null,
153
+ ),
154
+ h(Menu, {
155
+ open: shown,
156
+ onClose: () => { setOpen(false) },
157
+ items,
158
+ selectedId: props.value,
159
+ onSelect: (next) => {
160
+ if (trigger.current) trigger.current.focus({ preventScroll: true });
161
+ setOpen(false);
162
+ if (next !== props.value) props.onSelect(next);
163
+ },
164
+ portal: true, autoFocus: true, minWidthFromAnchor: true, anchor,
165
+ }),
166
+ props.invalid ? h('p', { className: 'exa-hint', role: 'status' }, props.invalidLabel) : null,
167
+ (props.hint || props.link)
168
+ ? h('p', { className: 'exa-hint' },
169
+ props.hint ? h('span', null, props.hint) : null,
170
+ props.link ? h('a', { className: 'exa-doclink', href: props.link.href, target: '_blank', rel: 'noopener noreferrer' }, props.link.label) : null,
171
+ )
172
+ : null,
173
+ );
174
+ }
175
+
176
+ /** The API-key control shared by both cards. */
177
+ function keyField(t, state, actions) {
178
+ return h(SettingsSecretField, {
179
+ id: 'plugin-config-exa-api-key',
180
+ label: t('apiKey'),
181
+ hint: t('apiKeyHint'),
182
+ text: state.apiKey.text,
183
+ disabled: !state.writable || state.saving,
184
+ configured: state.apiKeyConfigured,
185
+ stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
186
+ onEdit: (text) => { actions.edit('apiKey', text) },
187
+ });
188
+ }
189
+
190
+ /**
191
+ * The bundle-page card: just the key. `summary` renders the one-liner the
192
+ * row detail shows where a row has no description; `page` renders here.
193
+ */
194
+ function ExaKeyCard(props) {
195
+ const t = props.t;
196
+ if (props.view === 'summary') return t('summary');
197
+ const actions = props.exaActions;
198
+ const state = props.useExaForm(value => value);
199
+ return h(SettingsForm, { labels: formLabels(t), state, onSave: actions.save, onDiscard: actions.discard },
200
+ keyField(t, state, actions),
201
+ );
202
+ }
203
+
204
+ /** The row-page card: the key plus every deployment-level section field. */
205
+ function ExaConfigCard(props) {
206
+ const t = props.t;
207
+ if (props.view === 'summary') return t('summary');
208
+ const actions = props.exaActions;
209
+ const state = props.useExaForm(value => value);
210
+ return h(SettingsForm, { labels: formLabels(t), state, onSave: actions.save, onDiscard: actions.discard },
211
+ keyField(t, state, actions),
212
+ enumControl(t, state, actions, 'mode', 'plugin-config-exa-mode', t('mode'),
213
+ ['auto', 'anonymous', 'keyed'],
214
+ { auto: t('modeAuto'), anonymous: t('modeAnonymous'), keyed: t('modeKeyed') }, null),
215
+ enumControl(t, state, actions, 'searchType', 'plugin-config-exa-search-type', t('searchType'),
216
+ ['auto', 'keyword', 'neural', 'lite', 'deep-lite', 'deep', 'deep-reasoning'],
217
+ { auto: t('searchTypeAuto'), keyword: t('searchTypeKeyword'), neural: t('searchTypeNeural'), lite: t('searchTypeLite'), 'deep-lite': t('searchTypeDeepLite'), deep: t('searchTypeDeep'), 'deep-reasoning': t('searchTypeDeepReasoning') },
218
+ { href: DOCS_URL, label: t('docsLink') }),
219
+ valueField(t, state, actions, 'apiKeyEnv', 'plugin-config-exa-key-env', t('apiKeyEnv'), t('apiKeyEnvHint')),
220
+ valueField(t, state, actions, 'baseURL', 'plugin-config-exa-base-url', t('baseURL'), t('baseURLHint')),
221
+ valueField(t, state, actions, 'mcpURL', 'plugin-config-exa-mcp-url', t('mcpURL'), t('mcpURLHint')),
222
+ );
223
+ }
224
+
225
+ return {
226
+ inject: ['slots', 'locale', 'configForms'],
227
+ async apply(ctx) {
228
+ const LOCALES = await loadLocales();
229
+ ctx.effect(() => ctx.locale.register(NS, LOCALES), 'exa-web-search: dictionaries');
230
+ const scope = ctx.configForms.get(ENTRY_NS);
231
+ const describe = ctx.configForms.describe();
232
+ const form = new SettingsFormModel(scope, [
233
+ enumField('mode', ['auto', 'anonymous', 'keyed']),
234
+ enumField('searchType', ['auto', 'keyword', 'neural', 'lite', 'deep-lite', 'deep', 'deep-reasoning']),
235
+ settingsTextField('apiKeyEnv'),
236
+ settingsTextField('baseURL'),
237
+ settingsTextField('mcpURL'),
238
+ ], [{
239
+ field: 'apiKey',
240
+ write: (text) => scope.mutate([{ op: 'set', path: ['apiKey'], value: text }]),
241
+ }]);
242
+ ctx.effect(() => () => { form.dispose() }, 'exa-web-search: form model');
243
+ const project = () => ({
244
+ ...form.shell(),
245
+ apiKey: form.field('apiKey'),
246
+ apiKeyConfigured: apiKeyConfigured(describe.getSnapshot().view),
247
+ mode: form.field('mode'),
248
+ searchType: form.field('searchType'),
249
+ apiKeyEnv: form.field('apiKeyEnv'),
250
+ baseURL: form.field('baseURL'),
251
+ mcpURL: form.field('mcpURL'),
252
+ });
253
+ const formStore = form.bind(project);
254
+ // Secret presence lives outside the section, so a key written or
255
+ // cleared elsewhere would never republish the form on its own.
256
+ ctx.effect(() => describe.subscribe(() => { formStore.set(project()) }), 'exa-web-search: secret presence');
257
+ const actions = form.actions();
258
+ ctx.effect(() => ctx.configForms.whileServed([ENTRY_NS], () => {
259
+ const inject = () => ({ hooks: { exaForm: formStore }, exaActions: actions });
260
+ const key = ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register(
261
+ { name: 'plugins.bundle.config', key: BUNDLE_KEY, locale: NS, inject },
262
+ ExaKeyCard));
263
+ const row = ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
264
+ { name: 'plugins.row.config', key: SLOT_KEY, locale: NS, inject },
265
+ ExaConfigCard));
266
+ return () => { key(); row(); };
267
+ }), 'exa-web-search: config cards');
268
+ },
269
+ };
270
+ },
271
+ });