scoutline 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +8 -9
  2. package/bin/scoutline.js +2 -3
  3. package/dist/capabilities/crawl.d.ts +93 -0
  4. package/dist/capabilities/crawl.d.ts.map +1 -0
  5. package/dist/capabilities/crawl.js +62 -0
  6. package/dist/capabilities/crawl.js.map +1 -0
  7. package/dist/capabilities/diagnostics.d.ts +45 -43
  8. package/dist/capabilities/diagnostics.d.ts.map +1 -1
  9. package/dist/capabilities/diagnostics.js +58 -71
  10. package/dist/capabilities/diagnostics.js.map +1 -1
  11. package/dist/capabilities/map.d.ts +81 -0
  12. package/dist/capabilities/map.d.ts.map +1 -0
  13. package/dist/capabilities/map.js +60 -0
  14. package/dist/capabilities/map.js.map +1 -0
  15. package/dist/capabilities/quota.js +1 -1
  16. package/dist/capabilities/quota.js.map +1 -1
  17. package/dist/capabilities/research.d.ts +98 -0
  18. package/dist/capabilities/research.d.ts.map +1 -0
  19. package/dist/capabilities/research.js +71 -0
  20. package/dist/capabilities/research.js.map +1 -0
  21. package/dist/capabilities/search.d.ts +9 -1
  22. package/dist/capabilities/search.d.ts.map +1 -1
  23. package/dist/commands/crawl.d.ts +46 -0
  24. package/dist/commands/crawl.d.ts.map +1 -0
  25. package/dist/commands/crawl.js +170 -0
  26. package/dist/commands/crawl.js.map +1 -0
  27. package/dist/commands/doctor.d.ts +7 -8
  28. package/dist/commands/doctor.d.ts.map +1 -1
  29. package/dist/commands/doctor.js +37 -34
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/commands/map.d.ts +39 -0
  32. package/dist/commands/map.d.ts.map +1 -0
  33. package/dist/commands/map.js +121 -0
  34. package/dist/commands/map.js.map +1 -0
  35. package/dist/commands/read.d.ts.map +1 -1
  36. package/dist/commands/read.js +7 -1
  37. package/dist/commands/read.js.map +1 -1
  38. package/dist/commands/research.d.ts +62 -0
  39. package/dist/commands/research.d.ts.map +1 -0
  40. package/dist/commands/research.js +314 -0
  41. package/dist/commands/research.js.map +1 -0
  42. package/dist/commands/search.d.ts +2 -1
  43. package/dist/commands/search.d.ts.map +1 -1
  44. package/dist/commands/search.js +11 -4
  45. package/dist/commands/search.js.map +1 -1
  46. package/dist/index.d.ts +38 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +260 -13
  49. package/dist/index.js.map +1 -1
  50. package/dist/lib/cache.d.ts +13 -0
  51. package/dist/lib/cache.d.ts.map +1 -1
  52. package/dist/lib/cache.js +15 -0
  53. package/dist/lib/cache.js.map +1 -1
  54. package/dist/lib/execution.d.ts +105 -1
  55. package/dist/lib/execution.d.ts.map +1 -1
  56. package/dist/lib/execution.js +73 -1
  57. package/dist/lib/execution.js.map +1 -1
  58. package/dist/lib/redact.d.ts +2 -1
  59. package/dist/lib/redact.d.ts.map +1 -1
  60. package/dist/lib/redact.js +19 -7
  61. package/dist/lib/redact.js.map +1 -1
  62. package/dist/lib/research-state.d.ts +104 -0
  63. package/dist/lib/research-state.d.ts.map +1 -0
  64. package/dist/lib/research-state.js +214 -0
  65. package/dist/lib/research-state.js.map +1 -0
  66. package/dist/lib/search-topic.d.ts +21 -0
  67. package/dist/lib/search-topic.d.ts.map +1 -0
  68. package/dist/lib/search-topic.js +36 -0
  69. package/dist/lib/search-topic.js.map +1 -0
  70. package/dist/providers/minimax/adapter.d.ts.map +1 -1
  71. package/dist/providers/minimax/adapter.js +41 -4
  72. package/dist/providers/minimax/adapter.js.map +1 -1
  73. package/dist/providers/minimax/vision-conformance.d.ts +2 -2
  74. package/dist/providers/minimax/vision-conformance.js +2 -2
  75. package/dist/providers/registry.d.ts.map +1 -1
  76. package/dist/providers/registry.js +2 -0
  77. package/dist/providers/registry.js.map +1 -1
  78. package/dist/providers/tavily/adapter.d.ts +62 -0
  79. package/dist/providers/tavily/adapter.d.ts.map +1 -0
  80. package/dist/providers/tavily/adapter.js +970 -0
  81. package/dist/providers/tavily/adapter.js.map +1 -0
  82. package/dist/providers/tavily/client.d.ts +184 -0
  83. package/dist/providers/tavily/client.d.ts.map +1 -0
  84. package/dist/providers/tavily/client.js +486 -0
  85. package/dist/providers/tavily/client.js.map +1 -0
  86. package/dist/providers/tavily/credentials.d.ts +34 -0
  87. package/dist/providers/tavily/credentials.d.ts.map +1 -0
  88. package/dist/providers/tavily/credentials.js +56 -0
  89. package/dist/providers/tavily/credentials.js.map +1 -0
  90. package/dist/providers/tavily/diagnostics.d.ts +44 -0
  91. package/dist/providers/tavily/diagnostics.d.ts.map +1 -0
  92. package/dist/providers/tavily/diagnostics.js +70 -0
  93. package/dist/providers/tavily/diagnostics.js.map +1 -0
  94. package/dist/providers/tavily/quota.d.ts +60 -0
  95. package/dist/providers/tavily/quota.d.ts.map +1 -0
  96. package/dist/providers/tavily/quota.js +186 -0
  97. package/dist/providers/tavily/quota.js.map +1 -0
  98. package/dist/providers/types.d.ts +14 -2
  99. package/dist/providers/types.d.ts.map +1 -1
  100. package/dist/providers/types.js +1 -1
  101. package/dist/providers/types.js.map +1 -1
  102. package/dist/providers/zai/adapter.d.ts.map +1 -1
  103. package/dist/providers/zai/adapter.js +6 -1
  104. package/dist/providers/zai/adapter.js.map +1 -1
  105. package/dist/providers/zai/encoded-error.d.ts.map +1 -1
  106. package/dist/providers/zai/encoded-error.js +8 -6
  107. package/dist/providers/zai/encoded-error.js.map +1 -1
  108. package/package.json +1 -1
@@ -0,0 +1,970 @@
1
+ /**
2
+ * Tavily Provider Adapter (DESIGN.md §5, §7, tech-plan §7).
3
+ *
4
+ * Implements the Tavily Provider Descriptor with Search, Reader, and
5
+ * Crawl capabilities on top of the direct-HTTP transport (`./client.ts`). The
6
+ * Adapter owns credentials, transport lifecycle, Provider field mapping,
7
+ * and failure normalization; shared execution owns cache and retry
8
+ * policy.
9
+ *
10
+ * Boundary rules (ARCHITECTURE.md §2):
11
+ * - May import capability types, normalized errors, Provider identity
12
+ * types, and the Adapter-local credential and transport Modules.
13
+ * - Must NOT import command presentation, output mode, or another
14
+ * Provider's Adapter.
15
+ *
16
+ * Field mapping (tech-plan §7 Tavily mapping):
17
+ * Search results[].title -> title
18
+ * Search results[].url -> url
19
+ * Search results[].content -> summary
20
+ * Search results[].score -> (dropped)
21
+ *
22
+ * Extract results[0].raw_content -> content
23
+ * Extract results[0].url -> finalUrl
24
+ * Extract results[0] -> title: null (Tavily doesn't return one)
25
+ *
26
+ * Control mapping (SearchControls → Tavily-native API params):
27
+ * domain -> include_domains: [domain]
28
+ * recency -> time_range (oneDay→"day", oneWeek→"week",
29
+ * oneMonth→"month", oneYear→"year", noLimit→omit)
30
+ * contentSize -> search_depth (medium→"basic", high→"advanced")
31
+ * topic -> topic (native, pass as-is)
32
+ * location -> REJECTED (UnsupportedOptionError)
33
+ */
34
+ import crypto from "node:crypto";
35
+ import { decodeReaderFetchResult } from "../../capabilities/reader.js";
36
+ import { decodeCrawlResult } from "../../capabilities/crawl.js";
37
+ import { decodeMapResult } from "../../capabilities/map.js";
38
+ import { decodeResearchResult } from "../../capabilities/research.js";
39
+ import { computeResearchStateHash, createProductionResearchStateFile, } from "../../lib/research-state.js";
40
+ import { ApiError, AuthError, ConfigurationError, NetworkError, QuotaError, TimeoutError, UnsupportedOptionError, ValidationError, } from "../../lib/errors.js";
41
+ import { requireTavilyApiKey, isTavilyConfigured } from "./credentials.js";
42
+ import { fetchTavilySearch, fetchTavilyExtract, fetchTavilyCrawl, fetchTavilyMap, createTavilyResearch, pollTavilyResearch, } from "./client.js";
43
+ import { createTavilyQuotaCapability } from "./quota.js";
44
+ import { createTavilyDiagnosticsCapability } from "./diagnostics.js";
45
+ // ---------------------------------------------------------------------------
46
+ // Provider-owned credential fingerprint
47
+ // ---------------------------------------------------------------------------
48
+ function credentialFingerprint(apiKey) {
49
+ return crypto.createHash("sha256").update(apiKey).digest("hex");
50
+ }
51
+ function resolveApiKey(env) {
52
+ return requireTavilyApiKey(env);
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Helpers
56
+ // ---------------------------------------------------------------------------
57
+ function isPlainObject(value) {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Control mapping (SearchControls → Tavily-native API params)
62
+ // ---------------------------------------------------------------------------
63
+ function mapRecencyToTimeRange(recency) {
64
+ switch (recency) {
65
+ case "oneDay":
66
+ return "day";
67
+ case "oneWeek":
68
+ return "week";
69
+ case "oneMonth":
70
+ return "month";
71
+ case "oneYear":
72
+ return "year";
73
+ case "noLimit":
74
+ return undefined;
75
+ default:
76
+ return undefined;
77
+ }
78
+ }
79
+ function mapSearchControls(controls) {
80
+ if (!controls)
81
+ return undefined;
82
+ const params = {};
83
+ if (controls.domain) {
84
+ params.include_domains = [controls.domain];
85
+ }
86
+ if (controls.recency) {
87
+ const timeRange = mapRecencyToTimeRange(controls.recency);
88
+ if (timeRange)
89
+ params.time_range = timeRange;
90
+ }
91
+ if (controls.contentSize) {
92
+ params.search_depth = controls.contentSize === "high" ? "advanced" : "basic";
93
+ }
94
+ if (controls.topic) {
95
+ params.topic = controls.topic;
96
+ }
97
+ return params;
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Response normalization
101
+ // ---------------------------------------------------------------------------
102
+ /**
103
+ * Normalize a raw Tavily search response into `SearchSource[]`.
104
+ *
105
+ * results[].title -> title
106
+ * results[].url -> url
107
+ * results[].content -> summary
108
+ * results[].score -> (dropped)
109
+ *
110
+ * Any malformed shape is a retryable `ApiError` 500.
111
+ */
112
+ function normalizeTavilySearchResults(raw) {
113
+ if (!isPlainObject(raw)) {
114
+ throw new ApiError("Tavily search returned a malformed response", 500);
115
+ }
116
+ const results = raw.results;
117
+ if (!Array.isArray(results)) {
118
+ throw new ApiError("Tavily search returned a malformed response", 500);
119
+ }
120
+ const out = [];
121
+ for (const entry of results) {
122
+ if (!isPlainObject(entry)) {
123
+ throw new ApiError("Tavily search returned a malformed response", 500);
124
+ }
125
+ const title = entry.title;
126
+ const url = entry.url;
127
+ const content = entry.content;
128
+ if (typeof title !== "string" || typeof url !== "string" || typeof content !== "string") {
129
+ throw new ApiError("Tavily search returned a malformed response", 500);
130
+ }
131
+ out.push({ title, url, summary: content });
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * Normalize a raw Tavily extract response into a `ReaderFetchResult`.
137
+ *
138
+ * results[0].raw_content -> content
139
+ * results[0].url -> finalUrl
140
+ * results[0] -> title: null (Tavily doesn't return a title)
141
+ *
142
+ * If `failed_results` contains the requested URL, throw `ApiError` 422.
143
+ * 422 (Unprocessable Entity) is a terminal 4xx code so the reader does
144
+ * not retry a permanent extraction failure. Any other malformed shape
145
+ * is a retryable `ApiError` 500.
146
+ */
147
+ function normalizeTavilyExtractResult(raw, request) {
148
+ if (!isPlainObject(raw)) {
149
+ throw new ApiError("Tavily extract returned a malformed response", 500);
150
+ }
151
+ // Check failed_results for the requested URL.
152
+ const failedResults = raw.failed_results;
153
+ if (Array.isArray(failedResults)) {
154
+ for (const f of failedResults) {
155
+ if (isPlainObject(f) && typeof f.url === "string" && f.url === request.url) {
156
+ throw new ApiError("Tavily extract failed for URL", 422);
157
+ }
158
+ }
159
+ }
160
+ const results = raw.results;
161
+ if (!Array.isArray(results) || results.length === 0) {
162
+ throw new ApiError("Tavily extract returned a malformed response", 500);
163
+ }
164
+ const first = results[0];
165
+ if (!isPlainObject(first)) {
166
+ throw new ApiError("Tavily extract returned a malformed response", 500);
167
+ }
168
+ const content = first.raw_content;
169
+ if (typeof content !== "string" || content.length === 0) {
170
+ throw new ApiError("Tavily extract returned a malformed response", 500);
171
+ }
172
+ const finalUrl = typeof first.url === "string" && first.url.length > 0 ? first.url : request.url;
173
+ const contentFormat = request.format ?? "markdown";
174
+ return {
175
+ schemaVersion: 1,
176
+ url: request.url,
177
+ finalUrl,
178
+ title: null,
179
+ content,
180
+ contentFormat,
181
+ };
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Failure normalization: stable public codes, no raw payloads (NFR-006)
185
+ // ---------------------------------------------------------------------------
186
+ /**
187
+ * Resolve a stable HTTP-style status code for retry classification.
188
+ * Explicit terminal client errors (400, 404, 410, 422) map to their
189
+ * real codes; transient failures map to a representative status in the
190
+ * retryable set (429 or any 5xx 500..599 inclusive, per DESIGN.md §18 /
191
+ * FR-090). Unknown failures default to 500 (transient). When the caller
192
+ * already carries a numeric status (a typed ApiError), that status is
193
+ * honoured directly.
194
+ */
195
+ function inferStatusCode(lower, known) {
196
+ if (typeof known === "number" && Number.isFinite(known))
197
+ return known;
198
+ if (lower.includes("404") || lower.includes("not found"))
199
+ return 404;
200
+ if (lower.includes("400") || lower.includes("bad request"))
201
+ return 400;
202
+ if (lower.includes("410") || lower.includes("gone"))
203
+ return 410;
204
+ if (lower.includes("422") || lower.includes("unprocessable"))
205
+ return 422;
206
+ if (lower.includes("500") || lower.includes("internal"))
207
+ return 500;
208
+ if (lower.includes("502") || lower.includes("bad gateway"))
209
+ return 502;
210
+ if (lower.includes("503") || lower.includes("service unavailable"))
211
+ return 503;
212
+ if (lower.includes("504") || lower.includes("gateway timeout"))
213
+ return 504;
214
+ return 500;
215
+ }
216
+ /**
217
+ * Status-keyed outward message for rewrapped Tavily ApiErrors. The rewrap
218
+ * does not echo upstream `error.message` — a future change embedding a
219
+ * raw Provider body in an ApiError message would leak through
220
+ * normalization, the cache, and stdout. Curated constants only.
221
+ */
222
+ function tavilyApiErrorMessage(statusCode) {
223
+ if (statusCode === 429)
224
+ return "Tavily rate limit exceeded";
225
+ if (statusCode === 432) {
226
+ return "Tavily plan limit exceeded. Upgrade your plan at app.tavily.com.";
227
+ }
228
+ if (statusCode === 433) {
229
+ return "Tavily pay-as-you-go limit exceeded. Increase your limit on the Tavily dashboard.";
230
+ }
231
+ return "Tavily request failed";
232
+ }
233
+ /**
234
+ * Normalize a Provider failure with sanitized messages. Raw response
235
+ * bodies never cross the adapter boundary. Same pattern as
236
+ * `normalizeMiniMaxError`.
237
+ */
238
+ function normalizeTavilyError(error) {
239
+ // QuotaError pass-through — terminal retry guarantee preserved.
240
+ if (error instanceof QuotaError)
241
+ return error;
242
+ // Configuration/option/validation errors carry clean, human-authored
243
+ // messages and are safe to surface verbatim.
244
+ if (error instanceof ValidationError ||
245
+ error instanceof UnsupportedOptionError ||
246
+ error instanceof ConfigurationError) {
247
+ return error;
248
+ }
249
+ // Re-wrap typed transport errors with sanitized messages so a raw
250
+ // Provider response body embedded upstream never survives. Code +
251
+ // statusCode (retry signal) are preserved.
252
+ if (error instanceof AuthError) {
253
+ return new AuthError("Tavily authentication failed", "TAVILY_API_KEY");
254
+ }
255
+ if (error instanceof NetworkError) {
256
+ return new NetworkError("Tavily network error");
257
+ }
258
+ if (error instanceof TimeoutError) {
259
+ return new TimeoutError(error.durationMs, "Try again or increase timeout with TAVILY_TIMEOUT env var");
260
+ }
261
+ if (error instanceof ApiError) {
262
+ const statusCode = inferStatusCode("", error.statusCode);
263
+ return new ApiError(tavilyApiErrorMessage(statusCode), statusCode);
264
+ }
265
+ const message = error instanceof Error ? error.message : String(error);
266
+ const lower = message.toLowerCase();
267
+ if (lower.includes("401") ||
268
+ lower.includes("403") ||
269
+ lower.includes("unauthorized") ||
270
+ lower.includes("forbidden")) {
271
+ return new AuthError("Tavily authentication failed");
272
+ }
273
+ if (lower.includes("timeout") || lower.includes("timed out") || lower.includes("etimedout")) {
274
+ // Fallback branch: the transport wraps real timeouts into a typed
275
+ // TimeoutError (which carries the configured duration) before they
276
+ // reach this point. This untyped-message heuristic is rarely hit, so
277
+ // a constant default is fine and keeps process.env out of the
278
+ // normalization path (test isolation).
279
+ return new TimeoutError(30000);
280
+ }
281
+ if (lower.includes("econnrefused") ||
282
+ lower.includes("econnreset") ||
283
+ lower.includes("network") ||
284
+ lower.includes("enotfound") ||
285
+ lower.includes("fetch failed")) {
286
+ return new NetworkError("Tavily network error");
287
+ }
288
+ if (lower.includes("429") || lower.includes("rate limit")) {
289
+ return new ApiError("Tavily rate limit exceeded", 429);
290
+ }
291
+ return new ApiError("Tavily request failed", inferStatusCode(lower));
292
+ }
293
+ // ---------------------------------------------------------------------------
294
+ // Reader validation helpers
295
+ // ---------------------------------------------------------------------------
296
+ /** Z.AI-only reader options that Tavily does not accept. */
297
+ const UNSUPPORTED_READER_OPTIONS = [
298
+ "withLinksSummary",
299
+ "noGfm",
300
+ "keepImgDataUrl",
301
+ "withImagesSummary",
302
+ ];
303
+ function assertHttpUrl(url) {
304
+ if (typeof url !== "string" || url.length === 0) {
305
+ throw new ValidationError("Tavily reader URL must be a non-empty string");
306
+ }
307
+ if (!/^https?:\/\//.test(url)) {
308
+ throw new ValidationError("URL must start with http:// or https://");
309
+ }
310
+ }
311
+ function assertNoUnsupportedReaderOptions(request) {
312
+ for (const key of UNSUPPORTED_READER_OPTIONS) {
313
+ // Only reject when the user explicitly enabled the option (`true`).
314
+ // The read command handler sets boolean options to `false` (not
315
+ // `undefined`) when the flag is absent, so `!== undefined` would
316
+ // over-reject. `false` means "user didn't pass the flag" → accept.
317
+ if (request[key] === true) {
318
+ throw new UnsupportedOptionError("tavily", "reader", key);
319
+ }
320
+ }
321
+ }
322
+ function createTavilySearchCapability(options) {
323
+ const { env, transport } = options;
324
+ const capability = {
325
+ validate(request) {
326
+ if (!request || typeof request.query !== "string" || request.query.trim() === "") {
327
+ throw new ValidationError("Search query must contain at least one non-whitespace character");
328
+ }
329
+ // Tavily supports domain, recency, contentSize, and topic natively.
330
+ // location is Z.AI-specific and rejected before any transport call.
331
+ if (request.controls?.location !== undefined) {
332
+ throw new UnsupportedOptionError("tavily", "search", "location");
333
+ }
334
+ },
335
+ cacheIdentity(request) {
336
+ const apiKey = resolveApiKey(env);
337
+ const identityRequest = {
338
+ query: request.query,
339
+ };
340
+ if (request.controls) {
341
+ identityRequest.controls = request.controls;
342
+ }
343
+ return {
344
+ provider: "tavily",
345
+ capability: "search",
346
+ credentialFingerprint: credentialFingerprint(apiKey),
347
+ request: identityRequest,
348
+ // Tavily never probes legacy keys — no legacyCandidates.
349
+ };
350
+ },
351
+ async invoke(request) {
352
+ capability.validate(request);
353
+ const apiKey = resolveApiKey(env);
354
+ try {
355
+ const params = mapSearchControls(request.controls);
356
+ const raw = await fetchTavilySearch(apiKey, request.query, params, transport);
357
+ return normalizeTavilySearchResults(raw);
358
+ }
359
+ catch (error) {
360
+ throw normalizeTavilyError(error);
361
+ }
362
+ },
363
+ };
364
+ return capability;
365
+ }
366
+ function createTavilyReaderCapability(options) {
367
+ const { env, transport } = options;
368
+ const fetch = {
369
+ kind: "reader-fetch",
370
+ validate(request) {
371
+ assertHttpUrl(request.url);
372
+ assertNoUnsupportedReaderOptions(request);
373
+ },
374
+ cacheIdentity(request) {
375
+ const apiKey = resolveApiKey(env);
376
+ return {
377
+ provider: "tavily",
378
+ capability: "reader",
379
+ operation: "reader-fetch",
380
+ credentialFingerprint: credentialFingerprint(apiKey),
381
+ request,
382
+ legacyCandidates: [],
383
+ };
384
+ },
385
+ decodeCached(value) {
386
+ return decodeReaderFetchResult(value);
387
+ },
388
+ async invoke(request) {
389
+ fetch.validate(request);
390
+ const apiKey = resolveApiKey(env);
391
+ try {
392
+ const raw = await fetchTavilyExtract(apiKey, request.url, undefined, transport);
393
+ return normalizeTavilyExtractResult(raw, request);
394
+ }
395
+ catch (error) {
396
+ throw normalizeTavilyError(error);
397
+ }
398
+ },
399
+ };
400
+ return { fetch };
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // Crawl Capability
404
+ // ---------------------------------------------------------------------------
405
+ /**
406
+ * Split a comma-separated path-pattern string into an array, trimming
407
+ * whitespace from each entry. Returns `undefined` for empty/whitespace-
408
+ * only input.
409
+ */
410
+ function splitPathPatterns(value) {
411
+ if (value === undefined || value.trim() === "")
412
+ return undefined;
413
+ return value
414
+ .split(",")
415
+ .map((s) => s.trim())
416
+ .filter((s) => s.length > 0);
417
+ }
418
+ /**
419
+ * Map a Provider-neutral `CrawlRequest` into Tavily-native API params.
420
+ *
421
+ * url -> url (handled by transport)
422
+ * depth -> max_depth
423
+ * breadth -> max_breadth
424
+ * limit -> limit
425
+ * selectPaths -> select_paths (split on comma if string)
426
+ * excludePaths -> exclude_paths (split on comma if string)
427
+ * instructions -> instructions
428
+ * format -> format
429
+ * contentSize -> extract_depth (medium→"basic", high→"advanced")
430
+ * timeout -> timeout
431
+ */
432
+ function mapCrawlControls(request) {
433
+ const params = {};
434
+ if (request.depth !== undefined)
435
+ params.max_depth = request.depth;
436
+ if (request.breadth !== undefined)
437
+ params.max_breadth = request.breadth;
438
+ if (request.limit !== undefined)
439
+ params.limit = request.limit;
440
+ const selectPaths = splitPathPatterns(request.selectPaths);
441
+ if (selectPaths !== undefined)
442
+ params.select_paths = selectPaths;
443
+ const excludePaths = splitPathPatterns(request.excludePaths);
444
+ if (excludePaths !== undefined)
445
+ params.exclude_paths = excludePaths;
446
+ if (request.instructions !== undefined)
447
+ params.instructions = request.instructions;
448
+ if (request.format !== undefined)
449
+ params.format = request.format;
450
+ if (request.contentSize !== undefined) {
451
+ params.extract_depth = request.contentSize === "high" ? "advanced" : "basic";
452
+ }
453
+ if (request.timeout !== undefined)
454
+ params.timeout = request.timeout;
455
+ return params;
456
+ }
457
+ /**
458
+ * Normalize a raw Tavily crawl response into a `CrawlResult`.
459
+ *
460
+ * results[].url -> page.url
461
+ * results[].raw_content -> page.content
462
+ * format -> page.contentFormat (from request, default markdown)
463
+ * baseUrl -> the request URL
464
+ * totalPages -> results array length
465
+ *
466
+ * Any malformed shape is a retryable `ApiError` 500.
467
+ */
468
+ function normalizeTavilyCrawlResult(raw, request) {
469
+ if (!isPlainObject(raw)) {
470
+ throw new ApiError("Tavily crawl returned a malformed response", 500);
471
+ }
472
+ const results = raw.results;
473
+ if (!Array.isArray(results)) {
474
+ throw new ApiError("Tavily crawl returned a malformed response", 500);
475
+ }
476
+ const contentFormat = request.format ?? "markdown";
477
+ const pages = [];
478
+ for (const entry of results) {
479
+ if (!isPlainObject(entry)) {
480
+ throw new ApiError("Tavily crawl returned a malformed response", 500);
481
+ }
482
+ const url = entry.url;
483
+ const content = entry.raw_content;
484
+ if (typeof url !== "string" || typeof content !== "string") {
485
+ throw new ApiError("Tavily crawl returned a malformed response", 500);
486
+ }
487
+ pages.push({ url, content, contentFormat });
488
+ }
489
+ return {
490
+ schemaVersion: 1,
491
+ baseUrl: request.url,
492
+ pages,
493
+ totalPages: pages.length,
494
+ };
495
+ }
496
+ function createTavilyCrawlCapability(options) {
497
+ const { env, transport } = options;
498
+ const fetch = {
499
+ kind: "crawl-fetch",
500
+ validate(request) {
501
+ assertHttpUrl(request.url);
502
+ if (request.depth !== undefined) {
503
+ if (!Number.isInteger(request.depth) || request.depth < 1 || request.depth > 5) {
504
+ throw new ValidationError("Crawl depth must be an integer between 1 and 5");
505
+ }
506
+ }
507
+ if (request.breadth !== undefined) {
508
+ if (!Number.isInteger(request.breadth) || request.breadth < 1 || request.breadth > 500) {
509
+ throw new ValidationError("Crawl breadth must be an integer between 1 and 500");
510
+ }
511
+ }
512
+ if (request.limit !== undefined && request.limit <= 0) {
513
+ throw new ValidationError("Crawl limit must be greater than 0");
514
+ }
515
+ },
516
+ cacheIdentity(request) {
517
+ const apiKey = resolveApiKey(env);
518
+ return {
519
+ provider: "tavily",
520
+ capability: "crawl",
521
+ credentialFingerprint: credentialFingerprint(apiKey),
522
+ request,
523
+ };
524
+ },
525
+ decodeCached(value) {
526
+ return decodeCrawlResult(value);
527
+ },
528
+ async invoke(request) {
529
+ fetch.validate(request);
530
+ const apiKey = resolveApiKey(env);
531
+ try {
532
+ const params = mapCrawlControls(request);
533
+ const raw = await fetchTavilyCrawl(apiKey, request.url, params, transport);
534
+ return normalizeTavilyCrawlResult(raw, request);
535
+ }
536
+ catch (error) {
537
+ throw normalizeTavilyError(error);
538
+ }
539
+ },
540
+ };
541
+ return { fetch };
542
+ }
543
+ // ---------------------------------------------------------------------------
544
+ // Map Capability
545
+ // ---------------------------------------------------------------------------
546
+ /**
547
+ * Map a Provider-neutral `MapRequest` into Tavily-native API params.
548
+ *
549
+ * url -> url (handled by transport)
550
+ * depth -> max_depth
551
+ * breadth -> max_breadth
552
+ * limit -> limit
553
+ * selectPaths -> select_paths (split on comma if string)
554
+ * excludePaths -> exclude_paths (split on comma if string)
555
+ * instructions -> instructions
556
+ *
557
+ * Map returns URLs only — no `format`, `extract_depth`, or `timeout` are
558
+ * sent on the /map request body.
559
+ */
560
+ function mapMapControls(request) {
561
+ const params = {};
562
+ if (request.depth !== undefined)
563
+ params.max_depth = request.depth;
564
+ if (request.breadth !== undefined)
565
+ params.max_breadth = request.breadth;
566
+ if (request.limit !== undefined)
567
+ params.limit = request.limit;
568
+ const selectPaths = splitPathPatterns(request.selectPaths);
569
+ if (selectPaths !== undefined)
570
+ params.select_paths = selectPaths;
571
+ const excludePaths = splitPathPatterns(request.excludePaths);
572
+ if (excludePaths !== undefined)
573
+ params.exclude_paths = excludePaths;
574
+ if (request.instructions !== undefined)
575
+ params.instructions = request.instructions;
576
+ return params;
577
+ }
578
+ /**
579
+ * Normalize a raw Tavily map response into a `MapResult`.
580
+ *
581
+ * results (string[]) -> urls
582
+ * baseUrl -> the request URL
583
+ * totalUrls -> results array length
584
+ *
585
+ * Any malformed shape is a retryable `ApiError` 500.
586
+ */
587
+ function normalizeTavilyMapResult(raw, request) {
588
+ if (!isPlainObject(raw)) {
589
+ throw new ApiError("Tavily map returned a malformed response", 500);
590
+ }
591
+ const results = raw.results;
592
+ if (!Array.isArray(results)) {
593
+ throw new ApiError("Tavily map returned a malformed response", 500);
594
+ }
595
+ const urls = [];
596
+ for (const entry of results) {
597
+ if (typeof entry !== "string" || entry.length === 0) {
598
+ throw new ApiError("Tavily map returned a malformed response", 500);
599
+ }
600
+ urls.push(entry);
601
+ }
602
+ return {
603
+ schemaVersion: 1,
604
+ baseUrl: request.url,
605
+ urls,
606
+ totalUrls: urls.length,
607
+ };
608
+ }
609
+ function createTavilyMapCapability(options) {
610
+ const { env, transport } = options;
611
+ const fetch = {
612
+ kind: "map-fetch",
613
+ validate(request) {
614
+ assertHttpUrl(request.url);
615
+ if (request.depth !== undefined) {
616
+ if (!Number.isInteger(request.depth) || request.depth < 1 || request.depth > 5) {
617
+ throw new ValidationError("Map depth must be an integer between 1 and 5");
618
+ }
619
+ }
620
+ if (request.breadth !== undefined) {
621
+ if (!Number.isInteger(request.breadth) || request.breadth < 1 || request.breadth > 500) {
622
+ throw new ValidationError("Map breadth must be an integer between 1 and 500");
623
+ }
624
+ }
625
+ if (request.limit !== undefined && request.limit <= 0) {
626
+ throw new ValidationError("Map limit must be greater than 0");
627
+ }
628
+ },
629
+ cacheIdentity(request) {
630
+ const apiKey = resolveApiKey(env);
631
+ return {
632
+ provider: "tavily",
633
+ capability: "map",
634
+ credentialFingerprint: credentialFingerprint(apiKey),
635
+ request,
636
+ };
637
+ },
638
+ decodeCached(value) {
639
+ return decodeMapResult(value);
640
+ },
641
+ async invoke(request) {
642
+ fetch.validate(request);
643
+ const apiKey = resolveApiKey(env);
644
+ try {
645
+ const params = mapMapControls(request);
646
+ const raw = await fetchTavilyMap(apiKey, request.url, params, transport);
647
+ return normalizeTavilyMapResult(raw, request);
648
+ }
649
+ catch (error) {
650
+ throw normalizeTavilyError(error);
651
+ }
652
+ },
653
+ };
654
+ return { fetch };
655
+ }
656
+ // ---------------------------------------------------------------------------
657
+ // Research Capability (tech-plan §2c, §3)
658
+ // ---------------------------------------------------------------------------
659
+ /**
660
+ * Default polling interval between GET /research/{id} calls. Overridable
661
+ * via `TAVILY_RESEARCH_POLL_INTERVAL_MS` in the transport env so tests
662
+ * can poll instantly.
663
+ */
664
+ const DEFAULT_RESEARCH_POLL_INTERVAL_MS = 5000;
665
+ function resolvePollIntervalMs(env) {
666
+ const raw = env?.TAVILY_RESEARCH_POLL_INTERVAL_MS;
667
+ const parsed = parseInt(raw ?? "", 10);
668
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RESEARCH_POLL_INTERVAL_MS;
669
+ }
670
+ /**
671
+ * Build a `sleep(ms)` from the injected timers so tests that pass fake
672
+ * `setTimeout`/`clearTimeout` through the transport deps also control
673
+ * poll-loop timing. A non-positive interval resolves immediately.
674
+ *
675
+ * When `signal` is supplied, the sleep is abortable: if the signal is
676
+ * already aborted (or aborts while sleeping), the pending timer is
677
+ * cleared and the promise rejects with a `TimeoutError`. This lets the
678
+ * research poll loop unwind promptly when the command handler's
679
+ * `--timeout` fires, so lingering `setTimeout`s do not keep the event
680
+ * loop alive and freeze the CLI. The rejection is swallowed by the
681
+ * command handler's late-rejection guard.
682
+ */
683
+ function makeSleep(deps, signal) {
684
+ const setT = deps?.setTimeout ?? setTimeout;
685
+ const clearT = deps?.clearTimeout ?? clearTimeout;
686
+ return (ms) => new Promise((resolve, reject) => {
687
+ if (signal?.aborted) {
688
+ reject(new TimeoutError(0, "Research polling aborted"));
689
+ return;
690
+ }
691
+ if (ms <= 0) {
692
+ // Yield to the event loop even for a zero interval so the poll
693
+ // loop never starves macrotasks (tests, signal handlers).
694
+ setImmediate(() => {
695
+ if (signal?.aborted) {
696
+ reject(new TimeoutError(0, "Research polling aborted"));
697
+ return;
698
+ }
699
+ resolve();
700
+ });
701
+ return;
702
+ }
703
+ const onAbort = () => {
704
+ clearT(id);
705
+ reject(new TimeoutError(0, "Research polling aborted"));
706
+ };
707
+ const id = setT(() => {
708
+ signal?.removeEventListener("abort", onAbort);
709
+ resolve();
710
+ }, ms);
711
+ signal?.addEventListener("abort", onAbort);
712
+ });
713
+ }
714
+ function isEexistError(err) {
715
+ return (typeof err === "object" &&
716
+ err !== null &&
717
+ "code" in err &&
718
+ err.code === "EEXIST");
719
+ }
720
+ /**
721
+ * Map a Provider-neutral `ResearchRequest` into Tavily-native API params
722
+ * (minus the query, which the transport adds).
723
+ *
724
+ * model -> model
725
+ * outputLength -> output_length
726
+ * citationFormat -> citation_format
727
+ * domain -> domain
728
+ */
729
+ function mapResearchControls(request) {
730
+ const params = {};
731
+ if (request.model !== undefined)
732
+ params.model = request.model;
733
+ if (request.outputLength !== undefined)
734
+ params.output_length = request.outputLength;
735
+ if (request.citationFormat !== undefined)
736
+ params.citation_format = request.citationFormat;
737
+ if (request.domain !== undefined)
738
+ params.domain = request.domain;
739
+ return params;
740
+ }
741
+ /**
742
+ * Normalize a completed Tavily research poll result into a
743
+ * `ResearchResult`.
744
+ *
745
+ * content -> report
746
+ * sources[].title -> sources[].title
747
+ * sources[].url -> sources[].url (favicon dropped)
748
+ * model -> echoed from the request (default "auto")
749
+ */
750
+ function normalizeTavilyResearchResult(poll, request) {
751
+ const sources = [];
752
+ if (poll.sources) {
753
+ for (const entry of poll.sources) {
754
+ // Drop sources missing title or url rather than failing the whole
755
+ // report — a partial source list is still useful.
756
+ if (entry.title && entry.url) {
757
+ sources.push({ title: entry.title, url: entry.url });
758
+ }
759
+ }
760
+ }
761
+ return {
762
+ schemaVersion: 1,
763
+ query: request.query,
764
+ model: request.model ?? "auto",
765
+ report: poll.content ?? "",
766
+ sources,
767
+ };
768
+ }
769
+ function createTavilyResearchCapability(options) {
770
+ const { env, transport, researchStateFile } = options;
771
+ const run = {
772
+ kind: "research-fetch",
773
+ validate(request) {
774
+ if (!request || typeof request.query !== "string" || request.query.trim() === "") {
775
+ throw new ValidationError("Research query must contain at least one non-whitespace character");
776
+ }
777
+ },
778
+ cacheIdentity(request) {
779
+ const apiKey = resolveApiKey(env);
780
+ return {
781
+ provider: "tavily",
782
+ capability: "research",
783
+ credentialFingerprint: credentialFingerprint(apiKey),
784
+ request,
785
+ };
786
+ },
787
+ decodeCached(value) {
788
+ return decodeResearchResult(value);
789
+ },
790
+ async invoke(request, signal) {
791
+ run.validate(request);
792
+ const apiKey = resolveApiKey(env);
793
+ const credFingerprint = credentialFingerprint(apiKey);
794
+ const identityHash = computeResearchStateHash({
795
+ provider: "tavily",
796
+ capability: "research",
797
+ credentialFingerprint: credFingerprint,
798
+ request,
799
+ });
800
+ const pollIntervalMs = resolvePollIntervalMs(transport?.env);
801
+ // Abortable sleep: when `signal` aborts (the command handler's
802
+ // `--timeout` fired), the pending poll-interval timer is cleared
803
+ // and rejects, unwinding the loop so the process can exit.
804
+ const sleep = makeSleep(transport, signal);
805
+ try {
806
+ // 1. Check for an in-flight task (resume after Ctrl-C / crash).
807
+ // A valid state file with a pending/in_progress status means a
808
+ // task was already created server-side — poll it instead of
809
+ // creating a second one (double-charge prevention).
810
+ const existingState = await researchStateFile.read(identityHash);
811
+ let requestId;
812
+ if (existingState !== null) {
813
+ // Resume the existing task — no new POST.
814
+ requestId = existingState.requestId;
815
+ }
816
+ else {
817
+ // 2. No in-flight task: POST to create one. NO retry — a
818
+ // transient POST failure is terminal (the user re-runs);
819
+ // retrying risks a double-charge if the POST succeeded
820
+ // server-side but the response was lost.
821
+ requestId = await createResearchTask(apiKey, request, identityHash, researchStateFile, transport);
822
+ }
823
+ // 3. Poll loop until terminal status.
824
+ for (;;) {
825
+ // Re-check the abort signal each iteration. If the command
826
+ // handler's --timeout fired during the create POST or between
827
+ // sleeps, exit immediately rather than issuing another poll.
828
+ if (signal?.aborted) {
829
+ throw new TimeoutError(0, "Research polling aborted");
830
+ }
831
+ const poll = await pollTavilyResearch(apiKey, requestId, transport);
832
+ if (poll.status === "completed") {
833
+ // Success — delete the state file and return the result.
834
+ await researchStateFile.remove(identityHash);
835
+ return normalizeTavilyResearchResult(poll, request);
836
+ }
837
+ if (poll.status === "failed") {
838
+ // Server-side failure — delete the state file and throw.
839
+ await researchStateFile.remove(identityHash);
840
+ throw new ApiError("Tavily research task failed", 500);
841
+ }
842
+ if (poll.status === "not_found") {
843
+ // 404 — the server-side task expired/disappeared. Delete the
844
+ // stale state file and create a fresh task, then continue
845
+ // polling. The state file is removed first so the `wx`-flag
846
+ // write in createResearchTask succeeds.
847
+ await researchStateFile.remove(identityHash);
848
+ requestId = await createResearchTask(apiKey, request, identityHash, researchStateFile, transport);
849
+ continue;
850
+ }
851
+ // pending or in_progress: sleep and poll again. The state file
852
+ // already holds the requestId; we intentionally do NOT rewrite
853
+ // it here — the `wx` flag only allows creation, and the
854
+ // requestId (not the transient status) is the load-bearing
855
+ // field for resume. The transient poll status is therefore not
856
+ // persisted, which is why no reassignment is needed here.
857
+ await sleep(pollIntervalMs);
858
+ }
859
+ }
860
+ catch (error) {
861
+ throw normalizeTavilyError(error);
862
+ }
863
+ },
864
+ };
865
+ return { run };
866
+ }
867
+ /**
868
+ * POST /research to create a task, then persist its requestId in the
869
+ * state file atomically. On EEXIST (a concurrent invocation already
870
+ * created a task for this request), read the existing state file and
871
+ * return its requestId instead — the concurrent task is polled, not
872
+ * duplicated.
873
+ */
874
+ async function createResearchTask(apiKey, request, identityHash, stateFile, transport) {
875
+ const params = mapResearchControls(request);
876
+ const created = await createTavilyResearch(apiKey, request.query, params, transport);
877
+ const requestId = created.requestId;
878
+ const state = {
879
+ requestId,
880
+ identityHash,
881
+ createdAt: new Date().toISOString(),
882
+ status: "pending",
883
+ };
884
+ try {
885
+ await stateFile.write(identityHash, state);
886
+ }
887
+ catch (err) {
888
+ if (isEexistError(err)) {
889
+ // Concurrent invocation won the race — poll its task instead.
890
+ const existing = await stateFile.read(identityHash);
891
+ if (existing !== null) {
892
+ return existing.requestId;
893
+ }
894
+ // The existing file was corrupt (read returned null after deleting
895
+ // it). Fall through and poll the task we just created — it is
896
+ // valid server-side even if we cannot persist it.
897
+ }
898
+ else {
899
+ throw err;
900
+ }
901
+ }
902
+ return requestId;
903
+ }
904
+ // ---------------------------------------------------------------------------
905
+ // Descriptor factory
906
+ // ---------------------------------------------------------------------------
907
+ /**
908
+ * Build the Tavily Provider Descriptor. The descriptor advertises the
909
+ * full Tavily capability set and constructs an Adapter whose `search`
910
+ * and `reader` Capabilities own credentials, transport, Provider field
911
+ * mapping, and failure normalization. Construction is side-effect-free;
912
+ * the transport is invoked per Capability call. Tests pass `transport`
913
+ * (typically a fake-fetch wrapper); production uses the no-argument
914
+ * factory which resolves to the global `fetch` and timers inside the
915
+ * transport Module.
916
+ */
917
+ export function createTavilyDescriptor(dependencies) {
918
+ const transport = dependencies?.transport;
919
+ const researchStateFile = dependencies?.researchStateFile ?? createProductionResearchStateFile();
920
+ return {
921
+ id: "tavily",
922
+ isConfigured(env) {
923
+ return isTavilyConfigured(env);
924
+ },
925
+ capabilities() {
926
+ return new Set([
927
+ "search",
928
+ "reader",
929
+ "crawl",
930
+ "map",
931
+ "research",
932
+ "quota",
933
+ "diagnostics",
934
+ ]);
935
+ },
936
+ create(context) {
937
+ const search = createTavilySearchCapability({
938
+ env: context.env,
939
+ transport,
940
+ });
941
+ const reader = createTavilyReaderCapability({
942
+ env: context.env,
943
+ transport,
944
+ });
945
+ const crawl = createTavilyCrawlCapability({
946
+ env: context.env,
947
+ transport,
948
+ });
949
+ const map = createTavilyMapCapability({
950
+ env: context.env,
951
+ transport,
952
+ });
953
+ const research = createTavilyResearchCapability({
954
+ env: context.env,
955
+ transport,
956
+ researchStateFile,
957
+ });
958
+ const quota = createTavilyQuotaCapability({
959
+ env: context.env,
960
+ transport,
961
+ });
962
+ const diagnostics = createTavilyDiagnosticsCapability({
963
+ env: context.env,
964
+ transport,
965
+ });
966
+ return { id: "tavily", search, reader, crawl, map, research, quota, diagnostics };
967
+ },
968
+ };
969
+ }
970
+ //# sourceMappingURL=adapter.js.map