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.
- package/dist/index.js +4662 -21
- package/dist/index.js.map +4 -4
- package/dist/mcp-use.json +2 -2
- package/dist/src/clients/jina.js +202 -16
- package/dist/src/clients/jina.js.map +3 -3
- package/dist/src/clients/kernel.js +254 -7
- package/dist/src/clients/kernel.js.map +4 -4
- package/dist/src/clients/reddit.js +326 -23
- package/dist/src/clients/reddit.js.map +4 -4
- package/dist/src/clients/scraper.js +345 -22
- package/dist/src/clients/scraper.js.map +4 -4
- package/dist/src/clients/search.js +316 -20
- package/dist/src/clients/search.js.map +4 -4
- package/dist/src/config/index.js +39 -10
- package/dist/src/config/index.js.map +3 -3
- package/dist/src/effect/errors.js +130 -5
- package/dist/src/effect/errors.js.map +3 -3
- package/dist/src/effect/runtime.js +1893 -4
- package/dist/src/effect/runtime.js.map +4 -4
- package/dist/src/effect/services.js +2124 -22
- package/dist/src/effect/services.js.map +4 -4
- package/dist/src/schemas/scrape-links.js +6 -5
- package/dist/src/schemas/scrape-links.js.map +1 -1
- package/dist/src/schemas/start-research.js +2 -1
- package/dist/src/schemas/start-research.js.map +1 -1
- package/dist/src/schemas/web-search.js +9 -8
- package/dist/src/schemas/web-search.js.map +1 -1
- package/dist/src/services/llm-processor.js +406 -25
- package/dist/src/services/llm-processor.js.map +4 -4
- package/dist/src/services/markdown-cleaner.js +6 -5
- package/dist/src/services/markdown-cleaner.js.map +1 -1
- package/dist/src/tools/mcp-helpers.js +2 -1
- package/dist/src/tools/mcp-helpers.js.map +1 -1
- package/dist/src/tools/registry.js +4629 -3
- package/dist/src/tools/registry.js.map +4 -4
- package/dist/src/tools/scrape.js +2610 -80
- package/dist/src/tools/scrape.js.map +4 -4
- package/dist/src/tools/search.js +2388 -59
- package/dist/src/tools/search.js.map +4 -4
- package/dist/src/tools/start-research.js +2030 -23
- package/dist/src/tools/start-research.js.map +4 -4
- package/dist/src/tools/utils.js +98 -7
- package/dist/src/tools/utils.js.map +3 -3
- package/dist/src/utils/concurrency.js +1 -0
- package/dist/src/utils/concurrency.js.map +1 -1
- package/dist/src/utils/content-extractor.js +27 -2
- package/dist/src/utils/content-extractor.js.map +3 -3
- package/dist/src/utils/content-quality.js +4 -3
- package/dist/src/utils/content-quality.js.map +1 -1
- package/dist/src/utils/errors.js +26 -3
- package/dist/src/utils/errors.js.map +3 -3
- package/dist/src/utils/logger.js +1 -0
- package/dist/src/utils/logger.js.map +1 -1
- package/dist/src/utils/markdown-formatter.js +1 -0
- package/dist/src/utils/markdown-formatter.js.map +1 -1
- package/dist/src/utils/query-relax.js +9 -8
- package/dist/src/utils/query-relax.js.map +1 -1
- package/dist/src/utils/response.js +3 -2
- package/dist/src/utils/response.js.map +1 -1
- package/dist/src/utils/retry.js +5 -4
- package/dist/src/utils/retry.js.map +1 -1
- package/dist/src/utils/sanitize.js +4 -3
- package/dist/src/utils/sanitize.js.map +1 -1
- package/dist/src/utils/source-type.js +4 -3
- package/dist/src/utils/source-type.js.map +1 -1
- package/dist/src/utils/url-aggregator.js +112 -11
- package/dist/src/utils/url-aggregator.js.map +3 -3
- package/dist/src/version.js +7 -6
- package/dist/src/version.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,12 +1,1901 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
// src/effect/runtime.ts
|
|
2
|
+
import { Effect as Effect2, Layer as Layer2 } from "effect";
|
|
3
|
+
|
|
4
|
+
// src/effect/services.ts
|
|
5
|
+
import { Context, Effect, Layer } from "effect";
|
|
6
|
+
|
|
7
|
+
// src/config/index.ts
|
|
8
|
+
import { Logger } from "mcp-use";
|
|
9
|
+
|
|
10
|
+
// src/version.ts
|
|
11
|
+
import { createRequire } from "module";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import { dirname, join } from "path";
|
|
14
|
+
var DEFAULT_PACKAGE_INFO = {
|
|
15
|
+
version: "3.9.5",
|
|
16
|
+
name: "mcp-researchpowerpack-http",
|
|
17
|
+
description: "Research Powerpack MCP Server"
|
|
18
|
+
};
|
|
19
|
+
var packageJson = { ...DEFAULT_PACKAGE_INFO };
|
|
20
|
+
try {
|
|
21
|
+
if (typeof import.meta.url === "string" && import.meta.url.startsWith("file:")) {
|
|
22
|
+
const _require = createRequire(import.meta.url);
|
|
23
|
+
const _dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
try {
|
|
25
|
+
packageJson = _require(join(_dirname, "..", "package.json"));
|
|
26
|
+
} catch {
|
|
27
|
+
packageJson = _require(join(_dirname, "..", "..", "package.json"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
var VERSION = packageJson.version;
|
|
33
|
+
var PACKAGE_NAME = packageJson.name;
|
|
34
|
+
var PACKAGE_DESCRIPTION = packageJson.description;
|
|
35
|
+
var USER_AGENT_VERSION = `${PACKAGE_NAME}/${VERSION}`;
|
|
36
|
+
|
|
37
|
+
// src/config/index.ts
|
|
38
|
+
function safeParseInt(value, defaultVal, min, max) {
|
|
39
|
+
const logger = Logger.get("config");
|
|
40
|
+
if (!value) {
|
|
41
|
+
return defaultVal;
|
|
42
|
+
}
|
|
43
|
+
const parsed = parseInt(value, 10);
|
|
44
|
+
if (isNaN(parsed)) {
|
|
45
|
+
logger.warn(`Invalid number "${value}", using default ${defaultVal}`);
|
|
46
|
+
return defaultVal;
|
|
47
|
+
}
|
|
48
|
+
if (parsed < min) {
|
|
49
|
+
logger.warn(`Value ${parsed} below minimum ${min}, clamping to ${min}`);
|
|
50
|
+
return min;
|
|
51
|
+
}
|
|
52
|
+
if (parsed > max) {
|
|
53
|
+
logger.warn(`Value ${parsed} above maximum ${max}, clamping to ${max}`);
|
|
54
|
+
return max;
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
var cachedEnv = null;
|
|
59
|
+
function parseEnv() {
|
|
60
|
+
if (cachedEnv) return cachedEnv;
|
|
61
|
+
cachedEnv = {
|
|
62
|
+
SCRAPER_API_KEY: process.env.SCRAPEDO_API_KEY || "",
|
|
63
|
+
SEARCH_API_KEY: process.env.SERPER_API_KEY || void 0,
|
|
64
|
+
REDDIT_CLIENT_ID: process.env.REDDIT_CLIENT_ID || void 0,
|
|
65
|
+
REDDIT_CLIENT_SECRET: process.env.REDDIT_CLIENT_SECRET || void 0,
|
|
66
|
+
JINA_API_KEY: process.env.JINA_API_KEY || void 0,
|
|
67
|
+
KERNEL_API_KEY: process.env.KERNEL_API_KEY || void 0,
|
|
68
|
+
KERNEL_PROJECT: process.env.KERNEL_PROJECT || void 0
|
|
69
|
+
};
|
|
70
|
+
return cachedEnv;
|
|
71
|
+
}
|
|
72
|
+
function getCapabilities() {
|
|
73
|
+
const env = parseEnv();
|
|
74
|
+
return {
|
|
75
|
+
reddit: !!(env.REDDIT_CLIENT_ID && env.REDDIT_CLIENT_SECRET),
|
|
76
|
+
search: !!(env.SEARCH_API_KEY || env.JINA_API_KEY),
|
|
77
|
+
serperSearch: !!env.SEARCH_API_KEY,
|
|
78
|
+
jina: !!env.JINA_API_KEY,
|
|
79
|
+
scraping: !!env.SCRAPER_API_KEY,
|
|
80
|
+
kernel: !!env.KERNEL_API_KEY,
|
|
81
|
+
llmExtraction: getLLMConfigStatus().configured
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
var CONCURRENCY = {
|
|
85
|
+
SEARCH: safeParseInt(process.env.CONCURRENCY_SEARCH, 50, 1, 200),
|
|
86
|
+
SCRAPER: safeParseInt(process.env.CONCURRENCY_SCRAPER, 50, 1, 200),
|
|
87
|
+
JINA_READER: safeParseInt(process.env.CONCURRENCY_JINA_READER, 50, 1, 200),
|
|
88
|
+
REDDIT: safeParseInt(process.env.CONCURRENCY_REDDIT, 50, 1, 200),
|
|
89
|
+
LLM_EXTRACTION: safeParseInt(process.env.LLM_CONCURRENCY, 50, 1, 200),
|
|
90
|
+
KERNEL: safeParseInt(process.env.CONCURRENCY_KERNEL, 3, 1, 20)
|
|
91
|
+
};
|
|
92
|
+
var cachedLlmConfigStatus = null;
|
|
93
|
+
function getLLMConfigStatus() {
|
|
94
|
+
if (cachedLlmConfigStatus) return cachedLlmConfigStatus;
|
|
95
|
+
const apiKeyPresent = !!process.env.LLM_API_KEY?.trim();
|
|
96
|
+
const baseUrlPresent = !!process.env.LLM_BASE_URL?.trim();
|
|
97
|
+
const modelPresent = !!process.env.LLM_MODEL?.trim();
|
|
98
|
+
const missingVars = [];
|
|
99
|
+
if (!apiKeyPresent) missingVars.push("LLM_API_KEY");
|
|
100
|
+
if (!baseUrlPresent) missingVars.push("LLM_BASE_URL");
|
|
101
|
+
if (!modelPresent) missingVars.push("LLM_MODEL");
|
|
102
|
+
const configured = missingVars.length === 0;
|
|
103
|
+
cachedLlmConfigStatus = {
|
|
104
|
+
configured,
|
|
105
|
+
apiKeyPresent,
|
|
106
|
+
baseUrlPresent,
|
|
107
|
+
modelPresent,
|
|
108
|
+
missingVars,
|
|
109
|
+
error: configured ? null : `LLM disabled: missing ${missingVars.join(", ")}`
|
|
110
|
+
};
|
|
111
|
+
return cachedLlmConfigStatus;
|
|
112
|
+
}
|
|
113
|
+
var cachedLlmExtraction = null;
|
|
114
|
+
function getLlmExtraction() {
|
|
115
|
+
if (cachedLlmExtraction) return cachedLlmExtraction;
|
|
116
|
+
const apiKey = process.env.LLM_API_KEY?.trim() || "";
|
|
117
|
+
const baseUrl = process.env.LLM_BASE_URL?.trim();
|
|
118
|
+
const model = process.env.LLM_MODEL?.trim();
|
|
119
|
+
const fallbackModel = process.env.LLM_FALLBACK_MODEL?.trim() || "";
|
|
120
|
+
if (apiKey && !baseUrl) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"LLM_BASE_URL is required when LLM_API_KEY is set. Set LLM_BASE_URL to your OpenAI-compatible endpoint."
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (apiKey && !model) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"LLM_MODEL is required when LLM_API_KEY is set."
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
cachedLlmExtraction = {
|
|
131
|
+
API_KEY: apiKey,
|
|
132
|
+
BASE_URL: baseUrl || "",
|
|
133
|
+
MODEL: model || "",
|
|
134
|
+
FALLBACK_MODEL: fallbackModel
|
|
135
|
+
};
|
|
136
|
+
return cachedLlmExtraction;
|
|
137
|
+
}
|
|
138
|
+
var LLM_EXTRACTION = new Proxy({}, {
|
|
139
|
+
get(_target, prop) {
|
|
140
|
+
return getLlmExtraction()[prop];
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// src/utils/logger.ts
|
|
145
|
+
import { Logger as Logger2 } from "mcp-use";
|
|
146
|
+
function getLogger(name) {
|
|
147
|
+
return Logger2.get(name);
|
|
148
|
+
}
|
|
149
|
+
function mcpLog(level, message, loggerName) {
|
|
150
|
+
const logger = getLogger(loggerName ?? "research-powerpack");
|
|
151
|
+
switch (level) {
|
|
152
|
+
case "debug":
|
|
153
|
+
logger.debug(message);
|
|
154
|
+
break;
|
|
155
|
+
case "info":
|
|
156
|
+
logger.info(message);
|
|
157
|
+
break;
|
|
158
|
+
case "warning":
|
|
159
|
+
logger.warn(message);
|
|
160
|
+
break;
|
|
161
|
+
case "error":
|
|
162
|
+
logger.error(message);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/utils/errors.ts
|
|
168
|
+
var ErrorCode = {
|
|
169
|
+
// Retryable errors
|
|
170
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
171
|
+
TIMEOUT: "TIMEOUT",
|
|
172
|
+
NETWORK_ERROR: "NETWORK_ERROR",
|
|
173
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
|
174
|
+
// Non-retryable errors
|
|
175
|
+
AUTH_ERROR: "AUTH_ERROR",
|
|
176
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
177
|
+
NOT_FOUND: "NOT_FOUND",
|
|
178
|
+
QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
|
|
179
|
+
UNSUPPORTED_BINARY_CONTENT: "UNSUPPORTED_BINARY_CONTENT",
|
|
180
|
+
// Internal errors
|
|
181
|
+
INTERNAL_ERROR: "INTERNAL_ERROR",
|
|
182
|
+
PARSE_ERROR: "PARSE_ERROR",
|
|
183
|
+
UNKNOWN_ERROR: "UNKNOWN_ERROR"
|
|
184
|
+
};
|
|
185
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
186
|
+
maxRetries: 3,
|
|
187
|
+
baseDelayMs: 1e3,
|
|
188
|
+
maxDelayMs: 3e4,
|
|
189
|
+
retryableStatuses: [408, 429, 500, 502, 503, 504, 510]
|
|
190
|
+
};
|
|
191
|
+
function classifyDomException(error) {
|
|
192
|
+
if (error.name === "AbortError") {
|
|
193
|
+
return { code: ErrorCode.TIMEOUT, message: "Request timed out", retryable: true };
|
|
194
|
+
}
|
|
195
|
+
return { code: ErrorCode.UNKNOWN_ERROR, message: error.message, retryable: false };
|
|
196
|
+
}
|
|
197
|
+
function classifyByErrorCode(error) {
|
|
198
|
+
const errCode = error.code;
|
|
199
|
+
if (!errCode) return null;
|
|
200
|
+
const networkErrorMessages = {
|
|
201
|
+
ECONNREFUSED: "Connection refused \u2014 service may be down",
|
|
202
|
+
ECONNRESET: "Connection was reset \u2014 please retry",
|
|
203
|
+
ECONNABORTED: "Connection aborted \u2014 please retry",
|
|
204
|
+
ENOTFOUND: "Service not reachable \u2014 check your network",
|
|
205
|
+
EPIPE: "Connection lost \u2014 please retry",
|
|
206
|
+
EAI_AGAIN: "DNS lookup failed \u2014 check your network"
|
|
207
|
+
};
|
|
208
|
+
if (errCode === "ECONNREFUSED" || errCode === "ENOTFOUND" || errCode === "ECONNRESET") {
|
|
209
|
+
return { code: ErrorCode.NETWORK_ERROR, message: networkErrorMessages[errCode] || "Network connection failed", retryable: true, cause: error.message };
|
|
210
|
+
}
|
|
211
|
+
if (errCode === "ECONNABORTED" || errCode === "ETIMEDOUT") {
|
|
212
|
+
return { code: ErrorCode.TIMEOUT, message: networkErrorMessages[errCode] || "Request timed out", retryable: true, cause: error.message };
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
function classifyByStatusCode(error) {
|
|
217
|
+
const status = error.response?.status || error.status || error.statusCode;
|
|
218
|
+
if (!status) return null;
|
|
219
|
+
return classifyHttpError(status, error.message || String(error));
|
|
220
|
+
}
|
|
221
|
+
function classifyByMessage(message) {
|
|
222
|
+
const lower = message.toLowerCase();
|
|
223
|
+
if (lower.includes("timeout") || lower.includes("timed out") || lower.includes("aborterror")) {
|
|
224
|
+
return { code: ErrorCode.TIMEOUT, message: "Request timed out", retryable: true, cause: message };
|
|
225
|
+
}
|
|
226
|
+
if (lower.includes("rate limit") || lower.includes("too many requests")) {
|
|
227
|
+
return { code: ErrorCode.RATE_LIMITED, message: "Rate limit exceeded", retryable: true, cause: message };
|
|
228
|
+
}
|
|
229
|
+
if (message.includes("API_KEY") || message.includes("api_key") || message.includes("Invalid API")) {
|
|
230
|
+
return { code: ErrorCode.AUTH_ERROR, message: "API key missing or invalid", retryable: false, cause: message };
|
|
231
|
+
}
|
|
232
|
+
if (message.includes("JSON") || message.includes("parse") || message.includes("Unexpected token")) {
|
|
233
|
+
return { code: ErrorCode.PARSE_ERROR, message: "Failed to parse response", retryable: false, cause: message };
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
function classifyFallback(message, cause) {
|
|
238
|
+
return {
|
|
239
|
+
code: ErrorCode.UNKNOWN_ERROR,
|
|
240
|
+
message,
|
|
241
|
+
retryable: false,
|
|
242
|
+
cause: cause ? String(cause) : void 0
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function classifyError(error) {
|
|
246
|
+
if (error == null) {
|
|
247
|
+
return { code: ErrorCode.UNKNOWN_ERROR, message: "An unknown error occurred", retryable: false };
|
|
248
|
+
}
|
|
249
|
+
if (error instanceof DOMException) return classifyDomException(error);
|
|
250
|
+
if (!isErrorLike(error)) {
|
|
251
|
+
return { code: ErrorCode.UNKNOWN_ERROR, message: String(error), retryable: false };
|
|
252
|
+
}
|
|
253
|
+
return classifyByErrorCode(error) ?? classifyByStatusCode(error) ?? classifyByMessage(error.message ?? String(error)) ?? classifyFallback(error.message ?? String(error), error.cause);
|
|
254
|
+
}
|
|
255
|
+
function isErrorLike(value) {
|
|
256
|
+
return typeof value === "object" && value !== null;
|
|
257
|
+
}
|
|
258
|
+
function classifyHttpError(status, message) {
|
|
259
|
+
switch (status) {
|
|
260
|
+
case 400:
|
|
261
|
+
return { code: ErrorCode.INVALID_INPUT, message: "Bad request", retryable: false, statusCode: status };
|
|
262
|
+
case 401:
|
|
263
|
+
return { code: ErrorCode.AUTH_ERROR, message: "Invalid API key", retryable: false, statusCode: status };
|
|
264
|
+
case 403:
|
|
265
|
+
return { code: ErrorCode.QUOTA_EXCEEDED, message: "Access forbidden or quota exceeded", retryable: false, statusCode: status };
|
|
266
|
+
case 404:
|
|
267
|
+
return { code: ErrorCode.NOT_FOUND, message: "Resource not found", retryable: false, statusCode: status };
|
|
268
|
+
case 408:
|
|
269
|
+
return { code: ErrorCode.TIMEOUT, message: "Request timeout", retryable: true, statusCode: status };
|
|
270
|
+
case 429:
|
|
271
|
+
return { code: ErrorCode.RATE_LIMITED, message: "Rate limit exceeded", retryable: true, statusCode: status };
|
|
272
|
+
case 500:
|
|
273
|
+
return { code: ErrorCode.INTERNAL_ERROR, message: "Server error", retryable: true, statusCode: status };
|
|
274
|
+
case 502:
|
|
275
|
+
return { code: ErrorCode.SERVICE_UNAVAILABLE, message: "Bad gateway", retryable: true, statusCode: status };
|
|
276
|
+
case 503:
|
|
277
|
+
return { code: ErrorCode.SERVICE_UNAVAILABLE, message: "Service unavailable", retryable: true, statusCode: status };
|
|
278
|
+
case 504:
|
|
279
|
+
return { code: ErrorCode.TIMEOUT, message: "Gateway timeout", retryable: true, statusCode: status };
|
|
280
|
+
case 510:
|
|
281
|
+
return { code: ErrorCode.SERVICE_UNAVAILABLE, message: "Request canceled", retryable: true, statusCode: status };
|
|
282
|
+
default:
|
|
283
|
+
if (status >= 500) {
|
|
284
|
+
return { code: ErrorCode.SERVICE_UNAVAILABLE, message: `Server error: ${status}`, retryable: true, statusCode: status };
|
|
285
|
+
}
|
|
286
|
+
if (status >= 400) {
|
|
287
|
+
return { code: ErrorCode.INVALID_INPUT, message: `Client error: ${status}`, retryable: false, statusCode: status };
|
|
288
|
+
}
|
|
289
|
+
return { code: ErrorCode.UNKNOWN_ERROR, message: `HTTP ${status}: ${message}`, retryable: false, statusCode: status };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function calculateBackoff(attempt, options) {
|
|
293
|
+
const exponentialDelay = options.baseDelayMs * Math.pow(2, attempt);
|
|
294
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
295
|
+
return Math.min(exponentialDelay + jitter, options.maxDelayMs);
|
|
296
|
+
}
|
|
297
|
+
function sleep(ms, signal) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
if (signal?.aborted) {
|
|
300
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
function onAbort() {
|
|
304
|
+
clearTimeout(timeout);
|
|
305
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
306
|
+
}
|
|
307
|
+
const timeout = setTimeout(() => {
|
|
308
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
309
|
+
resolve();
|
|
310
|
+
}, ms);
|
|
311
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
312
|
+
if (signal?.aborted) {
|
|
313
|
+
onAbort();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function fetchWithTimeout(url, options = {}) {
|
|
318
|
+
const { timeoutMs = 3e4, signal: externalSignal, ...fetchOptions } = options;
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
321
|
+
let onExternalAbort;
|
|
322
|
+
if (externalSignal) {
|
|
323
|
+
onExternalAbort = () => controller.abort();
|
|
324
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
325
|
+
if (externalSignal.aborted) {
|
|
326
|
+
controller.abort();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return fetch(url, { ...fetchOptions, signal: controller.signal }).finally(() => {
|
|
330
|
+
clearTimeout(timeoutId);
|
|
331
|
+
if (externalSignal && onExternalAbort) {
|
|
332
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async function withStallProtection(fn, stallMs, maxAttempts = 2, label = "request") {
|
|
337
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
338
|
+
const controller = new AbortController();
|
|
339
|
+
let stallTimer;
|
|
340
|
+
const stallPromise = new Promise((_, reject) => {
|
|
341
|
+
stallTimer = setTimeout(() => {
|
|
342
|
+
controller.abort();
|
|
343
|
+
reject(Object.assign(new Error(`Service temporarily unavailable \u2014 no response received (attempt ${attempt + 1}/${maxAttempts})`), {
|
|
344
|
+
code: "ESTALLED",
|
|
345
|
+
retryable: attempt < maxAttempts - 1
|
|
346
|
+
}));
|
|
347
|
+
}, stallMs);
|
|
348
|
+
});
|
|
349
|
+
let fnPromise;
|
|
350
|
+
try {
|
|
351
|
+
fnPromise = fn(controller.signal);
|
|
352
|
+
const result = await Promise.race([fnPromise, stallPromise]);
|
|
353
|
+
clearTimeout(stallTimer);
|
|
354
|
+
return result;
|
|
355
|
+
} catch (err) {
|
|
356
|
+
fnPromise?.catch(() => {
|
|
357
|
+
});
|
|
358
|
+
clearTimeout(stallTimer);
|
|
359
|
+
const isStall = err instanceof Error && err.code === "ESTALLED";
|
|
360
|
+
if (isStall && attempt < maxAttempts - 1) {
|
|
361
|
+
const backoff = calculateBackoff(attempt, DEFAULT_RETRY_OPTIONS);
|
|
362
|
+
mcpLog("warning", `${label} stalled, retrying in ${backoff}ms (attempt ${attempt + 1})`, "stability");
|
|
363
|
+
await sleep(backoff);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
throw err;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`${label} failed after ${maxAttempts} stall-protection attempts`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/utils/retry.ts
|
|
373
|
+
var JITTER_FACTOR = 0.3;
|
|
374
|
+
var EXPONENTIAL_BASE = 2;
|
|
375
|
+
var DEFAULT_BASE_DELAY_MS = 1e3;
|
|
376
|
+
var DEFAULT_MAX_DELAY_MS = 3e4;
|
|
377
|
+
function calculateBackoff2(attempt, baseDelayMs = DEFAULT_BASE_DELAY_MS, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
378
|
+
const exponentialDelay = baseDelayMs * Math.pow(EXPONENTIAL_BASE, attempt);
|
|
379
|
+
const jitter = JITTER_FACTOR * exponentialDelay * Math.random();
|
|
380
|
+
return Math.min(exponentialDelay + jitter, maxDelayMs);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/utils/concurrency.ts
|
|
384
|
+
import pMapLib from "p-map";
|
|
385
|
+
async function pMap(items, mapper, concurrency = 6, signal) {
|
|
386
|
+
if (items.length === 0) return [];
|
|
387
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
388
|
+
return pMapLib(items, mapper, { concurrency: limit, signal });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/clients/search.ts
|
|
392
|
+
var SERPER_API_URL = "https://google.serper.dev/search";
|
|
393
|
+
var DEFAULT_RESULTS_PER_QUERY = 10;
|
|
394
|
+
var MAX_RETRIES = 3;
|
|
395
|
+
var SEARCH_RETRY_CONFIG = {
|
|
396
|
+
maxRetries: MAX_RETRIES,
|
|
397
|
+
baseDelayMs: 1e3,
|
|
398
|
+
maxDelayMs: 1e4,
|
|
399
|
+
timeoutMs: 3e4
|
|
400
|
+
};
|
|
401
|
+
var RETRYABLE_SEARCH_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
402
|
+
var REDDIT_SITE_REGEX = /site:\s*reddit\.com/i;
|
|
403
|
+
var REDDIT_SUBREDDIT_SUFFIX_REGEX = / : r\/\w+$/;
|
|
404
|
+
var REDDIT_SUFFIX_REGEX = / - Reddit$/;
|
|
405
|
+
function parseSearchResponses(responses, queries) {
|
|
406
|
+
return responses.map((resp, index) => {
|
|
407
|
+
try {
|
|
408
|
+
const organic = resp.organic || [];
|
|
409
|
+
const results = organic.map((item, idx) => ({
|
|
410
|
+
title: item.title || "No title",
|
|
411
|
+
link: item.link || "#",
|
|
412
|
+
snippet: item.snippet || "",
|
|
413
|
+
date: item.date,
|
|
414
|
+
position: item.position || idx + 1
|
|
415
|
+
}));
|
|
416
|
+
const searchInfo = resp.searchInformation;
|
|
417
|
+
const totalResults = searchInfo?.totalResults ? parseInt(String(searchInfo.totalResults).replace(/,/g, ""), 10) : results.length;
|
|
418
|
+
const relatedSearches = resp.relatedSearches || [];
|
|
419
|
+
const related = relatedSearches.map((r) => r.query || "");
|
|
420
|
+
return { query: queries[index] || "", results, totalResults, related };
|
|
421
|
+
} catch {
|
|
422
|
+
return { query: queries[index] || "", results: [], totalResults: 0, related: [] };
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
async function executeSearchWithRetry(apiKey, body, isRetryable) {
|
|
427
|
+
let lastError;
|
|
428
|
+
for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {
|
|
429
|
+
try {
|
|
430
|
+
if (attempt > 0) {
|
|
431
|
+
mcpLog("warning", `Retry attempt ${attempt}/${SEARCH_RETRY_CONFIG.maxRetries}`, "search");
|
|
432
|
+
}
|
|
433
|
+
const response = await fetchWithTimeout(SERPER_API_URL, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: {
|
|
436
|
+
"X-API-KEY": apiKey,
|
|
437
|
+
"Content-Type": "application/json"
|
|
438
|
+
},
|
|
439
|
+
body: JSON.stringify(body),
|
|
440
|
+
timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs
|
|
441
|
+
});
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const errorText = await response.text().catch(() => "");
|
|
444
|
+
lastError = classifyError({ status: response.status, message: errorText });
|
|
445
|
+
if (isRetryable(response.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
|
|
446
|
+
const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
|
|
447
|
+
mcpLog("warning", `API returned ${response.status}, retrying in ${delayMs}ms...`, "search");
|
|
448
|
+
await sleep(delayMs);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
return { data: void 0, error: lastError };
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const data = await response.json();
|
|
455
|
+
return { data };
|
|
456
|
+
} catch {
|
|
457
|
+
return {
|
|
458
|
+
data: void 0,
|
|
459
|
+
error: { code: ErrorCode.PARSE_ERROR, message: "Failed to parse search response", retryable: false }
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
} catch (error) {
|
|
463
|
+
lastError = classifyError(error);
|
|
464
|
+
if (isRetryable(void 0, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
|
|
465
|
+
const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
|
|
466
|
+
mcpLog("warning", `${lastError.code}: ${lastError.message}, retrying in ${delayMs}ms...`, "search");
|
|
467
|
+
await sleep(delayMs);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
return { data: void 0, error: lastError };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
data: void 0,
|
|
475
|
+
error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: "Search failed", retryable: false }
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
var SearchClient = class {
|
|
479
|
+
apiKey;
|
|
480
|
+
constructor(apiKey) {
|
|
481
|
+
const env = parseEnv();
|
|
482
|
+
this.apiKey = apiKey || env.SEARCH_API_KEY || "";
|
|
483
|
+
if (!this.apiKey) {
|
|
484
|
+
throw new Error("Web search capability is not configured. Please set up the required API credentials.");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Check if error is retryable
|
|
489
|
+
*/
|
|
490
|
+
isRetryable(status, error) {
|
|
491
|
+
if (status && RETRYABLE_SEARCH_CODES.has(status)) return true;
|
|
492
|
+
if (error == null) return false;
|
|
493
|
+
const message = typeof error === "object" && "message" in error && typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
494
|
+
return message.includes("timeout") || message.includes("rate limit") || message.includes("connection");
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Search multiple queries in parallel
|
|
498
|
+
* NEVER throws - always returns a valid response
|
|
499
|
+
*/
|
|
500
|
+
async searchMultiple(queries) {
|
|
501
|
+
const startTime = Date.now();
|
|
502
|
+
if (queries.length === 0) {
|
|
503
|
+
return {
|
|
504
|
+
searches: [],
|
|
505
|
+
totalQueries: 0,
|
|
506
|
+
executionTime: 0,
|
|
507
|
+
error: { code: ErrorCode.INVALID_INPUT, message: "No queries provided", retryable: false }
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const searchQueries = queries.map((query) => ({ q: query }));
|
|
511
|
+
const { data, error } = await executeSearchWithRetry(
|
|
512
|
+
this.apiKey,
|
|
513
|
+
searchQueries,
|
|
514
|
+
(status, err) => this.isRetryable(status, err)
|
|
515
|
+
);
|
|
516
|
+
if (error || data === void 0) {
|
|
517
|
+
return {
|
|
518
|
+
searches: [],
|
|
519
|
+
totalQueries: queries.length,
|
|
520
|
+
executionTime: Date.now() - startTime,
|
|
521
|
+
error: error ?? { code: ErrorCode.UNKNOWN_ERROR, message: "Search provider returned no data", retryable: false }
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const responses = Array.isArray(data) ? data : [data];
|
|
525
|
+
const searches = parseSearchResponses(responses, queries);
|
|
526
|
+
return { searches, totalQueries: queries.length, executionTime: Date.now() - startTime };
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Search Reddit via Google (adds site:reddit.com automatically)
|
|
530
|
+
* NEVER throws - returns empty array on failure
|
|
531
|
+
*/
|
|
532
|
+
async searchReddit(query, dateAfter) {
|
|
533
|
+
if (!query?.trim()) {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
let q = query.replace(REDDIT_SITE_REGEX, "").trim() + " site:reddit.com";
|
|
537
|
+
if (dateAfter) {
|
|
538
|
+
q += ` after:${dateAfter}`;
|
|
539
|
+
}
|
|
540
|
+
for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {
|
|
541
|
+
try {
|
|
542
|
+
const res = await fetchWithTimeout(SERPER_API_URL, {
|
|
543
|
+
method: "POST",
|
|
544
|
+
headers: { "X-API-KEY": this.apiKey, "Content-Type": "application/json" },
|
|
545
|
+
body: JSON.stringify({ q, num: DEFAULT_RESULTS_PER_QUERY }),
|
|
546
|
+
timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs
|
|
547
|
+
});
|
|
548
|
+
if (!res.ok) {
|
|
549
|
+
if (this.isRetryable(res.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
|
|
550
|
+
const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
|
|
551
|
+
mcpLog("warning", `Reddit search ${res.status}, retrying in ${delayMs}ms...`, "search");
|
|
552
|
+
await sleep(delayMs);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
mcpLog("error", `Reddit search failed with status ${res.status}`, "search");
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
const data = await res.json();
|
|
559
|
+
return (data.organic || []).map((r) => ({
|
|
560
|
+
title: (r.title || "").replace(REDDIT_SUBREDDIT_SUFFIX_REGEX, "").replace(REDDIT_SUFFIX_REGEX, ""),
|
|
561
|
+
url: r.link || "",
|
|
562
|
+
snippet: r.snippet || "",
|
|
563
|
+
date: r.date
|
|
564
|
+
}));
|
|
565
|
+
} catch (error) {
|
|
566
|
+
const err = classifyError(error);
|
|
567
|
+
if (this.isRetryable(void 0, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
|
|
568
|
+
const delayMs = calculateBackoff2(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
|
|
569
|
+
mcpLog("warning", `Reddit search ${err.code}, retrying in ${delayMs}ms...`, "search");
|
|
570
|
+
await sleep(delayMs);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
mcpLog("error", `Reddit search failed: ${err.message}`, "search");
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Search Reddit with multiple queries (bounded concurrency)
|
|
581
|
+
* NEVER throws - searchReddit never throws, pMap preserves order
|
|
582
|
+
*/
|
|
583
|
+
async searchRedditMultiple(queries, dateAfter) {
|
|
584
|
+
if (queries.length === 0) {
|
|
585
|
+
return /* @__PURE__ */ new Map();
|
|
586
|
+
}
|
|
587
|
+
const results = await pMap(
|
|
588
|
+
queries,
|
|
589
|
+
(q) => this.searchReddit(q, dateAfter),
|
|
590
|
+
CONCURRENCY.SEARCH
|
|
591
|
+
);
|
|
592
|
+
return new Map(queries.map((q, i) => [q, results[i] || []]));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// src/clients/jina.ts
|
|
597
|
+
var JINA_READER_BASE = "https://r.jina.ai/";
|
|
598
|
+
var JINA_SEARCH_BASE = "https://s.jina.ai/";
|
|
599
|
+
var DEFAULT_TIMEOUT_SECONDS = 15;
|
|
600
|
+
var DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_SECONDS * 1e3;
|
|
601
|
+
var MAX_RETRIES2 = 2;
|
|
602
|
+
var SEARCH_RESULTS_PER_QUERY = 10;
|
|
603
|
+
function buildJinaSearchUrl(query) {
|
|
604
|
+
const params = new URLSearchParams({ q: query });
|
|
605
|
+
return `${JINA_SEARCH_BASE}?${params.toString()}`;
|
|
606
|
+
}
|
|
607
|
+
var JinaClient = class {
|
|
608
|
+
apiKey;
|
|
609
|
+
constructor(apiKey) {
|
|
610
|
+
const fromEnv = process.env.JINA_API_KEY?.trim();
|
|
611
|
+
this.apiKey = apiKey?.trim() || fromEnv || void 0;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Convert a URL to markdown via Jina Reader.
|
|
615
|
+
* NEVER throws — always returns a JinaConvertResponse (possibly with error).
|
|
616
|
+
*/
|
|
617
|
+
async convert(request) {
|
|
618
|
+
const {
|
|
619
|
+
url,
|
|
620
|
+
timeoutSeconds = DEFAULT_TIMEOUT_SECONDS,
|
|
621
|
+
proxyUrl,
|
|
622
|
+
noCache = false,
|
|
623
|
+
allowProxyRetry = false
|
|
624
|
+
} = request;
|
|
625
|
+
try {
|
|
626
|
+
new URL(url);
|
|
627
|
+
} catch {
|
|
628
|
+
return {
|
|
629
|
+
content: `Invalid URL: ${url}`,
|
|
630
|
+
statusCode: 400,
|
|
631
|
+
credits: 0,
|
|
632
|
+
error: { code: ErrorCode.INVALID_INPUT, message: `Invalid URL: ${url}`, retryable: false }
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const first = await this.convertOnce({
|
|
636
|
+
url,
|
|
637
|
+
timeoutSeconds,
|
|
638
|
+
proxyUrl,
|
|
639
|
+
noCache
|
|
640
|
+
});
|
|
641
|
+
if (!first.error || !allowProxyRetry || proxyUrl || isTerminalReaderError(first.error)) {
|
|
642
|
+
return first;
|
|
643
|
+
}
|
|
644
|
+
mcpLog("warning", `Jina Reader failed for ${url}; retrying with Jina proxy`, "jina");
|
|
645
|
+
return this.convertOnce({
|
|
646
|
+
url,
|
|
647
|
+
timeoutSeconds,
|
|
648
|
+
proxyUrl: "auto",
|
|
649
|
+
noCache: true
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
async searchMultiple(queries) {
|
|
653
|
+
const startTime = Date.now();
|
|
654
|
+
if (queries.length === 0) {
|
|
655
|
+
return {
|
|
656
|
+
searches: [],
|
|
657
|
+
totalQueries: 0,
|
|
658
|
+
executionTime: 0,
|
|
659
|
+
error: { code: ErrorCode.INVALID_INPUT, message: "No queries provided", retryable: false }
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (!this.apiKey) {
|
|
663
|
+
return {
|
|
664
|
+
searches: [],
|
|
665
|
+
totalQueries: queries.length,
|
|
666
|
+
executionTime: Date.now() - startTime,
|
|
667
|
+
error: { code: ErrorCode.AUTH_ERROR, message: "Jina Search requires JINA_API_KEY", retryable: false }
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const searches = await Promise.all(queries.map((query) => this.searchOne(query)));
|
|
671
|
+
const firstError = searches.find((search) => search.error)?.error;
|
|
672
|
+
const allFailed = searches.every((search) => search.error);
|
|
673
|
+
return {
|
|
674
|
+
searches,
|
|
675
|
+
totalQueries: queries.length,
|
|
676
|
+
executionTime: Date.now() - startTime,
|
|
677
|
+
...allFailed && firstError ? { error: firstError } : {}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async convertOnce(request) {
|
|
681
|
+
const headers = {
|
|
682
|
+
Accept: "application/json",
|
|
683
|
+
"Content-Type": "application/json"
|
|
684
|
+
};
|
|
685
|
+
if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
686
|
+
if (request.proxyUrl && request.proxyUrl !== "auto") {
|
|
687
|
+
headers["X-Proxy-Url"] = request.proxyUrl;
|
|
688
|
+
}
|
|
689
|
+
const body = {
|
|
690
|
+
url: request.url,
|
|
691
|
+
respondWith: "markdown",
|
|
692
|
+
timeout: request.timeoutSeconds,
|
|
693
|
+
base: "final",
|
|
694
|
+
removeOverlay: true
|
|
695
|
+
};
|
|
696
|
+
if (request.proxyUrl === "auto") body["proxy"] = "auto";
|
|
697
|
+
if (request.noCache) body["noCache"] = true;
|
|
698
|
+
return this.fetchReader(body, headers, request.timeoutSeconds);
|
|
699
|
+
}
|
|
700
|
+
async fetchReader(body, headers, timeoutSeconds) {
|
|
701
|
+
let lastError;
|
|
702
|
+
for (let attempt = 0; attempt <= MAX_RETRIES2; attempt++) {
|
|
703
|
+
try {
|
|
704
|
+
const response = await fetchWithTimeout(JINA_READER_BASE, {
|
|
705
|
+
method: "POST",
|
|
706
|
+
headers,
|
|
707
|
+
body: JSON.stringify(body),
|
|
708
|
+
timeoutMs: (timeoutSeconds + 5) * 1e3
|
|
709
|
+
});
|
|
710
|
+
const raw = await response.text().catch(
|
|
711
|
+
(readError) => `Failed to read Jina response: ${readError instanceof Error ? readError.message : String(readError)}`
|
|
712
|
+
);
|
|
713
|
+
const usageHeader = response.headers.get("x-usage-tokens");
|
|
714
|
+
const usageTokens = usageHeader ? Number(usageHeader) : void 0;
|
|
715
|
+
const parsed = parseReaderContent(raw);
|
|
716
|
+
if (response.ok) {
|
|
717
|
+
if (!parsed.content.trim()) {
|
|
718
|
+
return emptyReaderResponse(response.status, usageTokens);
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
content: parsed.content,
|
|
722
|
+
statusCode: response.status,
|
|
723
|
+
credits: 0,
|
|
724
|
+
usageTokens: Number.isFinite(usageTokens) ? usageTokens : void 0
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const terminal = terminalReaderResponse(response.status, parsed.content || raw);
|
|
728
|
+
if (terminal) return terminal;
|
|
729
|
+
lastError = classifyError({ status: response.status, message: raw.slice(0, 200) });
|
|
730
|
+
if (lastError.retryable && attempt < MAX_RETRIES2) {
|
|
731
|
+
const delayMs = calculateBackoff2(attempt);
|
|
732
|
+
mcpLog(
|
|
733
|
+
"warning",
|
|
734
|
+
`Jina ${response.status} on attempt ${attempt + 1}/${MAX_RETRIES2 + 1}. Retrying in ${delayMs}ms`,
|
|
735
|
+
"jina"
|
|
736
|
+
);
|
|
737
|
+
await sleep(delayMs);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
content: `Jina Reader error (${response.status}): ${raw.slice(0, 200)}`,
|
|
742
|
+
statusCode: response.status,
|
|
743
|
+
credits: 0,
|
|
744
|
+
error: lastError
|
|
745
|
+
};
|
|
746
|
+
} catch (error) {
|
|
747
|
+
lastError = classifyError(error);
|
|
748
|
+
if (lastError.retryable && attempt < MAX_RETRIES2) {
|
|
749
|
+
const delayMs = calculateBackoff2(attempt);
|
|
750
|
+
mcpLog(
|
|
751
|
+
"warning",
|
|
752
|
+
`Jina ${lastError.code}: ${lastError.message}. Retry ${attempt + 1}/${MAX_RETRIES2 + 1} in ${delayMs}ms`,
|
|
753
|
+
"jina"
|
|
754
|
+
);
|
|
755
|
+
await sleep(delayMs);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
content: `Jina Reader failed: ${lastError.message}`,
|
|
760
|
+
statusCode: lastError.statusCode ?? 500,
|
|
761
|
+
credits: 0,
|
|
762
|
+
error: lastError
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
content: `Jina Reader failed after ${MAX_RETRIES2 + 1} attempts: ${lastError?.message ?? "Unknown error"}`,
|
|
768
|
+
statusCode: lastError?.statusCode ?? 500,
|
|
769
|
+
credits: 0,
|
|
770
|
+
error: lastError ?? { code: ErrorCode.UNKNOWN_ERROR, message: "All retries exhausted", retryable: false }
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
async searchOne(query) {
|
|
774
|
+
const headers = {
|
|
775
|
+
Accept: "application/json",
|
|
776
|
+
Authorization: `Bearer ${this.apiKey ?? ""}`
|
|
777
|
+
};
|
|
778
|
+
try {
|
|
779
|
+
const response = await fetchWithTimeout(buildJinaSearchUrl(query), {
|
|
780
|
+
method: "GET",
|
|
781
|
+
headers,
|
|
782
|
+
timeoutMs: DEFAULT_TIMEOUT_MS
|
|
783
|
+
});
|
|
784
|
+
const raw = await response.text().catch(
|
|
785
|
+
(readError) => `Failed to read Jina Search response: ${readError instanceof Error ? readError.message : String(readError)}`
|
|
786
|
+
);
|
|
787
|
+
if (!response.ok) {
|
|
788
|
+
return {
|
|
789
|
+
query,
|
|
790
|
+
results: [],
|
|
791
|
+
totalResults: 0,
|
|
792
|
+
related: [],
|
|
793
|
+
error: classifyError({ status: response.status, message: raw.slice(0, 200) })
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const results = parseSearchResults(raw);
|
|
797
|
+
return { query, results, totalResults: results.length, related: [] };
|
|
798
|
+
} catch (error) {
|
|
799
|
+
return {
|
|
800
|
+
query,
|
|
801
|
+
results: [],
|
|
802
|
+
totalResults: 0,
|
|
803
|
+
related: [],
|
|
804
|
+
error: classifyError(error)
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
function parseReaderContent(raw) {
|
|
810
|
+
try {
|
|
811
|
+
const parsed = JSON.parse(raw);
|
|
812
|
+
const data = readRecord(parsed, "data");
|
|
813
|
+
const content = readString(data, "content");
|
|
814
|
+
if (content) return { content };
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
return { content: raw };
|
|
818
|
+
}
|
|
819
|
+
function emptyReaderResponse(statusCode, usageTokens) {
|
|
820
|
+
return {
|
|
821
|
+
content: "Jina returned an empty body",
|
|
822
|
+
statusCode,
|
|
823
|
+
credits: 0,
|
|
824
|
+
usageTokens: Number.isFinite(usageTokens) ? usageTokens : void 0,
|
|
825
|
+
error: {
|
|
826
|
+
code: ErrorCode.UNSUPPORTED_BINARY_CONTENT,
|
|
827
|
+
message: "Jina Reader returned empty content for this URL",
|
|
828
|
+
retryable: false
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function terminalReaderResponse(statusCode, content) {
|
|
833
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
834
|
+
return {
|
|
835
|
+
content: `Jina auth/quota error (${statusCode}): ${content.slice(0, 200)}`,
|
|
836
|
+
statusCode,
|
|
837
|
+
credits: 0,
|
|
838
|
+
error: {
|
|
839
|
+
code: statusCode === 401 ? ErrorCode.AUTH_ERROR : ErrorCode.QUOTA_EXCEEDED,
|
|
840
|
+
message: statusCode === 401 ? "Jina Reader auth failed \u2014 check JINA_API_KEY" : "Jina Reader quota exceeded",
|
|
841
|
+
retryable: false,
|
|
842
|
+
statusCode
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (statusCode === 404) {
|
|
847
|
+
return {
|
|
848
|
+
content: "Jina could not fetch the target URL (404)",
|
|
849
|
+
statusCode: 404,
|
|
850
|
+
credits: 0,
|
|
851
|
+
error: {
|
|
852
|
+
code: ErrorCode.NOT_FOUND,
|
|
853
|
+
message: "Target URL not reachable by Jina Reader",
|
|
854
|
+
retryable: false,
|
|
855
|
+
statusCode: 404
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
|
|
860
|
+
return {
|
|
861
|
+
content: `Jina Reader error (${statusCode}): ${content.slice(0, 200)}`,
|
|
862
|
+
statusCode,
|
|
863
|
+
credits: 0,
|
|
864
|
+
error: {
|
|
865
|
+
code: ErrorCode.INVALID_INPUT,
|
|
866
|
+
message: `Jina Reader returned ${statusCode}`,
|
|
867
|
+
retryable: false,
|
|
868
|
+
statusCode
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
function isTerminalReaderError(error) {
|
|
875
|
+
return !error.retryable && (error.code === ErrorCode.AUTH_ERROR || error.code === ErrorCode.QUOTA_EXCEEDED || error.code === ErrorCode.NOT_FOUND || error.code === ErrorCode.INVALID_INPUT);
|
|
876
|
+
}
|
|
877
|
+
function parseSearchResults(raw) {
|
|
878
|
+
let data;
|
|
879
|
+
try {
|
|
880
|
+
const parsed = JSON.parse(raw);
|
|
881
|
+
data = readUnknown(parsed, "data");
|
|
882
|
+
} catch {
|
|
883
|
+
data = parseMarkdownSearchResults(raw);
|
|
884
|
+
}
|
|
885
|
+
const items = Array.isArray(data) ? data : [];
|
|
886
|
+
return items.map((item, index) => normalizeSearchItem(item, index)).filter((item) => item !== null).slice(0, SEARCH_RESULTS_PER_QUERY);
|
|
887
|
+
}
|
|
888
|
+
function normalizeSearchItem(item, index) {
|
|
889
|
+
const link = readString(item, "url") ?? readString(item, "link");
|
|
890
|
+
if (!link) return null;
|
|
891
|
+
return {
|
|
892
|
+
title: readString(item, "title") || link,
|
|
893
|
+
link,
|
|
894
|
+
snippet: (readString(item, "snippet") || readString(item, "description") || readString(item, "content") || "").slice(0, 500),
|
|
895
|
+
date: readString(item, "date") ?? readString(item, "publishedTime"),
|
|
896
|
+
position: index + 1
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
function parseMarkdownSearchResults(raw) {
|
|
900
|
+
const items = [];
|
|
901
|
+
const markdownLink = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
|
|
902
|
+
let match;
|
|
903
|
+
while ((match = markdownLink.exec(raw)) !== null && items.length < SEARCH_RESULTS_PER_QUERY) {
|
|
904
|
+
const title = match[1];
|
|
905
|
+
const url = match[2];
|
|
906
|
+
if (title && url) items.push({ title, url });
|
|
907
|
+
}
|
|
908
|
+
return items;
|
|
909
|
+
}
|
|
910
|
+
function isRecord(value) {
|
|
911
|
+
return typeof value === "object" && value !== null;
|
|
912
|
+
}
|
|
913
|
+
function readUnknown(value, key) {
|
|
914
|
+
return isRecord(value) ? value[key] : void 0;
|
|
915
|
+
}
|
|
916
|
+
function readRecord(value, key) {
|
|
917
|
+
const child = readUnknown(value, key);
|
|
918
|
+
return isRecord(child) ? child : void 0;
|
|
919
|
+
}
|
|
920
|
+
function readString(value, key) {
|
|
921
|
+
const child = readUnknown(value, key);
|
|
922
|
+
return typeof child === "string" ? child : void 0;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// src/clients/kernel.ts
|
|
926
|
+
import Kernel from "@onkernel/sdk";
|
|
927
|
+
var DEFAULT_RENDER_TIMEOUT_SECONDS = 15;
|
|
928
|
+
var BROWSER_IDLE_TIMEOUT_SECONDS = 300;
|
|
929
|
+
var KernelClient = class {
|
|
930
|
+
kernel;
|
|
931
|
+
constructor(apiKey) {
|
|
932
|
+
const env = parseEnv();
|
|
933
|
+
const resolvedKey = apiKey?.trim() || env.KERNEL_API_KEY;
|
|
934
|
+
if (!resolvedKey) {
|
|
935
|
+
throw new Error("Kernel browser rendering is not configured. Set KERNEL_API_KEY.");
|
|
936
|
+
}
|
|
937
|
+
this.kernel = new Kernel({
|
|
938
|
+
apiKey: resolvedKey,
|
|
939
|
+
timeout: 3e4,
|
|
940
|
+
maxRetries: 1,
|
|
941
|
+
...env.KERNEL_PROJECT ? { defaultHeaders: { "X-Kernel-Project-Id": env.KERNEL_PROJECT } } : {}
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
async render(request) {
|
|
945
|
+
const { url, timeoutSeconds = DEFAULT_RENDER_TIMEOUT_SECONDS } = request;
|
|
946
|
+
try {
|
|
947
|
+
new URL(url);
|
|
948
|
+
} catch {
|
|
949
|
+
return {
|
|
950
|
+
content: `Invalid URL: ${url}`,
|
|
951
|
+
statusCode: 400,
|
|
952
|
+
credits: 0,
|
|
953
|
+
error: { code: ErrorCode.INVALID_INPUT, message: `Invalid URL: ${url}`, retryable: false }
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
let sessionId;
|
|
957
|
+
try {
|
|
958
|
+
const session = await this.kernel.browsers.create({
|
|
959
|
+
headless: true,
|
|
960
|
+
stealth: true,
|
|
961
|
+
timeout_seconds: BROWSER_IDLE_TIMEOUT_SECONDS,
|
|
962
|
+
viewport: { width: 1280, height: 800 }
|
|
963
|
+
});
|
|
964
|
+
sessionId = session.session_id;
|
|
965
|
+
const response = await this.kernel.browsers.playwright.execute(session.session_id, {
|
|
966
|
+
code: buildRenderScript(url, timeoutSeconds),
|
|
967
|
+
timeout_sec: Math.min(timeoutSeconds + 5, 300)
|
|
968
|
+
});
|
|
969
|
+
if (!response.success) {
|
|
970
|
+
const message = response.error || response.stderr || "Kernel Playwright execution failed";
|
|
971
|
+
return {
|
|
972
|
+
content: `Kernel render failed: ${message}`,
|
|
973
|
+
statusCode: 500,
|
|
974
|
+
credits: 0,
|
|
975
|
+
error: { code: ErrorCode.SERVICE_UNAVAILABLE, message, retryable: true }
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
const rendered = parseRenderedPage(response.result);
|
|
979
|
+
if (!rendered) {
|
|
980
|
+
return {
|
|
981
|
+
content: "Kernel render returned an invalid payload",
|
|
982
|
+
statusCode: 500,
|
|
983
|
+
credits: 0,
|
|
984
|
+
error: {
|
|
985
|
+
code: ErrorCode.PARSE_ERROR,
|
|
986
|
+
message: "Kernel render returned an invalid payload",
|
|
987
|
+
retryable: false
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
content: rendered.html,
|
|
993
|
+
statusCode: 200,
|
|
994
|
+
credits: 0,
|
|
995
|
+
finalUrl: rendered.finalUrl,
|
|
996
|
+
title: rendered.title,
|
|
997
|
+
text: rendered.text
|
|
998
|
+
};
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
const err = formatKernelError(error);
|
|
1001
|
+
return {
|
|
1002
|
+
content: `Kernel render failed: ${err.message}`,
|
|
1003
|
+
statusCode: err.statusCode ?? 500,
|
|
1004
|
+
credits: 0,
|
|
1005
|
+
error: err
|
|
1006
|
+
};
|
|
1007
|
+
} finally {
|
|
1008
|
+
if (sessionId) {
|
|
1009
|
+
try {
|
|
1010
|
+
await this.kernel.browsers.deleteByID(sessionId);
|
|
1011
|
+
} catch (deleteError) {
|
|
1012
|
+
const err = formatKernelError(deleteError);
|
|
1013
|
+
mcpLog("warning", `Kernel browser cleanup failed for ${sessionId}: ${err.message}`, "kernel");
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
function buildRenderScript(url, timeoutSeconds) {
|
|
1020
|
+
const timeoutMs = timeoutSeconds * 1e3;
|
|
1021
|
+
return `
|
|
1022
|
+
const targetUrl = ${JSON.stringify(url)};
|
|
1023
|
+
const timeoutMs = ${timeoutMs};
|
|
1024
|
+
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
1025
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(5000, timeoutMs) }).catch(() => {});
|
|
1026
|
+
const html = await page.content();
|
|
1027
|
+
const text = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '');
|
|
1028
|
+
return {
|
|
1029
|
+
html,
|
|
1030
|
+
text,
|
|
1031
|
+
title: await page.title(),
|
|
1032
|
+
finalUrl: page.url(),
|
|
1033
|
+
};
|
|
1034
|
+
`;
|
|
1035
|
+
}
|
|
1036
|
+
function parseRenderedPage(value) {
|
|
1037
|
+
if (!isRecord2(value)) return null;
|
|
1038
|
+
const html = readString2(value, "html");
|
|
1039
|
+
const finalUrl = readString2(value, "finalUrl");
|
|
1040
|
+
const title = readString2(value, "title");
|
|
1041
|
+
const text = readString2(value, "text");
|
|
1042
|
+
if (!html || !finalUrl || title === void 0 || text === void 0) return null;
|
|
1043
|
+
return { html, finalUrl, title, text };
|
|
1044
|
+
}
|
|
1045
|
+
function formatKernelError(error) {
|
|
1046
|
+
if (error instanceof Kernel.APIError) {
|
|
1047
|
+
return {
|
|
1048
|
+
...classifyError({ status: error.status, message: error.message }),
|
|
1049
|
+
cause: error.message
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
return classifyError(error);
|
|
1053
|
+
}
|
|
1054
|
+
function isRecord2(value) {
|
|
1055
|
+
return typeof value === "object" && value !== null;
|
|
1056
|
+
}
|
|
1057
|
+
function readString2(value, key) {
|
|
1058
|
+
const child = value[key];
|
|
1059
|
+
return typeof child === "string" ? child : void 0;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/clients/reddit.ts
|
|
1063
|
+
import { Logger as Logger3 } from "mcp-use";
|
|
1064
|
+
var DEBUG_TOKEN_CACHE = process.env.DEBUG_REDDIT === "true";
|
|
1065
|
+
var clientLogger = Logger3.get("reddit-client");
|
|
1066
|
+
|
|
1067
|
+
// src/services/llm-processor.ts
|
|
1068
|
+
import OpenAI from "openai";
|
|
1069
|
+
|
|
1070
|
+
// src/schemas/web-search.ts
|
|
1071
|
+
import { z } from "zod";
|
|
1072
|
+
var QUERY_REWRITE_PAIR_EXAMPLES = [
|
|
1073
|
+
'Bad: `<feature> support` \u2192 Better: `site:<official-docs-domain> "<feature>" "<platform-or-version>"`',
|
|
1074
|
+
'Bad: `<product> pricing` \u2192 Better: `site:<vendor-domain> "<product>" pricing "enterprise" OR "free tier"`',
|
|
1075
|
+
'Bad: `<library> bug fix` \u2192 Better: `"<exact error text>" "<library-or-package>" "<version>" site:github.com`',
|
|
1076
|
+
'Bad: `<tool> reviews` \u2192 Better: `site:reddit.com/r/<community>/comments "<tool>" "migration" OR "regression"`'
|
|
1077
|
+
];
|
|
1078
|
+
var QUERY_REWRITE_PAIR_GUIDANCE = [
|
|
1079
|
+
"Write Google retrieval probes, not topic labels.",
|
|
1080
|
+
"For each broad idea, rewrite it into a query that names the evidence source class, discriminating anchor terms, and one useful operator when possible.",
|
|
1081
|
+
"Use rewrite-pair thinking before searching:",
|
|
1082
|
+
...QUERY_REWRITE_PAIR_EXAMPLES,
|
|
1083
|
+
"Do not repeat the same noun phrase with adjectives changed; fan out by source type and evidence need."
|
|
1084
|
+
];
|
|
1085
|
+
var QUERY_REWRITE_PAIR_GUIDANCE_TEXT = QUERY_REWRITE_PAIR_GUIDANCE.join(" ");
|
|
1086
|
+
var keywordSchema = z.string().min(1, { message: "search: Keyword cannot be empty" }).describe(
|
|
1087
|
+
`A single search keyword/query. Each item runs as a separate parallel search. ${QUERY_REWRITE_PAIR_GUIDANCE_TEXT}`
|
|
1088
|
+
);
|
|
1089
|
+
var keywordsSchema = z.array(keywordSchema).min(1, { message: "search: At least 1 keyword required" }).max(50, { message: "search: At most 50 keywords allowed per call" }).describe(
|
|
1090
|
+
`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.`
|
|
1091
|
+
);
|
|
1092
|
+
var rawWebSearchParamsSchema = z.object({
|
|
1093
|
+
keywords: keywordsSchema,
|
|
1094
|
+
extract: z.never().optional(),
|
|
1095
|
+
scope: z.never().optional(),
|
|
1096
|
+
verbose: z.never().optional()
|
|
1097
|
+
}).strict();
|
|
1098
|
+
var smartWebSearchParamsSchema = z.object({
|
|
1099
|
+
keywords: keywordsSchema,
|
|
1100
|
+
extract: z.string().min(1, { message: "smart-web-search: extract cannot be empty" }).describe(
|
|
1101
|
+
'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".'
|
|
1102
|
+
),
|
|
1103
|
+
scope: z.enum(["web", "reddit", "both"]).default("web").describe(
|
|
1104
|
+
'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.'
|
|
1105
|
+
),
|
|
1106
|
+
verbose: z.boolean().default(false).describe(
|
|
1107
|
+
"Include per-row scoring/coverage metadata, the trailing Signals block, and CONSENSUS labels even when they carry little signal. Default false."
|
|
1108
|
+
)
|
|
1109
|
+
}).strict();
|
|
1110
|
+
|
|
1111
|
+
// src/services/llm-processor.ts
|
|
1112
|
+
var MAX_LLM_INPUT_CHARS = 5e5;
|
|
1113
|
+
var MAX_PRIMARY_MODEL_INPUT_CHARS = 1e5;
|
|
1114
|
+
var LLM_CLIENT_TIMEOUT_MS = 6e5;
|
|
1115
|
+
var BACKOFF_JITTER_FACTOR = 0.3;
|
|
1116
|
+
var LLM_STALL_TIMEOUT_MS = 75e3;
|
|
1117
|
+
var LLM_REQUEST_DEADLINE_MS = 15e4;
|
|
1118
|
+
var llmHealth = {
|
|
1119
|
+
lastPlannerOk: false,
|
|
1120
|
+
lastExtractorOk: false,
|
|
1121
|
+
lastPlannerCheckedAt: null,
|
|
1122
|
+
lastExtractorCheckedAt: null,
|
|
1123
|
+
lastPlannerError: null,
|
|
1124
|
+
lastExtractorError: null,
|
|
1125
|
+
consecutivePlannerFailures: 0,
|
|
1126
|
+
consecutiveExtractorFailures: 0
|
|
1127
|
+
};
|
|
1128
|
+
function markLLMSuccess(kind) {
|
|
1129
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1130
|
+
if (kind === "planner") {
|
|
1131
|
+
llmHealth.lastPlannerOk = true;
|
|
1132
|
+
llmHealth.lastPlannerCheckedAt = ts;
|
|
1133
|
+
llmHealth.lastPlannerError = null;
|
|
1134
|
+
llmHealth.consecutivePlannerFailures = 0;
|
|
1135
|
+
} else {
|
|
1136
|
+
llmHealth.lastExtractorOk = true;
|
|
1137
|
+
llmHealth.lastExtractorCheckedAt = ts;
|
|
1138
|
+
llmHealth.lastExtractorError = null;
|
|
1139
|
+
llmHealth.consecutiveExtractorFailures = 0;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function markLLMFailure(kind, err) {
|
|
1143
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1144
|
+
const message = err instanceof Error ? err.message : String(err ?? "unknown error");
|
|
1145
|
+
if (kind === "planner") {
|
|
1146
|
+
llmHealth.lastPlannerOk = false;
|
|
1147
|
+
llmHealth.lastPlannerCheckedAt = ts;
|
|
1148
|
+
llmHealth.lastPlannerError = message;
|
|
1149
|
+
llmHealth.consecutivePlannerFailures += 1;
|
|
1150
|
+
} else {
|
|
1151
|
+
llmHealth.lastExtractorOk = false;
|
|
1152
|
+
llmHealth.lastExtractorCheckedAt = ts;
|
|
1153
|
+
llmHealth.lastExtractorError = message;
|
|
1154
|
+
llmHealth.consecutiveExtractorFailures += 1;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
var LLM_RETRY_CONFIG = {
|
|
1158
|
+
maxRetries: 2,
|
|
1159
|
+
baseDelayMs: 1e3,
|
|
1160
|
+
maxDelayMs: 5e3
|
|
1161
|
+
};
|
|
1162
|
+
var FALLBACK_RETRY_COUNT = 3;
|
|
1163
|
+
var RETRYABLE_LLM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
1164
|
+
"rate_limit_exceeded",
|
|
1165
|
+
"server_error",
|
|
1166
|
+
"timeout",
|
|
1167
|
+
"service_unavailable"
|
|
1168
|
+
]);
|
|
1169
|
+
function hasStatus(error) {
|
|
1170
|
+
return typeof error === "object" && error !== null && "status" in error && typeof error.status === "number";
|
|
1171
|
+
}
|
|
1172
|
+
var llmClient = null;
|
|
1173
|
+
function createLLMProcessor() {
|
|
1174
|
+
if (!getCapabilities().llmExtraction) return null;
|
|
1175
|
+
if (!llmClient) {
|
|
1176
|
+
llmClient = new OpenAI({
|
|
1177
|
+
baseURL: LLM_EXTRACTION.BASE_URL,
|
|
1178
|
+
apiKey: LLM_EXTRACTION.API_KEY,
|
|
1179
|
+
timeout: LLM_CLIENT_TIMEOUT_MS,
|
|
1180
|
+
maxRetries: 0,
|
|
1181
|
+
defaultHeaders: { "X-Title": "mcp-research-powerpack" }
|
|
1182
|
+
});
|
|
1183
|
+
mcpLog("info", `LLM extraction configured (model: ${LLM_EXTRACTION.MODEL}, baseURL: ${LLM_EXTRACTION.BASE_URL})`, "llm");
|
|
1184
|
+
}
|
|
1185
|
+
return llmClient;
|
|
1186
|
+
}
|
|
1187
|
+
function buildChatRequestBody(model, prompt) {
|
|
1188
|
+
return {
|
|
1189
|
+
model,
|
|
1190
|
+
messages: [{ role: "user", content: prompt }],
|
|
1191
|
+
reasoning_effort: "low"
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
function normalizeProviderError(err, message) {
|
|
1195
|
+
if (typeof err === "object" && err !== null) return err;
|
|
1196
|
+
return new Error(message);
|
|
1197
|
+
}
|
|
1198
|
+
function getProviderFailure(response) {
|
|
1199
|
+
if (response.content !== null || response.failureKind !== "provider") return null;
|
|
1200
|
+
return response.errorCause;
|
|
1201
|
+
}
|
|
1202
|
+
function emptyLLMExtractionResult(content) {
|
|
1203
|
+
return {
|
|
1204
|
+
content,
|
|
1205
|
+
processed: false,
|
|
1206
|
+
error: "LLM returned empty response",
|
|
1207
|
+
errorDetails: {
|
|
1208
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
1209
|
+
message: "LLM returned empty response",
|
|
1210
|
+
retryable: false
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
async function requestText(processor, prompt, operationLabel, signal, modelOverride) {
|
|
1215
|
+
const model = modelOverride || LLM_EXTRACTION.MODEL;
|
|
1216
|
+
try {
|
|
1217
|
+
const response = await withStallProtection(
|
|
1218
|
+
(stallSignal) => processor.chat.completions.create(
|
|
1219
|
+
buildChatRequestBody(model, prompt),
|
|
1220
|
+
{
|
|
1221
|
+
signal: signal ? AbortSignal.any([stallSignal, signal]) : stallSignal,
|
|
1222
|
+
timeout: LLM_REQUEST_DEADLINE_MS
|
|
1223
|
+
}
|
|
1224
|
+
),
|
|
1225
|
+
LLM_STALL_TIMEOUT_MS,
|
|
1226
|
+
3,
|
|
1227
|
+
`${operationLabel} (${model})`
|
|
1228
|
+
);
|
|
1229
|
+
const content = response.choices?.[0]?.message?.content?.trim();
|
|
1230
|
+
if (content) {
|
|
1231
|
+
return { content, model };
|
|
1232
|
+
}
|
|
1233
|
+
const err = `Empty response from model ${model}`;
|
|
1234
|
+
mcpLog("warning", `${operationLabel} returned empty content for model ${model}`, "llm");
|
|
1235
|
+
return { content: null, model, error: err, failureKind: "empty" };
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1238
|
+
mcpLog("warning", `${operationLabel} failed for model ${model}: ${message}`, "llm");
|
|
1239
|
+
return {
|
|
1240
|
+
content: null,
|
|
1241
|
+
model,
|
|
1242
|
+
error: message,
|
|
1243
|
+
failureKind: "provider",
|
|
1244
|
+
errorCause: normalizeProviderError(err, message)
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function requestTextWithFallback(processor, prompt, operationLabel, signal) {
|
|
1249
|
+
const primary = await requestText(processor, prompt, operationLabel, signal);
|
|
1250
|
+
if (primary.content !== null) return primary;
|
|
1251
|
+
const fallbackModel = LLM_EXTRACTION.FALLBACK_MODEL;
|
|
1252
|
+
if (!fallbackModel) return primary;
|
|
1253
|
+
mcpLog("warning", `Primary model failed, switching to fallback ${fallbackModel}`, "llm");
|
|
1254
|
+
let lastFailure = primary;
|
|
1255
|
+
for (let attempt = 0; attempt < FALLBACK_RETRY_COUNT; attempt++) {
|
|
1256
|
+
if (attempt > 0) {
|
|
1257
|
+
const delayMs = calculateLLMBackoff(attempt - 1);
|
|
1258
|
+
mcpLog("warning", `Fallback retry ${attempt}/${FALLBACK_RETRY_COUNT - 1} in ${delayMs}ms`, "llm");
|
|
1259
|
+
try {
|
|
1260
|
+
await sleep(delayMs, signal);
|
|
1261
|
+
} catch {
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
const result = await requestText(processor, prompt, `${operationLabel} [fallback]`, signal, fallbackModel);
|
|
1266
|
+
if (result.content !== null) return result;
|
|
1267
|
+
lastFailure = result;
|
|
1268
|
+
}
|
|
1269
|
+
return lastFailure;
|
|
1270
|
+
}
|
|
1271
|
+
function isRetryableLLMError(error) {
|
|
1272
|
+
if (!error || typeof error !== "object") return false;
|
|
1273
|
+
const stallCode = error?.code;
|
|
1274
|
+
if (stallCode === "ESTALLED" || stallCode === "ETIMEDOUT") {
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
if (hasStatus(error)) {
|
|
1278
|
+
if (error.status === 429 || error.status === 500 || error.status === 502 || error.status === 503 || error.status === 504) {
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const record = error;
|
|
1283
|
+
const code = typeof record.code === "string" ? record.code : void 0;
|
|
1284
|
+
const nested = typeof record.error === "object" && record.error !== null ? record.error : null;
|
|
1285
|
+
const errorCode = code ?? (nested && typeof nested.code === "string" ? nested.code : void 0) ?? (nested && typeof nested.type === "string" ? nested.type : void 0);
|
|
1286
|
+
if (errorCode && RETRYABLE_LLM_ERROR_CODES.has(errorCode)) {
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
const message = typeof record.message === "string" ? record.message.toLowerCase() : "";
|
|
1290
|
+
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")) {
|
|
1291
|
+
return true;
|
|
1292
|
+
}
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
function isContextWindowError(error) {
|
|
1296
|
+
if (!error || typeof error !== "object") return false;
|
|
1297
|
+
const record = error;
|
|
1298
|
+
const nested = typeof record.error === "object" && record.error !== null ? record.error : null;
|
|
1299
|
+
const code = typeof record.code === "string" ? record.code : void 0;
|
|
1300
|
+
const nestedCode = nested && typeof nested.code === "string" ? nested.code : void 0;
|
|
1301
|
+
if (code === "context_length_exceeded" || nestedCode === "context_length_exceeded") {
|
|
1302
|
+
return true;
|
|
1303
|
+
}
|
|
1304
|
+
const messages = [];
|
|
1305
|
+
if (typeof record.message === "string") messages.push(record.message);
|
|
1306
|
+
if (nested && typeof nested.message === "string") messages.push(nested.message);
|
|
1307
|
+
const combined = messages.join(" ").toLowerCase();
|
|
1308
|
+
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");
|
|
1309
|
+
}
|
|
1310
|
+
function calculateLLMBackoff(attempt) {
|
|
1311
|
+
const exponentialDelay = LLM_RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt);
|
|
1312
|
+
const jitter = Math.random() * BACKOFF_JITTER_FACTOR * exponentialDelay;
|
|
1313
|
+
return Math.min(exponentialDelay + jitter, LLM_RETRY_CONFIG.maxDelayMs);
|
|
1314
|
+
}
|
|
1315
|
+
async function processContentWithLLM(content, config, processor, signal) {
|
|
1316
|
+
if (!config.enabled) {
|
|
1317
|
+
return { content, processed: false };
|
|
1318
|
+
}
|
|
1319
|
+
if (!processor) {
|
|
1320
|
+
return {
|
|
1321
|
+
content,
|
|
1322
|
+
processed: false,
|
|
1323
|
+
error: "LLM processor not available (LLM_API_KEY, LLM_BASE_URL, and LLM_MODEL must all be set)",
|
|
1324
|
+
errorDetails: {
|
|
1325
|
+
code: ErrorCode.AUTH_ERROR,
|
|
1326
|
+
message: "LLM processor not available",
|
|
1327
|
+
retryable: false
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
if (!content?.trim()) {
|
|
1332
|
+
return { content: content || "", processed: false, error: "Empty content provided" };
|
|
1333
|
+
}
|
|
1334
|
+
const truncatedContent = content.length > MAX_LLM_INPUT_CHARS ? content.substring(0, MAX_LLM_INPUT_CHARS) + "\n\n[Content truncated due to length]" : content;
|
|
1335
|
+
const skipPrimaryForSize = truncatedContent.length > MAX_PRIMARY_MODEL_INPUT_CHARS && !!LLM_EXTRACTION.FALLBACK_MODEL;
|
|
1336
|
+
const safeUrl = (() => {
|
|
1337
|
+
if (!config.url) return void 0;
|
|
1338
|
+
try {
|
|
1339
|
+
const u = new URL(config.url);
|
|
1340
|
+
return `${u.origin}${u.pathname}`;
|
|
1341
|
+
} catch {
|
|
1342
|
+
return void 0;
|
|
1343
|
+
}
|
|
1344
|
+
})();
|
|
1345
|
+
const urlLine = safeUrl ? `PAGE URL: ${safeUrl}
|
|
1346
|
+
|
|
1347
|
+
` : "";
|
|
1348
|
+
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.
|
|
1349
|
+
|
|
1350
|
+
${urlLine}EXTRACTION INSTRUCTION: ${config.extract}
|
|
1351
|
+
|
|
1352
|
+
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:
|
|
1353
|
+
\`docs | changelog | github-readme | github-thread | reddit | hackernews | forum | blog | marketing | announcement | qa | cve | paper | release-notes | other\`
|
|
1354
|
+
|
|
1355
|
+
STEP 2 \u2014 Adjust emphasis by page type:
|
|
1356
|
+
- docs / changelog / github-readme / release-notes \u2192 API signatures, version numbers, flags, exact config keys, code blocks. Copy verbatim. Preserve tables as tables.
|
|
1357
|
+
- 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.
|
|
1358
|
+
- 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.
|
|
1359
|
+
- blog \u2192 prioritize concrete reproductions, code, measurements. If the author makes a claim without evidence, mark "[unsourced claim]".
|
|
1360
|
+
- 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.
|
|
1361
|
+
- qa (stackoverflow) \u2192 accepted answer's code + high-voted disagreements. Always note the answer date \u2014 SO rots.
|
|
1362
|
+
- cve \u2192 CVSS vector verbatim, CWE, CPE ranges, affected versions, fix version, references. Each with its label.
|
|
1363
|
+
- paper \u2192 claim, method, dataset, benchmark numbers, comparison baseline. Preserve numeric deltas verbatim.
|
|
1364
|
+
|
|
1365
|
+
STEP 3 \u2014 Emit markdown with these sections, in order:
|
|
1366
|
+
|
|
1367
|
+
## Source
|
|
1368
|
+
- URL: <verbatim if visible, else "unknown">
|
|
1369
|
+
- Page type: <the type you picked>
|
|
1370
|
+
- Page date: <verbatim if visible, else "not visible">
|
|
1371
|
+
- Author / maintainer (if identifiable): <verbatim>
|
|
1372
|
+
|
|
1373
|
+
## Matches
|
|
1374
|
+
One bullet per distinct piece of matching info:
|
|
1375
|
+
- **<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.
|
|
1376
|
+
|
|
1377
|
+
## Not found
|
|
1378
|
+
Every part of the extraction instruction this page did NOT answer. Be explicit. Example: "Enterprise pricing contact \u2014 not present on this page."
|
|
1379
|
+
|
|
1380
|
+
## Follow-up signals
|
|
1381
|
+
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.
|
|
1382
|
+
|
|
1383
|
+
## Contradictions
|
|
1384
|
+
(Include this section only if the page contains internally contradictory claims.) Bullet each contradiction with both sides quoted verbatim.
|
|
1385
|
+
|
|
1386
|
+
## Truncation
|
|
1387
|
+
(Include only if content appears cut mid-element.) "Content cut mid-<table row / code block / comment / paragraph>; extraction may be incomplete for <section>."
|
|
1388
|
+
|
|
1389
|
+
RULES:
|
|
1390
|
+
- Never paraphrase numbers, versions, code, or quoted text.
|
|
1391
|
+
- If an instruction item is not answered, it goes in "Not found" \u2014 do NOT invent an answer to please the caller.
|
|
1392
|
+
- Preserve code blocks, command examples, tables exactly.
|
|
1393
|
+
- Do NOT add commentary or recommendations outside "Follow-up signals".
|
|
1394
|
+
- Page language \u2260 English: quote verbatim in the original language AND provide a parenthetical gloss in English.
|
|
1395
|
+
- 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:
|
|
1396
|
+
\`## Matches\\n_Page did not load: <reason>_\`
|
|
1397
|
+
Valid reasons: \`404 | login-wall | paywall | JS-render-empty | non-text-asset | truncated-before-relevant-section\`.
|
|
1398
|
+
|
|
1399
|
+
Content:
|
|
1400
|
+
${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.
|
|
1401
|
+
|
|
1402
|
+
${urlLine}Content:
|
|
1403
|
+
${truncatedContent}`;
|
|
1404
|
+
let lastError;
|
|
1405
|
+
if (skipPrimaryForSize) {
|
|
1406
|
+
mcpLog(
|
|
1407
|
+
"info",
|
|
1408
|
+
`Input ${truncatedContent.length} chars exceeds primary model cap (${MAX_PRIMARY_MODEL_INPUT_CHARS}); routing directly to fallback`,
|
|
1409
|
+
"llm"
|
|
1410
|
+
);
|
|
1411
|
+
} else {
|
|
1412
|
+
for (let attempt = 0; attempt <= LLM_RETRY_CONFIG.maxRetries; attempt++) {
|
|
1413
|
+
try {
|
|
1414
|
+
if (attempt === 0) {
|
|
1415
|
+
mcpLog("info", `Starting extraction with ${LLM_EXTRACTION.MODEL}`, "llm");
|
|
1416
|
+
} else {
|
|
1417
|
+
mcpLog("warning", `Retry attempt ${attempt}/${LLM_RETRY_CONFIG.maxRetries}`, "llm");
|
|
1418
|
+
}
|
|
1419
|
+
const response = await requestText(processor, prompt, "LLM extraction", signal);
|
|
1420
|
+
if (response.content !== null) {
|
|
1421
|
+
mcpLog("info", `Successfully extracted ${response.content.length} characters`, "llm");
|
|
1422
|
+
markLLMSuccess("extractor");
|
|
1423
|
+
return { content: response.content, processed: true };
|
|
1424
|
+
}
|
|
1425
|
+
const providerFailure = getProviderFailure(response);
|
|
1426
|
+
if (providerFailure) {
|
|
1427
|
+
throw providerFailure;
|
|
1428
|
+
}
|
|
1429
|
+
mcpLog("warning", "Received empty response from LLM", "llm");
|
|
1430
|
+
markLLMFailure("extractor", "LLM returned empty response");
|
|
1431
|
+
return emptyLLMExtractionResult(content);
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
lastError = classifyError(err);
|
|
1434
|
+
const status = hasStatus(err) ? err.status : void 0;
|
|
1435
|
+
const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
|
|
1436
|
+
const ctxErr = isContextWindowError(err);
|
|
1437
|
+
mcpLog("error", `Error (attempt ${attempt + 1}): ${lastError.message} [status=${status}, code=${code}, retryable=${isRetryableLLMError(err)}, context_window=${ctxErr}]`, "llm");
|
|
1438
|
+
if (ctxErr) {
|
|
1439
|
+
mcpLog("warning", "Context window exceeded on primary \u2014 skipping remaining retries, routing to fallback", "llm");
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
if (isRetryableLLMError(err) && attempt < LLM_RETRY_CONFIG.maxRetries) {
|
|
1443
|
+
const delayMs = calculateLLMBackoff(attempt);
|
|
1444
|
+
mcpLog("warning", `Retrying in ${delayMs}ms...`, "llm");
|
|
1445
|
+
try {
|
|
1446
|
+
await sleep(delayMs, signal);
|
|
1447
|
+
} catch {
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
const fallbackModel = LLM_EXTRACTION.FALLBACK_MODEL;
|
|
1457
|
+
if (fallbackModel) {
|
|
1458
|
+
mcpLog("warning", `Primary exhausted, switching to fallback ${fallbackModel}`, "llm");
|
|
1459
|
+
for (let attempt = 0; attempt < FALLBACK_RETRY_COUNT; attempt++) {
|
|
1460
|
+
if (attempt > 0) {
|
|
1461
|
+
const delayMs = calculateLLMBackoff(attempt - 1);
|
|
1462
|
+
mcpLog("warning", `Fallback retry ${attempt}/${FALLBACK_RETRY_COUNT - 1} in ${delayMs}ms`, "llm");
|
|
1463
|
+
try {
|
|
1464
|
+
await sleep(delayMs, signal);
|
|
1465
|
+
} catch {
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
try {
|
|
1470
|
+
const response = await requestText(processor, prompt, "LLM extraction [fallback]", signal, fallbackModel);
|
|
1471
|
+
if (response.content !== null) {
|
|
1472
|
+
mcpLog("info", `Fallback extracted ${response.content.length} characters`, "llm");
|
|
1473
|
+
markLLMSuccess("extractor");
|
|
1474
|
+
return { content: response.content, processed: true };
|
|
1475
|
+
}
|
|
1476
|
+
const providerFailure = getProviderFailure(response);
|
|
1477
|
+
if (providerFailure) {
|
|
1478
|
+
throw providerFailure;
|
|
1479
|
+
}
|
|
1480
|
+
mcpLog("warning", "Fallback returned empty response", "llm");
|
|
1481
|
+
markLLMFailure("extractor", "LLM returned empty response");
|
|
1482
|
+
return emptyLLMExtractionResult(content);
|
|
1483
|
+
} catch (err) {
|
|
1484
|
+
lastError = classifyError(err);
|
|
1485
|
+
mcpLog("error", `Fallback error (attempt ${attempt + 1}): ${lastError.message}`, "llm");
|
|
1486
|
+
if (isContextWindowError(err) || !isRetryableLLMError(err)) break;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
const errorMessage = lastError?.message || "Unknown LLM error";
|
|
1491
|
+
mcpLog("error", `All attempts failed: ${errorMessage}. Returning original content.`, "llm");
|
|
1492
|
+
markLLMFailure("extractor", errorMessage);
|
|
1493
|
+
return {
|
|
1494
|
+
content,
|
|
1495
|
+
processed: false,
|
|
1496
|
+
error: `LLM extraction failed: ${errorMessage}`,
|
|
1497
|
+
errorDetails: lastError || {
|
|
1498
|
+
code: ErrorCode.UNKNOWN_ERROR,
|
|
1499
|
+
message: errorMessage,
|
|
1500
|
+
retryable: false
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
var MAX_CLASSIFICATION_URLS = 50;
|
|
1505
|
+
async function classifySearchResults(rankedUrls, objective, totalQueries, processor, previousQueries = []) {
|
|
1506
|
+
const urlsToClassify = rankedUrls.slice(0, MAX_CLASSIFICATION_URLS);
|
|
1507
|
+
const STATIC_WEIGHTS = [30, 20, 15, 10, 8, 6, 5, 4, 3, 2];
|
|
1508
|
+
const weightForRank = (rank) => STATIC_WEIGHTS[rank - 1] ?? 1;
|
|
1509
|
+
const lines = [];
|
|
1510
|
+
for (const url of urlsToClassify) {
|
|
1511
|
+
let domain;
|
|
1512
|
+
try {
|
|
1513
|
+
domain = new URL(url.url).hostname.replace(/^www\./, "");
|
|
1514
|
+
} catch {
|
|
1515
|
+
domain = url.url;
|
|
1516
|
+
}
|
|
1517
|
+
const snippet = url.snippet.length > 120 ? url.snippet.slice(0, 117) + "..." : url.snippet;
|
|
1518
|
+
lines.push(`[${url.rank}] w=${weightForRank(url.rank)} ${url.title} \u2014 ${domain} \u2014 ${snippet}`);
|
|
1519
|
+
}
|
|
1520
|
+
const prevQueriesBlock = previousQueries.length > 0 ? previousQueries.map((q) => `- ${q}`).join("\n") : "- (none provided)";
|
|
1521
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1522
|
+
const prompt = `You are the relevance filter for a research agent. Classify each search result below against the objective and produce a structured analysis.
|
|
1523
|
+
|
|
1524
|
+
OBJECTIVE: ${objective}
|
|
1525
|
+
TODAY: ${today}
|
|
1526
|
+
|
|
1527
|
+
PREVIOUS QUERIES (already run \u2014 do NOT paraphrase in refine_queries):
|
|
1528
|
+
${prevQueriesBlock}
|
|
1529
|
+
|
|
1530
|
+
Return ONLY a JSON object (no markdown, no code fences):
|
|
1531
|
+
|
|
1532
|
+
{
|
|
1533
|
+
"title": "2\u20138 word label for this RESULT CLUSTER (not the objective)",
|
|
1534
|
+
"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.",
|
|
1535
|
+
"confidence": "high | medium | low",
|
|
1536
|
+
"confidence_reason": "one sentence \u2014 why",
|
|
1537
|
+
"gaps": [
|
|
1538
|
+
{ "id": 0, "description": "specific, actionable thing the current results do NOT answer \u2014 not 'more info needed'" }
|
|
1539
|
+
],
|
|
1540
|
+
"refine_queries": [
|
|
1541
|
+
{ "query": "concrete next search", "gap_id": 0, "rationale": "\u226412 words" }
|
|
1542
|
+
],
|
|
1543
|
+
"results": [
|
|
1544
|
+
{
|
|
1545
|
+
"rank": 1,
|
|
1546
|
+
"tier": "HIGHLY_RELEVANT | MAYBE_RELEVANT | OTHER",
|
|
1547
|
+
"source_type": "vendor_doc | github | reddit | hackernews | blog | news | marketing | stackoverflow | cve | paper | release_notes | aggregator | other",
|
|
1548
|
+
"reason": "\u226412 words citing the snippet cue that drove the tier"
|
|
1549
|
+
}
|
|
1550
|
+
]
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
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.
|
|
1554
|
+
|
|
1555
|
+
SOURCE-OF-TRUTH RUBRIC (the "primary source" is goal-dependent \u2014 infer goal type from the objective):
|
|
1556
|
+
- spec / API / config questions \u2192 vendor_doc, github (README, RFC), release_notes are primary
|
|
1557
|
+
- bug / failure-mode questions \u2192 github (issue/PR), stackoverflow are primary
|
|
1558
|
+
- migration / sentiment / lived-experience \u2192 reddit, hackernews, blog are primary; docs are secondary
|
|
1559
|
+
- pricing / commercial \u2192 marketing (the vendor's own pricing page IS the primary source, but treat feature lists skeptically)
|
|
1560
|
+
- security / CVE \u2192 cve databases, distro security trackers (nvd.nist.gov, security-tracker.debian.org, ubuntu.com/security) are primary
|
|
1561
|
+
- synthesis / open-ended \u2192 blend; no single type is primary
|
|
1562
|
+
- product launch \u2192 vendor_doc + news + marketing for the launch itself; blogs + reddit for independent verification
|
|
1563
|
+
|
|
1564
|
+
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.
|
|
1565
|
+
|
|
1566
|
+
CONFIDENCE:
|
|
1567
|
+
- high = \u22653 HIGHLY_RELEVANT results from INDEPENDENT domains agree on the core answer
|
|
1568
|
+
- medium = \u22652 HIGHLY_RELEVANT exist but disagree or share a domain; OR a single authoritative primary source answers it
|
|
1569
|
+
- low = otherwise; snippet-only judgments cap at medium
|
|
1570
|
+
|
|
1571
|
+
REFINE QUERIES \u2014 each MUST differ from every previousQuery by:
|
|
1572
|
+
- a new operator (site:, quotes, verbatim version number), OR
|
|
1573
|
+
- a domain-specific noun ABSENT from every prior query
|
|
1574
|
+
Adding a year alone does NOT count as differentiation.
|
|
1575
|
+
Each refine_query MUST reference a specific gap_id from the gaps array above.
|
|
1576
|
+
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.
|
|
1577
|
+
|
|
1578
|
+
RULES:
|
|
1579
|
+
- Classify ALL ${urlsToClassify.length} results. Do not skip or collapse any.
|
|
1580
|
+
- Use only the three tier values.
|
|
1581
|
+
- Judge from title + domain + snippet only. Do NOT invent facts not present in the snippet.
|
|
1582
|
+
- If ALL results are OTHER: synthesis = "", confidence = "low", and \`gaps\` must explicitly state why the current queries missed the target.
|
|
1583
|
+
- Casing: tier = UPPERCASE_WITH_UNDERSCORES, confidence = lowercase.
|
|
1584
|
+
|
|
1585
|
+
SEARCH RESULTS (${urlsToClassify.length} URLs from ${totalQueries} queries):
|
|
1586
|
+
${lines.join("\n")}`;
|
|
1587
|
+
try {
|
|
1588
|
+
mcpLog("info", `Classifying ${urlsToClassify.length} URLs against objective`, "llm");
|
|
1589
|
+
const response = await requestTextWithFallback(
|
|
1590
|
+
processor,
|
|
1591
|
+
prompt,
|
|
1592
|
+
"Search classification"
|
|
1593
|
+
);
|
|
1594
|
+
if (response.content === null) {
|
|
1595
|
+
const errMsg = response.error ?? "LLM returned empty classification response";
|
|
1596
|
+
markLLMFailure("planner", errMsg);
|
|
1597
|
+
return { result: null, error: errMsg };
|
|
1598
|
+
}
|
|
1599
|
+
const cleaned = response.content.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
1600
|
+
const parsed = JSON.parse(cleaned);
|
|
1601
|
+
if (!parsed.title || typeof parsed.synthesis !== "string" || !Array.isArray(parsed.results)) {
|
|
1602
|
+
const errMsg = "LLM response missing required fields (title, synthesis, results)";
|
|
1603
|
+
markLLMFailure("planner", errMsg);
|
|
1604
|
+
return { result: null, error: errMsg };
|
|
1605
|
+
}
|
|
1606
|
+
mcpLog("info", `Classification complete: ${parsed.results.filter((r) => r.tier === "HIGHLY_RELEVANT").length} highly relevant`, "llm");
|
|
1607
|
+
markLLMSuccess("planner");
|
|
1608
|
+
return { result: parsed };
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1611
|
+
mcpLog("error", `Classification failed: ${message}`, "llm");
|
|
1612
|
+
markLLMFailure("planner", message);
|
|
1613
|
+
return { result: null, error: `Classification failed: ${message}` };
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
async function suggestRefineQueriesForRawMode(rankedUrls, objective, originalQueries, processor) {
|
|
1617
|
+
const urlsToSummarize = rankedUrls.slice(0, 12);
|
|
1618
|
+
const lines = urlsToSummarize.map((url) => {
|
|
1619
|
+
let domain;
|
|
1620
|
+
try {
|
|
1621
|
+
domain = new URL(url.url).hostname.replace(/^www\./, "");
|
|
1622
|
+
} catch {
|
|
1623
|
+
domain = url.url;
|
|
1624
|
+
}
|
|
1625
|
+
return `[${url.rank}] ${url.title} \u2014 ${domain}`;
|
|
1626
|
+
});
|
|
1627
|
+
const prompt = `You are generating follow-up search queries for an agent using raw search results.
|
|
1628
|
+
|
|
1629
|
+
Return ONLY a JSON object (no markdown, no code fences):
|
|
1630
|
+
{
|
|
1631
|
+
"refine_queries": [
|
|
1632
|
+
{ "query": "next search query", "gap_description": "what gap this closes", "rationale": "\u226412 words on why" }
|
|
1633
|
+
]
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
OBJECTIVE: ${objective}
|
|
1637
|
+
|
|
1638
|
+
PREVIOUS QUERIES (already run \u2014 do NOT paraphrase):
|
|
1639
|
+
${originalQueries.map((query) => `- ${query}`).join("\n")}
|
|
1640
|
+
|
|
1641
|
+
TOP RESULT TITLES (to seed new-term probes):
|
|
1642
|
+
${lines.join("\n")}
|
|
1643
|
+
|
|
1644
|
+
RULES:
|
|
1645
|
+
- 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.
|
|
1646
|
+
- 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.
|
|
1647
|
+
- Each refine_query MUST include a \`gap_description\` naming what the current results don't answer.
|
|
1648
|
+
- Do not include URLs.
|
|
1649
|
+
- Keep rationales \u226412 words.`;
|
|
1650
|
+
try {
|
|
1651
|
+
const response = await requestTextWithFallback(
|
|
1652
|
+
processor,
|
|
1653
|
+
prompt,
|
|
1654
|
+
"Raw-mode refine query generation"
|
|
1655
|
+
);
|
|
1656
|
+
if (response.content === null) {
|
|
1657
|
+
const errMsg = response.error ?? "LLM returned empty raw-mode refine query response";
|
|
1658
|
+
markLLMFailure("planner", errMsg);
|
|
1659
|
+
return { result: [], error: errMsg };
|
|
1660
|
+
}
|
|
1661
|
+
const cleaned = response.content.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
1662
|
+
const parsed = JSON.parse(cleaned);
|
|
1663
|
+
markLLMSuccess("planner");
|
|
1664
|
+
return { result: Array.isArray(parsed.refine_queries) ? parsed.refine_queries : [] };
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1667
|
+
mcpLog("error", `Raw-mode refine query generation failed: ${message}`, "llm");
|
|
1668
|
+
markLLMFailure("planner", message);
|
|
1669
|
+
return { result: [], error: message };
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
var VALID_GOAL_CLASSES = /* @__PURE__ */ new Set([
|
|
1673
|
+
"spec",
|
|
1674
|
+
"bug",
|
|
1675
|
+
"migration",
|
|
1676
|
+
"sentiment",
|
|
1677
|
+
"pricing",
|
|
1678
|
+
"security",
|
|
1679
|
+
"synthesis",
|
|
1680
|
+
"product_launch",
|
|
1681
|
+
"other"
|
|
1682
|
+
]);
|
|
1683
|
+
var VALID_FRESHNESS = /* @__PURE__ */ new Set(["days", "weeks", "months", "years"]);
|
|
1684
|
+
var VALID_BRANCHES = /* @__PURE__ */ new Set(["reddit", "web", "both"]);
|
|
1685
|
+
var VALID_STEP_TOOLS = /* @__PURE__ */ new Set(["raw-web-search", "smart-web-search", "raw-scrape-links", "smart-scrape-links"]);
|
|
1686
|
+
function isStringArray(value) {
|
|
1687
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
1688
|
+
}
|
|
1689
|
+
function isStepArray(value) {
|
|
1690
|
+
return Array.isArray(value) && value.every((s) => {
|
|
1691
|
+
if (typeof s !== "object" || s === null) return false;
|
|
1692
|
+
const tool = s.tool;
|
|
1693
|
+
const reason = s.reason;
|
|
1694
|
+
return typeof tool === "string" && VALID_STEP_TOOLS.has(tool) && typeof reason === "string" && reason.trim().length > 0;
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
function parseResearchBrief(raw) {
|
|
1698
|
+
try {
|
|
1699
|
+
const cleaned = raw.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
1700
|
+
const parsed = JSON.parse(cleaned);
|
|
1701
|
+
const goal_class = typeof parsed.goal_class === "string" ? parsed.goal_class : null;
|
|
1702
|
+
if (!goal_class || !VALID_GOAL_CLASSES.has(goal_class)) return null;
|
|
1703
|
+
const freshness_window = typeof parsed.freshness_window === "string" ? parsed.freshness_window : null;
|
|
1704
|
+
if (!freshness_window || !VALID_FRESHNESS.has(freshness_window)) return null;
|
|
1705
|
+
const primary_branch = parsed.primary_branch;
|
|
1706
|
+
if (typeof primary_branch !== "string" || !VALID_BRANCHES.has(primary_branch)) return null;
|
|
1707
|
+
if (!isStepArray(parsed.first_call_sequence) || parsed.first_call_sequence.length === 0) return null;
|
|
1708
|
+
if (!isStringArray(parsed.keyword_seeds) || parsed.keyword_seeds.length === 0) return null;
|
|
1709
|
+
return {
|
|
1710
|
+
goal_class,
|
|
1711
|
+
goal_class_reason: typeof parsed.goal_class_reason === "string" ? parsed.goal_class_reason : "",
|
|
1712
|
+
primary_branch,
|
|
1713
|
+
primary_branch_reason: typeof parsed.primary_branch_reason === "string" ? parsed.primary_branch_reason : "",
|
|
1714
|
+
freshness_window,
|
|
1715
|
+
first_call_sequence: parsed.first_call_sequence,
|
|
1716
|
+
keyword_seeds: parsed.keyword_seeds.filter((s) => s.trim().length > 0),
|
|
1717
|
+
iteration_hints: isStringArray(parsed.iteration_hints) ? parsed.iteration_hints : [],
|
|
1718
|
+
gaps_to_watch: isStringArray(parsed.gaps_to_watch) ? parsed.gaps_to_watch : [],
|
|
1719
|
+
stop_criteria: isStringArray(parsed.stop_criteria) ? parsed.stop_criteria : []
|
|
1720
|
+
};
|
|
1721
|
+
} catch {
|
|
1722
|
+
return null;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
async function generateResearchBrief(goal, processor, signal) {
|
|
1726
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1727
|
+
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:
|
|
1728
|
+
|
|
1729
|
+
- start-research: orientation and this brief
|
|
1730
|
+
- 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
|
|
1731
|
+
- 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
|
|
1732
|
+
- raw-scrape-links: fetch URLs as full markdown, urls only, no LLM; Reddit permalinks return threaded comments; best for complete context and ambiguous sources
|
|
1733
|
+
- 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
|
|
1734
|
+
|
|
1735
|
+
Produce a tailored JSON brief.
|
|
1736
|
+
|
|
1737
|
+
GOAL: ${goal}
|
|
1738
|
+
TODAY: ${today}
|
|
1739
|
+
|
|
1740
|
+
Return ONLY a JSON object (no markdown, no code fences):
|
|
1741
|
+
|
|
1742
|
+
{
|
|
1743
|
+
"goal_class": "spec | bug | migration | sentiment | pricing | security | synthesis | product_launch | other",
|
|
1744
|
+
"goal_class_reason": "one sentence \u2014 why this class",
|
|
1745
|
+
"primary_branch": "reddit | web | both",
|
|
1746
|
+
"primary_branch_reason": "one sentence \u2014 why this branch leads",
|
|
1747
|
+
"freshness_window": "days | weeks | months | years",
|
|
1748
|
+
"first_call_sequence": [
|
|
1749
|
+
{ "tool": "raw-web-search | smart-web-search | raw-scrape-links | smart-scrape-links", "reason": "what this call establishes for the agent" }
|
|
1750
|
+
],
|
|
1751
|
+
"keyword_seeds": ["25\u201350 concrete search keywords \u2014 flat list, to be fired in the first search call as keywords"],
|
|
1752
|
+
"iteration_hints": ["2\u20135 pointers on which harvested terms / follow-up signals to watch for after pass 1"],
|
|
1753
|
+
"gaps_to_watch": ["2\u20135 concrete questions the agent MUST verify or the answer is incomplete"],
|
|
1754
|
+
"stop_criteria": ["2\u20134 checkable conditions \u2014 all must hold before the agent declares done"]
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
RULES:
|
|
1758
|
+
|
|
1759
|
+
primary_branch:
|
|
1760
|
+
- "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.
|
|
1761
|
+
- "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.
|
|
1762
|
+
- "both" \u2192 opinion-heavy AND needs official sources (e.g. product launch + practitioner reception).
|
|
1763
|
+
|
|
1764
|
+
first_call_sequence:
|
|
1765
|
+
- 1\u20133 steps.
|
|
1766
|
+
- 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.
|
|
1767
|
+
- 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.
|
|
1768
|
+
- Use raw-scrape-links when complete page/thread context is valuable, the extraction shape is unclear, or Reddit comments are the source of truth.
|
|
1769
|
+
- 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.
|
|
1770
|
+
- 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.
|
|
1771
|
+
- 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.
|
|
1772
|
+
- 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.
|
|
1773
|
+
|
|
1774
|
+
keyword_seeds:
|
|
1775
|
+
- 25\u201350 total. Narrow bug \u2192 fewer. Open synthesis \u2192 more.
|
|
1776
|
+
- Write Google retrieval probes, not topic labels.
|
|
1777
|
+
- 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.
|
|
1778
|
+
- ${QUERY_REWRITE_PAIR_GUIDANCE_TEXT}
|
|
1779
|
+
- Use operators where helpful (site:, quotes, verbatim version numbers, exact error text, package names, release/version strings).
|
|
1780
|
+
- DIVERSE facets \u2014 same noun-phrase cannot repeat across seeds with adjectives-only variation.
|
|
1781
|
+
- 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.
|
|
1782
|
+
- Do NOT invent vendor names you are uncertain exist.
|
|
1783
|
+
- 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.
|
|
1784
|
+
|
|
1785
|
+
freshness_window:
|
|
1786
|
+
- If the goal mentions a recent release / date / version, use "days" or "weeks".
|
|
1787
|
+
- Stable protocols / APIs \u2192 "months" or "years".`;
|
|
1788
|
+
try {
|
|
1789
|
+
const response = await requestTextWithFallback(
|
|
1790
|
+
processor,
|
|
1791
|
+
prompt,
|
|
1792
|
+
"Research brief generation",
|
|
1793
|
+
signal
|
|
1794
|
+
);
|
|
1795
|
+
if (response.content === null) {
|
|
1796
|
+
mcpLog("warning", `Research brief generation returned no content: ${response.error ?? "unknown"}`, "llm");
|
|
1797
|
+
markLLMFailure("planner", response.error ?? "empty response");
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1800
|
+
const brief = parseResearchBrief(response.content);
|
|
1801
|
+
if (!brief) {
|
|
1802
|
+
mcpLog("warning", "Research brief JSON parse or shape validation failed", "llm");
|
|
1803
|
+
markLLMFailure("planner", "brief parse/validation failed");
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
markLLMSuccess("planner");
|
|
1807
|
+
return brief;
|
|
1808
|
+
} catch (err) {
|
|
1809
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1810
|
+
mcpLog("warning", `Research brief generation failed: ${message}`, "llm");
|
|
1811
|
+
markLLMFailure("planner", message);
|
|
1812
|
+
return null;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// src/effect/errors.ts
|
|
1817
|
+
import { Data } from "effect";
|
|
1818
|
+
var ProviderRequestError = class extends Data.TaggedError("ProviderRequestError") {
|
|
1819
|
+
};
|
|
1820
|
+
var ProviderTimeoutError = class extends Data.TaggedError("ProviderTimeoutError") {
|
|
1821
|
+
};
|
|
1822
|
+
var WeakContentError = class extends Data.TaggedError("WeakContentError") {
|
|
1823
|
+
};
|
|
1824
|
+
var AllStrategiesExhaustedError = class extends Data.TaggedError("AllStrategiesExhaustedError") {
|
|
1825
|
+
};
|
|
1826
|
+
function providerError(provider, operation, error) {
|
|
1827
|
+
return new ProviderRequestError({
|
|
1828
|
+
provider,
|
|
1829
|
+
operation,
|
|
1830
|
+
error: classifyError(error)
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// src/effect/services.ts
|
|
1835
|
+
var SearchService = Context.GenericTag("SearchService");
|
|
1836
|
+
var JinaService = Context.GenericTag("JinaService");
|
|
1837
|
+
var KernelService = Context.GenericTag("KernelService");
|
|
1838
|
+
var RedditService = Context.GenericTag("RedditService");
|
|
1839
|
+
var LlmService = Context.GenericTag("LlmService");
|
|
1840
|
+
var SearchServiceLive = Layer.sync(SearchService, () => {
|
|
1841
|
+
const env = parseEnv();
|
|
1842
|
+
return {
|
|
1843
|
+
serperSearchMultiple: (queries) => Effect.tryPromise({
|
|
1844
|
+
try: () => new SearchClient(env.SEARCH_API_KEY).searchMultiple([...queries]),
|
|
1845
|
+
catch: (error) => providerError("serper", "searchMultiple", error)
|
|
1846
|
+
}),
|
|
1847
|
+
jinaSearchMultiple: (queries) => Effect.tryPromise({
|
|
1848
|
+
try: () => new JinaClient(env.JINA_API_KEY).searchMultiple([...queries]),
|
|
1849
|
+
catch: (error) => providerError("jina", "searchMultiple", error)
|
|
1850
|
+
})
|
|
1851
|
+
};
|
|
1852
|
+
});
|
|
1853
|
+
var JinaServiceLive = Layer.sync(JinaService, () => {
|
|
1854
|
+
const env = parseEnv();
|
|
1855
|
+
const client = new JinaClient(env.JINA_API_KEY);
|
|
1856
|
+
return {
|
|
1857
|
+
convert: (request) => Effect.tryPromise({
|
|
1858
|
+
try: () => client.convert(request),
|
|
1859
|
+
catch: (error) => providerError("jina", "convert", error)
|
|
1860
|
+
})
|
|
1861
|
+
};
|
|
1862
|
+
});
|
|
1863
|
+
var KernelServiceLive = Layer.sync(KernelService, () => {
|
|
1864
|
+
const client = new KernelClient();
|
|
1865
|
+
return {
|
|
1866
|
+
render: (request) => Effect.tryPromise({
|
|
1867
|
+
try: () => client.render(request),
|
|
1868
|
+
catch: (error) => providerError("kernel", "render", error)
|
|
1869
|
+
})
|
|
1870
|
+
};
|
|
1871
|
+
});
|
|
1872
|
+
var LlmServiceLive = Layer.succeed(LlmService, {
|
|
1873
|
+
extractContent: (content, config) => Effect.tryPromise({
|
|
1874
|
+
try: () => processContentWithLLM(content, config, createLLMProcessor()),
|
|
1875
|
+
catch: (error) => providerError("llm", "extractContent", error)
|
|
1876
|
+
}),
|
|
1877
|
+
classifySearchResults: (rankedUrls, objective, totalQueries, processor, previousQueries) => Effect.tryPromise({
|
|
1878
|
+
try: () => classifySearchResults(rankedUrls, objective, totalQueries, processor, previousQueries),
|
|
1879
|
+
catch: (error) => providerError("llm", "classifySearchResults", error)
|
|
1880
|
+
}),
|
|
1881
|
+
suggestRefineQueriesForRawMode: (rankedUrls, objective, originalQueries, processor) => Effect.tryPromise({
|
|
1882
|
+
try: () => suggestRefineQueriesForRawMode(rankedUrls, objective, originalQueries, processor),
|
|
1883
|
+
catch: (error) => providerError("llm", "suggestRefineQueriesForRawMode", error)
|
|
1884
|
+
}),
|
|
1885
|
+
generateResearchBrief: (goal, processor) => Effect.tryPromise({
|
|
1886
|
+
try: () => generateResearchBrief(goal, processor),
|
|
1887
|
+
catch: (error) => providerError("llm", "generateResearchBrief", error)
|
|
1888
|
+
})
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
// src/effect/runtime.ts
|
|
1892
|
+
var BaseExternalLive = Layer2.mergeAll(
|
|
4
1893
|
JinaServiceLive,
|
|
5
1894
|
SearchServiceLive,
|
|
6
1895
|
LlmServiceLive
|
|
7
1896
|
);
|
|
8
1897
|
function runExternalEffect(effect, layer) {
|
|
9
|
-
return
|
|
1898
|
+
return Effect2.runPromise(effect.pipe(Effect2.provide(layer)));
|
|
10
1899
|
}
|
|
11
1900
|
export {
|
|
12
1901
|
BaseExternalLive,
|