crawlforge-mcp-server 6.22.2 → 6.23.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.
- package/README.md +10 -1
- package/package.json +1 -1
- package/server.js +14 -7
- package/src/core/ActionExecutor.js +117 -12
- package/src/core/AgentOrchestrator.js +133 -23
- package/src/core/AuthManager.js +7 -1
- package/src/core/ChangeTracker.js +52 -4
- package/src/core/LLMsTxtAnalyzer.js +21 -9
- package/src/core/LocalizationManager.js +20 -4
- package/src/core/ResearchOrchestrator.js +29 -3
- package/src/core/StealthBrowserManager.js +10 -5
- package/src/core/analysis/ContentAnalyzer.js +157 -29
- package/src/core/browser/guardedProxy.js +286 -0
- package/src/core/browser/snapshot.js +22 -0
- package/src/core/crawlers/BFSCrawler.js +10 -4
- package/src/core/processing/BrowserProcessor.js +5 -3
- package/src/core/processing/ContentProcessor.js +2 -1
- package/src/core/research/claimFilters.js +20 -1
- package/src/core/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +19 -11
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
- package/src/tools/agent/sourceChecks.js +103 -0
- package/src/tools/basic/extractMetadata.js +9 -4
- package/src/tools/basic/extractText.js +6 -4
- package/src/tools/basic/fetchUrl.js +3 -0
- package/src/tools/basic/scrapeStructured.js +66 -21
- package/src/tools/crawl/mapSite.js +18 -3
- package/src/tools/extract/extractStructured.js +134 -1
- package/src/tools/extract/processDocument.js +48 -5
- package/src/tools/extract/summarizeContent.js +123 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
- package/src/tools/result/readResult.js +79 -10
- package/src/tools/scrape/_mainContent.js +97 -6
- package/src/tools/search/adapters/dataforseoSearch.js +4 -1
- package/src/tools/search/searchWeb.js +46 -15
- package/src/tools/search/serpRank.js +1 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
- package/src/tools/tracking/trackChanges/schema.js +2 -1
- package/src/utils/htmlToMarkdown.js +77 -1
- package/src/utils/localFilePolicy.js +65 -0
- package/src/utils/provenance.js +102 -1
- package/src/utils/ssrfGuard.js +4 -3
- package/src/utils/ssrfProtection.js +3 -0
package/README.md
CHANGED
|
@@ -276,6 +276,12 @@ export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
|
|
|
276
276
|
|
|
277
277
|
# Optional: turn off the clearance jar (on by default) — see "Stealth engines and proxies"
|
|
278
278
|
export CRAWLFORGE_CLEARANCE_JAR="off"
|
|
279
|
+
|
|
280
|
+
# Optional: also refuse private networks (RFC1918, ULA, CGNAT, ...) — see "Security & Approvals"
|
|
281
|
+
export SSRF_STRICT="true"
|
|
282
|
+
|
|
283
|
+
# Optional: the only directories process_document may read local files from (":"-separated, ";" on Windows)
|
|
284
|
+
export CRAWLFORGE_ALLOWED_DIRS="$HOME/Documents:$HOME/Downloads"
|
|
279
285
|
```
|
|
280
286
|
|
|
281
287
|
### MCP Spec Features
|
|
@@ -380,7 +386,10 @@ Once configured, use these tools in your AI assistant:
|
|
|
380
386
|
|
|
381
387
|
### Security & Approvals
|
|
382
388
|
|
|
383
|
-
- **SSRF enforcement**: Every scraped URL is validated before the request is sent
|
|
389
|
+
- **SSRF enforcement**: Every scraped URL is validated before the request is sent, and every connection (each redirect hop included, capped at 5) is checked against the address it actually connects to — http/https only, cloud metadata hostnames (GCP, Azure) refused. Two levels:
|
|
390
|
+
- **Default (Stage 1)** blocks loopback (`127.0.0.0/8`, `::1`), the unspecified addresses (`0.0.0.0/8`, `::`), link-local and cloud metadata (`169.254.0.0/16` incl. `169.254.169.254`, `fe80::/10`). It **allows private networks** (RFC1918 `10/8`, `172.16/12`, `192.168/16`, IPv6 ULA): run on your own machine, the server acts for you, and reading a router page, a NAS or an intranet docs site on your LAN is a legitimate use that the stricter level would break.
|
|
391
|
+
- **`SSRF_STRICT=true`** adds every private and reserved range: RFC1918, ULA (`fc00::/7`), CGNAT (`100.64/10`), multicast, reserved, IPv4-compatible (`::/96`) and NAT64 (`64:ff9b::/96`) IPv6. Set it if an agent that reads untrusted pages (and could be prompt-injected into fetching a LAN address) should not reach your local network, or whenever the server is shared.
|
|
392
|
+
- **Local files** (`process_document` with `sourceType` `file` or `pdf_file`; refused outright when the server is served to a network): a path is judged by its realpath, so a symlink counts as its target. Only `.pdf`, `.docx`, `.txt`, `.md`, `.html` and `.htm` files are read, and nothing inside a dot-directory (`~/.ssh`, `~/.aws`, `~/.config`, `~/.crawlforge`, ...). `CRAWLFORGE_ALLOWED_DIRS` (directories separated by `:`, `;` on Windows) narrows reads to those directories instead; inside them the dot-directory rule is lifted, the extension rule is not.
|
|
384
393
|
- **Backend endpoint guard** (v3.0.18): The server's own calls to CrawlForge.dev use a separate fail-closed allow-list (`{crawlforge.dev, www.crawlforge.dev, api.crawlforge.dev}`, HTTPS required). Setting `CRAWLFORGE_API_URL` to an arbitrary host is blocked at parse time.
|
|
385
394
|
- **Action allowlist**: `scrape_with_actions` accepts only 11 action types (`snapshot`, `wait`, `click`, `type`, `press`, `scroll`, `screenshot`, `executeJavaScript`, `select`, `hover`, `navigate`). No download, file-write, or arbitrary cross-page navigation primitives exist — `navigate` goes through the same SSRF and robots.txt gate as the initial URL.
|
|
386
395
|
- **JavaScript gate**: The `executeJavaScript` action throws by default. Set `ALLOW_JAVASCRIPT_EXECUTION=true` at deploy time to enable (not recommended in production).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.23.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",
|
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.
|
|
116
|
+
version: "6.23.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",
|
|
@@ -427,7 +427,7 @@ const refusedByGate = (result) => result?.success === false && preflightRefusal(
|
|
|
427
427
|
|
|
428
428
|
// Tool: fetch_url
|
|
429
429
|
registerToolIfEnabled("fetch_url", {
|
|
430
|
-
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
430
|
+
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout; the result reports redirected and final_url. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
431
431
|
annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
432
432
|
inputSchema: {
|
|
433
433
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
@@ -504,7 +504,10 @@ registerToolIfEnabled("scrape_structured", {
|
|
|
504
504
|
annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
505
505
|
inputSchema: {
|
|
506
506
|
url: z.string().url().describe("The URL to scrape"),
|
|
507
|
-
selectors: z.record(z.
|
|
507
|
+
selectors: z.record(z.union([
|
|
508
|
+
z.string(),
|
|
509
|
+
z.object({ selector: z.string(), multiple: z.boolean().optional() })
|
|
510
|
+
])).describe("CSS selectors mapping field names to selectors. Append @attr to extract an attribute instead of text (e.g. \"a.link@href\", \"img@src\"); @href and @src values are resolved to absolute URLs. A field may also be {selector, multiple: true} to return every match as an array - inside a row_selector row a field otherwise keeps only the first match"),
|
|
508
511
|
row_selector: z.string().optional().describe("CSS selector for the repeating row/container element. When set, each field in selectors is matched inside each row and data is an array of row-aligned records ({field: value|null}) instead of parallel arrays"),
|
|
509
512
|
max_results: z.number().int().min(1).optional().describe("Maximum number of matches to return per field when a selector matches multiple elements, or the maximum number of rows when row_selector is set"),
|
|
510
513
|
...COMPLIANCE_PARAMS
|
|
@@ -754,7 +757,7 @@ registerToolIfEnabled("extract_content", {
|
|
|
754
757
|
|
|
755
758
|
// Tool: process_document
|
|
756
759
|
registerToolIfEnabled("process_document", {
|
|
757
|
-
description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count; for a PDF, pagesRead names the pages read (maxPages defaults to 100). Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
760
|
+
description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count; for a PDF, pagesRead names the pages read (maxPages defaults to 100). Not for ordinary web pages (scrape), though an HTML URL is accepted. A local file (sourceType file/pdf_file) must be .pdf, .docx, .txt, .md or .html and outside dot-directories; CRAWLFORGE_ALLOWED_DIRS limits reads to the listed directories. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
758
761
|
annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
759
762
|
inputSchema: {
|
|
760
763
|
source: z.string().describe("Document source - URL or file path"),
|
|
@@ -1060,7 +1063,7 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1060
1063
|
includeMetadata: z.boolean().default(true),
|
|
1061
1064
|
includeLinks: z.boolean().default(true),
|
|
1062
1065
|
includeImages: z.boolean().default(true)
|
|
1063
|
-
}).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response."),
|
|
1066
|
+
}).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response. Append @attr to a selector to read an attribute instead of the text (\"a.next@href\"); @value on an input, textarea or select reads what it holds after the actions (typed or selected), and any other @attr reads the attribute as written in the page's HTML."),
|
|
1064
1067
|
continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
|
|
1065
1068
|
maxRetries: z.number().min(0).max(3).default(0).describe("Whole-chain retries on failure (0-3). A retry re-navigates to the starting URL and replays every action; each attempt is reported under attempts[]."),
|
|
1066
1069
|
screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
|
|
@@ -1294,7 +1297,7 @@ registerToolIfEnabled("agent", {
|
|
|
1294
1297
|
|
|
1295
1298
|
// Tool: track_changes
|
|
1296
1299
|
registerToolIfEnabled("track_changes", {
|
|
1297
|
-
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1300
|
+
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). A compare always uses the baseline's granularity/customSelectors/excludeSelectors; different values are reported in ignoredOptions. Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1298
1301
|
annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1299
1302
|
// The tool module owns the schema (G5); this is the same shape it validates with.
|
|
1300
1303
|
inputSchema: {
|
|
@@ -1523,7 +1526,11 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1523
1526
|
};
|
|
1524
1527
|
if (verdict.blocked) result.blocked = verdict.blocked;
|
|
1525
1528
|
if (verdict.error) result.error = verdict.error;
|
|
1526
|
-
|
|
1529
|
+
// The requested wait_for and the render grace the browser added on top
|
|
1530
|
+
// are separate numbers; reporting only the grace made wait_for look
|
|
1531
|
+
// unapplied (wait_for:5000 showed as 4 ms, live sweep 2026-10-20).
|
|
1532
|
+
if (scraped.waitedMs > 0) result.waited_ms = scraped.waitedMs;
|
|
1533
|
+
if (scraped.gracedMs > 0) result.render_grace_ms = scraped.gracedMs;
|
|
1527
1534
|
|
|
1528
1535
|
// Screenshots follow the same crawlforge://screenshot/{id} pattern as
|
|
1529
1536
|
// scrape and scrape_with_actions, so the base64 never bloats the result.
|
|
@@ -9,7 +9,7 @@ import { EventEmitter } from 'events';
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
11
11
|
import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode, paceBrowserAction } from '../utils/robotsGate.js';
|
|
12
|
-
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
12
|
+
import { isRef, resolveRef, reresolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
15
15
|
import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
|
|
@@ -55,6 +55,27 @@ const withoutHash = (url) => url.split('#')[0];
|
|
|
55
55
|
* A failed action's message, naming its selector. Playwright names it only in
|
|
56
56
|
* the "Call log:", which is stripped before the model reads the error (R25 3.2).
|
|
57
57
|
*/
|
|
58
|
+
/**
|
|
59
|
+
* The live `.value` of every input, textarea and select, in document order.
|
|
60
|
+
*
|
|
61
|
+
* page.content() serializes attributes, and typing changes the property, not
|
|
62
|
+
* the attribute: `input[type=number]@value` came back null after typing "42"
|
|
63
|
+
* (live sweep 2026-10-20, 3.4). The serialized HTML's controls are matched to
|
|
64
|
+
* these by position (ScrapeWithActionsTool.extractWithSelectors).
|
|
65
|
+
* @param {Page} page - Playwright page
|
|
66
|
+
* @returns {Promise<Array<{tag: string, value: string}>>} [] when the page cannot answer
|
|
67
|
+
*/
|
|
68
|
+
async function readFormValues(page) {
|
|
69
|
+
try {
|
|
70
|
+
return await page.evaluate(() => Array.from(
|
|
71
|
+
document.querySelectorAll('input, textarea, select'),
|
|
72
|
+
(el) => ({ tag: el.localName, value: el.value })
|
|
73
|
+
));
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
function actionErrorMessage(action, error) {
|
|
59
80
|
const firstLine = String(error.message).split('\n')[0];
|
|
60
81
|
return action.selector && !firstLine.includes(action.selector)
|
|
@@ -363,6 +384,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
363
384
|
if (finalState) {
|
|
364
385
|
executionContext.finalHtml = finalState.html;
|
|
365
386
|
executionContext.finalUrl = finalState.url;
|
|
387
|
+
executionContext.finalFormValues = finalState.formValues;
|
|
366
388
|
}
|
|
367
389
|
|
|
368
390
|
this.stats.successfulChains++;
|
|
@@ -433,6 +455,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
433
455
|
...executionContext,
|
|
434
456
|
page: undefined, // Don't store page in history
|
|
435
457
|
finalHtml: undefined,
|
|
458
|
+
finalFormValues: undefined, // what was typed, passwords included
|
|
436
459
|
screenshots: undefined,
|
|
437
460
|
screenshotCount: executionContext.screenshots.length,
|
|
438
461
|
capturedStates: undefined,
|
|
@@ -459,6 +482,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
459
482
|
url,
|
|
460
483
|
finalUrl: executionContext.finalUrl || url,
|
|
461
484
|
finalHtml: executionContext.finalHtml,
|
|
485
|
+
finalFormValues: executionContext.finalFormValues || [],
|
|
462
486
|
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
463
487
|
navigations: executionContext.navigations || [],
|
|
464
488
|
consent: executionContext.consent,
|
|
@@ -579,6 +603,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
579
603
|
afterActionId: actionResult.id,
|
|
580
604
|
url: captured.url,
|
|
581
605
|
html: captured.html,
|
|
606
|
+
formValues: captured.formValues,
|
|
582
607
|
timestamp: Date.now()
|
|
583
608
|
});
|
|
584
609
|
}
|
|
@@ -780,18 +805,19 @@ export class ActionExecutor extends EventEmitter {
|
|
|
780
805
|
}
|
|
781
806
|
|
|
782
807
|
/**
|
|
783
|
-
* The page's URL and HTML, read natively (no
|
|
784
|
-
* works regardless of ALLOW_JAVASCRIPT_EXECUTION)
|
|
785
|
-
* read yields null and a warning;
|
|
808
|
+
* The page's URL and HTML, read natively (no caller-supplied JS, so it
|
|
809
|
+
* works regardless of ALLOW_JAVASCRIPT_EXECUTION), and the live value of
|
|
810
|
+
* each form control. A page that cannot be read yields null and a warning;
|
|
811
|
+
* a page the gate refuses throws.
|
|
786
812
|
* @param {Page} page - Playwright page
|
|
787
813
|
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
788
814
|
* @param {string} what - names the capture in the warning
|
|
789
|
-
* @returns {Promise<{ url: string, html: string }|null>}
|
|
815
|
+
* @returns {Promise<{ url: string, html: string, formValues: Array<{tag: string, value: string}> }|null>}
|
|
790
816
|
*/
|
|
791
817
|
async captureState(page, browserOptions, what) {
|
|
792
818
|
return await this.readGated(page, browserOptions, async () => {
|
|
793
819
|
try {
|
|
794
|
-
return { html: await page.content(), url: page.url() };
|
|
820
|
+
return { html: await page.content(), url: page.url(), formValues: await readFormValues(page) };
|
|
795
821
|
} catch (captureErr) {
|
|
796
822
|
this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
|
|
797
823
|
return null;
|
|
@@ -879,7 +905,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
879
905
|
// "Action timeout".
|
|
880
906
|
const backstopMs = timeout + ACTION_TIMEOUT_GRACE_MS;
|
|
881
907
|
let backstopTimer;
|
|
882
|
-
const executionPromise = this.
|
|
908
|
+
const executionPromise = this.executeActionWithRefRetry(page, action, executionContext);
|
|
883
909
|
const timeoutPromise = new Promise((_, reject) => {
|
|
884
910
|
backstopTimer = setTimeout(
|
|
885
911
|
() => reject(new Error(
|
|
@@ -1058,6 +1084,42 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1058
1084
|
return await settlePage(page, { timeout: remaining });
|
|
1059
1085
|
}
|
|
1060
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Run the action; if its ref names an element the page has since replaced,
|
|
1089
|
+
* re-find the element by the ref's role and name once and run it again.
|
|
1090
|
+
*
|
|
1091
|
+
* Checked up front as well as on the error, because a detached ref does not
|
|
1092
|
+
* always fail fast: Playwright reports "Element is not attached to the DOM"
|
|
1093
|
+
* when the swap lands mid-action, but waits out the whole action timeout for
|
|
1094
|
+
* a ref that was already detached when the action began.
|
|
1095
|
+
* @param {Page} page - Playwright page
|
|
1096
|
+
* @param {Object} action - Action configuration
|
|
1097
|
+
* @param {Object} [executionContext] - Execution context
|
|
1098
|
+
* @returns {Promise<any>} Action result
|
|
1099
|
+
*/
|
|
1100
|
+
async executeActionWithRefRetry(page, action, executionContext) {
|
|
1101
|
+
const ref = [action.selector, action.toElement].find(isRef);
|
|
1102
|
+
let reresolved = false;
|
|
1103
|
+
if (ref) {
|
|
1104
|
+
let matches = 1;
|
|
1105
|
+
try {
|
|
1106
|
+
matches = await this.elementLocator(page, ref).count();
|
|
1107
|
+
} catch (_) {
|
|
1108
|
+
// A stale or unknown ref: the action itself reports it.
|
|
1109
|
+
}
|
|
1110
|
+
if (matches === 0) reresolved = await reresolveRef(page, ref);
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
return await this.executeActionByType(page, action, executionContext);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
if (!ref || reresolved || !/not attached to the DOM/i.test(error?.message) ||
|
|
1116
|
+
!(await reresolveRef(page, ref))) {
|
|
1117
|
+
throw error;
|
|
1118
|
+
}
|
|
1119
|
+
return await this.executeActionByType(page, action, executionContext);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1061
1123
|
/**
|
|
1062
1124
|
* Execute action based on its type
|
|
1063
1125
|
* @param {Page} page - Playwright page
|
|
@@ -1442,14 +1504,22 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1442
1504
|
const startedAt = Date.now();
|
|
1443
1505
|
const timeout = this.actionTimeout(action);
|
|
1444
1506
|
|
|
1507
|
+
// Both checks run before the page is touched, so a target refused here
|
|
1508
|
+
// leaves the page where it was.
|
|
1445
1509
|
await assertUrlAllowed(action.url, { resolveDns: true });
|
|
1446
1510
|
const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
|
|
1447
1511
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1512
|
+
const previousUrl = page.url();
|
|
1513
|
+
try {
|
|
1514
|
+
await this.navigateToUrl(page, action.url, {
|
|
1515
|
+
waitUntil: action.waitUntil,
|
|
1516
|
+
timeout,
|
|
1517
|
+
browserOptions: executionContext?.browserOptions
|
|
1518
|
+
});
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
await this.restoreAfterRefusal(page, previousUrl, error, executionContext?.browserOptions);
|
|
1521
|
+
throw error;
|
|
1522
|
+
}
|
|
1453
1523
|
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1454
1524
|
|
|
1455
1525
|
// Consent handling gets at most what is left of the action's deadline, so
|
|
@@ -1477,6 +1547,41 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1477
1547
|
};
|
|
1478
1548
|
}
|
|
1479
1549
|
|
|
1550
|
+
/**
|
|
1551
|
+
* Put the page back after a navigation refused partway.
|
|
1552
|
+
*
|
|
1553
|
+
* A target refused up front never leaves the page. A redirect hop to a
|
|
1554
|
+
* blocked address is refused only once the browser asks for it — by the
|
|
1555
|
+
* guarded proxy — and by then the page is gone: blanked by
|
|
1556
|
+
* assertNavigationAllowed, or on the browser's error page when the proxy
|
|
1557
|
+
* refused an https tunnel. The browser cannot undo a committed navigation, so
|
|
1558
|
+
* the page that was left is loaded again (through the same gate). That keeps
|
|
1559
|
+
* a session on its page and its cookies, but not on state that lived only in
|
|
1560
|
+
* the page, and the error says which of the two happened (live sweep
|
|
1561
|
+
* 2026-10-20, 3.3).
|
|
1562
|
+
* @param {Page} page - Playwright page
|
|
1563
|
+
* @param {string} previousUrl - where the page was before the navigation
|
|
1564
|
+
* @param {Error} error - the navigation's failure; its message is extended
|
|
1565
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` for the reload's gate)
|
|
1566
|
+
* @returns {Promise<void>}
|
|
1567
|
+
*/
|
|
1568
|
+
async restoreAfterRefusal(page, previousUrl, error, browserOptions) {
|
|
1569
|
+
const refused = gateRefusalCode(error) || /ERR_TUNNEL_CONNECTION_FAILED/.test(error?.message);
|
|
1570
|
+
if (!refused || !/^https?:\/\//i.test(previousUrl)) return;
|
|
1571
|
+
// A refused tunnel's error page commits after goto() has already rejected;
|
|
1572
|
+
// let it land first, or it interrupts the reload.
|
|
1573
|
+
if (page.url() === previousUrl) {
|
|
1574
|
+
await page.waitForURL((url) => url.href !== previousUrl, { timeout: 2000 }).catch(() => {});
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
await this.navigateToUrl(page, previousUrl, { browserOptions });
|
|
1578
|
+
error.message += ` The page was returned to ${previousUrl} by loading it again; ` +
|
|
1579
|
+
'state held only in the page (unsaved form input, script state) was lost.';
|
|
1580
|
+
} catch (_) {
|
|
1581
|
+
error.message += ` The page could not be returned to ${previousUrl} and is now ${page.url()}.`;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1480
1585
|
/**
|
|
1481
1586
|
* What the navigation that just finished reached: the page, or a wall. The
|
|
1482
1587
|
* same verdict the chain's final document gets, run on this document with
|
|
@@ -27,6 +27,7 @@ import { fenceUntrusted } from '../utils/untrustedContent.js';
|
|
|
27
27
|
import { stealthDocumentVerdict as documentVerdict } from '../utils/stealthVerdict.js';
|
|
28
28
|
import { aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
29
|
import { AGENT_MAX_ESCALATIONS } from '../tools/agent/escalation.js';
|
|
30
|
+
import { siteLabel, corroboratedOrigins, updateLabelledDates } from '../tools/agent/sourceChecks.js';
|
|
30
31
|
|
|
31
32
|
const DEFAULT_WALL_CLOCK_MS = 120_000;
|
|
32
33
|
const DEFAULT_MAX_STEPS = 5;
|
|
@@ -161,13 +162,15 @@ export function unverifiedUrls(answer, citable, sourceText = '') {
|
|
|
161
162
|
});
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
/**
|
|
165
|
+
/** The http(s) links on a parsed page: absolute href and anchor text. */
|
|
165
166
|
function pageLinks($, baseUrl) {
|
|
166
167
|
const links = [];
|
|
167
168
|
$('a[href]').each((_, el) => {
|
|
168
169
|
try {
|
|
169
170
|
const url = new URL($(el).attr('href'), baseUrl);
|
|
170
|
-
if (url.protocol === 'http:' || url.protocol === 'https:')
|
|
171
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
172
|
+
links.push({ url: url.href, text: $(el).text().replace(/\s+/g, ' ').trim() });
|
|
173
|
+
}
|
|
171
174
|
} catch {
|
|
172
175
|
// An unparsable href cites nothing.
|
|
173
176
|
}
|
|
@@ -175,6 +178,38 @@ function pageLinks($, baseUrl) {
|
|
|
175
178
|
return links;
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
/** Most links one source lists under its text in the synthesis prompt. */
|
|
182
|
+
const SOURCE_LINKS_MAX = 8;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The links a source lists under its text in the synthesis prompt. Page text
|
|
186
|
+
* carries no link targets: the model could name the current HN top story but
|
|
187
|
+
* not link it, and cited an item URL from memory (R25 re-check, 2026-10-19).
|
|
188
|
+
* Listed: links whose anchor text is printed in the excerpt the model reads
|
|
189
|
+
* and is long enough to name something (three words: a title, not "97
|
|
190
|
+
* comments" or "next"), links to the page itself left out. Those whose anchor
|
|
191
|
+
* text carries one of the task's terms come first, then page order.
|
|
192
|
+
*
|
|
193
|
+
* @param {{url: string, text: string}[]} links
|
|
194
|
+
* @param {string} excerpt the source text the prompt carries
|
|
195
|
+
* @param {string} pageUrl
|
|
196
|
+
* @param {string[]} terms the task's terms, lowercase
|
|
197
|
+
* @returns {{url: string, text: string}[]}
|
|
198
|
+
*/
|
|
199
|
+
export function linksForSynthesis(links, excerpt, pageUrl, terms) {
|
|
200
|
+
const self = citationKey(pageUrl);
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
const listed = (links || []).filter(({ url, text }) => {
|
|
203
|
+
const key = citationKey(url);
|
|
204
|
+
if (!key || key === self || seen.has(key)) return false;
|
|
205
|
+
if (text.split(' ').length < 3 || !excerpt.includes(text)) return false;
|
|
206
|
+
seen.add(key);
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
const onTask = link => terms.some(t => link.text.toLowerCase().includes(t));
|
|
210
|
+
return [...listed.filter(onTask), ...listed.filter(l => !onTask(l))].slice(0, SOURCE_LINKS_MAX);
|
|
211
|
+
}
|
|
212
|
+
|
|
178
213
|
/**
|
|
179
214
|
* Current-state task gate: prompts about the live "now" ("right now",
|
|
180
215
|
* "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
@@ -246,13 +281,6 @@ export function isCurrentStateTask(prompt) {
|
|
|
246
281
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
247
282
|
}
|
|
248
283
|
|
|
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
284
|
/**
|
|
257
285
|
* True when a search result is the entity's own site: its domain is the
|
|
258
286
|
* entity's name (python.org for "Python"), or it is a site root whose title
|
|
@@ -546,15 +574,22 @@ export class AgentOrchestrator {
|
|
|
546
574
|
// live front page, which rarely states the fact asked for: "what does
|
|
547
575
|
// Southwest charge for a first checked bag" fetched southwest.com's home,
|
|
548
576
|
// booking and careers pages and answered that the fee is not stated
|
|
549
|
-
// (R20, 2026-09-07).
|
|
550
|
-
//
|
|
551
|
-
//
|
|
577
|
+
// (R20, 2026-09-07). Add the task's own words as a second query so the
|
|
578
|
+
// page that states the fact is in the queue too. A one-word name is also
|
|
579
|
+
// ambiguous: "Latest stable Rust version and its release date" searched
|
|
580
|
+
// "Rust" first and got the game and the film (live sweep, 2026-10-20).
|
|
581
|
+
// Then the task's words, its noun phrase intact, are searched first. The
|
|
582
|
+
// entity query is searched either way: the domain vote and live-root
|
|
583
|
+
// promotion below read its results.
|
|
584
|
+
const entityQuery = searchQueries[0];
|
|
585
|
+
const oneWordEntity = !/\s/.test(entityQuery.trim());
|
|
552
586
|
if (currentState && searchQueries.length === 1) {
|
|
553
587
|
try {
|
|
554
588
|
const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
|
|
555
589
|
const factQuery = clampSearchQuery(compactSearchTopic(prompt));
|
|
556
|
-
if (factQuery && factQuery.toLowerCase() !==
|
|
557
|
-
searchQueries.
|
|
590
|
+
if (factQuery && factQuery.toLowerCase() !== entityQuery.toLowerCase()) {
|
|
591
|
+
if (oneWordEntity) searchQueries.unshift(factQuery);
|
|
592
|
+
else searchQueries.push(factQuery);
|
|
558
593
|
}
|
|
559
594
|
} catch { /* the entity query alone is the pre-R20 behaviour */ }
|
|
560
595
|
}
|
|
@@ -622,7 +657,6 @@ export class AgentOrchestrator {
|
|
|
622
657
|
const official = new Set();
|
|
623
658
|
// Only the bare entity query votes: the fact query added above returns
|
|
624
659
|
// guides and news sites, which must not outvote the official domain.
|
|
625
|
-
const entityQuery = searchQueries[0];
|
|
626
660
|
for (const s of searchResults) {
|
|
627
661
|
if (s.query !== entityQuery) continue;
|
|
628
662
|
try {
|
|
@@ -631,6 +665,22 @@ export class AgentOrchestrator {
|
|
|
631
665
|
if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
|
|
632
666
|
} catch { /* unparsable result url */ }
|
|
633
667
|
}
|
|
668
|
+
// For a one-word name, a site the task's own search does not surface is
|
|
669
|
+
// a namesake, not the subject: rust.facepunch.com, a root titled "Rust",
|
|
670
|
+
// passed as official for the Rust language and was blamed in the
|
|
671
|
+
// degraded reason (live sweep, 2026-10-20).
|
|
672
|
+
const onTopic = oneWordEntity
|
|
673
|
+
? corroboratedOrigins(originCounts.keys(), searchResults.filter(s => s.query !== entityQuery).map(s => s.url))
|
|
674
|
+
: new Set(originCounts.keys());
|
|
675
|
+
for (const origin of official) if (!onTopic.has(origin)) official.delete(origin);
|
|
676
|
+
// The namesakes' pages are not fetched either: the game's site is not a
|
|
677
|
+
// source for the language's release.
|
|
678
|
+
for (let i = urlQueue.length - 1; i >= 0; i--) {
|
|
679
|
+
try {
|
|
680
|
+
if (!namedUrls.has(urlQueue[i]) && originCounts.has(new URL(urlQueue[i]).origin) &&
|
|
681
|
+
!onTopic.has(new URL(urlQueue[i]).origin)) urlQueue.splice(i, 1);
|
|
682
|
+
} catch { /* unparsable url stays */ }
|
|
683
|
+
}
|
|
634
684
|
// The entity's own site wins over any vote count; only when no result
|
|
635
685
|
// is official does the vote decide, and a tied vote decides nothing —
|
|
636
686
|
// with one result per origin it would just promote the top-ranked one.
|
|
@@ -638,6 +688,7 @@ export class AgentOrchestrator {
|
|
|
638
688
|
let bestCount = 0;
|
|
639
689
|
let tied = false;
|
|
640
690
|
for (const [origin, count] of originCounts) {
|
|
691
|
+
if (!onTopic.has(origin)) continue;
|
|
641
692
|
if (official.size > 0 && !official.has(origin)) continue;
|
|
642
693
|
// Strict > keeps the earliest-seen (top-ranked) origin on ties.
|
|
643
694
|
if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
|
|
@@ -665,6 +716,8 @@ export class AgentOrchestrator {
|
|
|
665
716
|
// URLs an answer may cite: every evidence page's URL (requested and
|
|
666
717
|
// final) and the links on it.
|
|
667
718
|
const citable = [];
|
|
719
|
+
/** evidence url -> the links on that page, for the synthesis prompt. */
|
|
720
|
+
const linksOf = new Map();
|
|
668
721
|
let urlsFetched = 0;
|
|
669
722
|
let step = 0;
|
|
670
723
|
const warnings = [];
|
|
@@ -700,7 +753,9 @@ export class AgentOrchestrator {
|
|
|
700
753
|
text: truncate(plain.textContent),
|
|
701
754
|
step
|
|
702
755
|
});
|
|
703
|
-
|
|
756
|
+
const links = pageLinks(plain.$, plain.finalUrl);
|
|
757
|
+
linksOf.set(plain.finalUrl, links);
|
|
758
|
+
citable.push(url, plain.finalUrl, ...links.map(l => l.url));
|
|
704
759
|
continue;
|
|
705
760
|
}
|
|
706
761
|
// A real page that is simply off-topic: nothing to retry.
|
|
@@ -745,7 +800,9 @@ export class AgentOrchestrator {
|
|
|
745
800
|
via: 'stealth',
|
|
746
801
|
step
|
|
747
802
|
});
|
|
748
|
-
|
|
803
|
+
const links = pageLinks(load(stealth.html || ''), stealth.url || url);
|
|
804
|
+
linksOf.set(stealth.url || url, links);
|
|
805
|
+
citable.push(url, stealth.url || url, ...links.map(l => l.url));
|
|
749
806
|
continue;
|
|
750
807
|
}
|
|
751
808
|
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})` : ''}`}`);
|
|
@@ -782,7 +839,12 @@ export class AgentOrchestrator {
|
|
|
782
839
|
const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
783
840
|
.filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
|
|
784
841
|
const combinedText = orderedEvidence
|
|
785
|
-
.map(e =>
|
|
842
|
+
.map(e => {
|
|
843
|
+
const excerpt = sliceForSynthesis(e.text, perSourceCap, sliceTerms);
|
|
844
|
+
const links = linksForSynthesis(linksOf.get(e.url), excerpt, e.url, sliceTerms);
|
|
845
|
+
return `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${excerpt}` +
|
|
846
|
+
(links.length > 0 ? `\nLinks on this page:\n${links.map(l => `- ${l.text}: ${l.url}`).join('\n')}` : '');
|
|
847
|
+
})
|
|
786
848
|
.join('\n\n');
|
|
787
849
|
|
|
788
850
|
if (!combinedText.trim()) {
|
|
@@ -851,6 +913,7 @@ export class AgentOrchestrator {
|
|
|
851
913
|
let unverified = [];
|
|
852
914
|
let unverifiedLinks = [];
|
|
853
915
|
let forumOnlyVersions = [];
|
|
916
|
+
let updateDates = [];
|
|
854
917
|
// Distinct from `degraded`: a run can be degraded because its version
|
|
855
918
|
// claims are unsupported and still have had its provenance checked.
|
|
856
919
|
let synthesisFailed = false;
|
|
@@ -880,7 +943,8 @@ export class AgentOrchestrator {
|
|
|
880
943
|
: '') +
|
|
881
944
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
882
945
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
883
|
-
`- Cite the exact
|
|
946
|
+
`- Cite the exact URL(s) you used: a source URL, or the URL an item has under "Links on this page".\n` +
|
|
947
|
+
`- For a date, use the one the sources give for the event itself (e.g. "released", "release date"), never a page's "last updated" or "modified" date, and quote the sentence or table row it came from.\n` +
|
|
884
948
|
`- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
|
|
885
949
|
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
886
950
|
`Provide a clear, concise answer.`;
|
|
@@ -888,6 +952,29 @@ export class AgentOrchestrator {
|
|
|
888
952
|
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
889
953
|
answer = text;
|
|
890
954
|
|
|
955
|
+
// A page's "Last updated on 16 October 2026" is not the release date
|
|
956
|
+
// its table gives (15 Oct 2026), and the provenance check below passes
|
|
957
|
+
// it: the string is on the page (live sweep, 2026-10-20). A date the
|
|
958
|
+
// sources state only as an update date gets one corrective rewrite
|
|
959
|
+
// before the provenance check; what survives it is flagged below.
|
|
960
|
+
updateDates = updateLabelledDates(answer, evidence, prompt);
|
|
961
|
+
if (updateDates.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
|
|
962
|
+
const dateRetryPrompt =
|
|
963
|
+
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
964
|
+
`It gives ${updateDates.map(d => `"${d.date}"`).join(', ')}, which the sources state only as the date a page was last updated or modified. ` +
|
|
965
|
+
`Rewrite the answer using the date the sources give for the event itself (e.g. "released", "release date", the date in the row of the version asked about), ` +
|
|
966
|
+
`and quote the sentence or table row it came from. If the sources give no such date, say so.`;
|
|
967
|
+
try {
|
|
968
|
+
const { text: rewritten } = await this._getSamplingClient().complete(dateRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
969
|
+
if (rewritten && rewritten.trim()) {
|
|
970
|
+
answer = rewritten;
|
|
971
|
+
updateDates = updateLabelledDates(answer, evidence, prompt);
|
|
972
|
+
}
|
|
973
|
+
} catch {
|
|
974
|
+
// Keep the first answer; it is flagged below.
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
891
978
|
// A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
|
|
892
979
|
// published January 18, 2024" — none of it on any fetched page (R17,
|
|
893
980
|
// 2026-09-04). Versions, dates and counts in the answer must appear in
|
|
@@ -967,18 +1054,40 @@ export class AgentOrchestrator {
|
|
|
967
1054
|
}
|
|
968
1055
|
}
|
|
969
1056
|
|
|
1057
|
+
// A provenance or version rewrite above may have changed the dates.
|
|
1058
|
+
updateDates = updateLabelledDates(answer, evidence, prompt);
|
|
1059
|
+
if (updateDates.length > 0) {
|
|
1060
|
+
answer +=
|
|
1061
|
+
`\n\nDate warning: ${updateDates.map(d => `"${d.date}"`).join(', ')} ` +
|
|
1062
|
+
`${updateDates.length === 1 ? 'is' : 'are'} the date a source page was last updated, not the date of the event asked about.`;
|
|
1063
|
+
if (!degraded) {
|
|
1064
|
+
degraded = true;
|
|
1065
|
+
degradedReason =
|
|
1066
|
+
`The answer gives a page-update date as the date asked for: ` +
|
|
1067
|
+
updateDates.map(d => `"${d.date}" is labelled as an update date on ${d.url}`).join('; ') + '.';
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
970
1071
|
// A promoted live page is only worth its +1000 if the answer came from
|
|
971
1072
|
// it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
|
|
972
1073
|
// Main Page, and the HN question from thehackernews.com, both with
|
|
973
1074
|
// degraded:false. The answer counts as the live page's when it cites
|
|
974
|
-
// that host
|
|
1075
|
+
// that host, states a value (version, date, number) the page shows, or
|
|
1076
|
+
// names an item the page links (anchor text of three words or more:
|
|
1077
|
+
// the HN title "DeepSeek just open-sourced its inference engine",
|
|
1078
|
+
// 2026-10-20). An invented URL on that host is no citation of it: it is
|
|
1079
|
+
// taken out before the host is looked for.
|
|
975
1080
|
if (currentState && promotedOrigin && !degraded) {
|
|
976
1081
|
const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
|
|
977
1082
|
const liveHost = host(promotedOrigin);
|
|
978
|
-
const
|
|
1083
|
+
const livePages = evidence.filter(e => host(e.url) === liveHost);
|
|
1084
|
+
const liveText = livePages.map(e => e.text || '').join('\n');
|
|
979
1085
|
// unverifiedValues against an empty text lists every value the answer states.
|
|
980
1086
|
const stated = unverifiedValues(answer, '');
|
|
981
|
-
const
|
|
1087
|
+
const cited = unverifiedLinks.reduce((text, url) => text.split(url).join(' '), answer).toLowerCase();
|
|
1088
|
+
const namesLiveItem = livePages.some(e => (linksOf.get(e.url) || [])
|
|
1089
|
+
.some(l => l.text.split(' ').length >= 3 && cited.includes(l.text.toLowerCase())));
|
|
1090
|
+
const fromLivePage = cited.includes(liveHost) || namesLiveItem ||
|
|
982
1091
|
(liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
|
|
983
1092
|
if (!fromLivePage) {
|
|
984
1093
|
degraded = true;
|
|
@@ -1016,7 +1125,8 @@ export class AgentOrchestrator {
|
|
|
1016
1125
|
checked: !synthesisFailed,
|
|
1017
1126
|
unverified,
|
|
1018
1127
|
...(unverifiedLinks.length > 0 ? { unverified_urls: unverifiedLinks } : {}),
|
|
1019
|
-
...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
|
|
1128
|
+
...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {}),
|
|
1129
|
+
...(updateDates.length > 0 ? { update_dates: updateDates } : {})
|
|
1020
1130
|
}
|
|
1021
1131
|
};
|
|
1022
1132
|
}
|
package/src/core/AuthManager.js
CHANGED
|
@@ -769,7 +769,7 @@ class AuthManager {
|
|
|
769
769
|
: 'Browser operation. configure/get_stats/cleanup cost 1 credit each.';
|
|
770
770
|
break;
|
|
771
771
|
case 'browser_session':
|
|
772
|
-
note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call
|
|
772
|
+
note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call's list price is ${projected}.`;
|
|
773
773
|
break;
|
|
774
774
|
case 'serp_rank':
|
|
775
775
|
note = projected === 0
|
|
@@ -811,6 +811,12 @@ class AuthManager {
|
|
|
811
811
|
note += ` redact_pii mode:"model" adds ${REDACT_PII_MODEL_CREDITS} once for the PERSON/LOCATION pass; the charge drops back when no LLM route answered.`;
|
|
812
812
|
}
|
|
813
813
|
|
|
814
|
+
// Creator mode bills nothing, so withAuth reports _cost.projected and
|
|
815
|
+
// actual as 0; the note must not read as a charge next to them (6.10).
|
|
816
|
+
if (this.isCreatorMode()) {
|
|
817
|
+
note += ` This install is not billed (creator mode): the figures above are list prices, and _cost.projected and actual are 0.`;
|
|
818
|
+
}
|
|
819
|
+
|
|
814
820
|
return { projected, note };
|
|
815
821
|
}
|
|
816
822
|
|