crawlforge-mcp-server 6.21.0 → 6.22.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 (54) 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 +141 -19
  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 +48 -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 +23 -15
  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/adapters/braveSearch.js +27 -5
  45. package/src/tools/search/providers/searxng.js +20 -3
  46. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  47. package/src/tools/search/searchWeb.js +2 -2
  48. package/src/tools/tracking/trackChanges/differ.js +2 -1
  49. package/src/tools/tracking/trackChanges/index.js +6 -1
  50. package/src/tools/tracking/trackChanges/schema.js +15 -1
  51. package/src/utils/fetchError.js +47 -0
  52. package/src/utils/fetchLadder.js +5 -5
  53. package/src/utils/htmlToMarkdown.js +36 -2
  54. 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.0",
3
+ "version": "6.22.0",
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.0",
116
+ version: "6.22.0",
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,7 +12,9 @@
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.
@@ -31,6 +33,12 @@ const DEFAULT_MAX_URLS = 10;
31
33
  // A stealth retry launches a browser and waits out a challenge; one started
32
34
  // with less than this left on the wall clock would overrun the hard stop.
33
35
  const ESCALATION_MIN_REMAINING_MS = 20_000;
36
+ // PLAN is optional (the raw prompt is the fallback query), so a slow model
37
+ // may spend at most this share of the wall clock on it, never all of it.
38
+ const PLAN_MAX_SHARE = 0.25;
39
+ // A corrective rewrite is a whole synthesis call; with less than this left it
40
+ // would run into the hard stop, so the first answer is kept and flagged.
41
+ const REWRITE_MIN_REMAINING_MS = 15_000;
34
42
 
35
43
  /** A refusal (robots.txt, blocklist) is a decision, not a wall — a browser must not retry it. */
36
44
  function isRefusal(err) {
@@ -103,14 +111,14 @@ export function unverifiedValues(answer, sourceText) {
103
111
 
104
112
  /**
105
113
  * Current-state task gate: prompts about the live "now" ("right now",
106
- * "currently", "today", "latest", "#1 … now") go stale through search alone —
114
+ * "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
107
115
  * dated articles rank for the task's words while the live page does not
108
116
  * (live repro 2026-08-26: "#1 story on Hacker News right now" answered from
109
117
  * a January thread; news.ycombinator.com was never fetched). Detected
110
118
  * deterministically here (never LLM-trusted) so PLAN steers search toward
111
119
  * the live entity page and SHAPE answers from it, not from dated results.
112
120
  */
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;
121
+ 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
122
 
115
123
  /** Version numbers as they appear in an answer, e.g. "2.11.4". */
116
124
  const VERSION_IN_TEXT = /\b\d+\.\d+(?:\.\d+){0,2}\b/g;
@@ -172,6 +180,66 @@ export function isCurrentStateTask(prompt) {
172
180
  return CURRENT_STATE_RE.test(prompt || '');
173
181
  }
174
182
 
183
+ /** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
184
+ function siteLabel(hostname) {
185
+ const labels = hostname.toLowerCase().split('.');
186
+ const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
187
+ return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
188
+ }
189
+
190
+ /**
191
+ * True when a search result is the entity's own site: its domain is the
192
+ * entity's name (python.org for "Python"), or it is a site root whose title
193
+ * leads with exactly that name (news.ycombinator.com, titled "Hacker News").
194
+ *
195
+ * The domain vote alone picked the wrong site in both R25 repros (2026-10-19).
196
+ * Brave returns each origin about once, so for "Python" every origin tied and
197
+ * the top-ranked one, en.wikipedia.org, was promoted — its root is the
198
+ * Wikipedia Main Page. For "Hacker News" thehackernews.com appeared twice and
199
+ * outvoted news.ycombinator.com. Neither look-alike passes this test: its
200
+ * domain is "thehackernews", its title "The Hacker News | …".
201
+ */
202
+ export function isOfficialResult(entity, url, title) {
203
+ const name = (entity || '').trim().toLowerCase().replace(/\s+/g, ' ');
204
+ if (!name) return false;
205
+ let u;
206
+ try { u = new URL(url); } catch { return false; }
207
+ const compact = s => s.replace(/[^\p{L}\p{N}]/gu, '');
208
+ if (compact(siteLabel(u.hostname)) === compact(name)) return true;
209
+ const lead = (title || '').split(/\s+[|–—-]\s+|:\s/)[0].trim().toLowerCase().replace(/\s+/g, ' ');
210
+ return u.pathname === '/' && !u.search && lead === name;
211
+ }
212
+
213
+ /**
214
+ * A page's share of the synthesis budget. Cut from the top, a page whose
215
+ * answer sits below its navigation menus loses it: python.org's
216
+ * "Latest: Python 3.15.0" sits ~3 500 characters in, under a wall of menu
217
+ * links, past a 2 400-character share (R25, 2026-10-19). When a line past the
218
+ * cut matches more of the task's terms than any line before it, the second
219
+ * half of the share goes to the text around that line.
220
+ */
221
+ export function sliceForSynthesis(text, cap, terms) {
222
+ if (!text || text.length <= cap) return text;
223
+ const score = line => {
224
+ const lc = line.toLowerCase();
225
+ return terms.filter(t => lc.includes(t)).length;
226
+ };
227
+ let keptBest = 0;
228
+ let best = 0;
229
+ let bestAt = -1;
230
+ let pos = 0;
231
+ for (const line of text.split('\n')) {
232
+ const s = score(line);
233
+ if (pos + line.length <= cap) keptBest = Math.max(keptBest, s);
234
+ else if (s > best) { best = s; bestAt = pos; }
235
+ pos += line.length + 1;
236
+ }
237
+ if (best <= keptBest) return truncate(text, cap);
238
+ const head = Math.floor(cap / 2);
239
+ const from = Math.max(head, bestAt - 200);
240
+ return `${text.slice(0, head)}\n[...]\n${truncate(text.slice(from), cap - head)}`;
241
+ }
242
+
175
243
  /**
176
244
  * The names a prompt uses: words carrying a capital letter that is not just
177
245
  * the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
@@ -309,7 +377,9 @@ export class AgentOrchestrator {
309
377
  usage.charged = 0;
310
378
 
311
379
  const startTime = Date.now();
312
- const deadline = () => (Date.now() - startTime) >= wallClockMs;
380
+ const remaining = () => wallClockMs - (Date.now() - startTime);
381
+ const deadline = () => remaining() <= 0;
382
+ const timeLimitReason = `Stopped at the ${wallClockMs} ms time limit; the evidence gathered before it is returned.`;
313
383
 
314
384
  // Hard-cap params regardless of what caller sends
315
385
  const capSteps = Math.min(maxSteps, 10);
@@ -358,7 +428,10 @@ export class AgentOrchestrator {
358
428
  ? `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
429
  : '') +
360
430
  `Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
361
- const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
431
+ const { text } = await this._getSamplingClient().complete(planPrompt, {
432
+ maxTokens: 200,
433
+ timeoutMs: Math.min(remaining(), wallClockMs * PLAN_MAX_SHARE)
434
+ });
362
435
  const lines = text.split('\n')
363
436
  .map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
364
437
  // A model that fences its list ("```\nq1\nq2\n```") or quotes each
@@ -477,8 +550,10 @@ export class AgentOrchestrator {
477
550
  // queue and first at SHAPE time so synthesis answers from it, never from
478
551
  // a dated article. A wrong root self-heals: it fails the relevance gate
479
552
  // and never enters evidence.
553
+ let promotedOrigin = null;
480
554
  if (currentState && searchResults.length > 0) {
481
555
  const originCounts = new Map();
556
+ const official = new Set();
482
557
  // Only the bare entity query votes: the fact query added above returns
483
558
  // guides and news sites, which must not outvote the official domain.
484
559
  const entityQuery = searchQueries[0];
@@ -487,14 +562,23 @@ export class AgentOrchestrator {
487
562
  try {
488
563
  const origin = new URL(s.url).origin;
489
564
  originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
565
+ if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
490
566
  } catch { /* unparsable result url */ }
491
567
  }
568
+ // The entity's own site wins over any vote count; only when no result
569
+ // is official does the vote decide, and a tied vote decides nothing —
570
+ // with one result per origin it would just promote the top-ranked one.
492
571
  let bestOrigin = null;
493
572
  let bestCount = 0;
573
+ let tied = false;
494
574
  for (const [origin, count] of originCounts) {
575
+ if (official.size > 0 && !official.has(origin)) continue;
495
576
  // Strict > keeps the earliest-seen (top-ranked) origin on ties.
496
- if (count > bestCount) { bestOrigin = origin; bestCount = count; }
577
+ if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
578
+ else if (count === bestCount) tied = true;
497
579
  }
580
+ if (tied && official.size === 0) bestOrigin = null;
581
+ promotedOrigin = bestOrigin;
498
582
  if (bestOrigin) {
499
583
  const liveRoot = `${bestOrigin}/`;
500
584
  if (!priorityUrls.includes(liveRoot)) priorityUrls.push(liveRoot);
@@ -527,7 +611,7 @@ export class AgentOrchestrator {
527
611
  let plainError = null;
528
612
  let verdict = null;
529
613
  try {
530
- plain = await fetchAndParse(url, { timeoutMs: 10000, errorDocuments: true });
614
+ plain = await fetchAndParse(url, { timeoutMs: Math.max(1, Math.min(10000, remaining())), errorDocuments: true });
531
615
  verdict = documentVerdict(
532
616
  { url: plain.finalUrl, title: plain.$('title').first().text().trim(), text: plain.textContent, html: plain.html, status: plain.status },
533
617
  { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
@@ -622,15 +706,18 @@ export class AgentOrchestrator {
622
706
  }))
623
707
  .sort((a, b) => b._score - a._score);
624
708
  const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
709
+ // Question words match any prose ("What is your name?" in a code sample).
710
+ const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
711
+ .filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
625
712
  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)}`)
713
+ .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${sliceForSynthesis(e.text, perSourceCap, sliceTerms)}`)
627
714
  .join('\n\n');
628
715
 
629
716
  if (!combinedText.trim()) {
630
717
  return {
631
718
  success: true,
632
719
  degraded: true,
633
- reason: 'No content could be fetched for the given prompt.',
720
+ reason: deadline() ? timeLimitReason : 'No content could be fetched for the given prompt.',
634
721
  search_results: searchResults,
635
722
  evidence: [],
636
723
  answer: null,
@@ -643,15 +730,19 @@ export class AgentOrchestrator {
643
730
  }
644
731
 
645
732
  // Schema path: use ExtractWithLlm for structured output
646
- if (schema && Object.keys(schema).length > 0) {
733
+ if (schema && Object.keys(schema).length > 0 && !deadline()) {
647
734
  try {
648
735
  const extractWithLlm = await this._getExtractWithLlm();
649
736
  const result = await extractWithLlm.execute({
650
737
  content: combinedText,
651
738
  prompt: `From the following research sources, answer this task and extract structured data:\n${prompt}`,
652
739
  schema,
653
- provider: 'auto'
740
+ provider: 'auto',
741
+ signal: AbortSignal.timeout(Math.max(0, remaining()))
654
742
  });
743
+ // Out of time: fall through so the time-limit result below carries
744
+ // the full evidence.
745
+ if (!result.success && deadline()) throw new Error(result.error);
655
746
  // A required field left null is an incomplete answer, not a
656
747
  // structured success (R24, 2026-10-03).
657
748
  const missingRequired = result.success && Array.isArray(schema.required)
@@ -692,6 +783,7 @@ export class AgentOrchestrator {
692
783
  let synthesisFailed = false;
693
784
 
694
785
  try {
786
+ if (deadline()) throw new Error('time limit reached before synthesis');
695
787
  // Wording matters for small local models (llama3.2-class): without the
696
788
  // "already fetched / do not refuse" framing they answer "I cannot access
697
789
  // the internet" or "the sources do not contain it" even when the answer
@@ -706,9 +798,13 @@ export class AgentOrchestrator {
706
798
  `Rules:\n` +
707
799
  // Short and imperative on purpose: the executing model is a small
708
800
  // local one (gemma3:4b-class) and ignores hedged phrasing.
709
- (currentState
801
+ // "The first source is the live page" is only true when GATHER
802
+ // promoted one; otherwise the first source is just the best term match.
803
+ (currentState && promotedOrigin
710
804
  ? `- 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
- : '') +
805
+ : currentState
806
+ ? `- 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`
807
+ : '') +
712
808
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
713
809
  `- Read the sources carefully before concluding anything is missing from them.\n` +
714
810
  `- Cite the exact source URL(s) you used.\n` +
@@ -716,7 +812,7 @@ export class AgentOrchestrator {
716
812
  `- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
717
813
  `Provide a clear, concise answer.`;
718
814
 
719
- const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
815
+ const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
720
816
  answer = text;
721
817
 
722
818
  // A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
@@ -725,14 +821,14 @@ export class AgentOrchestrator {
725
821
  // the source text; what does not gets one corrective rewrite, and
726
822
  // whatever still remains is named in the answer and in `provenance`.
727
823
  unverified = unverifiedValues(answer, combinedText);
728
- if (unverified.length > 0) {
824
+ if (unverified.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
729
825
  const retryPrompt =
730
826
  `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
731
827
  `It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
732
828
  `Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
733
829
  `Where the sources do not state a value, say that it is not stated instead of guessing.`;
734
830
  try {
735
- const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
831
+ const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
736
832
  if (rewritten && rewritten.trim()) {
737
833
  answer = rewritten;
738
834
  unverified = unverifiedValues(answer, combinedText);
@@ -756,7 +852,7 @@ export class AgentOrchestrator {
756
852
  // presented as the answer.
757
853
  if (currentState && isVersionQuestion(prompt)) {
758
854
  forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
759
- if (forumOnlyVersions.length > 0) {
855
+ if (forumOnlyVersions.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
760
856
  const versionRetryPrompt =
761
857
  `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
762
858
  `It states ${forumOnlyVersions.map(v => `"${v}"`).join(', ')}, which ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
@@ -765,7 +861,7 @@ export class AgentOrchestrator {
765
861
  `Rewrite the answer using only versions stated by the project's own sources. ` +
766
862
  `If none of them states a current version, say plainly that the fetched sources do not state one.`;
767
863
  try {
768
- const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024 });
864
+ const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
769
865
  if (rewritten && rewritten.trim()) {
770
866
  answer = rewritten;
771
867
  forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
@@ -786,10 +882,36 @@ export class AgentOrchestrator {
786
882
  `only in discussion pages. The authoritative pages fetched state no version.`;
787
883
  }
788
884
  }
885
+
886
+ // A promoted live page is only worth its +1000 if the answer came from
887
+ // it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
888
+ // Main Page, and the HN question from thehackernews.com, both with
889
+ // degraded:false. The answer counts as the live page's when it cites
890
+ // that host or states a value (version, date, number) the page shows.
891
+ if (currentState && promotedOrigin && !degraded) {
892
+ const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
893
+ const liveHost = host(promotedOrigin);
894
+ const liveText = evidence.filter(e => host(e.url) === liveHost).map(e => e.text || '').join('\n');
895
+ // unverifiedValues against an empty text lists every value the answer states.
896
+ const stated = unverifiedValues(answer, '');
897
+ const fromLivePage = answer.toLowerCase().includes(liveHost) ||
898
+ (liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
899
+ if (!fromLivePage) {
900
+ degraded = true;
901
+ degradedReason =
902
+ `The answer does not come from the live page ${promotedOrigin}/: ` +
903
+ (liveText
904
+ ? 'it neither cites that page nor states a value shown on it.'
905
+ : 'that page could not be fetched or did not match the task.') +
906
+ ' It may describe a dated or third-party source rather than the current state.';
907
+ }
908
+ }
789
909
  } catch (err) {
790
910
  degraded = true;
791
911
  synthesisFailed = true;
792
- degradedReason = `LLM synthesis unavailable: ${err.message}`;
912
+ degradedReason = err.code === 'LLM_TIMEOUT' || deadline()
913
+ ? timeLimitReason
914
+ : `LLM synthesis unavailable: ${err.message}`;
793
915
  // Return raw evidence so the host LLM can synthesize
794
916
  answer = null;
795
917
  }
@@ -758,7 +758,10 @@ class AuthManager {
758
758
  break;
759
759
  }
760
760
  case 'extract_with_llm':
761
- note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
761
+ // Only the cloud providers bill you; the default runs on local Ollama.
762
+ if (params?.provider === 'openai' || params?.provider === 'anthropic') {
763
+ note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
764
+ }
762
765
  break;
763
766
  case 'stealth_mode':
764
767
  note = projected === 1
@@ -236,7 +236,9 @@ export class LLMsTxtAnalyzer {
236
236
  const batch = commonPaths.slice(i, i + PROBE_CONCURRENCY);
237
237
  const results = await Promise.allSettled(
238
238
  batch.map(async (path) => {
239
- const apiUrl = `${baseUrl}${path}`;
239
+ // Resolved, not concatenated: the URL arrives as typed, and
240
+ // "https://site.com/" + "/json" was listed as "https://site.com//json" (R25 1.8).
241
+ const apiUrl = new URL(path, baseUrl).toString();
240
242
  const response = await this.fetchWithTimeout(apiUrl, { timeout: 5000 });
241
243
  if (response.ok) {
242
244
  const contentType = response.headers.get('content-type') || '';
@@ -367,7 +369,7 @@ export class LLMsTxtAnalyzer {
367
369
  const batch = sensitivePaths.slice(i, i + PROBE_CONCURRENCY);
368
370
  const results = await Promise.allSettled(
369
371
  batch.map(async (path) => {
370
- const testUrl = `${baseUrl}${path}`;
372
+ const testUrl = new URL(path, baseUrl).toString();
371
373
  const response = await this.fetchWithTimeout(testUrl, { timeout: 3000 });
372
374
  if (response.status === 200 || response.status === 302 || response.status === 401) {
373
375
  return {
@@ -571,7 +573,6 @@ export class LLMsTxtAnalyzer {
571
573
  */
572
574
  prioritizeUrls(urls, baseUrl, limit) {
573
575
  const sections = new Map();
574
- let sawAny = false;
575
576
 
576
577
  for (const url of urls) {
577
578
  let segments;
@@ -580,7 +581,6 @@ export class LLMsTxtAnalyzer {
580
581
  } catch {
581
582
  continue;
582
583
  }
583
- sawAny = true;
584
584
  // The root arrives as either "https://site.com" or "https://site.com/"
585
585
  // (normalizeUrl strips a trailing slash from every path but "/"), so it
586
586
  // is recognised by its path and re-added below in one canonical form.
@@ -590,10 +590,9 @@ export class LLMsTxtAnalyzer {
590
590
  sections.get(section).push({ url, depth: segments.length });
591
591
  }
592
592
 
593
- if (!sawAny) return [];
594
-
595
593
  // The site root leads: it is always fetched, and a site guide that omits
596
- // the homepage is a misleading one.
594
+ // the homepage is a misleading one. That holds with no other candidate
595
+ // too: a one-page site was analysed as 0 pages (R25 1.2).
597
596
  const ordered = [`${baseUrl}/`];
598
597
  const queues = [...sections.keys()].sort().map((name) =>
599
598
  sections.get(name).sort((a, b) => a.depth - b.depth || a.url.localeCompare(b.url)));
@@ -778,15 +778,40 @@ export class LocalizationManager extends EventEmitter {
778
778
  }
779
779
 
780
780
  getFirstDayOfWeek(countryCode) {
781
- // 0 = Sunday, 1 = Monday
781
+ // 0 = Sunday, 1 = Monday. From CLDR 48 supplemental weekData "firstDay";
782
+ // covers every SUPPORTED_COUNTRIES entry. The fallback is CLDR's world
783
+ // default (Monday). `??`, not `||`: Sunday is 0 (R25).
782
784
  const firstDays = {
783
785
  'US': 0,
784
786
  'GB': 1,
785
787
  'DE': 1,
786
- 'FR': 1
788
+ 'FR': 1,
789
+ 'JP': 0,
790
+ 'CN': 1,
791
+ 'AU': 1,
792
+ 'CA': 0,
793
+ 'IT': 1,
794
+ 'ES': 1,
795
+ 'RU': 1,
796
+ 'BR': 0,
797
+ 'IN': 0,
798
+ 'KR': 0,
799
+ 'MX': 0,
800
+ 'NL': 1,
801
+ 'SE': 1,
802
+ 'NO': 1,
803
+ 'SA': 0,
804
+ 'AE': 1,
805
+ 'TR': 1,
806
+ 'IL': 0,
807
+ 'TH': 0,
808
+ 'SG': 0,
809
+ 'PL': 1,
810
+ 'ZA': 0,
811
+ 'CH': 1
787
812
  };
788
-
789
- return firstDays[countryCode] || 1;
813
+
814
+ return firstDays[countryCode] ?? 1;
790
815
  }
791
816
 
792
817
  generateScreenProperties(countryCode) {