argo-search 2.9.7 → 2.9.9

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.
Files changed (154) hide show
  1. package/README.en.md +73 -150
  2. package/README.es.md +73 -150
  3. package/README.ja.md +79 -156
  4. package/README.ko.md +78 -155
  5. package/README.md +80 -159
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +78 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +269 -63
  15. package/docs/ARGO_INTRO.md +5 -3
  16. package/docs/ENGINE_CATALOG.md +50 -39
  17. package/docs/MCP_SETUP.md +3 -1
  18. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  19. package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
  20. package/engines/specs/cfets.yaml +41 -0
  21. package/engines/specs/defillama.yaml +38 -0
  22. package/engines/specs/firecrawl.yaml +1 -1
  23. package/engines/specs/fxtwitter.yaml +5 -0
  24. package/engines/specs/gleif.yaml +55 -0
  25. package/engines/specs/hkex.yaml +40 -0
  26. package/engines/specs/keenable_free.yaml +36 -0
  27. package/engines/specs/shfe.yaml +39 -0
  28. package/engines/specs/stackexchange.yaml +2 -2
  29. package/engines/specs/tavily_free.yaml +56 -0
  30. package/engines/specs/yuanbao.yaml +93 -0
  31. package/mcp/clients.yaml +32 -0
  32. package/package.json +7 -2
  33. package/packages/dsh-plugin/README.md +25 -6
  34. package/packages/dsh-plugin/dsh/client.js +402 -47
  35. package/packages/dsh-plugin/dsh/diagnostics.js +97 -0
  36. package/packages/dsh-plugin/dsh/index.js +135 -1876
  37. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  38. package/packages/dsh-plugin/dsh/runner.js +666 -0
  39. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  40. package/packages/dsh-plugin/package.json +3 -3
  41. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  42. package/packages/dsh-plugin/test/client.test.js +177 -2
  43. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  44. package/packages/dsh-plugin/test/index.test.js +44 -0
  45. package/scripts/adaptive.py +126 -43
  46. package/scripts/article_meta.py +131 -0
  47. package/scripts/bench_tail_ab.py +281 -0
  48. package/scripts/bounded_run.py +91 -49
  49. package/scripts/budget.py +247 -0
  50. package/scripts/budget_cli.py +93 -0
  51. package/scripts/build_release_assets.sh +65 -0
  52. package/scripts/cache.py +60 -72
  53. package/scripts/ci_drift_gate.py +90 -0
  54. package/scripts/clock_bridge.py +22 -0
  55. package/scripts/content_boundary.py +46 -0
  56. package/scripts/content_security.py +119 -4
  57. package/scripts/content_signals.py +26 -5
  58. package/scripts/cross_lingual_trace.py +293 -0
  59. package/scripts/dispatch_result.py +105 -0
  60. package/scripts/engine_add.py +602 -0
  61. package/scripts/engine_dispatch.py +58 -86
  62. package/scripts/engine_env.py +5 -0
  63. package/scripts/engine_families.py +6 -0
  64. package/scripts/engine_policy.py +7 -7
  65. package/scripts/engines.py +21 -0
  66. package/scripts/engines_base.py +3 -3
  67. package/scripts/engines_builders.py +12 -0
  68. package/scripts/engines_builders_batch10.py +48 -3
  69. package/scripts/engines_builders_batch14.py +656 -0
  70. package/scripts/engines_builders_batch15.py +249 -0
  71. package/scripts/engines_builders_keyless.py +111 -0
  72. package/scripts/engines_builders_tech.py +0 -1
  73. package/scripts/evidence.py +31 -3
  74. package/scripts/evidence_loop.py +58 -22
  75. package/scripts/fetch_quality.py +13 -0
  76. package/scripts/fetch_v3.py +28 -3
  77. package/scripts/gen_routing_snapshot.py +11 -0
  78. package/scripts/http_client.py +24 -38
  79. package/scripts/install.ps1 +18 -0
  80. package/scripts/install.sh +16 -1
  81. package/scripts/job.py +3 -3
  82. package/scripts/local_seek.py +41 -5
  83. package/scripts/macro_countries.py +15 -0
  84. package/scripts/matrix_search_eval.py +192 -0
  85. package/scripts/mcp_compact.py +234 -0
  86. package/scripts/mcp_handlers.py +60 -642
  87. package/scripts/mcp_launch.sh +10 -8
  88. package/scripts/mcp_local.py +241 -0
  89. package/scripts/mcp_server.py +19 -14
  90. package/scripts/mcp_setup.py +33 -9
  91. package/scripts/mcp_tools.py +1 -0
  92. package/scripts/mcp_transport.py +1 -1
  93. package/scripts/mcp_warm.py +127 -0
  94. package/scripts/net_proxy.py +27 -0
  95. package/scripts/output_transparency.py +200 -0
  96. package/scripts/paid_policy.py +98 -0
  97. package/scripts/quota.py +158 -13
  98. package/scripts/recovery.py +7 -3
  99. package/scripts/recovery_runner.py +150 -0
  100. package/scripts/replay_eval.py +18 -0
  101. package/scripts/research.py +37 -21
  102. package/scripts/research_expand.py +24 -1
  103. package/scripts/retry_policy.py +76 -0
  104. package/scripts/route.py +6 -6
  105. package/scripts/route_cache.py +18 -0
  106. package/scripts/route_combo.py +26 -5
  107. package/scripts/route_domains.py +170 -31
  108. package/scripts/safe_conn.py +52 -0
  109. package/scripts/search.py +15 -27
  110. package/scripts/search_attribution.py +100 -3
  111. package/scripts/search_cli.py +24 -11
  112. package/scripts/search_entry.py +8 -1
  113. package/scripts/search_output.py +99 -15
  114. package/scripts/search_pipeline.py +62 -195
  115. package/scripts/search_rank.py +6 -0
  116. package/scripts/search_types.py +107 -9
  117. package/scripts/semantic_evidence.py +179 -3
  118. package/scripts/serp_guard.py +138 -7
  119. package/scripts/serp_kill_log.py +25 -2
  120. package/scripts/stats_cli.py +13 -1
  121. package/scripts/tail_completion.py +212 -0
  122. package/scripts/url_safety.py +126 -3
  123. package/scripts/usage_log.py +45 -0
  124. package/scripts/wide_research.py +45 -0
  125. package/scripts/zhihu_quota.py +139 -0
  126. package/sub-skills/local-search/bm25.py +27 -3
  127. package/sub-skills/local-search/cli_runner.py +331 -0
  128. package/sub-skills/local-search/parsers.py +328 -0
  129. package/sub-skills/local-search/search_v3.py +31 -674
  130. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  131. package/assets/readme/settings-keys.png +0 -0
  132. package/assets/readme/settings-tools.png +0 -0
  133. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  134. package/sub-skills/ego-search/references/install.md +0 -67
  135. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  136. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  137. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  138. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  139. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  140. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  141. package/sub-skills/local-seek/references/git-integration.md +0 -54
  142. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  143. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  144. package/sub-skills/local-seek/references/strategies.md +0 -52
  145. package/sub-skills/local-seek/references/structural-search.md +0 -59
  146. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  147. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  148. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  149. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  150. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  151. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  152. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  153. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  154. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -375,6 +375,11 @@ export const NATIVE_TOOLS = {
375
375
  "type": "boolean",
376
376
  "description": "强制用反检测浏览器(HTTP 失败时已自动升级)",
377
377
  "default": false
378
+ },
379
+ "include_links": {
380
+ "type": "boolean",
381
+ "description": "附带正文内链接(最多 30 条;默认只回 links_count,省 token)",
382
+ "default": false
378
383
  }
379
384
  },
380
385
  "required": [
@@ -388,7 +393,8 @@ export const NATIVE_TOOLS = {
388
393
  "extract_mode",
389
394
  "focus",
390
395
  "max_chars",
391
- "use_browser"
396
+ "use_browser",
397
+ "include_links"
392
398
  ]
393
399
  },
394
400
  "argo_screenshot": {
@@ -0,0 +1,666 @@
1
+ /*
2
+ * runner.js — argo-dsh 执行与连接池层(Runner & Connection Pool)
3
+ *
4
+ * 负责:
5
+ * 1. 常驻 stdio MCP 连接池(按需增长、并发独立、空闲回收、零泄漏)
6
+ * 2. 原生一等工具(makeNativeTool / runNativeCall)单发兜底与 MCP 优先调度
7
+ * 3. 宿主 web_search & web_fetch seam 结果映射与错误分类
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { readFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { NATIVE_TOOLS as NATIVE_TOOL_SPECS } from './native-tools.mjs'
16
+
17
+ function resolvePluginVersion() {
18
+ try {
19
+ const here = dirname(fileURLToPath(import.meta.url))
20
+ const raw = readFileSync(join(here, '..', 'package.json'), 'utf8')
21
+ const parsed = JSON.parse(raw)
22
+ if (typeof parsed?.version === 'string' && parsed.version !== '') return parsed.version
23
+ } catch { /* 打包裁剪/权限异常:回退,不阻断插件加载 */ }
24
+ return '0.0.0-unknown'
25
+ }
26
+
27
+ export const PLUGIN_VERSION = resolvePluginVersion()
28
+
29
+ export const MCP_CHILD_TOOLS = Object.keys(NATIVE_TOOL_SPECS).map(name => `mcp__argo__${name}`)
30
+ export const DEFAULT_CHILD_TOOLS = Object.freeze([
31
+ ...MCP_CHILD_TOOLS,
32
+ 'argo_search',
33
+ 'argo_fetch',
34
+ 'web_search',
35
+ 'web_fetch',
36
+ ])
37
+
38
+ export const DEFAULTS = Object.freeze({
39
+ toolName: 'wide_research',
40
+ provider: 'spawn',
41
+ defaultWorkers: 6,
42
+ maxWorkers: 9,
43
+ maxTracks: 9,
44
+ maxSourcesPerTrack: 5,
45
+ workerMaxTokens: 5_000,
46
+ synthesisMaxTokens: 7_000,
47
+ childToolDeny: [],
48
+ childToolAllow: [...DEFAULT_CHILD_TOOLS],
49
+ searchProviderEnabled: true,
50
+ nativeTools: ['argo_search', 'argo_fetch'],
51
+ nativeTimeoutMs: 60_000,
52
+ nativeMaxChars: 24_000,
53
+ searchProviderId: 'argo',
54
+ fetchProviderEnabled: true,
55
+ fetchProviderId: 'argo',
56
+ fetchTimeoutMs: 25_000,
57
+ fetchMaxChars: 50_000,
58
+ searchCommand: process.env.ARGO_SEARCH_PYTHON || 'npx',
59
+ searchArgs: process.env.ARGO_SEARCH_MCP_SERVER
60
+ ? [process.env.ARGO_SEARCH_MCP_SERVER]
61
+ : ['-y', 'github:taxueseek/argo'],
62
+ searchTimeoutMs: 30_000,
63
+ searchIdleMs: 60_000,
64
+ searchConcurrency: 3,
65
+ reportDir: join(homedir(), '.dsh-research'),
66
+ keyRefs: ['DEEPSEEK_API_KEY'],
67
+ argoEnv: [],
68
+ })
69
+
70
+ const text = (value) => [{ type: 'text', text: value }]
71
+ const clip = (value, maximum) => value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`
72
+ const clamp = (value, fallback, minimum, maximum) => Number.isFinite(value)
73
+ ? Math.max(minimum, Math.min(maximum, Math.floor(value)))
74
+ : fallback
75
+
76
+ export function argoAborted() {
77
+ const err = new Error('argo search aborted')
78
+ err.code = 'WEB_ABORTED'
79
+ return err
80
+ }
81
+
82
+ export function argoToolError(message) {
83
+ const err = new Error(message)
84
+ err.code = 'ARGO_TOOL_ERROR'
85
+ return err
86
+ }
87
+
88
+ export const stripBoundary = (v) => typeof v === 'string'
89
+ ? v.replace(/<\s*\/?\s*untrusted-web-content\b[^>]*>/gi, '')
90
+ : v
91
+
92
+ export function mapArgoToWebResult(payload, query) {
93
+ const results = Array.isArray(payload?.results)
94
+ ? payload.results.filter(r => r && typeof r.url === 'string' && r.url !== '')
95
+ : []
96
+ const sources = results.map(r => {
97
+ const source = { url: r.url }
98
+ const title = stripBoundary(r.title)
99
+ const snippet = stripBoundary(r.snippet)
100
+ if (typeof title === 'string' && title !== '') source.title = title
101
+ if (typeof snippet === 'string' && snippet !== '') source.snippet = snippet
102
+ return source
103
+ })
104
+ return { content: undefined, sources, truncated: false, engine: payload?.engine ?? 'argo' }
105
+ }
106
+
107
+ let activeEnvResolver = null
108
+
109
+ export function setEnvResolver(fn) {
110
+ activeEnvResolver = fn
111
+ }
112
+
113
+ export async function resolveEnv(options = {}, config = undefined) {
114
+ if (typeof activeEnvResolver === 'function') {
115
+ return activeEnvResolver(options, config)
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ export const mcpPool = []
121
+
122
+ export function dropFromPool(conn) {
123
+ const index = mcpPool.indexOf(conn)
124
+ if (index >= 0) mcpPool.splice(index, 1)
125
+ }
126
+
127
+ export function createMcpConnection(options) {
128
+ const { command, args, idleMs, env } = options
129
+ let proc = null
130
+ let buffer = ''
131
+ let nextId = 1
132
+ let chain = Promise.resolve()
133
+ let idleTimer = null
134
+ let disposed = false
135
+ let claims = 0
136
+ const pending = new Map()
137
+
138
+ const touchIdle = () => {
139
+ if (idleTimer !== null) clearTimeout(idleTimer)
140
+ if (idleMs > 0) {
141
+ idleTimer = setTimeout(() => { close() }, idleMs)
142
+ if (idleTimer.unref) idleTimer.unref()
143
+ }
144
+ }
145
+
146
+ const close = () => {
147
+ if (disposed) return
148
+ disposed = true
149
+ if (idleTimer !== null) clearTimeout(idleTimer)
150
+ if (proc !== null) {
151
+ try { proc.kill() } catch { /* already gone */ }
152
+ proc = null
153
+ }
154
+ const err = new Error('argo MCP connection closed')
155
+ for (const entry of pending.values()) entry.rej(err)
156
+ pending.clear()
157
+ dropFromPool(conn)
158
+ }
159
+
160
+ const send = (method, params, timeoutMs = 0) => {
161
+ touchIdle()
162
+ const run = () => new Promise((res, rej) => {
163
+ if (proc === null || proc.stdin.destroyed) {
164
+ rej(new Error('argo MCP process not running'))
165
+ return
166
+ }
167
+ const id = nextId
168
+ nextId += 1
169
+ const entry = { res, rej }
170
+ pending.set(id, entry)
171
+
172
+ let reqTimer = null
173
+ if (timeoutMs > 0) {
174
+ reqTimer = setTimeout(() => {
175
+ pending.delete(id)
176
+ const err = new Error(`argo MCP request timed out after ${timeoutMs}ms: ${method}`)
177
+ err.code = 'ARGO_TIMEOUT'
178
+ rej(err)
179
+ close()
180
+ }, timeoutMs)
181
+ if (reqTimer.unref) reqTimer.unref()
182
+ }
183
+ const settle = (fn, value) => {
184
+ if (reqTimer !== null) clearTimeout(reqTimer)
185
+ fn(value)
186
+ }
187
+ entry.res = (v) => settle(res, v)
188
+ entry.rej = (e) => settle(rej, e)
189
+ try {
190
+ proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
191
+ } catch (err) {
192
+ pending.delete(id)
193
+ rej(err)
194
+ }
195
+ })
196
+ const result = chain.then(run)
197
+ chain = result.catch(() => { /* 失败不中断后续请求 */ })
198
+ return result
199
+ }
200
+
201
+ const conn = {
202
+ initialize: async (timeoutMs = 0) => {
203
+ if (proc !== null) return
204
+ proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], env: env ?? process.env })
205
+ proc.on('error', () => close())
206
+ proc.on('exit', () => close())
207
+ proc.stderr.on('data', () => { /* 预热日志忽略 */ })
208
+ proc.stdout.setEncoding('utf8')
209
+ proc.stdout.on('data', (chunk) => {
210
+ buffer += chunk
211
+ let nl
212
+ while ((nl = buffer.indexOf('\n')) >= 0) {
213
+ const line = buffer.slice(0, nl).trim()
214
+ buffer = buffer.slice(nl + 1)
215
+ if (line === '') continue
216
+ let msg
217
+ try {
218
+ msg = JSON.parse(line)
219
+ } catch {
220
+ continue
221
+ }
222
+ const entry = pending.get(msg.id)
223
+ if (entry === undefined) continue
224
+ pending.delete(msg.id)
225
+ if (msg.error !== undefined) entry.rej(new Error(msg.error.message ?? 'argo MCP error'))
226
+ else entry.res(msg.result)
227
+ }
228
+ })
229
+ await new Promise((res, rej) => {
230
+ proc.once('spawn', res)
231
+ proc.once('error', rej)
232
+ })
233
+ const init = await send('initialize', {
234
+ protocolVersion: '2025-06-18',
235
+ capabilities: {},
236
+ clientInfo: { name: 'argo-dsh', version: PLUGIN_VERSION }
237
+ }, timeoutMs)
238
+ conn.notify('notifications/initialized', {})
239
+ return init
240
+ },
241
+ request: (method, params, timeoutMs = 0) => {
242
+ if (claims > 0) claims -= 1
243
+ return send(method, params, timeoutMs)
244
+ },
245
+ claim: () => {
246
+ claims += 1
247
+ touchIdle()
248
+ },
249
+ release: () => {
250
+ if (claims > 0) claims -= 1
251
+ touchIdle()
252
+ },
253
+ load: () => pending.size + claims,
254
+ notify: (method, params) => {
255
+ if (proc !== null && !proc.stdin.destroyed) {
256
+ try {
257
+ proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n')
258
+ } catch { /* ignore */ }
259
+ }
260
+ },
261
+ close
262
+ }
263
+ return conn
264
+ }
265
+
266
+ export function initializeWithSignal(conn, initMs, signal) {
267
+ if (signal?.aborted) {
268
+ conn.close()
269
+ return Promise.reject(argoAborted())
270
+ }
271
+ if (signal === undefined) return conn.initialize(initMs)
272
+ return new Promise((resolve, reject) => {
273
+ const onAbort = () => {
274
+ conn.close()
275
+ reject(argoAborted())
276
+ }
277
+ signal.addEventListener('abort', onAbort, { once: true })
278
+ conn.initialize(initMs).then(
279
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
280
+ (err) => { signal.removeEventListener('abort', onAbort); reject(err) },
281
+ )
282
+ })
283
+ }
284
+
285
+ export async function acquireMcp(options, signal) {
286
+ const max = Math.max(1, Number(options.max) || DEFAULTS.searchConcurrency)
287
+ const initMs = Math.max(0, Number(options.initMs) || 0)
288
+ const idle = mcpPool.find(entry => entry.load() === 0)
289
+ if (idle !== undefined) {
290
+ idle.claim()
291
+ try {
292
+ await initializeWithSignal(idle, initMs, signal)
293
+ return idle
294
+ } catch (err) {
295
+ dropFromPool(idle)
296
+ idle.close()
297
+ if (mcpPool.length >= max) throw err
298
+ }
299
+ }
300
+ if (mcpPool.length < max) {
301
+ const env = await resolveEnv(options)
302
+ const conn = createMcpConnection({ ...options, env })
303
+ mcpPool.push(conn)
304
+ conn.claim()
305
+ try {
306
+ await initializeWithSignal(conn, initMs, signal)
307
+ } catch (err) {
308
+ dropFromPool(conn)
309
+ conn.close()
310
+ throw err
311
+ }
312
+ return conn
313
+ }
314
+ const least = mcpPool.reduce((a, b) => (a.load() <= b.load() ? a : b))
315
+ least.claim()
316
+ try {
317
+ await initializeWithSignal(least, initMs, signal)
318
+ } catch (err) {
319
+ dropFromPool(least)
320
+ least.close()
321
+ throw err
322
+ }
323
+ return least
324
+ }
325
+
326
+ export function disposeSharedMcp() {
327
+ for (const conn of [...mcpPool]) conn.close()
328
+ mcpPool.length = 0
329
+ }
330
+
331
+ export function classifyFetchError(err, url) {
332
+ const raw = String(err?.message ?? err ?? '')
333
+ const lower = raw.toLowerCase()
334
+ const prefix = (label, hint) => {
335
+ const e = new Error(`argo fetch ${label}: ${hint} (url=${url})`)
336
+ e.code = `ARGO_FETCH_${label.toUpperCase().replace(/-/g, '_')}`
337
+ return e
338
+ }
339
+ if (err?.code === 'WEB_ABORTED' || /abort/i.test(raw)) {
340
+ return prefix('aborted', 'cancelled by caller')
341
+ }
342
+ if (err?.code === 'ARGO_TIMEOUT' || /timed out|timeout/i.test(lower)) {
343
+ return prefix('timeout', 'the page took too long; try a more specific URL or retry')
344
+ }
345
+ if (/blocked|forbidden|403|robots|captcha|cloudflare|denied/i.test(lower)) {
346
+ return prefix('blocked', 'the source refused automated access; use a search result URL instead')
347
+ }
348
+ if (/no readable|no content|nothing extracted|no results|empty/i.test(lower)) {
349
+ return prefix('empty', 'no readable content was extracted from the page')
350
+ }
351
+ const e = new Error(`argo fetch failed: ${clip(raw, 300)} (url=${url})`)
352
+ e.code = 'ARGO_FETCH_FAILED'
353
+ return e
354
+ }
355
+
356
+ export function mapArgoToWebFetch(payload, requestUrl, maxChars) {
357
+ const url = typeof payload?.url === 'string' && payload.url !== '' ? payload.url : requestUrl
358
+ const content = typeof payload?.content === 'string' ? stripBoundary(payload.content) : ''
359
+ const clipped = content.length > maxChars
360
+ return {
361
+ url,
362
+ statusCode: 200,
363
+ body: { kind: 'text', content: clipped ? content.slice(0, maxChars) : content },
364
+ truncated: Boolean(payload?.truncated) || clipped,
365
+ }
366
+ }
367
+
368
+ export async function fetchViaArgo(url, signal, options = {}, deps = {}) {
369
+ const command = options.command ?? DEFAULTS.searchCommand
370
+ const args = options.args ?? DEFAULTS.searchArgs
371
+ const timeoutMs = options.timeoutMs ?? DEFAULTS.fetchTimeoutMs
372
+ const maxChars = clamp(options.maxChars ?? DEFAULTS.fetchMaxChars,
373
+ DEFAULTS.fetchMaxChars, 500, 50_000)
374
+ const argsObj = { url, max_chars: maxChars }
375
+ const spawnRun = deps.run ?? runNativeCall
376
+
377
+ const acquireOptions = {
378
+ command,
379
+ args,
380
+ idleMs: options.idleMs ?? DEFAULTS.searchIdleMs,
381
+ max: options.max ?? DEFAULTS.searchConcurrency,
382
+ initMs: timeoutMs,
383
+ credentials: options.credentials,
384
+ config: options.config,
385
+ }
386
+
387
+ let raw
388
+ try {
389
+ const conn = await acquireMcp(acquireOptions, signal)
390
+ raw = await new Promise((resolve, reject) => {
391
+ const onAbort = () => reject(argoAborted())
392
+ if (signal?.aborted) {
393
+ conn.release()
394
+ reject(argoAborted())
395
+ return
396
+ }
397
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
398
+ conn.request('tools/call', { name: 'argo_fetch', arguments: argsObj }, timeoutMs).then((value) => {
399
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
400
+ if (value?.isError) {
401
+ const body = value?.content?.[0]?.text
402
+ reject(argoToolError(typeof body === 'string' && body !== ''
403
+ ? body
404
+ : `argo tool argo_fetch returned an error`))
405
+ return
406
+ }
407
+ const text = value?.content?.[0]?.text
408
+ resolve(typeof text === 'string' ? JSON.parse(text) : {})
409
+ }, (err) => {
410
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
411
+ reject(err)
412
+ })
413
+ })
414
+ } catch (err) {
415
+ if (err?.code === 'ARGO_TOOL_ERROR' || err?.code === 'ARGO_TIMEOUT' || err?.code === 'WEB_ABORTED') {
416
+ throw classifyFetchError(err, url)
417
+ }
418
+ try {
419
+ const spawnSpec = resolveNativeSpawn(
420
+ { searchCommand: command, searchArgs: args },
421
+ 'argo_fetch',
422
+ JSON.stringify(argsObj),
423
+ )
424
+ const env = await resolveEnv(options)
425
+ const stdout = await spawnRun(env !== undefined ? { ...spawnSpec, env } : spawnSpec, timeoutMs, signal)
426
+ const parsed = JSON.parse(stdout)
427
+ if (parsed?.isError) {
428
+ const body = parsed?.content?.[0]?.text
429
+ throw argoToolError(typeof body === 'string' && body !== '' ? body : 'argo_fetch failed')
430
+ }
431
+ raw = JSON.parse(parsed?.content?.[0]?.text ?? '{}')
432
+ } catch (spawnErr) {
433
+ throw classifyFetchError(spawnErr, url)
434
+ }
435
+ }
436
+
437
+ if (raw?.success === false) {
438
+ throw classifyFetchError(new Error(String(raw?.error ?? 'fetch failed')), url)
439
+ }
440
+ return mapArgoToWebFetch(raw, url, maxChars)
441
+ }
442
+
443
+ export async function searchViaArgoMCP(query, maxResults = 5, signal, options = {}) {
444
+ const command = options.command ?? DEFAULTS.searchCommand
445
+ const args = options.args ?? DEFAULTS.searchArgs
446
+ const timeoutMs = options.timeoutMs ?? DEFAULTS.searchTimeoutMs
447
+ const count = clamp(maxResults ?? 5, 5, 1, 20)
448
+
449
+ const conn = await acquireMcp({
450
+ command,
451
+ args,
452
+ idleMs: options.idleMs ?? DEFAULTS.searchIdleMs,
453
+ max: options.max ?? DEFAULTS.searchConcurrency,
454
+ initMs: timeoutMs,
455
+ credentials: options.credentials,
456
+ config: options.config,
457
+ }, signal)
458
+
459
+ const result = await new Promise((resolve, reject) => {
460
+ const onAbort = () => reject(argoAborted())
461
+ if (signal !== undefined) {
462
+ if (signal.aborted) {
463
+ conn.release()
464
+ reject(argoAborted())
465
+ return
466
+ }
467
+ signal.addEventListener('abort', onAbort, { once: true })
468
+ }
469
+ conn.request('tools/call', {
470
+ name: 'argo_search',
471
+ arguments: { query, max_results: count, summary: true }
472
+ }, timeoutMs).then((value) => {
473
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
474
+ resolve(value)
475
+ }, (err) => {
476
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
477
+ reject(err)
478
+ })
479
+ })
480
+
481
+ let payload
482
+ try {
483
+ const raw = result?.content?.[0]?.text
484
+ payload = typeof raw === 'string' ? JSON.parse(raw) : {}
485
+ } catch (err) {
486
+ throw new Error(`argo search returned an unprocessable response: ${String(err)}`)
487
+ }
488
+ return mapArgoToWebResult(payload, query)
489
+ }
490
+
491
+ export function resolveNativeSpawn(config, tool, payload) {
492
+ const command = typeof config.searchCommand === 'string' && config.searchCommand !== ''
493
+ ? config.searchCommand
494
+ : DEFAULTS.searchCommand
495
+ const args = Array.isArray(config.searchArgs) && config.searchArgs.length > 0
496
+ ? config.searchArgs
497
+ : DEFAULTS.searchArgs
498
+ if (args.some(arg => typeof arg === 'string' && arg.endsWith('mcp_server.py'))) {
499
+ return { command, args: [...args, '--call', tool, payload] }
500
+ }
501
+ return { command, args: [...args, 'call', tool, payload] }
502
+ }
503
+
504
+ export function nativeErrorText(stdout, stderr) {
505
+ try {
506
+ const parsed = JSON.parse(stdout)
507
+ const body = parsed?.content?.[0]?.text
508
+ if (typeof body === 'string' && body !== '') return body
509
+ } catch { /* ignore */ }
510
+ return stderr.trim() !== '' ? stderr : stdout
511
+ }
512
+
513
+ export function runNativeCall({ command, args, env }, timeoutMs, signal) {
514
+ return new Promise((resolve, reject) => {
515
+ if (signal && signal.aborted) {
516
+ reject(argoAborted())
517
+ return
518
+ }
519
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env: env ?? process.env })
520
+ let stdout = ''
521
+ let stderr = ''
522
+ let settled = false
523
+ let timer = null
524
+ let detachAbort = null
525
+
526
+ const settle = (fn, value) => {
527
+ if (settled) return
528
+ settled = true
529
+ if (timer !== null) clearTimeout(timer)
530
+ if (detachAbort !== null) detachAbort()
531
+ fn(value)
532
+ }
533
+ const onAbort = () => {
534
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
535
+ settle(reject, argoAborted())
536
+ }
537
+ if (signal) {
538
+ signal.addEventListener('abort', onAbort, { once: true })
539
+ detachAbort = () => signal.removeEventListener('abort', onAbort)
540
+ }
541
+ timer = setTimeout(() => {
542
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
543
+ settle(reject, new Error(`argo native call timed out after ${timeoutMs}ms`))
544
+ }, timeoutMs)
545
+ child.stdout.on('data', (chunk) => { stdout += chunk })
546
+ child.stderr.on('data', (chunk) => { stderr += chunk })
547
+ child.on('error', (err) => settle(reject, err))
548
+ child.on('close', (code) => {
549
+ if (code === 0) { settle(resolve, stdout); return }
550
+ settle(reject, new Error(`argo native call exited ${code}: ${clip(nativeErrorText(stdout, stderr), 500)}`))
551
+ })
552
+ })
553
+ }
554
+
555
+ export function resolveEntry(config) {
556
+ const command = typeof config.searchCommand === 'string' && config.searchCommand !== ''
557
+ ? config.searchCommand
558
+ : DEFAULTS.searchCommand
559
+ const args = Array.isArray(config.searchArgs) && config.searchArgs.length > 0
560
+ ? config.searchArgs
561
+ : DEFAULTS.searchArgs
562
+ return { command, args }
563
+ }
564
+
565
+ export function pickArgs(args, allowed) {
566
+ const out = {}
567
+ for (const [key, value] of Object.entries(args ?? {})) {
568
+ if (allowed.includes(key) && value !== undefined && value !== null && value !== '') {
569
+ out[key] = value
570
+ }
571
+ }
572
+ return out
573
+ }
574
+
575
+ export async function nativeViaMcpPool(config, kind, argsObj, timeoutMs, signal) {
576
+ const { command, args } = resolveEntry(config)
577
+ const conn = await acquireMcp({
578
+ command,
579
+ args,
580
+ idleMs: config.searchIdleMs ?? DEFAULTS.searchIdleMs,
581
+ max: config.searchConcurrency ?? DEFAULTS.searchConcurrency,
582
+ initMs: timeoutMs,
583
+ credentials: config.credentials,
584
+ config,
585
+ }, signal)
586
+ const result = await new Promise((resolve, reject) => {
587
+ const onAbort = () => reject(argoAborted())
588
+ if (signal !== undefined) {
589
+ if (signal.aborted) {
590
+ conn.release()
591
+ reject(argoAborted())
592
+ return
593
+ }
594
+ signal.addEventListener('abort', onAbort, { once: true })
595
+ }
596
+ conn.request('tools/call', { name: kind, arguments: argsObj }, timeoutMs).then((value) => {
597
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
598
+ resolve(value)
599
+ }, (err) => {
600
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
601
+ reject(err)
602
+ })
603
+ })
604
+ if (result?.isError) {
605
+ const body = result?.content?.[0]?.text
606
+ throw argoToolError(typeof body === 'string' && body !== ''
607
+ ? body
608
+ : `argo tool ${kind} returned an error`)
609
+ }
610
+ return JSON.stringify(result ?? {})
611
+ }
612
+
613
+ export function makeNativeTool(config, kind, deps = {}) {
614
+ const spec = NATIVE_TOOL_SPECS[kind]
615
+ if (!spec) {
616
+ throw new Error(`argo-dsh: unknown native tool "${kind}" (known: ${Object.keys(NATIVE_TOOL_SPECS).join(', ')})`)
617
+ }
618
+ const spawnRun = deps.run ?? runNativeCall
619
+ const mcpCall = deps.mcpCall ?? nativeViaMcpPool
620
+ const forceSpawn = deps.run !== undefined && deps.mcpCall === undefined
621
+
622
+ const currentTimeout = () => {
623
+ const value = Number(config.nativeTimeoutMs)
624
+ return Number.isFinite(value) && value > 0 ? value : DEFAULTS.nativeTimeoutMs
625
+ }
626
+ const currentMaxChars = () => {
627
+ const value = Number(config.nativeMaxChars)
628
+ return Number.isFinite(value) && value > 0 ? value : DEFAULTS.nativeMaxChars
629
+ }
630
+ return {
631
+ name: kind,
632
+ description: spec.description,
633
+ parameters: spec.parameters,
634
+ isConcurrencySafe: () => true,
635
+ output: {
636
+ schema: { type: 'object' },
637
+ render: (_args, value) => {
638
+ let body = ''
639
+ try {
640
+ const parsed = JSON.parse(value.stdout)
641
+ body = parsed?.content?.[0]?.text ?? value.stdout
642
+ } catch {
643
+ body = value.stdout
644
+ }
645
+ return text(clip(body, currentMaxChars()))
646
+ },
647
+ },
648
+ async execute(args, exec) {
649
+ const argsObj = pickArgs(args, spec.allowed)
650
+ const payload = JSON.stringify(argsObj)
651
+ const timeoutMs = currentTimeout()
652
+ const signal = exec?.signal
653
+ if (!forceSpawn) {
654
+ try {
655
+ return { stdout: await mcpCall(config, kind, argsObj, timeoutMs, signal) }
656
+ } catch (err) {
657
+ if (err?.code === 'ARGO_TOOL_ERROR' || err?.code === 'ARGO_TIMEOUT' || err?.code === 'WEB_ABORTED') throw err
658
+ }
659
+ }
660
+ const spawnSpec = resolveNativeSpawn(config, kind, payload)
661
+ const env = await resolveEnv(undefined, config)
662
+ const stdout = await spawnRun(env !== undefined ? { ...spawnSpec, env } : spawnSpec, timeoutMs, signal)
663
+ return { stdout }
664
+ },
665
+ }
666
+ }