@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 CHANGED
@@ -1,18 +1,27 @@
1
- # @moguiyu/dsh-tool-tavily-search
1
+ # @moguiyu/dsh-tavily
2
2
 
3
- Registers the `tavily_search` model tool and the **Tavily Search** settings card for the
4
- DeepSeek Harness web UI.
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`). Keys are
8
- resolved from the `TAVILY_API_KEYS` credential on every call; round-robin rotation with
9
- failover on HTTP 401/429.
10
- - **Settings card** (`settings.plugin.item`, id `tavily-search`) — expandable card matching
11
- the native plugin-config design: flat key list (masked values, saved dates, show/edit/delete
12
- icons), key-usage strategy selector, usage gauge, and the on/off switch for the `web`
13
- provider.
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-toggle', { cache: 'no-store' })
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-toggle', {
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 ? 'Tavily search enabled — web_search uses Tavily.' : 'Tavily search off — web_search uses the native provider (DeepSeek). No Tavily key is needed.' })
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' } }, 'Tavily-backed web_search · key rotation · usage')
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: 'Tavily search enabled', onChange: toggleEnabled }),
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
- 'Tavily search is off — web_search uses the native provider (DeepSeek). No Tavily key is needed.'
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 key marked with the green dot is the primary key used by web_search. The tavily_search tool uses all keys according to the strategy. Changes take effect on the next search.'
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 — used by web_search',
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.10",
4
- "description": "Tavily Search for DSH: multiple API keys, rotation/failover, usage gauge, settings card, and web_search toggle in one plugin.",
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.6",
56
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
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-toggle', { cache: 'no-store' })
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-toggle', {
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 ? 'Tavily search enabled — web_search uses Tavily.' : 'Tavily search off — web_search uses the native provider (DeepSeek). No Tavily key is needed.' })
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' } }, 'Tavily-backed web_search · key rotation · usage')
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: 'Tavily search enabled', onChange: toggleEnabled }),
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
- 'Tavily search is off — web_search uses the native provider (DeepSeek). No Tavily key is needed.'
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 key marked with the green dot is the primary key used by web_search. The tavily_search tool uses all keys according to the strategy. Changes take effect on the next search.'
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 — used by web_search',
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 host half: `tavily_search` model tool plus
3
- * the local HTTP backend for usage, key management, and the web provider toggle.
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 { readFileSync, writeFileSync } from 'node:fs'
6
- import { join } from 'node:path'
7
- import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
8
- import { defineTool } from '@deepseek-ai/dsh-tools'
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 USAGE_TTL_MS = 60000
16
- const usageCache = new Map()
32
+ export const Config = z.object({
33
+ enabled: z.boolean().default(false),
34
+ })
17
35
 
18
- /** Clamp a number into [min, max]; `fallback` when not finite. */
19
- export function clampInt(value, min, max, fallback) {
20
- const n = Number(value)
21
- if (!Number.isFinite(n)) return fallback
22
- return Math.min(max, Math.max(min, Math.round(n)))
23
- }
24
-
25
- /** Normalize and validate model arguments (exported for tests). */
26
- export function normalizeArgs(args) {
27
- const query = typeof args.query === 'string' ? args.query.trim() : ''
28
- if (query.length === 0) throw new Error('tavily_search: query must be a non-empty string')
29
- const maxResults = clampInt(args.max_results, 1, 20, 5)
30
- const searchDepth = args.search_depth === 'advanced' ? 'advanced' : 'basic'
31
- const topic = args.topic === 'news' ? 'news' : 'general'
32
- const days = args.days === undefined ? undefined : clampInt(args.days, 1, 30, undefined)
33
- const includeAnswer = args.include_answer === true
34
- const includeRawContent = args.include_raw_content === true
35
- const includeDomains = Array.isArray(args.include_domains)
36
- ? args.include_domains.filter((d) => typeof d === 'string' && d.length > 0)
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
- hit = await credentials.resolve(ref)
369
- } catch {
370
- continue
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
- const primaryMasked = keys.length > 0 ? maskValue(keys[0]) : null
414
- const display = keys.map((value) => {
415
- const masked = maskValue(value)
416
- return {
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
- // ── persisted on/off + live provider flip ───────────────────────────────────
437
- async function applyProvider(enabled) {
438
- if (loader === undefined) return
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
- const entry = loader.resolve('include:web')
441
- if (entry !== undefined && entry !== null && entry.fiber !== undefined) {
442
- await entry.fiber.update({ searchProvider: enabled ? 'tavily' : 'deepseek-official' }, true)
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-backend: web provider flip failed: %s', error instanceof Error ? error.message : String(error))
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
- // Re-apply the persisted choice at activation (default: enabled).
450
- const toggleState = readState(TOGGLE_STATE)
451
- if (toggleState.enabled === false) {
452
- void applyProvider(false)
453
- }
454
-
455
- ctx.webServer.register({
456
- kind: 'exact',
457
- path: '/api/tavily-usage',
458
- handler: async (_req, res) => {
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
- ctx.webServer.register({
572
- kind: 'exact',
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
- }