@moguiyu/dsh-tavily 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,541 @@
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.
4
+ */
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, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
10
+
11
+ export const name = 'dsh-tavily'
12
+
13
+ export const inject = ['tools', 'webServer', 'credentials']
14
+
15
+ /** Clamp a number into [min, max]; `fallback` when not finite. */
16
+ export function clampInt(value, min, max, fallback) {
17
+ const n = Number(value)
18
+ if (!Number.isFinite(n)) return fallback
19
+ return Math.min(max, Math.max(min, Math.round(n)))
20
+ }
21
+
22
+ /** Normalize and validate model arguments (exported for tests). */
23
+ export function normalizeArgs(args) {
24
+ const query = typeof args.query === 'string' ? args.query.trim() : ''
25
+ if (query.length === 0) throw new Error('tavily_search: query must be a non-empty string')
26
+ const maxResults = clampInt(args.max_results, 1, 20, 5)
27
+ const searchDepth = args.search_depth === 'advanced' ? 'advanced' : 'basic'
28
+ const topic = args.topic === 'news' ? 'news' : 'general'
29
+ const days = args.days === undefined ? undefined : clampInt(args.days, 1, 30, undefined)
30
+ const includeAnswer = args.include_answer === true
31
+ const includeRawContent = args.include_raw_content === true
32
+ const includeDomains = Array.isArray(args.include_domains)
33
+ ? args.include_domains.filter((d) => typeof d === 'string' && d.length > 0)
34
+ : []
35
+ const excludeDomains = Array.isArray(args.exclude_domains)
36
+ ? args.exclude_domains.filter((d) => typeof d === 'string' && d.length > 0)
37
+ : []
38
+ return { query, maxResults, searchDepth, topic, days, includeAnswer, includeRawContent, includeDomains, excludeDomains }
39
+ }
40
+
41
+ function hostnameOf(url) {
42
+ try {
43
+ return new URL(url).hostname
44
+ } catch {
45
+ return url
46
+ }
47
+ }
48
+
49
+ /** Combine a caller signal with a hard timeout; call dispose() after the request. */
50
+ function combineSignals(signal, ms) {
51
+ const controller = new AbortController()
52
+ const timer = setTimeout(() => controller.abort(), ms)
53
+ const onAbort = () => controller.abort()
54
+ if (signal !== undefined && signal !== null) {
55
+ if (signal.aborted) controller.abort()
56
+ else signal.addEventListener('abort', onAbort, { once: true })
57
+ }
58
+ return {
59
+ signal: controller.signal,
60
+ dispose() {
61
+ clearTimeout(timer)
62
+ if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
63
+ },
64
+ }
65
+ }
66
+
67
+ function buildBody(input) {
68
+ const body = {
69
+ query: input.query,
70
+ max_results: input.maxResults,
71
+ search_depth: input.searchDepth,
72
+ }
73
+ if (input.topic === 'news') {
74
+ body.topic = 'news'
75
+ if (input.days !== undefined) body.days = input.days
76
+ }
77
+ if (input.includeAnswer) body.include_answer = true
78
+ if (input.includeRawContent) body.include_raw_content = true
79
+ if (input.includeDomains.length > 0) body.include_domains = input.includeDomains
80
+ if (input.excludeDomains.length > 0) body.exclude_domains = input.excludeDomains
81
+ return body
82
+ }
83
+
84
+ async function callTavily(input, key, exec) {
85
+ const combined = combineSignals(exec.signal, 30000)
86
+ try {
87
+ const response = await fetch('https://api.tavily.com/search', {
88
+ method: 'POST',
89
+ headers: {
90
+ 'content-type': 'application/json',
91
+ accept: 'application/json',
92
+ authorization: 'Bearer ' + key,
93
+ 'user-agent': 'dsh-tool-tavily-search/0.1.0',
94
+ },
95
+ body: JSON.stringify(buildBody(input)),
96
+ signal: combined.signal,
97
+ })
98
+ if (response.status === 401 || response.status === 429) {
99
+ return { kind: 'retryable', statusCode: response.status }
100
+ }
101
+ if (!response.ok) {
102
+ let message = 'HTTP ' + response.status
103
+ try {
104
+ const parsed = await response.json()
105
+ if (parsed !== null && typeof parsed === 'object') {
106
+ if (typeof parsed.error === 'string') message = parsed.error
107
+ else if (parsed.error !== null && typeof parsed.error === 'object' && typeof parsed.error.message === 'string') message = parsed.error.message
108
+ }
109
+ } catch {
110
+ /* keep the status message */
111
+ }
112
+ throw new Error('tavily_search: ' + message)
113
+ }
114
+ return { kind: 'ok', statusCode: response.status, json: await response.json() }
115
+ } catch (error) {
116
+ if (error instanceof Error && error.name === 'AbortError') {
117
+ throw new Error('tavily_search: request timed out or was aborted')
118
+ }
119
+ throw error
120
+ } finally {
121
+ combined.dispose()
122
+ }
123
+ }
124
+
125
+ function projectResults(json) {
126
+ const results = Array.isArray(json.results) ? json.results : []
127
+ const mapped = results.map((r) => {
128
+ const item = { url: String(r.url !== undefined && r.url !== null ? r.url : '') }
129
+ if (typeof r.title === 'string' && r.title.length > 0) item.title = r.title
130
+ if (typeof r.content === 'string' && r.content.length > 0) item.content = r.content
131
+ if (typeof r.score === 'number') item.score = r.score
132
+ if (typeof r.published_date === 'string' && r.published_date.length > 0) item.publishedAt = r.published_date
133
+ if (typeof r.raw_content === 'string' && r.raw_content.length > 0) {
134
+ item.rawContent = r.raw_content.length > 4000 ? r.raw_content.slice(0, 4000) + '…' : r.raw_content
135
+ }
136
+ return item
137
+ })
138
+ const value = { results: mapped }
139
+ if (typeof json.answer === 'string' && json.answer.length > 0) value.answer = json.answer
140
+ return value
141
+ }
142
+
143
+ function formatOutput(value) {
144
+ const parts = []
145
+ if (typeof value.answer === 'string' && value.answer.length > 0) parts.push(value.answer)
146
+ if (value.results.length > 0) {
147
+ const lines = value.results.map((r) => {
148
+ const label = typeof r.title === 'string' && r.title.length > 0 ? r.title : hostnameOf(r.url)
149
+ const meta = []
150
+ if (typeof r.content === 'string' && r.content.length > 0) meta.push(r.content)
151
+ if (typeof r.publishedAt === 'string' && r.publishedAt.length > 0) meta.push('(' + r.publishedAt + ')')
152
+ if (typeof r.score === 'number') meta.push('(relevance ' + r.score.toFixed(2) + ')')
153
+ const suffix = meta.length > 0 ? ' — ' + meta.join(' ') : ''
154
+ return '- [' + label + '](' + r.url + ')' + suffix
155
+ })
156
+ parts.push('Sources:\n' + lines.join('\n'))
157
+ } else {
158
+ parts.push('No results found.')
159
+ }
160
+ parts.push('Cite the relevant URLs above as markdown links in your answer.')
161
+ return parts.join('\n\n')
162
+ }
163
+
164
+ export function apply(ctx) {
165
+ applyBackend(ctx)
166
+
167
+ let rotation = 0
168
+
169
+ async function resolveKeys() {
170
+ const credentials = ctx.get('credentials')
171
+ if (credentials !== undefined) {
172
+ try {
173
+ const resolved = await credentials.resolve('TAVILY_API_KEYS')
174
+ if (resolved !== undefined && typeof resolved.value === 'string' && resolved.value.length > 0) {
175
+ const list = resolved.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
176
+ if (list.length > 0) return { keys: list, source: resolved.source }
177
+ }
178
+ } catch (error) {
179
+ ctx.logger.warn('tavily_search: credential resolution failed: %s', error instanceof Error ? error.message : String(error))
180
+ }
181
+ }
182
+ return { keys: [], source: 'unconfigured' }
183
+ }
184
+
185
+ async function execute(args, exec) {
186
+ const input = normalizeArgs(args)
187
+ const { keys, source } = await resolveKeys()
188
+ if (keys.length === 0) {
189
+ 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)')
190
+ }
191
+ const attempts = keys.length
192
+ let lastRetryable = 0
193
+ for (let attempt = 0; attempt < attempts; attempt++) {
194
+ const index = (rotation + attempt) % keys.length
195
+ const key = keys[index]
196
+ const started = Date.now()
197
+ const outcome = await callTavily(input, key, exec)
198
+ if (outcome.kind === 'ok') {
199
+ rotation = (index + 1) % keys.length
200
+ const count = Array.isArray(outcome.json.results) ? outcome.json.results.length : 0
201
+ ctx.logger.info('tavily_search: ok %j', { query: input.query, status: outcome.statusCode, results: count, ms: Date.now() - started, keys: keys.length, source })
202
+ return projectResults(outcome.json)
203
+ }
204
+ lastRetryable = outcome.statusCode
205
+ rotation = (index + 1) % keys.length
206
+ ctx.logger.info('tavily_search: key %d/%d returned HTTP %d, rotating', index + 1, keys.length, outcome.statusCode)
207
+ }
208
+ throw new Error('tavily_search: all ' + attempts + ' configured key(s) failed with HTTP ' + lastRetryable + ' (invalid key or rate limit)')
209
+ }
210
+
211
+ ctx.tools.register(defineTool({
212
+ name: 'tavily_search',
213
+ 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.',
214
+ parameters: {
215
+ query: { type: 'string', required: true, description: 'The search query.' },
216
+ max_results: { type: 'number', description: 'Number of results to return, 1-20 (default 5).' },
217
+ search_depth: { type: 'string', enum: ['basic', 'advanced'], description: 'basic is faster and cheaper; advanced returns deeper analysis (default basic).' },
218
+ topic: { type: 'string', enum: ['general', 'news'], description: 'Search scope: general web or news (default general).' },
219
+ days: { type: 'number', description: 'News freshness window in days, e.g. 7 for the last week; only applies when topic is news.' },
220
+ include_answer: { type: 'boolean', description: 'Ask Tavily to generate a concise answer to the query (default false).' },
221
+ include_raw_content: { type: 'boolean', description: 'Include raw page content for each result (default false; capped per result).' },
222
+ include_domains: { type: 'array', items: { type: 'string' }, description: 'Only search within these domains.' },
223
+ exclude_domains: { type: 'array', items: { type: 'string' }, description: 'Exclude these domains from results.' },
224
+ },
225
+ output: {
226
+ schema: {
227
+ type: 'object',
228
+ additionalProperties: false,
229
+ properties: {
230
+ answer: { type: 'string' },
231
+ results: {
232
+ type: 'array',
233
+ required: true,
234
+ items: {
235
+ type: 'object',
236
+ additionalProperties: false,
237
+ properties: {
238
+ url: { type: 'string', required: true },
239
+ title: { type: 'string' },
240
+ content: { type: 'string' },
241
+ score: { type: 'number' },
242
+ publishedAt: { type: 'string' },
243
+ rawContent: { type: 'string' },
244
+ },
245
+ },
246
+ },
247
+ },
248
+ },
249
+ render: (_args, value) => [{ type: 'text', text: formatOutput(value) }],
250
+ },
251
+ timeoutMs: 30000,
252
+ isConcurrencySafe: () => true,
253
+ execute,
254
+ }))
255
+
256
+ const systemPrompt = ctx.get('systemPrompt')
257
+ if (systemPrompt !== undefined) {
258
+ systemPrompt.section({
259
+ name: 'tool:tavily_search',
260
+ order: 111,
261
+ 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.',
262
+ })
263
+ }
264
+
265
+ const credentials = ctx.get('credentials')
266
+ if (credentials !== undefined) {
267
+ credentials.describe('TAVILY_API_KEYS').then((info) => {
268
+ if (!info.configured) {
269
+ ctx.logger.warn('tavily_search: TAVILY_API_KEYS credential is not configured; add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml')
270
+ }
271
+ }).catch((error) => {
272
+ ctx.logger.warn('tavily_search: credential describe failed: %s', error instanceof Error ? error.message : String(error))
273
+ })
274
+ }
275
+ }
276
+ const MANAGER_STATE = 'tavily-manager.json'
277
+ const TOGGLE_STATE = 'tavily-toggle.json'
278
+ const LEGACY_STATE = 'tavily-settings.json'
279
+ const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
280
+
281
+ function statePath(file) {
282
+ return join(resolveDshHome(), file)
283
+ }
284
+
285
+ function readState(file) {
286
+ return readJsonFile(statePath(file), {})
287
+ }
288
+
289
+ function writeState(file, state) {
290
+ writeFileSync(statePath(file), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
291
+ }
292
+
293
+ function readBody(req) {
294
+ return new Promise((resolve, reject) => {
295
+ const chunks = []
296
+ req.on('data', (chunk) => chunks.push(chunk))
297
+ req.on('end', () => {
298
+ const text = Buffer.concat(chunks).toString('utf8')
299
+ try {
300
+ resolve(text.length > 0 ? JSON.parse(text) : {})
301
+ } catch {
302
+ reject(new Error('invalid JSON body'))
303
+ }
304
+ })
305
+ req.on('error', reject)
306
+ })
307
+ }
308
+
309
+ async function fetchUsageFor(key) {
310
+ try {
311
+ const controller = new AbortController()
312
+ const timer = setTimeout(() => controller.abort(), 15000)
313
+ let response
314
+ try {
315
+ response = await fetch('https://api.tavily.com/usage', {
316
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
317
+ signal: controller.signal,
318
+ })
319
+ } finally {
320
+ clearTimeout(timer)
321
+ }
322
+ if (!response.ok) return null
323
+ const json = await response.json()
324
+ const account = json !== null && typeof json === 'object' ? json.account : undefined
325
+ const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
326
+ if (account !== undefined && typeof account.plan_usage === 'number') return account.plan_usage
327
+ if (keyUsage !== undefined && typeof keyUsage.usage === 'number') return keyUsage.usage
328
+ return null
329
+ } catch {
330
+ return null
331
+ }
332
+ }
333
+
334
+ async function collectStoredKeys(credentials) {
335
+ const out = []
336
+ const seen = new Set()
337
+ for (const ref of REFS) {
338
+ let hit
339
+ try {
340
+ hit = await credentials.resolve(ref)
341
+ } catch {
342
+ continue
343
+ }
344
+ if (hit === undefined || typeof hit.value !== 'string') continue
345
+ for (const key of parseKeyList(hit.value)) {
346
+ if (!seen.has(key)) {
347
+ seen.add(key)
348
+ out.push(key)
349
+ }
350
+ }
351
+ }
352
+ return out
353
+ }
354
+
355
+ function applyBackend(ctx) {
356
+ const credentials = ctx.get('credentials')
357
+ const loader = ctx.get('loader')
358
+
359
+ const send = (res, status, payload) => {
360
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
361
+ res.end(JSON.stringify(payload))
362
+ }
363
+
364
+ // ── key manager payload ────────────────────────────────────────────────────
365
+ async function buildManagerPayload(keys) {
366
+ const state = readState(MANAGER_STATE)
367
+ const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
368
+ if (Object.keys(keySavedAt).length === 0 && credentials !== undefined) {
369
+ // Migrate dates recorded by the pre-0.2 layout (ref-keyed legacy file).
370
+ const legacy = readJsonFile(statePath(LEGACY_STATE), null)
371
+ if (legacy !== null && typeof legacy === 'object') {
372
+ for (const ref of REFS) {
373
+ const entry = legacy[ref]
374
+ if (entry !== null && typeof entry === 'object' && typeof entry.savedAt === 'string') {
375
+ try {
376
+ const hit = await credentials.resolve(ref)
377
+ if (hit !== undefined) keySavedAt[maskValue(hit.value)] = entry.savedAt
378
+ } catch {
379
+ /* keep going */
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+ return {
386
+ keys: keys.map((value, index) => ({
387
+ masked: maskValue(value),
388
+ savedAt: keySavedAt[maskValue(value)] !== undefined ? keySavedAt[maskValue(value)] : null,
389
+ primary: index === 0,
390
+ })),
391
+ strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
392
+ }
393
+ }
394
+
395
+ // ── persisted on/off + live provider flip ───────────────────────────────────
396
+ async function applyProvider(enabled) {
397
+ if (loader === undefined) return
398
+ try {
399
+ const entry = loader.resolve('include:web')
400
+ if (entry !== undefined && entry !== null && entry.fiber !== undefined) {
401
+ await entry.fiber.update({ searchProvider: enabled ? 'tavily' : 'deepseek-official' }, true)
402
+ }
403
+ } catch (error) {
404
+ ctx.logger.warn('tavily-backend: web provider flip failed: %s', error instanceof Error ? error.message : String(error))
405
+ }
406
+ }
407
+
408
+ // Re-apply the persisted choice at activation (default: enabled).
409
+ const toggleState = readState(TOGGLE_STATE)
410
+ if (toggleState.enabled === false) {
411
+ void applyProvider(false)
412
+ }
413
+
414
+ ctx.webServer.register({
415
+ kind: 'exact',
416
+ path: '/api/tavily-usage',
417
+ handler: async (_req, res) => {
418
+ if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
419
+ try {
420
+ const keys = await collectStoredKeys(credentials)
421
+ if (keys.length === 0) return send(res, 200, { ok: false, error: 'no Tavily API key configured' })
422
+ const perKey = []
423
+ for (const key of keys) {
424
+ try {
425
+ const usage = await fetchUsageFor(key)
426
+ perKey.push(usage !== null
427
+ ? { ok: true, usage, planUsage: usage, planLimit: null, currentPlan: null }
428
+ : { ok: false, error: 'usage unavailable' })
429
+ } catch (error) {
430
+ perKey.push({ ok: false, error: String(error && error.message ? error.message : error) })
431
+ }
432
+ }
433
+ const okRows = perKey.filter((row) => row.ok)
434
+ return send(res, 200, {
435
+ ok: true,
436
+ perKey,
437
+ totals: {
438
+ keys: keys.length,
439
+ okKeys: okRows.length,
440
+ usage: okRows.reduce((sum, row) => sum + (row.usage || 0), 0),
441
+ planUsage: null,
442
+ planLimit: null,
443
+ },
444
+ })
445
+ } catch (error) {
446
+ send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
447
+ }
448
+ },
449
+ })
450
+
451
+ ctx.webServer.register({
452
+ kind: 'exact',
453
+ path: '/api/tavily-manager',
454
+ handler: async (req, res) => {
455
+ if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
456
+ try {
457
+ const url = new URL(req.url || '/', 'http://x')
458
+ if (req.method === 'GET') {
459
+ const reveal = url.searchParams.get('reveal')
460
+ const keys = await collectStoredKeys(credentials)
461
+ if (reveal !== null) {
462
+ const match = keys.find((key) => maskValue(key) === reveal)
463
+ if (match === undefined) return send(res, 404, { ok: false, error: 'key not found' })
464
+ return send(res, 200, { ok: true, value: match })
465
+ }
466
+ return send(res, 200, { ok: true, ...(await buildManagerPayload(keys)) })
467
+ }
468
+ if (req.method === 'POST') {
469
+ const body = await readBody(req)
470
+ const strategy = isValidStrategy(body.strategy) ? body.strategy : 'rotate'
471
+ const addValues = Array.isArray(body.add)
472
+ ? body.add.map((item) => typeof item === 'string' ? item.trim() : '').filter((value) => value.length > 0)
473
+ : []
474
+ const removeMasked = Array.isArray(body.remove)
475
+ ? body.remove.filter((item) => typeof item === 'string' && item.length > 0)
476
+ : []
477
+
478
+ let stored = await collectStoredKeys(credentials)
479
+ if (removeMasked.length > 0) {
480
+ stored = stored.filter((key) => !removeMasked.includes(maskValue(key)))
481
+ }
482
+ let values = [...stored]
483
+ for (const value of addValues) {
484
+ if (!values.includes(value)) values.push(value)
485
+ }
486
+ if (values.length === 0) {
487
+ await credentials.unset('TAVILY_API_KEYS')
488
+ await credentials.unset('TAVILY_API_KEY')
489
+ } else {
490
+ if (strategy !== 'rotate') {
491
+ const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
492
+ const usageOf = (value) => {
493
+ const row = usageRows.find((r) => r.value === value)
494
+ return row !== undefined && row.usage !== null ? row.usage : null
495
+ }
496
+ values = orderKeys(values, strategy, usageOf)
497
+ }
498
+ await credentials.set('TAVILY_API_KEYS', values.join(','))
499
+ await credentials.set('TAVILY_API_KEY', values[0])
500
+ }
501
+
502
+ const state = readState(MANAGER_STATE)
503
+ const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
504
+ const now = new Date().toISOString()
505
+ for (const value of values) {
506
+ const masked = maskValue(value)
507
+ if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
508
+ }
509
+ writeState(MANAGER_STATE, { keySavedAt, strategy })
510
+
511
+ return send(res, 200, { ok: true, ...(await buildManagerPayload(values)) })
512
+ }
513
+ return send(res, 405, { ok: false, error: 'method not allowed' })
514
+ } catch (error) {
515
+ send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
516
+ }
517
+ },
518
+ })
519
+
520
+ ctx.webServer.register({
521
+ kind: 'exact',
522
+ path: '/api/tavily-toggle',
523
+ handler: async (req, res) => {
524
+ try {
525
+ if (req.method === 'GET') {
526
+ return send(res, 200, { ok: true, enabled: readState(TOGGLE_STATE).enabled === true })
527
+ }
528
+ if (req.method === 'POST') {
529
+ const body = await readBody(req)
530
+ const enabled = body !== null && typeof body === 'object' && body.enabled === true
531
+ writeState(TOGGLE_STATE, { enabled })
532
+ await applyProvider(enabled)
533
+ return send(res, 200, { ok: true, enabled })
534
+ }
535
+ return send(res, 405, { ok: false, error: 'method not allowed' })
536
+ } catch (error) {
537
+ send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
538
+ }
539
+ },
540
+ })
541
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Package-owned invariant companion for `@moguiyu/dsh-tavily`.
3
+ */
4
+ const PACKAGE_NAME = '@moguiyu/dsh-tavily'
5
+
6
+ /** Cordis companion plugin name. */
7
+ const name = 'dsh-tavily-invariant'
8
+
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ['invariants']
11
+
12
+ /** No runtime invariant: this tool has no independent lifecycle stream. */
13
+ const install = () => {}
14
+
15
+ /** Register this package's invariant companion. */
16
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
17
+
18
+ export { apply, inject, name }
package/src/lib.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure helpers for the Tavily backend (exported for tests).
3
+ */
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ export const STRATEGIES = ['rotate', 'low-usage-first', 'high-usage-first']
7
+
8
+ export function isValidStrategy(value) {
9
+ return STRATEGIES.includes(value)
10
+ }
11
+
12
+ /**
13
+ * Mask a key for display: `first12…last4`; comma-separated lists are masked
14
+ * per part. Never reveals more than a fingerprint.
15
+ */
16
+ export function maskValue(value) {
17
+ if (typeof value !== 'string' || value.length === 0) return ''
18
+ return value.split(',').map((part) => {
19
+ const p = part.trim()
20
+ if (p.length <= 12) return '••••'
21
+ return p.slice(0, 12) + '…' + p.slice(-4)
22
+ }).join(', ')
23
+ }
24
+
25
+ /** Split a comma-separated credential value into trimmed, deduped keys. */
26
+ export function parseKeyList(value) {
27
+ if (typeof value !== 'string') return []
28
+ const seen = new Set()
29
+ const out = []
30
+ for (const part of value.split(',')) {
31
+ const key = part.trim()
32
+ if (key.length > 0 && !seen.has(key)) {
33
+ seen.add(key)
34
+ out.push(key)
35
+ }
36
+ }
37
+ return out
38
+ }
39
+
40
+ /**
41
+ * Order keys according to a strategy. `usageOf(key)` returns a number or null
42
+ * (unknown usage is treated as 0). `rotate` keeps the given order.
43
+ */
44
+ export function orderKeys(values, strategy, usageOf) {
45
+ if (strategy === 'rotate') return [...values]
46
+ const usageOfKey = (value) => {
47
+ const usage = usageOf(value)
48
+ return usage !== null && typeof usage === 'number' ? usage : 0
49
+ }
50
+ const sorted = [...values]
51
+ sorted.sort((a, b) => strategy === 'low-usage-first'
52
+ ? usageOfKey(a) - usageOfKey(b)
53
+ : usageOfKey(b) - usageOfKey(a))
54
+ return sorted
55
+ }
56
+
57
+ /** Read a JSON object file; `fallback` on missing or unparsable content. */
58
+ export function readJsonFile(path, fallback) {
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
61
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
62
+ } catch {
63
+ return fallback
64
+ }
65
+ }