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 +6 -0
- package/README.md +2 -2
- package/dist/cli.js +6 -2
- package/dist/index.js +6 -2
- package/dist/mcp.js +9 -5
- package/package.json +1 -1
- package/src/toolDocs.ts +2 -2
- package/src/tools/extract.ts +34 -8
- package/src/version.ts +1 -1
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.
|
|
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`
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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."
|
package/src/tools/extract.ts
CHANGED
|
@@ -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
|
|
18
|
-
* reads the schema's property DESCRIPTIONS as its only
|
|
19
|
-
* with no descriptions anywhere would extract garbage
|
|
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".
|
|
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(
|
|
583
|
+
messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
|
|
570
584
|
temperature: 0,
|
|
571
585
|
max_tokens: COMPLESION_TOKENS,
|
|
572
|
-
|
|
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
|
|
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