crawlforge-mcp-server 6.21.1 → 6.22.1

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 (52) hide show
  1. package/package.json +3 -2
  2. package/server.js +12 -12
  3. package/src/core/ActionExecutor.js +23 -4
  4. package/src/core/AgentOrchestrator.js +228 -21
  5. package/src/core/AuthManager.js +4 -1
  6. package/src/core/LLMsTxtAnalyzer.js +6 -7
  7. package/src/core/LocalizationManager.js +29 -4
  8. package/src/core/ResearchOrchestrator.js +76 -13
  9. package/src/core/SamplingClient.js +38 -8
  10. package/src/core/SnapshotManager.js +32 -13
  11. package/src/core/analysis/ContentAnalyzer.js +26 -25
  12. package/src/core/crawlers/BFSCrawler.js +48 -10
  13. package/src/core/processing/ContentProcessor.js +4 -2
  14. package/src/core/queue/QueueManager.js +16 -2
  15. package/src/resources/ResourceRegistry.js +19 -5
  16. package/src/schemas/toolOutputSchemas.js +22 -14
  17. package/src/server/fallbackHints.js +37 -7
  18. package/src/server/withAuth.js +27 -44
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  22. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
  26. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  28. package/src/tools/advanced/BrowserSessionTool.js +11 -2
  29. package/src/tools/advanced/batchScrape/queue.js +4 -2
  30. package/src/tools/advanced/batchScrape/worker.js +50 -11
  31. package/src/tools/agent/agent.js +1 -1
  32. package/src/tools/basic/_fetch.js +2 -1
  33. package/src/tools/basic/extractMetadata.js +2 -1
  34. package/src/tools/basic/extractText.js +12 -8
  35. package/src/tools/crawl/mapSite.js +8 -0
  36. package/src/tools/extract/extractContent.js +2 -1
  37. package/src/tools/extract/extractStructured.js +4 -2
  38. package/src/tools/extract/extractWithLlm.js +8 -4
  39. package/src/tools/extract/processDocument.js +29 -1
  40. package/src/tools/extract/summarizeContent.js +5 -1
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
  42. package/src/tools/scrape/_mainContent.js +54 -2
  43. package/src/tools/scrape/unifiedScrape.js +94 -6
  44. package/src/tools/search/providers/searxng.js +20 -3
  45. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  46. package/src/tools/tracking/trackChanges/differ.js +2 -1
  47. package/src/tools/tracking/trackChanges/index.js +6 -1
  48. package/src/tools/tracking/trackChanges/schema.js +15 -1
  49. package/src/utils/fetchError.js +47 -0
  50. package/src/utils/fetchLadder.js +5 -5
  51. package/src/utils/htmlToMarkdown.js +36 -2
  52. package/src/utils/ssrfGuard.js +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.21.1",
3
+ "version": "6.22.1",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 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",
@@ -121,7 +121,7 @@
121
121
  "cheerio": "^1.1.2",
122
122
  "commander": "^14.0.3",
123
123
  "compromise": "^14.14.4",
124
- "crawlforge-extractors": "^1.17.0",
124
+ "crawlforge-extractors": "^1.18.1",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
@@ -132,6 +132,7 @@
132
132
  "pdf-parse": "2.4.5",
133
133
  "playwright": "^1.54.2",
134
134
  "robots-parser": "^3.0.1",
135
+ "sentiment": "5.0.2",
135
136
  "turndown": "^7.2.4",
136
137
  "turndown-plugin-gfm": "^1.0.2",
137
138
  "undici": "^7.24.0",
package/server.js CHANGED
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
113
113
  // Create the server
114
114
  const server = new McpServer({
115
115
  name: "crawlforge",
116
- version: "6.21.1",
116
+ version: "6.22.1",
117
117
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, 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.",
118
118
  homepage: "https://www.crawlforge.dev",
119
119
  icon: "https://www.crawlforge.dev/icon.png",
@@ -572,10 +572,10 @@ registerToolIfEnabled("search_web", {
572
572
  try {
573
573
  if (!query && !queries) {
574
574
  // `query` stopped being a required field when `queries` arrived, so the
575
- // SDK no longer rejects this call for us — it reaches the handler, and
576
- // without this it would be billed the half-credit error rate for a
577
- // search that never ran. Same reasoning as ScrapeTemplateTool's
578
- // badRequest: we fetched nothing, so it costs nothing (G4).
575
+ // SDK no longer rejects this call for us — it reaches the handler, which
576
+ // refuses it before any search runs. Same reasoning as
577
+ // ScrapeTemplateTool's badRequest: we fetched nothing, so it costs
578
+ // nothing (G4).
579
579
  markPreflightRefusal('BAD_REQUEST');
580
580
  return { content: [{ type: "text", text: EXACTLY_ONE_QUERY_MESSAGE }], isError: true };
581
581
  }
@@ -1258,8 +1258,8 @@ registerToolIfEnabled("scrape", {
1258
1258
  return shot;
1259
1259
  });
1260
1260
  }
1261
- // A blocked verdict (0.1) is an error result: withAuth bills it at the
1262
- // error rate and appends the hint naming stealth_mode, and the SDK skips
1261
+ // A blocked verdict (0.1) is an error result: withAuth charges it nothing
1262
+ // and appends the hint naming stealth_mode, and the SDK skips
1263
1263
  // outputSchema validation on isError. The JSON body still carries the
1264
1264
  // verdict (success:false, error, blocked) the way stealth_mode's does.
1265
1265
  if (result.success === false) return { ...dualOutput(result), isError: true };
@@ -1274,7 +1274,7 @@ registerToolIfEnabled("agent", {
1274
1274
  description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web - no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps<=10, maxUrls<=20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Not for a URL you already have (scrape) or a question one search answers (search_web). Pages that block a plain fetch are retried in the stealth browser automatically (at most 2 a run, URLs you name first; evidence marked via:\"stealth\"). Cost: 18 credits at most - 8, plus 5 per stealth retry that gets the page; a retry that is blocked again is free. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1275
1275
  annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1276
1276
  inputSchema: {
1277
- prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
1277
+ prompt: z.string().trim().min(1).max(2000).describe("Natural-language task or question"),
1278
1278
  urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
1279
1279
  schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
1280
1280
  model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
@@ -1335,7 +1335,7 @@ registerToolIfEnabled("generate_llms_txt", {
1335
1335
  customRestrictions: z.array(z.string()).optional(),
1336
1336
  robotsStyle: z.boolean().optional().default(false)
1337
1337
  }).optional().describe("Output customization and organization details"),
1338
- complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("Compliance level for generated guidelines"),
1338
+ complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("Applies only with outputOptions.robotsStyle:true, where it sets how many paths the llms.txt Disallow list covers (basic < standard < strict). The default llmstxt.org markdown is the same at every level; llms-full.txt only records the level as a label."),
1339
1339
  format: z.enum(['both', 'llms-txt', 'llms-full-txt']).optional().default('both').describe("Output format: llms.txt, llms-full.txt, or both"),
1340
1340
  ...MAX_INLINE_CHARS_PARAM
1341
1341
  }
@@ -1423,10 +1423,10 @@ function stealthScrapeFormats(formats, scraped) {
1423
1423
 
1424
1424
  // Tool: stealth_mode
1425
1425
  registerToolIfEnabled("stealth_mode", {
1426
- description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. The create_context -> create_page -> cleanup operations remain for multi-step work. operation:\"configure\" only validates a stealthConfig and returns it with the defaults filled in - it stores nothing, so pass stealthConfig on each scrape or create_context call that should use it. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
1426
+ description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. create_context then create_page is a navigation and challenge check: create_page loads urlToTest, reports its status, title and any challenge page, and closes the page before the call returns, so nothing on it carries over to a later call. For work over several calls on one page (log in, then read; click, then read), use browser_session with stealth:true. operation:\"configure\" only validates a stealthConfig and returns it with the defaults filled in - it stores nothing, so pass stealthConfig on each scrape or create_context call that should use it. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
1427
1427
  annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1428
1428
  inputSchema: {
1429
- operation: z.enum(['scrape', 'configure', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform. scrape: render one URL and return the formats. configure: validate stealthConfig and return it with defaults filled in; nothing is stored. create_context / create_page: a context to open pages in. get_stats: this server process's open contexts, browser and proxy state. cleanup: close the browser and every context."),
1429
+ operation: z.enum(['scrape', 'configure', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform. scrape: render one URL and return the formats. configure: validate stealthConfig and return it with defaults filled in; nothing is stored. create_context: a stealth context to run create_page in. create_page: navigate urlToTest in that context and report status, title and any challenge page; the page is closed before the call returns. get_stats: this server process's open contexts, browser and proxy state. cleanup: close the browser and every context."),
1430
1430
  stealthConfig: z.object({
1431
1431
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
1432
1432
  randomizeFingerprint: z.boolean().default(true),
@@ -1467,7 +1467,7 @@ registerToolIfEnabled("stealth_mode", {
1467
1467
  }).optional().describe("Stealth browser configuration with anti-detection settings. Applies to the call it is passed on and is not remembered. Chromium applies customUserAgent, customViewport, locale and timezone per call. On camoufox customUserAgent and customViewport are not applied, locale is fixed when the browser launches (run operation:\"cleanup\" first to change it), and behind a proxy locale and timezone come from the exit IP; each of these is reported in `warnings`."),
1468
1468
  engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).optional().default("auto").describe("Browser engine: \"auto\" (default — camoufox when it is installed, Chromium otherwise, and the result says which), \"camoufox\" (Firefox-based, higher anti-detect score; fails if not installed), or \"chromium\" (\"playwright\" is the same engine under its old name)"),
1469
1469
  contextId: z.string().optional().describe("Browser context ID for page operations"),
1470
- urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page"),
1470
+ urlToTest: z.string().url().optional().describe("create_page: the URL to navigate to and check"),
1471
1471
  url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
1472
1472
  formats: z.array(z.enum(["markdown", "html", "text", "links", "metadata", "screenshot"])).optional().default(["markdown"]).describe("Formats to return from operation:\"scrape\" (default: [\"markdown\"]). \"screenshot\" returns a crawlforge://screenshot/{id} resource URI."),
1473
1473
  wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
@@ -51,6 +51,17 @@ const SELECTOR_WAIT_STATES = new Set(['attached', 'detached', 'visible', 'hidden
51
51
 
52
52
  const withoutHash = (url) => url.split('#')[0];
53
53
 
54
+ /**
55
+ * A failed action's message, naming its selector. Playwright names it only in
56
+ * the "Call log:", which is stripped before the model reads the error (R25 3.2).
57
+ */
58
+ function actionErrorMessage(action, error) {
59
+ const firstLine = String(error.message).split('\n')[0];
60
+ return action.selector && !firstLine.includes(action.selector)
61
+ ? `${action.type} on selector "${action.selector}": ${error.message}`
62
+ : error.message;
63
+ }
64
+
54
65
  // Action schemas
55
66
  const BaseActionSchema = z.object({
56
67
  type: z.string(),
@@ -854,6 +865,11 @@ export class ActionExecutor extends EventEmitter {
854
865
  action.timeout) {
855
866
  timeout = Math.max(this.defaultTimeout, action.timeout + 5000);
856
867
  }
868
+ // A pause before a selector/text wait (executeWaitAction) runs on top of
869
+ // that wait's deadline.
870
+ if (action.type === 'wait' && (action.selector || action.text)) {
871
+ timeout += action.duration || action.milliseconds || 0;
872
+ }
857
873
 
858
874
  // Execute based on action type. Playwright owns the real deadline (every
859
875
  // call below is given `timeout`), so this race is only a backstop for a
@@ -902,7 +918,7 @@ export class ActionExecutor extends EventEmitter {
902
918
  id: actionId,
903
919
  type: action.type,
904
920
  success: false,
905
- error: error.message,
921
+ error: actionErrorMessage(action, error),
906
922
  // Only when the gate refused (a `navigate` to a URL it would not
907
923
  // load), so the chain can tell an answer from a fault.
908
924
  ...(refusalCode ? { errorCode: refusalCode } : {}),
@@ -1094,14 +1110,17 @@ export class ActionExecutor extends EventEmitter {
1094
1110
  (!action.selector && !action.text ? action.timeout : undefined);
1095
1111
  if (waitTime) {
1096
1112
  await this.delay(waitTime);
1097
- return { waited: waitTime };
1113
+ // A pause given with a selector or text waits for that too, after the
1114
+ // pause; the selector and condition used to be dropped (R25 3.3).
1115
+ if (!action.selector && !action.text) return { waited: waitTime };
1098
1116
  }
1117
+ const waited = waitTime ? { waited: waitTime } : {};
1099
1118
 
1100
1119
  const timeout = this.actionTimeout(action);
1101
1120
 
1102
1121
  if (action.selector) {
1103
1122
  await this.waitForCondition(page, action.selector, action.condition, timeout);
1104
- return { selector: action.selector, condition: action.condition };
1123
+ return { ...waited, selector: action.selector, condition: action.condition };
1105
1124
  }
1106
1125
 
1107
1126
  if (action.text) {
@@ -1110,7 +1129,7 @@ export class ActionExecutor extends EventEmitter {
1110
1129
  action.text,
1111
1130
  { timeout }
1112
1131
  );
1113
- return { text: action.text };
1132
+ return { ...waited, text: action.text };
1114
1133
  }
1115
1134
 
1116
1135
  throw new Error('Wait action requires duration/milliseconds/timeout, selector, or text');
@@ -12,12 +12,15 @@
12
12
  * Hard stops (enforced here, not by the LLM):
13
13
  * 1. maxSteps iterations of the ACT loop
14
14
  * 2. maxUrls total URLs fetched
15
- * 3. wallClockMs wall-clock milliseconds (default 120 000)
15
+ * 3. wallClockMs wall-clock milliseconds (default 120 000); every LLM call
16
+ * gets only the time left, and a run that reaches it returns degraded
17
+ * with the evidence gathered so far
16
18
  *
17
19
  * No-LLM-key path: if all LLM calls fail, return collected evidence + {degraded:true}.
18
20
  * pro model: delegates to ResearchOrchestrator.conductResearch() for richer synthesis.
19
21
  */
20
22
 
23
+ import { load } from 'cheerio';
21
24
  import { fetchAndParse } from '../tools/extract/_fetchAndParse.js';
22
25
  import { SamplingClient } from './SamplingClient.js';
23
26
  import { fenceUntrusted } from '../utils/untrustedContent.js';
@@ -31,6 +34,12 @@ const DEFAULT_MAX_URLS = 10;
31
34
  // A stealth retry launches a browser and waits out a challenge; one started
32
35
  // with less than this left on the wall clock would overrun the hard stop.
33
36
  const ESCALATION_MIN_REMAINING_MS = 20_000;
37
+ // PLAN is optional (the raw prompt is the fallback query), so a slow model
38
+ // may spend at most this share of the wall clock on it, never all of it.
39
+ const PLAN_MAX_SHARE = 0.25;
40
+ // A corrective rewrite is a whole synthesis call; with less than this left it
41
+ // would run into the hard stop, so the first answer is kept and flagged.
42
+ const REWRITE_MIN_REMAINING_MS = 15_000;
34
43
 
35
44
  /** A refusal (robots.txt, blocklist) is a decision, not a wall — a browser must not retry it. */
36
45
  function isRefusal(err) {
@@ -67,7 +76,7 @@ function truncate(text, maxChars = 8000) {
67
76
  * strings that do not (deduplicated, in answer order). Comparison is
68
77
  * case-insensitive with whitespace and thousands separators removed, so
69
78
  * "6.3.3" matches "6.3.3" and "1,234" matches "1 234". URLs in the answer are
70
- * ignored: they are citations, not claims.
79
+ * skipped here: they are citations, checked by unverifiedUrls.
71
80
  */
72
81
  const MONTHS = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*';
73
82
  const VALUE_PATTERNS = [
@@ -101,16 +110,81 @@ export function unverifiedValues(answer, sourceText) {
101
110
  return missing.filter((v, i) => !missing.some((o, j) => j !== i && o.length > v.length && o.includes(v)));
102
111
  }
103
112
 
113
+ /**
114
+ * The http(s) URLs in a text, trailing punctuation removed. A closing
115
+ * parenthesis stays only when it closes one opened inside the URL, so
116
+ * "[HN](https://x/item?id=1)" yields the link and "/wiki/Foo_(bar)" survives.
117
+ */
118
+ export function urlsIn(text) {
119
+ return (String(text || '').match(/https?:\/\/[^\s<>"'`\]]+/g) || []).map(url => {
120
+ for (;;) {
121
+ if (/[.,;:!?*'"]$/.test(url)) url = url.slice(0, -1);
122
+ else if (url.endsWith(')') && url.split('(').length < url.split(')').length) url = url.slice(0, -1);
123
+ else return url;
124
+ }
125
+ });
126
+ }
127
+
128
+ /**
129
+ * A URL as a citation is compared: scheme, "www.", fragment and trailing
130
+ * slash dropped, host lowercased, "&amp;" decoded. Null when it does not parse.
131
+ */
132
+ function citationKey(url) {
133
+ try {
134
+ const u = new URL(String(url).replace(/&amp;/g, '&'));
135
+ return `${u.host.toLowerCase().replace(/^www\./, '')}${u.pathname.replace(/\/+$/, '')}${u.search}`;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * URLs the answer cites that no fetched page supports: not a fetched URL
143
+ * (before or after redirects), not a link on a fetched page, and not written
144
+ * in the source text. The R25 re-check (2026-10-19) answered the current HN
145
+ * top story with the right title and a link to a 2021 story that none of the
146
+ * sources contained, and the value-only check above skipped it as a citation.
147
+ *
148
+ * @param {string} answer
149
+ * @param {Iterable<string>} citable fetched URLs, their final URLs and the links on them
150
+ * @param {string} [sourceText]
151
+ * @returns {string[]} the cited URLs as written, deduplicated
152
+ */
153
+ export function unverifiedUrls(answer, citable, sourceText = '') {
154
+ const known = new Set([...citable, ...urlsIn(sourceText)].map(citationKey).filter(Boolean));
155
+ const seen = new Set();
156
+ return urlsIn(answer).filter(url => {
157
+ const key = citationKey(url);
158
+ if (!key || seen.has(key)) return false;
159
+ seen.add(key);
160
+ return !known.has(key);
161
+ });
162
+ }
163
+
164
+ /** Absolute http(s) hrefs of the links on a parsed page. */
165
+ function pageLinks($, baseUrl) {
166
+ const links = [];
167
+ $('a[href]').each((_, el) => {
168
+ try {
169
+ const url = new URL($(el).attr('href'), baseUrl);
170
+ if (url.protocol === 'http:' || url.protocol === 'https:') links.push(url.href);
171
+ } catch {
172
+ // An unparsable href cites nothing.
173
+ }
174
+ });
175
+ return links;
176
+ }
177
+
104
178
  /**
105
179
  * Current-state task gate: prompts about the live "now" ("right now",
106
- * "currently", "today", "latest", "#1 … now") go stale through search alone —
180
+ * "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
107
181
  * dated articles rank for the task's words while the live page does not
108
182
  * (live repro 2026-08-26: "#1 story on Hacker News right now" answered from
109
183
  * a January thread; news.ycombinator.com was never fetched). Detected
110
184
  * deterministically here (never LLM-trusted) so PLAN steers search toward
111
185
  * the live entity page and SHAPE answers from it, not from dated results.
112
186
  */
113
- const CURRENT_STATE_RE = /\b(right now|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
187
+ const CURRENT_STATE_RE = /\b(right now|current|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
114
188
 
115
189
  /** Version numbers as they appear in an answer, e.g. "2.11.4". */
116
190
  const VERSION_IN_TEXT = /\b\d+\.\d+(?:\.\d+){0,2}\b/g;
@@ -172,6 +246,66 @@ export function isCurrentStateTask(prompt) {
172
246
  return CURRENT_STATE_RE.test(prompt || '');
173
247
  }
174
248
 
249
+ /** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
250
+ function siteLabel(hostname) {
251
+ const labels = hostname.toLowerCase().split('.');
252
+ const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
253
+ return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
254
+ }
255
+
256
+ /**
257
+ * True when a search result is the entity's own site: its domain is the
258
+ * entity's name (python.org for "Python"), or it is a site root whose title
259
+ * leads with exactly that name (news.ycombinator.com, titled "Hacker News").
260
+ *
261
+ * The domain vote alone picked the wrong site in both R25 repros (2026-10-19).
262
+ * Brave returns each origin about once, so for "Python" every origin tied and
263
+ * the top-ranked one, en.wikipedia.org, was promoted — its root is the
264
+ * Wikipedia Main Page. For "Hacker News" thehackernews.com appeared twice and
265
+ * outvoted news.ycombinator.com. Neither look-alike passes this test: its
266
+ * domain is "thehackernews", its title "The Hacker News | …".
267
+ */
268
+ export function isOfficialResult(entity, url, title) {
269
+ const name = (entity || '').trim().toLowerCase().replace(/\s+/g, ' ');
270
+ if (!name) return false;
271
+ let u;
272
+ try { u = new URL(url); } catch { return false; }
273
+ const compact = s => s.replace(/[^\p{L}\p{N}]/gu, '');
274
+ if (compact(siteLabel(u.hostname)) === compact(name)) return true;
275
+ const lead = (title || '').split(/\s+[|–—-]\s+|:\s/)[0].trim().toLowerCase().replace(/\s+/g, ' ');
276
+ return u.pathname === '/' && !u.search && lead === name;
277
+ }
278
+
279
+ /**
280
+ * A page's share of the synthesis budget. Cut from the top, a page whose
281
+ * answer sits below its navigation menus loses it: python.org's
282
+ * "Latest: Python 3.15.0" sits ~3 500 characters in, under a wall of menu
283
+ * links, past a 2 400-character share (R25, 2026-10-19). When a line past the
284
+ * cut matches more of the task's terms than any line before it, the second
285
+ * half of the share goes to the text around that line.
286
+ */
287
+ export function sliceForSynthesis(text, cap, terms) {
288
+ if (!text || text.length <= cap) return text;
289
+ const score = line => {
290
+ const lc = line.toLowerCase();
291
+ return terms.filter(t => lc.includes(t)).length;
292
+ };
293
+ let keptBest = 0;
294
+ let best = 0;
295
+ let bestAt = -1;
296
+ let pos = 0;
297
+ for (const line of text.split('\n')) {
298
+ const s = score(line);
299
+ if (pos + line.length <= cap) keptBest = Math.max(keptBest, s);
300
+ else if (s > best) { best = s; bestAt = pos; }
301
+ pos += line.length + 1;
302
+ }
303
+ if (best <= keptBest) return truncate(text, cap);
304
+ const head = Math.floor(cap / 2);
305
+ const from = Math.max(head, bestAt - 200);
306
+ return `${text.slice(0, head)}\n[...]\n${truncate(text.slice(from), cap - head)}`;
307
+ }
308
+
175
309
  /**
176
310
  * The names a prompt uses: words carrying a capital letter that is not just
177
311
  * the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
@@ -309,7 +443,9 @@ export class AgentOrchestrator {
309
443
  usage.charged = 0;
310
444
 
311
445
  const startTime = Date.now();
312
- const deadline = () => (Date.now() - startTime) >= wallClockMs;
446
+ const remaining = () => wallClockMs - (Date.now() - startTime);
447
+ const deadline = () => remaining() <= 0;
448
+ const timeLimitReason = `Stopped at the ${wallClockMs} ms time limit; the evidence gathered before it is returned.`;
313
449
 
314
450
  // Hard-cap params regardless of what caller sends
315
451
  const capSteps = Math.min(maxSteps, 10);
@@ -358,7 +494,10 @@ export class AgentOrchestrator {
358
494
  ? `The task asks about a CURRENT live state: the FIRST query must be ONLY the name of the site or thing whose current state is asked, nothing else. `
359
495
  : '') +
360
496
  `Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
361
- const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
497
+ const { text } = await this._getSamplingClient().complete(planPrompt, {
498
+ maxTokens: 200,
499
+ timeoutMs: Math.min(remaining(), wallClockMs * PLAN_MAX_SHARE)
500
+ });
362
501
  const lines = text.split('\n')
363
502
  .map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
364
503
  // A model that fences its list ("```\nq1\nq2\n```") or quotes each
@@ -477,8 +616,10 @@ export class AgentOrchestrator {
477
616
  // queue and first at SHAPE time so synthesis answers from it, never from
478
617
  // a dated article. A wrong root self-heals: it fails the relevance gate
479
618
  // and never enters evidence.
619
+ let promotedOrigin = null;
480
620
  if (currentState && searchResults.length > 0) {
481
621
  const originCounts = new Map();
622
+ const official = new Set();
482
623
  // Only the bare entity query votes: the fact query added above returns
483
624
  // guides and news sites, which must not outvote the official domain.
484
625
  const entityQuery = searchQueries[0];
@@ -487,14 +628,23 @@ export class AgentOrchestrator {
487
628
  try {
488
629
  const origin = new URL(s.url).origin;
489
630
  originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
631
+ if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
490
632
  } catch { /* unparsable result url */ }
491
633
  }
634
+ // The entity's own site wins over any vote count; only when no result
635
+ // is official does the vote decide, and a tied vote decides nothing —
636
+ // with one result per origin it would just promote the top-ranked one.
492
637
  let bestOrigin = null;
493
638
  let bestCount = 0;
639
+ let tied = false;
494
640
  for (const [origin, count] of originCounts) {
641
+ if (official.size > 0 && !official.has(origin)) continue;
495
642
  // Strict > keeps the earliest-seen (top-ranked) origin on ties.
496
- if (count > bestCount) { bestOrigin = origin; bestCount = count; }
643
+ if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
644
+ else if (count === bestCount) tied = true;
497
645
  }
646
+ if (tied && official.size === 0) bestOrigin = null;
647
+ promotedOrigin = bestOrigin;
498
648
  if (bestOrigin) {
499
649
  const liveRoot = `${bestOrigin}/`;
500
650
  if (!priorityUrls.includes(liveRoot)) priorityUrls.push(liveRoot);
@@ -512,6 +662,9 @@ export class AgentOrchestrator {
512
662
  // cap whenever it was smaller than capUrls, leaving maxUrls unreachable
513
663
  // at its default. Both caps remain fully enforced (neither is weakened).
514
664
  const evidence = [];
665
+ // URLs an answer may cite: every evidence page's URL (requested and
666
+ // final) and the links on it.
667
+ const citable = [];
515
668
  let urlsFetched = 0;
516
669
  let step = 0;
517
670
  const warnings = [];
@@ -527,7 +680,7 @@ export class AgentOrchestrator {
527
680
  let plainError = null;
528
681
  let verdict = null;
529
682
  try {
530
- plain = await fetchAndParse(url, { timeoutMs: 10000, errorDocuments: true });
683
+ plain = await fetchAndParse(url, { timeoutMs: Math.max(1, Math.min(10000, remaining())), errorDocuments: true });
531
684
  verdict = documentVerdict(
532
685
  { url: plain.finalUrl, title: plain.$('title').first().text().trim(), text: plain.textContent, html: plain.html, status: plain.status },
533
686
  { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
@@ -547,6 +700,7 @@ export class AgentOrchestrator {
547
700
  text: truncate(plain.textContent),
548
701
  step
549
702
  });
703
+ citable.push(url, plain.finalUrl, ...pageLinks(plain.$, plain.finalUrl));
550
704
  continue;
551
705
  }
552
706
  // A real page that is simply off-topic: nothing to retry.
@@ -591,6 +745,7 @@ export class AgentOrchestrator {
591
745
  via: 'stealth',
592
746
  step
593
747
  });
748
+ citable.push(url, stealth.url || url, ...pageLinks(load(stealth.html || ''), stealth.url || url));
594
749
  continue;
595
750
  }
596
751
  warnings.push(`stealth retry of ${url} ${rendered.success ? 'returned a page unrelated to the task' : `did not get the page either${rendered.blocked?.vendor ? ` (${rendered.blocked.vendor})` : ''}`}`);
@@ -601,6 +756,7 @@ export class AgentOrchestrator {
601
756
 
602
757
  if (relevantExcerpt) {
603
758
  evidence.push({ url, title: relevantExcerpt.title, text: relevantExcerpt.text, snippet: true, step });
759
+ citable.push(url);
604
760
  }
605
761
  }
606
762
 
@@ -622,15 +778,18 @@ export class AgentOrchestrator {
622
778
  }))
623
779
  .sort((a, b) => b._score - a._score);
624
780
  const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
781
+ // Question words match any prose ("What is your name?" in a code sample).
782
+ const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
783
+ .filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
625
784
  const combinedText = orderedEvidence
626
- .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${truncate(e.text, perSourceCap)}`)
785
+ .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${sliceForSynthesis(e.text, perSourceCap, sliceTerms)}`)
627
786
  .join('\n\n');
628
787
 
629
788
  if (!combinedText.trim()) {
630
789
  return {
631
790
  success: true,
632
791
  degraded: true,
633
- reason: 'No content could be fetched for the given prompt.',
792
+ reason: deadline() ? timeLimitReason : 'No content could be fetched for the given prompt.',
634
793
  search_results: searchResults,
635
794
  evidence: [],
636
795
  answer: null,
@@ -643,15 +802,19 @@ export class AgentOrchestrator {
643
802
  }
644
803
 
645
804
  // Schema path: use ExtractWithLlm for structured output
646
- if (schema && Object.keys(schema).length > 0) {
805
+ if (schema && Object.keys(schema).length > 0 && !deadline()) {
647
806
  try {
648
807
  const extractWithLlm = await this._getExtractWithLlm();
649
808
  const result = await extractWithLlm.execute({
650
809
  content: combinedText,
651
810
  prompt: `From the following research sources, answer this task and extract structured data:\n${prompt}`,
652
811
  schema,
653
- provider: 'auto'
812
+ provider: 'auto',
813
+ signal: AbortSignal.timeout(Math.max(0, remaining()))
654
814
  });
815
+ // Out of time: fall through so the time-limit result below carries
816
+ // the full evidence.
817
+ if (!result.success && deadline()) throw new Error(result.error);
655
818
  // A required field left null is an incomplete answer, not a
656
819
  // structured success (R24, 2026-10-03).
657
820
  const missingRequired = result.success && Array.isArray(schema.required)
@@ -686,12 +849,14 @@ export class AgentOrchestrator {
686
849
  let degraded = false;
687
850
  let degradedReason;
688
851
  let unverified = [];
852
+ let unverifiedLinks = [];
689
853
  let forumOnlyVersions = [];
690
854
  // Distinct from `degraded`: a run can be degraded because its version
691
855
  // claims are unsupported and still have had its provenance checked.
692
856
  let synthesisFailed = false;
693
857
 
694
858
  try {
859
+ if (deadline()) throw new Error('time limit reached before synthesis');
695
860
  // Wording matters for small local models (llama3.2-class): without the
696
861
  // "already fetched / do not refuse" framing they answer "I cannot access
697
862
  // the internet" or "the sources do not contain it" even when the answer
@@ -706,9 +871,13 @@ export class AgentOrchestrator {
706
871
  `Rules:\n` +
707
872
  // Short and imperative on purpose: the executing model is a small
708
873
  // local one (gemma3:4b-class) and ignores hedged phrasing.
709
- (currentState
874
+ // "The first source is the live page" is only true when GATHER
875
+ // promoted one; otherwise the first source is just the best term match.
876
+ (currentState && promotedOrigin
710
877
  ? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page); if the FIRST source does not state the answer, take it from the next sources in order. NEVER present older or dated content as the current answer.\n`
711
- : '') +
878
+ : currentState
879
+ ? `- The task asks about the CURRENT state. Prefer the subject's own official site over third-party articles. NEVER present older or dated content as the current answer.\n`
880
+ : '') +
712
881
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
713
882
  `- Read the sources carefully before concluding anything is missing from them.\n` +
714
883
  `- Cite the exact source URL(s) you used.\n` +
@@ -716,7 +885,7 @@ export class AgentOrchestrator {
716
885
  `- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
717
886
  `Provide a clear, concise answer.`;
718
887
 
719
- const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
888
+ const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
720
889
  answer = text;
721
890
 
722
891
  // A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
@@ -724,18 +893,24 @@ export class AgentOrchestrator {
724
893
  // 2026-09-04). Versions, dates and counts in the answer must appear in
725
894
  // the source text; what does not gets one corrective rewrite, and
726
895
  // whatever still remains is named in the answer and in `provenance`.
896
+ // A cited URL is checked the same way (R25 re-check, 2026-10-19: the
897
+ // right HN title with an invented item link).
898
+ const sourceText = evidence.map(e => e.text || '').join('\n');
727
899
  unverified = unverifiedValues(answer, combinedText);
728
- if (unverified.length > 0) {
900
+ unverifiedLinks = unverifiedUrls(answer, citable, sourceText);
901
+ if ((unverified.length > 0 || unverifiedLinks.length > 0) && remaining() >= REWRITE_MIN_REMAINING_MS) {
729
902
  const retryPrompt =
730
903
  `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
731
- `It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
904
+ (unverified.length > 0 ? `It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` : '') +
905
+ (unverifiedLinks.length > 0 ? `It cites URLs that are not among the sources or the links on them: ${unverifiedLinks.join(', ')}. Cite only the source URLs given above. ` : '') +
732
906
  `Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
733
907
  `Where the sources do not state a value, say that it is not stated instead of guessing.`;
734
908
  try {
735
- const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
909
+ const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
736
910
  if (rewritten && rewritten.trim()) {
737
911
  answer = rewritten;
738
912
  unverified = unverifiedValues(answer, combinedText);
913
+ unverifiedLinks = unverifiedUrls(answer, citable, sourceText);
739
914
  }
740
915
  } catch {
741
916
  // Keep the first answer; it is flagged below.
@@ -746,6 +921,11 @@ export class AgentOrchestrator {
746
921
  `\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
747
922
  `${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
748
923
  }
924
+ if (unverifiedLinks.length > 0) {
925
+ answer +=
926
+ `\n\nProvenance warning: ${unverifiedLinks.join(', ')} ` +
927
+ `${unverifiedLinks.length === 1 ? 'is' : 'are'} not among the fetched pages or the links on them and may be invented.`;
928
+ }
749
929
 
750
930
  // Asked for a current version, the agent answered "2.4.3" from a forum
751
931
  // thread about a different project while the project's own site and its
@@ -756,7 +936,7 @@ export class AgentOrchestrator {
756
936
  // presented as the answer.
757
937
  if (currentState && isVersionQuestion(prompt)) {
758
938
  forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
759
- if (forumOnlyVersions.length > 0) {
939
+ if (forumOnlyVersions.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
760
940
  const versionRetryPrompt =
761
941
  `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
762
942
  `It states ${forumOnlyVersions.map(v => `"${v}"`).join(', ')}, which ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
@@ -765,7 +945,7 @@ export class AgentOrchestrator {
765
945
  `Rewrite the answer using only versions stated by the project's own sources. ` +
766
946
  `If none of them states a current version, say plainly that the fetched sources do not state one.`;
767
947
  try {
768
- const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024 });
948
+ const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
769
949
  if (rewritten && rewritten.trim()) {
770
950
  answer = rewritten;
771
951
  forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
@@ -786,10 +966,36 @@ export class AgentOrchestrator {
786
966
  `only in discussion pages. The authoritative pages fetched state no version.`;
787
967
  }
788
968
  }
969
+
970
+ // A promoted live page is only worth its +1000 if the answer came from
971
+ // it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
972
+ // Main Page, and the HN question from thehackernews.com, both with
973
+ // degraded:false. The answer counts as the live page's when it cites
974
+ // that host or states a value (version, date, number) the page shows.
975
+ if (currentState && promotedOrigin && !degraded) {
976
+ const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
977
+ const liveHost = host(promotedOrigin);
978
+ const liveText = evidence.filter(e => host(e.url) === liveHost).map(e => e.text || '').join('\n');
979
+ // unverifiedValues against an empty text lists every value the answer states.
980
+ const stated = unverifiedValues(answer, '');
981
+ const fromLivePage = answer.toLowerCase().includes(liveHost) ||
982
+ (liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
983
+ if (!fromLivePage) {
984
+ degraded = true;
985
+ degradedReason =
986
+ `The answer does not come from the live page ${promotedOrigin}/: ` +
987
+ (liveText
988
+ ? 'it neither cites that page nor states a value shown on it.'
989
+ : 'that page could not be fetched or did not match the task.') +
990
+ ' It may describe a dated or third-party source rather than the current state.';
991
+ }
992
+ }
789
993
  } catch (err) {
790
994
  degraded = true;
791
995
  synthesisFailed = true;
792
- degradedReason = `LLM synthesis unavailable: ${err.message}`;
996
+ degradedReason = err.code === 'LLM_TIMEOUT' || deadline()
997
+ ? timeLimitReason
998
+ : `LLM synthesis unavailable: ${err.message}`;
793
999
  // Return raw evidence so the host LLM can synthesize
794
1000
  answer = null;
795
1001
  }
@@ -809,6 +1015,7 @@ export class AgentOrchestrator {
809
1015
  provenance: {
810
1016
  checked: !synthesisFailed,
811
1017
  unverified,
1018
+ ...(unverifiedLinks.length > 0 ? { unverified_urls: unverifiedLinks } : {}),
812
1019
  ...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
813
1020
  }
814
1021
  };