cdp-toolkit 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.5.1] - 2026-09-12
9
+
10
+ ### Fixed
11
+
12
+ - **`extract_page { prompt: "schematron" }` no longer sends `response_format`.** Measured against a locally served open-weight Schematron-8B on `mlx_vlm.server` (llguidance-constrained decoding): with `response_format` present, a real CNN zone came back as `{"stories": []}` in 6 completion tokens; the IDENTICAL messages with `response_format` omitted returned valid, schema-conformant JSON with 5 stories in 285 tokens. A 3-link toy page worked either way, so the collapse only shows on real markup. The schema already rides inline in the schematron prompt, so constrained decoding on top of it was redundant and, on real pages, actively harmful; `response_format` is now omitted entirely (never sent as `null`) in `schematron` mode, unchanged in `html` mode. Since nothing constrains the shape server-side anymore in `schematron` mode, `extractPage` now also rejects a syntactically valid but non-object JSON response (array/null/primitive) with the same invalid-JSON error shape. One new unit test pins `html` mode still sending `response_format`; the existing schematron wire-shape test now asserts its absence (`test/extract.test.ts`).
13
+
8
14
  ## [2.5.0] - 2026-09-12
9
15
 
10
16
  ### Added
package/README.md CHANGED
@@ -254,7 +254,7 @@ That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on
254
254
  With no `name` it returns the grouped catalog of everything this server can run. The whole 48-tool surface:
255
255
 
256
256
  ```
257
- cdp-toolkit 2.5.0 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
257
+ cdp-toolkit 2.5.1 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
258
258
  [listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot
259
259
  [listed] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
260
260
  [listed] cookies (3): list_cookies, set_cookie, delete_cookies
@@ -314,7 +314,7 @@ Measured 2026-09-01: Claude Code 2.1.258 and Codex CLI 0.147.0 both connect and
314
314
  {"tool": "extract_page", "arguments": {"target": "index:0", "schema": {"type": "object", "properties": {"title": {"type": "string", "description": "the page title, exactly as printed"}}}}}
315
315
  ```
316
316
 
317
- **Prompt shape (`prompt`, `CDP_EXTRACT_PROMPT`).** By default (`prompt:"html"`) the cleaned HTML is the *only* user message and your schema rides in `response_format` alone — which is what the **hosted** Schematron API expects, since it injects the schema into the prompt server-side. Point `baseUrl` at a **locally served open-weight Schematron** (e.g. an MLX `schematron8B`) and that breaks: the open weights were fine-tuned on a prompt with the schema *inside* the user message, and a bare-HTML prompt returns garbage even under constrained decoding. `prompt:"schematron"` sends the model card's own messages instead — a `You are a helpful assistant` system message plus a user message carrying the compact `JSON.stringify`'d schema, then the HTML, then `MAKE SURE ITS VALID JSON.` That adds roughly the stringified schema's length to the prompt (`maxChars` caps the **HTML** only; the true count still comes back in `usage.promptTokens`). `response_format` is sent in both modes. The per-call arg wins over `CDP_EXTRACT_PROMPT`, and anything but `html`/`schematron` is rejected before any page or network work.
317
+ **Prompt shape (`prompt`, `CDP_EXTRACT_PROMPT`).** By default (`prompt:"html"`) the cleaned HTML is the *only* user message and your schema rides in `response_format` alone — which is what the **hosted** Schematron API expects, since it injects the schema into the prompt server-side. Point `baseUrl` at a **locally served open-weight Schematron** (e.g. an MLX `schematron8B`) and that breaks: the open weights were fine-tuned on a prompt with the schema *inside* the user message, and a bare-HTML prompt returns garbage even under constrained decoding. `prompt:"schematron"` sends the model card's own messages instead — a `You are a helpful assistant` system message plus a user message carrying the compact `JSON.stringify`'d schema, then the HTML, then `MAKE SURE ITS VALID JSON.` That adds roughly the stringified schema's length to the prompt (`maxChars` caps the **HTML** only; the true count still comes back in `usage.promptTokens`). `schematron` mode sends **no `response_format`** at all — the schema already lives in the prompt, and the client validates only that the response parses as JSON. (Measured 2026-09-12 against a locally served open-weight Schematron-8B under llguidance-constrained decoding: identical messages returned `{"stories": []}` in 6 completion tokens with `response_format` present, versus a valid, schema-conformant 5-story result in 285 tokens with it omitted — constrained decoding on top of an already-inlined schema is redundant and, on real markup, harmful.) The per-call arg wins over `CDP_EXTRACT_PROMPT`, and anything but `html`/`schematron` is rejected before any page or network work.
318
318
 
319
319
  Works on both backends (Chrome and Firefox); see the `extract_page` row in [The tools](#the-tools-29-parity--19-superset--48) for the full parameter set.
320
320
 
package/dist/cli.js CHANGED
@@ -8187,12 +8187,13 @@ function buildExtractionMessages(mode, html, schema) {
8187
8187
  }
8188
8188
  async function callExtractionEndpoint(opts) {
8189
8189
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
8190
+ const promptMode = opts.prompt ?? "html";
8190
8191
  const body = JSON.stringify({
8191
8192
  model: opts.model,
8192
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
8193
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
8193
8194
  temperature: 0,
8194
8195
  max_tokens: COMPLESION_TOKENS,
8195
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
8196
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
8196
8197
  });
8197
8198
  const headers = { "content-type": "application/json" };
8198
8199
  if (opts.apiKey)
@@ -8286,6 +8287,9 @@ async function extractPage(driver, args) {
8286
8287
  } catch (e) {
8287
8288
  throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8288
8289
  }
8290
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
8291
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8292
+ }
8289
8293
  const result = {
8290
8294
  data,
8291
8295
  model: respondedModel,
package/dist/index.js CHANGED
@@ -5589,12 +5589,13 @@ function buildExtractionMessages(mode, html, schema) {
5589
5589
  }
5590
5590
  async function callExtractionEndpoint(opts) {
5591
5591
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
5592
+ const promptMode = opts.prompt ?? "html";
5592
5593
  const body = JSON.stringify({
5593
5594
  model: opts.model,
5594
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
5595
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
5595
5596
  temperature: 0,
5596
5597
  max_tokens: COMPLESION_TOKENS,
5597
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
5598
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
5598
5599
  });
5599
5600
  const headers = { "content-type": "application/json" };
5600
5601
  if (opts.apiKey)
@@ -5688,6 +5689,9 @@ async function extractPage(driver, args) {
5688
5689
  } catch (e) {
5689
5690
  throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
5690
5691
  }
5692
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
5693
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
5694
+ }
5691
5695
  const result = {
5692
5696
  data,
5693
5697
  model: respondedModel,
package/dist/mcp.js CHANGED
@@ -8191,12 +8191,13 @@ function buildExtractionMessages(mode, html, schema) {
8191
8191
  }
8192
8192
  async function callExtractionEndpoint(opts) {
8193
8193
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
8194
+ const promptMode = opts.prompt ?? "html";
8194
8195
  const body = JSON.stringify({
8195
8196
  model: opts.model,
8196
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
8197
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
8197
8198
  temperature: 0,
8198
8199
  max_tokens: COMPLESION_TOKENS,
8199
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
8200
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
8200
8201
  });
8201
8202
  const headers = { "content-type": "application/json" };
8202
8203
  if (opts.apiKey)
@@ -8290,6 +8291,9 @@ async function extractPage(driver, args) {
8290
8291
  } catch (e) {
8291
8292
  throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8292
8293
  }
8294
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
8295
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8296
+ }
8293
8297
  const result = {
8294
8298
  data,
8295
8299
  model: respondedModel,
@@ -11248,7 +11252,7 @@ var TOOL_DOCS = {
11248
11252
  params: {}
11249
11253
  },
11250
11254
  extract_page: {
11251
- description: "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
11255
+ description: "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. In 'schematron' mode NO response_format is sent at all - the schema already lives in the prompt, and the client validates only that the response parses as JSON. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
11252
11256
  params: {
11253
11257
  lease: "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
11254
11258
  target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
@@ -11258,7 +11262,7 @@ var TOOL_DOCS = {
11258
11262
  clean: "HTML cleaning level before the payload is measured and sent. 'standard' (default) strips scripts, styles, hidden elements and other non-content noise. 'aggressive' additionally collapses whitespace, drops nav/footer/boilerplate - use when html_too_large bites or the extractor chases junk. 'none' sends the raw HTML; almost never what you want.",
11259
11263
  maxChars: "Character cap on the cleaned HTML payload. Default 300000 (also settable via CDP_EXTRACT_MAX_CHARS). Exceeding it fails with html_too_large reporting actual vs cap - it does NOT silently truncate, because a half page extracted as though it were whole is a wrong answer that looks right.",
11260
11264
  timeoutMs: "Total budget in ms for the endpoint call. Default 90000, hard max 300000 (CDP_EXTRACT_TIMEOUT_MS overrides the default). Expiry fails with a timeout error, never a hang.",
11261
- prompt: "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); response_format is sent in both modes. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
11265
+ prompt: "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); 'schematron' mode sends NO response_format - the schema lives in the prompt and the client validates only that the response parses as JSON. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
11262
11266
  model: "Model name for the endpoint call. Default 'schematron' on the loopback llm-ferry; override per-call or via CDP_EXTRACT_MODEL.",
11263
11267
  baseUrl: "OpenAI-compatible endpoint base URL (the server appends /chat/completions). DEFAULT IS http://127.0.0.1:8090/v1 - loopback llm-ferry, page content never leaves the machine. Setting this (or CDP_EXTRACT_BASE_URL) to a remote host is an operator decision about where page content may travel; the response never echoes the URL with credentials.",
11264
11268
  savePath: "Write the extracted JSON here instead of returning it inline: the response then carries the file path and usage counts only, keeping large payloads out of the transcript. An absolute path (starting with /) is used as-is; a relative path resolves under the artifact dir (/tmp/cdp-toolkit); missing parents are created."
@@ -11267,7 +11271,7 @@ var TOOL_DOCS = {
11267
11271
  };
11268
11272
 
11269
11273
  // src/version.ts
11270
- var VERSION = "2.5.0";
11274
+ var VERSION = "2.5.1";
11271
11275
 
11272
11276
  // src/mcp.ts
11273
11277
  var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/toolDocs.ts CHANGED
@@ -486,7 +486,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
486
486
  "params": {}
487
487
  },
488
488
  "extract_page": {
489
- "description": "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
489
+ "description": "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. In 'schematron' mode NO response_format is sent at all - the schema already lives in the prompt, and the client validates only that the response parses as JSON. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
490
490
  "params": {
491
491
  "lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
492
492
  "target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
@@ -496,7 +496,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
496
496
  "clean": "HTML cleaning level before the payload is measured and sent. 'standard' (default) strips scripts, styles, hidden elements and other non-content noise. 'aggressive' additionally collapses whitespace, drops nav/footer/boilerplate - use when html_too_large bites or the extractor chases junk. 'none' sends the raw HTML; almost never what you want.",
497
497
  "maxChars": "Character cap on the cleaned HTML payload. Default 300000 (also settable via CDP_EXTRACT_MAX_CHARS). Exceeding it fails with html_too_large reporting actual vs cap - it does NOT silently truncate, because a half page extracted as though it were whole is a wrong answer that looks right.",
498
498
  "timeoutMs": "Total budget in ms for the endpoint call. Default 90000, hard max 300000 (CDP_EXTRACT_TIMEOUT_MS overrides the default). Expiry fails with a timeout error, never a hang.",
499
- "prompt": "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); response_format is sent in both modes. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
499
+ "prompt": "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); 'schematron' mode sends NO response_format - the schema lives in the prompt and the client validates only that the response parses as JSON. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
500
500
  "model": "Model name for the endpoint call. Default 'schematron' on the loopback llm-ferry; override per-call or via CDP_EXTRACT_MODEL.",
501
501
  "baseUrl": "OpenAI-compatible endpoint base URL (the server appends /chat/completions). DEFAULT IS http://127.0.0.1:8090/v1 - loopback llm-ferry, page content never leaves the machine. Setting this (or CDP_EXTRACT_BASE_URL) to a remote host is an operator decision about where page content may travel; the response never echoes the URL with credentials.",
502
502
  "savePath": "Write the extracted JSON here instead of returning it inline: the response then carries the file path and usage counts only, keeping large payloads out of the transcript. An absolute path (starting with /) is used as-is; a relative path resolves under the artifact dir (/tmp/cdp-toolkit); missing parents are created."
@@ -14,10 +14,13 @@
14
14
  * remote host.
15
15
  *
16
16
  * The caller's JSON Schema is BOTH the response contract (sent as
17
- * response_format json_schema strict) AND the extraction prompt: Schematron
18
- * reads the schema's property DESCRIPTIONS as its only instructions. A schema
19
- * with no descriptions anywhere would extract garbage with no hint why, so it
20
- * is rejected up front, before any page work happens.
17
+ * response_format json_schema strict in "html" mode) AND the extraction
18
+ * prompt: Schematron reads the schema's property DESCRIPTIONS as its only
19
+ * instructions. A schema with no descriptions anywhere would extract garbage
20
+ * with no hint why, so it is rejected up front, before any page work happens.
21
+ * In "schematron" mode response_format is OMITTED — the schema already rides
22
+ * inline in the prompt, and constrained decoding on top of it collapses real
23
+ * pages to an empty result (see buildExtractionMessages/callExtractionEndpoint).
21
24
  *
22
25
  * COST IS ALWAYS VISIBLE: every successful call reports the endpoint's token
23
26
  * counts, so a bloated extraction is visible in the answer itself, not
@@ -99,7 +102,9 @@ export interface ExtractPageArgs {
99
102
  * locally served open-weight Schematron was fine-tuned on: without it, an
100
103
  * HTML-only prompt yields garbage even under constrained decoding. It adds
101
104
  * roughly the JSON-stringified schema's length to the prompt. Default from
102
- * CDP_EXTRACT_PROMPT, then "html". response_format is sent in BOTH modes.
105
+ * CDP_EXTRACT_PROMPT, then "html". "schematron" mode sends NO
106
+ * response_format — the schema is already inline in the prompt, and the
107
+ * client validates only that the response parses as JSON.
103
108
  */
104
109
  prompt?: "html" | "schematron";
105
110
  /** Model name override (default CDP_EXTRACT_MODEL, then "schematron"). */
@@ -564,12 +569,23 @@ export async function callExtractionEndpoint(opts: {
564
569
  prompt?: "html" | "schematron";
565
570
  }): Promise<{ content: string; usage: ExtractUsage; model: string; finishReason: string | undefined }> {
566
571
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
572
+ const promptMode = opts.prompt ?? "html";
573
+ // "schematron" mode OMITS response_format entirely (never sends it as null).
574
+ // Measured 2026-09-12: against a locally served open-weight Schematron-8B
575
+ // (llguidance-constrained decoding), the IDENTICAL messages with
576
+ // response_format present returned {"stories": []} in 6 completion tokens
577
+ // for a real CNN zone, while omitting response_format returned valid,
578
+ // schema-conformant JSON with 5 stories (285 tokens) — the schema is
579
+ // already inline in the schematron user message, so constrained decoding
580
+ // on top of it is redundant and, on real markup, harmful.
567
581
  const body = JSON.stringify({
568
582
  model: opts.model,
569
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
583
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
570
584
  temperature: 0,
571
585
  max_tokens: COMPLESION_TOKENS,
572
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } },
586
+ ...(promptMode === "schematron"
587
+ ? {}
588
+ : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }),
573
589
  });
574
590
  const headers: Record<string, string> = { "content-type": "application/json" };
575
591
  if (opts.apiKey) headers.authorization = `Bearer ${opts.apiKey}`;
@@ -715,6 +731,15 @@ export async function extractPage(
715
731
  `extract_page: endpoint returned invalid JSON (${(e as Error).message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`,
716
732
  );
717
733
  }
734
+ // In "schematron" mode nothing constrains the shape server-side (no
735
+ // response_format), so a syntactically valid but non-object payload
736
+ // (array/null/primitive) is caught here with the same invalid-JSON error
737
+ // shape used above.
738
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
739
+ throw new CdpError(
740
+ `extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`,
741
+ );
742
+ }
718
743
 
719
744
  const result: ExtractPageResult = {
720
745
  data,
@@ -759,7 +784,8 @@ export async function extractPage(
759
784
  * method is used, and the live DOM is never mutated (cleaning runs on a clone).
760
785
  *
761
786
  * Upstream dependency (global fetch, zero npm deps): POST ${baseUrl}/chat/completions
762
- * with response_format json_schema strict; default baseUrl is the loopback
787
+ * with response_format json_schema strict in "html" mode only ("schematron"
788
+ * mode omits it — see callExtractionEndpoint); default baseUrl is the loopback
763
789
  * llm-ferry (http://127.0.0.1:8090/v1, model "schematron"). If it is not
764
790
  * running, the network error names the URL and asks "is ferry running?".
765
791
  *
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.5.0";
9
+ export const VERSION = "2.5.1";