mcp-researchpowerpack 7.0.11 → 7.0.13

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 (70) hide show
  1. package/dist/index.js +4662 -21
  2. package/dist/index.js.map +4 -4
  3. package/dist/mcp-use.json +2 -2
  4. package/dist/src/clients/jina.js +202 -16
  5. package/dist/src/clients/jina.js.map +3 -3
  6. package/dist/src/clients/kernel.js +254 -7
  7. package/dist/src/clients/kernel.js.map +4 -4
  8. package/dist/src/clients/reddit.js +326 -23
  9. package/dist/src/clients/reddit.js.map +4 -4
  10. package/dist/src/clients/scraper.js +345 -22
  11. package/dist/src/clients/scraper.js.map +4 -4
  12. package/dist/src/clients/search.js +316 -20
  13. package/dist/src/clients/search.js.map +4 -4
  14. package/dist/src/config/index.js +39 -10
  15. package/dist/src/config/index.js.map +3 -3
  16. package/dist/src/effect/errors.js +130 -5
  17. package/dist/src/effect/errors.js.map +3 -3
  18. package/dist/src/effect/runtime.js +1893 -4
  19. package/dist/src/effect/runtime.js.map +4 -4
  20. package/dist/src/effect/services.js +2124 -22
  21. package/dist/src/effect/services.js.map +4 -4
  22. package/dist/src/schemas/scrape-links.js +6 -5
  23. package/dist/src/schemas/scrape-links.js.map +1 -1
  24. package/dist/src/schemas/start-research.js +2 -1
  25. package/dist/src/schemas/start-research.js.map +1 -1
  26. package/dist/src/schemas/web-search.js +9 -8
  27. package/dist/src/schemas/web-search.js.map +1 -1
  28. package/dist/src/services/llm-processor.js +406 -25
  29. package/dist/src/services/llm-processor.js.map +4 -4
  30. package/dist/src/services/markdown-cleaner.js +6 -5
  31. package/dist/src/services/markdown-cleaner.js.map +1 -1
  32. package/dist/src/tools/mcp-helpers.js +2 -1
  33. package/dist/src/tools/mcp-helpers.js.map +1 -1
  34. package/dist/src/tools/registry.js +4629 -3
  35. package/dist/src/tools/registry.js.map +4 -4
  36. package/dist/src/tools/scrape.js +2610 -80
  37. package/dist/src/tools/scrape.js.map +4 -4
  38. package/dist/src/tools/search.js +2388 -59
  39. package/dist/src/tools/search.js.map +4 -4
  40. package/dist/src/tools/start-research.js +2030 -23
  41. package/dist/src/tools/start-research.js.map +4 -4
  42. package/dist/src/tools/utils.js +98 -7
  43. package/dist/src/tools/utils.js.map +3 -3
  44. package/dist/src/utils/concurrency.js +1 -0
  45. package/dist/src/utils/concurrency.js.map +1 -1
  46. package/dist/src/utils/content-extractor.js +27 -2
  47. package/dist/src/utils/content-extractor.js.map +3 -3
  48. package/dist/src/utils/content-quality.js +4 -3
  49. package/dist/src/utils/content-quality.js.map +1 -1
  50. package/dist/src/utils/errors.js +26 -3
  51. package/dist/src/utils/errors.js.map +3 -3
  52. package/dist/src/utils/logger.js +1 -0
  53. package/dist/src/utils/logger.js.map +1 -1
  54. package/dist/src/utils/markdown-formatter.js +1 -0
  55. package/dist/src/utils/markdown-formatter.js.map +1 -1
  56. package/dist/src/utils/query-relax.js +9 -8
  57. package/dist/src/utils/query-relax.js.map +1 -1
  58. package/dist/src/utils/response.js +3 -2
  59. package/dist/src/utils/response.js.map +1 -1
  60. package/dist/src/utils/retry.js +5 -4
  61. package/dist/src/utils/retry.js.map +1 -1
  62. package/dist/src/utils/sanitize.js +4 -3
  63. package/dist/src/utils/sanitize.js.map +1 -1
  64. package/dist/src/utils/source-type.js +4 -3
  65. package/dist/src/utils/source-type.js.map +1 -1
  66. package/dist/src/utils/url-aggregator.js +112 -11
  67. package/dist/src/utils/url-aggregator.js.map +3 -3
  68. package/dist/src/version.js +7 -6
  69. package/dist/src/version.js.map +1 -1
  70. package/package.json +3 -3
@@ -1,50 +1,2580 @@
1
- import { Effect, Either } from "effect";
2
- import {
3
- SCRAPER,
4
- CONCURRENCY,
5
- getCapabilities,
6
- getMissingEnvMessage,
7
- parseEnv
8
- } from "../config/index.js";
9
- import {
10
- rawScrapeLinksParamsSchema,
11
- smartScrapeLinksParamsSchema
12
- } from "../schemas/scrape-links.js";
13
- import { buildScrapeDoProxyUrl, isTerminalReaderError } from "../clients/jina.js";
14
- import { MarkdownCleaner } from "../services/markdown-cleaner.js";
15
- import { createLLMProcessor } from "../services/llm-processor.js";
16
- import { removeMetaTags } from "../utils/markdown-formatter.js";
17
- import { extractReadableContent } from "../utils/content-extractor.js";
18
- import { classifyError, ErrorCode } from "../utils/errors.js";
19
- import { isDocumentUrl } from "../utils/source-type.js";
20
- import { assessMarkdownQuality } from "../utils/content-quality.js";
21
- import { runExternalEffect } from "../effect/runtime.js";
22
- import {
23
- JinaService,
1
+ // src/tools/scrape.ts
2
+ import { Effect as Effect3, Either } from "effect";
3
+
4
+ // src/config/index.ts
5
+ import { Logger } from "mcp-use";
6
+
7
+ // src/version.ts
8
+ import { createRequire } from "module";
9
+ import { fileURLToPath } from "url";
10
+ import { dirname, join } from "path";
11
+ var DEFAULT_PACKAGE_INFO = {
12
+ version: "3.9.5",
13
+ name: "mcp-researchpowerpack-http",
14
+ description: "Research Powerpack MCP Server"
15
+ };
16
+ var packageJson = { ...DEFAULT_PACKAGE_INFO };
17
+ try {
18
+ if (typeof import.meta.url === "string" && import.meta.url.startsWith("file:")) {
19
+ const _require = createRequire(import.meta.url);
20
+ const _dirname = dirname(fileURLToPath(import.meta.url));
21
+ try {
22
+ packageJson = _require(join(_dirname, "..", "package.json"));
23
+ } catch {
24
+ packageJson = _require(join(_dirname, "..", "..", "package.json"));
25
+ }
26
+ }
27
+ } catch {
28
+ }
29
+ var VERSION = packageJson.version;
30
+ var PACKAGE_NAME = packageJson.name;
31
+ var PACKAGE_DESCRIPTION = packageJson.description;
32
+ var USER_AGENT_VERSION = `${PACKAGE_NAME}/${VERSION}`;
33
+
34
+ // src/config/index.ts
35
+ function safeParseInt(value, defaultVal, min, max) {
36
+ const logger2 = Logger.get("config");
37
+ if (!value) {
38
+ return defaultVal;
39
+ }
40
+ const parsed = parseInt(value, 10);
41
+ if (isNaN(parsed)) {
42
+ logger2.warn(`Invalid number "${value}", using default ${defaultVal}`);
43
+ return defaultVal;
44
+ }
45
+ if (parsed < min) {
46
+ logger2.warn(`Value ${parsed} below minimum ${min}, clamping to ${min}`);
47
+ return min;
48
+ }
49
+ if (parsed > max) {
50
+ logger2.warn(`Value ${parsed} above maximum ${max}, clamping to ${max}`);
51
+ return max;
52
+ }
53
+ return parsed;
54
+ }
55
+ var cachedEnv = null;
56
+ function parseEnv() {
57
+ if (cachedEnv) return cachedEnv;
58
+ cachedEnv = {
59
+ SCRAPER_API_KEY: process.env.SCRAPEDO_API_KEY || "",
60
+ SEARCH_API_KEY: process.env.SERPER_API_KEY || void 0,
61
+ REDDIT_CLIENT_ID: process.env.REDDIT_CLIENT_ID || void 0,
62
+ REDDIT_CLIENT_SECRET: process.env.REDDIT_CLIENT_SECRET || void 0,
63
+ JINA_API_KEY: process.env.JINA_API_KEY || void 0,
64
+ KERNEL_API_KEY: process.env.KERNEL_API_KEY || void 0,
65
+ KERNEL_PROJECT: process.env.KERNEL_PROJECT || void 0
66
+ };
67
+ return cachedEnv;
68
+ }
69
+ function getCapabilities() {
70
+ const env = parseEnv();
71
+ return {
72
+ reddit: !!(env.REDDIT_CLIENT_ID && env.REDDIT_CLIENT_SECRET),
73
+ search: !!(env.SEARCH_API_KEY || env.JINA_API_KEY),
74
+ serperSearch: !!env.SEARCH_API_KEY,
75
+ jina: !!env.JINA_API_KEY,
76
+ scraping: !!env.SCRAPER_API_KEY,
77
+ kernel: !!env.KERNEL_API_KEY,
78
+ llmExtraction: getLLMConfigStatus().configured
79
+ };
80
+ }
81
+ function getMissingEnvMessage(capability) {
82
+ const messages = {
83
+ reddit: '\u274C **Reddit scraping unavailable.** Set `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET` to enable threaded Reddit post fetching in `raw-scrape-links` and `smart-scrape-links`.\n\n\u{1F449} Create a Reddit app at: https://www.reddit.com/prefs/apps (select "script" type)',
84
+ search: "\u274C **Search unavailable.** Set `SERPER_API_KEY` or `JINA_API_KEY` to enable `raw-web-search` and `smart-web-search`.\n\n\u{1F449} Serper provides Google SERPs; Jina Search is used as a fallback/search-only provider.",
85
+ serperSearch: "\u274C **Serper search unavailable.** Set `SERPER_API_KEY` to enable Google-backed primary search.",
86
+ jina: "\u274C **Jina unavailable.** Set `JINA_API_KEY` to enable Jina Search and authenticated Jina Reader requests.",
87
+ scraping: "\u26A0\uFE0F **Scrape.do proxy fallback unavailable.** Set `SCRAPEDO_API_KEY` to enable Jina Reader retries through Scrape.do proxy mode.\n\n\u{1F449} Sign up at: https://scrape.do (1,000 free credits)",
88
+ kernel: "\u274C **Kernel browser rendering unavailable.** Set `KERNEL_API_KEY` to enable optional browser-render fallback for raw/smart scraping.",
89
+ llmExtraction: "\u26A0\uFE0F **AI extraction disabled.** Set `LLM_API_KEY`, `LLM_BASE_URL`, and `LLM_MODEL` to enable `smart-web-search` and `smart-scrape-links`.\n\nUse the raw tools when you need markdown without LLM processing."
90
+ };
91
+ return messages[capability];
92
+ }
93
+ var CONCURRENCY = {
94
+ SEARCH: safeParseInt(process.env.CONCURRENCY_SEARCH, 50, 1, 200),
95
+ SCRAPER: safeParseInt(process.env.CONCURRENCY_SCRAPER, 50, 1, 200),
96
+ JINA_READER: safeParseInt(process.env.CONCURRENCY_JINA_READER, 50, 1, 200),
97
+ REDDIT: safeParseInt(process.env.CONCURRENCY_REDDIT, 50, 1, 200),
98
+ LLM_EXTRACTION: safeParseInt(process.env.LLM_CONCURRENCY, 50, 1, 200),
99
+ KERNEL: safeParseInt(process.env.CONCURRENCY_KERNEL, 3, 1, 20)
100
+ };
101
+ var SCRAPER = {
102
+ BATCH_SIZE: 30,
103
+ EXTRACTION_PREFIX: "Extract from document only \u2014 never hallucinate or add external knowledge.",
104
+ EXTRACTION_SUFFIX: "First line = content, not preamble. No confirmation messages."
105
+ };
106
+ var REDDIT = {
107
+ BATCH_SIZE: 10,
108
+ MAX_WORDS_PER_POST: 5e4,
109
+ MAX_WORDS_TOTAL: 5e5,
110
+ MIN_POSTS: 1,
111
+ MAX_POSTS: 50,
112
+ RETRY_COUNT: 5,
113
+ RETRY_DELAYS: [2e3, 4e3, 8e3, 16e3, 32e3]
114
+ };
115
+ var cachedLlmConfigStatus = null;
116
+ function getLLMConfigStatus() {
117
+ if (cachedLlmConfigStatus) return cachedLlmConfigStatus;
118
+ const apiKeyPresent = !!process.env.LLM_API_KEY?.trim();
119
+ const baseUrlPresent = !!process.env.LLM_BASE_URL?.trim();
120
+ const modelPresent = !!process.env.LLM_MODEL?.trim();
121
+ const missingVars = [];
122
+ if (!apiKeyPresent) missingVars.push("LLM_API_KEY");
123
+ if (!baseUrlPresent) missingVars.push("LLM_BASE_URL");
124
+ if (!modelPresent) missingVars.push("LLM_MODEL");
125
+ const configured = missingVars.length === 0;
126
+ cachedLlmConfigStatus = {
127
+ configured,
128
+ apiKeyPresent,
129
+ baseUrlPresent,
130
+ modelPresent,
131
+ missingVars,
132
+ error: configured ? null : `LLM disabled: missing ${missingVars.join(", ")}`
133
+ };
134
+ return cachedLlmConfigStatus;
135
+ }
136
+ var cachedLlmExtraction = null;
137
+ function getLlmExtraction() {
138
+ if (cachedLlmExtraction) return cachedLlmExtraction;
139
+ const apiKey = process.env.LLM_API_KEY?.trim() || "";
140
+ const baseUrl = process.env.LLM_BASE_URL?.trim();
141
+ const model = process.env.LLM_MODEL?.trim();
142
+ const fallbackModel = process.env.LLM_FALLBACK_MODEL?.trim() || "";
143
+ if (apiKey && !baseUrl) {
144
+ throw new Error(
145
+ "LLM_BASE_URL is required when LLM_API_KEY is set. Set LLM_BASE_URL to your OpenAI-compatible endpoint."
146
+ );
147
+ }
148
+ if (apiKey && !model) {
149
+ throw new Error(
150
+ "LLM_MODEL is required when LLM_API_KEY is set."
151
+ );
152
+ }
153
+ cachedLlmExtraction = {
154
+ API_KEY: apiKey,
155
+ BASE_URL: baseUrl || "",
156
+ MODEL: model || "",
157
+ FALLBACK_MODEL: fallbackModel
158
+ };
159
+ return cachedLlmExtraction;
160
+ }
161
+ var LLM_EXTRACTION = new Proxy({}, {
162
+ get(_target, prop) {
163
+ return getLlmExtraction()[prop];
164
+ }
165
+ });
166
+
167
+ // src/schemas/scrape-links.ts
168
+ import { z } from "zod";
169
+ var urlSchema = z.string().url({ message: "scrape: Invalid URL format" }).refine(
170
+ (url) => url.startsWith("http://") || url.startsWith("https://"),
171
+ { message: "scrape: URL must use http:// or https://" }
172
+ ).describe("A fully-qualified HTTP or HTTPS URL to scrape.");
173
+ var urlsSchema = z.array(urlSchema).min(1, { message: "scrape: At least 1 URL required" }).max(50, { message: "scrape: At most 50 URLs allowed per call" }).describe("URLs to fetch in parallel. Reddit post permalinks (`reddit.com/r/<sub>/comments/<id>/...`) are auto-detected and routed through the Reddit API (threaded post + comments). Non-Reddit URLs use Jina Reader first, then Jina Reader through Scrape.do proxy when configured, then optional Kernel browser rendering for web pages.");
174
+ var rawScrapeLinksParamsSchema = z.object({
175
+ urls: urlsSchema,
176
+ extract: z.never().optional()
177
+ }).strict();
178
+ var smartScrapeLinksParamsSchema = z.object({
179
+ urls: z.array(urlSchema).min(1, { message: "scrape: At least 1 URL required" }).max(50, { message: "scrape: At most 50 URLs allowed per call" }).describe("URLs to fetch and extract in parallel. Reddit post permalinks (`reddit.com/r/<sub>/comments/<id>/...`) are auto-detected and routed through the Reddit API (threaded post + comments). Non-Reddit URLs use Jina Reader first, then Jina Reader through Scrape.do proxy when configured, then optional Kernel browser rendering for web pages. Mix reddit + non-reddit URLs freely; branches run concurrently. Prefer contextually grouped batches \u2014 call this tool multiple times in parallel when URL sets are unrelated."),
180
+ extract: z.string().min(1, { message: "smart-scrape-links: extract cannot be empty" }).describe(
181
+ 'Required semantic extraction instruction. Describe the SHAPE of what you want, separated by `|`. The extractor classifies each page (docs / github-thread / reddit / marketing / cve / paper / announcement / qa / blog / changelog / release-notes) and adjusts emphasis per type: preserves numbers/versions/stacktraces verbatim from docs and CVE pages, quotes Reddit/HN with attribution plus sentiment distribution, flags what the page did NOT answer in a "Not found" section, and surfaces referenced-but-unscraped URLs in a "Follow-up signals" section. Good examples: "root cause | affected versions | fix | workarounds | timeline"; "pricing tiers | rate limits | enterprise contact | free-tier quotas"; "maintainer decisions | accepted fix commits | stacktraces | resolved version".'
182
+ )
183
+ }).strict();
184
+
185
+ // src/utils/logger.ts
186
+ import { Logger as Logger2 } from "mcp-use";
187
+ function getLogger(name) {
188
+ return Logger2.get(name);
189
+ }
190
+ function mcpLog(level, message, loggerName) {
191
+ const logger2 = getLogger(loggerName ?? "research-powerpack");
192
+ switch (level) {
193
+ case "debug":
194
+ logger2.debug(message);
195
+ break;
196
+ case "info":
197
+ logger2.info(message);
198
+ break;
199
+ case "warning":
200
+ logger2.warn(message);
201
+ break;
202
+ case "error":
203
+ logger2.error(message);
204
+ break;
205
+ }
206
+ }
207
+
208
+ // src/utils/errors.ts
209
+ var ErrorCode = {
210
+ // Retryable errors
211
+ RATE_LIMITED: "RATE_LIMITED",
212
+ TIMEOUT: "TIMEOUT",
213
+ NETWORK_ERROR: "NETWORK_ERROR",
214
+ SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
215
+ // Non-retryable errors
216
+ AUTH_ERROR: "AUTH_ERROR",
217
+ INVALID_INPUT: "INVALID_INPUT",
218
+ NOT_FOUND: "NOT_FOUND",
219
+ QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
220
+ UNSUPPORTED_BINARY_CONTENT: "UNSUPPORTED_BINARY_CONTENT",
221
+ // Internal errors
222
+ INTERNAL_ERROR: "INTERNAL_ERROR",
223
+ PARSE_ERROR: "PARSE_ERROR",
224
+ UNKNOWN_ERROR: "UNKNOWN_ERROR"
225
+ };
226
+ var DEFAULT_RETRY_OPTIONS = {
227
+ maxRetries: 3,
228
+ baseDelayMs: 1e3,
229
+ maxDelayMs: 3e4,
230
+ retryableStatuses: [408, 429, 500, 502, 503, 504, 510]
231
+ };
232
+ function classifyDomException(error2) {
233
+ if (error2.name === "AbortError") {
234
+ return { code: ErrorCode.TIMEOUT, message: "Request timed out", retryable: true };
235
+ }
236
+ return { code: ErrorCode.UNKNOWN_ERROR, message: error2.message, retryable: false };
237
+ }
238
+ function classifyByErrorCode(error2) {
239
+ const errCode = error2.code;
240
+ if (!errCode) return null;
241
+ const networkErrorMessages = {
242
+ ECONNREFUSED: "Connection refused \u2014 service may be down",
243
+ ECONNRESET: "Connection was reset \u2014 please retry",
244
+ ECONNABORTED: "Connection aborted \u2014 please retry",
245
+ ENOTFOUND: "Service not reachable \u2014 check your network",
246
+ EPIPE: "Connection lost \u2014 please retry",
247
+ EAI_AGAIN: "DNS lookup failed \u2014 check your network"
248
+ };
249
+ if (errCode === "ECONNREFUSED" || errCode === "ENOTFOUND" || errCode === "ECONNRESET") {
250
+ return { code: ErrorCode.NETWORK_ERROR, message: networkErrorMessages[errCode] || "Network connection failed", retryable: true, cause: error2.message };
251
+ }
252
+ if (errCode === "ECONNABORTED" || errCode === "ETIMEDOUT") {
253
+ return { code: ErrorCode.TIMEOUT, message: networkErrorMessages[errCode] || "Request timed out", retryable: true, cause: error2.message };
254
+ }
255
+ return null;
256
+ }
257
+ function classifyByStatusCode(error2) {
258
+ const status = error2.response?.status || error2.status || error2.statusCode;
259
+ if (!status) return null;
260
+ return classifyHttpError(status, error2.message || String(error2));
261
+ }
262
+ function classifyByMessage(message) {
263
+ const lower = message.toLowerCase();
264
+ if (lower.includes("timeout") || lower.includes("timed out") || lower.includes("aborterror")) {
265
+ return { code: ErrorCode.TIMEOUT, message: "Request timed out", retryable: true, cause: message };
266
+ }
267
+ if (lower.includes("rate limit") || lower.includes("too many requests")) {
268
+ return { code: ErrorCode.RATE_LIMITED, message: "Rate limit exceeded", retryable: true, cause: message };
269
+ }
270
+ if (message.includes("API_KEY") || message.includes("api_key") || message.includes("Invalid API")) {
271
+ return { code: ErrorCode.AUTH_ERROR, message: "API key missing or invalid", retryable: false, cause: message };
272
+ }
273
+ if (message.includes("JSON") || message.includes("parse") || message.includes("Unexpected token")) {
274
+ return { code: ErrorCode.PARSE_ERROR, message: "Failed to parse response", retryable: false, cause: message };
275
+ }
276
+ return null;
277
+ }
278
+ function classifyFallback(message, cause) {
279
+ return {
280
+ code: ErrorCode.UNKNOWN_ERROR,
281
+ message,
282
+ retryable: false,
283
+ cause: cause ? String(cause) : void 0
284
+ };
285
+ }
286
+ function classifyError(error2) {
287
+ if (error2 == null) {
288
+ return { code: ErrorCode.UNKNOWN_ERROR, message: "An unknown error occurred", retryable: false };
289
+ }
290
+ if (error2 instanceof DOMException) return classifyDomException(error2);
291
+ if (!isErrorLike(error2)) {
292
+ return { code: ErrorCode.UNKNOWN_ERROR, message: String(error2), retryable: false };
293
+ }
294
+ return classifyByErrorCode(error2) ?? classifyByStatusCode(error2) ?? classifyByMessage(error2.message ?? String(error2)) ?? classifyFallback(error2.message ?? String(error2), error2.cause);
295
+ }
296
+ function isErrorLike(value) {
297
+ return typeof value === "object" && value !== null;
298
+ }
299
+ function classifyHttpError(status, message) {
300
+ switch (status) {
301
+ case 400:
302
+ return { code: ErrorCode.INVALID_INPUT, message: "Bad request", retryable: false, statusCode: status };
303
+ case 401:
304
+ return { code: ErrorCode.AUTH_ERROR, message: "Invalid API key", retryable: false, statusCode: status };
305
+ case 403:
306
+ return { code: ErrorCode.QUOTA_EXCEEDED, message: "Access forbidden or quota exceeded", retryable: false, statusCode: status };
307
+ case 404:
308
+ return { code: ErrorCode.NOT_FOUND, message: "Resource not found", retryable: false, statusCode: status };
309
+ case 408:
310
+ return { code: ErrorCode.TIMEOUT, message: "Request timeout", retryable: true, statusCode: status };
311
+ case 429:
312
+ return { code: ErrorCode.RATE_LIMITED, message: "Rate limit exceeded", retryable: true, statusCode: status };
313
+ case 500:
314
+ return { code: ErrorCode.INTERNAL_ERROR, message: "Server error", retryable: true, statusCode: status };
315
+ case 502:
316
+ return { code: ErrorCode.SERVICE_UNAVAILABLE, message: "Bad gateway", retryable: true, statusCode: status };
317
+ case 503:
318
+ return { code: ErrorCode.SERVICE_UNAVAILABLE, message: "Service unavailable", retryable: true, statusCode: status };
319
+ case 504:
320
+ return { code: ErrorCode.TIMEOUT, message: "Gateway timeout", retryable: true, statusCode: status };
321
+ case 510:
322
+ return { code: ErrorCode.SERVICE_UNAVAILABLE, message: "Request canceled", retryable: true, statusCode: status };
323
+ default:
324
+ if (status >= 500) {
325
+ return { code: ErrorCode.SERVICE_UNAVAILABLE, message: `Server error: ${status}`, retryable: true, statusCode: status };
326
+ }
327
+ if (status >= 400) {
328
+ return { code: ErrorCode.INVALID_INPUT, message: `Client error: ${status}`, retryable: false, statusCode: status };
329
+ }
330
+ return { code: ErrorCode.UNKNOWN_ERROR, message: `HTTP ${status}: ${message}`, retryable: false, statusCode: status };
331
+ }
332
+ }
333
+ function calculateBackoff(attempt, options) {
334
+ const exponentialDelay = options.baseDelayMs * Math.pow(2, attempt);
335
+ const jitter = Math.random() * 0.3 * exponentialDelay;
336
+ return Math.min(exponentialDelay + jitter, options.maxDelayMs);
337
+ }
338
+ function sleep(ms, signal) {
339
+ return new Promise((resolve, reject) => {
340
+ if (signal?.aborted) {
341
+ reject(new DOMException("Aborted", "AbortError"));
342
+ return;
343
+ }
344
+ function onAbort() {
345
+ clearTimeout(timeout);
346
+ reject(new DOMException("Aborted", "AbortError"));
347
+ }
348
+ const timeout = setTimeout(() => {
349
+ if (signal) signal.removeEventListener("abort", onAbort);
350
+ resolve();
351
+ }, ms);
352
+ signal?.addEventListener("abort", onAbort, { once: true });
353
+ if (signal?.aborted) {
354
+ onAbort();
355
+ }
356
+ });
357
+ }
358
+ function fetchWithTimeout(url, options = {}) {
359
+ const { timeoutMs = 3e4, signal: externalSignal, ...fetchOptions } = options;
360
+ const controller = new AbortController();
361
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
362
+ let onExternalAbort;
363
+ if (externalSignal) {
364
+ onExternalAbort = () => controller.abort();
365
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
366
+ if (externalSignal.aborted) {
367
+ controller.abort();
368
+ }
369
+ }
370
+ return fetch(url, { ...fetchOptions, signal: controller.signal }).finally(() => {
371
+ clearTimeout(timeoutId);
372
+ if (externalSignal && onExternalAbort) {
373
+ externalSignal.removeEventListener("abort", onExternalAbort);
374
+ }
375
+ });
376
+ }
377
+ async function withStallProtection(fn, stallMs, maxAttempts = 2, label = "request") {
378
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
379
+ const controller = new AbortController();
380
+ let stallTimer;
381
+ const stallPromise = new Promise((_, reject) => {
382
+ stallTimer = setTimeout(() => {
383
+ controller.abort();
384
+ reject(Object.assign(new Error(`Service temporarily unavailable \u2014 no response received (attempt ${attempt + 1}/${maxAttempts})`), {
385
+ code: "ESTALLED",
386
+ retryable: attempt < maxAttempts - 1
387
+ }));
388
+ }, stallMs);
389
+ });
390
+ let fnPromise;
391
+ try {
392
+ fnPromise = fn(controller.signal);
393
+ const result = await Promise.race([fnPromise, stallPromise]);
394
+ clearTimeout(stallTimer);
395
+ return result;
396
+ } catch (err) {
397
+ fnPromise?.catch(() => {
398
+ });
399
+ clearTimeout(stallTimer);
400
+ const isStall = err instanceof Error && err.code === "ESTALLED";
401
+ if (isStall && attempt < maxAttempts - 1) {
402
+ const backoff = calculateBackoff(attempt, DEFAULT_RETRY_OPTIONS);
403
+ mcpLog("warning", `${label} stalled, retrying in ${backoff}ms (attempt ${attempt + 1})`, "stability");
404
+ await sleep(backoff);
405
+ continue;
406
+ }
407
+ throw err;
408
+ }
409
+ }
410
+ throw new Error(`${label} failed after ${maxAttempts} stall-protection attempts`);
411
+ }
412
+
413
+ // src/utils/retry.ts
414
+ var JITTER_FACTOR = 0.3;
415
+ var EXPONENTIAL_BASE = 2;
416
+ var DEFAULT_BASE_DELAY_MS = 1e3;
417
+ var DEFAULT_MAX_DELAY_MS = 3e4;
418
+ function calculateBackoff2(attempt, baseDelayMs = DEFAULT_BASE_DELAY_MS, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
419
+ const exponentialDelay = baseDelayMs * Math.pow(EXPONENTIAL_BASE, attempt);
420
+ const jitter = JITTER_FACTOR * exponentialDelay * Math.random();
421
+ return Math.min(exponentialDelay + jitter, maxDelayMs);
422
+ }
423
+
424
+ // src/clients/jina.ts
425
+ var JINA_READER_BASE = "https://r.jina.ai/";
426
+ var JINA_SEARCH_BASE = "https://s.jina.ai/";
427
+ var DEFAULT_TIMEOUT_SECONDS = 15;
428
+ var DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_SECONDS * 1e3;
429
+ var MAX_RETRIES = 2;
430
+ var SEARCH_RESULTS_PER_QUERY = 10;
431
+ function buildJinaSearchUrl(query) {
432
+ const params = new URLSearchParams({ q: query });
433
+ return `${JINA_SEARCH_BASE}?${params.toString()}`;
434
+ }
435
+ function buildScrapeDoProxyUrl(token, parameters = "render=false") {
436
+ const trimmed = token.trim();
437
+ if (!trimmed) return "";
438
+ return `http://${encodeURIComponent(trimmed)}:${parameters}@proxy.scrape.do:8080`;
439
+ }
440
+ var JinaClient = class {
441
+ apiKey;
442
+ constructor(apiKey) {
443
+ const fromEnv = process.env.JINA_API_KEY?.trim();
444
+ this.apiKey = apiKey?.trim() || fromEnv || void 0;
445
+ }
446
+ /**
447
+ * Convert a URL to markdown via Jina Reader.
448
+ * NEVER throws — always returns a JinaConvertResponse (possibly with error).
449
+ */
450
+ async convert(request) {
451
+ const {
452
+ url,
453
+ timeoutSeconds = DEFAULT_TIMEOUT_SECONDS,
454
+ proxyUrl,
455
+ noCache = false,
456
+ allowProxyRetry = false
457
+ } = request;
458
+ try {
459
+ new URL(url);
460
+ } catch {
461
+ return {
462
+ content: `Invalid URL: ${url}`,
463
+ statusCode: 400,
464
+ credits: 0,
465
+ error: { code: ErrorCode.INVALID_INPUT, message: `Invalid URL: ${url}`, retryable: false }
466
+ };
467
+ }
468
+ const first = await this.convertOnce({
469
+ url,
470
+ timeoutSeconds,
471
+ proxyUrl,
472
+ noCache
473
+ });
474
+ if (!first.error || !allowProxyRetry || proxyUrl || isTerminalReaderError(first.error)) {
475
+ return first;
476
+ }
477
+ mcpLog("warning", `Jina Reader failed for ${url}; retrying with Jina proxy`, "jina");
478
+ return this.convertOnce({
479
+ url,
480
+ timeoutSeconds,
481
+ proxyUrl: "auto",
482
+ noCache: true
483
+ });
484
+ }
485
+ async searchMultiple(queries) {
486
+ const startTime = Date.now();
487
+ if (queries.length === 0) {
488
+ return {
489
+ searches: [],
490
+ totalQueries: 0,
491
+ executionTime: 0,
492
+ error: { code: ErrorCode.INVALID_INPUT, message: "No queries provided", retryable: false }
493
+ };
494
+ }
495
+ if (!this.apiKey) {
496
+ return {
497
+ searches: [],
498
+ totalQueries: queries.length,
499
+ executionTime: Date.now() - startTime,
500
+ error: { code: ErrorCode.AUTH_ERROR, message: "Jina Search requires JINA_API_KEY", retryable: false }
501
+ };
502
+ }
503
+ const searches = await Promise.all(queries.map((query) => this.searchOne(query)));
504
+ const firstError = searches.find((search) => search.error)?.error;
505
+ const allFailed = searches.every((search) => search.error);
506
+ return {
507
+ searches,
508
+ totalQueries: queries.length,
509
+ executionTime: Date.now() - startTime,
510
+ ...allFailed && firstError ? { error: firstError } : {}
511
+ };
512
+ }
513
+ async convertOnce(request) {
514
+ const headers = {
515
+ Accept: "application/json",
516
+ "Content-Type": "application/json"
517
+ };
518
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
519
+ if (request.proxyUrl && request.proxyUrl !== "auto") {
520
+ headers["X-Proxy-Url"] = request.proxyUrl;
521
+ }
522
+ const body = {
523
+ url: request.url,
524
+ respondWith: "markdown",
525
+ timeout: request.timeoutSeconds,
526
+ base: "final",
527
+ removeOverlay: true
528
+ };
529
+ if (request.proxyUrl === "auto") body["proxy"] = "auto";
530
+ if (request.noCache) body["noCache"] = true;
531
+ return this.fetchReader(body, headers, request.timeoutSeconds);
532
+ }
533
+ async fetchReader(body, headers, timeoutSeconds) {
534
+ let lastError;
535
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
536
+ try {
537
+ const response = await fetchWithTimeout(JINA_READER_BASE, {
538
+ method: "POST",
539
+ headers,
540
+ body: JSON.stringify(body),
541
+ timeoutMs: (timeoutSeconds + 5) * 1e3
542
+ });
543
+ const raw = await response.text().catch(
544
+ (readError) => `Failed to read Jina response: ${readError instanceof Error ? readError.message : String(readError)}`
545
+ );
546
+ const usageHeader = response.headers.get("x-usage-tokens");
547
+ const usageTokens = usageHeader ? Number(usageHeader) : void 0;
548
+ const parsed = parseReaderContent(raw);
549
+ if (response.ok) {
550
+ if (!parsed.content.trim()) {
551
+ return emptyReaderResponse(response.status, usageTokens);
552
+ }
553
+ return {
554
+ content: parsed.content,
555
+ statusCode: response.status,
556
+ credits: 0,
557
+ usageTokens: Number.isFinite(usageTokens) ? usageTokens : void 0
558
+ };
559
+ }
560
+ const terminal = terminalReaderResponse(response.status, parsed.content || raw);
561
+ if (terminal) return terminal;
562
+ lastError = classifyError({ status: response.status, message: raw.slice(0, 200) });
563
+ if (lastError.retryable && attempt < MAX_RETRIES) {
564
+ const delayMs = calculateBackoff2(attempt);
565
+ mcpLog(
566
+ "warning",
567
+ `Jina ${response.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1}. Retrying in ${delayMs}ms`,
568
+ "jina"
569
+ );
570
+ await sleep(delayMs);
571
+ continue;
572
+ }
573
+ return {
574
+ content: `Jina Reader error (${response.status}): ${raw.slice(0, 200)}`,
575
+ statusCode: response.status,
576
+ credits: 0,
577
+ error: lastError
578
+ };
579
+ } catch (error2) {
580
+ lastError = classifyError(error2);
581
+ if (lastError.retryable && attempt < MAX_RETRIES) {
582
+ const delayMs = calculateBackoff2(attempt);
583
+ mcpLog(
584
+ "warning",
585
+ `Jina ${lastError.code}: ${lastError.message}. Retry ${attempt + 1}/${MAX_RETRIES + 1} in ${delayMs}ms`,
586
+ "jina"
587
+ );
588
+ await sleep(delayMs);
589
+ continue;
590
+ }
591
+ return {
592
+ content: `Jina Reader failed: ${lastError.message}`,
593
+ statusCode: lastError.statusCode ?? 500,
594
+ credits: 0,
595
+ error: lastError
596
+ };
597
+ }
598
+ }
599
+ return {
600
+ content: `Jina Reader failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`,
601
+ statusCode: lastError?.statusCode ?? 500,
602
+ credits: 0,
603
+ error: lastError ?? { code: ErrorCode.UNKNOWN_ERROR, message: "All retries exhausted", retryable: false }
604
+ };
605
+ }
606
+ async searchOne(query) {
607
+ const headers = {
608
+ Accept: "application/json",
609
+ Authorization: `Bearer ${this.apiKey ?? ""}`
610
+ };
611
+ try {
612
+ const response = await fetchWithTimeout(buildJinaSearchUrl(query), {
613
+ method: "GET",
614
+ headers,
615
+ timeoutMs: DEFAULT_TIMEOUT_MS
616
+ });
617
+ const raw = await response.text().catch(
618
+ (readError) => `Failed to read Jina Search response: ${readError instanceof Error ? readError.message : String(readError)}`
619
+ );
620
+ if (!response.ok) {
621
+ return {
622
+ query,
623
+ results: [],
624
+ totalResults: 0,
625
+ related: [],
626
+ error: classifyError({ status: response.status, message: raw.slice(0, 200) })
627
+ };
628
+ }
629
+ const results = parseSearchResults(raw);
630
+ return { query, results, totalResults: results.length, related: [] };
631
+ } catch (error2) {
632
+ return {
633
+ query,
634
+ results: [],
635
+ totalResults: 0,
636
+ related: [],
637
+ error: classifyError(error2)
638
+ };
639
+ }
640
+ }
641
+ };
642
+ function parseReaderContent(raw) {
643
+ try {
644
+ const parsed = JSON.parse(raw);
645
+ const data = readRecord(parsed, "data");
646
+ const content = readString(data, "content");
647
+ if (content) return { content };
648
+ } catch {
649
+ }
650
+ return { content: raw };
651
+ }
652
+ function emptyReaderResponse(statusCode, usageTokens) {
653
+ return {
654
+ content: "Jina returned an empty body",
655
+ statusCode,
656
+ credits: 0,
657
+ usageTokens: Number.isFinite(usageTokens) ? usageTokens : void 0,
658
+ error: {
659
+ code: ErrorCode.UNSUPPORTED_BINARY_CONTENT,
660
+ message: "Jina Reader returned empty content for this URL",
661
+ retryable: false
662
+ }
663
+ };
664
+ }
665
+ function terminalReaderResponse(statusCode, content) {
666
+ if (statusCode === 401 || statusCode === 403) {
667
+ return {
668
+ content: `Jina auth/quota error (${statusCode}): ${content.slice(0, 200)}`,
669
+ statusCode,
670
+ credits: 0,
671
+ error: {
672
+ code: statusCode === 401 ? ErrorCode.AUTH_ERROR : ErrorCode.QUOTA_EXCEEDED,
673
+ message: statusCode === 401 ? "Jina Reader auth failed \u2014 check JINA_API_KEY" : "Jina Reader quota exceeded",
674
+ retryable: false,
675
+ statusCode
676
+ }
677
+ };
678
+ }
679
+ if (statusCode === 404) {
680
+ return {
681
+ content: "Jina could not fetch the target URL (404)",
682
+ statusCode: 404,
683
+ credits: 0,
684
+ error: {
685
+ code: ErrorCode.NOT_FOUND,
686
+ message: "Target URL not reachable by Jina Reader",
687
+ retryable: false,
688
+ statusCode: 404
689
+ }
690
+ };
691
+ }
692
+ if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
693
+ return {
694
+ content: `Jina Reader error (${statusCode}): ${content.slice(0, 200)}`,
695
+ statusCode,
696
+ credits: 0,
697
+ error: {
698
+ code: ErrorCode.INVALID_INPUT,
699
+ message: `Jina Reader returned ${statusCode}`,
700
+ retryable: false,
701
+ statusCode
702
+ }
703
+ };
704
+ }
705
+ return null;
706
+ }
707
+ function isTerminalReaderError(error2) {
708
+ return !error2.retryable && (error2.code === ErrorCode.AUTH_ERROR || error2.code === ErrorCode.QUOTA_EXCEEDED || error2.code === ErrorCode.NOT_FOUND || error2.code === ErrorCode.INVALID_INPUT);
709
+ }
710
+ function parseSearchResults(raw) {
711
+ let data;
712
+ try {
713
+ const parsed = JSON.parse(raw);
714
+ data = readUnknown(parsed, "data");
715
+ } catch {
716
+ data = parseMarkdownSearchResults(raw);
717
+ }
718
+ const items = Array.isArray(data) ? data : [];
719
+ return items.map((item, index) => normalizeSearchItem(item, index)).filter((item) => item !== null).slice(0, SEARCH_RESULTS_PER_QUERY);
720
+ }
721
+ function normalizeSearchItem(item, index) {
722
+ const link = readString(item, "url") ?? readString(item, "link");
723
+ if (!link) return null;
724
+ return {
725
+ title: readString(item, "title") || link,
726
+ link,
727
+ snippet: (readString(item, "snippet") || readString(item, "description") || readString(item, "content") || "").slice(0, 500),
728
+ date: readString(item, "date") ?? readString(item, "publishedTime"),
729
+ position: index + 1
730
+ };
731
+ }
732
+ function parseMarkdownSearchResults(raw) {
733
+ const items = [];
734
+ const markdownLink = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
735
+ let match;
736
+ while ((match = markdownLink.exec(raw)) !== null && items.length < SEARCH_RESULTS_PER_QUERY) {
737
+ const title = match[1];
738
+ const url = match[2];
739
+ if (title && url) items.push({ title, url });
740
+ }
741
+ return items;
742
+ }
743
+ function isRecord(value) {
744
+ return typeof value === "object" && value !== null;
745
+ }
746
+ function readUnknown(value, key) {
747
+ return isRecord(value) ? value[key] : void 0;
748
+ }
749
+ function readRecord(value, key) {
750
+ const child = readUnknown(value, key);
751
+ return isRecord(child) ? child : void 0;
752
+ }
753
+ function readString(value, key) {
754
+ const child = readUnknown(value, key);
755
+ return typeof child === "string" ? child : void 0;
756
+ }
757
+
758
+ // src/services/markdown-cleaner.ts
759
+ import { Logger as Logger3 } from "mcp-use";
760
+ import TurndownService from "turndown";
761
+ var logger = Logger3.get("markdown-cleaner");
762
+ var turndown = new TurndownService({
763
+ headingStyle: "atx",
764
+ codeBlockStyle: "fenced",
765
+ bulletListMarker: "-"
766
+ });
767
+ turndown.remove(["script", "style", "nav", "footer", "aside", "noscript"]);
768
+ var MAX_CONTENT_LENGTH = 524288;
769
+ function removeHtmlComments(html) {
770
+ const parts = [];
771
+ let pos = 0;
772
+ while (pos < html.length) {
773
+ const start = html.indexOf("<!--", pos);
774
+ if (start === -1) {
775
+ parts.push(html.substring(pos));
776
+ break;
777
+ }
778
+ if (start > pos) parts.push(html.substring(pos, start));
779
+ const end = html.indexOf("-->", start + 4);
780
+ if (end === -1) {
781
+ parts.push(html.substring(start));
782
+ break;
783
+ }
784
+ pos = end + 3;
785
+ }
786
+ return parts.join("");
787
+ }
788
+ var MarkdownCleaner = class {
789
+ /**
790
+ * Process HTML content and convert to clean Markdown
791
+ * NEVER throws - returns original content on any error for graceful degradation
792
+ */
793
+ processContent(htmlContent) {
794
+ try {
795
+ if (!htmlContent || typeof htmlContent !== "string") {
796
+ return htmlContent || "";
797
+ }
798
+ if (!htmlContent.includes("<")) {
799
+ return htmlContent.trim();
800
+ }
801
+ if (htmlContent.length > MAX_CONTENT_LENGTH) {
802
+ htmlContent = htmlContent.substring(0, MAX_CONTENT_LENGTH);
803
+ }
804
+ let content = removeHtmlComments(htmlContent);
805
+ content = turndown.turndown(content);
806
+ content = content.replace(/\n{3,}/g, "\n\n");
807
+ content = content.trim();
808
+ return content;
809
+ } catch (error2) {
810
+ logger.warn(
811
+ `processContent failed: ${error2 instanceof Error ? error2.message : String(error2)} | Content length: ${htmlContent?.length ?? 0}`
812
+ );
813
+ return htmlContent || "";
814
+ }
815
+ }
816
+ };
817
+
818
+ // src/services/llm-processor.ts
819
+ import OpenAI from "openai";
820
+
821
+ // src/schemas/web-search.ts
822
+ import { z as z2 } from "zod";
823
+ var QUERY_REWRITE_PAIR_EXAMPLES = [
824
+ 'Bad: `<feature> support` \u2192 Better: `site:<official-docs-domain> "<feature>" "<platform-or-version>"`',
825
+ 'Bad: `<product> pricing` \u2192 Better: `site:<vendor-domain> "<product>" pricing "enterprise" OR "free tier"`',
826
+ 'Bad: `<library> bug fix` \u2192 Better: `"<exact error text>" "<library-or-package>" "<version>" site:github.com`',
827
+ 'Bad: `<tool> reviews` \u2192 Better: `site:reddit.com/r/<community>/comments "<tool>" "migration" OR "regression"`'
828
+ ];
829
+ var QUERY_REWRITE_PAIR_GUIDANCE = [
830
+ "Write Google retrieval probes, not topic labels.",
831
+ "For each broad idea, rewrite it into a query that names the evidence source class, discriminating anchor terms, and one useful operator when possible.",
832
+ "Use rewrite-pair thinking before searching:",
833
+ ...QUERY_REWRITE_PAIR_EXAMPLES,
834
+ "Do not repeat the same noun phrase with adjectives changed; fan out by source type and evidence need."
835
+ ];
836
+ var QUERY_REWRITE_PAIR_GUIDANCE_TEXT = QUERY_REWRITE_PAIR_GUIDANCE.join(" ");
837
+ var keywordSchema = z2.string().min(1, { message: "search: Keyword cannot be empty" }).describe(
838
+ `A single search keyword/query. Each item runs as a separate parallel search. ${QUERY_REWRITE_PAIR_GUIDANCE_TEXT}`
839
+ );
840
+ var keywordsSchema = z2.array(keywordSchema).min(1, { message: "search: At least 1 keyword required" }).max(50, { message: "search: At most 50 keywords allowed per call" }).describe(
841
+ `Search keywords to run in parallel. Serper is primary when configured; Jina Search is fallback when Serper is missing, fails, or yields empty query results. ${QUERY_REWRITE_PAIR_GUIDANCE_TEXT} Think of keywords as retrieval probes, not topic labels. Pack distinct facets in one call: official docs, implementation, failures, comparisons, sentiment, changelog, CVE, pricing, or other source classes.`
842
+ );
843
+ var rawWebSearchParamsSchema = z2.object({
844
+ keywords: keywordsSchema,
845
+ extract: z2.never().optional(),
846
+ scope: z2.never().optional(),
847
+ verbose: z2.never().optional()
848
+ }).strict();
849
+ var smartWebSearchParamsSchema = z2.object({
850
+ keywords: keywordsSchema,
851
+ extract: z2.string().min(1, { message: "smart-web-search: extract cannot be empty" }).describe(
852
+ 'Semantic instruction for the relevance classifier \u2014 what "relevant" means for THIS goal. This is the post-sort target, so name the evidence you need and the source-of-truth expectation: e.g. official docs/release notes for specs, issue/PR/error text for bugs, Reddit/HN/blogs for lived experience, vendor pricing pages for pricing, CVE databases for security. Drives tiering (HIGHLY_RELEVANT / MAYBE_RELEVANT / OTHER), synthesis, gap analysis, and refine-query suggestions. Be specific: "OAuth 2.1 support in TypeScript MCP frameworks \u2014 runnable code, not marketing", not "MCP OAuth".'
853
+ ),
854
+ scope: z2.enum(["web", "reddit", "both"]).default("web").describe(
855
+ 'Search scope. "web" (default) = open web, no augmentation. "reddit" = server appends `site:reddit.com` to every keyword and filters results to post permalinks (`/r/.+/comments/[a-z0-9]+/`); subreddit homepages are dropped. "both" = runs every keyword twice (open web + reddit-scoped), merges the result set, and tags each row with its source. Use "reddit" for sentiment/migration/lived-experience research; use "both" when opinion-heavy AND official sources also matter.'
856
+ ),
857
+ verbose: z2.boolean().default(false).describe(
858
+ "Include per-row scoring/coverage metadata, the trailing Signals block, and CONSENSUS labels even when they carry little signal. Default false."
859
+ )
860
+ }).strict();
861
+
862
+ // src/services/llm-processor.ts
863
+ var MAX_LLM_INPUT_CHARS = 5e5;
864
+ var MAX_PRIMARY_MODEL_INPUT_CHARS = 1e5;
865
+ var LLM_CLIENT_TIMEOUT_MS = 6e5;
866
+ var BACKOFF_JITTER_FACTOR = 0.3;
867
+ var LLM_STALL_TIMEOUT_MS = 75e3;
868
+ var LLM_REQUEST_DEADLINE_MS = 15e4;
869
+ var llmHealth = {
870
+ lastPlannerOk: false,
871
+ lastExtractorOk: false,
872
+ lastPlannerCheckedAt: null,
873
+ lastExtractorCheckedAt: null,
874
+ lastPlannerError: null,
875
+ lastExtractorError: null,
876
+ consecutivePlannerFailures: 0,
877
+ consecutiveExtractorFailures: 0
878
+ };
879
+ function markLLMSuccess(kind) {
880
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
881
+ if (kind === "planner") {
882
+ llmHealth.lastPlannerOk = true;
883
+ llmHealth.lastPlannerCheckedAt = ts;
884
+ llmHealth.lastPlannerError = null;
885
+ llmHealth.consecutivePlannerFailures = 0;
886
+ } else {
887
+ llmHealth.lastExtractorOk = true;
888
+ llmHealth.lastExtractorCheckedAt = ts;
889
+ llmHealth.lastExtractorError = null;
890
+ llmHealth.consecutiveExtractorFailures = 0;
891
+ }
892
+ }
893
+ function markLLMFailure(kind, err) {
894
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
895
+ const message = err instanceof Error ? err.message : String(err ?? "unknown error");
896
+ if (kind === "planner") {
897
+ llmHealth.lastPlannerOk = false;
898
+ llmHealth.lastPlannerCheckedAt = ts;
899
+ llmHealth.lastPlannerError = message;
900
+ llmHealth.consecutivePlannerFailures += 1;
901
+ } else {
902
+ llmHealth.lastExtractorOk = false;
903
+ llmHealth.lastExtractorCheckedAt = ts;
904
+ llmHealth.lastExtractorError = message;
905
+ llmHealth.consecutiveExtractorFailures += 1;
906
+ }
907
+ }
908
+ var LLM_RETRY_CONFIG = {
909
+ maxRetries: 2,
910
+ baseDelayMs: 1e3,
911
+ maxDelayMs: 5e3
912
+ };
913
+ var FALLBACK_RETRY_COUNT = 3;
914
+ var RETRYABLE_LLM_ERROR_CODES = /* @__PURE__ */ new Set([
915
+ "rate_limit_exceeded",
916
+ "server_error",
917
+ "timeout",
918
+ "service_unavailable"
919
+ ]);
920
+ function hasStatus(error2) {
921
+ return typeof error2 === "object" && error2 !== null && "status" in error2 && typeof error2.status === "number";
922
+ }
923
+ var llmClient = null;
924
+ function createLLMProcessor() {
925
+ if (!getCapabilities().llmExtraction) return null;
926
+ if (!llmClient) {
927
+ llmClient = new OpenAI({
928
+ baseURL: LLM_EXTRACTION.BASE_URL,
929
+ apiKey: LLM_EXTRACTION.API_KEY,
930
+ timeout: LLM_CLIENT_TIMEOUT_MS,
931
+ maxRetries: 0,
932
+ defaultHeaders: { "X-Title": "mcp-research-powerpack" }
933
+ });
934
+ mcpLog("info", `LLM extraction configured (model: ${LLM_EXTRACTION.MODEL}, baseURL: ${LLM_EXTRACTION.BASE_URL})`, "llm");
935
+ }
936
+ return llmClient;
937
+ }
938
+ function buildChatRequestBody(model, prompt) {
939
+ return {
940
+ model,
941
+ messages: [{ role: "user", content: prompt }],
942
+ reasoning_effort: "low"
943
+ };
944
+ }
945
+ function normalizeProviderError(err, message) {
946
+ if (typeof err === "object" && err !== null) return err;
947
+ return new Error(message);
948
+ }
949
+ function getProviderFailure(response) {
950
+ if (response.content !== null || response.failureKind !== "provider") return null;
951
+ return response.errorCause;
952
+ }
953
+ function emptyLLMExtractionResult(content) {
954
+ return {
955
+ content,
956
+ processed: false,
957
+ error: "LLM returned empty response",
958
+ errorDetails: {
959
+ code: ErrorCode.INTERNAL_ERROR,
960
+ message: "LLM returned empty response",
961
+ retryable: false
962
+ }
963
+ };
964
+ }
965
+ async function requestText(processor, prompt, operationLabel, signal, modelOverride) {
966
+ const model = modelOverride || LLM_EXTRACTION.MODEL;
967
+ try {
968
+ const response = await withStallProtection(
969
+ (stallSignal) => processor.chat.completions.create(
970
+ buildChatRequestBody(model, prompt),
971
+ {
972
+ signal: signal ? AbortSignal.any([stallSignal, signal]) : stallSignal,
973
+ timeout: LLM_REQUEST_DEADLINE_MS
974
+ }
975
+ ),
976
+ LLM_STALL_TIMEOUT_MS,
977
+ 3,
978
+ `${operationLabel} (${model})`
979
+ );
980
+ const content = response.choices?.[0]?.message?.content?.trim();
981
+ if (content) {
982
+ return { content, model };
983
+ }
984
+ const err = `Empty response from model ${model}`;
985
+ mcpLog("warning", `${operationLabel} returned empty content for model ${model}`, "llm");
986
+ return { content: null, model, error: err, failureKind: "empty" };
987
+ } catch (err) {
988
+ const message = err instanceof Error ? err.message : String(err);
989
+ mcpLog("warning", `${operationLabel} failed for model ${model}: ${message}`, "llm");
990
+ return {
991
+ content: null,
992
+ model,
993
+ error: message,
994
+ failureKind: "provider",
995
+ errorCause: normalizeProviderError(err, message)
996
+ };
997
+ }
998
+ }
999
+ async function requestTextWithFallback(processor, prompt, operationLabel, signal) {
1000
+ const primary = await requestText(processor, prompt, operationLabel, signal);
1001
+ if (primary.content !== null) return primary;
1002
+ const fallbackModel = LLM_EXTRACTION.FALLBACK_MODEL;
1003
+ if (!fallbackModel) return primary;
1004
+ mcpLog("warning", `Primary model failed, switching to fallback ${fallbackModel}`, "llm");
1005
+ let lastFailure = primary;
1006
+ for (let attempt = 0; attempt < FALLBACK_RETRY_COUNT; attempt++) {
1007
+ if (attempt > 0) {
1008
+ const delayMs = calculateLLMBackoff(attempt - 1);
1009
+ mcpLog("warning", `Fallback retry ${attempt}/${FALLBACK_RETRY_COUNT - 1} in ${delayMs}ms`, "llm");
1010
+ try {
1011
+ await sleep(delayMs, signal);
1012
+ } catch {
1013
+ break;
1014
+ }
1015
+ }
1016
+ const result = await requestText(processor, prompt, `${operationLabel} [fallback]`, signal, fallbackModel);
1017
+ if (result.content !== null) return result;
1018
+ lastFailure = result;
1019
+ }
1020
+ return lastFailure;
1021
+ }
1022
+ function isRetryableLLMError(error2) {
1023
+ if (!error2 || typeof error2 !== "object") return false;
1024
+ const stallCode = error2?.code;
1025
+ if (stallCode === "ESTALLED" || stallCode === "ETIMEDOUT") {
1026
+ return true;
1027
+ }
1028
+ if (hasStatus(error2)) {
1029
+ if (error2.status === 429 || error2.status === 500 || error2.status === 502 || error2.status === 503 || error2.status === 504) {
1030
+ return true;
1031
+ }
1032
+ }
1033
+ const record = error2;
1034
+ const code = typeof record.code === "string" ? record.code : void 0;
1035
+ const nested = typeof record.error === "object" && record.error !== null ? record.error : null;
1036
+ const errorCode = code ?? (nested && typeof nested.code === "string" ? nested.code : void 0) ?? (nested && typeof nested.type === "string" ? nested.type : void 0);
1037
+ if (errorCode && RETRYABLE_LLM_ERROR_CODES.has(errorCode)) {
1038
+ return true;
1039
+ }
1040
+ const message = typeof record.message === "string" ? record.message.toLowerCase() : "";
1041
+ if (message.includes("rate limit") || message.includes("timeout") || message.includes("timed out") || message.includes("service unavailable") || message.includes("server error") || message.includes("connection") || message.includes("econnreset")) {
1042
+ return true;
1043
+ }
1044
+ return false;
1045
+ }
1046
+ function isContextWindowError(error2) {
1047
+ if (!error2 || typeof error2 !== "object") return false;
1048
+ const record = error2;
1049
+ const nested = typeof record.error === "object" && record.error !== null ? record.error : null;
1050
+ const code = typeof record.code === "string" ? record.code : void 0;
1051
+ const nestedCode = nested && typeof nested.code === "string" ? nested.code : void 0;
1052
+ if (code === "context_length_exceeded" || nestedCode === "context_length_exceeded") {
1053
+ return true;
1054
+ }
1055
+ const messages = [];
1056
+ if (typeof record.message === "string") messages.push(record.message);
1057
+ if (nested && typeof nested.message === "string") messages.push(nested.message);
1058
+ const combined = messages.join(" ").toLowerCase();
1059
+ return combined.includes("context length") || combined.includes("context window") || combined.includes("maximum context") || combined.includes("maximum tokens") || combined.includes("token limit") || combined.includes("too many tokens") || combined.includes("prompt is too long") || combined.includes("reduce the length");
1060
+ }
1061
+ function calculateLLMBackoff(attempt) {
1062
+ const exponentialDelay = LLM_RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt);
1063
+ const jitter = Math.random() * BACKOFF_JITTER_FACTOR * exponentialDelay;
1064
+ return Math.min(exponentialDelay + jitter, LLM_RETRY_CONFIG.maxDelayMs);
1065
+ }
1066
+ async function processContentWithLLM(content, config, processor, signal) {
1067
+ if (!config.enabled) {
1068
+ return { content, processed: false };
1069
+ }
1070
+ if (!processor) {
1071
+ return {
1072
+ content,
1073
+ processed: false,
1074
+ error: "LLM processor not available (LLM_API_KEY, LLM_BASE_URL, and LLM_MODEL must all be set)",
1075
+ errorDetails: {
1076
+ code: ErrorCode.AUTH_ERROR,
1077
+ message: "LLM processor not available",
1078
+ retryable: false
1079
+ }
1080
+ };
1081
+ }
1082
+ if (!content?.trim()) {
1083
+ return { content: content || "", processed: false, error: "Empty content provided" };
1084
+ }
1085
+ const truncatedContent = content.length > MAX_LLM_INPUT_CHARS ? content.substring(0, MAX_LLM_INPUT_CHARS) + "\n\n[Content truncated due to length]" : content;
1086
+ const skipPrimaryForSize = truncatedContent.length > MAX_PRIMARY_MODEL_INPUT_CHARS && !!LLM_EXTRACTION.FALLBACK_MODEL;
1087
+ const safeUrl = (() => {
1088
+ if (!config.url) return void 0;
1089
+ try {
1090
+ const u = new URL(config.url);
1091
+ return `${u.origin}${u.pathname}`;
1092
+ } catch {
1093
+ return void 0;
1094
+ }
1095
+ })();
1096
+ const urlLine = safeUrl ? `PAGE URL: ${safeUrl}
1097
+
1098
+ ` : "";
1099
+ const prompt = config.extract ? `You are a factual extractor for a research agent. Extract ONLY the information that matches the instruction below. Do not summarize, interpret, or editorialize.
1100
+
1101
+ ${urlLine}EXTRACTION INSTRUCTION: ${config.extract}
1102
+
1103
+ STEP 1 \u2014 Classify this page. Look at the URL if present, plus structural cues (code blocks, table patterns, comment threads, marketing copy). Pick ONE:
1104
+ \`docs | changelog | github-readme | github-thread | reddit | hackernews | forum | blog | marketing | announcement | qa | cve | paper | release-notes | other\`
1105
+
1106
+ STEP 2 \u2014 Adjust emphasis by page type:
1107
+ - docs / changelog / github-readme / release-notes \u2192 API signatures, version numbers, flags, exact config keys, code blocks. Copy verbatim. Preserve tables as tables.
1108
+ - github-thread \u2192 weight MAINTAINER comments (label "[maintainer]") over drive-by commenters. Preserve stacktraces verbatim. Capture chronological resolution \u2014 what was decided and when. Link the accepted-fix commit/PR if referenced.
1109
+ - reddit / hackernews / forum \u2192 lived experience. Quote verbatim with attribution ("u/foo wrote: \u2026" or "user <name>"). Prioritize replies with stack details, specific failure stories, or replies that contradict the OP. Record overall sentiment distribution as one bullet if clear skew ("~70% agree / ~20% dissent / rest off-topic"). Drop context-free opinions ("this sucks") from Matches.
1110
+ - blog \u2192 prioritize concrete reproductions, code, measurements. If the author makes a claim without evidence, mark "[unsourced claim]".
1111
+ - marketing / announcement \u2192 pricing tiers, feature matrices verbatim, free-tier quotas, enterprise contact. Preserve tables as tables. Treat roadmap/future-tense claims skeptically \u2014 note them as "[announced, not shipped]" when framing is future-tense.
1112
+ - qa (stackoverflow) \u2192 accepted answer's code + high-voted disagreements. Always note the answer date \u2014 SO rots.
1113
+ - cve \u2192 CVSS vector verbatim, CWE, CPE ranges, affected versions, fix version, references. Each with its label.
1114
+ - paper \u2192 claim, method, dataset, benchmark numbers, comparison baseline. Preserve numeric deltas verbatim.
1115
+
1116
+ STEP 3 \u2014 Emit markdown with these sections, in order:
1117
+
1118
+ ## Source
1119
+ - URL: <verbatim if visible, else "unknown">
1120
+ - Page type: <the type you picked>
1121
+ - Page date: <verbatim if visible, else "not visible">
1122
+ - Author / maintainer (if identifiable): <verbatim>
1123
+
1124
+ ## Matches
1125
+ One bullet per distinct piece of matching info:
1126
+ - **<short label>** \u2014 the information. Quote VERBATIM for: numbers, versions, dates, API names, prices, error messages, stacktraces, CVSS vectors, benchmark scores, command flags, proper nouns, and people's words. Backticks for code/identifiers. Preserve tables.
1127
+
1128
+ ## Not found
1129
+ Every part of the extraction instruction this page did NOT answer. Be explicit. Example: "Enterprise pricing contact \u2014 not present on this page."
1130
+
1131
+ ## Follow-up signals
1132
+ Short bullets \u2014 NEW angles this page surfaced that the agent should investigate. Include: new terms, unexpected vendor names, contradicting claims, referenced-but-unscraped URLs. Copy URLs VERBATIM from the source; if only anchor text is visible, write "anchor: <text> (URL not in scraped content)". Skip this section if nothing new surfaced. Do NOT invent.
1133
+
1134
+ ## Contradictions
1135
+ (Include this section only if the page contains internally contradictory claims.) Bullet each contradiction with both sides quoted verbatim.
1136
+
1137
+ ## Truncation
1138
+ (Include only if content appears cut mid-element.) "Content cut mid-<table row / code block / comment / paragraph>; extraction may be incomplete for <section>."
1139
+
1140
+ RULES:
1141
+ - Never paraphrase numbers, versions, code, or quoted text.
1142
+ - If an instruction item is not answered, it goes in "Not found" \u2014 do NOT invent an answer to please the caller.
1143
+ - Preserve code blocks, command examples, tables exactly.
1144
+ - Do NOT add commentary or recommendations outside "Follow-up signals".
1145
+ - Page language \u2260 English: quote verbatim in the original language AND provide a parenthetical gloss in English.
1146
+ - Page appears gated (login wall, paywall, JS-render-empty shell) or near-empty: BEFORE dismissing the page, look for ANY visible text \u2014 og:title, og:description, meta description, headline, author name, nav labels, teaser/preview sentences, visible comment snippets. If ANY such text exists, extract it as usual under \`## Source\` + \`## Matches\`, and list the blocked facets under \`## Not found\`. Prefix the first \`## Matches\` bullet with \`**[partial \u2014 <reason>]**\` so the caller knows the body is gated (reasons: \`login-wall | paywall | JS-render-empty | truncated-before-relevant-section\`). ONLY when there is NO visible extractable text at all (< 50 words AND no og:* AND no headline AND no preview), return exactly one line:
1147
+ \`## Matches\\n_Page did not load: <reason>_\`
1148
+ Valid reasons: \`404 | login-wall | paywall | JS-render-empty | non-text-asset | truncated-before-relevant-section\`.
1149
+
1150
+ Content:
1151
+ ${truncatedContent}` : `Clean the following page content: drop navigation, ads, cookie banners, footers, author bios, related-article lists. Preserve headings, paragraphs, code blocks, tables, and inline links as \`[text](url)\`. Do NOT summarize \u2014 preserve the full body.
1152
+
1153
+ ${urlLine}Content:
1154
+ ${truncatedContent}`;
1155
+ let lastError;
1156
+ if (skipPrimaryForSize) {
1157
+ mcpLog(
1158
+ "info",
1159
+ `Input ${truncatedContent.length} chars exceeds primary model cap (${MAX_PRIMARY_MODEL_INPUT_CHARS}); routing directly to fallback`,
1160
+ "llm"
1161
+ );
1162
+ } else {
1163
+ for (let attempt = 0; attempt <= LLM_RETRY_CONFIG.maxRetries; attempt++) {
1164
+ try {
1165
+ if (attempt === 0) {
1166
+ mcpLog("info", `Starting extraction with ${LLM_EXTRACTION.MODEL}`, "llm");
1167
+ } else {
1168
+ mcpLog("warning", `Retry attempt ${attempt}/${LLM_RETRY_CONFIG.maxRetries}`, "llm");
1169
+ }
1170
+ const response = await requestText(processor, prompt, "LLM extraction", signal);
1171
+ if (response.content !== null) {
1172
+ mcpLog("info", `Successfully extracted ${response.content.length} characters`, "llm");
1173
+ markLLMSuccess("extractor");
1174
+ return { content: response.content, processed: true };
1175
+ }
1176
+ const providerFailure = getProviderFailure(response);
1177
+ if (providerFailure) {
1178
+ throw providerFailure;
1179
+ }
1180
+ mcpLog("warning", "Received empty response from LLM", "llm");
1181
+ markLLMFailure("extractor", "LLM returned empty response");
1182
+ return emptyLLMExtractionResult(content);
1183
+ } catch (err) {
1184
+ lastError = classifyError(err);
1185
+ const status = hasStatus(err) ? err.status : void 0;
1186
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
1187
+ const ctxErr = isContextWindowError(err);
1188
+ mcpLog("error", `Error (attempt ${attempt + 1}): ${lastError.message} [status=${status}, code=${code}, retryable=${isRetryableLLMError(err)}, context_window=${ctxErr}]`, "llm");
1189
+ if (ctxErr) {
1190
+ mcpLog("warning", "Context window exceeded on primary \u2014 skipping remaining retries, routing to fallback", "llm");
1191
+ break;
1192
+ }
1193
+ if (isRetryableLLMError(err) && attempt < LLM_RETRY_CONFIG.maxRetries) {
1194
+ const delayMs = calculateLLMBackoff(attempt);
1195
+ mcpLog("warning", `Retrying in ${delayMs}ms...`, "llm");
1196
+ try {
1197
+ await sleep(delayMs, signal);
1198
+ } catch {
1199
+ break;
1200
+ }
1201
+ continue;
1202
+ }
1203
+ break;
1204
+ }
1205
+ }
1206
+ }
1207
+ const fallbackModel = LLM_EXTRACTION.FALLBACK_MODEL;
1208
+ if (fallbackModel) {
1209
+ mcpLog("warning", `Primary exhausted, switching to fallback ${fallbackModel}`, "llm");
1210
+ for (let attempt = 0; attempt < FALLBACK_RETRY_COUNT; attempt++) {
1211
+ if (attempt > 0) {
1212
+ const delayMs = calculateLLMBackoff(attempt - 1);
1213
+ mcpLog("warning", `Fallback retry ${attempt}/${FALLBACK_RETRY_COUNT - 1} in ${delayMs}ms`, "llm");
1214
+ try {
1215
+ await sleep(delayMs, signal);
1216
+ } catch {
1217
+ break;
1218
+ }
1219
+ }
1220
+ try {
1221
+ const response = await requestText(processor, prompt, "LLM extraction [fallback]", signal, fallbackModel);
1222
+ if (response.content !== null) {
1223
+ mcpLog("info", `Fallback extracted ${response.content.length} characters`, "llm");
1224
+ markLLMSuccess("extractor");
1225
+ return { content: response.content, processed: true };
1226
+ }
1227
+ const providerFailure = getProviderFailure(response);
1228
+ if (providerFailure) {
1229
+ throw providerFailure;
1230
+ }
1231
+ mcpLog("warning", "Fallback returned empty response", "llm");
1232
+ markLLMFailure("extractor", "LLM returned empty response");
1233
+ return emptyLLMExtractionResult(content);
1234
+ } catch (err) {
1235
+ lastError = classifyError(err);
1236
+ mcpLog("error", `Fallback error (attempt ${attempt + 1}): ${lastError.message}`, "llm");
1237
+ if (isContextWindowError(err) || !isRetryableLLMError(err)) break;
1238
+ }
1239
+ }
1240
+ }
1241
+ const errorMessage = lastError?.message || "Unknown LLM error";
1242
+ mcpLog("error", `All attempts failed: ${errorMessage}. Returning original content.`, "llm");
1243
+ markLLMFailure("extractor", errorMessage);
1244
+ return {
1245
+ content,
1246
+ processed: false,
1247
+ error: `LLM extraction failed: ${errorMessage}`,
1248
+ errorDetails: lastError || {
1249
+ code: ErrorCode.UNKNOWN_ERROR,
1250
+ message: errorMessage,
1251
+ retryable: false
1252
+ }
1253
+ };
1254
+ }
1255
+ var MAX_CLASSIFICATION_URLS = 50;
1256
+ async function classifySearchResults(rankedUrls, objective, totalQueries, processor, previousQueries = []) {
1257
+ const urlsToClassify = rankedUrls.slice(0, MAX_CLASSIFICATION_URLS);
1258
+ const STATIC_WEIGHTS = [30, 20, 15, 10, 8, 6, 5, 4, 3, 2];
1259
+ const weightForRank = (rank) => STATIC_WEIGHTS[rank - 1] ?? 1;
1260
+ const lines = [];
1261
+ for (const url of urlsToClassify) {
1262
+ let domain;
1263
+ try {
1264
+ domain = new URL(url.url).hostname.replace(/^www\./, "");
1265
+ } catch {
1266
+ domain = url.url;
1267
+ }
1268
+ const snippet = url.snippet.length > 120 ? url.snippet.slice(0, 117) + "..." : url.snippet;
1269
+ lines.push(`[${url.rank}] w=${weightForRank(url.rank)} ${url.title} \u2014 ${domain} \u2014 ${snippet}`);
1270
+ }
1271
+ const prevQueriesBlock = previousQueries.length > 0 ? previousQueries.map((q) => `- ${q}`).join("\n") : "- (none provided)";
1272
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1273
+ const prompt = `You are the relevance filter for a research agent. Classify each search result below against the objective and produce a structured analysis.
1274
+
1275
+ OBJECTIVE: ${objective}
1276
+ TODAY: ${today}
1277
+
1278
+ PREVIOUS QUERIES (already run \u2014 do NOT paraphrase in refine_queries):
1279
+ ${prevQueriesBlock}
1280
+
1281
+ Return ONLY a JSON object (no markdown, no code fences):
1282
+
1283
+ {
1284
+ "title": "2\u20138 word label for this RESULT CLUSTER (not the objective)",
1285
+ "synthesis": "3\u20135 sentences grounded in the results. Every non-trivial claim cites a rank in [brackets], e.g. '[3] documents the flag; [7][12] report it is broken on macOS.' A synthesis with zero citations is invalid.",
1286
+ "confidence": "high | medium | low",
1287
+ "confidence_reason": "one sentence \u2014 why",
1288
+ "gaps": [
1289
+ { "id": 0, "description": "specific, actionable thing the current results do NOT answer \u2014 not 'more info needed'" }
1290
+ ],
1291
+ "refine_queries": [
1292
+ { "query": "concrete next search", "gap_id": 0, "rationale": "\u226412 words" }
1293
+ ],
1294
+ "results": [
1295
+ {
1296
+ "rank": 1,
1297
+ "tier": "HIGHLY_RELEVANT | MAYBE_RELEVANT | OTHER",
1298
+ "source_type": "vendor_doc | github | reddit | hackernews | blog | news | marketing | stackoverflow | cve | paper | release_notes | aggregator | other",
1299
+ "reason": "\u226412 words citing the snippet cue that drove the tier"
1300
+ }
1301
+ ]
1302
+ }
1303
+
1304
+ WEIGHT SCHEME: each row is prefixed with a weight (w=N). Higher weight means the URL ranked better across input queries \u2014 prefer HIGHLY_RELEVANT for high-weight rows when content matches the objective. Weight alone never justifies HIGHLY_RELEVANT; snippet cues still drive the decision.
1305
+
1306
+ SOURCE-OF-TRUTH RUBRIC (the "primary source" is goal-dependent \u2014 infer goal type from the objective):
1307
+ - spec / API / config questions \u2192 vendor_doc, github (README, RFC), release_notes are primary
1308
+ - bug / failure-mode questions \u2192 github (issue/PR), stackoverflow are primary
1309
+ - migration / sentiment / lived-experience \u2192 reddit, hackernews, blog are primary; docs are secondary
1310
+ - pricing / commercial \u2192 marketing (the vendor's own pricing page IS the primary source, but treat feature lists skeptically)
1311
+ - security / CVE \u2192 cve databases, distro security trackers (nvd.nist.gov, security-tracker.debian.org, ubuntu.com/security) are primary
1312
+ - synthesis / open-ended \u2192 blend; no single type is primary
1313
+ - product launch \u2192 vendor_doc + news + marketing for the launch itself; blogs + reddit for independent verification
1314
+
1315
+ FRESHNESS: proportional to topic velocity. For a week-old release, demote anything older than 30 days. For general tech questions, demote older than 18 months. For stable protocols (HTTP, TCP, POSIX), don't demote by age.
1316
+
1317
+ CONFIDENCE:
1318
+ - high = \u22653 HIGHLY_RELEVANT results from INDEPENDENT domains agree on the core answer
1319
+ - medium = \u22652 HIGHLY_RELEVANT exist but disagree or share a domain; OR a single authoritative primary source answers it
1320
+ - low = otherwise; snippet-only judgments cap at medium
1321
+
1322
+ REFINE QUERIES \u2014 each MUST differ from every previousQuery by:
1323
+ - a new operator (site:, quotes, verbatim version number), OR
1324
+ - a domain-specific noun ABSENT from every prior query
1325
+ Adding a year alone does NOT count as differentiation.
1326
+ Each refine_query MUST reference a specific gap_id from the gaps array above.
1327
+ Produce 4\u20138 refine_queries total. Cover: (a) a primary-source probe, (b) a temporal sharpener, (c) a failure-mode or comparison probe, (d) at least one new-term probe seeded by a specific result's snippet.
1328
+
1329
+ RULES:
1330
+ - Classify ALL ${urlsToClassify.length} results. Do not skip or collapse any.
1331
+ - Use only the three tier values.
1332
+ - Judge from title + domain + snippet only. Do NOT invent facts not present in the snippet.
1333
+ - If ALL results are OTHER: synthesis = "", confidence = "low", and \`gaps\` must explicitly state why the current queries missed the target.
1334
+ - Casing: tier = UPPERCASE_WITH_UNDERSCORES, confidence = lowercase.
1335
+
1336
+ SEARCH RESULTS (${urlsToClassify.length} URLs from ${totalQueries} queries):
1337
+ ${lines.join("\n")}`;
1338
+ try {
1339
+ mcpLog("info", `Classifying ${urlsToClassify.length} URLs against objective`, "llm");
1340
+ const response = await requestTextWithFallback(
1341
+ processor,
1342
+ prompt,
1343
+ "Search classification"
1344
+ );
1345
+ if (response.content === null) {
1346
+ const errMsg = response.error ?? "LLM returned empty classification response";
1347
+ markLLMFailure("planner", errMsg);
1348
+ return { result: null, error: errMsg };
1349
+ }
1350
+ const cleaned = response.content.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
1351
+ const parsed = JSON.parse(cleaned);
1352
+ if (!parsed.title || typeof parsed.synthesis !== "string" || !Array.isArray(parsed.results)) {
1353
+ const errMsg = "LLM response missing required fields (title, synthesis, results)";
1354
+ markLLMFailure("planner", errMsg);
1355
+ return { result: null, error: errMsg };
1356
+ }
1357
+ mcpLog("info", `Classification complete: ${parsed.results.filter((r) => r.tier === "HIGHLY_RELEVANT").length} highly relevant`, "llm");
1358
+ markLLMSuccess("planner");
1359
+ return { result: parsed };
1360
+ } catch (err) {
1361
+ const message = err instanceof Error ? err.message : String(err);
1362
+ mcpLog("error", `Classification failed: ${message}`, "llm");
1363
+ markLLMFailure("planner", message);
1364
+ return { result: null, error: `Classification failed: ${message}` };
1365
+ }
1366
+ }
1367
+ async function suggestRefineQueriesForRawMode(rankedUrls, objective, originalQueries, processor) {
1368
+ const urlsToSummarize = rankedUrls.slice(0, 12);
1369
+ const lines = urlsToSummarize.map((url) => {
1370
+ let domain;
1371
+ try {
1372
+ domain = new URL(url.url).hostname.replace(/^www\./, "");
1373
+ } catch {
1374
+ domain = url.url;
1375
+ }
1376
+ return `[${url.rank}] ${url.title} \u2014 ${domain}`;
1377
+ });
1378
+ const prompt = `You are generating follow-up search queries for an agent using raw search results.
1379
+
1380
+ Return ONLY a JSON object (no markdown, no code fences):
1381
+ {
1382
+ "refine_queries": [
1383
+ { "query": "next search query", "gap_description": "what gap this closes", "rationale": "\u226412 words on why" }
1384
+ ]
1385
+ }
1386
+
1387
+ OBJECTIVE: ${objective}
1388
+
1389
+ PREVIOUS QUERIES (already run \u2014 do NOT paraphrase):
1390
+ ${originalQueries.map((query) => `- ${query}`).join("\n")}
1391
+
1392
+ TOP RESULT TITLES (to seed new-term probes):
1393
+ ${lines.join("\n")}
1394
+
1395
+ RULES:
1396
+ - Produce 4\u20136 diverse follow-ups. Cover: (a) a primary-source probe (site:, RFC, vendor docs); (b) a temporal sharpener (changelog, version number); (c) a failure-mode or comparison probe; (d) at least one new-term probe seeded by a specific result title.
1397
+ - Each query MUST differ from every previousQuery by either a new operator (site:, quotes, a verbatim version number) OR a domain-specific noun absent from every prior query. Adding a year alone does NOT count.
1398
+ - Each refine_query MUST include a \`gap_description\` naming what the current results don't answer.
1399
+ - Do not include URLs.
1400
+ - Keep rationales \u226412 words.`;
1401
+ try {
1402
+ const response = await requestTextWithFallback(
1403
+ processor,
1404
+ prompt,
1405
+ "Raw-mode refine query generation"
1406
+ );
1407
+ if (response.content === null) {
1408
+ const errMsg = response.error ?? "LLM returned empty raw-mode refine query response";
1409
+ markLLMFailure("planner", errMsg);
1410
+ return { result: [], error: errMsg };
1411
+ }
1412
+ const cleaned = response.content.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
1413
+ const parsed = JSON.parse(cleaned);
1414
+ markLLMSuccess("planner");
1415
+ return { result: Array.isArray(parsed.refine_queries) ? parsed.refine_queries : [] };
1416
+ } catch (err) {
1417
+ const message = err instanceof Error ? err.message : String(err);
1418
+ mcpLog("error", `Raw-mode refine query generation failed: ${message}`, "llm");
1419
+ markLLMFailure("planner", message);
1420
+ return { result: [], error: message };
1421
+ }
1422
+ }
1423
+ var VALID_GOAL_CLASSES = /* @__PURE__ */ new Set([
1424
+ "spec",
1425
+ "bug",
1426
+ "migration",
1427
+ "sentiment",
1428
+ "pricing",
1429
+ "security",
1430
+ "synthesis",
1431
+ "product_launch",
1432
+ "other"
1433
+ ]);
1434
+ var VALID_FRESHNESS = /* @__PURE__ */ new Set(["days", "weeks", "months", "years"]);
1435
+ var VALID_BRANCHES = /* @__PURE__ */ new Set(["reddit", "web", "both"]);
1436
+ var VALID_STEP_TOOLS = /* @__PURE__ */ new Set(["raw-web-search", "smart-web-search", "raw-scrape-links", "smart-scrape-links"]);
1437
+ function isStringArray(value) {
1438
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
1439
+ }
1440
+ function isStepArray(value) {
1441
+ return Array.isArray(value) && value.every((s) => {
1442
+ if (typeof s !== "object" || s === null) return false;
1443
+ const tool = s.tool;
1444
+ const reason = s.reason;
1445
+ return typeof tool === "string" && VALID_STEP_TOOLS.has(tool) && typeof reason === "string" && reason.trim().length > 0;
1446
+ });
1447
+ }
1448
+ function parseResearchBrief(raw) {
1449
+ try {
1450
+ const cleaned = raw.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
1451
+ const parsed = JSON.parse(cleaned);
1452
+ const goal_class = typeof parsed.goal_class === "string" ? parsed.goal_class : null;
1453
+ if (!goal_class || !VALID_GOAL_CLASSES.has(goal_class)) return null;
1454
+ const freshness_window = typeof parsed.freshness_window === "string" ? parsed.freshness_window : null;
1455
+ if (!freshness_window || !VALID_FRESHNESS.has(freshness_window)) return null;
1456
+ const primary_branch = parsed.primary_branch;
1457
+ if (typeof primary_branch !== "string" || !VALID_BRANCHES.has(primary_branch)) return null;
1458
+ if (!isStepArray(parsed.first_call_sequence) || parsed.first_call_sequence.length === 0) return null;
1459
+ if (!isStringArray(parsed.keyword_seeds) || parsed.keyword_seeds.length === 0) return null;
1460
+ return {
1461
+ goal_class,
1462
+ goal_class_reason: typeof parsed.goal_class_reason === "string" ? parsed.goal_class_reason : "",
1463
+ primary_branch,
1464
+ primary_branch_reason: typeof parsed.primary_branch_reason === "string" ? parsed.primary_branch_reason : "",
1465
+ freshness_window,
1466
+ first_call_sequence: parsed.first_call_sequence,
1467
+ keyword_seeds: parsed.keyword_seeds.filter((s) => s.trim().length > 0),
1468
+ iteration_hints: isStringArray(parsed.iteration_hints) ? parsed.iteration_hints : [],
1469
+ gaps_to_watch: isStringArray(parsed.gaps_to_watch) ? parsed.gaps_to_watch : [],
1470
+ stop_criteria: isStringArray(parsed.stop_criteria) ? parsed.stop_criteria : []
1471
+ };
1472
+ } catch {
1473
+ return null;
1474
+ }
1475
+ }
1476
+ async function generateResearchBrief(goal, processor, signal) {
1477
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1478
+ const prompt = `You are a research planner. An agent is about to run a multi-pass research loop on the goal below using 5 tools:
1479
+
1480
+ - start-research: orientation and this brief
1481
+ - raw-web-search: raw search fan-out, keywords only, up to 50 keywords per call, no LLM; best for breadth, audit trails, and candidate URL capture
1482
+ - smart-web-search: search fan-out + required LLM prioritization/classification over titles/snippets, scope: web|reddit|both, up to 50 keywords per call; best for triage after a strong diverse keyword set
1483
+ - raw-scrape-links: fetch URLs as full markdown, urls only, no LLM; Reddit permalinks return threaded comments; best for complete context and ambiguous sources
1484
+ - smart-scrape-links: fetch URLs then required LLM extraction over page bodies; Reddit permalinks return threaded comments before extraction; best for focused evidence extraction once facets are known
1485
+
1486
+ Produce a tailored JSON brief.
1487
+
1488
+ GOAL: ${goal}
1489
+ TODAY: ${today}
1490
+
1491
+ Return ONLY a JSON object (no markdown, no code fences):
1492
+
1493
+ {
1494
+ "goal_class": "spec | bug | migration | sentiment | pricing | security | synthesis | product_launch | other",
1495
+ "goal_class_reason": "one sentence \u2014 why this class",
1496
+ "primary_branch": "reddit | web | both",
1497
+ "primary_branch_reason": "one sentence \u2014 why this branch leads",
1498
+ "freshness_window": "days | weeks | months | years",
1499
+ "first_call_sequence": [
1500
+ { "tool": "raw-web-search | smart-web-search | raw-scrape-links | smart-scrape-links", "reason": "what this call establishes for the agent" }
1501
+ ],
1502
+ "keyword_seeds": ["25\u201350 concrete search keywords \u2014 flat list, to be fired in the first search call as keywords"],
1503
+ "iteration_hints": ["2\u20135 pointers on which harvested terms / follow-up signals to watch for after pass 1"],
1504
+ "gaps_to_watch": ["2\u20135 concrete questions the agent MUST verify or the answer is incomplete"],
1505
+ "stop_criteria": ["2\u20134 checkable conditions \u2014 all must hold before the agent declares done"]
1506
+ }
1507
+
1508
+ RULES:
1509
+
1510
+ primary_branch:
1511
+ - "reddit" \u2192 sentiment / migration / lived-experience / community-consensus goals. Usually leads with raw-web-search using Reddit-focused keywords, then raw-scrape-links on post permalinks to preserve full comments.
1512
+ - "web" \u2192 spec / bug / pricing / CVE / API / primary-source goals. Usually leads with raw-web-search for maximum candidate breadth OR smart-web-search scope:"web" when the initial keyword set is already diverse and needs prioritization.
1513
+ - "both" \u2192 opinion-heavy AND needs official sources (e.g. product launch + practitioner reception).
1514
+
1515
+ first_call_sequence:
1516
+ - 1\u20133 steps.
1517
+ - Use raw-web-search when the first need is recall: many distinct source classes, exact candidate URLs, Reddit permalink discovery, or cheap follow-up passes.
1518
+ - Use smart-web-search when the first need is prioritization: the agent has 10\u201350 distinct keyword probes and needs HIGHLY/MAYBE tiers, gaps, and refine queries. Smart search reads snippets only; never plan it as final evidence.
1519
+ - Use raw-scrape-links when complete page/thread context is valuable, the extraction shape is unclear, or Reddit comments are the source of truth.
1520
+ - Use smart-scrape-links when the extraction shape is known (facets separated by |) and the agent needs compact evidence from page bodies; this is usually the highest-value smart tool for answer construction.
1521
+ - reddit-first: step 1 = raw-web-search with Reddit permalink probes; step 2 = raw-scrape-links on best post permalinks for full comments. Add smart-web-search only when there are many candidate posts to triage.
1522
+ - web-first: step 1 = raw-web-search for broad URL capture OR smart-web-search scope:"web" for prioritizing a diverse keyword fan-out; step 2 = smart-scrape-links on selected URLs when extraction facets are known, otherwise raw-scrape-links first.
1523
+ - both: step 1 = parallel search calls split by source need; step 2 = raw-scrape-links for full evidence and smart-scrape-links for final extraction.
1524
+
1525
+ keyword_seeds:
1526
+ - 25\u201350 total. Narrow bug \u2192 fewer. Open synthesis \u2192 more.
1527
+ - Write Google retrieval probes, not topic labels.
1528
+ - For each broad idea, first do a bad \u2192 better rewrite in your head: replace a vague phrase with a query that names the evidence source class, discriminating anchor terms, and one useful operator when possible.
1529
+ - ${QUERY_REWRITE_PAIR_GUIDANCE_TEXT}
1530
+ - Use operators where helpful (site:, quotes, verbatim version numbers, exact error text, package names, release/version strings).
1531
+ - DIVERSE facets \u2014 same noun-phrase cannot repeat across seeds with adjectives-only variation.
1532
+ - Optimize keyword_seeds for distinct coverage first. Smart-web-search can prioritize a broad result set, but it cannot compensate for a narrow or repetitive keyword set.
1533
+ - Do NOT invent vendor names you are uncertain exist.
1534
+ - For \`site:<domain>\` filters, ONLY use domains you are highly confident are real. Safe choices: \`github.com\`, \`stackoverflow.com\`, \`reddit.com\`, \`news.ycombinator.com\`, \`arxiv.org\`, \`nvd.nist.gov\`, \`pypi.org\`, \`npmjs.com\`, plus any canonical homepage/docs domain explicitly spelled out in the goal itself (e.g. goal names "Cursor" \u2192 \`cursor.com\`/\`docs.cursor.com\` is acceptable). If you don't know the product's real docs domain, leave the query open (no \`site:\`) instead of guessing.
1535
+
1536
+ freshness_window:
1537
+ - If the goal mentions a recent release / date / version, use "days" or "weeks".
1538
+ - Stable protocols / APIs \u2192 "months" or "years".`;
1539
+ try {
1540
+ const response = await requestTextWithFallback(
1541
+ processor,
1542
+ prompt,
1543
+ "Research brief generation",
1544
+ signal
1545
+ );
1546
+ if (response.content === null) {
1547
+ mcpLog("warning", `Research brief generation returned no content: ${response.error ?? "unknown"}`, "llm");
1548
+ markLLMFailure("planner", response.error ?? "empty response");
1549
+ return null;
1550
+ }
1551
+ const brief = parseResearchBrief(response.content);
1552
+ if (!brief) {
1553
+ mcpLog("warning", "Research brief JSON parse or shape validation failed", "llm");
1554
+ markLLMFailure("planner", "brief parse/validation failed");
1555
+ return null;
1556
+ }
1557
+ markLLMSuccess("planner");
1558
+ return brief;
1559
+ } catch (err) {
1560
+ const message = err instanceof Error ? err.message : String(err);
1561
+ mcpLog("warning", `Research brief generation failed: ${message}`, "llm");
1562
+ markLLMFailure("planner", message);
1563
+ return null;
1564
+ }
1565
+ }
1566
+
1567
+ // src/utils/markdown-formatter.ts
1568
+ function removeMetaTags(content) {
1569
+ if (!content || typeof content !== "string") {
1570
+ return content;
1571
+ }
1572
+ const lines = content.split("\n");
1573
+ const filteredLines = lines.filter((line) => {
1574
+ const trimmed = line.trim();
1575
+ return !trimmed.startsWith("- Meta:") && !trimmed.startsWith("Meta:");
1576
+ });
1577
+ return filteredLines.join("\n");
1578
+ }
1579
+
1580
+ // src/utils/content-extractor.ts
1581
+ import { Readability } from "@mozilla/readability";
1582
+ import { JSDOM, VirtualConsole } from "jsdom";
1583
+ var MAX_READABILITY_BYTES = 15e5;
1584
+ function extractReadableContent(html, url) {
1585
+ if (!html || typeof html !== "string") {
1586
+ return { title: "", content: html ?? "", extracted: false };
1587
+ }
1588
+ if (html.length > MAX_READABILITY_BYTES) {
1589
+ return { title: "", content: html, extracted: false };
1590
+ }
1591
+ if (!html.includes("<")) {
1592
+ return { title: "", content: html, extracted: false };
1593
+ }
1594
+ const virtualConsole = new VirtualConsole();
1595
+ virtualConsole.on("error", () => {
1596
+ });
1597
+ virtualConsole.on("warn", () => {
1598
+ });
1599
+ virtualConsole.on("jsdomError", () => {
1600
+ });
1601
+ let dom;
1602
+ try {
1603
+ dom = new JSDOM(html, {
1604
+ url: url && /^https?:/i.test(url) ? url : "https://example.com/",
1605
+ virtualConsole
1606
+ });
1607
+ } catch (err) {
1608
+ mcpLog("warning", `JSDOM construction failed: ${err instanceof Error ? err.message : String(err)}`, "content-extractor");
1609
+ return { title: "", content: html, extracted: false };
1610
+ }
1611
+ try {
1612
+ const reader = new Readability(dom.window.document, {
1613
+ // Keep classes that downstream cleanup may need; Turndown ignores them.
1614
+ keepClasses: false
1615
+ // Strip <script>/<style> already handled by Readability defaults.
1616
+ });
1617
+ const article = reader.parse();
1618
+ if (!article || !article.content) {
1619
+ return { title: article?.title ?? "", content: html, extracted: false };
1620
+ }
1621
+ return {
1622
+ title: article.title ?? "",
1623
+ content: article.content,
1624
+ byline: article.byline ?? void 0,
1625
+ extracted: true
1626
+ };
1627
+ } catch (err) {
1628
+ mcpLog("warning", `Readability.parse failed: ${err instanceof Error ? err.message : String(err)}`, "content-extractor");
1629
+ return { title: "", content: html, extracted: false };
1630
+ } finally {
1631
+ try {
1632
+ dom.window.close();
1633
+ } catch {
1634
+ }
1635
+ }
1636
+ }
1637
+
1638
+ // src/utils/source-type.ts
1639
+ var DOCUMENT_PATH_SUFFIXES = [
1640
+ ".pdf",
1641
+ ".doc",
1642
+ ".docx",
1643
+ ".ppt",
1644
+ ".pptx",
1645
+ ".xls",
1646
+ ".xlsx"
1647
+ ];
1648
+ function isDocumentUrl(url) {
1649
+ let pathname;
1650
+ try {
1651
+ pathname = new URL(url).pathname.toLowerCase();
1652
+ } catch {
1653
+ return false;
1654
+ }
1655
+ for (const suffix of DOCUMENT_PATH_SUFFIXES) {
1656
+ if (pathname.endsWith(suffix)) return true;
1657
+ }
1658
+ return false;
1659
+ }
1660
+
1661
+ // src/utils/content-quality.ts
1662
+ var MIN_MARKDOWN_CHARS = 800;
1663
+ var MIN_MARKDOWN_WORDS = 120;
1664
+ var BLOCK_PHRASES = [
1665
+ "access denied",
1666
+ "are you a human",
1667
+ "captcha",
1668
+ "checking your browser",
1669
+ "enable javascript",
1670
+ "just a moment",
1671
+ "login required",
1672
+ "please enable cookies",
1673
+ "please verify you are human",
1674
+ "sign in to continue",
1675
+ "temporarily unavailable"
1676
+ ];
1677
+ function countWords(content) {
1678
+ const matches = content.trim().match(/\S+/g);
1679
+ return matches ? matches.length : 0;
1680
+ }
1681
+ function findBlockPhrase(content) {
1682
+ const lower = content.toLowerCase();
1683
+ return BLOCK_PHRASES.find((phrase) => lower.includes(phrase));
1684
+ }
1685
+ function assessMarkdownQuality(content) {
1686
+ const trimmed = content.trim();
1687
+ const charCount = trimmed.length;
1688
+ const wordCount = countWords(trimmed);
1689
+ const blockPhrase = findBlockPhrase(trimmed);
1690
+ if (blockPhrase) {
1691
+ return {
1692
+ weak: true,
1693
+ reason: `blocked_or_interstitial:${blockPhrase}`,
1694
+ charCount,
1695
+ wordCount,
1696
+ blockPhrase
1697
+ };
1698
+ }
1699
+ if (charCount < MIN_MARKDOWN_CHARS) {
1700
+ return {
1701
+ weak: true,
1702
+ reason: `too_few_chars:${charCount}<${MIN_MARKDOWN_CHARS}`,
1703
+ charCount,
1704
+ wordCount
1705
+ };
1706
+ }
1707
+ if (wordCount < MIN_MARKDOWN_WORDS) {
1708
+ return {
1709
+ weak: true,
1710
+ reason: `too_few_words:${wordCount}<${MIN_MARKDOWN_WORDS}`,
1711
+ charCount,
1712
+ wordCount
1713
+ };
1714
+ }
1715
+ return {
1716
+ weak: false,
1717
+ reason: "ok",
1718
+ charCount,
1719
+ wordCount
1720
+ };
1721
+ }
1722
+
1723
+ // src/effect/runtime.ts
1724
+ import { Effect as Effect2, Layer as Layer2 } from "effect";
1725
+
1726
+ // src/effect/services.ts
1727
+ import { Context, Effect, Layer } from "effect";
1728
+
1729
+ // src/utils/concurrency.ts
1730
+ import pMapLib from "p-map";
1731
+ async function pMap(items, mapper, concurrency = 6, signal) {
1732
+ if (items.length === 0) return [];
1733
+ const limit = Math.max(1, Math.min(concurrency, items.length));
1734
+ return pMapLib(items, mapper, { concurrency: limit, signal });
1735
+ }
1736
+ async function pMapSettled(items, mapper, concurrency = 6, signal) {
1737
+ if (items.length === 0) return [];
1738
+ const limit = Math.max(1, Math.min(concurrency, items.length));
1739
+ return pMapLib(
1740
+ items,
1741
+ async (item, index) => {
1742
+ try {
1743
+ const value = await mapper(item, index);
1744
+ return { status: "fulfilled", value };
1745
+ } catch (reason) {
1746
+ return { status: "rejected", reason };
1747
+ }
1748
+ },
1749
+ { concurrency: limit, signal, stopOnError: false }
1750
+ );
1751
+ }
1752
+
1753
+ // src/clients/search.ts
1754
+ var SERPER_API_URL = "https://google.serper.dev/search";
1755
+ var DEFAULT_RESULTS_PER_QUERY = 10;
1756
+ var MAX_RETRIES2 = 3;
1757
+ var SEARCH_RETRY_CONFIG = {
1758
+ maxRetries: MAX_RETRIES2,
1759
+ baseDelayMs: 1e3,
1760
+ maxDelayMs: 1e4,
1761
+ timeoutMs: 3e4
1762
+ };
1763
+ var RETRYABLE_SEARCH_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
1764
+ var REDDIT_SITE_REGEX = /site:\s*reddit\.com/i;
1765
+ var REDDIT_SUBREDDIT_SUFFIX_REGEX = / : r\/\w+$/;
1766
+ var REDDIT_SUFFIX_REGEX = / - Reddit$/;
1767
+ function parseSearchResponses(responses, queries) {
1768
+ return responses.map((resp, index) => {
1769
+ try {
1770
+ const organic = resp.organic || [];
1771
+ const results = organic.map((item, idx) => ({
1772
+ title: item.title || "No title",
1773
+ link: item.link || "#",
1774
+ snippet: item.snippet || "",
1775
+ date: item.date,
1776
+ position: item.position || idx + 1
1777
+ }));
1778
+ const searchInfo = resp.searchInformation;
1779
+ const totalResults = searchInfo?.totalResults ? parseInt(String(searchInfo.totalResults).replace(/,/g, ""), 10) : results.length;
1780
+ const relatedSearches = resp.relatedSearches || [];
1781
+ const related = relatedSearches.map((r) => r.query || "");
1782
+ return { query: queries[index] || "", results, totalResults, related };
1783
+ } catch {
1784
+ return { query: queries[index] || "", results: [], totalResults: 0, related: [] };
1785
+ }
1786
+ });
1787
+ }
1788
+ async function executeSearchWithRetry(apiKey, body, isRetryable) {
1789
+ let lastError;
1790
+ for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {
1791
+ try {
1792
+ if (attempt > 0) {
1793
+ mcpLog("warning", `Retry attempt ${attempt}/${SEARCH_RETRY_CONFIG.maxRetries}`, "search");
1794
+ }
1795
+ const response = await fetchWithTimeout(SERPER_API_URL, {
1796
+ method: "POST",
1797
+ headers: {
1798
+ "X-API-KEY": apiKey,
1799
+ "Content-Type": "application/json"
1800
+ },
1801
+ body: JSON.stringify(body),
1802
+ timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs
1803
+ });
1804
+ if (!response.ok) {
1805
+ const errorText = await response.text().catch(() => "");
1806
+ lastError = classifyError({ status: response.status, message: errorText });
1807
+ if (isRetryable(response.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
1808
+ const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
1809
+ mcpLog("warning", `API returned ${response.status}, retrying in ${delayMs}ms...`, "search");
1810
+ await sleep(delayMs);
1811
+ continue;
1812
+ }
1813
+ return { data: void 0, error: lastError };
1814
+ }
1815
+ try {
1816
+ const data = await response.json();
1817
+ return { data };
1818
+ } catch {
1819
+ return {
1820
+ data: void 0,
1821
+ error: { code: ErrorCode.PARSE_ERROR, message: "Failed to parse search response", retryable: false }
1822
+ };
1823
+ }
1824
+ } catch (error2) {
1825
+ lastError = classifyError(error2);
1826
+ if (isRetryable(void 0, error2) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
1827
+ const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
1828
+ mcpLog("warning", `${lastError.code}: ${lastError.message}, retrying in ${delayMs}ms...`, "search");
1829
+ await sleep(delayMs);
1830
+ continue;
1831
+ }
1832
+ return { data: void 0, error: lastError };
1833
+ }
1834
+ }
1835
+ return {
1836
+ data: void 0,
1837
+ error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: "Search failed", retryable: false }
1838
+ };
1839
+ }
1840
+ var SearchClient = class {
1841
+ apiKey;
1842
+ constructor(apiKey) {
1843
+ const env = parseEnv();
1844
+ this.apiKey = apiKey || env.SEARCH_API_KEY || "";
1845
+ if (!this.apiKey) {
1846
+ throw new Error("Web search capability is not configured. Please set up the required API credentials.");
1847
+ }
1848
+ }
1849
+ /**
1850
+ * Check if error is retryable
1851
+ */
1852
+ isRetryable(status, error2) {
1853
+ if (status && RETRYABLE_SEARCH_CODES.has(status)) return true;
1854
+ if (error2 == null) return false;
1855
+ const message = typeof error2 === "object" && "message" in error2 && typeof error2.message === "string" ? error2.message.toLowerCase() : "";
1856
+ return message.includes("timeout") || message.includes("rate limit") || message.includes("connection");
1857
+ }
1858
+ /**
1859
+ * Search multiple queries in parallel
1860
+ * NEVER throws - always returns a valid response
1861
+ */
1862
+ async searchMultiple(queries) {
1863
+ const startTime = Date.now();
1864
+ if (queries.length === 0) {
1865
+ return {
1866
+ searches: [],
1867
+ totalQueries: 0,
1868
+ executionTime: 0,
1869
+ error: { code: ErrorCode.INVALID_INPUT, message: "No queries provided", retryable: false }
1870
+ };
1871
+ }
1872
+ const searchQueries = queries.map((query) => ({ q: query }));
1873
+ const { data, error: error2 } = await executeSearchWithRetry(
1874
+ this.apiKey,
1875
+ searchQueries,
1876
+ (status, err) => this.isRetryable(status, err)
1877
+ );
1878
+ if (error2 || data === void 0) {
1879
+ return {
1880
+ searches: [],
1881
+ totalQueries: queries.length,
1882
+ executionTime: Date.now() - startTime,
1883
+ error: error2 ?? { code: ErrorCode.UNKNOWN_ERROR, message: "Search provider returned no data", retryable: false }
1884
+ };
1885
+ }
1886
+ const responses = Array.isArray(data) ? data : [data];
1887
+ const searches = parseSearchResponses(responses, queries);
1888
+ return { searches, totalQueries: queries.length, executionTime: Date.now() - startTime };
1889
+ }
1890
+ /**
1891
+ * Search Reddit via Google (adds site:reddit.com automatically)
1892
+ * NEVER throws - returns empty array on failure
1893
+ */
1894
+ async searchReddit(query, dateAfter) {
1895
+ if (!query?.trim()) {
1896
+ return [];
1897
+ }
1898
+ let q = query.replace(REDDIT_SITE_REGEX, "").trim() + " site:reddit.com";
1899
+ if (dateAfter) {
1900
+ q += ` after:${dateAfter}`;
1901
+ }
1902
+ for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {
1903
+ try {
1904
+ const res = await fetchWithTimeout(SERPER_API_URL, {
1905
+ method: "POST",
1906
+ headers: { "X-API-KEY": this.apiKey, "Content-Type": "application/json" },
1907
+ body: JSON.stringify({ q, num: DEFAULT_RESULTS_PER_QUERY }),
1908
+ timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs
1909
+ });
1910
+ if (!res.ok) {
1911
+ if (this.isRetryable(res.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
1912
+ const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
1913
+ mcpLog("warning", `Reddit search ${res.status}, retrying in ${delayMs}ms...`, "search");
1914
+ await sleep(delayMs);
1915
+ continue;
1916
+ }
1917
+ mcpLog("error", `Reddit search failed with status ${res.status}`, "search");
1918
+ return [];
1919
+ }
1920
+ const data = await res.json();
1921
+ return (data.organic || []).map((r) => ({
1922
+ title: (r.title || "").replace(REDDIT_SUBREDDIT_SUFFIX_REGEX, "").replace(REDDIT_SUFFIX_REGEX, ""),
1923
+ url: r.link || "",
1924
+ snippet: r.snippet || "",
1925
+ date: r.date
1926
+ }));
1927
+ } catch (error2) {
1928
+ const err = classifyError(error2);
1929
+ if (this.isRetryable(void 0, error2) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
1930
+ const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
1931
+ mcpLog("warning", `Reddit search ${err.code}, retrying in ${delayMs}ms...`, "search");
1932
+ await sleep(delayMs);
1933
+ continue;
1934
+ }
1935
+ mcpLog("error", `Reddit search failed: ${err.message}`, "search");
1936
+ return [];
1937
+ }
1938
+ }
1939
+ return [];
1940
+ }
1941
+ /**
1942
+ * Search Reddit with multiple queries (bounded concurrency)
1943
+ * NEVER throws - searchReddit never throws, pMap preserves order
1944
+ */
1945
+ async searchRedditMultiple(queries, dateAfter) {
1946
+ if (queries.length === 0) {
1947
+ return /* @__PURE__ */ new Map();
1948
+ }
1949
+ const results = await pMap(
1950
+ queries,
1951
+ (q) => this.searchReddit(q, dateAfter),
1952
+ CONCURRENCY.SEARCH
1953
+ );
1954
+ return new Map(queries.map((q, i) => [q, results[i] || []]));
1955
+ }
1956
+ };
1957
+
1958
+ // src/clients/kernel.ts
1959
+ import Kernel from "@onkernel/sdk";
1960
+ var DEFAULT_RENDER_TIMEOUT_SECONDS = 15;
1961
+ var BROWSER_IDLE_TIMEOUT_SECONDS = 300;
1962
+ var KernelClient = class {
1963
+ kernel;
1964
+ constructor(apiKey) {
1965
+ const env = parseEnv();
1966
+ const resolvedKey = apiKey?.trim() || env.KERNEL_API_KEY;
1967
+ if (!resolvedKey) {
1968
+ throw new Error("Kernel browser rendering is not configured. Set KERNEL_API_KEY.");
1969
+ }
1970
+ this.kernel = new Kernel({
1971
+ apiKey: resolvedKey,
1972
+ timeout: 3e4,
1973
+ maxRetries: 1,
1974
+ ...env.KERNEL_PROJECT ? { defaultHeaders: { "X-Kernel-Project-Id": env.KERNEL_PROJECT } } : {}
1975
+ });
1976
+ }
1977
+ async render(request) {
1978
+ const { url, timeoutSeconds = DEFAULT_RENDER_TIMEOUT_SECONDS } = request;
1979
+ try {
1980
+ new URL(url);
1981
+ } catch {
1982
+ return {
1983
+ content: `Invalid URL: ${url}`,
1984
+ statusCode: 400,
1985
+ credits: 0,
1986
+ error: { code: ErrorCode.INVALID_INPUT, message: `Invalid URL: ${url}`, retryable: false }
1987
+ };
1988
+ }
1989
+ let sessionId;
1990
+ try {
1991
+ const session = await this.kernel.browsers.create({
1992
+ headless: true,
1993
+ stealth: true,
1994
+ timeout_seconds: BROWSER_IDLE_TIMEOUT_SECONDS,
1995
+ viewport: { width: 1280, height: 800 }
1996
+ });
1997
+ sessionId = session.session_id;
1998
+ const response = await this.kernel.browsers.playwright.execute(session.session_id, {
1999
+ code: buildRenderScript(url, timeoutSeconds),
2000
+ timeout_sec: Math.min(timeoutSeconds + 5, 300)
2001
+ });
2002
+ if (!response.success) {
2003
+ const message = response.error || response.stderr || "Kernel Playwright execution failed";
2004
+ return {
2005
+ content: `Kernel render failed: ${message}`,
2006
+ statusCode: 500,
2007
+ credits: 0,
2008
+ error: { code: ErrorCode.SERVICE_UNAVAILABLE, message, retryable: true }
2009
+ };
2010
+ }
2011
+ const rendered = parseRenderedPage(response.result);
2012
+ if (!rendered) {
2013
+ return {
2014
+ content: "Kernel render returned an invalid payload",
2015
+ statusCode: 500,
2016
+ credits: 0,
2017
+ error: {
2018
+ code: ErrorCode.PARSE_ERROR,
2019
+ message: "Kernel render returned an invalid payload",
2020
+ retryable: false
2021
+ }
2022
+ };
2023
+ }
2024
+ return {
2025
+ content: rendered.html,
2026
+ statusCode: 200,
2027
+ credits: 0,
2028
+ finalUrl: rendered.finalUrl,
2029
+ title: rendered.title,
2030
+ text: rendered.text
2031
+ };
2032
+ } catch (error2) {
2033
+ const err = formatKernelError(error2);
2034
+ return {
2035
+ content: `Kernel render failed: ${err.message}`,
2036
+ statusCode: err.statusCode ?? 500,
2037
+ credits: 0,
2038
+ error: err
2039
+ };
2040
+ } finally {
2041
+ if (sessionId) {
2042
+ try {
2043
+ await this.kernel.browsers.deleteByID(sessionId);
2044
+ } catch (deleteError) {
2045
+ const err = formatKernelError(deleteError);
2046
+ mcpLog("warning", `Kernel browser cleanup failed for ${sessionId}: ${err.message}`, "kernel");
2047
+ }
2048
+ }
2049
+ }
2050
+ }
2051
+ };
2052
+ function buildRenderScript(url, timeoutSeconds) {
2053
+ const timeoutMs = timeoutSeconds * 1e3;
2054
+ return `
2055
+ const targetUrl = ${JSON.stringify(url)};
2056
+ const timeoutMs = ${timeoutMs};
2057
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
2058
+ await page.waitForLoadState('networkidle', { timeout: Math.min(5000, timeoutMs) }).catch(() => {});
2059
+ const html = await page.content();
2060
+ const text = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '');
2061
+ return {
2062
+ html,
2063
+ text,
2064
+ title: await page.title(),
2065
+ finalUrl: page.url(),
2066
+ };
2067
+ `;
2068
+ }
2069
+ function parseRenderedPage(value) {
2070
+ if (!isRecord2(value)) return null;
2071
+ const html = readString2(value, "html");
2072
+ const finalUrl = readString2(value, "finalUrl");
2073
+ const title = readString2(value, "title");
2074
+ const text = readString2(value, "text");
2075
+ if (!html || !finalUrl || title === void 0 || text === void 0) return null;
2076
+ return { html, finalUrl, title, text };
2077
+ }
2078
+ function formatKernelError(error2) {
2079
+ if (error2 instanceof Kernel.APIError) {
2080
+ return {
2081
+ ...classifyError({ status: error2.status, message: error2.message }),
2082
+ cause: error2.message
2083
+ };
2084
+ }
2085
+ return classifyError(error2);
2086
+ }
2087
+ function isRecord2(value) {
2088
+ return typeof value === "object" && value !== null;
2089
+ }
2090
+ function readString2(value, key) {
2091
+ const child = value[key];
2092
+ return typeof child === "string" ? child : void 0;
2093
+ }
2094
+
2095
+ // src/clients/reddit.ts
2096
+ import { Logger as Logger4 } from "mcp-use";
2097
+ var REDDIT_TOKEN_URL = "https://www.reddit.com/api/v1/access_token";
2098
+ var REDDIT_API_BASE = "https://oauth.reddit.com";
2099
+ var TOKEN_EXPIRY_MS = 55e3;
2100
+ var FETCH_LIMIT = 500;
2101
+ var cachedToken = null;
2102
+ var cachedTokenExpiry = 0;
2103
+ var DEBUG_TOKEN_CACHE = process.env.DEBUG_REDDIT === "true";
2104
+ var clientLogger = Logger4.get("reddit-client");
2105
+ var pendingAuthPromise = null;
2106
+ async function fetchRedditJson(sub, id, token, userAgent) {
2107
+ const limit = Math.min(FETCH_LIMIT, 500);
2108
+ const apiUrl = `${REDDIT_API_BASE}/r/${sub}/comments/${id}?sort=top&limit=${limit}&depth=10&raw_json=1`;
2109
+ const res = await fetchWithTimeout(apiUrl, {
2110
+ headers: {
2111
+ "Authorization": `Bearer ${token}`,
2112
+ "User-Agent": userAgent
2113
+ },
2114
+ timeoutMs: 3e4
2115
+ });
2116
+ if (res.status === 429) {
2117
+ const err = new Error("Rate limited by Reddit API");
2118
+ err.status = 429;
2119
+ throw err;
2120
+ }
2121
+ if (res.status === 404) {
2122
+ throw new Error(`Post not found: /r/${sub}/comments/${id}`);
2123
+ }
2124
+ if (!res.ok) {
2125
+ const err = new Error(`Reddit API error: ${res.status}`);
2126
+ err.status = res.status;
2127
+ throw err;
2128
+ }
2129
+ try {
2130
+ return await res.json();
2131
+ } catch {
2132
+ throw new Error("Failed to parse Reddit API response");
2133
+ }
2134
+ }
2135
+ function parsePostData(postListing, sub) {
2136
+ const p = postListing?.data?.children?.[0]?.data;
2137
+ if (!p) {
2138
+ throw new Error(`Post data not found in response for /r/${sub}`);
2139
+ }
2140
+ return {
2141
+ title: p.title || "Untitled",
2142
+ author: p.author || "[deleted]",
2143
+ subreddit: p.subreddit || sub,
2144
+ body: formatBody(p),
2145
+ score: p.score || 0,
2146
+ commentCount: p.num_comments || 0,
2147
+ url: `https://reddit.com${p.permalink || ""}`,
2148
+ created: new Date((p.created_utc || 0) * 1e3),
2149
+ flair: p.link_flair_text || void 0,
2150
+ isNsfw: p.over_18 || false,
2151
+ isPinned: p.stickied || false
2152
+ };
2153
+ }
2154
+ function formatBody(p) {
2155
+ if (p.selftext?.trim()) return p.selftext;
2156
+ if (p.is_self) return "";
2157
+ if (p.url) return `**Link:** ${p.url}`;
2158
+ return "";
2159
+ }
2160
+ var MAX_COMMENT_DEPTH = 15;
2161
+ function parseCommentTree(commentListing, opAuthor) {
2162
+ const result = [];
2163
+ const extract = (items, depth = 0) => {
2164
+ if (depth > MAX_COMMENT_DEPTH) return;
2165
+ const sorted = [...items].sort((a, b) => (b.data?.score || 0) - (a.data?.score || 0));
2166
+ for (const c of sorted) {
2167
+ if (c.kind !== "t1" || !c.data?.author || c.data.author === "[deleted]") continue;
2168
+ result.push({
2169
+ author: c.data.author,
2170
+ body: c.data.body || "",
2171
+ score: c.data.score || 0,
2172
+ depth,
2173
+ isOP: c.data.author === opAuthor
2174
+ });
2175
+ if (typeof c.data.replies === "object" && c.data.replies?.data?.children) {
2176
+ extract(c.data.replies.data.children, depth + 1);
2177
+ }
2178
+ }
2179
+ };
2180
+ extract(commentListing?.data?.children || []);
2181
+ return result;
2182
+ }
2183
+ async function processBatch(client, batchUrls) {
2184
+ const results = /* @__PURE__ */ new Map();
2185
+ let rateLimitHits = 0;
2186
+ const batchResults = await pMapSettled(
2187
+ batchUrls,
2188
+ (url) => client.getPost(url),
2189
+ CONCURRENCY.REDDIT
2190
+ );
2191
+ for (let i = 0; i < batchResults.length; i++) {
2192
+ const result = batchResults[i];
2193
+ if (!result) continue;
2194
+ const url = batchUrls[i] ?? "";
2195
+ if (result.status === "fulfilled") {
2196
+ results.set(url, result.value);
2197
+ } else {
2198
+ const errorMsg = result.reason?.message || String(result.reason);
2199
+ if (errorMsg.includes("429") || errorMsg.includes("rate")) rateLimitHits++;
2200
+ results.set(url, new Error(errorMsg));
2201
+ }
2202
+ }
2203
+ return { results, rateLimitHits };
2204
+ }
2205
+ var RedditClient = class {
2206
+ constructor(clientId, clientSecret) {
2207
+ this.clientId = clientId;
2208
+ this.clientSecret = clientSecret;
2209
+ }
2210
+ userAgent = `script:${USER_AGENT_VERSION} (by /u/research-powerpack)`;
2211
+ /**
2212
+ * Authenticate with Reddit API with retry logic
2213
+ * Uses module-level token cache and promise deduplication to prevent
2214
+ * concurrent auth calls from firing multiple token requests
2215
+ * Returns null on failure instead of throwing
2216
+ */
2217
+ async auth() {
2218
+ if (cachedToken && Date.now() < cachedTokenExpiry - TOKEN_EXPIRY_MS) {
2219
+ if (DEBUG_TOKEN_CACHE) clientLogger.debug("Token cache HIT");
2220
+ return cachedToken;
2221
+ }
2222
+ if (pendingAuthPromise) {
2223
+ if (DEBUG_TOKEN_CACHE) clientLogger.debug("Auth already in flight, awaiting...");
2224
+ return pendingAuthPromise;
2225
+ }
2226
+ pendingAuthPromise = this.performAuth();
2227
+ try {
2228
+ return await pendingAuthPromise;
2229
+ } finally {
2230
+ pendingAuthPromise = null;
2231
+ }
2232
+ }
2233
+ async performAuth() {
2234
+ if (DEBUG_TOKEN_CACHE) clientLogger.debug("Token cache MISS - authenticating");
2235
+ const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64");
2236
+ for (let attempt = 0; attempt < 3; attempt++) {
2237
+ try {
2238
+ const res = await fetchWithTimeout(REDDIT_TOKEN_URL, {
2239
+ method: "POST",
2240
+ headers: {
2241
+ "Authorization": `Basic ${credentials}`,
2242
+ "Content-Type": "application/x-www-form-urlencoded",
2243
+ "User-Agent": this.userAgent
2244
+ },
2245
+ body: "grant_type=client_credentials",
2246
+ timeoutMs: 15e3
2247
+ });
2248
+ if (!res.ok) {
2249
+ const text = await res.text().catch(() => "");
2250
+ mcpLog("error", `Auth failed (${res.status}): ${text}`, "reddit");
2251
+ if (res.status === 401 || res.status === 403) {
2252
+ cachedToken = null;
2253
+ cachedTokenExpiry = 0;
2254
+ return null;
2255
+ }
2256
+ if (res.status >= 500 && attempt < 2) {
2257
+ await sleep(calculateBackoff2(attempt));
2258
+ continue;
2259
+ }
2260
+ return null;
2261
+ }
2262
+ const data = await res.json();
2263
+ if (!data.access_token) {
2264
+ mcpLog("error", "Auth response missing access_token", "reddit");
2265
+ return null;
2266
+ }
2267
+ cachedToken = data.access_token;
2268
+ cachedTokenExpiry = Date.now() + (data.expires_in || 3600) * 1e3;
2269
+ return cachedToken;
2270
+ } catch (error2) {
2271
+ const err = classifyError(error2);
2272
+ mcpLog("error", `Auth error (attempt ${attempt + 1}): ${err.message}`, "reddit");
2273
+ if (err.code === ErrorCode.AUTH_ERROR) {
2274
+ cachedToken = null;
2275
+ cachedTokenExpiry = 0;
2276
+ }
2277
+ if (attempt < 2 && err.retryable) {
2278
+ await sleep(calculateBackoff2(attempt));
2279
+ continue;
2280
+ }
2281
+ return null;
2282
+ }
2283
+ }
2284
+ return null;
2285
+ }
2286
+ parseUrl(url) {
2287
+ const m = url.match(/reddit\.com\/r\/([^\/]+)\/comments\/([a-z0-9]+)/i);
2288
+ return m ? { sub: m[1], id: m[2] } : null;
2289
+ }
2290
+ /**
2291
+ * Get a single Reddit post with comments
2292
+ * Returns PostResult or throws Error (for use with Promise.allSettled)
2293
+ */
2294
+ async getPost(url) {
2295
+ const parsed = this.parseUrl(url);
2296
+ if (!parsed) {
2297
+ throw new Error(`Invalid Reddit URL format: ${url}`);
2298
+ }
2299
+ const token = await this.auth();
2300
+ if (!token) {
2301
+ throw new Error("Reddit authentication failed - check credentials");
2302
+ }
2303
+ let lastError = null;
2304
+ for (let attempt = 0; attempt < REDDIT.RETRY_COUNT; attempt++) {
2305
+ try {
2306
+ const data = await fetchRedditJson(parsed.sub, parsed.id, token, this.userAgent);
2307
+ const [postListing, commentListing] = data;
2308
+ const post = parsePostData(postListing, parsed.sub);
2309
+ const comments = parseCommentTree(commentListing, post.author);
2310
+ return { post, comments, actualComments: post.commentCount };
2311
+ } catch (error2) {
2312
+ lastError = classifyError(error2);
2313
+ const status = error2.status;
2314
+ if (status === 429) {
2315
+ const delay = REDDIT.RETRY_DELAYS[attempt] || 32e3;
2316
+ mcpLog("warning", `Rate limited. Retry ${attempt + 1}/${REDDIT.RETRY_COUNT} after ${delay}ms`, "reddit");
2317
+ await sleep(delay);
2318
+ continue;
2319
+ }
2320
+ if (!lastError.retryable) {
2321
+ throw error2 instanceof Error ? error2 : new Error(lastError.message);
2322
+ }
2323
+ if (attempt < REDDIT.RETRY_COUNT - 1) {
2324
+ const delay = REDDIT.RETRY_DELAYS[attempt] || 2e3;
2325
+ mcpLog("warning", `${lastError.code}: ${lastError.message}. Retry ${attempt + 1}/${REDDIT.RETRY_COUNT}`, "reddit");
2326
+ await sleep(delay);
2327
+ }
2328
+ }
2329
+ }
2330
+ throw new Error(lastError?.message || "Failed to fetch Reddit post after retries");
2331
+ }
2332
+ async batchGetPosts(urls, fetchComments = true, onBatchComplete) {
2333
+ const allResults = /* @__PURE__ */ new Map();
2334
+ let rateLimitHits = 0;
2335
+ const totalBatches = Math.ceil(urls.length / REDDIT.BATCH_SIZE);
2336
+ mcpLog("info", `Fetching ${urls.length} posts in ${totalBatches} batch(es), up to ${FETCH_LIMIT} comments/post`, "reddit");
2337
+ for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
2338
+ const startIdx = batchNum * REDDIT.BATCH_SIZE;
2339
+ const batchUrls = urls.slice(startIdx, startIdx + REDDIT.BATCH_SIZE);
2340
+ mcpLog("info", `Batch ${batchNum + 1}/${totalBatches} (${batchUrls.length} posts)`, "reddit");
2341
+ const batchResult = await processBatch(this, batchUrls);
2342
+ for (const [url, result] of batchResult.results) {
2343
+ allResults.set(url, result);
2344
+ }
2345
+ rateLimitHits += batchResult.rateLimitHits;
2346
+ try {
2347
+ onBatchComplete?.(batchNum + 1, totalBatches, allResults.size);
2348
+ } catch (callbackError) {
2349
+ mcpLog("error", `onBatchComplete callback error: ${callbackError}`, "reddit");
2350
+ }
2351
+ mcpLog("info", `Batch ${batchNum + 1} complete (${allResults.size}/${urls.length})`, "reddit");
2352
+ if (batchNum < totalBatches - 1) {
2353
+ await sleep(500);
2354
+ }
2355
+ }
2356
+ return { results: allResults, batchesProcessed: totalBatches, totalPosts: urls.length, rateLimitHits };
2357
+ }
2358
+ };
2359
+
2360
+ // src/effect/errors.ts
2361
+ import { Data } from "effect";
2362
+ var ProviderRequestError = class extends Data.TaggedError("ProviderRequestError") {
2363
+ };
2364
+ var ProviderTimeoutError = class extends Data.TaggedError("ProviderTimeoutError") {
2365
+ };
2366
+ var WeakContentError = class extends Data.TaggedError("WeakContentError") {
2367
+ };
2368
+ var AllStrategiesExhaustedError = class extends Data.TaggedError("AllStrategiesExhaustedError") {
2369
+ };
2370
+ function providerError(provider, operation, error2) {
2371
+ return new ProviderRequestError({
2372
+ provider,
2373
+ operation,
2374
+ error: classifyError(error2)
2375
+ });
2376
+ }
2377
+
2378
+ // src/effect/services.ts
2379
+ var SearchService = Context.GenericTag("SearchService");
2380
+ var JinaService = Context.GenericTag("JinaService");
2381
+ var KernelService = Context.GenericTag("KernelService");
2382
+ var RedditService = Context.GenericTag("RedditService");
2383
+ var LlmService = Context.GenericTag("LlmService");
2384
+ var SearchServiceLive = Layer.sync(SearchService, () => {
2385
+ const env = parseEnv();
2386
+ return {
2387
+ serperSearchMultiple: (queries) => Effect.tryPromise({
2388
+ try: () => new SearchClient(env.SEARCH_API_KEY).searchMultiple([...queries]),
2389
+ catch: (error2) => providerError("serper", "searchMultiple", error2)
2390
+ }),
2391
+ jinaSearchMultiple: (queries) => Effect.tryPromise({
2392
+ try: () => new JinaClient(env.JINA_API_KEY).searchMultiple([...queries]),
2393
+ catch: (error2) => providerError("jina", "searchMultiple", error2)
2394
+ })
2395
+ };
2396
+ });
2397
+ var JinaServiceLive = Layer.sync(JinaService, () => {
2398
+ const env = parseEnv();
2399
+ const client = new JinaClient(env.JINA_API_KEY);
2400
+ return {
2401
+ convert: (request) => Effect.tryPromise({
2402
+ try: () => client.convert(request),
2403
+ catch: (error2) => providerError("jina", "convert", error2)
2404
+ })
2405
+ };
2406
+ });
2407
+ var KernelServiceLive = Layer.sync(KernelService, () => {
2408
+ const client = new KernelClient();
2409
+ return {
2410
+ render: (request) => Effect.tryPromise({
2411
+ try: () => client.render(request),
2412
+ catch: (error2) => providerError("kernel", "render", error2)
2413
+ })
2414
+ };
2415
+ });
2416
+ function RedditServiceLive(clientId, clientSecret) {
2417
+ return Layer.sync(RedditService, () => {
2418
+ const client = new RedditClient(clientId, clientSecret);
2419
+ return {
2420
+ batchGetPosts: (urls, includeComments) => Effect.tryPromise({
2421
+ try: () => client.batchGetPosts([...urls], includeComments),
2422
+ catch: (error2) => providerError("reddit", "batchGetPosts", error2)
2423
+ })
2424
+ };
2425
+ });
2426
+ }
2427
+ var LlmServiceLive = Layer.succeed(LlmService, {
2428
+ extractContent: (content, config) => Effect.tryPromise({
2429
+ try: () => processContentWithLLM(content, config, createLLMProcessor()),
2430
+ catch: (error2) => providerError("llm", "extractContent", error2)
2431
+ }),
2432
+ classifySearchResults: (rankedUrls, objective, totalQueries, processor, previousQueries) => Effect.tryPromise({
2433
+ try: () => classifySearchResults(rankedUrls, objective, totalQueries, processor, previousQueries),
2434
+ catch: (error2) => providerError("llm", "classifySearchResults", error2)
2435
+ }),
2436
+ suggestRefineQueriesForRawMode: (rankedUrls, objective, originalQueries, processor) => Effect.tryPromise({
2437
+ try: () => suggestRefineQueriesForRawMode(rankedUrls, objective, originalQueries, processor),
2438
+ catch: (error2) => providerError("llm", "suggestRefineQueriesForRawMode", error2)
2439
+ }),
2440
+ generateResearchBrief: (goal, processor) => Effect.tryPromise({
2441
+ try: () => generateResearchBrief(goal, processor),
2442
+ catch: (error2) => providerError("llm", "generateResearchBrief", error2)
2443
+ })
2444
+ });
2445
+
2446
+ // src/effect/runtime.ts
2447
+ var BaseExternalLive = Layer2.mergeAll(
24
2448
  JinaServiceLive,
25
- KernelService,
26
- KernelServiceLive,
27
- LlmService,
28
- LlmServiceLive,
29
- RedditService,
30
- RedditServiceLive
31
- } from "../effect/services.js";
32
- import { ProviderTimeoutError } from "../effect/errors.js";
33
- import {
34
- mcpLog,
35
- formatSuccess,
36
- formatError,
37
- formatBatchHeader,
38
- formatDuration
39
- } from "./utils.js";
40
- import {
41
- createToolReporter,
42
- NOOP_REPORTER,
43
- toolFailure,
44
- toolSuccess,
45
- toToolResponse
46
- } from "./mcp-helpers.js";
47
- const markdownCleaner = new MarkdownCleaner();
2449
+ SearchServiceLive,
2450
+ LlmServiceLive
2451
+ );
2452
+ function runExternalEffect(effect, layer) {
2453
+ return Effect2.runPromise(effect.pipe(Effect2.provide(layer)));
2454
+ }
2455
+
2456
+ // src/utils/response.ts
2457
+ var SECONDS_MS = 1e3;
2458
+ var MINUTES_MS = 6e4;
2459
+ function formatSuccess(opts) {
2460
+ const parts = [];
2461
+ parts.push(`\u2713 ${opts.title}`);
2462
+ parts.push("");
2463
+ parts.push(opts.summary);
2464
+ if (opts.data) {
2465
+ parts.push("");
2466
+ parts.push("---");
2467
+ parts.push(opts.data);
2468
+ }
2469
+ if (opts.nextSteps?.length) {
2470
+ parts.push("");
2471
+ parts.push("---");
2472
+ parts.push("**Next Steps:**");
2473
+ opts.nextSteps.forEach((step) => parts.push(`\u2192 ${step}`));
2474
+ }
2475
+ if (opts.metadata && Object.keys(opts.metadata).length > 0) {
2476
+ parts.push("");
2477
+ parts.push("---");
2478
+ const metaStr = Object.entries(opts.metadata).map(([k, v]) => `${k}: ${v}`).join(" | ");
2479
+ parts.push(`*${metaStr}*`);
2480
+ }
2481
+ return parts.join("\n");
2482
+ }
2483
+ function formatError(opts) {
2484
+ const parts = [];
2485
+ const prefix = opts.toolName ? `[${opts.toolName}] ` : "";
2486
+ parts.push(`\u274C ${prefix}${opts.code}: ${opts.message}`);
2487
+ if (opts.retryable) {
2488
+ parts.push("*Retryable.*");
2489
+ }
2490
+ if (opts.howToFix?.length) {
2491
+ parts.push("");
2492
+ parts.push("**How to Fix:**");
2493
+ opts.howToFix.forEach((step, i) => parts.push(`${i + 1}. ${step}`));
2494
+ }
2495
+ if (opts.alternatives?.length) {
2496
+ parts.push("");
2497
+ parts.push("**Alternatives:**");
2498
+ opts.alternatives.forEach((alt, i) => parts.push(`${i + 1}. ${alt}`));
2499
+ }
2500
+ return parts.join("\n");
2501
+ }
2502
+ function formatBatchHeader(opts) {
2503
+ const parts = [];
2504
+ const successRate = opts.totalItems > 0 ? opts.successful / opts.totalItems : 0;
2505
+ const emoji = successRate === 1 ? "\u2713" : successRate >= 0.5 ? "\u26A0\uFE0F" : "\u274C";
2506
+ parts.push(`${emoji} ${opts.title}`);
2507
+ parts.push("");
2508
+ parts.push(`\u2022 Total: ${opts.totalItems}`);
2509
+ parts.push(`\u2022 Successful: ${opts.successful}`);
2510
+ if (opts.failed > 0) {
2511
+ parts.push(`\u2022 Failed: ${opts.failed}`);
2512
+ }
2513
+ if (opts.tokensPerItem) {
2514
+ parts.push(`\u2022 Tokens/item: ~${opts.tokensPerItem.toLocaleString()}`);
2515
+ }
2516
+ if (opts.batches) {
2517
+ parts.push(`\u2022 Batches: ${opts.batches}`);
2518
+ }
2519
+ if (opts.extras) {
2520
+ Object.entries(opts.extras).forEach(([key, val]) => {
2521
+ parts.push(`\u2022 ${key}: ${val}`);
2522
+ });
2523
+ }
2524
+ return parts.join("\n");
2525
+ }
2526
+ function formatDuration(ms) {
2527
+ if (ms < SECONDS_MS) return `${ms}ms`;
2528
+ if (ms < MINUTES_MS) return `${(ms / SECONDS_MS).toFixed(1)}s`;
2529
+ return `${(ms / MINUTES_MS).toFixed(1)}m`;
2530
+ }
2531
+
2532
+ // src/tools/mcp-helpers.ts
2533
+ import { error, markdown } from "mcp-use/server";
2534
+ var NOOP_REPORTER = {
2535
+ async log() {
2536
+ },
2537
+ async progress() {
2538
+ }
2539
+ };
2540
+ function toolSuccess(content, structuredContent) {
2541
+ return {
2542
+ isError: false,
2543
+ content,
2544
+ structuredContent
2545
+ };
2546
+ }
2547
+ function toolFailure(content) {
2548
+ return {
2549
+ isError: true,
2550
+ content
2551
+ };
2552
+ }
2553
+ function createToolReporter(ctx, loggerName) {
2554
+ return {
2555
+ log(level, message) {
2556
+ return ctx.log(level, message, loggerName);
2557
+ },
2558
+ progress(loaded, total, message) {
2559
+ return ctx.reportProgress?.(loaded, total, message) ?? Promise.resolve();
2560
+ }
2561
+ };
2562
+ }
2563
+ function toToolResponse(result) {
2564
+ if (result.isError) {
2565
+ return error(result.content);
2566
+ }
2567
+ if (result.structuredContent) {
2568
+ return {
2569
+ ...markdown(result.content),
2570
+ structuredContent: result.structuredContent
2571
+ };
2572
+ }
2573
+ return markdown(result.content);
2574
+ }
2575
+
2576
+ // src/tools/scrape.ts
2577
+ var markdownCleaner = new MarkdownCleaner();
48
2578
  function formatInputValidationError(toolName, issues) {
49
2579
  const details = issues.map((issue) => {
50
2580
  const path = issue.path.length > 0 ? issue.path.map(String).join(".") : "<root>";
@@ -71,23 +2601,23 @@ function cleanFetchedContent(rawContent, url) {
71
2601
  return rawContent;
72
2602
  }
73
2603
  }
74
- function effectErrorMessage(error) {
75
- if (typeof error === "object" && error !== null) {
76
- if ("error" in error) {
77
- const structured = error.error;
2604
+ function effectErrorMessage(error2) {
2605
+ if (typeof error2 === "object" && error2 !== null) {
2606
+ if ("error" in error2) {
2607
+ const structured = error2.error;
78
2608
  if (typeof structured?.message === "string") return structured.message;
79
2609
  }
80
- if ("message" in error && typeof error.message === "string") {
81
- return error.message;
2610
+ if ("message" in error2 && typeof error2.message === "string") {
2611
+ return error2.message;
82
2612
  }
83
- if ("_tag" in error && typeof error._tag === "string") {
84
- return error._tag;
2613
+ if ("_tag" in error2 && typeof error2._tag === "string") {
2614
+ return error2._tag;
85
2615
  }
86
2616
  }
87
- return String(error);
2617
+ return String(error2);
88
2618
  }
89
- const REDDIT_HOST = /(?:^|\.)reddit\.com$/i;
90
- const REDDIT_POST_PERMALINK = /\/r\/[^/]+\/comments\/[a-z0-9]+/i;
2619
+ var REDDIT_HOST = /(?:^|\.)reddit\.com$/i;
2620
+ var REDDIT_POST_PERMALINK = /\/r\/[^/]+\/comments\/[a-z0-9]+/i;
91
2621
  function isRedditUrl(url) {
92
2622
  try {
93
2623
  const u = new URL(url);
@@ -185,12 +2715,12 @@ async function fetchJinaFirstBranch(inputs, kernelEnabled, scrapeDoProxyUrl) {
185
2715
  "scrape"
186
2716
  );
187
2717
  const directResults = await runExternalEffect(
188
- Effect.gen(function* () {
2718
+ Effect3.gen(function* () {
189
2719
  const jina = yield* JinaService;
190
- return yield* Effect.forEach(
2720
+ return yield* Effect3.forEach(
191
2721
  inputs,
192
2722
  (input) => jina.convert({ url: input.url, timeoutSeconds: 15, allowProxyRetry: false }).pipe(
193
- Effect.timeoutFail({
2723
+ Effect3.timeoutFail({
194
2724
  duration: "20 seconds",
195
2725
  onTimeout: () => new ProviderTimeoutError({
196
2726
  provider: "jina",
@@ -198,7 +2728,7 @@ async function fetchJinaFirstBranch(inputs, kernelEnabled, scrapeDoProxyUrl) {
198
2728
  durationMs: 2e4
199
2729
  })
200
2730
  }),
201
- Effect.either
2731
+ Effect3.either
202
2732
  ),
203
2733
  { concurrency: CONCURRENCY.JINA_READER }
204
2734
  );
@@ -261,9 +2791,9 @@ async function fetchJinaFirstBranch(inputs, kernelEnabled, scrapeDoProxyUrl) {
261
2791
  "scrape"
262
2792
  );
263
2793
  const proxyResults = await runExternalEffect(
264
- Effect.gen(function* () {
2794
+ Effect3.gen(function* () {
265
2795
  const jina = yield* JinaService;
266
- return yield* Effect.forEach(
2796
+ return yield* Effect3.forEach(
267
2797
  proxyInputs,
268
2798
  (input) => jina.convert({
269
2799
  url: input.url,
@@ -272,7 +2802,7 @@ async function fetchJinaFirstBranch(inputs, kernelEnabled, scrapeDoProxyUrl) {
272
2802
  noCache: true,
273
2803
  allowProxyRetry: false
274
2804
  }).pipe(
275
- Effect.timeoutFail({
2805
+ Effect3.timeoutFail({
276
2806
  duration: "20 seconds",
277
2807
  onTimeout: () => new ProviderTimeoutError({
278
2808
  provider: "jina",
@@ -280,7 +2810,7 @@ async function fetchJinaFirstBranch(inputs, kernelEnabled, scrapeDoProxyUrl) {
280
2810
  durationMs: 2e4
281
2811
  })
282
2812
  }),
283
- Effect.either
2813
+ Effect3.either
284
2814
  ),
285
2815
  { concurrency: CONCURRENCY.JINA_READER }
286
2816
  );
@@ -337,12 +2867,12 @@ async function fetchKernelBranch(inputs) {
337
2867
  "scrape"
338
2868
  );
339
2869
  const results = await runExternalEffect(
340
- Effect.gen(function* () {
2870
+ Effect3.gen(function* () {
341
2871
  const kernel = yield* KernelService;
342
- return yield* Effect.forEach(
2872
+ return yield* Effect3.forEach(
343
2873
  inputs,
344
2874
  (input) => kernel.render({ url: input.url, timeoutSeconds: 15 }).pipe(
345
- Effect.timeoutFail({
2875
+ Effect3.timeoutFail({
346
2876
  duration: "25 seconds",
347
2877
  onTimeout: () => new ProviderTimeoutError({
348
2878
  provider: "kernel",
@@ -350,7 +2880,7 @@ async function fetchKernelBranch(inputs) {
350
2880
  durationMs: 25e3
351
2881
  })
352
2882
  }),
353
- Effect.either
2883
+ Effect3.either
354
2884
  ),
355
2885
  { concurrency: CONCURRENCY.KERNEL }
356
2886
  );
@@ -488,10 +3018,10 @@ async function fetchRedditBranch(inputs) {
488
3018
  mcpLog("info", `[concurrency] reddit branch: fetching ${postInputs.length} post(s) with limit=${CONCURRENCY.REDDIT}`, "scrape");
489
3019
  const urls = postInputs.map((i) => i.url);
490
3020
  const batchResult = await runExternalEffect(
491
- Effect.gen(function* () {
3021
+ Effect3.gen(function* () {
492
3022
  const reddit = yield* RedditService;
493
3023
  return yield* reddit.batchGetPosts(urls, true).pipe(
494
- Effect.timeoutFail({
3024
+ Effect3.timeoutFail({
495
3025
  duration: "60 seconds",
496
3026
  onTimeout: () => new ProviderTimeoutError({
497
3027
  provider: "reddit",
@@ -523,8 +3053,8 @@ async function fetchRedditBranch(inputs) {
523
3053
  }
524
3054
  return { successItems, failedContents, metrics: { successful, failed, totalCredits: 0 } };
525
3055
  }
526
- const TERSE_LLM_FAILURE_RE = /^\s*##\s*Matches\s*\n+\s*_Page did not load:\s*([a-z0-9_-]+)_\s*\.?\s*$/i;
527
- const RAW_FALLBACK_CHAR_CAP = 4e3;
3056
+ var TERSE_LLM_FAILURE_RE = /^\s*##\s*Matches\s*\n+\s*_Page did not load:\s*([a-z0-9_-]+)_\s*\.?\s*$/i;
3057
+ var RAW_FALLBACK_CHAR_CAP = 4e3;
528
3058
  function detectTerseFailure(llmOutput) {
529
3059
  const m = llmOutput.trim().match(TERSE_LLM_FAILURE_RE);
530
3060
  return m ? m[1] : null;
@@ -558,15 +3088,15 @@ async function processItemsWithLlm(successItems, enhancedInstruction, llmProcess
558
3088
  }
559
3089
  mcpLog("info", `[concurrency] llm extraction: fanning out ${successItems.length} item(s) with limit=${CONCURRENCY.LLM_EXTRACTION}`, "scrape");
560
3090
  const llmResults = await runExternalEffect(
561
- Effect.gen(function* () {
3091
+ Effect3.gen(function* () {
562
3092
  const llm = yield* LlmService;
563
- return yield* Effect.forEach(
3093
+ return yield* Effect3.forEach(
564
3094
  successItems,
565
3095
  (item) => llm.extractContent(
566
3096
  item.content,
567
3097
  { enabled: true, extract: enhancedInstruction, url: item.url }
568
3098
  ).pipe(
569
- Effect.timeoutFail({
3099
+ Effect3.timeoutFail({
570
3100
  duration: "155 seconds",
571
3101
  onTimeout: () => new ProviderTimeoutError({
572
3102
  provider: "llm",
@@ -574,8 +3104,8 @@ async function processItemsWithLlm(successItems, enhancedInstruction, llmProcess
574
3104
  durationMs: 155e3
575
3105
  })
576
3106
  }),
577
- Effect.either,
578
- Effect.map((result) => ({ item, result }))
3107
+ Effect3.either,
3108
+ Effect3.map((result) => ({ item, result }))
579
3109
  ),
580
3110
  { concurrency: CONCURRENCY.LLM_EXTRACTION }
581
3111
  );
@@ -707,8 +3237,8 @@ async function handleScrapeLinksMode(params, reporter = NOOP_REPORTER) {
707
3237
  const env = parseEnv();
708
3238
  kernelEnabled = getCapabilities().kernel;
709
3239
  scrapeDoProxyUrl = env.SCRAPER_API_KEY ? buildScrapeDoProxyUrl(env.SCRAPER_API_KEY) : void 0;
710
- } catch (error) {
711
- const err = classifyError(error);
3240
+ } catch (error2) {
3241
+ const err = classifyError(error2);
712
3242
  return createScrapeErrorResponse(
713
3243
  "CLIENT_INIT_FAILED",
714
3244
  `Failed to initialize scrape providers: ${err.message}`,