crawlforge-mcp-server 6.23.2 → 6.25.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 (38) hide show
  1. package/package.json +4 -4
  2. package/server.js +21 -11
  3. package/src/core/ActionExecutor.js +14 -0
  4. package/src/core/pageCache.js +249 -0
  5. package/src/schemas/toolOutputSchemas.js +18 -1
  6. package/src/server/inlineThreshold.js +12 -5
  7. package/src/server/requestContext.js +40 -0
  8. package/src/server/withAuth.js +84 -9
  9. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  10. package/src/skills/agent-skills/crawlforge-brand-style-guide/SKILL.md +107 -0
  11. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  12. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  13. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  14. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -1
  15. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  16. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  17. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  18. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  19. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -0
  20. package/src/tools/basic/_fetch.js +35 -4
  21. package/src/tools/basic/extractLinks.js +3 -2
  22. package/src/tools/basic/extractMetadata.js +3 -2
  23. package/src/tools/basic/extractText.js +3 -2
  24. package/src/tools/basic/fetchUrl.js +3 -2
  25. package/src/tools/basic/scrapeStructured.js +3 -2
  26. package/src/tools/extract/_fetchAndParse.js +47 -18
  27. package/src/tools/extract/extractEmbeddedState.js +58 -7
  28. package/src/tools/extract/extractStructured.js +4 -1
  29. package/src/tools/extract/extractWithLlm.js +3 -0
  30. package/src/tools/scrape/_brandingColor.js +185 -0
  31. package/src/tools/scrape/_brandingComputed.js +262 -0
  32. package/src/tools/scrape/_brandingExtractor.js +62 -90
  33. package/src/tools/scrape/_brandingTokens.js +350 -0
  34. package/src/tools/scrape/unifiedScrape.js +61 -17
  35. package/src/utils/canonicalUrl.js +29 -0
  36. package/src/utils/fetchLadder.js +3 -3
  37. package/src/utils/maxAgeParam.js +18 -0
  38. package/src/utils/robotsGate.js +24 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.23.2",
3
+ "version": "6.25.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",
@@ -135,9 +135,9 @@
135
135
  "sentiment": "5.0.2",
136
136
  "turndown": "^7.2.4",
137
137
  "turndown-plugin-gfm": "^1.0.2",
138
- "undici": "^7.24.0",
138
+ "undici": "^7.30.1",
139
139
  "winston": "^3.11.0",
140
- "zod": "^4.5.4"
140
+ "zod": "^4.6.7"
141
141
  },
142
142
  "optionalDependencies": {
143
143
  "camoufox": "^0.1.19",
@@ -151,7 +151,7 @@
151
151
  "jest": "^30.3.0"
152
152
  },
153
153
  "overrides": {
154
- "undici": "^7.24.0",
154
+ "undici": "^7.30.1",
155
155
  "underscore": "^1.13.8",
156
156
  "qs": "^6.14.2",
157
157
  "path-to-regexp": "^8.4.2",
package/server.js CHANGED
@@ -60,6 +60,7 @@ import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
60
60
  import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
61
61
  import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
62
62
  import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
63
+ import { MAX_AGE_PARAM, PAGE_CACHE_SENTENCE } from "./src/utils/maxAgeParam.js"; // repeat-call reduction 1.5
63
64
  import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
64
65
  import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
65
66
  import { markPreflightRefusal, preflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
@@ -113,7 +114,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
113
114
  // Create the server
114
115
  const server = new McpServer({
115
116
  name: "crawlforge",
116
- version: "6.23.2",
117
+ version: "6.25.0",
117
118
  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
119
  homepage: "https://www.crawlforge.dev",
119
120
  icon: "https://www.crawlforge.dev/icon.png",
@@ -427,20 +428,21 @@ const refusedByGate = (result) => result?.success === false && preflightRefusal(
427
428
 
428
429
  // Tool: fetch_url
429
430
  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; the result reports redirected and final_url. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
431
+ 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." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
431
432
  annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
432
433
  inputSchema: {
433
434
  url: z.string().url().describe("The URL to fetch content from"),
434
435
  headers: z.record(z.string()).optional().describe("Custom HTTP headers to include in the request"),
435
436
  timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)"),
436
437
  ...COMPLIANCE_PARAMS,
438
+ ...MAX_AGE_PARAM,
437
439
  ...MAX_INLINE_CHARS_PARAM
438
440
  }
439
441
  }, withAuth("fetch_url", fetchUrlHandler));
440
442
 
441
443
  // Tool: extract_text
442
444
  registerToolIfEnabled("extract_text", {
443
- description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
445
+ description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
444
446
  annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
445
447
  inputSchema: {
446
448
  url: z.string().url().describe("The URL to extract text from"),
@@ -451,6 +453,7 @@ registerToolIfEnabled("extract_text", {
451
453
  max_length: z.number().int().min(1).max(1000000).optional().describe("Maximum characters of text or markdown to return; a longer result is cut, ends with \"...\" and carries truncated:true"),
452
454
  ...EXTRACT_ESCALATION_SHAPE,
453
455
  ...COMPLIANCE_PARAMS,
456
+ ...MAX_AGE_PARAM,
454
457
  ...REDACT_PII_PARAM
455
458
  }
456
459
  }, withAuth("extract_text", createExtractTextHandler({
@@ -459,7 +462,7 @@ registerToolIfEnabled("extract_text", {
459
462
 
460
463
  // Tool: extract_links
461
464
  registerToolIfEnabled("extract_links", {
462
- description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
465
+ description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
463
466
  annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
464
467
  inputSchema: {
465
468
  url: z.string().url().describe("The URL to extract links from"),
@@ -467,6 +470,7 @@ registerToolIfEnabled("extract_links", {
467
470
  base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
468
471
  ...EXTRACT_ESCALATION_SHAPE,
469
472
  ...COMPLIANCE_PARAMS,
473
+ ...MAX_AGE_PARAM,
470
474
  ...MAX_INLINE_CHARS_PARAM
471
475
  }
472
476
  }, withAuth("extract_links", createExtractLinksHandler({
@@ -475,23 +479,25 @@ registerToolIfEnabled("extract_links", {
475
479
 
476
480
  // Tool: extract_metadata
477
481
  registerToolIfEnabled("extract_metadata", {
478
- description: "Use this for a page's SEO metadata only: title, meta description, Open Graph tags, canonical URL, schema.org data. `url` is the final URL; redirected:true (with requested_url) says a redirect moved the request to another page. Not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"metadata\"] returns both in one fetch. Cost: 1 credit. Example: extract_metadata({url: \"https://example.com\"})",
482
+ description: "Use this for a page's SEO metadata only: title, meta description, Open Graph tags, canonical URL, schema.org data. `url` is the final URL; redirected:true (with requested_url) says a redirect moved the request to another page. Not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"metadata\"] returns both in one fetch." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: extract_metadata({url: \"https://example.com\"})",
479
483
  annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
480
484
  inputSchema: {
481
485
  url: z.string().url().describe("The URL to extract metadata from"),
482
486
  json_ld_types: z.array(z.string()).optional().describe("Filter the returned JSON-LD to nodes of these schema.org types, e.g. [\"Product\",\"Offer\"]. Subtypes match their parent: \"Event\" returns MusicEvent, \"Offer\" returns AggregateOffer, \"ItemList\" returns BreadcrumbList. Nodes are found at any depth, including inside @graph and nested inside a parent node; a match nested inside another returned node comes back inside it, not again on its own. When set, json_ld carries only the matching nodes instead of the raw dump, and json_ld_type_counts reports how many matched per requested type, nested ones included. Documented types: ItemList, Product, Offer, Event, JobPosting, RealEstateListing — any other schema.org type is matched exactly."),
483
487
  ...COMPLIANCE_PARAMS,
488
+ ...MAX_AGE_PARAM,
484
489
  ...MAX_INLINE_CHARS_PARAM
485
490
  }
486
491
  }, withAuth("extract_metadata", extractMetadataHandler));
487
492
 
488
493
  // Tool: extract_embedded_state
489
494
  registerToolIfEnabled("extract_embedded_state", {
490
- description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__, and React Server Component payloads with references between rows resolved plus a data_rows index of the rows carrying data), Nuxt (Nuxt 3's __NUXT_DATA__ decoded), SvelteKit, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), ytInitialData and ytInitialPlayerResponse, Inertia, Shopify, JSON data-* attributes and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree, keys_only:true to see the first two levels of keys before choosing one, or find:\"<key>\" to get every path where a field of that name lives, each ready to pass back as `path`. A result over max_inline_chars comes back as a preview (whole lines of the JSON), data_keys and a result_handle; read the rest with read_result json_path, e.g. \"data.next_data.props\". Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page, run the same parser and also read the globals off window (window_state: __NEXT_DATA__, __NUXT__, ytInitialData and others, read after JavaScript ran) - projected at 7, charged 2 when the plain fetch worked. Not for the rendered text of a page (scrape) or for sites built without a framework payload. Cost: 2 credits. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
495
+ description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__, and React Server Component payloads with references between rows resolved plus a data_rows index of the rows carrying data), Nuxt (Nuxt 3's __NUXT_DATA__ decoded), SvelteKit, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), ytInitialData and ytInitialPlayerResponse, Inertia, Shopify, JSON data-* attributes and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree, keys_only:true to see the first two levels of keys before choosing one, or find:\"<key>\" to get every path where a field of that name lives, each ready to pass back as `path`. A result over max_inline_chars comes back as a preview (whole lines of the JSON), data_keys and a result_handle; read the rest with read_result json_path, e.g. \"data.next_data.props\". Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page, run the same parser and also read the globals off window (window_state: __NEXT_DATA__, __NUXT__, ytInitialData and others, read after JavaScript ran) - projected at 7, charged 2 when the plain fetch worked. Not for the rendered text of a page (scrape) or for sites built without a framework payload." + PAGE_CACHE_SENTENCE + " Cost: 2 credits. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
491
496
  annotations: { title: "Extract Embedded State", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
492
497
  inputSchema: {
493
498
  ...EMBEDDED_STATE_INPUT_SHAPE,
494
499
  ...COMPLIANCE_PARAMS,
500
+ ...MAX_AGE_PARAM,
495
501
  ...MAX_INLINE_CHARS_PARAM
496
502
  }
497
503
  }, withAuth("extract_embedded_state", createExtractEmbeddedStateHandler({
@@ -500,7 +506,7 @@ registerToolIfEnabled("extract_embedded_state", {
500
506
 
501
507
  // Tool: scrape_structured
502
508
  registerToolIfEnabled("scrape_structured", {
503
- description: "Use this when you know the exact CSS selectors for the data you want - e.g. a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. By default each selector is matched independently across the whole page, so the returned arrays are NOT row-aligned: data.price[0] need not belong to the same row as data.name[0]. Pass row_selector to get aligned records instead - one object per row, null for a field the row lacks. Not for pages whose markup varies or where you cannot name the selectors (extract_structured, LLM-driven). Cost: 2 credits. Example: scrape_structured({url: \"https://shop.com/products\", row_selector: \".product-card\", selectors: {price: \".price\", name: \".product-title\"}})",
509
+ description: "Use this when you know the exact CSS selectors for the data you want - e.g. a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. By default each selector is matched independently across the whole page, so the returned arrays are NOT row-aligned: data.price[0] need not belong to the same row as data.name[0]. Pass row_selector to get aligned records instead - one object per row, null for a field the row lacks. Not for pages whose markup varies or where you cannot name the selectors (extract_structured, LLM-driven)." + PAGE_CACHE_SENTENCE + " Cost: 2 credits. Example: scrape_structured({url: \"https://shop.com/products\", row_selector: \".product-card\", selectors: {price: \".price\", name: \".product-title\"}})",
504
510
  annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
505
511
  inputSchema: {
506
512
  url: z.string().url().describe("The URL to scrape"),
@@ -510,7 +516,8 @@ registerToolIfEnabled("scrape_structured", {
510
516
  ])).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"),
511
517
  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"),
512
518
  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"),
513
- ...COMPLIANCE_PARAMS
519
+ ...COMPLIANCE_PARAMS,
520
+ ...MAX_AGE_PARAM
514
521
  }
515
522
  }, withAuth("scrape_structured", scrapeStructuredHandler));
516
523
 
@@ -823,7 +830,7 @@ registerToolIfEnabled("analyze_content", {
823
830
 
824
831
  // Tool: extract_structured
825
832
  registerToolIfEnabled("extract_structured", {
826
- description: "Use this to get a specific data shape from a page using a JSON schema when you can describe the fields but not their selectors - product details, job listings, event data. Uses an LLM by default; falls back to CSS selectors when no LLM is configured. Not for pages with stable markup whose selectors you know (scrape_structured, no LLM, cheaper). Cost: 3 credits. Example: extract_structured({url: \"https://jobs.example.com/post/123\", schema: {properties: {title: {type:\"string\"}, salary: {type:\"string\"}}, required:[\"title\"]}})",
833
+ description: "Use this to get a specific data shape from a page using a JSON schema when you can describe the fields but not their selectors - product details, job listings, event data. Uses an LLM by default; falls back to CSS selectors when no LLM is configured. Not for pages with stable markup whose selectors you know (scrape_structured, no LLM, cheaper)." + PAGE_CACHE_SENTENCE + " Cost: 3 credits. Example: extract_structured({url: \"https://jobs.example.com/post/123\", schema: {properties: {title: {type:\"string\"}, salary: {type:\"string\"}}, required:[\"title\"]}})",
827
834
  annotations: { title: "Extract Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
828
835
  inputSchema: {
829
836
  url: z.string().url().describe("The URL to extract structured data from"),
@@ -840,6 +847,7 @@ registerToolIfEnabled("extract_structured", {
840
847
  fallbackToSelectors: z.boolean().optional().default(true).describe("Fall back to CSS selector extraction if LLM is unavailable"),
841
848
  selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction"),
842
849
  ...COMPLIANCE_PARAMS,
850
+ ...MAX_AGE_PARAM,
843
851
  ...VERIFY_NUMBERS_PARAM
844
852
  },
845
853
  outputSchema: OUTPUT_SCHEMAS.extract_structured
@@ -858,7 +866,7 @@ registerToolIfEnabled("extract_structured", {
858
866
 
859
867
  // Tool: extract_with_llm
860
868
  registerToolIfEnabled("extract_with_llm", {
861
- description: "Extract data from a URL or text using a natural-language prompt. Defaults to a local Ollama model (http://localhost:11434, no API key required) and picks an installed model itself; pass `model` to choose one, or provider: \"openai\" or \"anthropic\" with the matching API key to use a cloud model instead. Not for known selectors (scrape_structured) or a schema-shaped result (extract_structured). Call list_ollama_models only when a model name is rejected. Cost: 3 credits plus your LLM provider's own charge.",
869
+ description: "Extract data from a URL or text using a natural-language prompt. Defaults to a local Ollama model (http://localhost:11434, no API key required) and picks an installed model itself; pass `model` to choose one, or provider: \"openai\" or \"anthropic\" with the matching API key to use a cloud model instead. Not for known selectors (scrape_structured) or a schema-shaped result (extract_structured). Call list_ollama_models only when a model name is rejected." + PAGE_CACHE_SENTENCE + " Cost: 3 credits plus your LLM provider's own charge.",
862
870
  annotations: { title: "Extract With LLM", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
863
871
  inputSchema: {
864
872
  url: z.string().url().optional().describe("URL to fetch and extract from (one of url/content required)"),
@@ -869,6 +877,7 @@ registerToolIfEnabled("extract_with_llm", {
869
877
  model: z.string().optional().describe("Override the model. For ollama, pass a name returned by list_ollama_models (e.g. 'llama3.2', 'qwen2.5:7b'). Defaults: openai='gpt-4o-mini', anthropic='claude-haiku-4-5-20251001', ollama=$OLLAMA_DEFAULT_MODEL, else the best installed model for extraction (gemma3:12b, then gemma3:4b, gpt-oss:20b, mistral:7b, llama3.2, qwen2.5:3b)."),
870
878
  maxTokens: z.number().optional().default(4096).describe("Maximum output tokens"),
871
879
  ...COMPLIANCE_PARAMS,
880
+ ...MAX_AGE_PARAM,
872
881
  ...VERIFY_NUMBERS_PARAM
873
882
  }
874
883
  }, withAuth("extract_with_llm", async (params) => {
@@ -1231,7 +1240,7 @@ registerToolIfEnabled("deep_research", {
1231
1240
 
1232
1241
  // Tool: scrape (D4 D1 — unified multi-format single-fetch)
1233
1242
  registerToolIfEnabled("scrape", {
1234
- description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it comes back walled does the stealth browser retry and return the page - projected at 7, charged 2 when the plain fetch worked. Not for raw API/JSON bodies (fetch_url), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape). Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
1243
+ description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (brand style: colour roles, buttons and type from the rendered page, plus logo, fonts and grouped design tokens), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it comes back walled does the stealth browser retry and return the page - projected at 7, charged 2 when the plain fetch worked. Not for raw API/JSON bodies (fetch_url), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape)." + PAGE_CACHE_SENTENCE + " Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
1235
1244
  annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1236
1245
  // Claude Code tool search loads only names + instructions at session start; this flag
1237
1246
  // ships the full definition too, so the first call needs no ToolSearch round-trip.
@@ -1240,6 +1249,7 @@ registerToolIfEnabled("scrape", {
1240
1249
  inputSchema: {
1241
1250
  ...SCRAPE_INPUT_SHAPE,
1242
1251
  ...COMPLIANCE_PARAMS,
1252
+ ...MAX_AGE_PARAM,
1243
1253
  ...MAX_INLINE_CHARS_PARAM,
1244
1254
  ...REDACT_PII_PARAM
1245
1255
  },
@@ -387,6 +387,18 @@ export class ActionExecutor extends EventEmitter {
387
387
  executionContext.finalFormValues = finalState.formValues;
388
388
  }
389
389
 
390
+ // A read the server itself asks for (the branding format's computed
391
+ // styles). It has to be a function, so no tool call can supply one:
392
+ // JSON carries no functions, and caller-written scripts still go
393
+ // through executeJavaScript and ALLOW_JAVASCRIPT_EXECUTION.
394
+ if (typeof browserOptions.pageProbe === 'function') {
395
+ try {
396
+ executionContext.probe = await this.readGated(page, browserOptions, () => page.evaluate(browserOptions.pageProbe));
397
+ } catch (probeErr) {
398
+ executionContext.probeError = probeErr.message;
399
+ }
400
+ }
401
+
390
402
  this.stats.successfulChains++;
391
403
  executionContext.success = true;
392
404
 
@@ -492,6 +504,8 @@ export class ActionExecutor extends EventEmitter {
492
504
  attempts: executionContext.attempts,
493
505
  screenshots: executionContext.screenshots,
494
506
  capturedStates: executionContext.capturedStates || [],
507
+ probe: executionContext.probe,
508
+ probeError: executionContext.probeError,
495
509
  metadata: executionContext.metadata,
496
510
  stats: {
497
511
  totalActions: executionContext.results.length,
@@ -0,0 +1,249 @@
1
+ /**
2
+ * pageCache — one copy of each fetched page per process, shared by the two
3
+ * fetch seams (REPEAT_CALL_REDUCTION_PLAN Phase 1).
4
+ *
5
+ * fetchWithTimeout (fetch_url, extract_metadata, scrape_structured,
6
+ * extract_text, extract_links) and fetchAndParse (scrape,
7
+ * extract_embedded_state, extract_structured, extract_with_llm, agent) both go
8
+ * through throughPageCache, so asking for the same page again with other
9
+ * formats, paths or selectors reuses the first fetch. What is kept is the
10
+ * response (status, final URL, headers, body), never a tool's output.
11
+ *
12
+ * Bounded by bytes, least recently used first out (CRAWLFORGE_PAGE_CACHE_MB,
13
+ * default 64; 0 turns it off). Memory only. A copy is served while it is
14
+ * younger than `maxAge` (default 10 min, D2); `maxAge: 0` fetches fresh and
15
+ * stores the result, `noCache: true` neither reads nor writes.
16
+ *
17
+ * Off on the hosted HTTP server and for internal (REST-proxy) requests until
18
+ * tenant isolation lands (D3), and off under NODE_ENV=test unless a test turns
19
+ * it on with _resetPageCache({ enabled: true }) (likewise inside node --test).
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { canonicalUrl } from '../utils/canonicalUrl.js';
24
+ import { config } from '../constants/config.js';
25
+ import { preflightDecision } from '../utils/robotsGate.js';
26
+ import { plainVerdict } from '../tools/basic/_targetFailure.js';
27
+ import { isInternalRequest, recordPageCache } from '../server/requestContext.js';
28
+
29
+ export const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
30
+ const DEFAULT_CACHE_MB = 64;
31
+ // A bot wall is tens of KB. Larger bodies skip the verdict's second parse.
32
+ const VERDICT_MAX_BYTES = 1024 * 1024;
33
+ // Requests that carry a credential are never served from or stored in the cache.
34
+ const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']);
35
+
36
+ /** key -> entry, least recently used first */
37
+ const entries = new Map();
38
+ /** in-flight key -> Promise<{ result, page, entry }> */
39
+ const inflight = new Map();
40
+ let totalBytes = 0;
41
+ let testOverride = null;
42
+
43
+ function maxBytes() {
44
+ if (testOverride?.maxBytes != null) return testOverride.maxBytes;
45
+ const raw = process.env.CRAWLFORGE_PAGE_CACHE_MB;
46
+ const mb = raw === undefined || raw === '' ? DEFAULT_CACHE_MB : Number(raw);
47
+ return (Number.isFinite(mb) && mb >= 0 ? mb : DEFAULT_CACHE_MB) * 1024 * 1024;
48
+ }
49
+
50
+ /** Whether this process and this request may use the cache at all. */
51
+ export function pageCacheActive() {
52
+ // node --test marks its test processes with NODE_TEST_CONTEXT.
53
+ const underTest = process.env.NODE_ENV === 'test' || process.env.NODE_TEST_CONTEXT !== undefined;
54
+ if (underTest && !testOverride?.enabled) return false;
55
+ if (process.argv.includes('--http') || process.env.MCP_HTTP === 'true') return false;
56
+ if (isInternalRequest()) return false;
57
+ return maxBytes() > 0;
58
+ }
59
+
60
+ /**
61
+ * The fetch, canonicalised: URL, method, robots setting, identity, the
62
+ * caller's headers and API key, and which seam asked (the two send different
63
+ * Accept headers). Hashed, so no header value or key is kept in memory as a
64
+ * key. Null when the request must not touch the cache.
65
+ */
66
+ export function pageCacheKey(url, options, variant) {
67
+ const canonical = canonicalUrl(url);
68
+ if (!canonical) return null;
69
+ if (options.method && String(options.method).toUpperCase() !== 'GET') return null;
70
+ const headers = Object.entries(options.headers || {})
71
+ .map(([name, value]) => [name.toLowerCase(), String(value)])
72
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
73
+ if (headers.some(([name]) => CREDENTIAL_HEADERS.has(name))) return null;
74
+ const respect = options.respectRobots === undefined
75
+ ? config.crawling.respectRobots
76
+ : options.respectRobots !== false;
77
+ const userAgent = typeof options.userAgent === 'string' ? options.userAgent.trim() || null : null;
78
+ return createHash('sha256')
79
+ .update(JSON.stringify([variant, 'GET', canonical, respect, userAgent, headers, options.apiKey ?? null]))
80
+ .digest('hex');
81
+ }
82
+
83
+ /** The status, final URL, headers and body of a fetched response, as stored. */
84
+ export function pageFromResponse(response, body) {
85
+ const headers = [];
86
+ if (typeof response.headers?.forEach === 'function') {
87
+ response.headers.forEach((value, name) => headers.push([name.toLowerCase(), value]));
88
+ } else {
89
+ // A headers object with only get() (test doubles): keep what the seams read.
90
+ for (const name of ['content-type', 'cache-control']) {
91
+ const value = response.headers?.get?.(name);
92
+ if (value != null) headers.push([name, value]);
93
+ }
94
+ }
95
+ return {
96
+ status: response.status,
97
+ statusText: response.statusText ?? '',
98
+ url: response.url,
99
+ redirected: response.redirected === true,
100
+ headers,
101
+ body,
102
+ fetchedAt: Date.now()
103
+ };
104
+ }
105
+
106
+ export function pageHeader(page, name) {
107
+ return page.headers.find(([key]) => key === name)?.[1] ?? null;
108
+ }
109
+
110
+ /** 2xx, storable, and not a bot wall (the same verdict extract_text applies). */
111
+ function cacheable(page) {
112
+ if (!(page.status >= 200 && page.status <= 299) || typeof page.body !== 'string') return false;
113
+ if (/\bno-store\b/i.test(pageHeader(page, 'cache-control') ?? '')) return false;
114
+ if (Buffer.byteLength(page.body) <= VERDICT_MAX_BYTES) {
115
+ const verdict = plainVerdict(
116
+ { status: page.status, url: page.url, _body: page.body, headers: new Headers(page.headers) },
117
+ page.url
118
+ );
119
+ if (verdict?.blocked) return false;
120
+ }
121
+ return true;
122
+ }
123
+
124
+ function store(key, page) {
125
+ if (!cacheable(page)) return null;
126
+ const entry = {
127
+ status: page.status,
128
+ statusText: page.statusText,
129
+ url: page.url,
130
+ redirected: page.redirected,
131
+ headers: page.headers,
132
+ body: page.body,
133
+ responseTime: page.responseTime,
134
+ fetchedAt: page.fetchedAt
135
+ };
136
+ entry.bytes = Buffer.byteLength(entry.body) + Buffer.byteLength(JSON.stringify(entry.headers)) + (entry.url?.length ?? 0);
137
+ const cap = maxBytes();
138
+ if (entry.bytes > cap) return null;
139
+ drop(key);
140
+ entries.set(key, entry);
141
+ totalBytes += entry.bytes;
142
+ for (const [oldKey] of entries) {
143
+ if (totalBytes <= cap) break;
144
+ drop(oldKey);
145
+ }
146
+ return entry;
147
+ }
148
+
149
+ function drop(key) {
150
+ const entry = entries.get(key);
151
+ if (!entry) return;
152
+ entries.delete(key);
153
+ totalBytes -= entry.bytes;
154
+ }
155
+
156
+ function lookup(key, maxAge) {
157
+ const entry = entries.get(key);
158
+ if (!entry || Date.now() - entry.fetchedAt > maxAge) return null;
159
+ entries.delete(key);
160
+ entries.set(key, entry); // most recently used
161
+ return entry;
162
+ }
163
+
164
+ /**
165
+ * fetchAndParse asks for HTML (Accept: text/html…); fetchWithTimeout sends no
166
+ * Accept. A server that answered "any type" with HTML has an HTML variant, so
167
+ * a request preferring HTML gets the same: that copy serves fetchAndParse. The
168
+ * other direction is not safe (an API may answer `*\/*` with JSON).
169
+ */
170
+ function lookupHtmlFromPlain(url, options, maxAge) {
171
+ const entry = lookup(pageCacheKey(url, options, 'plain'), maxAge);
172
+ const type = (entry && pageHeader(entry, 'content-type')) || '';
173
+ return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(type) ? entry : null;
174
+ }
175
+
176
+ async function serveHit(url, options, entry, fromEntry) {
177
+ // The blocklist and robots.txt are asked again; only the request is skipped.
178
+ const decision = await preflightDecision(url, options);
179
+ if (entry.redirected && entry.url) await preflightDecision(entry.url, options, { redirectedFrom: url });
180
+ recordPageCache('hit', entry.fetchedAt);
181
+ return fromEntry(entry, decision.warnings);
182
+ }
183
+
184
+ /**
185
+ * Serve a page from the cache, or fetch it once and keep it.
186
+ *
187
+ * @param {string} url
188
+ * @param {{ maxAge?: number, noCache?: boolean, headers?: object, userAgent?: string,
189
+ * respectRobots?: boolean, apiKey?: string, method?: string, errorDocuments?: boolean }} options
190
+ * @param {'plain'|'html'} variant the seam asking
191
+ * @param {() => Promise<{ result: any, page: object }>} fetchFresh the seam's
192
+ * own fetch: its usual return value plus the page as pageFromResponse gives it
193
+ * @param {(entry: object, warnings: string[]) => any} fromEntry the seam's
194
+ * return value built from a stored page
195
+ */
196
+ export async function throughPageCache(url, options, variant, fetchFresh, fromEntry) {
197
+ if (!pageCacheActive()) return (await fetchFresh()).result;
198
+
199
+ const key = options.noCache === true ? null : pageCacheKey(url, options, variant);
200
+ if (key === null) {
201
+ const { result, page } = await fetchFresh();
202
+ recordPageCache('bypass', page?.fetchedAt);
203
+ return result;
204
+ }
205
+
206
+ const maxAge = Number.isFinite(options.maxAge) && options.maxAge >= 0 ? options.maxAge : DEFAULT_MAX_AGE_MS;
207
+ // A caller that reads error bodies (errorDocuments) must not inherit the
208
+ // throw of one that does not, so the two never share an in-flight fetch.
209
+ const flightKey = options.errorDocuments ? `${key}:errors` : key;
210
+
211
+ if (maxAge > 0) {
212
+ const entry = lookup(key, maxAge) ?? (variant === 'html' ? lookupHtmlFromPlain(url, options, maxAge) : null);
213
+ if (entry) return serveHit(url, options, entry, fromEntry);
214
+ const pending = inflight.get(flightKey);
215
+ if (pending) {
216
+ // A failed fetch rejects every caller waiting on it; nothing is stored.
217
+ const shared = await pending;
218
+ if (shared.entry) return serveHit(url, options, shared.entry, fromEntry);
219
+ // Not storable (non-2xx, a wall): this call makes its own request.
220
+ }
221
+ }
222
+
223
+ const run = fetchFresh().then(({ result, page }) => ({ result, page, entry: page ? store(key, page) : null }));
224
+ if (!inflight.has(flightKey)) inflight.set(flightKey, run);
225
+ try {
226
+ const { result, page } = await run;
227
+ recordPageCache(maxAge > 0 ? 'miss' : 'bypass', page?.fetchedAt);
228
+ return result;
229
+ } finally {
230
+ if (inflight.get(flightKey) === run) inflight.delete(flightKey);
231
+ }
232
+ }
233
+
234
+ /** Bytes currently held (for tests and diagnostics). */
235
+ export function pageCacheBytes() {
236
+ return totalBytes;
237
+ }
238
+
239
+ /**
240
+ * Empty the cache. Tests pass `{ enabled: true }` to turn it on under
241
+ * NODE_ENV=test or node --test, optionally with a small `maxBytes`; no argument restores the
242
+ * defaults.
243
+ */
244
+ export function _resetPageCache(override = null) {
245
+ entries.clear();
246
+ inflight.clear();
247
+ totalBytes = 0;
248
+ testOverride = override;
249
+ }
@@ -26,6 +26,15 @@ const costShape = z.object({
26
26
  projection_note: z.string().optional().describe('Human-readable note about how the cost was projected')
27
27
  }).passthrough().optional().describe('Cost-transparency metadata (D3.5): what this call was projected to cost and what it was charged');
28
28
 
29
+ // `cache` is injected the same way when the call fetched through the page
30
+ // cache (REPEAT_CALL_REDUCTION_PLAN 1.6).
31
+ const cacheShape = z.object({
32
+ state: z.enum(['hit', 'miss', 'bypass']).optional().describe('hit: every page came from this session\'s copy; bypass: fetched fresh on request (maxAge 0) or not cacheable; miss: fetched'),
33
+ fetchedAt: z.string().nullable().optional().describe('When the oldest page used was fetched (ISO 8601)'),
34
+ ageMs: z.number().nullable().optional().describe('Age of that copy in ms'),
35
+ note: z.string().optional().describe('Hits only: how old the copy is and how to force a fresh fetch')
36
+ }).passthrough().optional().describe('Page cache: whether this call reused a copy of the page fetched earlier in the session');
37
+
29
38
  const nextStepShape = z.string().optional().describe('Failed calls only: the tool or fix to try next');
30
39
 
31
40
  // ── result handles (Phase 2) ────────────────────────────────────────────────
@@ -107,7 +116,7 @@ const scrapeFormatShapes = {
107
116
  text: z.string().optional(),
108
117
  links: scrapeLinksShape.optional(),
109
118
  metadata: scrapeMetadataShape.optional(),
110
- branding: z.record(z.unknown()).optional().describe('Static design tokens: colors, fonts, logo'),
119
+ branding: z.record(z.unknown()).optional().describe('Brand style: colour roles, button components and type (computed or inferred), logo, fonts and grouped design tokens'),
111
120
  screenshots: z.array(z.object({}).passthrough()).optional().describe('Present for the "screenshot" format; each item carries a resourceUri once published'),
112
121
  json: z.unknown().optional().describe('Result of the {type:"json"} format (LLM-structured extraction)'),
113
122
  highlights: z.array(scrapeHighlightUnitShape).optional().describe('Result of the {type:"highlights"} format: the units matching the query, best first, verbatim with offsets'),
@@ -144,6 +153,7 @@ const scrapeShape = {
144
153
  redaction: redactionShape,
145
154
  ...resultHandleShape,
146
155
  _cost: costShape,
156
+ cache: cacheShape,
147
157
  next_step: nextStepShape
148
158
  };
149
159
 
@@ -188,6 +198,7 @@ const mapSiteShape = {
188
198
  warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only, or when the result is over max_inline_chars'),
189
199
  ...resultHandleShape,
190
200
  _cost: costShape,
201
+ cache: cacheShape,
191
202
  next_step: nextStepShape
192
203
  };
193
204
 
@@ -223,6 +234,7 @@ const serpRankShape = {
223
234
  cost: z.number().optional().describe('USD charged by DataForSEO for this lookup (separate from CrawlForge credits)'),
224
235
  checkedAt: z.string().optional(),
225
236
  _cost: costShape,
237
+ cache: cacheShape,
226
238
  next_step: nextStepShape
227
239
  };
228
240
 
@@ -277,6 +289,7 @@ const redditSearchShape = {
277
289
  notes: z.array(z.string()).optional().describe('Data-provenance caveats (archive freshness, coverage gaps)'),
278
290
  checkedAt: z.string().optional(),
279
291
  _cost: costShape,
292
+ cache: cacheShape,
280
293
  next_step: nextStepShape
281
294
  };
282
295
 
@@ -339,6 +352,7 @@ const searchWebShape = {
339
352
  warnings: z.array(z.string()).optional().describe('Notes on this result; over max_inline_chars, where the full result is kept and how to read it'),
340
353
  ...resultHandleShape,
341
354
  _cost: costShape,
355
+ cache: cacheShape,
342
356
  next_step: nextStepShape
343
357
  };
344
358
 
@@ -373,6 +387,7 @@ const extractStructuredShape = {
373
387
  skipped: z.string().optional().describe('"empty_source" when there was nothing to check against')
374
388
  }).passthrough().optional(),
375
389
  _cost: costShape,
390
+ cache: cacheShape,
376
391
  next_step: nextStepShape
377
392
  };
378
393
 
@@ -426,6 +441,7 @@ const crawlDeepShape = {
426
441
  warnings: z.array(z.string()).optional().describe('Notes on this result; over max_inline_chars, where the full result is kept and how to read it'),
427
442
  ...resultHandleShape,
428
443
  _cost: costShape,
444
+ cache: cacheShape,
429
445
  next_step: nextStepShape
430
446
  };
431
447
 
@@ -465,6 +481,7 @@ const readResultShape = {
465
481
  truncated: z.boolean().optional().describe('search: more matches than returned; json_path: value replaced by a preview'),
466
482
  warnings: z.array(z.string()).optional(),
467
483
  _cost: costShape,
484
+ cache: cacheShape,
468
485
  next_step: nextStepShape
469
486
  };
470
487
 
@@ -222,10 +222,10 @@ function warningsOf(resultObject) {
222
222
  * @param {string} toolName
223
223
  * @param {object} resultObject — the parsed JSON result
224
224
  * @param {object} params — the tool params (max_inline_chars, url, operation)
225
- * @param {{ store: import('../core/ResultStore.js').ResultStore, env?: object }} deps
225
+ * @param {{ store: import('../core/ResultStore.js').ResultStore, env?: object, reserve?: number }} deps
226
226
  * @returns {{ result: object, stored: boolean }}
227
227
  */
228
- export function applyInlineThreshold(toolName, resultObject, params, { store, env = process.env }) {
228
+ export function applyInlineThreshold(toolName, resultObject, params, { store, env = process.env, reserve = COST_BLOCK_RESERVE }) {
229
229
  const unchanged = { result: resultObject, stored: false };
230
230
  const config = INLINE_THRESHOLD_TOOLS[toolName];
231
231
  if (!config || (config.when && !config.when(params))) return unchanged;
@@ -254,7 +254,7 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
254
254
  const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
255
255
 
256
256
  if (toolName === 'extract_embedded_state') {
257
- return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }), stored: true };
257
+ return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith, reserve }), stored: true };
258
258
  }
259
259
 
260
260
  const preview = text.slice(0, maxInline);
@@ -316,6 +316,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
316
316
  */
317
317
  export const COST_BLOCK_RESERVE = 400;
318
318
 
319
+ /**
320
+ * Extra room when the call went through the page cache: the `cache` block
321
+ * with its hit note (~250 chars) and the longer projection note (~70 chars).
322
+ * withAuth passes it only then, so other calls keep their preview budget.
323
+ */
324
+ export const CACHE_BLOCK_RESERVE = 350;
325
+
319
326
  /** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
320
327
  export function embeddedDataKeys(data) {
321
328
  if (Array.isArray(data)) return `array(${data.length})`;
@@ -359,7 +366,7 @@ export function wholeLinePreview(text, budget) {
359
366
  * under the handle for read_result json_path. The REST route shapes it the
360
367
  * same way, measured as the compact JSON it sends; keep the two in step.
361
368
  */
362
- function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
369
+ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith, reserve = COST_BLOCK_RESERVE }) {
363
370
  const dataKeys = embeddedDataKeys(resultObject.data);
364
371
  const firstKey = Array.isArray(dataKeys) && dataKeys.length > 0 ? dataKeys[0] : null;
365
372
  const example = firstKey && /^[A-Za-z_$][\w$]*$/.test(firstKey) ? ` Read one payload with operation "json_path", path "data.${firstKey}".` : '';
@@ -386,7 +393,7 @@ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expir
386
393
  };
387
394
  // The preview's value is one line of the pretty-printed text and escapes
388
395
  // the same way there as in compact JSON; `""` is its empty value's quotes.
389
- const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length + COST_BLOCK_RESERVE;
396
+ const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length + reserve;
390
397
  const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
391
398
  return { ...shaped, preview, ...tail };
392
399
  }