Skip to content

feat: opt-in query-scoped extraction (BM25) for GET /api and MCP read_url - #43

Merged
syswave-dev merged 11 commits into
mainfrom
feat/query-extract
Jul 10, 2026
Merged

feat: opt-in query-scoped extraction (BM25) for GET /api and MCP read_url#43
syswave-dev merged 11 commits into
mainfrom
feat/query-extract

Conversation

@syswave-dev

Copy link
Copy Markdown
Collaborator

Summary

Opt-in extract mode: a non-empty query parameter on GET /api and the MCP read_url tool returns only the BM25-relevant markdown sections of the converted page instead of the whole document (typically an ~70-85% token reduction on documentation-style pages; measured 1751 -> 499 tokens on the bundled fixture).

Without query, behavior is byte-identical to v3.3.0 - verified against pre-change golden fixtures committed before the feature was built (test/fixtures/query-extract/golden/, reproduced byte-for-byte at the final commit).

Design

  • Extraction runs on the finished converted markdown (post-convert, pre-frontmatter), on the response copy only - the cache keeps storing the full page, so N queries on one URL cost 1 fetch within the cache TTL.
  • Dependency-free: line-based block parser (lib/markdown-sections.js), BM25 with per-page IDF (lib/bm25.js), selection/reassembly core (lib/query-extract.js).
  • Atomic blocks (code fences, tables, lists, blockquotes) are never split - structurally guaranteed (output is composed only of whole block texts).
  • Ancestor-heading breadcrumbs for context; <!-- … --> elision markers between non-contiguous regions; paragraph-level fallback for pages with <2 headings; whole-page fallback with confidence: low when nothing matches.
  • New params: query, max_tokens (64..20000, default 600). New response metadata: X-Extract-* headers, extract object in format=json, five optional frontmatter fields.
  • Hardening from final review: query-term cap (64 deduped terms) closes a measured CPU-DoS vector on huge heading-poor pages.

Tests

922/922 in a clean worktree (794 baseline + 128 new), including a byte-identity acceptance gate against pre-feature goldens and 1:1 coverage of the spec's acceptance checklist.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J2sCeV2sJ2k4CGwr5mnXr2

syswave-dev and others added 11 commits July 8, 2026 22:22
Captured from the unmodified server (base eab7bd4); capture.mjs documents
the exact requests and normalization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J2sCeV2sJ2k4CGwr5mnXr2
…rkdown-sections.js)

Pure, dependency-free line-based parser (parseBlocks) plus heading-tree
builder (buildSectionTree) - the first building block for the opt-in
query-extract feature, which will later BM25-score these sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
findFenceClose hardcoded the closing-fence leading-indent tolerance to
{0,3} even when the fence was opened via FENCE_OPEN_ANY_INDENT_RE
(unlimited indent, used for fences nested in list items). A fence
indented 4+ spaces inside a list item never found its closer, so the
list block silently swallowed everything after it, including headings.

Parameterize the close-tolerance on which open regex was used: keep
{0,3} for top-level fences, allow unlimited leading whitespace for
list-nested fences.

Also add a one-line regression test asserting 7+ '#' chars is not a
heading (ATX caps at 6) - already correctly handled, test added for
coverage per review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependency-free tokenizer (letters/digits, hyphen/dot-joined
identifiers survive as single tokens) and Okapi BM25 scorer with
Lucene-style per-call IDF, for the opt-in query-extract feature's
section-scoring step (Task 2 of the query-extract SDD plan).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add lib/query-extract.js: estimateTokens + queryExtract, the pure
orchestrating module for the opt-in query-extract feature. Given full
converted markdown and a query, returns only the BM25-relevant sections
(section mode, >=2 headings) or blocks (block mode / oversized-section
descent), reassembled in document order with an elision marker between
non-contiguous regions. Whole-page short-circuit for small inputs and
whole-page fallback (exact input string) on empty/no-match queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gates the BM25-based query-extract feature on a non-empty `query` param
across cache-hit, Reddit, HN, and web response paths: extraction runs on
the response copy only, after cache.put/cache read and before frontmatter
merge and format branching, so the byte-identical no-query default and
cache purity (cache.put always receives the full markdown) are preserved.
Adds X-Extracted/-Confidence/-Sections/-Original-Tokens/-Returned-Tokens
headers, a JSON `extract` object, and five allowlisted frontmatter fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gates on the new `query`/`max_tokens` params (Task 1-4's queryExtract, same
frontmatter field names as GET /api). Wired into all three read_url
body-producing paths (cache-hit, Reddit fresh, web fresh); get_share and
list_recent are untouched. Since MCP has no response headers, a no-match
whole-page fallback is signaled in-band via a marker comment instead of
server.js's X-Extracted/X-Extract-* headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-cutting suite mapping 1:1 to the query-extract handoff checklist:
byte-identity against pre-feature golden files (no query), well-formed
subset (balanced fences, intact table, breadcrumb heading), heading-less
page block-mode fallback, non-contiguous elision-marker well-formedness,
no-match confidence-low fallback, measured token reduction on the fixture
page (1751 -> 499 tokens, ratio ~0.285), and single-extractor-call on
repeated queried requests to the same URL. 921/921 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds README (API table, response headers, new "Query-scoped extraction"
section, architecture bullet) and help.html (DE/EN parameter row) coverage
for the `query`/`max_tokens` params shipped in Tasks 1-6, plus a
CHANGELOG entry. Default /api behavior without `query` is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gate, telemetry length, docs dash

- lib/query-extract.js: cap deduped query terms at 64 to bound BM25 scoring
  cost (CPU DoS on huge heading-poor pages with adversarial queries)
- lib/mcp.js: applyQueryExtract now treats a whitespace-only query as
  inactive, mirroring server.js's trimmedQuery/queryActive gate
- server.js: cache-hit extraction telemetry now logs the full pre-extraction
  body length (baseMd), matching the fresh-path logging convention
- public/help.html: use en-dash consistently in the EN max_tokens row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@syswave-dev
syswave-dev merged commit ba5afb5 into main Jul 10, 2026
4 checks passed
@syswave-dev
syswave-dev deleted the feat/query-extract branch July 10, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant