@moguiyu/dsh-tavily 0.1.10 → 0.1.12
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/README.md +19 -10
- package/lib/client.js +13 -8
- package/package.json +16 -5
- package/src/client.js +13 -8
- package/src/index.js +88 -570
- package/src/lib.js +0 -65
package/README.md
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
|
-
# @moguiyu/dsh-
|
|
1
|
+
# @moguiyu/dsh-tavily
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Combined plugin: the opt-in advanced `tavily_search` model tool, the **Tavily Search**
|
|
4
|
+
settings card, and the local HTTP backend for key/usage management.
|
|
5
5
|
|
|
6
6
|
- **`tavily_search`** — full Tavily surface (`max_results`, `search_depth`, `topic`, `days`,
|
|
7
|
-
`include_answer`, `include_raw_content`, `include_domains`, `exclude_domains`).
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- **
|
|
11
|
-
the
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
`include_answer`, `include_raw_content`, `include_domains`, `exclude_domains`). Opt-in and
|
|
8
|
+
off by default.
|
|
9
|
+
- **Settings card** — key list, usage gauge, strategy selector, and the advanced-tool switch.
|
|
10
|
+
- **Plugin-config settings card** — the Host registers the `tavily-search` settings namespace
|
|
11
|
+
and the card is keyed by it (keyed `settings.plugin.item`), so the Plugins configuration tab
|
|
12
|
+
serves the card exactly when this plugin is composed (needs DSH **0.1.0-rc.7 or newer**).
|
|
13
|
+
The switch writes the namespace and restarts the row; the choice is mirrored to
|
|
14
|
+
`~/.dsh/tavily-tool.json`.
|
|
15
|
+
- **`web_search` is never replaced** — no `ctx.web` provider is registered and
|
|
16
|
+
`web.searchProvider` is never rewritten; the built-in DeepSeek `web_search` keeps its
|
|
17
|
+
native provider and schema. Tavily is an extra, opt-in search option.
|
|
14
18
|
|
|
15
19
|
Host half: plain ESM (`src/index.js`). Client half: prebuilt `window.__ModuleLoader__` bundle
|
|
16
20
|
at `lib/client.js`, generated from `src/client.js` via `pnpm build`.
|
|
17
21
|
|
|
22
|
+
The host half composes the two standalone packages instead of carrying a second copy of the
|
|
23
|
+
implementation: the tool comes from [`@moguiyu/dsh-tool-tavily-search`](../dsh-tool-tavily-search)
|
|
24
|
+
and the routes/switch pipeline from [`@moguiyu/dsh-tavily-backend`](../dsh-tavily-backend).
|
|
25
|
+
Both are regular dependencies, installed automatically with this package.
|
|
26
|
+
|
|
18
27
|
See the [workspace README](../../README.md) for install and configuration.
|
package/lib/client.js
CHANGED
|
@@ -97,7 +97,7 @@ window.__ModuleLoader__.load({
|
|
|
97
97
|
|
|
98
98
|
const refresh = react.useCallback(async () => {
|
|
99
99
|
try {
|
|
100
|
-
const response = await fetch('/api/tavily-
|
|
100
|
+
const response = await fetch('/api/tavily-tool', { cache: 'no-store' })
|
|
101
101
|
const data = await response.json()
|
|
102
102
|
if (data.ok) { setEnabled(data.enabled); setLoadError(null) } else { setLoadError(data.error || 'Failed to load settings') }
|
|
103
103
|
} catch (error) {
|
|
@@ -127,7 +127,7 @@ window.__ModuleLoader__.load({
|
|
|
127
127
|
setBusy(true)
|
|
128
128
|
setNotice(null)
|
|
129
129
|
try {
|
|
130
|
-
const response = await fetch('/api/tavily-
|
|
130
|
+
const response = await fetch('/api/tavily-tool', {
|
|
131
131
|
method: 'POST',
|
|
132
132
|
headers: { 'content-type': 'application/json' },
|
|
133
133
|
body: JSON.stringify({ enabled: next })
|
|
@@ -135,7 +135,7 @@ window.__ModuleLoader__.load({
|
|
|
135
135
|
const data = await response.json()
|
|
136
136
|
if (!data.ok) { setNotice({ error: data.error || 'Switch failed' }); return }
|
|
137
137
|
setEnabled(data.enabled)
|
|
138
|
-
setNotice({ ok: data.enabled ? '
|
|
138
|
+
setNotice({ ok: data.enabled ? 'Advanced tavily_search tool enabled.' : 'Advanced tavily_search tool disabled — built-in web_search is unchanged.' })
|
|
139
139
|
} catch (error) {
|
|
140
140
|
setNotice({ error: String(error && error.message ? error.message : error) })
|
|
141
141
|
} finally {
|
|
@@ -295,10 +295,10 @@ window.__ModuleLoader__.load({
|
|
|
295
295
|
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' }
|
|
296
296
|
},
|
|
297
297
|
react.createElement('strong', { style: { fontSize: 14, fontWeight: 600, lineHeight: '20px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Tavily Search'),
|
|
298
|
-
react.createElement('span', { style: { fontSize: 12, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '
|
|
298
|
+
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')
|
|
299
299
|
),
|
|
300
300
|
react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' } },
|
|
301
|
-
react.createElement(Switch, { checked: enabled, disabled: busy, label: '
|
|
301
|
+
react.createElement(Switch, { checked: enabled, disabled: busy, label: 'Advanced tavily_search tool enabled', onChange: toggleEnabled }),
|
|
302
302
|
react.createElement('button', {
|
|
303
303
|
type: 'button',
|
|
304
304
|
className: 'dts-icon-btn',
|
|
@@ -316,11 +316,11 @@ window.__ModuleLoader__.load({
|
|
|
316
316
|
expanded && react.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 16 } },
|
|
317
317
|
isOff
|
|
318
318
|
? react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
319
|
-
'
|
|
319
|
+
'The advanced tavily_search tool is off. The built-in web_search keeps its native provider and is never replaced.'
|
|
320
320
|
)
|
|
321
321
|
: react.createElement(react.Fragment, null,
|
|
322
322
|
react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
323
|
-
'All keys are listed below; the
|
|
323
|
+
'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.'
|
|
324
324
|
),
|
|
325
325
|
loadError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(loadError)),
|
|
326
326
|
react.createElement('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 13 } },
|
|
@@ -357,7 +357,7 @@ window.__ModuleLoader__.load({
|
|
|
357
357
|
: react.createElement('span', null,
|
|
358
358
|
isRevealed ? revealed[masked] : masked,
|
|
359
359
|
key.primary === true && !isRemoved && react.createElement('span', {
|
|
360
|
-
title: 'Primary —
|
|
360
|
+
title: 'Primary — the first key in the rotation',
|
|
361
361
|
style: { display: 'inline-block', width: 8, height: 8, borderRadius: 4, background: 'var(--dsw-alias-state-success-primary)', marginLeft: 8, verticalAlign: 'middle', flex: 'none' }
|
|
362
362
|
})
|
|
363
363
|
)
|
|
@@ -437,6 +437,11 @@ window.__ModuleLoader__.load({
|
|
|
437
437
|
function apply(ctx) {
|
|
438
438
|
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
439
439
|
name: 'settings.plugin.item',
|
|
440
|
+
// rc.7 plugin management: this card is keyed by the settings namespace
|
|
441
|
+
// the Host serves (`tavily-search`); the Plugins configuration tab
|
|
442
|
+
// dispatches it when the namespace is present. The id/order/label props
|
|
443
|
+
// keep rc.6 list-slot deployments rendering the card as well.
|
|
444
|
+
key: 'tavily-search',
|
|
440
445
|
id: 'tavily-search',
|
|
441
446
|
order: 30,
|
|
442
447
|
label: 'Tavily Search'
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moguiyu/dsh-tavily",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.12",
|
|
4
|
+
"description": "Opt-in advanced tavily_search tool for DeepSeek Harness with the Tavily Search settings card (rc.8 plugin management). The built-in web_search is never replaced.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"platform": "web",
|
|
31
31
|
"inject": [
|
|
32
32
|
"@deepseek-ai/dsh-client-runtime",
|
|
33
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
34
35
|
]
|
|
35
36
|
}
|
|
36
37
|
},
|
|
@@ -52,7 +53,17 @@
|
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
55
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.
|
|
56
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.
|
|
56
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
|
|
57
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
|
58
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
62
|
+
"@moguiyu/dsh-tavily-backend": "^0.1.12",
|
|
63
|
+
"@moguiyu/dsh-tool-tavily-search": "^0.1.12"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
67
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8"
|
|
57
68
|
}
|
|
58
69
|
}
|
package/src/client.js
CHANGED
|
@@ -102,7 +102,7 @@ export function factory(require) {
|
|
|
102
102
|
|
|
103
103
|
const refresh = react.useCallback(async () => {
|
|
104
104
|
try {
|
|
105
|
-
const response = await fetch('/api/tavily-
|
|
105
|
+
const response = await fetch('/api/tavily-tool', { cache: 'no-store' })
|
|
106
106
|
const data = await response.json()
|
|
107
107
|
if (data.ok) { setEnabled(data.enabled); setLoadError(null) } else { setLoadError(data.error || 'Failed to load settings') }
|
|
108
108
|
} catch (error) {
|
|
@@ -132,7 +132,7 @@ export function factory(require) {
|
|
|
132
132
|
setBusy(true)
|
|
133
133
|
setNotice(null)
|
|
134
134
|
try {
|
|
135
|
-
const response = await fetch('/api/tavily-
|
|
135
|
+
const response = await fetch('/api/tavily-tool', {
|
|
136
136
|
method: 'POST',
|
|
137
137
|
headers: { 'content-type': 'application/json' },
|
|
138
138
|
body: JSON.stringify({ enabled: next })
|
|
@@ -140,7 +140,7 @@ export function factory(require) {
|
|
|
140
140
|
const data = await response.json()
|
|
141
141
|
if (!data.ok) { setNotice({ error: data.error || 'Switch failed' }); return }
|
|
142
142
|
setEnabled(data.enabled)
|
|
143
|
-
setNotice({ ok: data.enabled ? '
|
|
143
|
+
setNotice({ ok: data.enabled ? 'Advanced tavily_search tool enabled.' : 'Advanced tavily_search tool disabled — built-in web_search is unchanged.' })
|
|
144
144
|
} catch (error) {
|
|
145
145
|
setNotice({ error: String(error && error.message ? error.message : error) })
|
|
146
146
|
} finally {
|
|
@@ -300,10 +300,10 @@ export function factory(require) {
|
|
|
300
300
|
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' }
|
|
301
301
|
},
|
|
302
302
|
react.createElement('strong', { style: { fontSize: 14, fontWeight: 600, lineHeight: '20px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Tavily Search'),
|
|
303
|
-
react.createElement('span', { style: { fontSize: 12, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '
|
|
303
|
+
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')
|
|
304
304
|
),
|
|
305
305
|
react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' } },
|
|
306
|
-
react.createElement(Switch, { checked: enabled, disabled: busy, label: '
|
|
306
|
+
react.createElement(Switch, { checked: enabled, disabled: busy, label: 'Advanced tavily_search tool enabled', onChange: toggleEnabled }),
|
|
307
307
|
react.createElement('button', {
|
|
308
308
|
type: 'button',
|
|
309
309
|
className: 'dts-icon-btn',
|
|
@@ -321,11 +321,11 @@ export function factory(require) {
|
|
|
321
321
|
expanded && react.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 16 } },
|
|
322
322
|
isOff
|
|
323
323
|
? react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
324
|
-
'
|
|
324
|
+
'The advanced tavily_search tool is off. The built-in web_search keeps its native provider and is never replaced.'
|
|
325
325
|
)
|
|
326
326
|
: react.createElement(react.Fragment, null,
|
|
327
327
|
react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
328
|
-
'All keys are listed below; the
|
|
328
|
+
'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.'
|
|
329
329
|
),
|
|
330
330
|
loadError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(loadError)),
|
|
331
331
|
react.createElement('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 13 } },
|
|
@@ -362,7 +362,7 @@ export function factory(require) {
|
|
|
362
362
|
: react.createElement('span', null,
|
|
363
363
|
isRevealed ? revealed[masked] : masked,
|
|
364
364
|
key.primary === true && !isRemoved && react.createElement('span', {
|
|
365
|
-
title: 'Primary —
|
|
365
|
+
title: 'Primary — the first key in the rotation',
|
|
366
366
|
style: { display: 'inline-block', width: 8, height: 8, borderRadius: 4, background: 'var(--dsw-alias-state-success-primary)', marginLeft: 8, verticalAlign: 'middle', flex: 'none' }
|
|
367
367
|
})
|
|
368
368
|
)
|
|
@@ -442,6 +442,11 @@ export function factory(require) {
|
|
|
442
442
|
function apply(ctx) {
|
|
443
443
|
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
444
444
|
name: 'settings.plugin.item',
|
|
445
|
+
// rc.7 plugin management: this card is keyed by the settings namespace
|
|
446
|
+
// the Host serves (`tavily-search`); the Plugins configuration tab
|
|
447
|
+
// dispatches it when the namespace is present. The id/order/label props
|
|
448
|
+
// keep rc.6 list-slot deployments rendering the card as well.
|
|
449
|
+
key: 'tavily-search',
|
|
445
450
|
id: 'tavily-search',
|
|
446
451
|
order: 30,
|
|
447
452
|
label: 'Tavily Search'
|
package/src/index.js
CHANGED
|
@@ -1,592 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@moguiyu/dsh-tavily` combined
|
|
3
|
-
* the local HTTP backend for usage
|
|
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:
|
|
6
|
+
*
|
|
7
|
+
* - tool half — `@moguiyu/dsh-tool-tavily-search` {@link installTavilyTool};
|
|
8
|
+
* - backend half — `@moguiyu/dsh-tavily-backend` {@link installBackend}.
|
|
9
|
+
*
|
|
10
|
+
* This package adds the rc.7 plugin-management seam: the Host registers the
|
|
11
|
+
* `tavily-search` settings namespace (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),
|
|
15
|
+
* persisted first, then the row restarts itself so the tool registers or
|
|
16
|
+
* unregisters cleanly.
|
|
17
|
+
*
|
|
18
|
+
* The settings-card switch controls ONLY the opt-in `tavily_search` tool. The
|
|
19
|
+
* built-in `web_search` tool is never replaced: this package registers no web
|
|
20
|
+
* search provider and never rewrites `web.searchProvider` — `web_search`
|
|
21
|
+
* keeps its native provider.
|
|
4
22
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { STRATEGIES, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
|
|
23
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
24
|
+
import z from '@deepseek-ai/schemastery'
|
|
25
|
+
import { isToolEnabled, installTavilyTool } from '@moguiyu/dsh-tool-tavily-search'
|
|
26
|
+
import { installBackend, readToolState, restoreToolState, writeToolState } from '@moguiyu/dsh-tavily-backend'
|
|
10
27
|
|
|
11
28
|
export const name = 'dsh-tavily'
|
|
12
29
|
|
|
13
|
-
export const inject = ['tools', 'webServer', 'credentials']
|
|
30
|
+
export const inject = ['tools', 'webServer', 'credentials', 'systemPrompt', 'loader']
|
|
14
31
|
|
|
15
|
-
const
|
|
16
|
-
|
|
32
|
+
export const Config = z.object({
|
|
33
|
+
enabled: z.boolean().default(false),
|
|
34
|
+
})
|
|
17
35
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
: []
|
|
38
|
-
const excludeDomains = Array.isArray(args.exclude_domains)
|
|
39
|
-
? args.exclude_domains.filter((d) => typeof d === 'string' && d.length > 0)
|
|
40
|
-
: []
|
|
41
|
-
return { query, maxResults, searchDepth, topic, days, includeAnswer, includeRawContent, includeDomains, excludeDomains }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function hostnameOf(url) {
|
|
45
|
-
try {
|
|
46
|
-
return new URL(url).hostname
|
|
47
|
-
} catch {
|
|
48
|
-
return url
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Combine a caller signal with a hard timeout; call dispose() after the request. */
|
|
53
|
-
function combineSignals(signal, ms) {
|
|
54
|
-
const controller = new AbortController()
|
|
55
|
-
const timer = setTimeout(() => controller.abort(), ms)
|
|
56
|
-
const onAbort = () => controller.abort()
|
|
57
|
-
if (signal !== undefined && signal !== null) {
|
|
58
|
-
if (signal.aborted) controller.abort()
|
|
59
|
-
else signal.addEventListener('abort', onAbort, { once: true })
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
signal: controller.signal,
|
|
63
|
-
dispose() {
|
|
64
|
-
clearTimeout(timer)
|
|
65
|
-
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
66
|
-
},
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function buildBody(input) {
|
|
71
|
-
const body = {
|
|
72
|
-
query: input.query,
|
|
73
|
-
max_results: input.maxResults,
|
|
74
|
-
search_depth: input.searchDepth,
|
|
75
|
-
}
|
|
76
|
-
if (input.topic === 'news') {
|
|
77
|
-
body.topic = 'news'
|
|
78
|
-
if (input.days !== undefined) body.days = input.days
|
|
79
|
-
}
|
|
80
|
-
if (input.includeAnswer) body.include_answer = true
|
|
81
|
-
if (input.includeRawContent) body.include_raw_content = true
|
|
82
|
-
if (input.includeDomains.length > 0) body.include_domains = input.includeDomains
|
|
83
|
-
if (input.excludeDomains.length > 0) body.exclude_domains = input.excludeDomains
|
|
84
|
-
return body
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function callTavily(input, key, exec) {
|
|
88
|
-
const combined = combineSignals(exec.signal, 30000)
|
|
89
|
-
try {
|
|
90
|
-
const response = await fetch('https://api.tavily.com/search', {
|
|
91
|
-
method: 'POST',
|
|
92
|
-
headers: {
|
|
93
|
-
'content-type': 'application/json',
|
|
94
|
-
accept: 'application/json',
|
|
95
|
-
authorization: 'Bearer ' + key,
|
|
96
|
-
'user-agent': 'dsh-tool-tavily-search/0.1.0',
|
|
97
|
-
},
|
|
98
|
-
body: JSON.stringify(buildBody(input)),
|
|
99
|
-
signal: combined.signal,
|
|
100
|
-
})
|
|
101
|
-
if (response.status === 401 || response.status === 429) {
|
|
102
|
-
return { kind: 'retryable', statusCode: response.status }
|
|
103
|
-
}
|
|
104
|
-
if (!response.ok) {
|
|
105
|
-
let message = 'HTTP ' + response.status
|
|
106
|
-
try {
|
|
107
|
-
const parsed = await response.json()
|
|
108
|
-
if (parsed !== null && typeof parsed === 'object') {
|
|
109
|
-
if (typeof parsed.error === 'string') message = parsed.error
|
|
110
|
-
else if (parsed.error !== null && typeof parsed.error === 'object' && typeof parsed.error.message === 'string') message = parsed.error.message
|
|
111
|
-
}
|
|
112
|
-
} catch {
|
|
113
|
-
/* keep the status message */
|
|
114
|
-
}
|
|
115
|
-
throw new Error('tavily_search: ' + message)
|
|
116
|
-
}
|
|
117
|
-
return { kind: 'ok', statusCode: response.status, json: await response.json() }
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
120
|
-
throw new Error('tavily_search: request timed out or was aborted')
|
|
121
|
-
}
|
|
122
|
-
throw error
|
|
123
|
-
} finally {
|
|
124
|
-
combined.dispose()
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function projectResults(json) {
|
|
129
|
-
const results = Array.isArray(json.results) ? json.results : []
|
|
130
|
-
const mapped = results.map((r) => {
|
|
131
|
-
const item = { url: String(r.url !== undefined && r.url !== null ? r.url : '') }
|
|
132
|
-
if (typeof r.title === 'string' && r.title.length > 0) item.title = r.title
|
|
133
|
-
if (typeof r.content === 'string' && r.content.length > 0) item.content = r.content
|
|
134
|
-
if (typeof r.score === 'number') item.score = r.score
|
|
135
|
-
if (typeof r.published_date === 'string' && r.published_date.length > 0) item.publishedAt = r.published_date
|
|
136
|
-
if (typeof r.raw_content === 'string' && r.raw_content.length > 0) {
|
|
137
|
-
item.rawContent = r.raw_content.length > 4000 ? r.raw_content.slice(0, 4000) + '…' : r.raw_content
|
|
138
|
-
}
|
|
139
|
-
return item
|
|
140
|
-
})
|
|
141
|
-
const value = { results: mapped }
|
|
142
|
-
if (typeof json.answer === 'string' && json.answer.length > 0) value.answer = json.answer
|
|
143
|
-
return value
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function formatOutput(value) {
|
|
147
|
-
const parts = []
|
|
148
|
-
if (typeof value.answer === 'string' && value.answer.length > 0) parts.push(value.answer)
|
|
149
|
-
if (value.results.length > 0) {
|
|
150
|
-
const lines = value.results.map((r) => {
|
|
151
|
-
const label = typeof r.title === 'string' && r.title.length > 0 ? r.title : hostnameOf(r.url)
|
|
152
|
-
const meta = []
|
|
153
|
-
if (typeof r.content === 'string' && r.content.length > 0) meta.push(r.content)
|
|
154
|
-
if (typeof r.publishedAt === 'string' && r.publishedAt.length > 0) meta.push('(' + r.publishedAt + ')')
|
|
155
|
-
if (typeof r.score === 'number') meta.push('(relevance ' + r.score.toFixed(2) + ')')
|
|
156
|
-
const suffix = meta.length > 0 ? ' — ' + meta.join(' ') : ''
|
|
157
|
-
return '- [' + label + '](' + r.url + ')' + suffix
|
|
158
|
-
})
|
|
159
|
-
parts.push('Sources:\n' + lines.join('\n'))
|
|
160
|
-
} else {
|
|
161
|
-
parts.push('No results found.')
|
|
162
|
-
}
|
|
163
|
-
parts.push('Cite the relevant URLs above as markdown links in your answer.')
|
|
164
|
-
return parts.join('\n\n')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function apply(ctx) {
|
|
168
|
-
applyBackend(ctx)
|
|
169
|
-
|
|
170
|
-
let rotation = 0
|
|
171
|
-
|
|
172
|
-
async function resolveKeys() {
|
|
173
|
-
const credentials = ctx.get('credentials')
|
|
174
|
-
if (credentials !== undefined) {
|
|
175
|
-
try {
|
|
176
|
-
const resolved = await credentials.resolve('TAVILY_API_KEYS')
|
|
177
|
-
if (resolved !== undefined && typeof resolved.value === 'string' && resolved.value.length > 0) {
|
|
178
|
-
const list = resolved.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
179
|
-
if (list.length > 0) return { keys: list, source: resolved.source }
|
|
180
|
-
}
|
|
181
|
-
} catch (error) {
|
|
182
|
-
ctx.logger.warn('tavily_search: credential resolution failed: %s', error instanceof Error ? error.message : String(error))
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return { keys: [], source: 'unconfigured' }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function execute(args, exec) {
|
|
189
|
-
const input = normalizeArgs(args)
|
|
190
|
-
const { keys, source } = await resolveKeys()
|
|
191
|
-
if (keys.length === 0) {
|
|
192
|
-
throw new Error('tavily_search: TAVILY_API_KEYS is not configured — add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml (comma-separated)')
|
|
193
|
-
}
|
|
194
|
-
const attempts = keys.length
|
|
195
|
-
let lastRetryable = 0
|
|
196
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
197
|
-
const index = (rotation + attempt) % keys.length
|
|
198
|
-
const key = keys[index]
|
|
199
|
-
const started = Date.now()
|
|
200
|
-
const outcome = await callTavily(input, key, exec)
|
|
201
|
-
if (outcome.kind === 'ok') {
|
|
202
|
-
rotation = (index + 1) % keys.length
|
|
203
|
-
const count = Array.isArray(outcome.json.results) ? outcome.json.results.length : 0
|
|
204
|
-
ctx.logger.info('tavily_search: ok %j', { query: input.query, status: outcome.statusCode, results: count, ms: Date.now() - started, keys: keys.length, source })
|
|
205
|
-
return projectResults(outcome.json)
|
|
206
|
-
}
|
|
207
|
-
lastRetryable = outcome.statusCode
|
|
208
|
-
rotation = (index + 1) % keys.length
|
|
209
|
-
ctx.logger.info('tavily_search: key %d/%d returned HTTP %d, rotating', index + 1, keys.length, outcome.statusCode)
|
|
210
|
-
}
|
|
211
|
-
throw new Error('tavily_search: all ' + attempts + ' configured key(s) failed with HTTP ' + lastRetryable + ' (invalid key or rate limit)')
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
ctx.tools.register(defineTool({
|
|
215
|
-
name: 'tavily_search',
|
|
216
|
-
description: 'Search the web through the Tavily API. Returns ranked results with titles, URLs, snippets, relevance scores, and an optional generated answer. Supports basic/advanced depth, news topic with a freshness window, and domain allow/deny filters.',
|
|
217
|
-
parameters: {
|
|
218
|
-
query: { type: 'string', required: true, description: 'The search query.' },
|
|
219
|
-
max_results: { type: 'number', description: 'Number of results to return, 1-20 (default 5).' },
|
|
220
|
-
search_depth: { type: 'string', enum: ['basic', 'advanced'], description: 'basic is faster and cheaper; advanced returns deeper analysis (default basic).' },
|
|
221
|
-
topic: { type: 'string', enum: ['general', 'news'], description: 'Search scope: general web or news (default general).' },
|
|
222
|
-
days: { type: 'number', description: 'News freshness window in days, e.g. 7 for the last week; only applies when topic is news.' },
|
|
223
|
-
include_answer: { type: 'boolean', description: 'Ask Tavily to generate a concise answer to the query (default false).' },
|
|
224
|
-
include_raw_content: { type: 'boolean', description: 'Include raw page content for each result (default false; capped per result).' },
|
|
225
|
-
include_domains: { type: 'array', items: { type: 'string' }, description: 'Only search within these domains.' },
|
|
226
|
-
exclude_domains: { type: 'array', items: { type: 'string' }, description: 'Exclude these domains from results.' },
|
|
227
|
-
},
|
|
228
|
-
output: {
|
|
229
|
-
schema: {
|
|
230
|
-
type: 'object',
|
|
231
|
-
additionalProperties: false,
|
|
232
|
-
properties: {
|
|
233
|
-
answer: { type: 'string' },
|
|
234
|
-
results: {
|
|
235
|
-
type: 'array',
|
|
236
|
-
required: true,
|
|
237
|
-
items: {
|
|
238
|
-
type: 'object',
|
|
239
|
-
additionalProperties: false,
|
|
240
|
-
properties: {
|
|
241
|
-
url: { type: 'string', required: true },
|
|
242
|
-
title: { type: 'string' },
|
|
243
|
-
content: { type: 'string' },
|
|
244
|
-
score: { type: 'number' },
|
|
245
|
-
publishedAt: { type: 'string' },
|
|
246
|
-
rawContent: { type: 'string' },
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
},
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
render: (_args, value) => [{ type: 'text', text: formatOutput(value) }],
|
|
253
|
-
},
|
|
254
|
-
timeoutMs: 30000,
|
|
255
|
-
isConcurrencySafe: () => true,
|
|
256
|
-
execute,
|
|
257
|
-
}))
|
|
258
|
-
|
|
259
|
-
const systemPrompt = ctx.get('systemPrompt')
|
|
260
|
-
if (systemPrompt !== undefined) {
|
|
261
|
-
systemPrompt.section({
|
|
262
|
-
name: 'tool:tavily_search',
|
|
263
|
-
order: 111,
|
|
264
|
-
text: 'Use the tavily_search tool for web search powered by the Tavily API. It supports result count, search depth, news topic with a freshness window, domain allow/deny filters, and an optional generated answer (include_answer). Cite the returned URLs as markdown links in your answer.',
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const credentials = ctx.get('credentials')
|
|
269
|
-
if (credentials !== undefined) {
|
|
270
|
-
credentials.describe('TAVILY_API_KEYS').then((info) => {
|
|
271
|
-
if (!info.configured) {
|
|
272
|
-
ctx.logger.warn('tavily_search: TAVILY_API_KEYS credential is not configured; add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml')
|
|
273
|
-
}
|
|
274
|
-
}).catch((error) => {
|
|
275
|
-
ctx.logger.warn('tavily_search: credential describe failed: %s', error instanceof Error ? error.message : String(error))
|
|
276
|
-
})
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
const MANAGER_STATE = 'tavily-manager.json'
|
|
280
|
-
const TOGGLE_STATE = 'tavily-toggle.json'
|
|
281
|
-
const LEGACY_STATE = 'tavily-settings.json'
|
|
282
|
-
const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
|
|
283
|
-
|
|
284
|
-
function statePath(file) {
|
|
285
|
-
return join(resolveDshHome(), file)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function readState(file) {
|
|
289
|
-
return readJsonFile(statePath(file), {})
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function writeState(file, state) {
|
|
293
|
-
writeFileSync(statePath(file), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function readBody(req) {
|
|
297
|
-
return new Promise((resolve, reject) => {
|
|
298
|
-
const chunks = []
|
|
299
|
-
req.on('data', (chunk) => chunks.push(chunk))
|
|
300
|
-
req.on('end', () => {
|
|
301
|
-
const text = Buffer.concat(chunks).toString('utf8')
|
|
302
|
-
try {
|
|
303
|
-
resolve(text.length > 0 ? JSON.parse(text) : {})
|
|
304
|
-
} catch {
|
|
305
|
-
reject(new Error('invalid JSON body'))
|
|
306
|
-
}
|
|
307
|
-
})
|
|
308
|
-
req.on('error', reject)
|
|
309
|
-
})
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
async function fetchUsageDetailsFor(key) {
|
|
313
|
-
const cached = usageCache.get(key)
|
|
314
|
-
if (cached !== undefined && Date.now() - cached.at < USAGE_TTL_MS) return cached.details
|
|
315
|
-
try {
|
|
316
|
-
const controller = new AbortController()
|
|
317
|
-
const timer = setTimeout(() => controller.abort(), 15000)
|
|
318
|
-
let response
|
|
319
|
-
try {
|
|
320
|
-
response = await fetch('https://api.tavily.com/usage', {
|
|
321
|
-
headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
|
|
322
|
-
signal: controller.signal,
|
|
323
|
-
})
|
|
324
|
-
} finally {
|
|
325
|
-
clearTimeout(timer)
|
|
326
|
-
}
|
|
327
|
-
if (!response.ok) return null
|
|
328
|
-
const json = await response.json()
|
|
329
|
-
const account = json !== null && typeof json === 'object' ? json.account : undefined
|
|
330
|
-
const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
|
|
331
|
-
const usage = keyUsage !== undefined && typeof keyUsage.usage === 'number'
|
|
332
|
-
? keyUsage.usage
|
|
333
|
-
: (account !== undefined && typeof account.plan_usage === 'number' ? account.plan_usage : null)
|
|
334
|
-
const planUsage = account !== undefined && typeof account.plan_usage === 'number'
|
|
335
|
-
? account.plan_usage
|
|
336
|
-
: (keyUsage !== undefined && typeof keyUsage.usage === 'number' ? keyUsage.usage : null)
|
|
337
|
-
const planLimit = account !== undefined && typeof account.plan_limit === 'number'
|
|
338
|
-
? account.plan_limit
|
|
339
|
-
: (keyUsage !== undefined && typeof keyUsage.limit === 'number' ? keyUsage.limit
|
|
340
|
-
: (keyUsage !== undefined && typeof keyUsage.plan_limit === 'number' ? keyUsage.plan_limit : null))
|
|
341
|
-
const currentPlan = account !== undefined && typeof account.plan === 'string'
|
|
342
|
-
? account.plan
|
|
343
|
-
: (keyUsage !== undefined && typeof keyUsage.plan === 'string' ? keyUsage.plan : null)
|
|
344
|
-
if (usage === null && planUsage === null) return null
|
|
345
|
-
const details = { usage, planUsage, planLimit, currentPlan }
|
|
346
|
-
usageCache.set(key, { at: Date.now(), details })
|
|
347
|
-
return details
|
|
348
|
-
} catch {
|
|
349
|
-
return null
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async function fetchUsageFor(key) {
|
|
354
|
-
const details = await fetchUsageDetailsFor(key)
|
|
355
|
-
if (details === null) return null
|
|
356
|
-
if (details.planLimit !== null && details.planLimit > 0 && details.planUsage !== null) {
|
|
357
|
-
return (details.planUsage / details.planLimit) * 100
|
|
358
|
-
}
|
|
359
|
-
return details.usage !== null ? details.usage : details.planUsage
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
async function collectStoredKeys(credentials) {
|
|
363
|
-
const out = []
|
|
364
|
-
const seen = new Set()
|
|
365
|
-
for (const ref of REFS) {
|
|
366
|
-
let hit
|
|
36
|
+
/**
|
|
37
|
+
* Settings namespace owned by this package. It is the join key between the
|
|
38
|
+
* Host half and the browser card: the rc.7 Plugins configuration tab serves
|
|
39
|
+
* this namespace and dispatches the card registered under the same key.
|
|
40
|
+
*/
|
|
41
|
+
export const TAVILY_NS = settingsNamespace('tavily-search')
|
|
42
|
+
|
|
43
|
+
export function apply(ctx, config) {
|
|
44
|
+
// The switch value the current fiber is acting on. Every write path updates
|
|
45
|
+
// this before the row restart so a watcher re-fired after a restart no-ops.
|
|
46
|
+
let lastApplied = isToolEnabled(config)
|
|
47
|
+
let settingsScope = null
|
|
48
|
+
|
|
49
|
+
/** Persist the switch first, then restart this row so the tool (un)registers. */
|
|
50
|
+
async function applySwitchLocal(enabled) {
|
|
51
|
+
if (enabled === lastApplied) return
|
|
52
|
+
lastApplied = enabled
|
|
53
|
+
const previous = readToolState()
|
|
54
|
+
writeToolState(enabled)
|
|
367
55
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (hit === undefined || typeof hit.value !== 'string') continue
|
|
373
|
-
for (const key of parseKeyList(hit.value)) {
|
|
374
|
-
if (!seen.has(key)) {
|
|
375
|
-
seen.add(key)
|
|
376
|
-
out.push(key)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return out
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function applyBackend(ctx) {
|
|
384
|
-
const credentials = ctx.get('credentials')
|
|
385
|
-
const loader = ctx.get('loader')
|
|
386
|
-
|
|
387
|
-
const send = (res, status, payload) => {
|
|
388
|
-
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
389
|
-
res.end(JSON.stringify(payload))
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ── key manager payload ────────────────────────────────────────────────────
|
|
393
|
-
async function buildManagerPayload(keys) {
|
|
394
|
-
const state = readState(MANAGER_STATE)
|
|
395
|
-
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
396
|
-
if (Object.keys(keySavedAt).length === 0 && credentials !== undefined) {
|
|
397
|
-
// Migrate dates recorded by the pre-0.2 layout (ref-keyed legacy file).
|
|
398
|
-
const legacy = readJsonFile(statePath(LEGACY_STATE), null)
|
|
399
|
-
if (legacy !== null && typeof legacy === 'object') {
|
|
400
|
-
for (const ref of REFS) {
|
|
401
|
-
const entry = legacy[ref]
|
|
402
|
-
if (entry !== null && typeof entry === 'object' && typeof entry.savedAt === 'string') {
|
|
403
|
-
try {
|
|
404
|
-
const hit = await credentials.resolve(ref)
|
|
405
|
-
if (hit !== undefined) keySavedAt[maskValue(hit.value)] = entry.savedAt
|
|
406
|
-
} catch {
|
|
407
|
-
/* keep going */
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
56
|
+
const loader = ctx.get('loader')
|
|
57
|
+
const self = loader !== undefined && loader !== null ? loader.resolve('include:dsh-tavily') : undefined
|
|
58
|
+
if (self === undefined || self === null || self.fiber === undefined) {
|
|
59
|
+
throw new Error('dsh-tavily: row include:dsh-tavily is not loaded')
|
|
411
60
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
masked,
|
|
418
|
-
savedAt: keySavedAt[masked] !== undefined ? keySavedAt[masked] : null,
|
|
419
|
-
}
|
|
420
|
-
}).sort((a, b) => {
|
|
421
|
-
if (a.savedAt === b.savedAt) return 0
|
|
422
|
-
if (a.savedAt === null) return 1
|
|
423
|
-
if (b.savedAt === null) return -1
|
|
424
|
-
return a.savedAt < b.savedAt ? -1 : 1
|
|
425
|
-
})
|
|
426
|
-
return {
|
|
427
|
-
keys: display.map((entry) => ({
|
|
428
|
-
masked: entry.masked,
|
|
429
|
-
savedAt: entry.savedAt,
|
|
430
|
-
primary: entry.masked === primaryMasked,
|
|
431
|
-
})),
|
|
432
|
-
strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
|
|
61
|
+
await self.fiber.update({ enabled }, true)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
lastApplied = previous !== null ? previous.enabled : (config.enabled !== false)
|
|
64
|
+
restoreToolState(previous)
|
|
65
|
+
throw error
|
|
433
66
|
}
|
|
434
67
|
}
|
|
435
68
|
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
69
|
+
// First-class settings integration (rc.7 plugin management): register the
|
|
70
|
+
// `tavily-search` namespace so the Plugins configuration surface serves it
|
|
71
|
+
// and pairs this package's card. The row config is the composition base
|
|
72
|
+
// layer; the user document overlays it. Runs whether or not the tool is on.
|
|
73
|
+
const settings = ctx.get('settings')
|
|
74
|
+
if (settings !== undefined && settings !== null && typeof settings.register === 'function') {
|
|
439
75
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
76
|
+
settingsScope = settings.register(TAVILY_NS, Config, {
|
|
77
|
+
base: { enabled: config.enabled },
|
|
78
|
+
applies: 'restart',
|
|
79
|
+
})
|
|
80
|
+
settingsScope.watch((next) => {
|
|
81
|
+
const nextEnabled = next !== null && typeof next === 'object' && typeof next.enabled === 'boolean'
|
|
82
|
+
? next.enabled
|
|
83
|
+
: false
|
|
84
|
+
applySwitchLocal(nextEnabled).catch((error) => {
|
|
85
|
+
ctx.logger.warn('tavily-search: applying settings switch failed: %s', error instanceof Error ? error.message : String(error))
|
|
86
|
+
})
|
|
87
|
+
})
|
|
444
88
|
} catch (error) {
|
|
445
|
-
ctx.logger.warn('tavily-
|
|
89
|
+
ctx.logger.warn('tavily-search: settings namespace registration failed: %s', error instanceof Error ? error.message : String(error))
|
|
90
|
+
settingsScope = null
|
|
446
91
|
}
|
|
447
92
|
}
|
|
448
93
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
|
|
460
|
-
try {
|
|
461
|
-
const keys = await collectStoredKeys(credentials)
|
|
462
|
-
if (keys.length === 0) return send(res, 200, { ok: false, error: 'no Tavily API key configured' })
|
|
463
|
-
const state = readState(MANAGER_STATE)
|
|
464
|
-
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
465
|
-
const orderedKeys = keys.slice().sort((a, b) => {
|
|
466
|
-
const am = keySavedAt[maskValue(a)] !== undefined ? keySavedAt[maskValue(a)] : null
|
|
467
|
-
const bm = keySavedAt[maskValue(b)] !== undefined ? keySavedAt[maskValue(b)] : null
|
|
468
|
-
if (am === bm) return 0
|
|
469
|
-
if (am === null) return 1
|
|
470
|
-
if (bm === null) return -1
|
|
471
|
-
return am < bm ? -1 : 1
|
|
472
|
-
})
|
|
473
|
-
const perKey = []
|
|
474
|
-
for (const key of orderedKeys) {
|
|
475
|
-
try {
|
|
476
|
-
const usage = await fetchUsageDetailsFor(key)
|
|
477
|
-
perKey.push(usage !== null
|
|
478
|
-
? { ok: true, masked: maskValue(key), ...usage }
|
|
479
|
-
: { ok: false, masked: maskValue(key), error: 'usage unavailable' })
|
|
480
|
-
} catch (error) {
|
|
481
|
-
perKey.push({ ok: false, masked: maskValue(key), error: String(error && error.message ? error.message : error) })
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
const okRows = perKey.filter((row) => row.ok)
|
|
485
|
-
return send(res, 200, {
|
|
486
|
-
ok: true,
|
|
487
|
-
perKey,
|
|
488
|
-
totals: {
|
|
489
|
-
keys: keys.length,
|
|
490
|
-
okKeys: okRows.length,
|
|
491
|
-
usage: okRows.reduce((sum, row) => sum + (row.usage || 0), 0),
|
|
492
|
-
planUsage: okRows.reduce((sum, row) => sum + (row.planUsage || 0), 0),
|
|
493
|
-
planLimit: okRows.every((row) => row.planLimit !== null) ? okRows.reduce((sum, row) => sum + row.planLimit, 0) : null,
|
|
494
|
-
},
|
|
495
|
-
})
|
|
496
|
-
} catch (error) {
|
|
497
|
-
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
498
|
-
}
|
|
499
|
-
},
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
ctx.webServer.register({
|
|
503
|
-
kind: 'exact',
|
|
504
|
-
path: '/api/tavily-manager',
|
|
505
|
-
handler: async (req, res) => {
|
|
506
|
-
if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
|
|
507
|
-
try {
|
|
508
|
-
const url = new URL(req.url || '/', 'http://x')
|
|
509
|
-
if (req.method === 'GET') {
|
|
510
|
-
const reveal = url.searchParams.get('reveal')
|
|
511
|
-
const keys = await collectStoredKeys(credentials)
|
|
512
|
-
if (reveal !== null) {
|
|
513
|
-
const match = keys.find((key) => maskValue(key) === reveal)
|
|
514
|
-
if (match === undefined) return send(res, 404, { ok: false, error: 'key not found' })
|
|
515
|
-
return send(res, 200, { ok: true, value: match })
|
|
516
|
-
}
|
|
517
|
-
return send(res, 200, { ok: true, ...(await buildManagerPayload(keys)) })
|
|
518
|
-
}
|
|
519
|
-
if (req.method === 'POST') {
|
|
520
|
-
const body = await readBody(req)
|
|
521
|
-
const strategy = isValidStrategy(body.strategy) ? body.strategy : 'rotate'
|
|
522
|
-
const addValues = Array.isArray(body.add)
|
|
523
|
-
? body.add.map((item) => typeof item === 'string' ? item.trim() : '').filter((value) => value.length > 0)
|
|
524
|
-
: []
|
|
525
|
-
const removeMasked = Array.isArray(body.remove)
|
|
526
|
-
? body.remove.filter((item) => typeof item === 'string' && item.length > 0)
|
|
527
|
-
: []
|
|
528
|
-
|
|
529
|
-
let stored = await collectStoredKeys(credentials)
|
|
530
|
-
if (removeMasked.length > 0) {
|
|
531
|
-
stored = stored.filter((key) => !removeMasked.includes(maskValue(key)))
|
|
532
|
-
}
|
|
533
|
-
let values = [...stored]
|
|
534
|
-
for (const value of addValues) {
|
|
535
|
-
if (!values.includes(value)) values.push(value)
|
|
536
|
-
}
|
|
537
|
-
if (values.length === 0) {
|
|
538
|
-
await credentials.unset('TAVILY_API_KEYS')
|
|
539
|
-
await credentials.unset('TAVILY_API_KEY')
|
|
540
|
-
} else {
|
|
541
|
-
if (strategy !== 'rotate') {
|
|
542
|
-
const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
|
|
543
|
-
const usageOf = (value) => {
|
|
544
|
-
const row = usageRows.find((r) => r.value === value)
|
|
545
|
-
return row !== undefined && row.usage !== null ? row.usage : null
|
|
546
|
-
}
|
|
547
|
-
values = orderKeys(values, strategy, usageOf)
|
|
548
|
-
}
|
|
549
|
-
await credentials.set('TAVILY_API_KEYS', values.join(','))
|
|
550
|
-
await credentials.set('TAVILY_API_KEY', values[0])
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const state = readState(MANAGER_STATE)
|
|
554
|
-
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
555
|
-
const now = new Date().toISOString()
|
|
556
|
-
for (const value of values) {
|
|
557
|
-
const masked = maskValue(value)
|
|
558
|
-
if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
|
|
559
|
-
}
|
|
560
|
-
writeState(MANAGER_STATE, { keySavedAt, strategy })
|
|
561
|
-
|
|
562
|
-
return send(res, 200, { ok: true, ...(await buildManagerPayload(values)) })
|
|
563
|
-
}
|
|
564
|
-
return send(res, 405, { ok: false, error: 'method not allowed' })
|
|
565
|
-
} catch (error) {
|
|
566
|
-
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
94
|
+
installBackend(ctx, {
|
|
95
|
+
enabled: () => isToolEnabled(config),
|
|
96
|
+
async applySwitch(enabled) {
|
|
97
|
+
// Every write converges on one pipeline: the settings namespace when a
|
|
98
|
+
// settings service is served (its watcher persists and restarts this
|
|
99
|
+
// row), or the plain state-file path otherwise.
|
|
100
|
+
if (settings !== undefined && settingsScope !== null) {
|
|
101
|
+
await settings.update(TAVILY_NS, { enabled })
|
|
102
|
+
} else {
|
|
103
|
+
await applySwitchLocal(enabled)
|
|
567
104
|
}
|
|
568
105
|
},
|
|
569
106
|
})
|
|
570
107
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
path: '/api/tavily-toggle',
|
|
574
|
-
handler: async (req, res) => {
|
|
575
|
-
try {
|
|
576
|
-
if (req.method === 'GET') {
|
|
577
|
-
return send(res, 200, { ok: true, enabled: readState(TOGGLE_STATE).enabled === true })
|
|
578
|
-
}
|
|
579
|
-
if (req.method === 'POST') {
|
|
580
|
-
const body = await readBody(req)
|
|
581
|
-
const enabled = body !== null && typeof body === 'object' && body.enabled === true
|
|
582
|
-
writeState(TOGGLE_STATE, { enabled })
|
|
583
|
-
await applyProvider(enabled)
|
|
584
|
-
return send(res, 200, { ok: true, enabled })
|
|
585
|
-
}
|
|
586
|
-
return send(res, 405, { ok: false, error: 'method not allowed' })
|
|
587
|
-
} catch (error) {
|
|
588
|
-
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
589
|
-
}
|
|
590
|
-
},
|
|
591
|
-
})
|
|
108
|
+
if (!isToolEnabled(config)) return
|
|
109
|
+
installTavilyTool(ctx)
|
|
592
110
|
}
|
package/src/lib.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
}
|