crawlforge-mcp-server 5.6.7 → 5.6.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.7",
3
+ "version": "5.6.9",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -116,7 +116,7 @@
116
116
  "cheerio": "^1.1.2",
117
117
  "commander": "^14.0.3",
118
118
  "compromise": "^14.14.4",
119
- "crawlforge-extractors": "^1.6.3",
119
+ "crawlforge-extractors": "^1.6.4",
120
120
  "diff": "^9.0.0",
121
121
  "dotenv": "^17.2.1",
122
122
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -34,6 +34,7 @@ import { LocalizationManager } from "./src/core/LocalizationManager.js";
34
34
  import * as cheerio from "cheerio";
35
35
  import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
36
36
  import { detectChallengePage } from "./src/utils/challengeDetection.js";
37
+ import { stealthDocumentVerdict } from "./src/utils/stealthVerdict.js";
37
38
  import { browserPreflight } from "./src/utils/robotsGate.js";
38
39
  import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
39
40
  import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
@@ -106,7 +107,7 @@ const taskStore = createTaskStore({ logger });
106
107
  // Create the server
107
108
  const server = new McpServer({
108
109
  name: "crawlforge",
109
- version: "5.6.7",
110
+ version: "5.6.9",
110
111
  description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
111
112
  homepage: "https://www.crawlforge.dev",
112
113
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1404,28 +1405,20 @@ registerToolIfEnabled("stealth_mode", {
1404
1405
 
1405
1406
  // A bot-wall interstitial arrives as HTTP 200 with a title and prose
1406
1407
  // of its own; reported as a successful scrape it hid the block for
1407
- // three rounds (R10 Q1 → R15). Name it — the content is still returned
1408
- // so the caller can see what the site served.
1409
- const challenge = detectChallengePage(scraped);
1408
+ // three rounds (R10 Q1 → R15). An HTTP error page and a short
1409
+ // error-titled placeholder did the same in R18. Name each — the
1410
+ // content is still returned so the caller can see what the site served.
1411
+ const verdict = stealthDocumentVerdict(scraped, { waitedMs: (wait_for || 0) + (scraped.gracedMs || 0) });
1410
1412
  result = {
1411
- success: !challenge,
1413
+ success: verdict.success,
1412
1414
  url: scraped.url,
1413
1415
  title: scraped.title,
1416
+ status: verdict.status,
1414
1417
  content: stealthScrapeFormats(formats, scraped)
1415
1418
  };
1416
- if (challenge) {
1417
- result.blocked = challenge;
1418
- result.error = `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`;
1419
- } else if (!scraped.title && !String(scraped.text || '').trim()) {
1420
- // A document with no title and no text is not a scraped page; it
1421
- // is a render that has not happened yet (or an empty response).
1422
- // Reported as success it reads like a page with nothing on it.
1423
- result.success = false;
1424
- result.error =
1425
- `The stealth browser reached ${scraped.url} but the document rendered no title and no text` +
1426
- ` after ${wait_for || 0}ms of extra wait (${(scraped.html || '').length} bytes of HTML).` +
1427
- ' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.';
1428
- }
1419
+ if (verdict.blocked) result.blocked = verdict.blocked;
1420
+ if (verdict.error) result.error = verdict.error;
1421
+ if (scraped.gracedMs > 0) result.waited_for_render_ms = scraped.gracedMs;
1429
1422
 
1430
1423
  // Screenshots follow the same crawlforge://screenshot/{id} pattern as
1431
1424
  // scrape and scrape_with_actions, so the base64 never bloats the result.
@@ -381,6 +381,7 @@ export class ActionExecutor extends EventEmitter {
381
381
  url,
382
382
  finalUrl: executionContext.finalUrl || url,
383
383
  finalHtml: executionContext.finalHtml,
384
+ navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
384
385
  executionTime: Date.now() - startTime,
385
386
  results: executionContext.results,
386
387
  screenshots: executionContext.screenshots,
@@ -1171,7 +1172,7 @@ export class ActionExecutor extends EventEmitter {
1171
1172
  // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
1172
1173
  await assertUrlAllowed(url, { resolveDns: true });
1173
1174
 
1174
- await page.goto(url, {
1175
+ const response = await page.goto(url, {
1175
1176
  waitUntil: options.waitUntil || 'domcontentloaded',
1176
1177
  timeout: options.timeout || 30000
1177
1178
  });
@@ -1182,6 +1183,14 @@ export class ActionExecutor extends EventEmitter {
1182
1183
  if (/^https?:\/\//i.test(landedUrl)) {
1183
1184
  await assertUrlAllowed(landedUrl, { resolveDns: true });
1184
1185
  }
1186
+
1187
+ // Remembered on the page so the chain result can report the status of
1188
+ // the last navigation: tesla.com's Akamai denial ran a full action chain
1189
+ // and came back success:true (R18, 2026-09-04).
1190
+ let status = null;
1191
+ try { status = response ? response.status() : null; } catch { status = null; }
1192
+ page.__crawlforgeNavigation = { url: landedUrl, status };
1193
+ return { url: landedUrl, status };
1185
1194
  }
1186
1195
 
1187
1196
  /**
@@ -34,7 +34,11 @@ const DEFAULT_MAX_URLS = 10;
34
34
  function isRelevant(text, query) {
35
35
  if (!text || !query) return true; // fail-open
36
36
  const lc = text.toLowerCase();
37
- return query.toLowerCase().split(/\s+/).some(term => term.length > 3 && lc.includes(term));
37
+ // Terms are compared bare: the quoted entity in `npm package "commander"?`
38
+ // arrived as `"commander"?` and matched nothing (R17, 2026-09-04).
39
+ return query.toLowerCase().split(/\s+/)
40
+ .map(term => term.replace(/[^\p{L}\p{N}]/gu, ''))
41
+ .some(term => term.length > 3 && lc.includes(term));
38
42
  }
39
43
 
40
44
  /**
@@ -45,6 +49,46 @@ function truncate(text, maxChars = 8000) {
45
49
  return text.slice(0, maxChars) + '\n[...truncated]';
46
50
  }
47
51
 
52
+ /**
53
+ * Literal provenance check: every version, date and large number in the
54
+ * answer must appear somewhere in the fetched source text. Returns the raw
55
+ * strings that do not (deduplicated, in answer order). Comparison is
56
+ * case-insensitive with whitespace and thousands separators removed, so
57
+ * "6.3.3" matches "6.3.3" and "1,234" matches "1 234". URLs in the answer are
58
+ * ignored: they are citations, not claims.
59
+ */
60
+ const MONTHS = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*';
61
+ const VALUE_PATTERNS = [
62
+ /\b\d+\.\d+(?:\.\d+){0,2}\b/g, // versions 1.2 / 1.2.3 / 1.2.3.4
63
+ /\b\d{4}-\d{2}-\d{2}\b/g, // ISO dates
64
+ new RegExp(`\\b${MONTHS}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+\\d{4}\\b`, 'gi'), // January 18, 2024
65
+ new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTHS}\\.?\\s+\\d{4}\\b`, 'gi'), // 18 January 2024
66
+ /\b\d{1,3}(?:,\d{3})+\b|\b\d{4,}\b/g, // 1,234 / 12345
67
+ ];
68
+
69
+ function normalizeValue(value) {
70
+ return value.toLowerCase().replace(/[\s,]/g, '');
71
+ }
72
+
73
+ export function unverifiedValues(answer, sourceText) {
74
+ if (!answer) return [];
75
+ const stripped = answer.replace(/https?:\/\/\S+/g, ' ');
76
+ const haystack = normalizeValue(sourceText || '');
77
+ const missing = [];
78
+ const seen = new Set();
79
+ for (const pattern of VALUE_PATTERNS) {
80
+ for (const match of stripped.matchAll(pattern)) {
81
+ const raw = match[0];
82
+ const key = normalizeValue(raw);
83
+ if (!key || seen.has(key)) continue;
84
+ seen.add(key);
85
+ if (!haystack.includes(key)) missing.push(raw);
86
+ }
87
+ }
88
+ // "2024" inside an already-flagged "January 18, 2024" is the same claim.
89
+ return missing.filter((v, i) => !missing.some((o, j) => j !== i && o.length > v.length && o.includes(v)));
90
+ }
91
+
48
92
  /**
49
93
  * Current-state task gate: prompts about the live "now" ("right now",
50
94
  * "currently", "today", "latest", "#1 … now") go stale through search alone —
@@ -222,6 +266,17 @@ export class AgentOrchestrator {
222
266
  } catch {
223
267
  // Sampling unavailable — use raw prompt
224
268
  }
269
+ // A quoted term in the prompt ("commander") is the thing being asked
270
+ // about. A planner told to search the bare entity reduced
271
+ // 'npm package "commander"' to "npm", fetched only npm's own docs and
272
+ // answered from memory (R17, 2026-09-04). Every query carries the quoted
273
+ // terms.
274
+ const quotedTerms = [...prompt.matchAll(/"([^"\n]{2,60})"/g)].map(m => m[1].trim()).filter(Boolean);
275
+ if (quotedTerms.length > 0) {
276
+ searchQueries = searchQueries.map(q =>
277
+ quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
278
+ );
279
+ }
225
280
 
226
281
  // ── GATHER (search) ───────────────────────────────────────────────────────
227
282
  const urlQueue = [...seedUrls]; // start with any user-provided seeds
@@ -238,6 +293,8 @@ export class AgentOrchestrator {
238
293
  if (!priorityUrls.includes(url)) priorityUrls.push(url);
239
294
  }
240
295
  const searchResults = [];
296
+ /** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
297
+ const excerpts = new Map();
241
298
 
242
299
  if (urlQueue.length < capUrls) {
243
300
  try {
@@ -253,6 +310,11 @@ export class AgentOrchestrator {
253
310
  for (const r of parsed.results) {
254
311
  if (r.link && !urlQueue.includes(r.link)) urlQueue.push(r.link);
255
312
  searchResults.push({ query: q, title: r.title || '', url: r.link || '', snippet: r.snippet || '' });
313
+ // What the engine saw of the page: the snippet plus the
314
+ // page's own meta description, which for npmjs.com carries
315
+ // "Latest version: 15.0.0" while the snippet does not.
316
+ const excerpt = [r.snippet, r.pagemap?.metatags?.description].filter(Boolean).join(' ');
317
+ if (r.link && excerpt && !excerpts.has(r.link)) excerpts.set(r.link, { title: r.title || '', text: excerpt });
256
318
  }
257
319
  }
258
320
  } catch { /* skip failed search */ }
@@ -318,7 +380,17 @@ export class AgentOrchestrator {
318
380
  text: truncate(textContent),
319
381
  step
320
382
  });
321
- } catch { /* skip unreachable URL */ }
383
+ } catch {
384
+ // The page is out of reach (challenge page, 403, timeout) but the
385
+ // search engine's excerpt of it is not: npmjs.com's "Latest version:
386
+ // 15.0.0" sat in the snippet while every fetch of the package page was
387
+ // challenged, and the run ended with no evidence at all (R17,
388
+ // 2026-09-04). Keep a relevant snippet as evidence, labelled as one.
389
+ const excerpt = excerpts.get(url);
390
+ if (excerpt && isRelevant(excerpt.text, prompt)) {
391
+ evidence.push({ url, title: excerpt.title, text: excerpt.text, snippet: true, step });
392
+ }
393
+ }
322
394
  }
323
395
 
324
396
  // ── SHAPE ─────────────────────────────────────────────────────────────────
@@ -340,7 +412,7 @@ export class AgentOrchestrator {
340
412
  .sort((a, b) => b._score - a._score);
341
413
  const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
342
414
  const combinedText = orderedEvidence
343
- .map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
415
+ .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${truncate(e.text, perSourceCap)}`)
344
416
  .join('\n\n');
345
417
 
346
418
  if (!combinedText.trim()) {
@@ -386,6 +458,7 @@ export class AgentOrchestrator {
386
458
  let answer = null;
387
459
  let degraded = false;
388
460
  let degradedReason;
461
+ let unverified = [];
389
462
 
390
463
  try {
391
464
  // Wording matters for small local models (llama3.2-class): without the
@@ -414,6 +487,34 @@ export class AgentOrchestrator {
414
487
 
415
488
  const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
416
489
  answer = text;
490
+
491
+ // A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
492
+ // published January 18, 2024" — none of it on any fetched page (R17,
493
+ // 2026-09-04). Versions, dates and counts in the answer must appear in
494
+ // the source text; what does not gets one corrective rewrite, and
495
+ // whatever still remains is named in the answer and in `provenance`.
496
+ unverified = unverifiedValues(answer, combinedText);
497
+ if (unverified.length > 0) {
498
+ const retryPrompt =
499
+ `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
500
+ `It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
501
+ `Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
502
+ `Where the sources do not state a value, say that it is not stated instead of guessing.`;
503
+ try {
504
+ const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
505
+ if (rewritten && rewritten.trim()) {
506
+ answer = rewritten;
507
+ unverified = unverifiedValues(answer, combinedText);
508
+ }
509
+ } catch {
510
+ // Keep the first answer; it is flagged below.
511
+ }
512
+ }
513
+ if (unverified.length > 0) {
514
+ answer +=
515
+ `\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
516
+ `${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
517
+ }
417
518
  } catch (err) {
418
519
  degraded = true;
419
520
  degradedReason = `LLM synthesis unavailable: ${err.message}`;
@@ -425,11 +526,12 @@ export class AgentOrchestrator {
425
526
  success: true,
426
527
  answer,
427
528
  search_results: searchResults,
428
- evidence: degraded ? evidence : evidence.map(e => ({ url: e.url })),
529
+ evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : { url: e.url })),
429
530
  degraded,
430
531
  reason: degradedReason,
431
532
  steps: step,
432
- urls_fetched: urlsFetched
533
+ urls_fetched: urlsFetched,
534
+ provenance: { checked: !degraded, unverified }
433
535
  };
434
536
  }
435
537
 
@@ -13,6 +13,7 @@ import { load } from 'cheerio';
13
13
  import { diffWords, diffLines, diffChars } from 'diff';
14
14
  import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
15
15
  import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
16
+ import { pageTitle } from '../utils/pageTitle.js';
16
17
 
17
18
  const ChangeTrackingSchema = z.object({
18
19
  url: z.string().url(),
@@ -892,7 +893,7 @@ export class ChangeTracker extends EventEmitter {
892
893
 
893
894
  extractMetadata($, options) {
894
895
  const metadata = {
895
- title: $('title').text() || '',
896
+ title: pageTitle($) || '',
896
897
  headings: [],
897
898
  links: [],
898
899
  images: [],
@@ -9,6 +9,7 @@ import { safeFetch } from '../utils/ssrfGuard.js';
9
9
  import { resolveUserAgent } from '../utils/fetchIdentity.js';
10
10
  import { preflightFetch } from '../utils/robotsGate.js';
11
11
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
12
+ import { pageTitle } from '../utils/pageTitle.js';
12
13
 
13
14
  const logger = new Logger('LLMsTxtAnalyzer');
14
15
 
@@ -722,7 +723,7 @@ export class LLMsTxtAnalyzer {
722
723
  type: 'static',
723
724
  confidence: 0.7,
724
725
  metadata: {
725
- title: $('title').text().trim(),
726
+ title: pageTitle($),
726
727
  contentLength: html.length
727
728
  }
728
729
  };
@@ -262,7 +262,7 @@ export class LocalizationManager extends EventEmitter {
262
262
  timezone: validatedInput.timezone || countryData.timezone,
263
263
  currency: validatedInput.currency || countryData.currency,
264
264
  searchDomain: countryData.searchDomain,
265
- acceptLanguage: this.buildAcceptLanguageHeader(validatedInput.language || countryData.language),
265
+ acceptLanguage: this.buildAcceptLanguageHeader(validatedInput.language || countryData.language, validatedInput.countryCode),
266
266
  customHeaders: validatedInput.customHeaders || {},
267
267
  geoLocation: validatedInput.geoLocation,
268
268
  proxySettings: validatedInput.proxySettings,
@@ -601,8 +601,18 @@ export class LocalizationManager extends EventEmitter {
601
601
  return await this.configureCountry(countryCode);
602
602
  }
603
603
 
604
- buildAcceptLanguageHeader(language) {
605
- const langCode = language.split('-')[0];
604
+ /**
605
+ * The header a browser in `countryCode` sends for `language`. A bare
606
+ * language takes the country as its region — configure_country GB used to
607
+ * answer en-US (R18, 2026-09-04) because the country never reached here.
608
+ */
609
+ buildAcceptLanguageHeader(language, countryCode) {
610
+ const [langCode, region] = String(language).split('-');
611
+ const cc = region || (countryCode ? String(countryCode).toUpperCase() : null);
612
+ if (cc) {
613
+ const tag = `${langCode}-${cc}`;
614
+ return langCode === 'en' ? `${tag},en;q=0.9` : `${tag},${langCode};q=0.9,en;q=0.8`;
615
+ }
606
616
  return LANGUAGE_MAPPINGS[langCode] || `${language},${langCode};q=0.9,en;q=0.8`;
607
617
  }
608
618
 
@@ -679,11 +689,15 @@ export class LocalizationManager extends EventEmitter {
679
689
  return this.timezoneCache.get(timezone);
680
690
  }
681
691
 
682
- // Calculate dynamically if not cached
692
+ // Calculate dynamically if not cached. Compare the target zone against
693
+ // UTC, never against the host zone: the old form added the host offset
694
+ // once more, so America/Sao_Paulo came out as -420 from a UTC-4 host
695
+ // (R17, 2026-09-04). Both wall-clock strings are parsed the same way, so
696
+ // the difference is the zone's current offset in minutes.
683
697
  const now = new Date();
684
- const utc = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
685
- const targetTime = new Date(utc.toLocaleString('en-US', { timeZone: timezone }));
686
- const offset = (targetTime.getTime() - utc.getTime()) / (1000 * 60);
698
+ const asTarget = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
699
+ const asUtc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
700
+ const offset = Math.round((asTarget.getTime() - asUtc.getTime()) / 60000);
687
701
 
688
702
  this.timezoneCache.set(timezone, offset);
689
703
  return offset;
@@ -1344,10 +1358,13 @@ export class LocalizationManager extends EventEmitter {
1344
1358
  }
1345
1359
 
1346
1360
  // CJK script detection
1361
+ // Kana and Hangul are tested before the unified ideographs: Japanese
1362
+ // text is full of kanji, so a chinese-first order labelled every
1363
+ // Japanese page "chinese" (R17, 2026-09-04).
1347
1364
  const cjkPatterns = {
1348
- chinese: /[\u4E00-\u9FFF]/,
1349
1365
  japanese: /[\u3040-\u309F\u30A0-\u30FF]/,
1350
- korean: /[\uAC00-\uD7AF]/
1366
+ korean: /[\uAC00-\uD7AF]/,
1367
+ chinese: /[\u4E00-\u9FFF]/
1351
1368
  };
1352
1369
 
1353
1370
  for (const [script, pattern] of Object.entries(cjkPatterns)) {
@@ -79,6 +79,41 @@ const MAX_CONFLICT_PAIRS = 40;
79
79
  * - Advanced provenance tracking and activity logging
80
80
  * - Smart URL prioritization based on content quality
81
81
  */
82
+ // A research topic is often a paragraph ("Why does X get stuck on Y and what
83
+ // makes Z pass it in 2026: A, B, C, D and known working approaches"). Google
84
+ // answers a 40-word query with nothing: the same run found 1 source from the
85
+ // paragraph and 8 from a six-word restatement (R18, 2026-09-04). The full
86
+ // topic still steers the LLM expansion and the relevance ranking; the search
87
+ // backend gets a keyword-dense form of it.
88
+ export const SEARCH_TOPIC_MAX_WORDS = 12;
89
+ export const SEARCH_QUERY_MAX_WORDS = 16;
90
+ const TOPIC_STOPWORDS = new Set([
91
+ 'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for', 'with', 'from', 'by', 'as', 'at', 'into',
92
+ 'what', 'why', 'how', 'when', 'where', 'which', 'who', 'whom', 'whose', 'that', 'this', 'these', 'those',
93
+ 'does', 'do', 'did', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'their', 'there', 'they',
94
+ 'can', 'could', 'should', 'would', 'will', 'may', 'might', 'get', 'gets', 'make', 'makes', 'made', 'about',
95
+ 'known', 'required', 'versus', 'vs', 'i', 'we', 'you', 'my', 'our', 'your', 'me', 'us', 'them', 'not', 'no', 'so'
96
+ ]);
97
+
98
+ export function compactSearchTopic(topic) {
99
+ const clean = String(topic || '').replace(/\s+/g, ' ').trim();
100
+ const words = clean.split(' ').filter(Boolean);
101
+ if (words.length <= SEARCH_TOPIC_MAX_WORDS) return clean;
102
+ const bare = clean.replace(/[“”"'`()[\]{}:;,?!.]/g, ' ').split(/\s+/).filter(Boolean);
103
+ const kept = [];
104
+ for (const word of bare) {
105
+ if (TOPIC_STOPWORDS.has(word.toLowerCase())) continue;
106
+ kept.push(word);
107
+ if (kept.length >= SEARCH_TOPIC_MAX_WORDS) break;
108
+ }
109
+ return (kept.length > 0 ? kept : bare.slice(0, SEARCH_TOPIC_MAX_WORDS)).join(' ');
110
+ }
111
+
112
+ export function clampSearchQuery(query, maxWords = SEARCH_QUERY_MAX_WORDS) {
113
+ const words = String(query || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
114
+ return (words.length > maxWords ? words.slice(0, maxWords) : words).join(' ');
115
+ }
116
+
82
117
  export class ResearchOrchestrator extends EventEmitter {
83
118
  constructor(options = {}) {
84
119
  super();
@@ -374,9 +409,17 @@ export class ResearchOrchestrator extends EventEmitter {
374
409
  });
375
410
  }
376
411
 
377
- // Add research-specific query variations
378
- const researchVariations = this.generateResearchVariations(topic);
379
- const allQueries = [...new Set([topic, ...expandedQueries, ...researchVariations])];
412
+ // Add research-specific query variations. The search backend sees the
413
+ // keyword-dense form of a long topic; every query is clamped to what a
414
+ // search engine answers.
415
+ const searchTopic = compactSearchTopic(topic);
416
+ if (searchTopic !== topic) {
417
+ this.logger.info('Long research topic compacted for search', { topic, searchTopic });
418
+ }
419
+ const researchVariations = this.generateResearchVariations(searchTopic);
420
+ const allQueries = [...new Set(
421
+ [searchTopic, ...expandedQueries, ...researchVariations].map((q) => clampSearchQuery(q)).filter(Boolean)
422
+ )];
380
423
 
381
424
  // Rank queries by research relevance with semantic understanding
382
425
  const rankedQueries = await this.rankResearchQueriesWithSemantics(allQueries, topic);
@@ -390,7 +433,7 @@ export class ResearchOrchestrator extends EventEmitter {
390
433
  } catch (error) {
391
434
  this.logger.warn('Topic expansion failed, using original topic', { error: error.message });
392
435
  this.metrics.queryExpansionTime += Date.now() - startTime;
393
- return [topic];
436
+ return [compactSearchTopic(topic)];
394
437
  }
395
438
  }
396
439