@moguiyu/dsh-tavily 0.2.7 → 0.3.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/src/client.js CHANGED
@@ -7,7 +7,6 @@ export const id = '@moguiyu/dsh-tavily'
7
7
 
8
8
  export function factory(require) {
9
9
  const react = require('react')
10
- const _primitives = require('@deepseek-ai/dsh-client-ui-primitives')
11
10
 
12
11
  const inject = ['slots']
13
12
 
@@ -57,18 +56,6 @@ export function factory(require) {
57
56
  }, react.createElement(SvgIcon, { name: icon, size: 13 }))
58
57
  }
59
58
 
60
- function Switch({ checked, disabled, label, onChange }) {
61
- return react.createElement('button', {
62
- type: 'button',
63
- role: 'switch',
64
- 'aria-checked': checked === true,
65
- 'aria-label': label,
66
- disabled: disabled || checked === null,
67
- onClick: onChange,
68
- style: { width: 36, height: 20, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2)', background: checked === true ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-1)', cursor: 'pointer', padding: 0, position: 'relative', flex: 'none', opacity: disabled ? 0.5 : 1 }
69
- }, react.createElement('span', { style: { position: 'absolute', top: 2, left: checked === true ? 18 : 2, width: 14, height: 14, borderRadius: 7, background: '#fff', transition: 'left .12s ease' } }))
70
- }
71
-
72
59
  function UsageCircle({ percent, size, label, onClick }) {
73
60
  const radius = 15
74
61
  const circumference = 2 * Math.PI * radius
@@ -83,9 +70,7 @@ export function factory(require) {
83
70
  )
84
71
  }
85
72
 
86
- function TavilyCard() {
87
- const [enabled, setEnabled] = react.useState(null)
88
- const [expanded, setExpanded] = react.useState(false)
73
+ function TavilySettingsCard() {
89
74
  const [server, setServer] = react.useState(null)
90
75
  const [loadError, setLoadError] = react.useState(null)
91
76
  const [strategy, setStrategy] = react.useState('rotate')
@@ -101,13 +86,6 @@ export function factory(require) {
101
86
  const [usageError, setUsageError] = react.useState(null)
102
87
 
103
88
  const refresh = react.useCallback(async () => {
104
- try {
105
- const response = await fetch('/api/tavily-tool', { cache: 'no-store' })
106
- const data = await response.json()
107
- if (data.ok) { setEnabled(data.enabled); setLoadError(null) } else { setLoadError(data.error || 'Failed to load settings') }
108
- } catch (error) {
109
- setLoadError(String(error && error.message ? error.message : error))
110
- }
111
89
  try {
112
90
  const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
113
91
  const data = await response.json()
@@ -127,27 +105,6 @@ export function factory(require) {
127
105
 
128
106
  react.useEffect(() => { refresh() }, [refresh])
129
107
 
130
- const toggleEnabled = async () => {
131
- const next = enabled !== true
132
- setBusy(true)
133
- setNotice(null)
134
- try {
135
- const response = await fetch('/api/tavily-tool', {
136
- method: 'POST',
137
- headers: { 'content-type': 'application/json' },
138
- body: JSON.stringify({ enabled: next })
139
- })
140
- const data = await response.json()
141
- if (!data.ok) { setNotice({ error: data.error || 'Switch failed' }); return }
142
- setEnabled(data.enabled)
143
- setNotice({ ok: data.enabled ? 'Advanced tavily_search tool enabled.' : 'Advanced tavily_search tool disabled — built-in web_search is unchanged.' })
144
- } catch (error) {
145
- setNotice({ error: String(error && error.message ? error.message : error) })
146
- } finally {
147
- setBusy(false)
148
- }
149
- }
150
-
151
108
  const saveStrategy = async (next) => {
152
109
  setBusy(true)
153
110
  setNotice(null)
@@ -288,46 +245,12 @@ export function factory(require) {
288
245
  const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
289
246
  const cellStyle = { padding: '8px', verticalAlign: 'top' }
290
247
 
291
- const isOff = enabled === false
292
-
293
- // Card box mirrors the native plugin-config card (PluginCard.module.css,
294
- // ui-settings-plugins): bg-layer-3 fill, .5px border-l4, 16px radius,
295
- // hover/open border lift, and the open state's bg-layer-2 upgrade — so
296
- // the card sits at the same elevation as native cards in dark mode too.
297
- return react.createElement('div', { className: expanded ? 'dts-card dts-card-open' : 'dts-card', style: { minWidth: 0 } },
298
- react.createElement('style', null, '.dts-card{border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}.dts-card:hover{border-color:var(--dsw-alias-label-dimmed)}.dts-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
299
- react.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, padding: '12px 14px', minHeight: 52, boxSizing: 'border-box' } },
300
- react.createElement('button', {
301
- type: 'button',
302
- 'aria-expanded': expanded,
303
- onClick: () => setExpanded((current) => !current),
304
- style: { flex: 1, minWidth: 0, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 2, font: 'inherit', color: 'inherit' }
305
- },
306
- react.createElement('strong', { style: { fontSize: 14, fontWeight: 600, lineHeight: '20px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Tavily Search'),
307
- react.createElement('span', { style: { fontSize: 12, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Advanced tavily_search tool · key rotation · usage')
308
- ),
309
- react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' } },
310
- react.createElement(Switch, { checked: enabled, disabled: busy, label: 'Advanced tavily_search tool enabled', onChange: toggleEnabled }),
311
- react.createElement('button', {
312
- type: 'button',
313
- className: 'dts-icon-btn',
314
- title: expanded ? 'Collapse' : 'Expand',
315
- 'aria-label': expanded ? 'Collapse' : 'Expand',
316
- onClick: () => setExpanded((current) => !current),
317
- style: { width: 26, height: 26, borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0 }
318
- },
319
- react.createElement('span', { style: { display: 'inline-flex', transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform .14s var(--ds-ease-in-out)' } },
320
- react.createElement(_primitives.IconChevronDownOutline14, { size: 12, 'aria-hidden': 'true' })
321
- )
322
- )
323
- )
324
- ),
325
- expanded && react.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 16 } },
326
- isOff
327
- ? react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
328
- 'The advanced tavily_search tool is off. The built-in web_search keeps its native provider and is never replaced.'
329
- )
330
- : react.createElement(react.Fragment, null,
248
+ // The Plugins page draws the title, the icon, and the crumb — the
249
+ // `plugins.bundle.config` contract puts the form alone in the entry — so
250
+ // this renders no card chrome, no second heading, and no collapse.
251
+ return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 } },
252
+ react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
253
+ react.createElement(react.Fragment, null,
331
254
  react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
332
255
  'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
333
256
  ),
@@ -439,21 +362,33 @@ export function factory(require) {
439
362
  usageError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(usageError)),
440
363
  usage !== null && usage.ok === false && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(usage.error))
441
364
  )
442
- )
443
365
  )
444
366
  }
445
367
 
368
+ // The entry the Plugins page renders. The contract asks for two views:
369
+ // `summary` for the one-liner under the title, and `page` for the form with
370
+ // its own save control. The page supplies the title, icon, and crumb, so the
371
+ // summary is a plain string and the page view is the form alone — matching
372
+ // how the shipped cards (WebSearchCard and friends) are written.
373
+ function TavilyCard(props) {
374
+ if (props && props.view === 'summary') {
375
+ return 'Manage Tavily API keys and the key-usage strategy. The built-in web_search is never replaced.'
376
+ }
377
+ return react.createElement(TavilySettingsCard, null)
378
+ }
379
+
446
380
  function apply(ctx) {
447
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
448
- name: 'settings.plugin.item',
449
- // rc.7 plugin management: this card is keyed by the settings namespace
450
- // the Host serves (`tavily-search`); the Plugins configuration tab
451
- // dispatches it when the namespace is present. The id/order/label props
452
- // keep rc.6 list-slot deployments rendering the card as well.
453
- key: 'tavily-search',
454
- id: 'tavily-search',
455
- order: 30,
456
- label: 'Tavily Search'
381
+ // ONE slot. 0.1.6-alpha.2 moved plugin configuration to the sidebar
382
+ // Plugins page and retired `settings.plugin.item`. This package no longer
383
+ // registers a settings namespace, so the retired keyed slot has no key it
384
+ // could claim. On rc.7 through 0.1.6-alpha.1 the tools and routes work but
385
+ // this card has no host — see `docs/agents/plugin-design.md`.
386
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
387
+ name: 'plugins.bundle.config',
388
+ // Keyed by the bundle's package name. The Plugins page renders this on
389
+ // the @moguiyu/dsh-tavily bundle page, between its description and rows.
390
+ key: '@moguiyu/dsh-tavily',
391
+ id: '@moguiyu/dsh-tavily'
457
392
  }, TavilyCard))
458
393
  }
459
394
 
package/src/index.js CHANGED
@@ -1,170 +1,43 @@
1
1
  /**
2
- * `@moguiyu/dsh-tavily` combined package: the advanced `tavily_search` model
3
- * tool plus the local HTTP backend for usage and key management. The
4
- * implementations live in the two standalone packages and are composed here,
5
- * so there is exactly one copy of each:
2
+ * `@moguiyu/dsh-tavily` — the combined plugin, and now the only package.
6
3
  *
7
- * - tool half — `@moguiyu/dsh-tool-tavily-search` {@link installTavilyTool};
8
- * - backend half — `@moguiyu/dsh-tavily-backend` {@link installBackend}.
4
+ * Both halves live here, so there is no cross-package contract left to skew:
9
5
  *
10
- * This package adds the plugin-management seam: the Host installs the
11
- * `tavily-search` settings section (the key the Plugins configuration tab
12
- * pairs the card against). The switch value lives both in that namespace and
13
- * in `~/.dsh/tavily-tool.json`; every write path converges on
14
- * `settings.update` (or the state file when no settings service exists).
6
+ * - the model tools (`tools.js`) — `tavily_search`, `tavily_extract`,
7
+ * `tavily_map`, `tavily_crawl`, with key rotation and failover on 401/429;
8
+ * - key and usage management (`backend.js`) — `/api/tavily-usage` and
9
+ * `/api/tavily-manager`;
10
+ * - the browser card (`client.js`) — keys, usage, and strategy.
15
11
  *
16
- * The switch owns the tool half's lifecycle DIRECTLY: it holds the disposer
17
- * returned by {@link installTavilyTool} and disposes / re-installs it in place.
18
- * Earlier versions instead restarted the composing row and let `apply()` re-run;
19
- * that is gone on purpose. A row restart on 0.1.6 re-mounts the fiber into a
20
- * scope the agent does not see, so the tools ended up registered nowhere and
21
- * stayed missing for every session until DSH restarted (AGENTS.md §9 gate 5).
22
- * Do not reintroduce the restart.
12
+ * Composing the plugin registers the tools. There is NO tool-level on/off:
13
+ * the Plugins page's own bundle toggle is the only one. Two earlier designs
14
+ * are gone deliberately, both recorded in `docs/agents/verification.md`:
23
15
  *
24
- * The settings-card switch controls ONLY the opt-in `tavily_search` tool. The
25
- * built-in `web_search` tool is never replaced: this package registers no web
26
- * search provider and never rewrites `web.searchProvider` — `web_search`
27
- * keeps its native provider.
16
+ * 1. A row restart. On 0.1.6 it re-mounts the fiber into a scope the agent
17
+ * does not see, so the tools ended up registered nowhere and stayed
18
+ * missing, process-wide, until dsh restarted.
19
+ * 2. A tool-level switch split across packages. The combined package needed a
20
+ * disposer from the tool half, but its dependency range still allowed the
21
+ * old tool half, so `installTavilyTool` returned `undefined` and turning
22
+ * the switch off threw. One package removes the range as a failure mode.
23
+ *
24
+ * The built-in `web_search` tool is never replaced: no web search provider is
25
+ * registered, `web.searchProvider` / `DSH_WEB_SEARCH_PROVIDER` are never
26
+ * rewritten, and `ctx.web` is untouched.
28
27
  */
29
- import z from '@deepseek-ai/schemastery'
30
- import { isToolEnabled, installTavilyTool } from '@moguiyu/dsh-tool-tavily-search'
31
- import { installBackend, readToolState, restoreToolState, writeToolState } from '@moguiyu/dsh-tavily-backend'
28
+ import { installTavilyTool } from './tools.js'
29
+ import { installBackend } from './backend.js'
32
30
 
33
31
  export const name = 'dsh-tavily'
34
32
 
35
33
  export const inject = ['tools', 'webServer', 'credentials', 'systemPrompt']
36
34
 
37
- export const Config = z.object({
38
- enabled: z.boolean().default(false),
39
- })
40
-
41
- /**
42
- * Settings namespace owned by this package. It is the join key between the
43
- * Host half and the browser card: the Plugins configuration tab serves this
44
- * namespace and dispatches the card registered under the same key. A plain
45
- * lowercase-hyphenated string, valid on every supported host line: rc.7/rc.8
46
- * treat the namespace as a branded string (runtime-identical), and 0.1.2
47
- * validates and brands it on registration (`settingsNamespace()` was
48
- * removed upstream).
49
- */
50
- export const TAVILY_NS = 'tavily-search'
51
-
52
- export function apply(ctx, config) {
53
- // The switch value this fiber is acting on. Every write path updates it
54
- // before (un)registering, so a watcher re-fired afterwards no-ops.
55
- let lastApplied = isToolEnabled(config)
56
- // Settings seam state: the attached provider (null on hosts without one)
57
- // and the authoritative switch source — the resolved section while the
58
- // provider is attached, the composition entry otherwise.
59
- let settingsProvider = null
60
- let settingsSource = () => config
61
- // The live registration of the tool half, or null while it is off.
62
- let disposeTools = null
63
-
64
- /**
65
- * Add or remove the tool half to match `enabled`. This is the whole switch:
66
- * `installTavilyTool` hands back a disposer covering its tools and prompt
67
- * sections, so turning the capability off is a disposal rather than a row
68
- * restart. See the module header for why the restart is gone.
69
- */
70
- function setTools(enabled) {
71
- if (enabled) {
72
- if (disposeTools === null) disposeTools = installTavilyTool(ctx)
73
- return
74
- }
75
- if (disposeTools !== null) {
76
- const dispose = disposeTools
77
- disposeTools = null
78
- dispose()
79
- }
80
- }
81
-
82
- // Seed from the composition entry / persisted switch, and hand the fiber the
83
- // teardown so unloading the row leaves nothing registered.
84
- setTools(lastApplied)
85
- ctx.effect(() => () => { setTools(false) })
86
-
87
- /** Persist the switch, then (un)register the tool half in place. */
88
- async function applySwitchLocal(enabled) {
89
- if (enabled === lastApplied) return
90
- const previous = readToolState()
91
- lastApplied = enabled
92
- writeToolState(enabled)
93
- try {
94
- setTools(enabled)
95
- } catch (error) {
96
- lastApplied = previous !== null ? previous.enabled : (config.enabled !== false)
97
- restoreToolState(previous)
98
- setTools(lastApplied)
99
- throw error
100
- }
101
- }
102
-
103
- /** Re-judge the switch from a resolved settings value (either seam's shape). */
104
- function applySwitchValue(value) {
105
- const nextEnabled = value !== null && typeof value === 'object' && typeof value.enabled === 'boolean'
106
- ? value.enabled
107
- : false
108
- applySwitchLocal(nextEnabled).catch((error) => {
109
- ctx.logger.warn('tavily-search: applying settings switch failed: %s', error instanceof Error ? error.message : String(error))
110
- })
111
- }
112
-
113
- // First-class settings integration: install the `tavily-search` section so
114
- // the Plugins configuration surface serves it and pairs this package's
115
- // card. Runtime-optional on purpose: the row boots (state-file path) even
116
- // on hosts that never provide a settings service. Two host seams are
117
- // supported by feature detection, so ONE package version covers both host
118
- // lines:
119
- //
120
- // - 0.1.2 (`installSection`) — the row config is the composition entry
121
- // (base layer while a provider is attached, fallback value when one
122
- // detaches); `onChange` re-judges the switch on attach, on detach, and
123
- // on every committed change.
124
- // - rc.7/rc.8 (`register`) — the row config is the composition base layer
125
- // and the scope watcher re-judges the switch on every committed change
126
- // (`applies: 'restart'`, the 0.2.0 behavior).
127
- ctx.inject(['settings'], (settingsCtx) => {
128
- const provider = settingsCtx.settings
129
- try {
130
- if (typeof provider.installSection === 'function') {
131
- provider.installSection(ctx, TAVILY_NS, Config, config, {
132
- setSource: (current) => { settingsSource = current },
133
- onChange: () => { applySwitchValue(settingsSource()) },
134
- })
135
- settingsProvider = provider
136
- return
137
- }
138
- if (typeof provider.register === 'function') {
139
- const scope = provider.register(TAVILY_NS, Config, {
140
- base: { enabled: config.enabled },
141
- applies: 'restart',
142
- })
143
- if (scope !== undefined && scope !== null && typeof scope.watch === 'function') {
144
- scope.watch((next) => { applySwitchValue(next) })
145
- settingsProvider = provider
146
- } else {
147
- ctx.logger.warn('tavily-search: settings scope exposes no watcher; using the state-file path')
148
- }
149
- return
150
- }
151
- ctx.logger.warn('tavily-search: settings service exposes neither installSection nor register; using the state-file path')
152
- } catch (error) {
153
- ctx.logger.warn('tavily-search: settings section installation failed: %s', error instanceof Error ? error.message : String(error))
154
- }
155
- })
35
+ export function apply(ctx) {
36
+ const disposeTools = installTavilyTool(ctx)
37
+ installBackend(ctx)
156
38
 
157
- installBackend(ctx, {
158
- enabled: () => isToolEnabled(config),
159
- async applySwitch(enabled) {
160
- // Every write converges on one pipeline: the settings namespace when a
161
- // settings service is attached (its watcher persists and re-applies the
162
- // switch in place), or the plain state-file path otherwise.
163
- if (settingsProvider !== null) {
164
- await settingsProvider.update(TAVILY_NS, { enabled })
165
- } else {
166
- await applySwitchLocal(enabled)
167
- }
168
- },
169
- })
39
+ // The fiber owns the teardown: unloading the row — including the Plugins
40
+ // page's runtime unload on 0.1.6-alpha.2 — unregisters every tool and
41
+ // prompt section this row added.
42
+ ctx.effect(() => () => { disposeTools() })
170
43
  }
package/src/lib.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure helpers for the Tavily backend (exported for tests).
3
+ */
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ export const STRATEGIES = ['rotate', 'low-usage-first', 'high-usage-first']
7
+
8
+ export function isValidStrategy(value) {
9
+ return STRATEGIES.includes(value)
10
+ }
11
+
12
+ /**
13
+ * Mask a key for display: `first12…last4`; comma-separated lists are masked
14
+ * per part. Never reveals more than a fingerprint.
15
+ */
16
+ export function maskValue(value) {
17
+ if (typeof value !== 'string' || value.length === 0) return ''
18
+ return value.split(',').map((part) => {
19
+ const p = part.trim()
20
+ if (p.length <= 12) return '••••'
21
+ return p.slice(0, 12) + '…' + p.slice(-4)
22
+ }).join(', ')
23
+ }
24
+
25
+ /** Split a comma-separated credential value into trimmed, deduped keys. */
26
+ export function parseKeyList(value) {
27
+ if (typeof value !== 'string') return []
28
+ const seen = new Set()
29
+ const out = []
30
+ for (const part of value.split(',')) {
31
+ const key = part.trim()
32
+ if (key.length > 0 && !seen.has(key)) {
33
+ seen.add(key)
34
+ out.push(key)
35
+ }
36
+ }
37
+ return out
38
+ }
39
+
40
+ /**
41
+ * Order keys according to a strategy. `usageOf(key)` returns a number or null
42
+ * (unknown usage is treated as 0). `rotate` keeps the given order.
43
+ */
44
+ export function orderKeys(values, strategy, usageOf) {
45
+ if (strategy === 'rotate') return [...values]
46
+ const usageOfKey = (value) => {
47
+ const usage = usageOf(value)
48
+ return usage !== null && typeof usage === 'number' ? usage : 0
49
+ }
50
+ const sorted = [...values]
51
+ sorted.sort((a, b) => strategy === 'low-usage-first'
52
+ ? usageOfKey(a) - usageOfKey(b)
53
+ : usageOfKey(b) - usageOfKey(a))
54
+ return sorted
55
+ }
56
+
57
+ /** Read a JSON object file; `fallback` on missing or unparsable content. */
58
+ export function readJsonFile(path, fallback) {
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
61
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
62
+ } catch {
63
+ return fallback
64
+ }
65
+ }