diff --git a/CHANGELOG.md b/CHANGELOG.md index 950a9c6..221164e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Self-hosters should consult [`MIGRATION.md`](./MIGRATION.md) when upgrading acro --- +## [Unreleased] + +### Changed + +- **`query` is now described by when to use it, not by how it works.** The MCP `read_url` schema, the bundled Claude Code skill, the README and the in-app help page all described the parameter mechanically ("return only the sections relevant to this text", "BM25 over the converted Markdown"), which told an agent what the feature does but never that it should reach for it. In practice the parameter went unused: a model reading the old description had no trigger condition to match against. All four surfaces now lead with the trigger - when you need specific information from a page rather than the whole document, pass the question you are trying to answer, in natural language - name the payoff (typically 70-95% fewer tokens on long pages), and frame the full-page fetch as the case that needs a reason (summarizing, translating, archiving). `max_tokens` additionally states that it has no effect without `query`. Text only: no parameter, default, validation or response shape changed, and nothing changes for an existing integration except how likely an agent is to use the parameter at all. + +--- + ## [3.8.0] - 2026-07-31 ### Added diff --git a/README.md b/README.md index 2171f73..0544fdb 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,8 @@ Returns clean Markdown (text/markdown). Optional query params: nocache=true bypass the 1h cache and refetch render=force|skip override the auto Playwright fallback pdf=ocr high-quality PDF conversion (tables) - query= return only the sections relevant to + query= got a specific question? pass it and get back only + the matching sections (70-95% fewer tokens) max_tokens=N budget for query= (default 600, 64-20000) lang=de|en language for the comments section header @@ -370,8 +371,9 @@ Reddit URLs are auto-detected (incl. redd.it short links and /s/ shares). Hacker News URLs are auto-detected too — items, comment permalinks, and the front/newest/ask/show/jobs listings. Use this whenever you would otherwise fetch raw HTML — the markdown is -much cleaner and saves significant context window space. For a long page -where you only need one thing, add query= and get just that. +much cleaner and saves significant context window space. When you only +need specific information rather than the whole document, pass your +question as query= and get back just the relevant sections. ``` ### 2. Claude Code skill @@ -509,8 +511,8 @@ for it. | `pdf` | — | `ocr` → route PDFs through the [OCR tier](#high-quality-pdf-ocr). Bypasses cache. | | `yt_timecodes` / `yt_chunk` | see [YouTube](#youtube-transcripts) | Transcript format overrides. Bypass cache when set. | | `lang` | `de` | Comments-section header language (`de` or `en`). | -| `query` | — | Return only the sections relevant to this text instead of the full page (see [Query-scoped extraction](#query-scoped-extraction)). Empty/whitespace-only is treated as absent — output is unchanged. | -| `max_tokens` | `600` | Token budget for `query` extraction (`64`–`20000`). Only validated when `query` is non-empty; an invalid value returns `400`. | +| `query` | — | Set this when you need specific information from a page rather than the whole document: pass the question, get back only the matching sections (see [Query-scoped extraction](#query-scoped-extraction)). Empty/whitespace-only is treated as absent — output is unchanged. | +| `max_tokens` | `600` | Token budget for `query` extraction (`64`–`20000`). No effect without `query`; raise it when the answer likely spans several sections. Only validated when `query` is non-empty; an invalid value returns `400`. | Both `query` and `max_tokens` are also available on the MCP `read_url` tool. @@ -527,9 +529,11 @@ Both `query` and `max_tokens` are also available on the MCP `read_url` tool. ### Query-scoped extraction -Pass `?query=` on `GET /api` (or the `query` param on the MCP `read_url` -tool) to get back only the sections of the page relevant to that text instead -of the full Markdown body: +When you have a specific question about a long page, you rarely need the whole +document. Pass the question as `?query=` on `GET /api` (or the `query` +param on the MCP `read_url` tool) to get back only the sections relevant to it +instead of the full Markdown body - typically 70-95% fewer tokens on long +pages: ``` GET /api?url=https://example.com/long-article&query=how+does+caching+work diff --git a/lib/mcp.js b/lib/mcp.js index 36ba8f8..0faab5d 100644 --- a/lib/mcp.js +++ b/lib/mcp.js @@ -73,7 +73,7 @@ export function createMcpServer({ 'read_url', { description: - 'Fetch a URL and return clean Markdown. Handles web pages (Cloudflare-Markdown, Readability+Trafilatura, headless-Chromium fallback for JS-heavy sites), Reddit threads with comment trees, documents (PDF, Word, PowerPoint, Excel, EPUB, CSV, …), YouTube videos (title, description, transcript with timecodes), and image/audio URLs (AI caption / transcription) — document, YouTube, and media support depend on server configuration. Use this whenever you would otherwise fetch raw HTML or download a document — it produces dramatically cleaner content and saves context window space.', + 'Fetch a URL and return clean Markdown. Handles web pages (Cloudflare-Markdown, Readability+Trafilatura, headless-Chromium fallback for JS-heavy sites), Reddit threads with comment trees, documents (PDF, Word, PowerPoint, Excel, EPUB, CSV, …), YouTube videos (title, description, transcript with timecodes), and image/audio URLs (AI caption / transcription) — document, YouTube, and media support depend on server configuration. Use this whenever you would otherwise fetch raw HTML or download a document — it produces dramatically cleaner content and saves context window space. If you only need specific information from a page rather than the whole document, pass `query` - you get back just the relevant sections, typically 70-95% fewer tokens on long pages.', inputSchema: { url: z.string().url().describe('The URL to fetch'), comments: z.boolean().optional().describe('Include Reddit comments. Default true (ignored for non-Reddit URLs).'), @@ -86,8 +86,8 @@ export function createMcpServer({ yt_timecodes: z.enum(['links', 'plain', 'none']).optional().describe('YouTube transcript timecode style (links = clickable timestamps, default).'), yt_chunk: z.number().int().min(0).optional().describe('YouTube transcript block size in seconds; 0 = per original snippet.'), pdf_ocr: z.boolean().optional().describe('Use high-quality OCR for PDFs instead of the default text extraction (requires PULLMD_PDF_OCR_API_KEY). Default false.'), - query: z.string().min(1).optional().describe('Return only the sections relevant to this query (BM25 over the converted markdown). Omit for the full page.'), - max_tokens: z.number().int().min(64).max(20000).optional().describe('Token budget for query extraction (default 600).'), + query: z.string().min(1).optional().describe('Set this when you need specific information from a page rather than the whole document - pass the question you are trying to answer, in natural language. Returns only the matching sections, typically 70-95% fewer tokens on long pages. Prefer this over fetching the full page whenever the request is a specific question. Omit only when you genuinely need the complete document (summarizing, translating, archiving).'), + max_tokens: z.number().int().min(64).max(20000).optional().describe('Token budget for `query` extraction (default 600, range 64-20000). No effect without `query`. Raise it when the answer likely spans several sections; leave the default for single-fact lookups.'), }, }, async ({ diff --git a/public/help.html b/public/help.html index 24c20ce..90942e7 100644 --- a/public/help.html +++ b/public/help.html @@ -447,7 +447,8 @@

Option 1 — Universal: paste a prompt

render=force|skip override the auto headless-Chromium fallback extractor=readability|trafilatura|playwright force one extractor pdf=ocr high-quality PDF conversion (tables) - query= return only the sections relevant to + query=<text> got a specific question? pass it and get back only + the matching sections (70-95% fewer tokens) max_tokens=N budget for query= (default 600, 64-20000) lang=de|en language for the comments section header @@ -464,8 +465,9 @@

Option 1 — Universal: paste a prompt

Hacker News URLs are auto-detected too — items, comment permalinks, and the front/newest/ask/show/jobs listings. Use this whenever you would otherwise fetch raw HTML — the markdown is -much cleaner and saves significant context window space. For a long page -where you only need one thing, add query= and get just that. +much cleaner and saves significant context window space. When you only +need specific information rather than the whole document, pass your +question as query= and get back just the relevant sections.

Option 2 — Claude Code Skill

@@ -704,12 +706,12 @@

API-ParameterAPI parametersLanguage for the comments header (de or en). query— - Liefert nur die zur Anfrage passenden Abschnitte statt der ganzen Seite, siehe Query-Extraktion. - Returns only the sections relevant to this text instead of the whole page, see Query extraction. + Für eine konkrete Frage an eine lange Seite: Frage hier übergeben, zurück kommen nur die passenden Abschnitte statt der ganzen Seite, siehe Query-Extraktion. + For a specific question about a long page: pass the question here and get back only the matching sections instead of the whole page, see Query extraction. max_tokens600 - Token-Budget für query (64–20000). Wird nur geprüft, wenn query gesetzt ist; ein ungültiger Wert liefert 400. - Token budget for query (64–20000). Only validated when query is set; an invalid value returns 400. + Token-Budget für query (64–20000). Ohne query wirkungslos; höher setzen, wenn die Antwort über mehrere Abschnitte verteilt liegt. Wird nur geprüft, wenn query gesetzt ist; ein ungültiger Wert liefert 400. + Token budget for query (64–20000). No effect without query; raise it when the answer likely spans several sections. Only validated when query is set; an invalid value returns 400. @@ -772,8 +774,8 @@

Response-HeaderResponse headers

Query-ExtraktionQuery extraction?query=

-

Bei langen Seiten braucht ein Agent selten alles. ?query= liefert nur die Abschnitte, die zur Frage passen:

-

On long pages an agent rarely needs everything. ?query= returns just the sections that match:

+

Wer eine konkrete Frage an eine lange Seite hat, braucht selten die ganze Seite. ?query= nimmt die Frage in natürlicher Sprache entgegen und liefert nur die passenden Abschnitte - auf langen Seiten typischerweise 70-95 % weniger Tokens. Die ganze Seite lohnt sich dann noch, wenn man sie wirklich komplett braucht: zusammenfassen, übersetzen, archivieren.

+

When you have a specific question about a long page, you rarely need the whole page. ?query= takes that question in natural language and returns only the matching sections - typically 70-95% fewer tokens on long pages. Fetching the full page still makes sense when you genuinely need all of it: summarizing, translating, archiving.

example diff --git a/skill/pullmd/skills/pullmd/SKILL.md b/skill/pullmd/skills/pullmd/SKILL.md index fb6b61a..f7de5b4 100644 --- a/skill/pullmd/skills/pullmd/SKILL.md +++ b/skill/pullmd/skills/pullmd/SKILL.md @@ -54,8 +54,8 @@ The response is `text/markdown` — ready to use as-is. | `pdf` | — | `ocr` → high-quality OCR conversion for PDFs (table-grade output; needs a server-side OCR key). Bypasses cache. | | `yt_timecodes` | `links` | YouTube transcripts: `links` (clickable timestamps), `plain` (`[MM:SS]`), `none`. | | `yt_chunk` | `30` | YouTube transcript block size in seconds; `0` = per original snippet. | -| `query` | — | Return only the sections relevant to this text instead of the whole page (BM25 over the converted Markdown, no LLM). Empty/absent = full page, unchanged. | -| `max_tokens` | `600` | Token budget for `query` (64–20000). Only validated when `query` is set. | +| `query` | — | Set this when you need specific information from a page rather than the whole document: pass the question you are trying to answer, in natural language, and get back only the matching sections - typically 70-95% fewer tokens on long pages. No LLM involved. Empty/absent = full page, unchanged. | +| `max_tokens` | `600` | Token budget for `query` (64–20000). No effect without `query`. Raise it when the answer likely spans several sections; leave the default for single-fact lookups. Only validated when `query` is set. | | `lang` | `de` | Language for the comments-section header (`de` or `en`). | **Response headers worth checking:** @@ -134,7 +134,7 @@ Need to read a URL? - PullMD caches results for 1 hour. Use `nocache=true` if you need the latest version. `render=force|skip`, `extractor=`, `pdf=ocr`, and explicit `yt_*` params also bypass the cache. - For pages with important comments or discussions (forums, HN, Reddit), add `comments=true` to include the discussion below the post. Reddit and Hacker News URLs are auto-detected and use dedicated pipelines; `comment_depth` controls how deep the tree goes. -- When the page is long and you only need one aspect of it, add `query=` instead of pulling the whole thing — it returns just the matching sections and reports the token saving in `X-Extract-*`. It falls back to the full page when nothing matches, so it is safe to try. +- When you need specific information from a page rather than the whole document, add `query=`, phrased in natural language - it returns just the matching sections (typically 70-95% fewer tokens on long pages) and reports the saving in `X-Extract-*`. It falls back to the full page when nothing matches, so it is safe to try. Omit it only when you genuinely need the complete document - summarizing, translating, archiving. - For JS-rendered apps where the auto-fallback didn't fire (e.g. content lives in a tab the heuristic didn't reach), `render=force` re-extracts via headless Chromium. - Reddit URLs are automatically detected (incl. `redd.it` short links and `/r//s/` share links) and use a specialized extraction pipeline that handles posts, comments, galleries, and videos. - Add `frontmatter=true` when you want metadata: extraction source and quality always; for Reddit posts also subreddit, author, upvotes, and publish date; for media/YouTube/OCR results duration, image size, and LLM token usage (cost tracking). diff --git a/test/mcp-query-extract.test.js b/test/mcp-query-extract.test.js index 546eab0..a7ba3ce 100644 --- a/test/mcp-query-extract.test.js +++ b/test/mcp-query-extract.test.js @@ -96,6 +96,28 @@ describe('MCP read_url - query-extract: tool schema', () => { assert.ok(props.query, 'query param must be present'); assert.ok(props.max_tokens, 'max_tokens param must be present'); }); + + // Discoverability regression: agents only reach for `query` if the schema + // tells them WHEN to use it. Assert on content markers, not exact strings, + // so wording can be polished but a silent revert to a mechanism-only + // description ("BM25 over the converted markdown") fails here. + it('describes query by its trigger condition, not its mechanism', async () => { + const app = createApp({ cache: createCache(':memory:') }); + const j = await listTools(app); + const readUrl = j.result.tools.find(t => t.name === 'read_url'); + const props = readUrl.inputSchema.properties; + + assert.match(readUrl.description, /query/i, 'tool description must mention query'); + + const q = props.query.description; + assert.match(q, /when you need|Set this when/i, 'query must state its trigger condition'); + assert.ok(!/bm25/i.test(q), 'query description must not name the mechanism (BM25)'); + assert.match(q, /70-95%/, 'query description must keep the token-saving figure'); + assert.match(q, /natural language/i, 'query description must ask for natural language'); + + const mt = props.max_tokens.description; + assert.match(mt, /no effect without/i, 'max_tokens must state it is inert without query'); + }); }); describe('MCP read_url - query-extract: byte-identical without query', () => {