mcp-researchpowerpack 3.9.4 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +116 -208
  2. package/dist/index.js +280 -337
  3. package/dist/index.js.map +7 -1
  4. package/dist/mcp-use.json +7 -0
  5. package/dist/src/clients/reddit.js +278 -0
  6. package/dist/src/clients/reddit.js.map +7 -0
  7. package/dist/src/clients/scraper.js +326 -0
  8. package/dist/src/clients/scraper.js.map +7 -0
  9. package/dist/src/clients/search.js +217 -0
  10. package/dist/src/clients/search.js.map +7 -0
  11. package/dist/src/config/index.js +138 -0
  12. package/dist/src/config/index.js.map +7 -0
  13. package/dist/src/prompts/deep-research.js +41 -0
  14. package/dist/src/prompts/deep-research.js.map +7 -0
  15. package/dist/src/prompts/reddit-sentiment.js +47 -0
  16. package/dist/src/prompts/reddit-sentiment.js.map +7 -0
  17. package/dist/src/schemas/reddit.js +21 -0
  18. package/dist/src/schemas/reddit.js.map +7 -0
  19. package/dist/src/schemas/scrape-links.js +26 -0
  20. package/dist/src/schemas/scrape-links.js.map +7 -0
  21. package/dist/src/schemas/start-research.js +17 -0
  22. package/dist/src/schemas/start-research.js.map +7 -0
  23. package/dist/src/schemas/web-search.js +53 -0
  24. package/dist/src/schemas/web-search.js.map +7 -0
  25. package/dist/src/services/llm-processor.js +675 -0
  26. package/dist/src/services/llm-processor.js.map +7 -0
  27. package/dist/src/services/markdown-cleaner.js +62 -0
  28. package/dist/src/services/markdown-cleaner.js.map +7 -0
  29. package/dist/src/services/workflow-state.js +116 -0
  30. package/dist/src/services/workflow-state.js.map +7 -0
  31. package/dist/src/tools/mcp-helpers.js +50 -0
  32. package/dist/src/tools/mcp-helpers.js.map +7 -0
  33. package/dist/src/tools/reddit.js +277 -0
  34. package/dist/src/tools/reddit.js.map +7 -0
  35. package/dist/src/tools/registry.js +18 -0
  36. package/dist/src/tools/registry.js.map +7 -0
  37. package/dist/src/tools/scrape.js +334 -0
  38. package/dist/src/tools/scrape.js.map +7 -0
  39. package/dist/src/tools/search.js +423 -0
  40. package/dist/src/tools/search.js.map +7 -0
  41. package/dist/src/tools/start-research.js +199 -0
  42. package/dist/src/tools/start-research.js.map +7 -0
  43. package/dist/src/tools/utils.js +29 -0
  44. package/dist/src/tools/utils.js.map +7 -0
  45. package/dist/src/utils/bootstrap-guard.js +27 -0
  46. package/dist/src/utils/bootstrap-guard.js.map +7 -0
  47. package/dist/src/utils/concurrency.js +62 -0
  48. package/dist/src/utils/concurrency.js.map +7 -0
  49. package/dist/src/utils/content-extractor.js +61 -0
  50. package/dist/src/utils/content-extractor.js.map +7 -0
  51. package/dist/src/utils/errors.js +211 -0
  52. package/dist/src/utils/errors.js.map +7 -0
  53. package/dist/src/utils/logger.js +25 -0
  54. package/dist/src/utils/logger.js.map +7 -0
  55. package/dist/src/utils/markdown-formatter.js +15 -0
  56. package/dist/src/utils/markdown-formatter.js.map +7 -0
  57. package/dist/src/utils/reddit-keyword-guard.js +29 -0
  58. package/dist/src/utils/reddit-keyword-guard.js.map +7 -0
  59. package/dist/src/utils/response.js +81 -0
  60. package/dist/src/utils/response.js.map +7 -0
  61. package/dist/src/utils/retry.js +13 -0
  62. package/dist/src/utils/retry.js.map +7 -0
  63. package/dist/src/utils/sanitize.js +10 -0
  64. package/dist/src/utils/sanitize.js.map +7 -0
  65. package/dist/src/utils/source-type.js +41 -0
  66. package/dist/src/utils/source-type.js.map +7 -0
  67. package/dist/src/utils/url-aggregator.js +227 -0
  68. package/dist/src/utils/url-aggregator.js.map +7 -0
  69. package/dist/src/utils/workflow-key.js +14 -0
  70. package/dist/src/utils/workflow-key.js.map +7 -0
  71. package/dist/src/version.js +32 -0
  72. package/dist/src/version.js.map +7 -0
  73. package/package.json +33 -28
  74. package/dist/clients/reddit.d.ts +0 -69
  75. package/dist/clients/reddit.d.ts.map +0 -1
  76. package/dist/clients/reddit.js +0 -369
  77. package/dist/clients/reddit.js.map +0 -1
  78. package/dist/clients/research.d.ts +0 -67
  79. package/dist/clients/research.d.ts.map +0 -1
  80. package/dist/clients/research.js +0 -290
  81. package/dist/clients/research.js.map +0 -1
  82. package/dist/clients/scraper.d.ts +0 -72
  83. package/dist/clients/scraper.d.ts.map +0 -1
  84. package/dist/clients/scraper.js +0 -338
  85. package/dist/clients/scraper.js.map +0 -1
  86. package/dist/clients/search.d.ts +0 -57
  87. package/dist/clients/search.d.ts.map +0 -1
  88. package/dist/clients/search.js +0 -223
  89. package/dist/clients/search.js.map +0 -1
  90. package/dist/config/index.d.ts +0 -78
  91. package/dist/config/index.d.ts.map +0 -1
  92. package/dist/config/index.js +0 -201
  93. package/dist/config/index.js.map +0 -1
  94. package/dist/config/loader.d.ts +0 -40
  95. package/dist/config/loader.d.ts.map +0 -1
  96. package/dist/config/loader.js +0 -322
  97. package/dist/config/loader.js.map +0 -1
  98. package/dist/config/types.d.ts +0 -81
  99. package/dist/config/types.d.ts.map +0 -1
  100. package/dist/config/types.js +0 -6
  101. package/dist/config/types.js.map +0 -1
  102. package/dist/config/yaml/tools.yaml +0 -146
  103. package/dist/index.d.ts +0 -7
  104. package/dist/index.d.ts.map +0 -1
  105. package/dist/schemas/deep-research.d.ts +0 -64
  106. package/dist/schemas/deep-research.d.ts.map +0 -1
  107. package/dist/schemas/deep-research.js +0 -224
  108. package/dist/schemas/deep-research.js.map +0 -1
  109. package/dist/schemas/scrape-links.d.ts +0 -32
  110. package/dist/schemas/scrape-links.d.ts.map +0 -1
  111. package/dist/schemas/scrape-links.js +0 -34
  112. package/dist/schemas/scrape-links.js.map +0 -1
  113. package/dist/schemas/web-search.d.ts +0 -22
  114. package/dist/schemas/web-search.d.ts.map +0 -1
  115. package/dist/schemas/web-search.js +0 -21
  116. package/dist/schemas/web-search.js.map +0 -1
  117. package/dist/services/file-attachment.d.ts +0 -30
  118. package/dist/services/file-attachment.d.ts.map +0 -1
  119. package/dist/services/file-attachment.js +0 -207
  120. package/dist/services/file-attachment.js.map +0 -1
  121. package/dist/services/llm-processor.d.ts +0 -29
  122. package/dist/services/llm-processor.d.ts.map +0 -1
  123. package/dist/services/llm-processor.js +0 -244
  124. package/dist/services/llm-processor.js.map +0 -1
  125. package/dist/services/markdown-cleaner.d.ts +0 -8
  126. package/dist/services/markdown-cleaner.d.ts.map +0 -1
  127. package/dist/services/markdown-cleaner.js +0 -74
  128. package/dist/services/markdown-cleaner.js.map +0 -1
  129. package/dist/tools/definitions.d.ts +0 -16
  130. package/dist/tools/definitions.d.ts.map +0 -1
  131. package/dist/tools/definitions.js +0 -17
  132. package/dist/tools/definitions.js.map +0 -1
  133. package/dist/tools/reddit.d.ts +0 -14
  134. package/dist/tools/reddit.d.ts.map +0 -1
  135. package/dist/tools/reddit.js +0 -265
  136. package/dist/tools/reddit.js.map +0 -1
  137. package/dist/tools/registry.d.ts +0 -71
  138. package/dist/tools/registry.d.ts.map +0 -1
  139. package/dist/tools/registry.js +0 -252
  140. package/dist/tools/registry.js.map +0 -1
  141. package/dist/tools/research.d.ts +0 -14
  142. package/dist/tools/research.d.ts.map +0 -1
  143. package/dist/tools/research.js +0 -196
  144. package/dist/tools/research.js.map +0 -1
  145. package/dist/tools/scrape.d.ts +0 -14
  146. package/dist/tools/scrape.d.ts.map +0 -1
  147. package/dist/tools/scrape.js +0 -234
  148. package/dist/tools/scrape.js.map +0 -1
  149. package/dist/tools/search.d.ts +0 -10
  150. package/dist/tools/search.d.ts.map +0 -1
  151. package/dist/tools/search.js +0 -158
  152. package/dist/tools/search.js.map +0 -1
  153. package/dist/tools/utils.d.ts +0 -105
  154. package/dist/tools/utils.d.ts.map +0 -1
  155. package/dist/tools/utils.js +0 -159
  156. package/dist/tools/utils.js.map +0 -1
  157. package/dist/utils/concurrency.d.ts +0 -28
  158. package/dist/utils/concurrency.d.ts.map +0 -1
  159. package/dist/utils/concurrency.js +0 -92
  160. package/dist/utils/concurrency.js.map +0 -1
  161. package/dist/utils/errors.d.ts +0 -95
  162. package/dist/utils/errors.d.ts.map +0 -1
  163. package/dist/utils/errors.js +0 -390
  164. package/dist/utils/errors.js.map +0 -1
  165. package/dist/utils/logger.d.ts +0 -39
  166. package/dist/utils/logger.d.ts.map +0 -1
  167. package/dist/utils/logger.js +0 -57
  168. package/dist/utils/logger.js.map +0 -1
  169. package/dist/utils/markdown-formatter.d.ts +0 -5
  170. package/dist/utils/markdown-formatter.d.ts.map +0 -1
  171. package/dist/utils/markdown-formatter.js +0 -15
  172. package/dist/utils/markdown-formatter.js.map +0 -1
  173. package/dist/utils/response.d.ts +0 -93
  174. package/dist/utils/response.d.ts.map +0 -1
  175. package/dist/utils/response.js +0 -170
  176. package/dist/utils/response.js.map +0 -1
  177. package/dist/utils/retry.d.ts +0 -43
  178. package/dist/utils/retry.d.ts.map +0 -1
  179. package/dist/utils/retry.js +0 -57
  180. package/dist/utils/retry.js.map +0 -1
  181. package/dist/utils/url-aggregator.d.ts +0 -90
  182. package/dist/utils/url-aggregator.d.ts.map +0 -1
  183. package/dist/utils/url-aggregator.js +0 -538
  184. package/dist/utils/url-aggregator.js.map +0 -1
  185. package/dist/version.d.ts +0 -29
  186. package/dist/version.d.ts.map +0 -1
  187. package/dist/version.js +0 -55
  188. package/dist/version.js.map +0 -1
@@ -0,0 +1,675 @@
1
+ import OpenAI from "openai";
2
+ import { LLM_EXTRACTION, getCapabilities } from "../config/index.js";
3
+ import {
4
+ classifyError,
5
+ sleep,
6
+ ErrorCode,
7
+ withStallProtection
8
+ } from "../utils/errors.js";
9
+ import { mcpLog } from "../utils/logger.js";
10
+ const MAX_LLM_INPUT_CHARS = 1e5;
11
+ const LLM_CLIENT_TIMEOUT_MS = 12e4;
12
+ const BACKOFF_JITTER_FACTOR = 0.3;
13
+ const LLM_STALL_TIMEOUT_MS = 15e3;
14
+ const LLM_REQUEST_DEADLINE_MS = 3e4;
15
+ const llmHealth = {
16
+ lastPlannerOk: false,
17
+ lastExtractorOk: false,
18
+ lastPlannerCheckedAt: null,
19
+ lastExtractorCheckedAt: null,
20
+ lastPlannerError: null,
21
+ lastExtractorError: null
22
+ };
23
+ function markLLMSuccess(kind) {
24
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
25
+ if (kind === "planner") {
26
+ llmHealth.lastPlannerOk = true;
27
+ llmHealth.lastPlannerCheckedAt = ts;
28
+ llmHealth.lastPlannerError = null;
29
+ } else {
30
+ llmHealth.lastExtractorOk = true;
31
+ llmHealth.lastExtractorCheckedAt = ts;
32
+ llmHealth.lastExtractorError = null;
33
+ }
34
+ }
35
+ function markLLMFailure(kind, err) {
36
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
37
+ const message = err instanceof Error ? err.message : String(err ?? "unknown error");
38
+ if (kind === "planner") {
39
+ llmHealth.lastPlannerOk = false;
40
+ llmHealth.lastPlannerCheckedAt = ts;
41
+ llmHealth.lastPlannerError = message;
42
+ } else {
43
+ llmHealth.lastExtractorOk = false;
44
+ llmHealth.lastExtractorCheckedAt = ts;
45
+ llmHealth.lastExtractorError = message;
46
+ }
47
+ }
48
+ function getLLMHealth() {
49
+ const cap = getCapabilities();
50
+ return {
51
+ lastPlannerOk: llmHealth.lastPlannerOk,
52
+ lastExtractorOk: llmHealth.lastExtractorOk,
53
+ lastPlannerCheckedAt: llmHealth.lastPlannerCheckedAt,
54
+ lastExtractorCheckedAt: llmHealth.lastExtractorCheckedAt,
55
+ lastPlannerError: llmHealth.lastPlannerError,
56
+ lastExtractorError: llmHealth.lastExtractorError,
57
+ // Static capability — based on env presence at boot. Runtime health (above)
58
+ // tells whether the last attempt actually succeeded.
59
+ plannerConfigured: cap.llmExtraction,
60
+ extractorConfigured: cap.llmExtraction
61
+ };
62
+ }
63
+ function _resetLLMHealthForTests() {
64
+ llmHealth.lastPlannerOk = false;
65
+ llmHealth.lastExtractorOk = false;
66
+ llmHealth.lastPlannerCheckedAt = null;
67
+ llmHealth.lastExtractorCheckedAt = null;
68
+ llmHealth.lastPlannerError = null;
69
+ llmHealth.lastExtractorError = null;
70
+ }
71
+ const LLM_RETRY_CONFIG = {
72
+ maxRetries: 2,
73
+ baseDelayMs: 1e3,
74
+ maxDelayMs: 5e3
75
+ };
76
+ const RETRYABLE_LLM_ERROR_CODES = /* @__PURE__ */ new Set([
77
+ "rate_limit_exceeded",
78
+ "server_error",
79
+ "timeout",
80
+ "service_unavailable"
81
+ ]);
82
+ function hasStatus(error) {
83
+ return typeof error === "object" && error !== null && "status" in error && typeof error.status === "number";
84
+ }
85
+ let llmClient = null;
86
+ function createLLMProcessor() {
87
+ if (!getCapabilities().llmExtraction) return null;
88
+ if (!llmClient) {
89
+ llmClient = new OpenAI({
90
+ baseURL: LLM_EXTRACTION.BASE_URL,
91
+ apiKey: LLM_EXTRACTION.API_KEY,
92
+ timeout: LLM_CLIENT_TIMEOUT_MS,
93
+ maxRetries: 0,
94
+ defaultHeaders: { "X-Title": "mcp-research-powerpack" }
95
+ });
96
+ mcpLog("info", `LLM extraction configured (model: ${LLM_EXTRACTION.MODEL}, baseURL: ${LLM_EXTRACTION.BASE_URL})`, "llm");
97
+ }
98
+ return llmClient;
99
+ }
100
+ function buildChatRequestBody(model, prompt, maxTokens) {
101
+ const requestBody = {
102
+ model,
103
+ messages: [{ role: "user", content: prompt }],
104
+ max_tokens: maxTokens
105
+ };
106
+ if (LLM_EXTRACTION.REASONING_EFFORT !== "none") {
107
+ requestBody.reasoning_effort = LLM_EXTRACTION.REASONING_EFFORT;
108
+ }
109
+ return requestBody;
110
+ }
111
+ async function requestTextWithFallback(processor, prompt, maxTokens, operationLabel, signal) {
112
+ const models = [...new Set([
113
+ LLM_EXTRACTION.MODEL,
114
+ LLM_EXTRACTION.FALLBACK_MODEL
115
+ ].filter(Boolean))];
116
+ let lastError = "Unknown LLM error";
117
+ for (const model of models) {
118
+ try {
119
+ const response = await withStallProtection(
120
+ (stallSignal) => processor.chat.completions.create(
121
+ buildChatRequestBody(model, prompt, maxTokens),
122
+ {
123
+ signal: signal ? AbortSignal.any([stallSignal, signal]) : stallSignal,
124
+ timeout: LLM_REQUEST_DEADLINE_MS
125
+ }
126
+ ),
127
+ LLM_STALL_TIMEOUT_MS,
128
+ 3,
129
+ `${operationLabel} (${model})`
130
+ );
131
+ const content = response.choices?.[0]?.message?.content?.trim();
132
+ if (content) {
133
+ if (model !== LLM_EXTRACTION.MODEL) {
134
+ mcpLog("warning", `${operationLabel} succeeded with fallback model ${model}`, "llm");
135
+ }
136
+ return { content, model };
137
+ }
138
+ lastError = `Empty response from model ${model}`;
139
+ mcpLog("warning", `${operationLabel} returned empty content for model ${model}`, "llm");
140
+ } catch (err) {
141
+ lastError = err instanceof Error ? err.message : String(err);
142
+ mcpLog("warning", `${operationLabel} failed for model ${model}: ${lastError}`, "llm");
143
+ }
144
+ }
145
+ return { content: null, model: LLM_EXTRACTION.FALLBACK_MODEL, error: lastError };
146
+ }
147
+ function isRetryableLLMError(error) {
148
+ if (!error || typeof error !== "object") return false;
149
+ const stallCode = error?.code;
150
+ if (stallCode === "ESTALLED" || stallCode === "ETIMEDOUT") {
151
+ return true;
152
+ }
153
+ if (hasStatus(error)) {
154
+ if (error.status === 429 || error.status === 500 || error.status === 502 || error.status === 503 || error.status === 504) {
155
+ return true;
156
+ }
157
+ }
158
+ const record = error;
159
+ const code = typeof record.code === "string" ? record.code : void 0;
160
+ const nested = typeof record.error === "object" && record.error !== null ? record.error : null;
161
+ const errorCode = code ?? (nested && typeof nested.code === "string" ? nested.code : void 0) ?? (nested && typeof nested.type === "string" ? nested.type : void 0);
162
+ if (errorCode && RETRYABLE_LLM_ERROR_CODES.has(errorCode)) {
163
+ return true;
164
+ }
165
+ const message = typeof record.message === "string" ? record.message.toLowerCase() : "";
166
+ 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")) {
167
+ return true;
168
+ }
169
+ return false;
170
+ }
171
+ function calculateLLMBackoff(attempt) {
172
+ const exponentialDelay = LLM_RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt);
173
+ const jitter = Math.random() * BACKOFF_JITTER_FACTOR * exponentialDelay;
174
+ return Math.min(exponentialDelay + jitter, LLM_RETRY_CONFIG.maxDelayMs);
175
+ }
176
+ async function processContentWithLLM(content, config, processor, signal) {
177
+ if (!config.enabled) {
178
+ return { content, processed: false };
179
+ }
180
+ if (!processor) {
181
+ return {
182
+ content,
183
+ processed: false,
184
+ error: "LLM processor not available (LLM_EXTRACTION_API_KEY or OPENROUTER_API_KEY not set)",
185
+ errorDetails: {
186
+ code: ErrorCode.AUTH_ERROR,
187
+ message: "LLM processor not available",
188
+ retryable: false
189
+ }
190
+ };
191
+ }
192
+ if (!content?.trim()) {
193
+ return { content: content || "", processed: false, error: "Empty content provided" };
194
+ }
195
+ const truncatedContent = content.length > MAX_LLM_INPUT_CHARS ? content.substring(0, MAX_LLM_INPUT_CHARS) + "\n\n[Content truncated due to length]" : content;
196
+ const safeUrl = (() => {
197
+ if (!config.url) return void 0;
198
+ try {
199
+ const u = new URL(config.url);
200
+ return `${u.origin}${u.pathname}`;
201
+ } catch {
202
+ return void 0;
203
+ }
204
+ })();
205
+ const urlLine = safeUrl ? `PAGE URL: ${safeUrl}
206
+
207
+ ` : "";
208
+ 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.
209
+
210
+ ${urlLine}EXTRACTION INSTRUCTION: ${config.extract}
211
+
212
+ 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:
213
+ \`docs | changelog | github-readme | github-thread | reddit | hackernews | forum | blog | marketing | announcement | qa | cve | paper | release-notes | other\`
214
+
215
+ STEP 2 \u2014 Adjust emphasis by page type:
216
+ - docs / changelog / github-readme / release-notes \u2192 API signatures, version numbers, flags, exact config keys, code blocks. Copy verbatim. Preserve tables as tables.
217
+ - 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.
218
+ - 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.
219
+ - blog \u2192 prioritize concrete reproductions, code, measurements. If the author makes a claim without evidence, mark "[unsourced claim]".
220
+ - 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.
221
+ - qa (stackoverflow) \u2192 accepted answer's code + high-voted disagreements. Always note the answer date \u2014 SO rots.
222
+ - cve \u2192 CVSS vector verbatim, CWE, CPE ranges, affected versions, fix version, references. Each with its label.
223
+ - paper \u2192 claim, method, dataset, benchmark numbers, comparison baseline. Preserve numeric deltas verbatim.
224
+
225
+ STEP 3 \u2014 Emit markdown with these sections, in order:
226
+
227
+ ## Source
228
+ - URL: <verbatim if visible, else "unknown">
229
+ - Page type: <the type you picked>
230
+ - Page date: <verbatim if visible, else "not visible">
231
+ - Author / maintainer (if identifiable): <verbatim>
232
+
233
+ ## Matches
234
+ One bullet per distinct piece of matching info:
235
+ - **<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.
236
+
237
+ ## Not found
238
+ Every part of the extraction instruction this page did NOT answer. Be explicit. Example: "Enterprise pricing contact \u2014 not present on this page."
239
+
240
+ ## Follow-up signals
241
+ 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.
242
+
243
+ ## Contradictions
244
+ (Include this section only if the page contains internally contradictory claims.) Bullet each contradiction with both sides quoted verbatim.
245
+
246
+ ## Truncation
247
+ (Include only if content appears cut mid-element.) "Content cut mid-<table row / code block / comment / paragraph>; extraction may be incomplete for <section>."
248
+
249
+ RULES:
250
+ - Never paraphrase numbers, versions, code, or quoted text.
251
+ - If an instruction item is not answered, it goes in "Not found" \u2014 do NOT invent an answer to please the caller.
252
+ - Preserve code blocks, command examples, tables exactly.
253
+ - Do NOT add commentary or recommendations outside "Follow-up signals".
254
+ - Page language \u2260 English: quote verbatim in the original language AND provide a parenthetical gloss in English.
255
+ - Content clearly failed to load: return ONLY a single line, choosing from:
256
+ \`## Matches\\n_Page did not load: 404_\`
257
+ \`## Matches\\n_Page did not load: login-wall_\`
258
+ \`## Matches\\n_Page did not load: paywall_\`
259
+ \`## Matches\\n_Page did not load: JS-render-empty_\`
260
+ \`## Matches\\n_Page did not load: non-text-asset_\`
261
+ \`## Matches\\n_Page did not load: truncated-before-relevant-section_\`
262
+
263
+ Content:
264
+ ${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.
265
+
266
+ ${urlLine}Content:
267
+ ${truncatedContent}`;
268
+ let lastError;
269
+ for (let attempt = 0; attempt <= LLM_RETRY_CONFIG.maxRetries; attempt++) {
270
+ try {
271
+ if (attempt === 0) {
272
+ mcpLog("info", `Starting extraction with ${LLM_EXTRACTION.MODEL}`, "llm");
273
+ } else {
274
+ mcpLog("warning", `Retry attempt ${attempt}/${LLM_RETRY_CONFIG.maxRetries}`, "llm");
275
+ }
276
+ const response = await requestTextWithFallback(
277
+ processor,
278
+ prompt,
279
+ config.max_tokens || LLM_EXTRACTION.MAX_TOKENS,
280
+ "LLM extraction",
281
+ signal
282
+ );
283
+ if (response.content) {
284
+ mcpLog("info", `Successfully extracted ${response.content.length} characters`, "llm");
285
+ markLLMSuccess("extractor");
286
+ return { content: response.content, processed: true };
287
+ }
288
+ mcpLog("warning", "Received empty response from LLM", "llm");
289
+ markLLMFailure("extractor", "LLM returned empty response");
290
+ return {
291
+ content,
292
+ processed: false,
293
+ error: "LLM returned empty response",
294
+ errorDetails: {
295
+ code: ErrorCode.INTERNAL_ERROR,
296
+ message: "LLM returned empty response",
297
+ retryable: false
298
+ }
299
+ };
300
+ } catch (err) {
301
+ lastError = classifyError(err);
302
+ const status = hasStatus(err) ? err.status : void 0;
303
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
304
+ mcpLog("error", `Error (attempt ${attempt + 1}): ${lastError.message} [status=${status}, code=${code}, retryable=${isRetryableLLMError(err)}]`, "llm");
305
+ if (isRetryableLLMError(err) && attempt < LLM_RETRY_CONFIG.maxRetries) {
306
+ const delayMs = calculateLLMBackoff(attempt);
307
+ mcpLog("warning", `Retrying in ${delayMs}ms...`, "llm");
308
+ try {
309
+ await sleep(delayMs, signal);
310
+ } catch {
311
+ break;
312
+ }
313
+ continue;
314
+ }
315
+ break;
316
+ }
317
+ }
318
+ const errorMessage = lastError?.message || "Unknown LLM error";
319
+ mcpLog("error", `All attempts failed: ${errorMessage}. Returning original content.`, "llm");
320
+ markLLMFailure("extractor", errorMessage);
321
+ return {
322
+ content,
323
+ // Return original content as fallback
324
+ processed: false,
325
+ error: `LLM extraction failed: ${errorMessage}`,
326
+ errorDetails: lastError || {
327
+ code: ErrorCode.UNKNOWN_ERROR,
328
+ message: errorMessage,
329
+ retryable: false
330
+ }
331
+ };
332
+ }
333
+ const MAX_CLASSIFICATION_URLS = 50;
334
+ async function classifySearchResults(rankedUrls, objective, totalQueries, processor, previousQueries = []) {
335
+ const urlsToClassify = rankedUrls.slice(0, MAX_CLASSIFICATION_URLS);
336
+ const lines = [];
337
+ for (const url of urlsToClassify) {
338
+ let domain;
339
+ try {
340
+ domain = new URL(url.url).hostname.replace(/^www\./, "");
341
+ } catch {
342
+ domain = url.url;
343
+ }
344
+ const snippet = url.snippet.length > 120 ? url.snippet.slice(0, 117) + "..." : url.snippet;
345
+ lines.push(`[${url.rank}] ${url.title} \u2014 ${domain} \u2014 ${snippet}`);
346
+ }
347
+ const prevQueriesBlock = previousQueries.length > 0 ? previousQueries.map((q) => `- ${q}`).join("\n") : "- (none provided)";
348
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
349
+ const prompt = `You are the relevance filter for a research agent. Classify each search result below against the objective and produce a structured analysis.
350
+
351
+ OBJECTIVE: ${objective}
352
+ TODAY: ${today}
353
+
354
+ PREVIOUS QUERIES (already run \u2014 do NOT paraphrase in refine_queries):
355
+ ${prevQueriesBlock}
356
+
357
+ Return ONLY a JSON object (no markdown, no code fences):
358
+
359
+ {
360
+ "title": "2\u20138 word label for this RESULT CLUSTER (not the objective)",
361
+ "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.",
362
+ "confidence": "high | medium | low",
363
+ "confidence_reason": "one sentence \u2014 why",
364
+ "gaps": [
365
+ { "id": 0, "description": "specific, actionable thing the current results do NOT answer \u2014 not 'more info needed'" }
366
+ ],
367
+ "refine_queries": [
368
+ { "query": "concrete next search", "gap_id": 0, "rationale": "\u226412 words" }
369
+ ],
370
+ "results": [
371
+ {
372
+ "rank": 1,
373
+ "tier": "HIGHLY_RELEVANT | MAYBE_RELEVANT | OTHER",
374
+ "source_type": "vendor_doc | github | reddit | hackernews | blog | news | marketing | stackoverflow | cve | paper | release_notes | aggregator | other",
375
+ "reason": "\u226412 words citing the snippet cue that drove the tier"
376
+ }
377
+ ]
378
+ }
379
+
380
+ SOURCE-OF-TRUTH RUBRIC (the "primary source" is goal-dependent \u2014 infer goal type from the objective):
381
+ - spec / API / config questions \u2192 vendor_doc, github (README, RFC), release_notes are primary
382
+ - bug / failure-mode questions \u2192 github (issue/PR), stackoverflow are primary
383
+ - migration / sentiment / lived-experience \u2192 reddit, hackernews, blog are primary; docs are secondary
384
+ - pricing / commercial \u2192 marketing (the vendor's own pricing page IS the primary source, but treat feature lists skeptically)
385
+ - security / CVE \u2192 cve databases, distro security trackers (nvd.nist.gov, security-tracker.debian.org, ubuntu.com/security) are primary
386
+ - synthesis / open-ended \u2192 blend; no single type is primary
387
+ - product launch \u2192 vendor_doc + news + marketing for the launch itself; blogs + reddit for independent verification
388
+
389
+ 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.
390
+
391
+ CONFIDENCE:
392
+ - high = \u22653 HIGHLY_RELEVANT results from INDEPENDENT domains agree on the core answer
393
+ - medium = \u22652 HIGHLY_RELEVANT exist but disagree or share a domain; OR a single authoritative primary source answers it
394
+ - low = otherwise; snippet-only judgments cap at medium
395
+
396
+ REFINE QUERIES \u2014 each MUST differ from every previousQuery by:
397
+ - a new operator (site:, quotes, verbatim version number), OR
398
+ - a domain-specific noun ABSENT from every prior query
399
+ Adding a year alone does NOT count as differentiation.
400
+ Each refine_query MUST reference a specific gap_id from the gaps array above.
401
+ 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.
402
+
403
+ RULES:
404
+ - Classify ALL ${urlsToClassify.length} results. Do not skip or collapse any.
405
+ - Use only the three tier values.
406
+ - Judge from title + domain + snippet only. Do NOT invent facts not present in the snippet.
407
+ - If ALL results are OTHER: synthesis = "", confidence = "low", and \`gaps\` must explicitly state why the current queries missed the target.
408
+ - Casing: tier = UPPERCASE_WITH_UNDERSCORES, confidence = lowercase.
409
+
410
+ SEARCH RESULTS (${urlsToClassify.length} URLs from ${totalQueries} queries):
411
+ ${lines.join("\n")}`;
412
+ try {
413
+ mcpLog("info", `Classifying ${urlsToClassify.length} URLs against objective`, "llm");
414
+ const response = await requestTextWithFallback(
415
+ processor,
416
+ prompt,
417
+ 8e3,
418
+ "Search classification"
419
+ );
420
+ if (!response.content) {
421
+ const errMsg = response.error ?? "LLM returned empty classification response";
422
+ markLLMFailure("planner", errMsg);
423
+ return { result: null, error: errMsg };
424
+ }
425
+ const cleaned = response.content.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
426
+ const parsed = JSON.parse(cleaned);
427
+ if (!parsed.title || typeof parsed.synthesis !== "string" || !Array.isArray(parsed.results)) {
428
+ const errMsg = "LLM response missing required fields (title, synthesis, results)";
429
+ markLLMFailure("planner", errMsg);
430
+ return { result: null, error: errMsg };
431
+ }
432
+ mcpLog("info", `Classification complete: ${parsed.results.filter((r) => r.tier === "HIGHLY_RELEVANT").length} highly relevant`, "llm");
433
+ markLLMSuccess("planner");
434
+ return { result: parsed };
435
+ } catch (err) {
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ mcpLog("error", `Classification failed: ${message}`, "llm");
438
+ markLLMFailure("planner", message);
439
+ return { result: null, error: `Classification failed: ${message}` };
440
+ }
441
+ }
442
+ async function suggestRefineQueriesForRawMode(rankedUrls, objective, originalQueries, processor) {
443
+ const urlsToSummarize = rankedUrls.slice(0, 12);
444
+ const lines = urlsToSummarize.map((url) => {
445
+ let domain;
446
+ try {
447
+ domain = new URL(url.url).hostname.replace(/^www\./, "");
448
+ } catch {
449
+ domain = url.url;
450
+ }
451
+ return `[${url.rank}] ${url.title} \u2014 ${domain}`;
452
+ });
453
+ const prompt = `You are generating follow-up search queries for an agent using raw web-search results.
454
+
455
+ Return ONLY a JSON object (no markdown, no code fences):
456
+ {
457
+ "refine_queries": [
458
+ { "query": "next search query", "gap_description": "what gap this closes", "rationale": "\u226412 words on why" }
459
+ ]
460
+ }
461
+
462
+ OBJECTIVE: ${objective}
463
+
464
+ PREVIOUS QUERIES (already run \u2014 do NOT paraphrase):
465
+ ${originalQueries.map((query) => `- ${query}`).join("\n")}
466
+
467
+ TOP RESULT TITLES (to seed new-term probes):
468
+ ${lines.join("\n")}
469
+
470
+ RULES:
471
+ - 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.
472
+ - 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.
473
+ - Each refine_query MUST include a \`gap_description\` naming what the current results don't answer.
474
+ - Do not include URLs.
475
+ - Keep rationales \u226412 words.`;
476
+ try {
477
+ const response = await requestTextWithFallback(
478
+ processor,
479
+ prompt,
480
+ 800,
481
+ "Raw-mode refine query generation"
482
+ );
483
+ if (!response.content) {
484
+ const errMsg = response.error ?? "LLM returned empty raw-mode refine query response";
485
+ markLLMFailure("planner", errMsg);
486
+ return { result: [], error: errMsg };
487
+ }
488
+ const cleaned = response.content.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
489
+ const parsed = JSON.parse(cleaned);
490
+ markLLMSuccess("planner");
491
+ return { result: Array.isArray(parsed.refine_queries) ? parsed.refine_queries : [] };
492
+ } catch (err) {
493
+ const message = err instanceof Error ? err.message : String(err);
494
+ mcpLog("error", `Raw-mode refine query generation failed: ${message}`, "llm");
495
+ markLLMFailure("planner", message);
496
+ return { result: [], error: message };
497
+ }
498
+ }
499
+ const VALID_GOAL_CLASSES = /* @__PURE__ */ new Set([
500
+ "spec",
501
+ "bug",
502
+ "migration",
503
+ "sentiment",
504
+ "pricing",
505
+ "security",
506
+ "synthesis",
507
+ "product_launch",
508
+ "other"
509
+ ]);
510
+ const VALID_FRESHNESS = /* @__PURE__ */ new Set(["days", "weeks", "months", "years"]);
511
+ function isStringArray(value) {
512
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
513
+ }
514
+ function isConceptGroupArray(value) {
515
+ return Array.isArray(value) && value.every((g) => {
516
+ if (typeof g !== "object" || g === null) return false;
517
+ const facet = g.facet;
518
+ const queries = g.queries;
519
+ return typeof facet === "string" && facet.trim().length > 0 && isStringArray(queries) && queries.length > 0 && queries.every((q) => q.trim().length > 0);
520
+ });
521
+ }
522
+ function parseResearchBrief(raw) {
523
+ try {
524
+ const cleaned = raw.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
525
+ const parsed = JSON.parse(cleaned);
526
+ const goal_class = typeof parsed.goal_class === "string" ? parsed.goal_class : null;
527
+ if (!goal_class || !VALID_GOAL_CLASSES.has(goal_class)) return null;
528
+ const freshness_window = typeof parsed.freshness_window === "string" ? parsed.freshness_window : null;
529
+ if (!freshness_window || !VALID_FRESHNESS.has(freshness_window)) return null;
530
+ if (typeof parsed.fire_reddit_branch !== "boolean") return null;
531
+ if (!isConceptGroupArray(parsed.concept_groups) || parsed.concept_groups.length === 0) return null;
532
+ return {
533
+ goal_class,
534
+ goal_class_reason: typeof parsed.goal_class_reason === "string" ? parsed.goal_class_reason : "",
535
+ source_priority: isStringArray(parsed.source_priority) ? parsed.source_priority : [],
536
+ sources_to_deprioritize: isStringArray(parsed.sources_to_deprioritize) ? parsed.sources_to_deprioritize : [],
537
+ fire_reddit_branch: parsed.fire_reddit_branch,
538
+ fire_reddit_reason: typeof parsed.fire_reddit_reason === "string" ? parsed.fire_reddit_reason : "",
539
+ freshness_window,
540
+ concept_groups: parsed.concept_groups,
541
+ anticipated_gaps: isStringArray(parsed.anticipated_gaps) ? parsed.anticipated_gaps : [],
542
+ first_scrape_targets: isStringArray(parsed.first_scrape_targets) ? parsed.first_scrape_targets : [],
543
+ success_criteria: isStringArray(parsed.success_criteria) ? parsed.success_criteria : []
544
+ };
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+ async function generateResearchBrief(goal, processor, signal) {
550
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
551
+ const prompt = `You are a research planner. An agent is about to run a multi-pass research loop on the goal below. Produce a tailored research brief in JSON.
552
+
553
+ GOAL: ${goal}
554
+ TODAY: ${today}
555
+
556
+ Return ONLY a JSON object (no markdown, no code fences):
557
+
558
+ {
559
+ "goal_class": "spec | bug | migration | sentiment | pricing | security | synthesis | product_launch | other",
560
+ "goal_class_reason": "one sentence \u2014 why this class",
561
+ "source_priority": ["ordered list from: vendor_docs, changelog, github_code, github_issues, reddit, hackernews, blogs, marketing_pages, stackoverflow, cve_databases, arxiv, news, release_notes"],
562
+ "sources_to_deprioritize": ["same vocabulary \u2014 sources that add noise for this goal"],
563
+ "fire_reddit_branch": true,
564
+ "fire_reddit_reason": "one sentence \u2014 why yes or why no",
565
+ "freshness_window": "days | weeks | months | years",
566
+ "concept_groups": [
567
+ {
568
+ "facet": "2-4 word facet name",
569
+ "queries": ["5-10 concrete Google queries using operators where helpful: site:, quotes, version numbers"]
570
+ }
571
+ ],
572
+ "anticipated_gaps": ["2-5 things likely missing after pass 1 that the agent should watch for"],
573
+ "first_scrape_targets": ["domain names or URL patterns most likely to contain the answer"],
574
+ "success_criteria": ["2-4 concrete facts the agent must verify before declaring done"]
575
+ }
576
+
577
+ Rules:
578
+ - Concept groups must probe DIFFERENT facets. Same noun-phrase cannot repeat across groups.
579
+ - Queries within a group vary by operator/phrasing but probe the same facet.
580
+ - Total queries across all groups: 25\u201350. Narrow bugs fewer; open synthesis more.
581
+ - If the goal mentions a recent release / date / version, freshness_window = days or weeks.
582
+ - Do NOT invent vendor names you are uncertain exist. Leave shaky queries out.
583
+ - source_priority MUST reflect the goal type \u2014 docs for spec, github_issues for bugs, reddit/hackernews/blogs for migration/sentiment, cve_databases for security.
584
+ - fire_reddit_branch should be false for CVE / pricing / API spec / primary-source lookups.`;
585
+ try {
586
+ const response = await requestTextWithFallback(
587
+ processor,
588
+ prompt,
589
+ 2500,
590
+ "Research brief generation",
591
+ signal
592
+ );
593
+ if (!response.content) {
594
+ mcpLog("warning", `Research brief generation returned no content: ${response.error ?? "unknown"}`, "llm");
595
+ markLLMFailure("planner", response.error ?? "empty response");
596
+ return null;
597
+ }
598
+ const brief = parseResearchBrief(response.content);
599
+ if (!brief) {
600
+ mcpLog("warning", "Research brief JSON parse or shape validation failed", "llm");
601
+ markLLMFailure("planner", "brief parse/validation failed");
602
+ return null;
603
+ }
604
+ markLLMSuccess("planner");
605
+ return brief;
606
+ } catch (err) {
607
+ const message = err instanceof Error ? err.message : String(err);
608
+ mcpLog("warning", `Research brief generation failed: ${message}`, "llm");
609
+ markLLMFailure("planner", message);
610
+ return null;
611
+ }
612
+ }
613
+ function renderResearchBrief(brief) {
614
+ const lines = [];
615
+ lines.push("## Your research brief (goal-tailored)");
616
+ lines.push("");
617
+ lines.push(`**Goal class**: \`${brief.goal_class}\` \u2014 ${brief.goal_class_reason}`);
618
+ lines.push(`**Freshness target**: \`${brief.freshness_window}\``);
619
+ lines.push(`**Reddit branch**: ${brief.fire_reddit_branch ? "**fire**" : "skip"} \u2014 ${brief.fire_reddit_reason}`);
620
+ lines.push("");
621
+ if (brief.source_priority.length > 0) {
622
+ lines.push("**Source priority** (highest \u2192 lowest):");
623
+ brief.source_priority.forEach((src, i) => lines.push(`${i + 1}. \`${src}\``));
624
+ lines.push("");
625
+ }
626
+ if (brief.sources_to_deprioritize.length > 0) {
627
+ lines.push(`**Deprioritize**: ${brief.sources_to_deprioritize.map((s) => `\`${s}\``).join(", ")}`);
628
+ lines.push("");
629
+ }
630
+ lines.push("### Pass 1 concept groups");
631
+ lines.push("");
632
+ lines.push("Issue every query below in ONE `web-search` call (flat array).");
633
+ lines.push("");
634
+ for (const group of brief.concept_groups) {
635
+ lines.push(`#### ${group.facet}`);
636
+ for (const query of group.queries) {
637
+ lines.push(`- ${query}`);
638
+ }
639
+ lines.push("");
640
+ }
641
+ if (brief.anticipated_gaps.length > 0) {
642
+ lines.push("### Anticipated gaps (watch the classifier's `gaps[]` output for these)");
643
+ brief.anticipated_gaps.forEach((g) => lines.push(`- ${g}`));
644
+ lines.push("");
645
+ }
646
+ if (brief.first_scrape_targets.length > 0) {
647
+ lines.push("### First-pass scrape targets (prioritize these in `scrape-links`)");
648
+ brief.first_scrape_targets.forEach((t) => lines.push(`- ${t}`));
649
+ lines.push("");
650
+ }
651
+ if (brief.success_criteria.length > 0) {
652
+ lines.push("### Success criteria (do not declare done until all are verified)");
653
+ brief.success_criteria.forEach((c) => lines.push(`- ${c}`));
654
+ lines.push("");
655
+ }
656
+ lines.push("---");
657
+ lines.push("");
658
+ lines.push("Run all concept-group queries above in ONE `web-search` call, then loop per the discipline above.");
659
+ return lines.join("\n");
660
+ }
661
+ export {
662
+ _resetLLMHealthForTests,
663
+ classifySearchResults,
664
+ createLLMProcessor,
665
+ generateResearchBrief,
666
+ getLLMHealth,
667
+ markLLMFailure,
668
+ markLLMSuccess,
669
+ parseResearchBrief,
670
+ processContentWithLLM,
671
+ renderResearchBrief,
672
+ requestTextWithFallback,
673
+ suggestRefineQueriesForRawMode
674
+ };
675
+ //# sourceMappingURL=llm-processor.js.map