Skip to content

Release 0.5.5 — a cache you can warm, an outline before the read, half-length hashes - #34

Merged
CoderDayton merged 12 commits into
mainfrom
release/0.5.5
Sep 1, 2026
Merged

Release 0.5.5 — a cache you can warm, an outline before the read, half-length hashes#34
CoderDayton merged 12 commits into
mainfrom
release/0.5.5

Conversation

@CoderDayton

Copy link
Copy Markdown
Owner

Two costs were being paid on every turn regardless of what any tool did, and neither was visible in any per-call measurement. The advertised tool list sits in the prompt prefix of every request — output schemas were 11.5k of this server's 19.8k advertised tokens, paid whether or not a tool was ever called, and the Anthropic Messages API has no field to receive them. Separately, every tool result went out twice: once as a text block and once as structuredContent, byte for byte identical, measured at 8.8k + 8.9k tokens for a single 584-line file. Both defaults now pick the cheaper shape, and SCMCP_PUBLISH_OUTPUT_SCHEMA / SCMCP_STRUCTURED_CONTENT opt back in for a client that actually consumes structured output. The Pydantic response models stay either way — they remain the declared contract, enforced by tests/test_response_contract.py.

The other half of this release is about the read that comes before you know what to read. Search and grep only see files the cache holds, so making a tree searchable meant reading it — paying for content in order to find out which content you wanted. warm breaks that: it indexes paths or globs and returns counts only, never a byte. The opening move on an unfamiliar tree becomes warm, then grep or search, then read only what those name.

No cache-format change and no migration. A client that ignores the new parameters behaves as it did in 0.5.4.

Added

  • warm — index files into the cache so grep and search can see them, returning warmed, already_current, skipped and tokens_indexed and no content. Takes paths or globs. Every file left out is named with a reason (not_found, not_a_file, binary, too_large, unreadable, timeout), and a cap that stops the walk sets truncated or incomplete rather than a short count that reads as complete. Bounded by a per-file size limit, a total byte budget, and a deadline, so a careless glob cannot run away with the process.
  • read(outline=true) — one line: signature per definition instead of the file's text. The cheap first read of a large file: a map of where things are, which is exactly why it reports file_hash and not a claimable content_hash. Backed by extract_outline/render_outline in core.text, pure and I/O-free like the rest of that layer.
  • Line anchors on summaries. A summary is non-contiguous, so a reader told to re-read "specific sections" held no line number it could name and the follow-up was a guess. Each kept segment now opens with // L<start>-<end>, a 1-based inclusive range that feeds straight into read(offset, limit). About 6 tokens per segment, charged against the existing marker reserve.

Changed

  • Content hashes travel as their first 16 hex characters. A claim is only ever checked against the entry for the path it names, so 64 bits separates two versions of one file with room to spare. The full digest is still accepted; a shorter prefix is not, since that would match every version of the file at once. Stored hashes are unchanged — this is a wire form, not a storage change.
  • A multi-file response names its shared directory once as root and reports paths relative to it, instead of repeating the common prefix on every entry.
  • grep hits are "<line>:<text>" strings grouped by file, context lines "<line>-<text>", with overlapping context windows merged so no line is sent twice — 37% fewer tokens than the per-match objects they replace, and glob 50%. output="count" turns a 2.6k-token answer into 77.
  • Ranged reads no longer number their lines by default. The gutter costs ~17% of a window and the range is reported in lines regardless; line_numbers=true restores it.
  • search previews centre on the matching term. The stored preview was the file's first 200 characters — for source, the module docstring and its imports, which never says why the file ranked and is text the follow-up read returns anyway.
  • An over-budget response refits instead of being cut. The trim now drops the parts a caller can do without and keeps the answer, rather than truncating at a boundary that could leave the result unreadable.

Fixed

  • An over-budget batch_read could return files with their content silently removed. The budgeted refit assumed a files list was grep-shaped; batch_read's entries carry content/status and no lines, so every entry was rewritten to bare {"path": ...} and the function returned before setting truncated — handing back a list of paths that the caller had every reason to read as the files it asked for. Covered by tests/test_server_tools.py::TestMinimalPayload::test_batch_read_files_are_not_silently_emptied.
  • read's tool description still promised numbered lines on a ranged read after the default changed — a caller trusting it would misread every line position in an unnumbered window.
  • warm overwrote one hint with another when both the max_files cap and the byte/time budget fired, losing the max_files guidance.
  • grep(output="count") rendered every file's lines and walked the character budget for a files array it never sends, and could report truncation of a list the caller did not receive.
  • An invalid SCMCP_STRUCTURED_CONTENT value logged its warning twice.
  • warm's docstring promised every skipped file appears under failures; the list is capped at 20.
  • Corrupted box-drawing characters in the docs/architecture.md diagrams, and the tool count there was still 13.

Verification

  • uv run pytest -q → 5118 passed, 6 skipped
  • uv run ruff check src/ tests/ → All checks passed
  • uv run ruff format --check src/ tests/ → 101 files already formatted
  • uv run mypy src/ → no issues in 45 source files
  • lefthook pre-commit (bandit, mypy, ruff-check, ruff-format, conventional-commit) passed on every commit in the series

A summary is non-contiguous, so a reader told to re-read "specific sections" held no line number it could name. Each kept segment now opens with a `// L<start>-<end>` anchor whose range feeds straight into read(offset, limit).
extract_outline walks a file's definition lines and records where each one lives; render_outline prints them. Pure and I/O-free, so it stays inside the core layer.
A claim is only ever checked against the entry for the path it names, so 64 bits separates two versions of one file with room to spare. hash_matches accepts the wire form or the full digest and nothing between, so a truncated claim buys nothing.
shared_root finds the directory a set of paths agree on and relativize strips it, so a multi-file response spends the common prefix once.
A tool result was going out as both structured content and a text block, paying for the same JSON twice. Middleware now sends one representation; SCMCP_STRUCTURED_CONTENT and SCMCP_PUBLISH_OUTPUT_SCHEMA opt back in for clients that need the schema.
The stored preview was the file's first 200 characters — for source that is the module docstring and imports, which never says why the file matched. The preview now windows around the first query term.
warm indexes a glob so search and grep can see files before any read. read gains an outline mode and stops numbering ranged lines by default. Responses now spend a shared path root once, emit 16-character hashes, and refit to the token budget instead of being cut.
The 2s budget had to cover a macOS spawn plus a cold-cache import of server.tools, which alone measures 1135ms, leaving under a second of headroom on a shared runner. One test in this file already used 5s; the rest now match.
@CoderDayton
CoderDayton merged commit e3e8b2a into main Sep 1, 2026
8 checks passed
@CoderDayton
CoderDayton deleted the release/0.5.5 branch September 1, 2026 06:52
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