scoutline 0.7.0 → 0.10.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 (122) hide show
  1. package/README.md +31 -20
  2. package/dist/capabilities/quota.d.ts +12 -1
  3. package/dist/capabilities/quota.d.ts.map +1 -1
  4. package/dist/capabilities/quota.js.map +1 -1
  5. package/dist/capabilities/search.d.ts +11 -1
  6. package/dist/capabilities/search.d.ts.map +1 -1
  7. package/dist/commands/crawl.js +1 -1
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +13 -10
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/map.js +1 -1
  12. package/dist/commands/quota.d.ts +23 -0
  13. package/dist/commands/quota.d.ts.map +1 -1
  14. package/dist/commands/quota.js +21 -4
  15. package/dist/commands/quota.js.map +1 -1
  16. package/dist/commands/read.js +1 -1
  17. package/dist/commands/repo.js +1 -1
  18. package/dist/commands/research.js +5 -5
  19. package/dist/commands/research.js.map +1 -1
  20. package/dist/commands/search.d.ts +2 -1
  21. package/dist/commands/search.d.ts.map +1 -1
  22. package/dist/commands/search.js +21 -12
  23. package/dist/commands/search.js.map +1 -1
  24. package/dist/index.d.ts +7 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +35 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/lib/async-job-state.d.ts +119 -0
  29. package/dist/lib/async-job-state.d.ts.map +1 -0
  30. package/dist/lib/{research-state.js → async-job-state.js} +43 -34
  31. package/dist/lib/async-job-state.js.map +1 -0
  32. package/dist/lib/cache.d.ts +16 -8
  33. package/dist/lib/cache.d.ts.map +1 -1
  34. package/dist/lib/cache.js +33 -8
  35. package/dist/lib/cache.js.map +1 -1
  36. package/dist/lib/execution.d.ts.map +1 -1
  37. package/dist/lib/execution.js +8 -4
  38. package/dist/lib/execution.js.map +1 -1
  39. package/dist/lib/redact.d.ts +2 -2
  40. package/dist/lib/redact.d.ts.map +1 -1
  41. package/dist/lib/redact.js +16 -2
  42. package/dist/lib/redact.js.map +1 -1
  43. package/dist/providers/brave/adapter.d.ts +42 -0
  44. package/dist/providers/brave/adapter.d.ts.map +1 -0
  45. package/dist/providers/brave/adapter.js +504 -0
  46. package/dist/providers/brave/adapter.js.map +1 -0
  47. package/dist/providers/brave/client.d.ts +161 -0
  48. package/dist/providers/brave/client.d.ts.map +1 -0
  49. package/dist/providers/brave/client.js +297 -0
  50. package/dist/providers/brave/client.js.map +1 -0
  51. package/dist/providers/brave/credentials.d.ts +33 -0
  52. package/dist/providers/brave/credentials.d.ts.map +1 -0
  53. package/dist/providers/brave/credentials.js +55 -0
  54. package/dist/providers/brave/credentials.js.map +1 -0
  55. package/dist/providers/brave/diagnostics.d.ts +46 -0
  56. package/dist/providers/brave/diagnostics.d.ts.map +1 -0
  57. package/dist/providers/brave/diagnostics.js +76 -0
  58. package/dist/providers/brave/diagnostics.js.map +1 -0
  59. package/dist/providers/brave/quota.d.ts +86 -0
  60. package/dist/providers/brave/quota.d.ts.map +1 -0
  61. package/dist/providers/brave/quota.js +247 -0
  62. package/dist/providers/brave/quota.js.map +1 -0
  63. package/dist/providers/exa/adapter.d.ts +58 -0
  64. package/dist/providers/exa/adapter.d.ts.map +1 -0
  65. package/dist/providers/exa/adapter.js +894 -0
  66. package/dist/providers/exa/adapter.js.map +1 -0
  67. package/dist/providers/exa/client.d.ts +143 -0
  68. package/dist/providers/exa/client.d.ts.map +1 -0
  69. package/dist/providers/exa/client.js +328 -0
  70. package/dist/providers/exa/client.js.map +1 -0
  71. package/dist/providers/exa/credentials.d.ts +34 -0
  72. package/dist/providers/exa/credentials.d.ts.map +1 -0
  73. package/dist/providers/exa/credentials.js +56 -0
  74. package/dist/providers/exa/credentials.js.map +1 -0
  75. package/dist/providers/exa/diagnostics.d.ts +42 -0
  76. package/dist/providers/exa/diagnostics.d.ts.map +1 -0
  77. package/dist/providers/exa/diagnostics.js +69 -0
  78. package/dist/providers/exa/diagnostics.js.map +1 -0
  79. package/dist/providers/firecrawl/adapter.d.ts +53 -0
  80. package/dist/providers/firecrawl/adapter.d.ts.map +1 -0
  81. package/dist/providers/firecrawl/adapter.js +962 -0
  82. package/dist/providers/firecrawl/adapter.js.map +1 -0
  83. package/dist/providers/firecrawl/client.d.ts +182 -0
  84. package/dist/providers/firecrawl/client.d.ts.map +1 -0
  85. package/dist/providers/firecrawl/client.js +426 -0
  86. package/dist/providers/firecrawl/client.js.map +1 -0
  87. package/dist/providers/firecrawl/credentials.d.ts +38 -0
  88. package/dist/providers/firecrawl/credentials.d.ts.map +1 -0
  89. package/dist/providers/firecrawl/credentials.js +60 -0
  90. package/dist/providers/firecrawl/credentials.js.map +1 -0
  91. package/dist/providers/firecrawl/diagnostics.d.ts +36 -0
  92. package/dist/providers/firecrawl/diagnostics.d.ts.map +1 -0
  93. package/dist/providers/firecrawl/diagnostics.js +65 -0
  94. package/dist/providers/firecrawl/diagnostics.js.map +1 -0
  95. package/dist/providers/firecrawl/quota.d.ts +46 -0
  96. package/dist/providers/firecrawl/quota.d.ts.map +1 -0
  97. package/dist/providers/firecrawl/quota.js +120 -0
  98. package/dist/providers/firecrawl/quota.js.map +1 -0
  99. package/dist/providers/minimax/adapter.js +1 -1
  100. package/dist/providers/minimax/adapter.js.map +1 -1
  101. package/dist/providers/registry.d.ts.map +1 -1
  102. package/dist/providers/registry.js +6 -0
  103. package/dist/providers/registry.js.map +1 -1
  104. package/dist/providers/tavily/adapter.d.ts +2 -2
  105. package/dist/providers/tavily/adapter.d.ts.map +1 -1
  106. package/dist/providers/tavily/adapter.js +10 -3
  107. package/dist/providers/tavily/adapter.js.map +1 -1
  108. package/dist/providers/types.d.ts +1 -1
  109. package/dist/providers/types.d.ts.map +1 -1
  110. package/dist/providers/types.js +1 -1
  111. package/dist/providers/types.js.map +1 -1
  112. package/dist/providers/zai/adapter.d.ts.map +1 -1
  113. package/dist/providers/zai/adapter.js +7 -1
  114. package/dist/providers/zai/adapter.js.map +1 -1
  115. package/package.json +1 -1
  116. package/dist/lib/research-state.d.ts +0 -104
  117. package/dist/lib/research-state.d.ts.map +0 -1
  118. package/dist/lib/research-state.js.map +0 -1
  119. package/dist/providers/minimax/sdk-client.d.ts +0 -29
  120. package/dist/providers/minimax/sdk-client.d.ts.map +0 -1
  121. package/dist/providers/minimax/sdk-client.js +0 -50
  122. package/dist/providers/minimax/sdk-client.js.map +0 -1
@@ -0,0 +1,894 @@
1
+ /**
2
+ * Exa Provider Adapter (tech-plan §7, Control Mapping, Field Normalization,
3
+ * Failure Normalization).
4
+ *
5
+ * Implements the Exa Provider Descriptor with Search, Reader, Research,
6
+ * and Diagnostics capabilities on top of the direct-HTTP transport
7
+ * (`./client.ts`). The Adapter owns credentials, transport lifecycle,
8
+ * Provider field mapping, and failure normalization; shared execution
9
+ * owns cache and retry policy.
10
+ *
11
+ * Boundary rules (ARCHITECTURE.md §2):
12
+ * - May import capability types, normalized errors, Provider identity
13
+ * types, and the Adapter-local credential and transport Modules.
14
+ * - Must NOT import command presentation, output mode, or another
15
+ * Provider's Adapter.
16
+ *
17
+ * Field mapping (tech-plan §7 Exa mapping):
18
+ * Search results[].title -> title
19
+ * Search results[].url -> url
20
+ * Search results[].highlights[] -> summary (join with " ")
21
+ * Search results[].author -> source
22
+ * Search results[].publishedDate -> date
23
+ * Search results[].score -> (dropped)
24
+ *
25
+ * Control mapping (SearchControls → Exa-native API params):
26
+ * domain -> includeDomains: [domain]
27
+ * recency -> startPublishedDate (oneDay→now-1d ISO, etc.)
28
+ * contentSize -> type (medium/omitted→"auto", high→"deep")
29
+ * topic -> category (general→omit, news→"news",
30
+ * finance→"financial report")
31
+ * location -> REJECTED (UnsupportedOptionError)
32
+ */
33
+ import crypto from "node:crypto";
34
+ import { decodeReaderFetchResult } from "../../capabilities/reader.js";
35
+ import { decodeResearchResult } from "../../capabilities/research.js";
36
+ import { computeAsyncJobStateHash, createProductionAsyncJobStateFile, } from "../../lib/async-job-state.js";
37
+ import { asyncJobStateDir } from "../../lib/cache.js";
38
+ import { ApiError, AuthError, ConfigurationError, NetworkError, QuotaError, TimeoutError, UnsupportedOptionError, ValidationError, } from "../../lib/errors.js";
39
+ import { requireExaApiKey, isExaConfigured } from "./credentials.js";
40
+ import { fetchExaSearch, fetchExaContents, createExaAgentRun, pollExaAgentRun, } from "./client.js";
41
+ import { createExaDiagnosticsCapability } from "./diagnostics.js";
42
+ // ---------------------------------------------------------------------------
43
+ // Provider-owned credential fingerprint
44
+ // ---------------------------------------------------------------------------
45
+ function credentialFingerprint(apiKey) {
46
+ return crypto.createHash("sha256").update(apiKey).digest("hex");
47
+ }
48
+ function resolveApiKey(env) {
49
+ return requireExaApiKey(env);
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers
53
+ // ---------------------------------------------------------------------------
54
+ function isPlainObject(value) {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Control mapping (SearchControls → Exa-native API params)
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * Map a recency filter to an Exa `startPublishedDate` (ISO 8601). Exa
62
+ * accepts a date lower bound; `noLimit` omits the filter. The cutoff is
63
+ * computed relative to `now` so the Adapter can produce a deterministic
64
+ * value in tests.
65
+ */
66
+ function mapRecencyToStartPublishedDate(recency, now) {
67
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
68
+ switch (recency) {
69
+ case "oneDay":
70
+ return new Date(now.getTime() - 1 * MS_PER_DAY).toISOString();
71
+ case "oneWeek":
72
+ return new Date(now.getTime() - 7 * MS_PER_DAY).toISOString();
73
+ case "oneMonth":
74
+ return new Date(now.getTime() - 30 * MS_PER_DAY).toISOString();
75
+ case "oneYear":
76
+ return new Date(now.getTime() - 365 * MS_PER_DAY).toISOString();
77
+ case "noLimit":
78
+ return undefined;
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+ /**
84
+ * Map a topic hint to an Exa `category`. Exa has no `general` category;
85
+ * general is omitted so the search is unscoped. The Tavily adapter
86
+ * passes `topic` natively; Exa remaps to `category`.
87
+ */
88
+ function mapTopicToCategory(topic) {
89
+ switch (topic) {
90
+ case "news":
91
+ return "news";
92
+ case "finance":
93
+ return "financial report";
94
+ case "general":
95
+ return undefined;
96
+ default:
97
+ return undefined;
98
+ }
99
+ }
100
+ /**
101
+ * Map Provider-neutral `SearchControls` to Exa-native search params.
102
+ * `now` defaults to the current time; tests pass a fixed date so the
103
+ * `startPublishedDate` cutoff is deterministic.
104
+ */
105
+ function mapSearchControls(controls, now = new Date()) {
106
+ if (!controls)
107
+ return undefined;
108
+ const params = {};
109
+ if (controls.domain) {
110
+ params.includeDomains = [controls.domain];
111
+ }
112
+ if (controls.recency) {
113
+ const start = mapRecencyToStartPublishedDate(controls.recency, now);
114
+ if (start)
115
+ params.startPublishedDate = start;
116
+ }
117
+ if (controls.contentSize) {
118
+ params.type = controls.contentSize === "high" ? "deep" : "auto";
119
+ }
120
+ if (controls.topic) {
121
+ const category = mapTopicToCategory(controls.topic);
122
+ if (category)
123
+ params.category = category;
124
+ }
125
+ return params;
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Response normalization
129
+ // ---------------------------------------------------------------------------
130
+ /**
131
+ * Normalize a raw Exa search response into `SearchSource[]`.
132
+ *
133
+ * results[].title -> title
134
+ * results[].url -> url
135
+ * results[].highlights[] -> summary (join with " "; empty→"")
136
+ * results[].author -> source
137
+ * results[].publishedDate -> date
138
+ * results[].score -> (dropped)
139
+ *
140
+ * `title` and `url` must be strings; any malformed shape is a retryable
141
+ * `ApiError` 500. `highlights` is optional — when missing or empty,
142
+ * `summary` is the empty string (the result is still returned with its
143
+ * title/url). `author` and `publishedDate` are optional; when present
144
+ * they must be strings.
145
+ */
146
+ function normalizeExaSearchResults(raw) {
147
+ if (!isPlainObject(raw)) {
148
+ throw new ApiError("Exa search returned a malformed response", 500);
149
+ }
150
+ const results = raw.results;
151
+ if (!Array.isArray(results)) {
152
+ throw new ApiError("Exa search returned a malformed response", 500);
153
+ }
154
+ const out = [];
155
+ for (const entry of results) {
156
+ if (!isPlainObject(entry)) {
157
+ throw new ApiError("Exa search returned a malformed response", 500);
158
+ }
159
+ const title = entry.title;
160
+ const url = entry.url;
161
+ if (typeof title !== "string" || typeof url !== "string") {
162
+ throw new ApiError("Exa search returned a malformed response", 500);
163
+ }
164
+ // highlights is an optional string array; join with " ".
165
+ const highlights = entry.highlights;
166
+ let summary;
167
+ if (highlights === undefined) {
168
+ summary = "";
169
+ }
170
+ else if (Array.isArray(highlights)) {
171
+ summary = highlights.filter((h) => typeof h === "string").join(" ");
172
+ }
173
+ else {
174
+ throw new ApiError("Exa search returned a malformed response", 500);
175
+ }
176
+ const source = { title, url, summary };
177
+ if (typeof entry.author === "string") {
178
+ source.source = entry.author;
179
+ }
180
+ if (typeof entry.publishedDate === "string") {
181
+ source.date = entry.publishedDate;
182
+ }
183
+ out.push(source);
184
+ }
185
+ return out;
186
+ }
187
+ // ---------------------------------------------------------------------------
188
+ // Failure normalization: stable public codes, no raw payloads (NFR-006)
189
+ // ---------------------------------------------------------------------------
190
+ /**
191
+ * Resolve a stable HTTP-style status code for retry classification.
192
+ * Explicit terminal client errors (400, 404, 410, 422) map to their
193
+ * real codes; transient failures map to a representative status in the
194
+ * retryable set (429 or any 5xx 500..599 inclusive). Unknown failures
195
+ * default to 500 (transient). When the caller already carries a numeric
196
+ * status (a typed ApiError), that status is honoured directly.
197
+ */
198
+ function inferStatusCode(lower, known) {
199
+ if (typeof known === "number" && Number.isFinite(known))
200
+ return known;
201
+ if (lower.includes("404") || lower.includes("not found"))
202
+ return 404;
203
+ if (lower.includes("400") || lower.includes("bad request"))
204
+ return 400;
205
+ if (lower.includes("410") || lower.includes("gone"))
206
+ return 410;
207
+ if (lower.includes("422") || lower.includes("unprocessable"))
208
+ return 422;
209
+ if (lower.includes("500") || lower.includes("internal"))
210
+ return 500;
211
+ if (lower.includes("502") || lower.includes("bad gateway"))
212
+ return 502;
213
+ if (lower.includes("503") || lower.includes("service unavailable"))
214
+ return 503;
215
+ if (lower.includes("504") || lower.includes("gateway timeout"))
216
+ return 504;
217
+ return 500;
218
+ }
219
+ /**
220
+ * Status-keyed outward message for rewrapped Exa ApiErrors. The rewrap
221
+ * does not echo upstream `error.message` — a future change embedding a
222
+ * raw Provider body in an ApiError message would leak through
223
+ * normalization, the cache, and stdout. Curated constants only.
224
+ */
225
+ function exaApiErrorMessage(statusCode) {
226
+ if (statusCode === 429)
227
+ return "Exa rate limit exceeded";
228
+ return "Exa request failed";
229
+ }
230
+ /**
231
+ * Normalize a Provider failure with sanitized messages. Raw response
232
+ * bodies never cross the adapter boundary. Mirrors the Tavily adapter's
233
+ * `normalizeTavilyError` pattern.
234
+ */
235
+ function normalizeExaError(error) {
236
+ // QuotaError pass-through — terminal retry guarantee preserved.
237
+ if (error instanceof QuotaError)
238
+ return error;
239
+ // Configuration/option/validation errors carry clean, human-authored
240
+ // messages and are safe to surface verbatim.
241
+ if (error instanceof ValidationError ||
242
+ error instanceof UnsupportedOptionError ||
243
+ error instanceof ConfigurationError) {
244
+ return error;
245
+ }
246
+ // Re-wrap typed transport errors with sanitized messages so a raw
247
+ // Provider response body embedded upstream never survives. Code +
248
+ // statusCode (retry signal) are preserved.
249
+ if (error instanceof AuthError) {
250
+ return new AuthError("Exa authentication failed", "EXA_API_KEY");
251
+ }
252
+ if (error instanceof NetworkError) {
253
+ return new NetworkError("Exa network error");
254
+ }
255
+ if (error instanceof TimeoutError) {
256
+ return new TimeoutError(error.durationMs, "Try again or increase timeout with EXA_TIMEOUT env var");
257
+ }
258
+ if (error instanceof ApiError) {
259
+ const statusCode = inferStatusCode("", error.statusCode);
260
+ return new ApiError(exaApiErrorMessage(statusCode), statusCode);
261
+ }
262
+ const message = error instanceof Error ? error.message : String(error);
263
+ const lower = message.toLowerCase();
264
+ if (lower.includes("401") ||
265
+ lower.includes("403") ||
266
+ lower.includes("unauthorized") ||
267
+ lower.includes("forbidden")) {
268
+ return new AuthError("Exa authentication failed");
269
+ }
270
+ if (lower.includes("timeout") || lower.includes("timed out") || lower.includes("etimedout")) {
271
+ return new TimeoutError(30000);
272
+ }
273
+ if (lower.includes("econnrefused") ||
274
+ lower.includes("econnreset") ||
275
+ lower.includes("network") ||
276
+ lower.includes("enotfound") ||
277
+ lower.includes("fetch failed")) {
278
+ return new NetworkError("Exa network error");
279
+ }
280
+ if (lower.includes("429") || lower.includes("rate limit")) {
281
+ return new ApiError("Exa rate limit exceeded", 429);
282
+ }
283
+ return new ApiError("Exa request failed", inferStatusCode(lower));
284
+ }
285
+ function createExaSearchCapability(options) {
286
+ const { env, transport } = options;
287
+ const capability = {
288
+ validate(request) {
289
+ if (!request || typeof request.query !== "string" || request.query.trim() === "") {
290
+ throw new ValidationError("Search query must contain at least one non-whitespace character");
291
+ }
292
+ // Exa supports domain, recency, contentSize, and topic natively.
293
+ // location is Z.AI-specific and rejected before any transport call.
294
+ if (request.controls?.location !== undefined) {
295
+ throw new UnsupportedOptionError("exa", "search", "location");
296
+ }
297
+ },
298
+ cacheIdentity(request) {
299
+ const apiKey = resolveApiKey(env);
300
+ const identityRequest = {
301
+ query: request.query,
302
+ };
303
+ if (request.controls) {
304
+ identityRequest.controls = request.controls;
305
+ }
306
+ return {
307
+ provider: "exa",
308
+ capability: "search",
309
+ credentialFingerprint: credentialFingerprint(apiKey),
310
+ request: identityRequest,
311
+ // Exa never probes legacy keys — no legacyCandidates.
312
+ };
313
+ },
314
+ async invoke(request) {
315
+ capability.validate(request);
316
+ const apiKey = resolveApiKey(env);
317
+ try {
318
+ const params = mapSearchControls(request.controls);
319
+ const raw = await fetchExaSearch(apiKey, request.query, params, transport);
320
+ return normalizeExaSearchResults(raw);
321
+ }
322
+ catch (error) {
323
+ throw normalizeExaError(error);
324
+ }
325
+ },
326
+ };
327
+ return capability;
328
+ }
329
+ // ---------------------------------------------------------------------------
330
+ // Reader validation helpers
331
+ // ---------------------------------------------------------------------------
332
+ /** Options the Exa Reader does NOT accept (Z.AI-only). `retainImages`
333
+ * is accepted but silently ignored (Exa has no equivalent param); the
334
+ * read command handler sends it as `true` by default. */
335
+ const UNSUPPORTED_READER_OPTIONS = [
336
+ "withLinksSummary",
337
+ "noGfm",
338
+ "keepImgDataUrl",
339
+ "withImagesSummary",
340
+ ];
341
+ function assertHttpUrl(url) {
342
+ if (typeof url !== "string" || url.length === 0) {
343
+ throw new ValidationError("Exa reader URL must be a non-empty string");
344
+ }
345
+ if (!/^https?:\/\//.test(url)) {
346
+ throw new ValidationError("URL must start with http:// or https://");
347
+ }
348
+ }
349
+ function assertNoUnsupportedReaderOptions(request) {
350
+ for (const key of UNSUPPORTED_READER_OPTIONS) {
351
+ if (request[key] === true) {
352
+ throw new UnsupportedOptionError("exa", "reader", key);
353
+ }
354
+ }
355
+ }
356
+ // ---------------------------------------------------------------------------
357
+ // Markdown stripping (format: text — best-effort)
358
+ // ---------------------------------------------------------------------------
359
+ /**
360
+ * Best-effort markdown-to-text conversion. Exa always returns text
361
+ * content; when the caller requests `format: "text"`, this strips
362
+ * common markdown markers so the output is closer to plain text.
363
+ * Code blocks and tables degrade (their content is kept but
364
+ * formatting is lost); that is an acceptable edge case for a "rough
365
+ * text" mode. This is adapter-local — no shared stripper exists.
366
+ */
367
+ function stripMarkdown(input) {
368
+ let result = input;
369
+ // Remove fenced code blocks (keep the inner text).
370
+ result = result.replace(/```[\s\S]*?```/g, (block) => block.replace(/```\w*\n?/g, "").replace(/```$/g, ""));
371
+ // Remove inline code backticks.
372
+ result = result.replace(/`([^`]+)`/g, "$1");
373
+ // Images: ![alt](url) → alt.
374
+ result = result.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1");
375
+ // Links: [text](url) → text.
376
+ result = result.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1");
377
+ // Headers: leading # markers.
378
+ result = result.replace(/^#{1,6}\s+/gm, "");
379
+ // Emphasis: **bold**, __bold__, *italic*, _italic_, ~~strike~~.
380
+ result = result.replace(/\*\*(.+?)\*\*/g, "$1");
381
+ result = result.replace(/__(.+?)__/g, "$1");
382
+ result = result.replace(/\*(.+?)\*/g, "$1");
383
+ result = result.replace(/_(.+?)_/g, "$1");
384
+ result = result.replace(/~~(.+?)~~/g, "$1");
385
+ // Blockquotes: leading > markers.
386
+ result = result.replace(/^>\s+/gm, "");
387
+ // Horizontal rules: ---, ***, ___.
388
+ result = result.replace(/^[-*_]{3,}\s*$/gm, "");
389
+ // List markers: -, *, +, 1.
390
+ result = result.replace(/^[\s]*[-*+]\s+/gm, "");
391
+ result = result.replace(/^[\s]*\d+\.\s+/gm, "");
392
+ return result.trim();
393
+ }
394
+ // ---------------------------------------------------------------------------
395
+ // Per-URL status total function (the load-bearing mechanism)
396
+ // ---------------------------------------------------------------------------
397
+ /**
398
+ * Known error-tag → HTTP status mapping. When `error.httpStatusCode` is
399
+ * present on the response, it is preferred over this table for retry
400
+ * classification. Unknown tags default to 500 (retryable).
401
+ */
402
+ const CONTENTS_ERROR_STATUS = {
403
+ CRAWL_NOT_FOUND: 404,
404
+ UNSUPPORTED_URL: 400,
405
+ SOURCE_NOT_AVAILABLE: 403,
406
+ CRAWL_TIMEOUT: 504,
407
+ CRAWL_LIVECRAWL_TIMEOUT: 504,
408
+ CRAWL_UNKNOWN_ERROR: 500,
409
+ };
410
+ /**
411
+ * Normalize a raw Exa `/contents` response into a `ReaderFetchResult`.
412
+ *
413
+ * **Critical mechanism — per-URL status inspection (total function).**
414
+ * `/contents` returns HTTP 200 even when an individual URL fails. The
415
+ * adapter MUST resolve the status entry whose `statuses[].id` matches
416
+ * the requested URL, then apply a total mapping. Match by `id`, never
417
+ * assume `results[0]` is the requested URL.
418
+ *
419
+ * Field mapping:
420
+ * results[].text -> content (the entry whose id/status matches)
421
+ * results[].url -> finalUrl
422
+ * results[].title -> title (coerce blank → null)
423
+ * request.url -> url
424
+ * request.format -> contentFormat (after any text stripping)
425
+ *
426
+ * On `format: "text"`, the content is run through {@link stripMarkdown}
427
+ * (best-effort) and `contentFormat` is set to `"text"`.
428
+ */
429
+ function normalizeExaContentsResult(raw, request) {
430
+ if (!isPlainObject(raw)) {
431
+ throw new ApiError("Exa contents returned a malformed response", 500);
432
+ }
433
+ // Step 1: find the status entry matching the requested URL. Never
434
+ // assume results[0] is the match — the API returns HTTP 200 even on
435
+ // per-URL failure. For a single-URL fetch, accept the sole entry
436
+ // even if its id doesn't exactly match (Exa may normalize URLs).
437
+ const statuses = raw.statuses;
438
+ if (!Array.isArray(statuses)) {
439
+ throw new ApiError("Exa contents returned a malformed response", 500);
440
+ }
441
+ let statusEntry = statuses.find((s) => isPlainObject(s) && typeof s.id === "string" && s.id === request.url);
442
+ // Single-URL fallback: if no exact id match but exactly one status
443
+ // entry exists, accept it. This mirrors the results[] leniency and
444
+ // guards against URL normalization differences.
445
+ if (!statusEntry && statuses.length === 1 && isPlainObject(statuses[0])) {
446
+ statusEntry = statuses[0];
447
+ }
448
+ if (!statusEntry) {
449
+ throw new ApiError("Exa contents returned a malformed response", 500);
450
+ }
451
+ const statusValue = statusEntry.status;
452
+ // Step 2: error path — map the tag + httpStatusCode to a sanitized ApiError.
453
+ if (statusValue !== "success") {
454
+ const errorObj = isPlainObject(statusEntry.error) ? statusEntry.error : {};
455
+ const tag = typeof errorObj.tag === "string" ? errorObj.tag : undefined;
456
+ const httpStatusCode = typeof errorObj.httpStatusCode === "number" ? errorObj.httpStatusCode : undefined;
457
+ const statusCode = httpStatusCode ?? CONTENTS_ERROR_STATUS[tag ?? ""] ?? 500;
458
+ throw new ApiError("Exa contents request failed", statusCode);
459
+ }
460
+ // Step 3: success path — find the matching result in results[].
461
+ const results = raw.results;
462
+ if (!Array.isArray(results)) {
463
+ throw new ApiError("Exa contents returned a malformed response", 500);
464
+ }
465
+ // For a single-URL fetch, the result entry's id or url should match.
466
+ const result = results.find((r) => isPlainObject(r) &&
467
+ ((typeof r.id === "string" && r.id === request.url) ||
468
+ (typeof r.url === "string" && r.url === request.url)));
469
+ // Fall back to the first result if no URL match (single-URL fetch).
470
+ const entry = result ?? (results.length > 0 && isPlainObject(results[0]) ? results[0] : null);
471
+ if (!entry) {
472
+ throw new ApiError("Exa contents returned a malformed response", 500);
473
+ }
474
+ // Step 4: field mapping.
475
+ const content = entry.text;
476
+ if (typeof content !== "string" || content.length === 0) {
477
+ throw new ApiError("Exa contents returned a malformed response", 500);
478
+ }
479
+ const finalUrl = typeof entry.url === "string" && entry.url.length > 0 ? entry.url : request.url;
480
+ const rawTitle = typeof entry.title === "string" ? entry.title.trim() : "";
481
+ const title = rawTitle.length > 0 ? rawTitle : null;
482
+ const requestedFormat = request.format ?? "markdown";
483
+ if (requestedFormat === "text") {
484
+ return {
485
+ schemaVersion: 1,
486
+ url: request.url,
487
+ finalUrl,
488
+ title,
489
+ content: stripMarkdown(content),
490
+ contentFormat: "text",
491
+ };
492
+ }
493
+ return {
494
+ schemaVersion: 1,
495
+ url: request.url,
496
+ finalUrl,
497
+ title,
498
+ content,
499
+ contentFormat: "markdown",
500
+ };
501
+ }
502
+ /**
503
+ * Map a `ReaderFetchRequest` to Exa-native contents params. The CLI
504
+ * `--timeout` is in seconds; Exa's `livecrawlTimeout` is in
505
+ * milliseconds. The conversion (`* 1000`) is validated here so a direct
506
+ * pass-through doesn't send 20ms instead of 20s.
507
+ */
508
+ function mapReaderControls(request) {
509
+ if (typeof request.timeout !== "number" ||
510
+ !Number.isFinite(request.timeout) ||
511
+ request.timeout <= 0) {
512
+ return undefined;
513
+ }
514
+ const livecrawlTimeout = Math.round(request.timeout * 1000);
515
+ if (!Number.isFinite(livecrawlTimeout) || livecrawlTimeout <= 0) {
516
+ return undefined;
517
+ }
518
+ return { livecrawlTimeout };
519
+ }
520
+ function createExaReaderCapability(options) {
521
+ const { env, transport } = options;
522
+ const fetchOp = {
523
+ kind: "reader-fetch",
524
+ validate(request) {
525
+ assertHttpUrl(request.url);
526
+ assertNoUnsupportedReaderOptions(request);
527
+ },
528
+ cacheIdentity(request) {
529
+ const apiKey = resolveApiKey(env);
530
+ return {
531
+ provider: "exa",
532
+ capability: "reader",
533
+ operation: "reader-fetch",
534
+ credentialFingerprint: credentialFingerprint(apiKey),
535
+ request,
536
+ legacyCandidates: [],
537
+ };
538
+ },
539
+ decodeCached(value) {
540
+ return decodeReaderFetchResult(value);
541
+ },
542
+ async invoke(request) {
543
+ fetchOp.validate(request);
544
+ const apiKey = resolveApiKey(env);
545
+ try {
546
+ const params = mapReaderControls(request);
547
+ const raw = await fetchExaContents(apiKey, request.url, params, transport);
548
+ return normalizeExaContentsResult(raw, request);
549
+ }
550
+ catch (error) {
551
+ throw normalizeExaError(error);
552
+ }
553
+ },
554
+ };
555
+ return { fetch: fetchOp };
556
+ }
557
+ // ---------------------------------------------------------------------------
558
+ // Research Capability (the hardest mechanism — tech-plan §3, §7)
559
+ // ---------------------------------------------------------------------------
560
+ const DEFAULT_RESEARCH_POLL_INTERVAL_MS = 5000;
561
+ function resolvePollIntervalMs(env) {
562
+ const raw = env?.EXA_RESEARCH_POLL_INTERVAL_MS;
563
+ const parsed = parseInt(raw ?? "", 10);
564
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RESEARCH_POLL_INTERVAL_MS;
565
+ }
566
+ /**
567
+ * Build an abortable `sleep(ms)` from the injected timers. Copied from
568
+ * the Tavily adapter — same mechanism, different transport type.
569
+ */
570
+ function makeSleep(deps, signal) {
571
+ const setT = deps?.setTimeout ?? setTimeout;
572
+ const clearT = deps?.clearTimeout ?? clearTimeout;
573
+ return (ms) => new Promise((resolve, reject) => {
574
+ if (signal?.aborted) {
575
+ reject(new TimeoutError(0, "Research polling aborted"));
576
+ return;
577
+ }
578
+ if (ms <= 0) {
579
+ setImmediate(() => {
580
+ if (signal?.aborted) {
581
+ reject(new TimeoutError(0, "Research polling aborted"));
582
+ return;
583
+ }
584
+ resolve();
585
+ });
586
+ return;
587
+ }
588
+ const onAbort = () => {
589
+ clearT(id);
590
+ reject(new TimeoutError(0, "Research polling aborted"));
591
+ };
592
+ const id = setT(() => {
593
+ signal?.removeEventListener("abort", onAbort);
594
+ resolve();
595
+ }, ms);
596
+ signal?.addEventListener("abort", onAbort);
597
+ });
598
+ }
599
+ function isEexistError(err) {
600
+ return (typeof err === "object" &&
601
+ err !== null &&
602
+ "code" in err &&
603
+ err.code === "EEXIST");
604
+ }
605
+ /**
606
+ * Map `model` → Exa Agent `effort`. The result echoes the REQUESTED
607
+ * model (not the effort string) so the contract is identical across
608
+ * Tavily and Exa. Exa accepts effort values `low|medium|high|xhigh|
609
+ * auto`; only `low` (mini), `high` (pro), and `auto` are reachable
610
+ * from the Normal command.
611
+ */
612
+ function mapModelToEffort(model) {
613
+ switch (model) {
614
+ case "mini":
615
+ return "low";
616
+ case "pro":
617
+ return "high";
618
+ case "auto":
619
+ default:
620
+ return "auto";
621
+ }
622
+ }
623
+ /**
624
+ * Validate a `ResearchRequest` for Exa. Exa supports `query` and
625
+ * `model` natively; `outputLength`, `citationFormat`, and `domain` are
626
+ * concepts the Agent lacks and are rejected before transport.
627
+ *
628
+ * **OD1 note:** `domain` is rejected for now. It MAY be revalidatable
629
+ * against the pinned Agent's internal search-tool config — track as a
630
+ * follow-up if the config accepts `includeDomains`.
631
+ */
632
+ function assertNoUnsupportedResearchOptions(request) {
633
+ if (request.outputLength !== undefined) {
634
+ throw new UnsupportedOptionError("exa", "research", "outputLength");
635
+ }
636
+ if (request.citationFormat !== undefined) {
637
+ throw new UnsupportedOptionError("exa", "research", "citationFormat");
638
+ }
639
+ if (request.domain !== undefined) {
640
+ throw new UnsupportedOptionError("exa", "research", "domain");
641
+ }
642
+ }
643
+ /**
644
+ * Normalize a completed Exa Agent run's `output` into a
645
+ * `ResearchResult`.
646
+ *
647
+ * output.text -> report
648
+ * output.grounding[].citations[] -> sources[] (flatten {title, url};
649
+ * drop incomplete)
650
+ * output.structured -> ignored (distinct capability)
651
+ * request.model -> model (echoed, NOT effort string)
652
+ */
653
+ function normalizeExaResearchResult(poll, request) {
654
+ const output = poll.output;
655
+ if (!isPlainObject(output)) {
656
+ throw new ApiError("Exa research returned a malformed response", 500);
657
+ }
658
+ const text = output.text;
659
+ if (typeof text !== "string") {
660
+ throw new ApiError("Exa research returned a malformed response", 500);
661
+ }
662
+ const sources = [];
663
+ const grounding = output.grounding;
664
+ if (Array.isArray(grounding)) {
665
+ for (const entry of grounding) {
666
+ if (!isPlainObject(entry))
667
+ continue;
668
+ const citations = entry.citations;
669
+ if (!Array.isArray(citations))
670
+ continue;
671
+ for (const citation of citations) {
672
+ if (!isPlainObject(citation))
673
+ continue;
674
+ if (typeof citation.title === "string" && typeof citation.url === "string") {
675
+ sources.push({ title: citation.title, url: citation.url });
676
+ }
677
+ }
678
+ }
679
+ }
680
+ return {
681
+ schemaVersion: 1,
682
+ query: request.query,
683
+ model: request.model ?? "auto",
684
+ report: text,
685
+ sources,
686
+ };
687
+ }
688
+ /**
689
+ * True when an error from the poll GET is safe to retry. The poll is
690
+ * idempotent — retrying it never creates a new run or charges the
691
+ * account. Only transient failures (429, 5xx, network, timeout) qualify;
692
+ * auth/quota/validation errors are terminal and propagate immediately.
693
+ */
694
+ function isTransientPollError(err) {
695
+ if (err instanceof ApiError && typeof err.statusCode === "number") {
696
+ return err.statusCode === 429 || (err.statusCode >= 500 && err.statusCode <= 599);
697
+ }
698
+ if (err instanceof NetworkError || err instanceof TimeoutError)
699
+ return true;
700
+ return false;
701
+ }
702
+ /**
703
+ * POST /agent/runs to create a task, then persist its run ID in the
704
+ * state file atomically. On EEXIST (a concurrent invocation already
705
+ * created a task for this request), read the existing state file and
706
+ * return its run ID instead — the concurrent task is polled, not
707
+ * duplicated.
708
+ *
709
+ * **OD1 limitation:** the POST happens before the `wx` write, so two
710
+ * callers that both read "absent" can both POST before either writes.
711
+ * This reduces (Ctrl-C+retry) but does NOT eliminate duplicate runs.
712
+ * Same pre-existing Tavily issue Exa inherits.
713
+ */
714
+ async function createResearchTask(apiKey, request, identityHash, stateFile, transport) {
715
+ const agentParams = {
716
+ query: request.query,
717
+ effort: mapModelToEffort(request.model),
718
+ };
719
+ const created = await createExaAgentRun(apiKey, agentParams, transport);
720
+ const runId = created.id;
721
+ const state = {
722
+ requestId: runId,
723
+ identityHash,
724
+ createdAt: new Date().toISOString(),
725
+ status: "pending",
726
+ };
727
+ try {
728
+ await stateFile.write(identityHash, state);
729
+ }
730
+ catch (err) {
731
+ if (isEexistError(err)) {
732
+ const existing = await stateFile.read(identityHash);
733
+ if (existing !== null) {
734
+ return existing.requestId;
735
+ }
736
+ }
737
+ else {
738
+ throw err;
739
+ }
740
+ }
741
+ return runId;
742
+ }
743
+ function createExaResearchCapability(options) {
744
+ const { env, transport, researchStateFile } = options;
745
+ const run = {
746
+ kind: "research-fetch",
747
+ validate(request) {
748
+ if (!request || typeof request.query !== "string" || request.query.trim() === "") {
749
+ throw new ValidationError("Research query must contain at least one non-whitespace character");
750
+ }
751
+ assertNoUnsupportedResearchOptions(request);
752
+ },
753
+ cacheIdentity(request) {
754
+ const apiKey = resolveApiKey(env);
755
+ return {
756
+ provider: "exa",
757
+ capability: "research",
758
+ credentialFingerprint: credentialFingerprint(apiKey),
759
+ request,
760
+ };
761
+ },
762
+ decodeCached(value) {
763
+ return decodeResearchResult(value);
764
+ },
765
+ async invoke(request, signal) {
766
+ run.validate(request);
767
+ const apiKey = resolveApiKey(env);
768
+ const credFingerprint = credentialFingerprint(apiKey);
769
+ const identityHash = computeAsyncJobStateHash({
770
+ provider: "exa",
771
+ capability: "research",
772
+ credentialFingerprint: credFingerprint,
773
+ request,
774
+ });
775
+ const pollIntervalMs = resolvePollIntervalMs(transport?.env);
776
+ const sleep = makeSleep(transport, signal);
777
+ try {
778
+ // 1. Check for an in-flight task (resume after Ctrl-C / crash).
779
+ const existingState = await researchStateFile.read(identityHash);
780
+ let runId;
781
+ if (existingState !== null) {
782
+ runId = existingState.requestId;
783
+ }
784
+ else {
785
+ // 2. No in-flight task: POST to create one. NO retry — a
786
+ // transient POST failure is terminal (double-charge
787
+ // prevention on a usage-based endpoint).
788
+ runId = await createResearchTask(apiKey, request, identityHash, researchStateFile, transport);
789
+ }
790
+ // 3. Poll loop until terminal status.
791
+ // The zero-retry policy wraps the whole invoke() and protects
792
+ // the POST (create). The GET (poll) is idempotent and safe to
793
+ // retry — a transient 429/5xx/network error on poll MUST NOT
794
+ // terminate a paid research run that is still active
795
+ // server-side. So we catch transient poll errors and retry
796
+ // the GET (bounded by MAX_POLL_RETRIES) before propagating.
797
+ const MAX_POLL_RETRIES = 3;
798
+ let consecutivePollFailures = 0;
799
+ for (;;) {
800
+ if (signal?.aborted) {
801
+ throw new TimeoutError(0, "Research polling aborted");
802
+ }
803
+ let poll;
804
+ try {
805
+ poll = await pollExaAgentRun(apiKey, runId, transport);
806
+ consecutivePollFailures = 0;
807
+ }
808
+ catch (pollErr) {
809
+ if (isTransientPollError(pollErr) && consecutivePollFailures < MAX_POLL_RETRIES) {
810
+ consecutivePollFailures++;
811
+ await sleep(pollIntervalMs);
812
+ continue;
813
+ }
814
+ throw pollErr;
815
+ }
816
+ if (poll.status === "completed") {
817
+ await researchStateFile.remove(identityHash);
818
+ return normalizeExaResearchResult(poll, request);
819
+ }
820
+ if (poll.status === "failed") {
821
+ await researchStateFile.remove(identityHash);
822
+ throw new ApiError("Exa research task failed", 500);
823
+ }
824
+ if (poll.status === "cancelled") {
825
+ // Exa-specific: cancelled is terminal (treated as failure).
826
+ await researchStateFile.remove(identityHash);
827
+ throw new ApiError("Exa research task was cancelled", 500);
828
+ }
829
+ if (poll.status === "not_found") {
830
+ // 404 — server-side run expired. Delete stale state and
831
+ // create a fresh run.
832
+ await researchStateFile.remove(identityHash);
833
+ runId = await createResearchTask(apiKey, request, identityHash, researchStateFile, transport);
834
+ continue;
835
+ }
836
+ // queued or running: sleep and poll again.
837
+ await sleep(pollIntervalMs);
838
+ }
839
+ }
840
+ catch (error) {
841
+ throw normalizeExaError(error);
842
+ }
843
+ },
844
+ };
845
+ return { run };
846
+ }
847
+ // ---------------------------------------------------------------------------
848
+ // Descriptor factory
849
+ // ---------------------------------------------------------------------------
850
+ /**
851
+ * Build the Exa Provider Descriptor. The descriptor advertises the Exa
852
+ * capability set (search, reader, research, diagnostics) and constructs
853
+ * an Adapter whose Capabilities own credentials, transport, Provider
854
+ * field mapping, and failure normalization. Construction is
855
+ * side-effect-free; the transport is invoked per Capability call. Tests
856
+ * pass `transport` (typically a fake-fetch wrapper); production uses
857
+ * the no-argument factory which resolves to the global `fetch` and
858
+ * timers inside the transport Module.
859
+ */
860
+ export function createExaDescriptor(dependencies) {
861
+ const transport = dependencies?.transport;
862
+ const researchStateFile = dependencies?.researchStateFile ??
863
+ createProductionAsyncJobStateFile(asyncJobStateDir("research"));
864
+ return {
865
+ id: "exa",
866
+ isConfigured(env) {
867
+ return isExaConfigured(env);
868
+ },
869
+ capabilities() {
870
+ return new Set(["search", "reader", "research", "diagnostics"]);
871
+ },
872
+ create(context) {
873
+ const search = createExaSearchCapability({
874
+ env: context.env,
875
+ transport,
876
+ });
877
+ const reader = createExaReaderCapability({
878
+ env: context.env,
879
+ transport,
880
+ });
881
+ const research = createExaResearchCapability({
882
+ env: context.env,
883
+ transport,
884
+ researchStateFile,
885
+ });
886
+ const diagnostics = createExaDiagnosticsCapability({
887
+ env: context.env,
888
+ transport,
889
+ });
890
+ return { id: "exa", search, reader, research, diagnostics };
891
+ },
892
+ };
893
+ }
894
+ //# sourceMappingURL=adapter.js.map