Merge main into realign-F3-toin-per-tenant

This commit is contained in:
JD Davis 2026-08-05 22:39:16 -05:00
commit 72b590e064
3104 changed files with 252074 additions and 292273 deletions

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.21.28"
"version": "0.34.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.21.28",
"version": "0.34.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

16
.codegraph/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

View file

@ -21,4 +21,4 @@ FROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}
# doesn't need yarn.
RUN rm -f /etc/apt/sources.list.d/yarn.list
RUN python -m pip install --no-cache-dir uv==0.6.17 'maturin>=1.5,<2.0'
RUN python -m pip install --no-cache-dir 'uv>=0.7.0' 'maturin>=1.5,<2.0'

View file

@ -1,5 +1,9 @@
# VCS
.git
.git/*
!.git/HEAD
!.git/packed-refs
!.git/refs/
!.git/refs/**
.github
.github/*
!.github/plugin/

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
# Copy this file to .env and fill in real values before running in production.
# IMPORTANT: Change NEO4J_AUTH before deploying — default credentials are insecure.
NEO4J_AUTH=neo4j/CHANGEME

4
.gitattributes vendored
View file

@ -1,2 +1,6 @@
*.py text eol=lf
*.sh text eol=lf
# CHANGELOG appends conflict on nearly every concurrent PR; union-merge keeps
# all entries instead of forcing a manual resolution, killing the merge cascade.
CHANGELOG.md merge=union

View file

@ -32,3 +32,19 @@ secret:
match: "sk-ant-oat01-oauth-fixture"
- name: "Anthropic-shaped fixture token (PAYG via bearer)"
match: "sk-ant-api03-payg-bearer-fixture"
# Minimal GitHub-shaped tokens used in tests/test_copilot_auth.py to
# exercise _token_kind() prefix detection and _is_copilot_api_token().
# Values are intentionally short/low-entropy — they carry no privilege.
- name: "GitHub OAuth token fixture (test_copilot_auth)"
match: "gho_x"
- name: "GitHub Apps token fixture (test_copilot_auth)"
match: "ghs_x"
- name: "GitHub PAT fixture (test_copilot_auth)"
match: "ghp_x"
- name: "GitHub fine-grained PAT fixture (test_copilot_auth)"
match: "github_pat_x"
- name: "Copilot session token fixture (test_copilot_auth)"
match: "tid_x"
- name: "GitHub OAuth token fixture for exchange_token test"
match: "gho_test"

11
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,11 @@
# CODEOWNERS — default reviewers for this repository.
# Docs: https://docs.github.com/articles/about-code-owners
#
# Owners listed here are auto-requested for review on matching pull requests.
# When branch protection requires code-owner review, any one of them can
# satisfy it. Owners must have write access to the repo or the line is ignored.
#
# Order matters: the last matching pattern wins.
# Catch-all: the maintainers own everything by default.
* @chopratejas @JerrettDavis @DevanshiVyas

7
.github/FUNDING.yml vendored
View file

@ -1,7 +0,0 @@
# These are supported funding model platforms
github: [headroom-sdk]
# patreon: headroom
# open_collective: headroom
# ko_fi: headroom
# custom: ["https://headroom.dev/sponsor"]

View file

@ -1,8 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Questions & Discussions
url: https://github.com/headroom-sdk/headroom/discussions
url: https://github.com/chopratejas/headroom/discussions
about: Ask questions and discuss ideas in GitHub Discussions
- name: Documentation
url: https://headroom.dev/docs
url: https://headroom-docs.vercel.app/docs
about: Check out the documentation for guides and API reference

View file

@ -0,0 +1,54 @@
---
name: Copilot Subscription Test Report
about: Report results of testing `headroom wrap copilot --subscription` on Linux/Windows/macOS
title: '[COPILOT-SUB] <OS> test report'
labels: copilot-subscription, testing
assignees: ''
---
<!--
Thanks for helping verify Copilot subscription mode across platforms!
See TESTING-copilot-subscription.md for the step-by-step flows.
Redact your actual token everywhere.
-->
## Environment
- **OS + version**: (e.g., Ubuntu 24.04, Windows 11 23H2, macOS 14.5)
- **Architecture**: (x86_64 / arm64)
- **How you installed headroom**: (pipx/pip `--pre` wheel · Docker install.sh/ps1 · built from source)
- **headroom version**: (`headroom --version`)
- **Copilot CLI version**: (`copilot --version`)
- **Was plain `copilot` logged in before the test?**: yes / no
## Result
- **Command run**:
```
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
```
- **Did it print `HEADROOM_OK`?**: yes / no
- **Worked WITHOUT `GITHUB_COPILOT_TOKEN` (auto-discovery)?**: yes / no / didn't try
- **Worked WITH `GITHUB_COPILOT_TOKEN` set?**: yes / no / didn't try
## Error output (if any)
```
paste any error here
```
## Token storage schema (only if auto-discovery failed)
Helps us fix auto-discovery. **Redact the secret value.**
- Linux: `secret-tool search --all 2>/dev/null | sed -E 's/^secret = .*/secret = <redacted>/'`
- Windows: `cmd /c "cmdkey /list"` (paste the Copilot-related `Target:` line)
- macOS (reference): service `copilot-cli`
```
paste the attribute / Target lines here (secret redacted)
```
## Anything else
(logs from `~/.headroom/logs/proxy.log`, surprises, etc.)

View file

@ -1,8 +1,8 @@
## Description
Brief description of changes and motivation.
<!-- Briefly explain the change and why it is needed. -->
Fixes #(issue number)
Closes #
## Type of Change
@ -15,13 +15,11 @@ Fixes #(issue number)
## Changes Made
- Change 1
- Change 2
- Change 3
-
## Testing
Describe the tests you ran to verify your changes:
<!-- Check what you actually ran, then paste the real command output below. -->
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
@ -29,12 +27,23 @@ Describe the tests you ran to verify your changes:
- [ ] New tests added for new functionality
- [ ] Manual testing performed
## Test Output
### Test Output
```text
# Paste relevant command output or artifact links here
```
# Paste relevant test output here
pytest -v tests/test_your_feature.py
```
## Real Behavior Proof
- Environment:
- Exact command / steps:
- Observed result:
- Not tested:
## Review Readiness
- [ ] I have performed a self-review
- [ ] This PR is ready for human review
## Checklist
@ -45,7 +54,7 @@ pytest -v tests/test_your_feature.py
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
- [ ] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)
## Screenshots (if applicable)
@ -53,4 +62,4 @@ Add screenshots to help explain your changes.
## Additional Notes
Any additional information that reviewers should know.
<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. -->

19
.github/act/pr-governance-invalid.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
"action": "opened",
"number": 42,
"pull_request": {
"number": 42,
"draft": false,
"title": "feat: add PR governance",
"body": "## Description\n\nFixes #123\n",
"user": {
"login": "octocat"
},
"base": {
"sha": "dff6a199"
}
},
"repository": {
"full_name": "JerrettDavis/headroom"
}
}

19
.github/act/pr-governance-valid.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
"action": "ready_for_review",
"number": 42,
"pull_request": {
"number": 42,
"draft": false,
"title": "feat: add PR governance",
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\nCloses #123\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n- Added a commit-msg hook that runs commitlint locally.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n- [x] Manual testing performed\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR with an incomplete template, then fixed the body.\n- Observed result: The governance check failed until the template and readiness boxes were complete.\n- Not tested: Repository-level automatic Copilot rulesets.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
"user": {
"login": "octocat"
},
"base": {
"sha": "dff6a199"
}
},
"repository": {
"full_name": "JerrettDavis/headroom"
}
}

11
.github/act/release-published.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"action": "published",
"release": {
"tag_name": "v0.9.2",
"name": "Release v0.9.2",
"draft": false,
"prerelease": false,
"body": "## What's Changed\n\n* fix: example change for act dry-run simulation"
},
"ref": "refs/tags/v0.9.2"
}

View file

@ -1,15 +1,25 @@
name: Headroom e2e setup
description: >-
Checkout-agnostic setup shared by native e2e workflows (init, install, wrap).
Installs Python + Rust toolchain, installs headroom in editable mode (which
builds the bundled Rust extension via maturin), and (optionally) drops a
noop shim onto PATH so ``headroom init -g <target>`` can detect a tool
that isn't actually installed on the runner.
Installs Python, optionally installs the Rust toolchain + editable headroom
package, and (optionally) drops PATH shims for the local ``headroom`` CLI and
target binaries so ``headroom init -g <target>`` can detect tools that aren't
actually installed on the runner.
inputs:
python-version:
description: Python version to install
required: false
default: "3.11"
install-mode:
description: >-
Install strategy. ``editable-proxy`` builds the local package with
``pip install -e .[proxy]`` and verifies ``headroom._core``.
``deps-only-proxy`` installs the base + ``[proxy]`` dependency set from
pyproject.toml, then drops a local ``headroom`` launcher that imports
from the checkout without building the package; use this for CLI tests
that do not exercise the Rust extension.
required: false
default: "editable-proxy"
shim-target:
description: >-
Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave
@ -30,16 +40,55 @@ runs:
# Single-wheel architecture: `pip install -e .` invokes maturin (declared
# in pyproject.toml's build-system) which calls cargo to compile the Rust
# extension. Toolchain has to be set up before the install step.
# extension. Toolchain has to be set up before the editable install path.
- name: Install Rust toolchain
if: ${{ inputs.install-mode == 'editable-proxy' }}
uses: dtolnay/rust-toolchain@1.95.0
- name: Cache cargo registry + build
if: ${{ inputs.install-mode == 'editable-proxy' }}
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
# macos-latest (macos-15) runners have varying Xcode versions installed.
# The Rust cc crate probes the active Xcode for
# .../lib/clang/<ver>/lib/darwin/libclang_rt.osx.a. Some Xcode versions
# (notably 16.4 / clang 17 on certain runner images) lack this path.
# 1. Find an Xcode whose clang runtime directory actually exists.
# 2. If none found, locate libclang_rt.osx and create the expected symlink.
- name: Fix clang_rt.osx linker path (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
found=
for app in /Applications/Xcode_*.app; do
[ -d "$app" ] || continue
clang_dir=$(ls -d "$app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/"*/lib/darwin 2>/dev/null | head -1)
if [ -n "$clang_dir" ]; then
sudo xcode-select -s "$app"
echo "Selected Xcode: $app (has clang runtime at $clang_dir)"
found=1
break
fi
done
if [ -z "$found" ]; then
rt_lib=$(find /Applications -name "libclang_rt.osx*" 2>/dev/null | head -1)
if [ -n "$rt_lib" ]; then
xcode_ver=$(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')
clang_ver=$(clang --version 2>/dev/null | head -1 | grep -oP 'version \K\d+')
exp_dir="/Applications/Xcode_${xcode_ver}.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/${clang_ver}/lib/darwin"
sudo mkdir -p "$exp_dir"
target="$exp_dir/$(basename "$rt_lib")"
[ -f "$target" ] || sudo ln -sf "$rt_lib" "$target"
echo "Symlinked $rt_lib -> $target"
else
echo "WARNING: libclang_rt.osx not found anywhere. Build may fail."
fi
fi
- name: Install headroom (editable, with proxy extras — builds Rust extension)
if: ${{ inputs.install-mode == 'editable-proxy' }}
shell: bash
run: |
python -m pip install --upgrade pip
@ -49,6 +98,51 @@ runs:
pip install -e ".[proxy]"
python -c "from headroom._core import DiffCompressor; print('headroom._core OK:', DiffCompressor)"
- name: Install base + proxy dependencies without building headroom
if: ${{ inputs.install-mode == 'deps-only-proxy' }}
shell: bash
run: |
python -m pip install --upgrade pip
python - <<'PY'
import subprocess
import sys
import tomllib
from pathlib import Path
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
requirements = list(project["project"]["dependencies"])
requirements.extend(project["project"]["optional-dependencies"]["proxy"])
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--retries", "10", "--timeout", "60", *requirements]
)
PY
python -c "from headroom.cli.main import main; print('headroom CLI OK:', main)"
- name: Drop local headroom launcher (POSIX)
if: ${{ inputs.install-mode == 'deps-only-proxy' && runner.os != 'Windows' }}
shell: bash
run: |
shim_dir="${RUNNER_TEMP}/headroom-local-bin"
mkdir -p "$shim_dir"
cat > "$shim_dir/headroom" <<'SH'
#!/usr/bin/env bash
exec python -m headroom.cli "$@"
SH
chmod +x "$shim_dir/headroom"
echo "$shim_dir" >> "$GITHUB_PATH"
- name: Drop local headroom launcher (Windows)
if: ${{ inputs.install-mode == 'deps-only-proxy' && runner.os == 'Windows' }}
shell: pwsh
run: |
$shimDir = Join-Path $env:RUNNER_TEMP "headroom-local-bin"
New-Item -ItemType Directory -Force -Path $shimDir | Out-Null
@"
@echo off
python -m headroom.cli %*
"@ | Out-File -FilePath (Join-Path $shimDir "headroom.cmd") -Encoding ascii
Add-Content -Path $env:GITHUB_PATH -Value $shimDir
- name: Drop shim (POSIX)
if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }}
id: shim-posix

7
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,7 @@
When performing a pull request review in this repository:
1. Treat `.github/PULL_REQUEST_TEMPLATE.md` and `CONTRIBUTING.md` as required policy, not optional guidance.
2. Flag pull requests that do not include concrete "Real Behavior Proof" with environment, exact commands or steps, observed result, and what was not tested.
3. Be strict about contributor verification: missing tests, missing runtime evidence, or placeholder PR text should be called out.
4. For user-facing, release, dependency, workflow, or security-sensitive changes, prefer blocking feedback over optional suggestions.
5. Focus on correctness, safety, and whether the PR is actually ready for human maintainer review.

View file

@ -40,3 +40,35 @@ updates:
update-types:
- "minor"
- "patch"
# Rust dependency updates (cargo workspace: crates/*)
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
commit-message:
prefix: "deps"
open-pull-requests-limit: 5
groups:
cargo-minor-patch:
update-types:
- "minor"
- "patch"
# npm dependency updates (TS SDK, plugins, docs site)
- package-ecosystem: npm
directories:
- "/sdk/typescript"
- "/plugins/openclaw"
- "/plugins/opencode"
- "/docs"
schedule:
interval: weekly
commit-message:
prefix: "deps"
open-pull-requests-limit: 5
groups:
npm-minor-patch:
update-types:
- "minor"
- "patch"

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.21.28"
"version": "0.34.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.21.28",
"version": "0.34.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" font-family="Segoe UI, Helvetica, Arial, sans-serif">
<rect x="0" y="0" width="1200" height="900" fill="#ffffff"/>
<text x="600" y="36" text-anchor="middle" font-size="22" font-weight="700" fill="#111827">Headroom: Error-Output Protection False-Positive Fix (#1696)</text>
<!-- Box 1: pipeline -->
<g>
<rect x="20" y="60" width="560" height="190" rx="10" fill="#eff6ff" stroke="#2563eb"/>
<text x="36" y="88" font-size="15" font-weight="700" fill="#1d4ed8">1. THE GATE</text>
<text x="36" y="112" font-size="13" fill="#111827">content_router.py routes every message/content-block through</text>
<text x="36" y="130" font-size="13" fill="#111827">content_has_strong_error_indicators() before deciding whether</text>
<text x="36" y="148" font-size="13" fill="#111827">to compress it or protect it (pass through untouched).</text>
<text x="36" y="176" font-size="13" fill="#111827">Rule: if the text contains 2+ DISTINCT keywords from</text>
<text x="36" y="194" font-size="13" font-family="Consolas, monospace" fill="#b91c1c">[error, fail, exception, traceback, fatal, panic, crash]</text>
<text x="36" y="218" font-size="13" fill="#111827">the block is protected — assumed to be a real failure trace.</text>
</g>
<!-- Box 2: the bug -->
<g>
<rect x="620" y="60" width="560" height="190" rx="10" fill="#fef2f2" stroke="#dc2626"/>
<text x="636" y="88" font-size="15" font-weight="700" fill="#b91c1c">2. THE BUG</text>
<text x="636" y="112" font-size="13" fill="#111827">Passing build/test output mentions BOTH words without failing:</text>
<text x="636" y="136" font-size="13" font-family="Consolas, monospace" fill="#111827">tsc: "Found 0 errors"</text>
<text x="636" y="156" font-size="13" font-family="Consolas, monospace" fill="#111827">jest: "0 failures, 42 passed"</text>
<text x="636" y="184" font-size="13" fill="#111827">→ 2 distinct keyword hits ("error" + "fail") on a CLEAN run</text>
<text x="636" y="202" font-size="13" font-weight="700" fill="#b91c1c">→ wrongly protected, forever, from compression.</text>
</g>
<!-- Box 3: impact -->
<g>
<rect x="20" y="270" width="1160" height="150" rx="10" fill="#fff7ed" stroke="#ea580c"/>
<text x="36" y="298" font-size="15" font-weight="700" fill="#c2410c">3. OBSERVED IMPACT (issue #1696 stats.json)</text>
<text x="36" y="324" font-size="13" fill="#111827">In a long multi-turn JS/TS coding session (KiloCode via headroom proxy), "router:protected:error_output" fired on</text>
<text x="36" y="344" font-size="13" fill="#111827">nearly every request. Combined with tool-output exclusion and small-block skips, only a sliver of tokens were ever</text>
<text x="36" y="364" font-size="13" fill="#111827">eligible for compression.</text>
<rect x="900" y="325" width="60" height="60" fill="#fecaca"/>
<text x="930" y="360" text-anchor="middle" font-size="16" font-weight="700" fill="#7f1d1d">0.3%</text>
<text x="930" y="400" text-anchor="middle" font-size="11" fill="#111827">actual</text>
<rect x="980" y="285" width="60" height="100" fill="#bbf7d0"/>
<text x="1010" y="340" text-anchor="middle" font-size="14" font-weight="700" fill="#14532d">60-95%</text>
<text x="1010" y="400" text-anchor="middle" font-size="11" fill="#111827">expected</text>
</g>
<!-- Box 4: the fix -->
<g>
<rect x="20" y="440" width="560" height="200" rx="10" fill="#ecfdf5" stroke="#059669"/>
<text x="36" y="468" font-size="15" font-weight="700" fill="#047857">4. THE FIX</text>
<text x="36" y="494" font-size="13" fill="#111827">Strip common zero-result phrases before the keyword scan:</text>
<text x="36" y="516" font-size="13" font-family="Consolas, monospace" fill="#111827">"0 error(s)", "no error(s)", "0 failing",</text>
<text x="36" y="536" font-size="13" font-family="Consolas, monospace" fill="#111827">"0 failure(s)", "no failure(s)"</text>
<text x="36" y="562" font-size="13" fill="#111827">headroom/transforms/error_detection.py</text>
<text x="36" y="582" font-size="13" fill="#111827">content_has_strong_error_indicators()</text>
<text x="36" y="608" font-size="13" font-weight="700" fill="#047857">→ clean tool output no longer trips protection.</text>
</g>
<!-- Box 5: still safe -->
<g>
<rect x="620" y="440" width="560" height="200" rx="10" fill="#f5f3ff" stroke="#7c3aed"/>
<text x="636" y="468" font-size="15" font-weight="700" fill="#6d28d9">5. REAL FAILURES STILL PROTECTED</text>
<text x="636" y="494" font-size="13" font-family="Consolas, monospace" fill="#111827">Traceback (most recent call last):</text>
<text x="636" y="512" font-size="13" font-family="Consolas, monospace" fill="#111827"> ...</text>
<text x="636" y="530" font-size="13" font-family="Consolas, monospace" fill="#111827">ValueError: fatal error during load</text>
<text x="636" y="558" font-size="13" fill="#111827">"traceback" + "fatal" (+ "error") — 2+ distinct hits</text>
<text x="636" y="576" font-size="13" fill="#111827">outside a stripped zero-result phrase</text>
<text x="636" y="602" font-size="13" font-weight="700" fill="#6d28d9">→ still protected, correctly.</text>
</g>
<!-- Box 6: tests -->
<g>
<rect x="20" y="660" width="1160" height="140" rx="10" fill="#f8fafc" stroke="#334155"/>
<text x="36" y="688" font-size="15" font-weight="700" fill="#1e293b">6. TEST COVERAGE ADDED (tests/test_error_detection.py — none existed before)</text>
<text x="36" y="714" font-size="13" fill="#111827">✓ real error/traceback text is still flagged ✓ single keyword mention is not flagged</text>
<text x="36" y="736" font-size="13" fill="#111827">✓ passing tsc summary ("Found 0 errors") is not flagged ✓ passing eslint summary is not flagged</text>
<text x="36" y="758" font-size="13" fill="#111827">✓ "0 errors" text does not mask a real second indicator elsewhere in the same blob</text>
<text x="36" y="782" font-size="13" fill="#111827">All 5 new tests + full existing content_router suite (86 tests total) pass.</text>
</g>
<text x="600" y="850" text-anchor="middle" font-size="12" fill="#6b7280">Fixes GitHub issue #1696 · headroomlabs-ai/headroom</text>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

79
.github/scripts/pr-health-labels.py vendored Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Helpers for PR health maintenance labels."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from typing import Any
FAILING_STATES = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "ERROR"}
def _parse_timestamp(value: Any) -> datetime:
if not isinstance(value, str) or not value:
return datetime.min.replace(tzinfo=timezone.utc)
normalized = value.removesuffix("Z") + "+00:00" if value.endswith("Z") else value
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return datetime.min.replace(tzinfo=timezone.utc)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _check_key(check: dict[str, Any]) -> tuple[str, str]:
workflow = str(check.get("workflowName") or check.get("workflow") or "")
name = str(check.get("name") or check.get("context") or "")
return workflow, name
def _check_time(check: dict[str, Any]) -> datetime:
return max(
_parse_timestamp(check.get("startedAt")),
_parse_timestamp(check.get("completedAt")),
)
def _state(check: dict[str, Any]) -> str:
return str(check.get("conclusion") or check.get("state") or "").upper()
def current_checks(payload: dict[str, Any]) -> list[dict[str, Any]]:
latest_by_key: dict[tuple[str, str], dict[str, Any]] = {}
for check in payload.get("statusCheckRollup") or []:
if not isinstance(check, dict):
continue
key = _check_key(check)
if not any(key):
continue
previous = latest_by_key.get(key)
if previous is None or _check_time(check) >= _check_time(previous):
latest_by_key[key] = check
return list(latest_by_key.values())
def check_state(payload: dict[str, Any]) -> str:
for check in current_checks(payload):
if _state(check) in FAILING_STATES:
return "failing"
return "passing"
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state-json", required=True, help="JSON from gh pr view")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
print(check_state(json.loads(args.state_json)))
return 0
if __name__ == "__main__":
raise SystemExit(main())

38
.github/workflows/changelog-guard.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Changelog Guard
# CHANGELOG.md is generated by release-please from Conventional Commit titles
# (see .release-please-config.json). Hand-editing it makes every concurrent PR
# conflict on the same `## Unreleased` lines — the "changelog cascade" where one
# merge turns the rest DIRTY. `.gitattributes merge=union` does not help because
# GitHub squash-merge ignores merge drivers. So the fix is to stop hand-edits at
# the source: this guard fails any PR that touches CHANGELOG.md, except
# release-please's own release PR (the one place it is meant to change).
#
# ponytail: uses the preinstalled gh CLI, no third-party action to pin. If a
# rare PR legitimately must edit CHANGELOG.md, a maintainer can merge past this
# non-required check; add a label-based exemption only if that ever recurs.
on:
pull_request:
permissions:
contents: read
pull-requests: read
jobs:
no-manual-changelog:
# release-please's release PR is the sole author of CHANGELOG.md.
if: ${{ !startsWith(github.head_ref, 'release-please--') }}
runs-on: ubuntu-latest
steps:
- name: Reject manual CHANGELOG.md edits
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh pr view "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--json files --jq '.files[].path' | grep -qx 'CHANGELOG.md'; then
echo "::error::Do not edit CHANGELOG.md by hand. release-please generates it from your Conventional Commit PR title (e.g. 'fix(proxy): ...'). Remove the CHANGELOG.md change — your entry appears automatically in the next release PR."
exit 1
fi
echo "OK — CHANGELOG.md not modified."

View file

@ -1,157 +1,525 @@
name: CI
# Intelligent + parallel pipeline (cutover from the old 4-version matrix):
# changes — paths-filter; skips heavy work for docs-only changes
# build-wheel — compile the Rust ext ONCE (fast `ci` cargo profile), share via artifact
# lint — ruff + mypy, once
# prefetch-model — download the embedding model ONCE (authenticated), warm shared cache
# test — 4 parallel shards (pytest-split), each a fresh runner VM; run offline
# test-extras / test-agno / build / commitlint / workflow-validation / *-e2e — preserved
#
# Notes: CPU-only torch everywhere (no CUDA stack); test shards run HF_HUB_OFFLINE.
# Multi-version (3.10/3.11/3.13) coverage on main is a planned follow-up.
# Windows wheel (win_amd64) built separately — builds the Rust ext just like the
# Linux wheel, then uploads as a separate artifact for downstream consumption.
on:
push:
branches: [main]
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- 'wiki/**'
- '**/*.md'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
# Cancel superseded runs on PRs/branches, but never cancel a main build.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PY_VERSION: "3.12"
# CPU-only torch — runners have no GPU; the default CUDA wheels pull ~2.5 GB.
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
jobs:
test:
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.filter.outputs.code }}
native: ${{ steps.filter.outputs.native }}
dashboard: ${{ steps.filter.outputs.dashboard }}
packaging: ${{ steps.filter.outputs.packaging }}
workflows: ${{ steps.filter.outputs.workflows }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- 'headroom/**'
- 'crates/**'
- '**/*.rs'
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'tests/**'
- 'scripts/**'
- '.github/workflows/**'
# native = anything that can change the compiled wrapper, the native
# install flow, or the docker image (drives the scarce macOS/Windows
# runners + docker E2E). A pure-Python logic change hits none of these.
native:
- 'headroom/cli/**'
- 'headroom/install/**'
- 'headroom/providers/**'
- 'crates/**'
- '**/*.rs'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'docker/**'
- 'Dockerfile'
- 'e2e/**'
- 'scripts/install*'
- 'pyproject.toml'
- '.github/workflows/**'
dashboard:
- 'headroom/dashboard/**'
- '.github/workflows/**'
# packaging = anything that changes how the wheel is built (so the
# cross-platform wheel build only reruns when the build actually changes).
packaging:
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'uv.lock'
- 'rust-toolchain.toml'
- 'crates/**'
- '**/*.rs'
- 'scripts/**'
- 'MANIFEST.in'
- '.github/workflows/**'
workflows:
- '.github/workflows/**'
lint:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-lint-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-lint-
- name: Verify Ruff version alignment
id: ruff-version
run: echo "version=$(python scripts/verify-ruff-version.py --print-version)" >> "$GITHUB_OUTPUT"
- run: python -m pip install --upgrade pip "ruff==${{ steps.ruff-version.outputs.version }}" "mypy==1.20.2"
- name: ruff check
run: ruff check .
- name: ruff format --check
run: ruff format --check .
- name: mypy
run: mypy headroom --ignore-missing-imports
build-wheel:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- uses: dtolnay/rust-toolchain@1.96.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Build wheel once (fast CI cargo profile)
run: |
python -m pip install --upgrade pip maturin
maturin build --profile ci --out dist --interpreter "python${PY_VERSION}"
- uses: actions/upload-artifact@v7
with:
name: headroom-wheel
path: dist/*.whl
retention-days: 1
build-wheel-windows:
needs: changes
if: needs.changes.outputs.packaging == 'true'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Build wheel (fast CI cargo profile)
shell: bash
run: |
python -m pip install --upgrade pip maturin
maturin build --profile ci --out dist --interpreter "python${{ env.PY_VERSION }}"
- uses: actions/upload-artifact@v7
with:
name: headroom-wheel-windows
path: dist/*.whl
retention-days: 1
prefetch-model:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Cache HuggingFace model
id: hfcache
uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-models-allMiniLM-v2
- name: Fetch all-MiniLM-L6-v2 once (authenticated, resilient)
if: steps.hfcache.outputs.cache-hit != 'true'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_DISABLE_TELEMETRY: "1"
run: |
python -m pip install --upgrade pip huggingface_hub
for i in 1 2 3 4 5 6; do
if python -c "from huggingface_hub import snapshot_download; snapshot_download('sentence-transformers/all-MiniLM-L6-v2')"; then exit 0; fi
echo "::warning::model fetch attempt $i failed; backing off"; sleep $((i * 30))
done
echo "::error::could not fetch all-MiniLM-L6-v2 from HuggingFace"; exit 1
test:
needs: [changes, build-wheel, prefetch-model]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
shard: [1, 2, 3, 4]
env:
TRANSFORMERS_OFFLINE: "1"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version: ${{ env.PY_VERSION }}
# `pip install -e .` invokes maturin (declared in `[build-system]
# requires`) under the hood, which calls cargo to build the Rust
# extension. The toolchain has to be available before the install
# step, otherwise build-isolation pulls maturin but `cargo` is
# missing.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Cache pip packages
uses: actions/cache@v4
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-
key: ${{ runner.os }}-pip-${{ env.PY_VERSION }}-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-${{ env.PY_VERSION }}-
- name: Install dependencies (builds Rust extension via maturin)
- name: Restore HuggingFace model cache (warmed by prefetch-model)
id: restore-hfcache
uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-models-allMiniLM-v2
- name: Fallback model download if cache missed
if: steps.restore-hfcache.outputs.cache-hit != 'true'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_DISABLE_TELEMETRY: "1"
TRANSFORMERS_OFFLINE: "0"
HF_HUB_OFFLINE: "0"
run: |
python -m pip install --upgrade pip huggingface_hub
for i in 1 2 3 4 5 6; do
if python -c "from huggingface_hub import snapshot_download; snapshot_download('sentence-transformers/all-MiniLM-L6-v2')"; then exit 0; fi
if [ "$i" -lt 6 ]; then echo "::warning::fallback model fetch attempt $i failed; backing off"; sleep $((i * 30)); fi
done
echo "::error::could not fetch all-MiniLM-L6-v2 from HuggingFace (fallback)"; exit 1
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + prebuilt wheel + dev deps, no cargo rebuild)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
python -c "from headroom._core import DiffCompressor; print('headroom._core OK:', DiffCompressor)"
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev]" pytest-split
# cwd's ./headroom source tree shadows the installed wheel; copy the
# compiled extension in so tests import it (no second cargo build).
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
python -c "from headroom._core import DiffCompressor; print('headroom._core OK')"
- name: Run linting
if: matrix.python-version == '3.12'
- name: Verify offline HuggingFace model cache
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
HF_HUB_DISABLE_TELEMETRY: "1"
run: python scripts/ci/verify_hf_model_cache.py
# Coverage upload: without this, codecov only receives reports from
# the two native-e2e workflows (3 CLI test files total), so head
# coverage reads ~6% and codecov/patch fails for ANY diff not
# exercised by those files — a false negative on every PR. The main
# suite runs here; its coverage must be what codecov sees.
- name: Run test shard ${{ matrix.shard }}/4
run: |
ruff check .
ruff format --check .
pytest tests scripts/tests \
--splits 4 --group ${{ matrix.shard }} \
--cov=headroom --cov-branch \
--cov-report=xml:coverage-${{ matrix.shard }}.xml \
--cov-report= \
--tb=short -q
- name: Run type checking
if: matrix.python-version == '3.12'
run: |
mypy headroom --ignore-missing-imports
- name: Run tests
run: |
pytest -v --tb=short tests scripts/tests
- name: Run tests with coverage
if: matrix.python-version == '3.11'
run: |
pytest tests scripts/tests --cov=headroom --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
if: matrix.python-version == '3.11'
uses: codecov/codecov-action@v4
- name: Upload coverage shard ${{ matrix.shard }} to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml
files: coverage-${{ matrix.shard }}.xml
disable_search: true
flags: python
name: python-shard-${{ matrix.shard }}
# Token is sent so uploads authenticate once the repo is activated on
# Codecov. Until then Codecov may 404 ("Repository not found"); either
# way, coverage upload is reporting-only and must never fail a build
# whose tests pass — so this stays non-blocking.
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
test-extras:
needs: [changes, build-wheel]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FASTEMBED_CACHE_PATH: ${{ github.workspace }}/.fastembed-cache
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
python-version: ${{ env.PY_VERSION }}
- name: Cache pip
uses: actions/cache@v6
with:
workspaces: ". -> target"
- name: Install with relevance extras (builds Rust extension via maturin)
path: ~/.cache/pip
key: ${{ runner.os }}-pip-extras-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-extras-
- name: Cache fastembed model
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.fastembed-cache
key: ${{ runner.os }}-fastembed-bge-small-v1
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + wheel[dev,relevance])
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,relevance]"
python -c "from headroom._core import SmartCrusher; print('headroom._core OK:', SmartCrusher)"
- name: Run relevance tests
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev,relevance]"
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
python -c "from headroom._core import SmartCrusher; print('headroom._core OK')"
- name: Pre-fetch fastembed model (authenticated, resilient)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_DISABLE_TELEMETRY: "1"
run: |
pytest tests/test_relevance.py -v
for i in 1 2 3 4 5; do
if python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')"; then exit 0; fi
echo "::warning::fastembed fetch attempt $i failed; backing off"; sleep $((i * 20))
done
echo "::error::could not fetch fastembed model from HuggingFace"; exit 1
- name: Run relevance tests
# Offline so fastembed reads the cache the prefetch step just warmed,
# without an unauthenticated cache-validation HEAD that could 429.
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
run: pytest tests/test_relevance.py -v
test-agno:
needs: [changes, build-wheel]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
python-version: ${{ env.PY_VERSION }}
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
workspaces: ". -> target"
- name: Install with agno extras (builds Rust extension via maturin)
name: headroom-wheel
path: dist
- name: Install (CPU torch + wheel[dev,agno])
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,agno]"
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev,agno]"
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
- name: Run agno tests
run: |
pytest tests/test_integrations/agno/ -v
run: pytest tests/test_integrations/agno/ -v
docker-native-e2e:
test-dashboard-ui:
needs: [changes, build-wheel]
if: needs.changes.outputs.dashboard == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + wheel[dev] + playwright)
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev]" playwright
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
- name: Install chromium
run: playwright install --with-deps chromium
- name: Run dashboard playwright tests
# Stub-based dashboard tests only (routes fully mocked, no network).
# tests/test_dashboard/test_live_feed.py needs a live proxy on
# localhost:8787 and stays excluded; the main shards keep skipping
# these via importorskip since playwright is not installed there.
env:
HEADROOM_PLAYWRIGHT_ARTIFACT_DIR: ${{ runner.temp }}/playwright-artifacts
run: pytest tests/test_dashboard_*_playwright.py -v
- name: Upload dashboard screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: dashboard-playwright-artifacts
path: ${{ runner.temp }}/playwright-artifacts
if-no-files-found: ignore
retention-days: 7
- name: Set up Python
uses: actions/setup-python@v5
commitlint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
build:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Build local Headroom image
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-build-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-build-
- uses: dtolnay/rust-toolchain@1.96.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
# Smoke check that the SHIPPED build (release profile) + sdist are wired
# right; release.yml's matrix is what actually publishes to PyPI.
- name: Install build tools
run: |
docker build -t headroom-native-e2e:latest .
python -m pip install --upgrade pip
pip install 'maturin>=1.5,<2.0' twine
- name: Build wheel + sdist
run: |
maturin sdist --out dist
maturin build --release --out dist
- name: Check package
run: twine check dist/*
workflow-validation:
needs: changes
if: needs.changes.outputs.workflows == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Cache actionlint + act
id: tools-cache
uses: actions/cache@v6
with:
path: |
/usr/local/bin/actionlint
/usr/local/bin/act
# Key off the workflow file itself: when someone updates the
# download URLs to a newer tool version, the hash changes and
# the cache busts automatically.
key: ${{ runner.os }}-ci-tools-${{ hashFiles('.github/workflows/ci.yml') }}
- name: Install actionlint
if: steps.tools-cache.outputs.cache-hit != 'true'
run: |
curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash
sudo mv ./actionlint /usr/local/bin/actionlint
- name: Install act
if: steps.tools-cache.outputs.cache-hit != 'true'
run: |
curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
sudo install ./bin/act /usr/local/bin/act
- name: Validate workflow files
run: bash scripts/validate-workflows.sh
docker-native-e2e:
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Build local Headroom image
run: docker build -t headroom-native-e2e:latest .
- name: Run Docker-native installer e2e
env:
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
run: |
bash e2e/docker-native-install.sh
run: bash e2e/docker-native-install.sh
- name: Run Docker-native compose smoke test
env:
HEADROOM_IMAGE: headroom-native-e2e:latest
@ -171,126 +539,49 @@ jobs:
fi
sleep 1
done
- name: Run Docker-native wrap e2e
run: |
docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
docker run --rm headroom-wrap-e2e
- name: Run Docker-native init e2e
run: |
docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
docker run --rm headroom-init-e2e
windows-native-wrapper:
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run native installer wrapper tests
run: |
pytest tests/test_install/test_native_installers.py -q
run: pytest tests/test_install/test_native_installers.py -q
macos-native-wrapper:
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install bash and test dependencies
run: |
brew install bash
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest
- name: Run native installer wrapper tests
run: |
BASH_PREFIX="$(brew --prefix bash)"
export PATH="$BASH_PREFIX/bin:$PATH"
pytest tests/test_install/test_native_installers.py -q
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
# Single-wheel build via maturin: produces both the linux wheel and
# the platform-independent sdist in one shot. release.yml's matrix
# is what builds per-platform wheels for PyPI; this `build` job is
# a smoke check that the build system is wired right.
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install 'maturin>=1.5,<2.0' twine
- name: Build wheel + sdist
run: |
maturin sdist --out dist
maturin build --release --out dist
- name: Check package
run: |
twine check dist/*
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
commitlint:
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'Merge pull request ')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v5
with:
configFile: .commitlintrc.json
workflow-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install actionlint
run: |
curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash
sudo mv ./actionlint /usr/local/bin/actionlint
- name: Install act
run: |
curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
sudo install ./bin/act /usr/local/bin/act
- name: Validate workflow files
run: |
bash scripts/validate-workflows.sh

View file

@ -30,15 +30,36 @@ jobs:
config: .devcontainer/memory-stack/devcontainer.json
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# Both variants exhaust the GitHub runner's disk, so free space up front
# on every variant. memory-stack brings up Neo4j + Postgres + Redis +
# Qdrant (PR #495: the diagnostic log writer hit "No space left on device"
# mid-smoke-test). The default variant's post-create `uv sync` fills the
# disk installing the ML wheel set — it failed copying numpy into the venv
# with "No space left on device" (os error 28). Previously this ran only
# on memory-stack, which left the default variant with no cushion.
#
# Reclaim ~14 GB by stripping preinstalled tools none of the devcontainer
# paths use (Android SDK, .NET, Haskell).
- name: Free runner disk
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Install Dev Container CLI
run: npm install -g @devcontainers/cli@0.85.0
@ -59,18 +80,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# The worktree devcontainer runs the same `uv sync --extra dev` build as
# the default validate job, which now pulls transformers 5.x. Copying that
# into the venv volume exhausts the GitHub runner's disk ("No space left on
# device", see PR #495). Reclaim ~14 GB by stripping preinstalled tools the
# build never touches — mirrors the memory-stack job's existing remedy.
- name: Free runner disk
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
- name: Create linked worktree
run: git worktree add "$RUNNER_TEMP/headroom-worktree" HEAD
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Install Dev Container CLI
run: npm install -g @devcontainers/cli@0.85.0

View file

@ -1,6 +1,8 @@
name: Docker
on:
push:
branches: [main]
workflow_call:
inputs:
version:
@ -20,6 +22,14 @@ on:
release:
types: [published]
# A merge spree pushes many commits to main; without this, each commit starts
# a full multi-arch image build and they pile up against the 20-job concurrency
# cap. Supersede all but the latest build for a given ref. cancel-in-progress is
# scoped to main only so a release tag's publish (its own ref) is never killed.
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
env:
REGISTRY: ghcr.io
@ -44,6 +54,7 @@ jobs:
# final multi-arch tagged manifest, which is what users pull by tag.
docker-build:
runs-on: ${{ matrix.arch.runs_on }}
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
@ -61,7 +72,7 @@ jobs:
- { name: arm64, runs_on: ubuntu-24.04-arm, platform: linux/arm64 }
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Normalize image name
id: image-name
@ -83,7 +94,7 @@ jobs:
- name: Set up Python
if: steps.version.outputs.version != ''
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"
@ -165,8 +176,33 @@ jobs:
mkdir -p "${RUNNER_TEMP}/digests"
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
# Smoke-test the built image before recording its digest. If the
# Python ABI is wrong (e.g. builder Python 3.11 vs distroless
# Python 3.13) pydantic_core._pydantic_core fails to dlopen and
# the import raises ModuleNotFoundError. Catching it here prevents
# a broken digest from reaching the manifest merge job and being
# tagged and published. Both python-slim and distroless variants
# expose python3 in PATH and honour the image's PYTHONPATH env.
- name: Smoke-test image (pydantic_core + headroom._core)
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
DIGEST: ${{ steps.digest.outputs.digest }}
PLATFORM: ${{ matrix.arch.platform }}
run: |
docker run --rm \
--platform "$PLATFORM" \
--entrypoint python3 \
"${IMAGE}@${DIGEST}" \
-c "
import pydantic_core
from headroom._core import DiffCompressor, SmartCrusher
print('smoke-test OK: pydantic_core', pydantic_core.__version__,
'| DiffCompressor', DiffCompressor.__name__,
'| SmartCrusher', SmartCrusher.__name__)
"
- name: Upload digest marker
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
# Variant + arch in the artifact name so the manifest job can
# download with `pattern: digests-<variant>-*` to gather all
@ -185,6 +221,7 @@ jobs:
docker-manifest:
needs: docker-build
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
@ -236,7 +273,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download per-arch digests for this variant
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: digests-${{ matrix.variant.name || 'root' }}-*
path: ${{ runner.temp }}/digests
@ -253,6 +290,7 @@ jobs:
tags: |
type=ref,event=branch,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=ref,event=pr,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=raw,value=dev,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name == 'push' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ steps.version.outputs.version != '' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=raw,value=${{ steps.version.outputs.version }}-${{ steps.short-sha.outputs.sha }},enable=${{ steps.version.outputs.version != '' && matrix.variant.name == '' }}
type=raw,value=${{ steps.version.outputs.version }}-${{ matrix.variant.name }}-${{ steps.short-sha.outputs.sha }},enable=${{ steps.version.outputs.version != '' && matrix.variant.name != '' }}
@ -351,6 +389,7 @@ jobs:
# at the top instead of whichever variant happened to finish last.
needs: docker-manifest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Normalize image name
id: image-name

View file

@ -1,33 +1,52 @@
name: Deploy Documentation
name: Validate Docs
# There is ONE documentation site: the Next.js/Fumadocs app in `docs/`, published
# at https://headroom-docs.vercel.app by Vercel's own Git integration. That URL is
# what README and `pyproject.toml` (Homepage, Documentation) point at.
#
# This workflow therefore only *validates* — it deploys nothing. Vercel owns
# deployment; duplicating it here is what produced a `deploy-vercel` job that
# failed 30 times on main without ever deploying (no VERCEL_* secrets were set).
#
# A second site used to be built from `wiki/` by MkDocs and published to GitHub
# Pages off the `gh-pages` branch. It was linked from nowhere in the repo, it meant
# every documented change had to be written twice, and each Pages deploy
# force-pushed `gh-pages` — which Vercel then tried to build, failing with
# "The specified Root Directory 'docs' does not exist" because that branch holds
# only the rendered site. Removed. `wiki/` stays in the repo as unpublished
# markdown pending migration of the pages `docs/` does not yet cover (notably
# `wiki/cli.md`); nothing builds or publishes it, so it needs no syncing.
on:
push:
branches:
- main
pull_request:
branches: [main]
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
contents: write
contents: read
jobs:
deploy:
validate-nextjs:
name: Validate Next.js build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
python-version: '3.11'
node-version: '20'
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: pip install mkdocs-material
run: npm ci
working-directory: docs
- name: Build and deploy
run: mkdocs gh-deploy --force
- name: Build docs
run: npm run build
working-directory: docs

View file

@ -15,16 +15,24 @@ jobs:
smoke-test:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-eval-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-eval-
# `pip install -e .` invokes maturin (declared in pyproject.toml's
# build-system) which calls cargo to compile the Rust extension.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
uses: dtolnay/rust-toolchain@1.96.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
@ -46,6 +54,17 @@ jobs:
print(f'CCR Round-trip: {result.passed_cases}/{result.total_cases} passed')
assert result.passed, f'CCR failures: {result.errors}'
"
- name: Run tool schema compaction integrity eval (zero cost)
run: |
python -c "
from headroom.evals.runners.compression_only import CompressionOnlyRunner
runner = CompressionOnlyRunner()
result = runner.evaluate_tool_schema_compaction()
print(f'Tool schema compaction: {result.passed_cases}/{result.total_cases} passed, {result.total_tokens_saved} annotation tokens stripped')
assert result.passed, f'Schema compaction failures: {result.errors}'
"
# OPENAI_API_KEY is intentionally not set in the public OSS repo
# (the secret list is empty). The CCR round-trip step above is the
# mandatory gate; this step only runs when an operator has wired
@ -66,15 +85,22 @@ jobs:
weekly-suite:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-eval-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-eval-
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
uses: dtolnay/rust-toolchain@1.96.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
@ -100,9 +126,53 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# Recall-based fidelity report on the production routing path. Zero cost
# (synthetic structured cases -> Rust compressors; no model, no API, no
# secrets). Non-blocking: surfaces recall trends weekly without gating.
# The blocking per-PR fidelity gate lives in
# tests/test_compression_fidelity_regression.py (runs in the [dev] shard).
- name: Information-retention recall report (zero cost, non-blocking)
run: |
python -c "
from headroom.evals.runners.compression_only import CompressionOnlyRunner
runner = CompressionOnlyRunner()
cases = runner.generate_info_retention_cases(n=50)
result = runner.evaluate_information_retention(cases)
print(f'Information retention: {result.passed_cases}/{result.total_cases} cases >=0.9 recall, avg compression {result.avg_compression_ratio:.1%}')
if not result.passed:
print(f'::warning title=Fidelity recall::{result.failed_cases} case(s) fell below 0.9 recall: {result.errors[:3]}')
"
# Real-dataset recall on the prose path (HotpotQA): does the ground-truth
# answer survive compressing the supporting context? Uses the production
# routing path, so prose flows through Kompress (ModernBERT) — allowed here
# because the weekly job installs [all]. Non-blocking and defensive: a
# dataset download or model failure warns rather than fails the job.
- name: Dataset recall report — HotpotQA (model-allowed, non-blocking)
run: |
python -c "
try:
from headroom.transforms.kompress_compressor import warm_kompress_model
from headroom.evals.datasets import load_hotpotqa
from headroom.evals.runners.compression_only import CompressionOnlyRunner
# Block until the Kompress model is loaded; otherwise prose passes
# through uncompressed and the recall number is meaningless.
warmed = warm_kompress_model()
suite = load_hotpotqa(n=50)
result = CompressionOnlyRunner().evaluate_dataset_recall(suite)
print(f'HotpotQA answer recall: {result.passed_cases}/{result.total_cases} probeable cases >=0.9, avg compression {result.avg_compression_ratio:.1%} (model_warmed={warmed})')
if result.avg_compression_ratio < 0.01:
print('::warning title=Dataset recall::compression did not engage (~0%); recall is not a meaningful fidelity signal — check Kompress model availability')
elif result.failed_cases:
print(f'::warning title=Dataset recall::{result.failed_cases} HotpotQA case(s) lost the answer under compression')
except Exception as e:
print(f'::warning title=Dataset recall::skipped (dataset/model unavailable): {e}')
" || true
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: eval-results-${{ github.run_number }}
path: eval_results/

View file

@ -1,22 +1,46 @@
name: Init E2E
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
jobs:
docker-init-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Build init e2e image
run: docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
- name: Run init e2e container
run: docker run --rm headroom-init-e2e
name: Init E2E
on:
pull_request:
branches: [main]
# Scoped to what the `headroom init` flow actually exercises (mirrors
# init-native-e2e), not all of headroom/** — a pure-Python logic change
# elsewhere shouldn't spin up a docker init E2E.
paths:
- 'headroom/cli/**'
- 'headroom/install/**'
- 'crates/**'
- 'docker/**'
- 'Dockerfile'
- 'e2e/**'
- 'scripts/install*'
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'uv.lock'
- '.claude-plugin'
- '.github/plugin/**'
- 'plugins/headroom-agent-hooks/**'
- '.github/workflows/init-e2e.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: init-e2e-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
docker-init-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- name: Build init e2e image
run: docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
- name: Run init e2e container
run: docker run --rm headroom-init-e2e

View file

@ -55,11 +55,12 @@ jobs:
- target: openclaw
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Setup (shim=${{ matrix.target }})
uses: ./.github/actions/headroom-e2e-setup
with:
install-mode: deps-only-proxy
python-version: "3.11"
shim-target: ${{ matrix.target }}

View file

@ -0,0 +1,68 @@
name: Install Native E2E
# Cross-platform smoke tests for safe ``headroom install`` paths. The goal here
# is portable CLI coverage that runs on real runners without mutating OS service
# managers or requiring Docker. Deeper lifecycle behavior remains covered by the
# existing native installer wrapper tests and install unit tests.
on:
pull_request:
branches: [main]
paths:
- "headroom/cli/install.py"
- "headroom/install/**"
- "tests/test_cli/test_install_cli.py"
- "tests/test_install/test_paths.py"
- ".github/actions/headroom-e2e-setup/**"
- ".github/workflows/install-native-e2e.yml"
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
install-native:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# Windows is excluded today: upstream `esaxx-rs` (transitively from
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
# Rust extension cannot build for `win_amd64` until the upstream
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
# builds cleanly there. Match init-native-e2e.yml so this workflow
# doesn't fail during setup before the install smoke tests run.
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./.github/actions/headroom-e2e-setup
with:
install-mode: deps-only-proxy
python-version: "3.11"
- name: Install pytest
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest pytest-cov
- name: Run install native smoke tests
shell: bash
run: |
pytest tests/test_cli/test_install_cli.py tests/test_install/test_paths.py --cov=headroom --cov-report=xml:coverage-install-native.xml --cov-report=term-missing -q
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage-install-native.xml
flags: install-native
name: install-native-${{ matrix.os }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

34
.github/workflows/merge-conflicts.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Merge Conflicts
# Reject unresolved Git merge-conflict markers committed into tracked files.
#
# This lives in its own workflow ON PURPOSE: ci.yml sets
# `on.pull_request.paths-ignore: ['**/*.md', ...]`, so a Markdown/CHANGELOG-only
# PR skips that workflow entirely. The conflict markers this guard exists to
# catch landed in CHANGELOG.md, so the check must run with no `paths-ignore`.
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: merge-conflicts-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
merge-conflicts:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- name: Reject unresolved merge-conflict markers
run: |
if git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)' -- .; then
echo "::error::Unresolved Git merge-conflict markers found in tracked files (see matches above)."
exit 1
fi
echo "No merge-conflict markers found."

View file

@ -0,0 +1,164 @@
name: Network Diff Capture
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: network-diff-capture-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PY_VERSION: "3.12"
jobs:
offline:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Install offline test tools
run: |
python -m pip install --upgrade pip
python -m pip install \
'tiktoken>=0.5.0' \
'pydantic>=2.0.0' \
'litellm==1.82.3' \
'click>=8.1.0' \
'rich>=13.0.0' \
'opentelemetry-api>=1.24.0' \
'ast-grep-cli>=0.30.0' \
'fastapi>=0.100.0' \
'uvicorn>=0.23.0' \
'httpx[http2]>=0.24.0' \
'openai>=2.14.0' \
'mcp>=1.0.0' \
'magika>=0.6.0' \
'zstandard>=0.20.0' \
'websockets>=13.0' \
'onnxruntime>=1.24' \
'transformers>=4.30.0' \
'watchdog>=4.0.0' \
'sqlite-vec>=0.1.6' \
pytest ruff mypy
- name: Lint capture code
run: ruff check headroom/capture headroom/cli/capture.py tests/test_network_diff_capture.py
- name: Format check capture code
run: ruff format --check headroom/capture headroom/cli/capture.py tests/test_network_diff_capture.py
- name: Type-check capture code
run: mypy headroom/capture/network_diff.py headroom/cli/capture.py
- name: Run capture tests
run: python -m pytest tests/test_network_diff_capture.py
- name: Validate compose model
env:
ANTHROPIC_API_KEY: dummy
run: docker compose -f docker/differential-network-capture/docker-compose.yml --profile run config
- name: Build Claude Code runner image
env:
ANTHROPIC_API_KEY: dummy
run: docker compose -f docker/differential-network-capture/docker-compose.yml --profile run build claude-direct
- name: Smoke Claude Code runner image
run: docker run --rm -e CLAUDE_COMMAND="claude --version" headroom-network-diff-claude-direct:latest
live-anthropic:
runs-on: ubuntu-latest
timeout-minutes: 90
needs: offline
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_PROMPT: "Summarize this repository in one sentence. Keep the answer under 30 words."
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Install report dependencies
run: |
python -m pip install --upgrade pip
python -m pip install \
'tiktoken>=0.5.0' \
'pydantic>=2.0.0' \
'litellm==1.82.3' \
'click>=8.1.0' \
'rich>=13.0.0' \
'opentelemetry-api>=1.24.0' \
'ast-grep-cli>=0.30.0' \
'fastapi>=0.100.0' \
'uvicorn>=0.23.0' \
'httpx[http2]>=0.24.0' \
'openai>=2.14.0' \
'mcp>=1.0.0' \
'magika>=0.6.0' \
'zstandard>=0.20.0' \
'websockets>=13.0' \
'onnxruntime>=1.24' \
'transformers>=4.30.0' \
'watchdog>=4.0.0' \
'sqlite-vec>=0.1.6'
- name: Run live Claude Code differential capture
if: env.ANTHROPIC_API_KEY != ''
working-directory: docker/differential-network-capture
run: |
set -euo pipefail
mkdir -p captures
docker compose up -d --build mitm-direct mitm-headroom-upstream headroom-proxy mitm-headroom-client
trap 'docker compose --profile run down -v' EXIT
for i in $(seq 1 90); do
if docker compose exec -T headroom-proxy curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
break
fi
if [ "$i" -eq 90 ]; then
docker compose logs headroom-proxy
exit 1
fi
sleep 2
done
docker compose --profile run run --rm claude-direct
docker compose --profile run run --rm claude-headroom
- name: Report skipped live capture
if: env.ANTHROPIC_API_KEY == ''
run: |
echo "::warning title=Live network diff skipped::ANTHROPIC_API_KEY is not configured for this repository; offline harness checks ran, but live Claude Code capture was skipped."
mkdir -p docker/differential-network-capture/captures
cat > docker/differential-network-capture/captures/skipped.md <<'EOF'
# Live Network Diff Capture Skipped
`ANTHROPIC_API_KEY` is not configured for this repository.
EOF
- name: Generate network diff report
if: env.ANTHROPIC_API_KEY != ''
run: |
python -m headroom.cli capture network-diff \
--direct docker/differential-network-capture/captures/direct.jsonl \
--headroom docker/differential-network-capture/captures/headroom-client.jsonl \
--output docker/differential-network-capture/captures/report.md \
--json-output docker/differential-network-capture/captures/report.json
- name: Upload capture artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: network-diff-capture-${{ github.run_number }}
path: docker/differential-network-capture/captures/
if-no-files-found: warn

53
.github/workflows/opencode-plugin.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: OpenCode Plugin
# The OpenCode plugin (plugins/opencode) is the routing shim that carries
# `headroom wrap opencode` traffic through the proxy, yet nothing in CI ever
# compiled it — so TypeScript / @types/node major bumps and source changes had
# zero build evidence. This gate runs the plugin's own typecheck + build + test
# whenever it (or this workflow) changes.
on:
pull_request:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- ".github/workflows/opencode-plugin.yml"
push:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- ".github/workflows/opencode-plugin.yml"
permissions:
contents: read
jobs:
build:
name: typecheck + build + test
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: plugins/opencode
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm
cache-dependency-path: plugins/opencode/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
# The wheel ships a committed self-contained bundle at
# headroom/providers/opencode/_dist/entry.opencode.js so pip installs
# get the transport plugin too. Rebuild it and fail if the committed
# artifact has drifted from the source.
- run: npm run build:standalone
- name: verify committed wheel bundle matches source
run: |
cmp dist-standalone/entry.opencode.js \
../../headroom/providers/opencode/_dist/entry.opencode.js \
|| { echo "::error::headroom/providers/opencode/_dist/entry.opencode.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }

228
.github/workflows/pr-health.yml vendored Normal file
View file

@ -0,0 +1,228 @@
name: PR Governance
on:
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review, converted_to_draft]
schedule:
# Keep labels fresh even when base branches move or checks finish later.
- cron: '23 14 * * 1-5'
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
checks: read
statuses: read
concurrency:
group: pr-health-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
template:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Fetch current PR body
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > .pr-body.md
- name: Validate PR template
id: validate
run: python3 scripts/pr-governance.py --event "$GITHUB_EVENT_PATH" --body-file .pr-body.md --report .pr-governance-report.json
- name: Append governance summary
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path
report = json.loads(Path(".pr-governance-report.json").read_text(encoding="utf-8"))
summary = report["summary_markdown"].strip()
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as handle:
handle.write(f"{summary}\n")
PY
- name: Ensure governance labels exist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh label create "status: needs author action" \
--repo "$REPO" \
--color "d93f0b" \
--description "Pull request body or readiness checklist still needs author updates" \
--force
gh label create "status: ready for review" \
--repo "$REPO" \
--color "0e8a16" \
--description "Pull request body is complete and the author marked it ready for human review" \
--force
- name: Sync governance comment and labels
if: steps.validate.outputs.is_bot_pr != 'true'
uses: actions/github-script@v7
env:
REPORT_PATH: .pr-governance-report.json
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const marker = report.comment_marker;
const body = `${marker}\n${report.comment_markdown}`.trim();
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.type === 'Bot' && typeof comment.body === 'string' && comment.body.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
if (report.labels_to_add.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: report.labels_to_add,
});
}
for (const label of report.labels_to_remove) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
- name: Report incomplete PR body
if: steps.validate.outputs.valid != 'true'
run: |
echo "PR template validation found missing fields. The governance comment and labels identify the required author updates."
label:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Ensure maintenance labels exist
run: |
set -euo pipefail
gh label create "status: needs rebase" \
--repo "$REPO" \
--color "fbca04" \
--description "Pull request branch is behind the base branch" \
--force
gh label create "status: has conflicts" \
--repo "$REPO" \
--color "d73a4a" \
--description "Pull request has merge conflicts with the base branch" \
--force
gh label create "status: ci failing" \
--repo "$REPO" \
--color "d73a4a" \
--description "Required or reported CI checks are failing" \
--force
gh label create "status: needs author action" \
--repo "$REPO" \
--color "d93f0b" \
--description "Pull request body or readiness checklist still needs author updates" \
--force
gh label create "status: ready for review" \
--repo "$REPO" \
--color "0e8a16" \
--description "Pull request body is complete and the author marked it ready for human review" \
--force
- name: Label open pull requests
run: |
set -euo pipefail
if jq -e '.pull_request.number' "$GITHUB_EVENT_PATH" >/dev/null; then
pr_numbers="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")"
else
pr_numbers="$(gh pr list --repo "$REPO" --state open --limit 100 --json number --jq '.[].number')"
fi
for pr in $pr_numbers; do
data="$(gh pr view "$pr" --repo "$REPO" \
--json isDraft,labels,mergeStateStatus,reviewDecision,statusCheckRollup)"
merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$data")"
check_state="$(python3 .github/scripts/pr-health-labels.py --state-json "$data")"
is_draft="$(jq -r '.isDraft' <<<"$data")"
review_decision="$(jq -r '.reviewDecision // ""' <<<"$data")"
if [[ "$merge_state" == "BEHIND" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: needs rebase"
elif [[ "$merge_state" != "UNKNOWN" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: needs rebase" || true
fi
if [[ "$merge_state" == "DIRTY" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: has conflicts"
elif [[ "$merge_state" != "UNKNOWN" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: has conflicts" || true
fi
if [[ "$check_state" == "failing" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: ci failing"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ci failing" || true
fi
if [[ "$merge_state" == "BEHIND" || "$merge_state" == "DIRTY" || "$check_state" == "failing" || "$is_draft" == "true" || "$review_decision" == "CHANGES_REQUESTED" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ready for review" || true
fi
done

View file

@ -17,15 +17,15 @@ jobs:
contents: write # For uploading release assets
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.95.0
uses: dtolnay/rust-toolchain@1.96.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
@ -49,9 +49,9 @@ jobs:
--outfile dist/headroom-sbom.cdx.json
- name: Upload SBOM to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: dist/headroom-sbom.cdx.json
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@v1.13.0

View file

@ -0,0 +1,87 @@
name: Release Metadata Sync
# Keep generated version-carrying files in sync on release-please's branch.
#
# Why this exists
# ---------------
# release-please only rewrites `pyproject.toml` plus the `extra-files` listed in
# `.release-please-config.json` (currently the TypeScript SDK and OpenClaw
# package.json). Several other tracked files also carry the version, and
# `server.json` is asserted byte-for-byte against `render_server_json()` — which
# derives its version from `pyproject.toml`. So the moment release-please bumps
# the version, `tests/test_mcp_registry/test_server_json.py::
# test_root_server_json_matches_builder` fails on the release PR, and the release
# cannot be merged. That is what blocked v0.33.0 (PR #2339).
#
# `release.yml` already runs `scripts/version-sync.py` before its own
# `verify-versions.py` gate, so the release *build* self-heals in the workspace.
# The regular CI test job does not, so the fix has to be committed.
#
# Why a workflow rather than more `extra-files` entries
# ----------------------------------------------------
# `scripts/version-sync.py` is the single place that knows every version-carrying
# file. Restating that list as per-file jsonpaths would duplicate it, and a
# jsonpath that silently fails to match produces exactly the failure we are trying
# to remove. Running the script instead means files added to it in future are
# covered with no change here.
#
# Why the push trigger
# --------------------
# release-please regenerates (force-pushes) its branch on every merge to main.
# That is what repeatedly wiped the hand-pushed metadata fixes on #2339. Keying
# off a push to the branch means the sync re-applies after every regeneration
# instead of being lost.
on:
push:
branches:
- "release-please--branches--**"
permissions:
contents: write
concurrency:
# Never cancel: a half-applied sync would leave the release PR inconsistent.
group: release-metadata-sync-${{ github.ref }}
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref_name }}
# PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one:
# a push made with GITHUB_TOKEN does not trigger workflows, so the release
# PR's checks would never re-run against the synced commit and would stay
# red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual
# re-run of the PR's checks picks it up.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@v6
with:
python-version: "3.12"
# version-sync.py is stdlib-only (json/re/tomllib), so no install step.
- name: Sync version-carrying files release-please does not bump
run: python scripts/version-sync.py
- name: Verify all versions agree
run: python scripts/verify-versions.py
- name: Commit and push if anything changed
run: |
if git diff --quiet; then
echo "Already in sync — nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore: sync generated version metadata"
# This push re-triggers this workflow. version-sync.py is idempotent, so
# the next run finds no diff and exits above without pushing — the loop
# terminates after one no-op run.
git push origin HEAD:"${GITHUB_REF_NAME}"

57
.github/workflows/release-please.yml vendored Normal file
View file

@ -0,0 +1,57 @@
name: Release Please
# What this does
# ----------------
# release-please watches `main` for conventional-commit traffic and
# maintains a single "Release vX.Y.Z" PR that aggregates everything
# released since the last tag. Merging that PR is what triggers an
# actual PyPI / npm / GitHub-Release publish (via release.yml, which
# fires on the published-release event the bot emits at merge time).
#
# This replaces the prior "every push to main is a release" pattern
# that burned PyPI's per-project storage quota by uploading a fresh
# wheel matrix (~200 MB) for each merged `fix:` / `feat:` PR.
#
# Day-to-day:
# - Merge a `fix:` PR into main -> bot updates the release PR
# - Merge a `feat:` PR into main -> bot bumps minor in release PR
# - Merge `ci:` / `docs:` / `chore:` -> no PR change (hidden)
# - Ready to ship -> merge the release PR
# (bot tags + emits release event;
# release.yml does the actual builds + publishes)
#
# Config lives in `.release-please-config.json`; current versions
# tracked in `.release-please-manifest.json`.
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
concurrency:
# Serialize bot runs on main so two pushes don't race the
# release-PR update. We never cancel mid-flight — losing a manifest
# write would mean the next push computes the wrong base version.
group: release-please-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v5
with:
# PAT (not GITHUB_TOKEN): a release/tag created by GITHUB_TOKEN does
# NOT emit events that trigger other workflows, so release.yml
# (PyPI/npm) and docker.yml — which fire on `release: published` —
# never ran, and releases had to be cut by hand. A PAT is treated as a
# real user, so the release it creates DOES trigger those publishes; it
# also lets the bot tag past branch/tag protection. Falls back to
# GITHUB_TOKEN when the secret is unset (the release PR still opens; it
# just won't trigger the downstream publishes).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: .release-please-config.json
manifest-file: .release-please-manifest.json

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,22 @@
name: rust
# Path gating lives in the `rust-changes` job below, NOT in a workflow-level
# `paths:` filter. The distinction matters for branch protection: a workflow
# skipped by `paths:` never creates its check runs at all, so a required status
# check from it sits pending forever on any PR that misses those paths, and the
# PR can never merge. A job skipped by `if:` still creates a check run, reports
# `skipped`, and GitHub counts skipped as success for a required check.
#
# Same coverage as the old filter — the path list moved verbatim into
# `rust-changes` — but `parity` is now safe to mark required on `main`.
on:
push:
branches: [ main, rust-rewrite ]
paths:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'tests/parity/**'
- 'Makefile'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'tests/parity/**'
- 'Makefile'
- '.github/workflows/rust.yml'
schedule:
# Nightly parity run at 07:17 UTC (weekdays only). Phase 0 allows failure.
# Nightly parity run at 07:17 UTC (weekdays only). Redundant with the
# per-PR gate below, but catches drift from toolchain/dependency updates
# that land without touching any filtered path.
- cron: '17 7 * * 1-5'
concurrency:
@ -36,11 +31,47 @@ permissions:
contents: read
jobs:
# Carries the path list the workflow-level `paths:` filter used to hold. Named
# `rust-changes` rather than `changes` so it does not collide with ci.yml's
# `changes` check.
rust-changes:
name: rust-changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
rust: ${{ steps.decide.outputs.rust }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
if: github.event_name == 'pull_request' || github.event_name == 'push'
with:
filters: |
rust:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'tests/parity/**'
- 'Makefile'
- '.github/workflows/rust.yml'
- id: decide
# `schedule` and `workflow_dispatch` have no diff to filter against, so
# they run the full suite — that is the point of the nightly job.
run: |
case "${{ github.event_name }}" in
pull_request|push) echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT" ;;
*) echo "rust=true" >> "$GITHUB_OUTPUT" ;;
esac
test:
name: test (ubuntu)
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install stable toolchain
# Pin action code to @stable (latest fixes), toolchain version
# via input. The @1.95.0 ref shipped action code that errors on
@ -53,6 +84,60 @@ jobs:
components: rustfmt, clippy
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Provide ONNX Runtime dylib
# headroom-core is built with `ort-load-dynamic` (see its
# Cargo.toml): the ONNX Runtime shared library is dlopen'd at
# runtime instead of statically linked, so the magika/detection
# tests need a real libonnxruntime.so and ORT_DYLIB_PATH pointing
# at it — same contract `headroom/_ort.py` fulfills for Python
# users via the pip `onnxruntime` package.
run: |
# >= 1.24, not 1.16: `ort`'s ORT_API_VERSION resolves to 24 because
# `fastembed` enables its `api-24` feature.
pip install 'onnxruntime>=1.24'
# Pre-flight, not just a pin. `ort` deadlocks rather than errors on
# ANY failure inside `load_dylib_from_path` — a version mismatch and
# a library that cannot be resolved at all both re-enter the `Once`
# that `setup_api()` is initialising, and `std::sync::Once` blocks
# forever on re-entry. Either way the job burns its full 30-minute
# timeout at 0% CPU with nothing in the log. Assert both conditions
# here so a bad runner fails in seconds with a readable message.
python - <<'PY' >> "$GITHUB_ENV"
import pathlib, sys
def die(msg: str) -> None:
print(f"::error::{msg}", file=sys.stderr)
raise SystemExit(1)
try:
import onnxruntime
except Exception as exc: # noqa: BLE001 - any import failure is fatal here
die(f"onnxruntime is not importable: {exc}")
version = onnxruntime.__version__
try:
major, minor = (int(part) for part in version.split(".")[:2])
except ValueError:
die(f"cannot parse onnxruntime version {version!r}")
if (major, minor) < (1, 24):
die(
f"onnxruntime {version} is too old: ort requires >= 1.24 "
"(ORT_API_VERSION=24, set by fastembed's api-24 feature). "
"ort DEADLOCKS instead of erroring below this, so the tests "
"would hang rather than fail."
)
capi = pathlib.Path(onnxruntime.__file__).parent / "capi"
libs = sorted(capi.glob("libonnxruntime.so*")) or sorted(capi.glob("libonnxruntime*.dylib"))
if not libs:
die(f"no libonnxruntime shared library under {capi}")
print(f"ORT_DYLIB_PATH={libs[0]}")
print(f"onnxruntime {version} -> {libs[0]}", file=sys.stderr)
PY
- name: cargo fmt --check
run: cargo fmt --all -- --check
- name: cargo clippy
@ -60,9 +145,33 @@ jobs:
- name: cargo test
run: cargo test --workspace
simulator-e2e:
name: simulator e2e (${{ matrix.os }})
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
- name: cargo test simulator-backed proxy e2e
run: cargo test -p headroom-proxy --test e2e_simulators
wheels:
name: wheels (${{ matrix.target }})
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@ -73,17 +182,14 @@ jobs:
- os: macos-14
target: aarch64-apple-darwin
maturin-target: aarch64-apple-darwin
# macOS x86_64 (Intel) is NOT in this matrix.
# `fastembed` → `ort` → `ort-sys` does not publish prebuilt ONNX
# Runtime binaries for `x86_64-apple-darwin`; building from source
# in CI is a multi-hour cmake job. Apple Silicon has been the
# default macOS target since 2020 and is sufficient for the wheels
# we ship. If a customer needs Intel macOS, build from source
# locally (the toolchain works; only prebuilt distribution skips
# this target).
- os: macos-15-intel
target: x86_64-apple-darwin
maturin-target: x86_64-apple-darwin
# Intel macOS uses `ort-load-dynamic` (no prebuilt ORT from ort-sys);
# Apple Silicon bundles ORT via `ort-download-binaries-rustls-tls`.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: "Build wheel (single-wheel architecture builds headroom-ai)"
@ -97,24 +203,27 @@ jobs:
args: --release --out dist
target: ${{ matrix.maturin-target }}
- name: Upload wheel artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: wheels-${{ matrix.target }}
path: dist/*.whl
audit:
name: audit
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit + cargo-deny
run: |
cargo install --locked cargo-audit || true
cargo install --locked cargo-deny || true
uses: taiki-e/install-action@v2
with:
tool: cargo-audit,cargo-deny
- name: cargo audit (soft-fail)
continue-on-error: true
run: cargo audit
@ -122,28 +231,29 @@ jobs:
continue-on-error: true
run: cargo deny check licenses
parity-nightly:
name: parity (nightly, allowed to fail during Phase 0)
if: github.event_name == 'schedule'
# Blocking on every PR that touches Rust. Safe to harden now because the
# harness fails only on a Diff — `parity-run` sets `any_diffs` inside the
# diffed loop alone, so the 65 fixtures still served by `stub_comparator!`
# report as Skipped and cannot turn this red. Measured on main today:
# 111 matched / 65 skipped / 0 diffed.
#
# What it protects: the recorded fixtures are frozen Python output, so this
# gate catches the Rust side drifting away from that snapshot — exactly the
# failure mode the ongoing port produces. It cannot detect the Python side
# drifting away from the fixtures; that needs re-recording, not this job.
parity:
name: parity
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: Swatinem/rust-cache@v2
- name: Install deps
run: |
python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install maturin
pip install -e .
# No Python toolchain: headroom-parity links headroom-core directly and
# never crosses into the interpreter, so the venv + maturin + `pip
# install -e .` setup this job used to do was pure overhead.
- name: Run parity harness
run: |
source .venv/bin/activate
make test-parity
run: make test-parity

120
.github/workflows/security.yml vendored Normal file
View file

@ -0,0 +1,120 @@
name: Security
# Security gate: dependency vulnerability scanning (SCA), static analysis
# (CodeQL/SAST), and secret scanning. Runs on every PR to main, on push to
# main, weekly (to catch newly-disclosed CVEs without a code change), and on
# demand. Each job is an independent required check.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Mondays 06:00 UTC — surface CVEs disclosed since the last commit.
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
# ---- SCA: dependency vulnerability scan -------------------------------
# Audits the PRODUCTION dependency set ([all]) exported from uv.lock. The
# `benchmark` extra is intentionally excluded from [all] (it pulls lm-eval's
# sqlitedict/nltk, which carry unpatchable upstream High CVEs and are never
# installed in production), so this gate fails only on actionable findings.
dependency-audit:
name: Dependency audit (pip-audit)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Export production dependency set from uv.lock
run: |
uv export --frozen --no-dev --no-emit-project --no-hashes \
--extra all --format requirements-txt > requirements-prod.txt
echo "Production dependencies audited:"
wc -l requirements-prod.txt
- name: Audit dependencies (pip-audit)
uses: pypa/gh-action-pip-audit@v1.1.0
with:
inputs: requirements-prod.txt
# ---- SAST: CodeQL static analysis ------------------------------------
codeql:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ---- Secret scanning -------------------------------------------------
# Uses the gitleaks BINARY (MIT-licensed, no key) instead of
# gitleaks-action, which requires a paid GITLEAKS_LICENSE for organization
# repos. On PRs we scan only the PR's commits so pre-existing history can't
# block a PR; on push/schedule we scan the working tree. Config + allowlist
# live in .gitleaks.toml at the repo root (auto-loaded).
secret-scan:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install gitleaks
run: |
version=8.18.4
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
sudo install /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan for secrets
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
echo "Scanning PR commits ${BASE_SHA}..HEAD"
gitleaks detect --source . --log-opts="${BASE_SHA}..HEAD" --redact --no-banner
else
echo "Scanning working tree"
gitleaks detect --source . --no-git --redact --no-banner
fi

60
.github/workflows/stale.yml vendored Normal file
View file

@ -0,0 +1,60 @@
name: Stale Triage
on:
schedule:
# Daily weekday pass during US morning hours.
- cron: '17 15 * * 1-5'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: stale-triage
cancel-in-progress: false
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Ensure stale label exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh label create "status: stale" \
--repo "${{ github.repository }}" \
--color "ededed" \
--description "No recent activity; may be closed if it stays inactive" \
--force
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 200
remove-stale-when-updated: true
exempt-all-milestones: true
exempt-issue-labels: pinned,security,good first issue,help wanted,needs reproduction
exempt-pr-labels: pinned,security,dependencies,release,do not merge
stale-issue-label: "status: stale"
days-before-issue-stale: 60
days-before-issue-close: 14
stale-issue-message: >
This issue has had no recent activity and is being marked stale.
Please comment with new context if it is still relevant.
close-issue-message: >
Closing this issue due to continued inactivity. It can be reopened
if there is new information or a clear next step.
stale-pr-label: "status: stale"
days-before-pr-stale: 30
days-before-pr-close: 14
stale-pr-message: >
This pull request has had no recent activity and is being marked
stale. Please rebase, resolve conflicts, or comment if it is still
actively being worked.
close-pr-message: >
Closing this pull request due to continued inactivity. It can be
reopened when it is ready for review again.

View file

@ -3,17 +3,40 @@ name: Wrap E2E
on:
pull_request:
branches: [main]
# Scoped to what the `headroom wrap` flow actually exercises (mirrors
# wrap-native-e2e), not all of headroom/** — a pure-Python logic change
# elsewhere shouldn't spin up a docker wrap E2E.
paths:
- 'headroom/cli/**'
- 'headroom/providers/**'
- 'crates/**'
- 'docker/**'
- 'Dockerfile'
- 'e2e/**'
- 'scripts/install*'
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'uv.lock'
- 'sdk/typescript/**'
- 'plugins/openclaw/**'
- '.github/workflows/wrap-e2e.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: wrap-e2e-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
docker-wrap-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Build wrap e2e image
run: docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .

72
.github/workflows/wrap-native-e2e.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Wrap Native E2E
# Cross-platform smoke tests for the hidden ``headroom wrap ... --prepare-only``
# flows. These reuse the existing pytest bridge cases so we exercise the real
# CLI on linux / macos without depending on agent binaries or long-lived proxy
# processes. Windows will be added once the upstream CRT conflict is resolved
# (see matrix comment below).
#
# This complements the Docker-native wrap e2e by catching host-specific issues
# such as home-directory layout and filesystem quirks in prepare-only config
# injection.
on:
pull_request:
branches: [main]
paths:
- "headroom/cli/**"
- "headroom/providers/**"
- "tests/test_cli/test_wrap_bridge.py"
- ".github/actions/headroom-e2e-setup/**"
- ".github/workflows/wrap-native-e2e.yml"
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
wrap-native:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# Windows is excluded today: upstream `esaxx-rs` (transitively from
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
# Rust extension cannot build for `win_amd64` until the upstream
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
# builds cleanly there. Match init-native-e2e.yml so this workflow
# doesn't fail during setup before the wrap smoke tests run.
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./.github/actions/headroom-e2e-setup
with:
install-mode: deps-only-proxy
python-version: "3.11"
- name: Install pytest
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest pytest-cov
- name: Run wrap native bridge tests
shell: bash
run: |
pytest tests/test_cli/test_wrap_bridge.py --cov=headroom --cov-report=xml:coverage-wrap-native.xml --cov-report=term-missing -q
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage-wrap-native.xml
flags: wrap-native
name: wrap-native-${{ matrix.os }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

35
.gitignore vendored
View file

@ -3,6 +3,10 @@
.fastembed_cache/
**/.fastembed_cache/
# Local Kompress ONNX export artifacts (scripts/export_kompress_v2_onnx.py).
# Hundreds of MB each — published to HuggingFace, never committed.
/onnx/
# Private scripts (contain credentials). Allowlist checked-in helpers below.
scripts/
!scripts/
@ -13,9 +17,17 @@ scripts/*
!scripts/sync-plugin-versions.py
!scripts/changelog-gen.py
!scripts/verify-versions.py
!scripts/verify-ruff-version.py
!scripts/pr-governance.py
!scripts/bootstrap-windows-dev.ps1
!scripts/build_npm_release_assets.mjs
!scripts/build_python_release_smoke.py
!scripts/release_smoke_all.py
!scripts/verify_npm_release_assets.mjs
!scripts/tests/
!scripts/README.md
!scripts/repro_codex_replay.py
!scripts/eval_output_shaper.py
!scripts/fixtures/
!scripts/fixtures/*.json
!scripts/record_fixtures.py
@ -24,6 +36,10 @@ scripts/*
!scripts/smoke_issue_327.py
!scripts/refresh_model_limits.sh
!scripts/audit_wheel_glibc_symbols.py
!scripts/replay_codex_ws_load.py
!scripts/export_kompress_v2_onnx.py
!scripts/record_kompress_fixtures.py
!scripts/record_code_compressor_fixtures.py
# Rust / Cargo build artifacts
/target/
@ -101,6 +117,7 @@ pytest_cache/
.env
.env.*
!.env.act.example
!.env.example
.venv
env/
venv/
@ -109,6 +126,12 @@ env.bak/
venv.bak/
.python-version
# Node.js dependencies (never commit vendored deps)
node_modules/
# Local release smoke outputs
release-assets-local/
# Secrets and API keys - NEVER commit these
*.pem
*.key
@ -192,6 +215,7 @@ headroom.db
headroom_*.db
*.jsonl
!tests/fixtures/*.jsonl
docker/differential-network-capture/captures/
# Documentation build
docs/_build/
@ -213,16 +237,16 @@ pyrightconfig.json
\#*\#
.\#*
# Local git worktrees (isolated feature branches)
.worktrees/
# Local development configuration
CLAUDE.md
# Vitals provenance data
.vitals/
# Superpowers working files (plans, specs, brainstorming)
# docs/spec/ (lives in git - git-versioned living specification)
# Managed platform (separate private repo)
# Separate private repos — never commit here
headroom-managed/
# Local act testing (never commit test tokens)
@ -242,3 +266,6 @@ uv.lock
# package shadows the maturin overlay on sys.path.
/headroom/_core.*.so
/headroom/_core.so
.tokensave
.codebase-memory/

27
.gitleaks.toml Normal file
View file

@ -0,0 +1,27 @@
# gitleaks configuration — extends the tuned default ruleset and allowlists
# paths that contain hashes/identifiers (not real secrets) to avoid false
# positives. Used by the Security workflow's secret-scan job.
[extend]
useDefault = true
[allowlist]
description = "Non-secret artifacts: SBOMs, lockfiles, vendored hashes, test/benchmark fixtures, and verified example values."
paths = [
'''sbom/.*''',
'''.*\.lock$''',
'''.*package-lock\.json$''',
'''pnpm-lock\.yaml$''',
# Test / benchmark / parity trees use synthetic JWTs and API keys by design.
'''(^|/)tests/''',
'''(^|/)benchmarks/''',
'''crates/.*/(tests|benches)/''',
]
# Verified non-secret strings that appear in production source. Kept narrow
# (exact tokens) so a genuine secret in these files would still be caught.
regexes = [
'''eyJhbGciOiJIUzI1NiIs''', # example JWT header prefix in a docstring (headroom/config.py)
'''sk-ant-dummy''', # documented placeholder key in the CLI banner (headroom/cli/proxy.py)
'''ANTHROPIC_API_KEY=''', # env-var NAME shown in CLI help text (headroom/cli/proxy.py)
'''Iv1\.b507a08c87ecfe98''', # GitHub Copilot PUBLIC OAuth client_id (not a secret)
]

View file

@ -3,12 +3,31 @@ repos:
hooks:
- id: sync-plugin-versions
name: Sync plugin versions
entry: python scripts/sync-plugin-versions.py
entry: python3 scripts/sync-plugin-versions.py
language: system
pass_filenames: false
always_run: true
- id: verify-ruff-version
name: Verify Ruff version alignment
entry: python3 scripts/verify-ruff-version.py
language: system
pass_filenames: false
always_run: true
- id: commitlint
name: Commitlint
entry: bash -lc 'npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional -- commitlint --edit "$1" --config .commitlintrc.json' --
language: system
stages: [commit-msg]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-merge-conflict
# Catch markers even outside an in-progress merge (e.g. committing a
# botched conflict resolution from a rebase). CI re-checks this
# unconditionally, so installing hooks is not required for enforcement.
args: [--assume-in-merge]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.4
rev: v0.15.17
hooks:
- id: ruff
args: [--fix]

View file

@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "python",
"include-v-in-tag": true,
"include-component-in-tag": false,
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"draft": false,
"prerelease": false,
"separate-pull-requests": false,
"pull-request-title-pattern": "chore: release ${version}",
"packages": {
".": {
"package-name": "headroom-ai",
"release-type": "python",
"extra-files": [
{
"type": "json",
"path": "sdk/typescript/package.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "plugins/openclaw/package.json",
"jsonpath": "$.version"
}
]
}
},
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance Improvements" },
{ "type": "deps", "section": "Dependencies" },
{ "type": "revert", "section": "Reverts" },
{ "type": "refactor", "section": "Code Refactoring" },
{ "type": "ci", "section": "Continuous Integration", "hidden": true },
{ "type": "build", "section": "Build System", "hidden": true },
{ "type": "chore", "section": "Miscellaneous Chores", "hidden": true },
{ "type": "docs", "section": "Documentation", "hidden": true },
{ "type": "style", "section": "Styles", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true }
]
}

View file

@ -0,0 +1,3 @@
{
".": "0.34.0"
}

9
.releasemetadata Normal file
View file

@ -0,0 +1,9 @@
{
"version": "0.34.0",
"packages": {
"pypi": "0.34.0",
"npm-sdk": "0.34.0",
"npm-openclaw": "0.34.0",
"agent-hooks-plugin": "0.34.0"
}
}

2
.serena/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/cache
/project.local.yml

169
.serena/project.yml Normal file
View file

@ -0,0 +1,169 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "headroom"
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart elixir
# elm erlang fortran fsharp gdscript
# go groovy haskell haxe hlsl
# html java json julia kotlin
# latex lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor php_phpantom powershell
# python python_basedpyright python_jedi python_pyrefly python_ty
# qml r rego ruby ruby_solargraph
# rust scala scss solidity svelte
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers:
- python
- rust
- typescript
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0

File diff suppressed because it is too large Load diff

View file

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**conduct@headroom.dev**.
**conduct@headroomlabs.ai**.
All complaints will be reviewed and investigated promptly and fairly.

View file

@ -1,227 +1,133 @@
# Contributing to Headroom
Thank you for your interest in contributing to Headroom! This document provides guidelines and instructions for contributing.
Thanks for contributing! Please skim this before opening a PR : the policies exist because we've been burned skipping them, not because we love paperwork.
## Code of Conduct
By participating, you agree to our [Code of Conduct](CODE_OF_CONDUCT.md).
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
## Where does my contribution go?
## How to Contribute
| Type | What to do |
| --- | --- |
| 🐛 Bug or small fix | **Open a PR** (with repro + test) |
| ✨ New feature / architectural change | **Open an issue or ask in Discord first.** |
| 🧹 Refactor-only | **Don't.** Only if a maintainer asked, as part of a concrete fix. |
| 🧪 Test/CI-only PR chasing a known `main` failure | **Don't.** We're tracking it. |
| 📦 New dep or version bump | **PR with written justification.** |
| ❓ Question | Ask in **Discord `#help`** |
### Reporting Bugs
**Open PR cap: 10 per author.** Get existing ones merged before opening more.
Before creating a bug report, please check existing issues to avoid duplicates. When creating a bug report, include:
## Guiding principles
- **Clear title** describing the issue
- **Steps to reproduce** the behavior
- **Expected behavior** vs what actually happened
- **Environment details** (Python version, OS, Headroom version)
- **Code samples** or minimal reproduction if possible
- **Verification is the author's job, not the reviewer's.**
- **Supply chain is a real threat.** Dependency changes get human review, every time.
### Suggesting Features
## Bug fixes
Feature requests are welcome! Please:
Every bug-fix PR must include:
- Check existing issues/discussions first
- Clearly describe the use case and motivation
- Explain how it fits with Headroom's goals (context optimization, safety, determinism)
1. **A reproduction** — minimal code, failing test, or steps.
2. **A test that fails before your fix and passes after** (unit, integration, or e2e).
### Pull Requests
If you genuinely can't write a test, say so explicitly and explain how you verified.
1. **Fork the repository** and create your branch from `main`
2. **Install development dependencies**:
```bash
pip install -e ".[dev]"
```
3. **Make your changes** following our coding standards
4. **Add tests** for new functionality
5. **Run the test suite**:
```bash
pytest
```
6. **Run linting**:
```bash
ruff check .
ruff format .
```
7. **Update documentation** if needed
8. **Submit your PR** with a clear description
## "Real behavior proof" — required on every external PR
## Development Setup
We can't merge what we can't verify. Include a **`Real behavior proof`** section in the PR body covering:
- **Setup you tested on** (OS, Python, config, provider/model)
- **Exact command or steps you ran after the patch**
- **After-fix evidence** + **observed result**
- **What you did *not* test**
✅ Counts: screenshots, recordings, terminal output, copied live output, linked artifacts, redacted runtime logs.
❌ Does **not** count alone: unit tests, mocks, snapshots, lint, typechecks, green CI. Have them too — but they prove the test passes, not that the feature works.
**PRs missing this may be autoclosed.**
## New features
Before writing code:
1. **Open a feature-request issue** (or raise in Discord).
2. **Get a 👍 from a core maintainer** before implementing.
3. **Include a short spec** covering:
- **API surface** (public functions, config, CLI flags)
- **Changes to existing behavior**
- **User stories** — Given / When / Then, golden path + one edge case
- **Failure modes**
- **Recovery / resilience**
- **Security considerations**
Short and concrete beats long.
## Dependencies & supply chain
A human maintainer reviews every dep change. PRs that add or bump a package must justify:
- **Why this package** (vs. doing it ourselves / using existing deps)
- **Who maintains it** (activity, release cadence, security history)
- **Install surface** (transitive deps, native code, install/runtime network)
- **Why this version** — permitted reasons: **bug fix**, **security patch**, **required new functionality**. Cosmetic bumps will be closed.
## PR workflow
1. Fork, branch from `main`.
2. Install **Node 18+** and run `uv sync --extra dev` then `make install-git-hooks` — installs repo pre-commit checks on every commit, commitlint on every commit message, and ci-precheck on every push.
3. One logical change per PR.
4. Add tests.
5. `uv run pytest` · `uv run ruff check .` · `uv run ruff format .`
6. Do **not** edit `CHANGELOG.md` — release-please generates it from your Conventional Commit PR title, so a clear `fix(...)`/`feat(...)` title *is* your changelog entry. A CI guard rejects manual edits.
7. Open the PR with a clear description + `Real behavior proof` + any spec/justification required, and keep the PR in draft until the `Review Readiness` boxes are complete.
**Title format** (conventional commits): `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
**Commit message format** is enforced locally by the repo's `commit-msg` hook and again in CI.
**Review:** CI green, one maintainer review, coverage held/improved.
## Development setup
```bash
# Clone the repository
git clone https://github.com/chopratejas/headroom.git
cd headroom
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate # or `.venv\Scripts\activate` on Windows
# Install in development mode with all dependencies
pip install -e ".[dev,relevance,proxy]"
# Run tests
pytest
# Run tests with coverage
pytest --cov=headroom --cov-report=html
python -m venv .venv && source .venv/bin/activate
node --version # Node 18+ required for commitlint hooks
python -m pip install --upgrade pip
python -m pip install -e ".[dev,relevance,proxy]"
python -m pytest
```
Headroom uses a `pyproject.toml`/`maturin` build backend. Older `pip`
versions may fail editable installs by looking for `setup.py`; upgrade `pip`
first or use `uv sync --extra dev`.
### Dev Containers
If you use VS Code or GitHub Codespaces, Headroom ships two Dev Container configs:
Two configs ship for VS Code / Codespaces:
- **`.devcontainer/devcontainer.json`** - default contributor environment with Python 3.12, `uv`, Node.js, and GitHub CLI
- **`.devcontainer/memory-stack/devcontainer.json`** - the same environment plus Qdrant and Neo4j sidecars, with the locked `memory-stack` extra installed for `qdrant-neo4j` backend work
- **`.devcontainer/devcontainer.json`** — Python 3.12, `uv`, Node.js, `gh`.
- **`.devcontainer/memory-stack/devcontainer.json`** — adds Qdrant + Neo4j sidecars (use `qdrant:6333`, `neo4j://neo4j:7687`).
Inside the memory-stack container, use the sidecar service names instead of `localhost`:
Inside, use: `uv run ruff check .`, `uv run pytest`, etc.
```bash
qdrant:6333
neo4j://neo4j:7687
```
## Optional automated review
The default container runs `uv sync --frozen --extra dev` on creation, so the usual repo commands become:
This repository includes `.github/copilot-instructions.md` so maintainers can opt into GitHub Copilot code review without adding workflow billing noise to every PR.
```bash
uv run ruff check .
uv run ruff format --check .
uv run mypy headroom --ignore-missing-imports
uv run pytest -v --tb=short
```
Enable or disable automatic Copilot review in **Settings → Rules → Rulesets → Automatically request Copilot code review**. Keep it off unless maintainers explicitly want the extra review traffic.
## Coding Standards
## Coding standards
### Style
- [Ruff](https://github.com/astral-sh/ruff) for lint + format, line length 100, PEP 8.
- Type hints on public functions; Google-style docstrings.
- Cover new behavior + edge cases; aim >80% coverage on new code.
- Python 3.10+. Optional features go behind extras.
- We use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting
- Line length: 100 characters
- Use type hints for all public functions
- Follow PEP 8 naming conventions
## Architecture principles
### Code Organization
**Safety first:** never drop user/assistant content, never break tool call/response pairing, malformed content passes through unchanged, prefer false negatives.
```
headroom/
├── __init__.py # Public API exports
├── client.py # HeadroomClient wrapper
├── config.py # Configuration dataclasses
├── transforms/ # Context transforms
│ ├── smart_crusher.py # Statistical compression
│ ├── cache_aligner.py # Cache optimization
│ └── rolling_window.py# Context windowing
├── relevance/ # Relevance scoring
├── providers/ # LLM provider adapters
├── proxy/ # Proxy server
└── storage/ # Metrics storage
```
**Performance:** transforms <50ms at P99, lazy-load optional deps, profile before optimizing.
### Testing
- Write tests for all new functionality
- Use pytest fixtures for common setup
- Test edge cases and error conditions
- Aim for >80% coverage on new code
Example test structure:
```python
class TestSmartCrusher:
"""Tests for SmartCrusher transform."""
def test_compresses_large_arrays(self):
"""Should compress arrays above token threshold."""
...
def test_preserves_errors(self):
"""Should never drop items containing errors."""
...
```
### Documentation
- Add docstrings to all public classes and functions
- Use Google-style docstrings
- Update README.md for user-facing changes
- Add examples for new features
```python
def compress_tool_output(
content: str,
max_items: int = 50,
) -> str:
"""Compress tool output while preserving important items.
Args:
content: The tool output content (usually JSON).
max_items: Maximum items to keep in arrays.
Returns:
Compressed content string.
Raises:
ValueError: If content is not valid JSON.
Example:
>>> compress_tool_output('[{"id": 1}, {"id": 2}]', max_items=1)
'[{"id": 1}]'
"""
```
## Pull Request Guidelines
### PR Title Format
Use conventional commit style:
- `feat: Add semantic caching to proxy`
- `fix: Handle empty tool outputs correctly`
- `docs: Update proxy documentation`
- `test: Add tests for CacheAligner`
- `refactor: Simplify rolling window logic`
### PR Description
Include:
- **What** changes were made
- **Why** the changes were needed
- **How** to test the changes
- **Breaking changes** if any
### Review Process
1. All PRs require at least one review
2. CI must pass (tests, linting, type checking)
3. Maintain or improve test coverage
4. Update CHANGELOG.md for notable changes
## Architecture Decisions
### Safety First
Headroom's core principle is **safety**. When in doubt:
- Never drop user/assistant content
- Never break tool call/response pairing
- Malformed content passes through unchanged
- Prefer false negatives over false positives
### Performance
- Transforms should add <50ms latency at P99
- Use lazy loading for optional dependencies
- Profile before optimizing
### Compatibility
- Support Python 3.10+
- Core functionality has minimal dependencies
- Optional features use extras (e.g., `pip install headroom[relevance]`)
## Recognition
Contributors are recognized in:
- The CHANGELOG for their contributions
- The GitHub contributors page
- Release notes for significant features
Thank you for contributing to Headroom!
Contributors are credited in `CHANGELOG`, the GitHub contributors page, and release notes. Thanks again. 💚

1175
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/headroom-core",
"crates/headroom-proxy",
"crates/headroom-simulators",
"crates/headroom-py",
"crates/headroom-parity",
]
@ -14,6 +15,7 @@ members = [
default-members = [
"crates/headroom-core",
"crates/headroom-proxy",
"crates/headroom-simulators",
"crates/headroom-parity",
]
@ -47,22 +49,29 @@ serde = { version = "1", features = ["derive"] }
# Enabled here in Phase A so PR-B2 can land as a pure consumer change.
serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision", "raw_value"] }
bytes = "1"
thiserror = "1"
tracing = "0.1"
thiserror = "2"
# `log` compat: when no tracing subscriber is active (the case inside the
# headroom-py cdylib), events are re-emitted as `log` records so pyo3-log
# can forward them to Python's logging. No effect on binaries that install
# a real subscriber.
tracing = { version = "0.1", features = ["log"] }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
axum = "0.7"
tower = "0.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
pyo3 = "0.22"
pyo3 = { version = "0.29", features = ["abi3-py310"] }
# Forwards Rust `log` records (incl. tracing events via the `log` compat
# feature above) into Python's `logging` inside the _core extension module.
pyo3-log = "0.13"
# Phase D PR-D1: AWS SigV4 signing for native Bedrock InvokeModel route.
# `aws-sigv4` provides the canonical-request + signing-key implementation;
# `aws-config` resolves credentials from the standard provider chain
# (env vars, profiles, IMDS, ECS task role, etc); `aws-credential-types`
# exposes `Credentials` so the signer accepts whatever the chain returned.
aws-sigv4 = { version = "1", default-features = false, features = ["sign-http", "http1"] }
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio"] }
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio", "sso"] }
aws-credential-types = { version = "1", default-features = false }
# `Identity` lives in aws-smithy-runtime-api; the SigV4 builder
# accepts `&Identity`. Pinning the version explicitly avoids a
@ -75,3 +84,47 @@ aws-smithy-runtime-api = { version = "1", default-features = false, features = [
# us baking provider-specific knowledge in. The token source is wrapped
# in a `TokenSource` trait so tests inject a static-token mock.
gcp_auth = "0.12"
# ── Release profile — wheel size optimization ───────────────────────
#
# PyPI imposes a 10 GB cumulative storage limit per project. We hit it
# at version 0.21.36 (191 versions × ~213 MB/release = 10.00 GB
# exactly). Recent wheels were ~16-18 MB each, of which ~6.4 MB was
# pure debug metadata (`.strtab` + `.symtab` ELF sections; uncovered
# by post-mortem inspection of an actual production wheel).
#
# This profile shrinks each Linux wheel from ~18 MB → ~10-11 MB by:
# * Stripping symbol/string tables (~6.4 MB direct savings)
# * Link-time optimization across crate boundaries (~5-10% .text
# savings via dead-code elim across the workspace)
# * Single codegen unit (better inlining + dead-code elim, at the
# cost of slightly slower release builds)
#
# We deliberately do NOT set ``panic = "abort"``. The proxy is a
# long-lived async process — a single misbehaving request triggering
# panic-abort would terminate the whole proxy and disconnect every
# concurrent client. Accept the smaller savings; keep unwind behaviour.
#
# Estimated impact: 213 MB/release → ~130 MB/release. Buys ~30+ more
# release slots within the 10 GB ceiling at the current release
# cadence. Per-PyPI-version savings AND faster downloads for end
# users. Tradeoff: release builds take ~30-50% longer due to
# `codegen-units = 1` + LTO; acceptable for the size win.
[profile.release]
strip = "symbols"
lto = "thin"
codegen-units = 1
# Fast-to-compile profile for CI test wheels. The shipped wheel uses
# `release` (lto + codegen-units=1) for runtime/size; CI only needs a working
# extension, so trade runtime perf for ~parallel, lto-free compilation. Used
# via `maturin build --profile ci`. Does NOT affect `--release` builds.
[profile.ci]
inherits = "release"
lto = false
codegen-units = 256
opt-level = 1
strip = "none"
debug = false
incremental = false

View file

@ -1,16 +1,14 @@
ARG PYTHON_VERSION=3.11
ARG UV_VERSION=0.6.17
# Pinned 2026-04-15. Update via Dependabot or: docker pull python:3.11-slim
ARG PYTHON_DIGEST=sha256:233de06753d30d120b1a3ce359d8d3be8bda78524cd8f520c99883bfe33964cf
# Pinned 2026-04-15. Update via Dependabot or: docker pull gcr.io/distroless/python3-debian13
ARG DISTROLESS_DIGEST=sha256:ed3a4beb46f8f8baac068743ba1b1f95ea3f793422129cf6dd23967f779b6018
ARG PYTHON_VERSION=3.13
ARG UV_VERSION=0.11.18
ARG DISTROLESS_IMAGE=gcr.io/distroless/python3-debian13
ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages
# ---- Build stage: compile native extensions, build wheel ----
FROM python:${PYTHON_VERSION}-slim@${PYTHON_DIGEST} AS builder
FROM python:${PYTHON_VERSION}-slim AS builder
ARG UV_VERSION
ARG PYTHON_SITE_PACKAGES
ARG HEADROOM_BUILD_VERSION=""
# build-essential / g++ for any C extension wheels uv may need to build
# from source. curl + ca-certificates are required by the rustup
@ -51,10 +49,90 @@ COPY headroom/ headroom/
ARG HEADROOM_EXTRAS=proxy,code
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \
uv pip install --system ".[${HEADROOM_EXTRAS}]"
RUN --mount=type=bind,source=.,target=/context,readonly \
HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" PYTHON_SITE_PACKAGES="${PYTHON_SITE_PACKAGES}" python - <<'PY'
import hashlib
import os
from pathlib import Path
def git_revision(context: Path) -> str | None:
git_dir = context / ".git"
head_path = git_dir / "HEAD"
if not head_path.exists():
return None
head = head_path.read_text(encoding="utf-8").strip()
if head.startswith("ref: "):
ref_name = head.removeprefix("ref: ").strip()
ref_path = git_dir / ref_name
if ref_path.exists():
head = ref_path.read_text(encoding="utf-8").strip()
else:
packed_refs = git_dir / "packed-refs"
if not packed_refs.exists():
return None
for line in packed_refs.read_text(encoding="utf-8").splitlines():
if line.startswith("#") or not line.strip():
continue
sha, _, name = line.partition(" ")
if name.strip() == ref_name:
head = sha
break
else:
return None
return head[:12] if len(head) >= 7 and all(c in "0123456789abcdef" for c in head.lower()) else None
def source_digest(root: Path) -> str:
digest = hashlib.sha256()
inputs = (
"pyproject.toml",
"uv.lock",
"README.md",
"Cargo.toml",
"Cargo.lock",
"rust-toolchain.toml",
"crates",
"headroom",
)
for name in inputs:
path = root / name
if not path.exists():
continue
files = [path] if path.is_file() else sorted(p for p in path.rglob("*") if p.is_file())
for file in files:
digest.update(file.relative_to(root).as_posix().encode("utf-8"))
digest.update(b"\0")
digest.update(file.read_bytes())
digest.update(b"\0")
return digest.hexdigest()[:12]
build_version = os.environ["HEADROOM_BUILD_VERSION"].strip()
if not build_version:
print("no Headroom build version override provided; using installed package metadata")
raise SystemExit(0)
if build_version == "source-build":
revision = git_revision(Path("/context"))
build_version = (
f"source-build+g{revision}"
if revision
else f"source-build+sha256.{source_digest(Path('/build'))}"
)
package_dir = Path(os.environ["PYTHON_SITE_PACKAGES"]) / "headroom"
(package_dir / "_build_info.py").write_text(
"BUILD_VERSION = " + repr(build_version) + "\n",
encoding="utf-8",
)
print("baked Headroom build version: " + build_version)
PY
# Build-stage smoke check: verify the extension loads end-to-end inside
# the build image before we copy site-packages into the runtime image.
# If this fails, the runtime image would fail Phase A0's fail-loud
@ -64,10 +142,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \
RUN cd /tmp && python -c "from headroom._core import DiffCompressor, SmartCrusher; \
print(f'build-stage rust core verify OK: {DiffCompressor.__name__}, {SmartCrusher.__name__}')"
# Build the native Rust reverse proxy binary and stage it for the runtime
# images (issue #976). These images already run "the proxy"; bundling the
# native `headroom-proxy` binary lets operators front the Python proxy with
# the Rust SigV4 / live-zone compression path from the same image. The
# binary is copied out of the cache-mounted target dir into a persistent
# path so the COPY in the runtime stages can pick it up.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release --locked --bin headroom-proxy && \
cp target/release/headroom-proxy /usr/local/bin/headroom-proxy
# ---- Runtime stage (python-slim): supports root/nonroot via build arg ----
FROM python:${PYTHON_VERSION}-slim@${PYTHON_DIGEST} AS runtime-slim-base
FROM python:${PYTHON_VERSION}-slim AS runtime-slim-base
ARG RUNTIME_USER=nonroot
ARG RUNTIME_HOME=/home/nonroot
ARG PYTHON_SITE_PACKAGES
RUN apt-get update && \
@ -76,6 +166,8 @@ RUN apt-get update && \
COPY --from=builder ${PYTHON_SITE_PACKAGES} ${PYTHON_SITE_PACKAGES}
COPY --from=builder /usr/local/bin/headroom /usr/local/bin/headroom
# Native Rust reverse proxy binary (issue #976).
COPY --from=builder /usr/local/bin/headroom-proxy /usr/local/bin/headroom-proxy
RUN mkdir -p /home/nonroot /data && \
if [ "$RUNTIME_USER" = "nonroot" ]; then \
@ -88,12 +180,19 @@ RUN mkdir -p /home/nonroot /data && \
fi
USER ${RUNTIME_USER}
WORKDIR /home/nonroot
WORKDIR ${RUNTIME_HOME}
ENV HEADROOM_HOST=0.0.0.0 \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Declare ~/.headroom as a volume so Docker (and ACA) can attach persistent
# storage here. Bare `docker run` gets an anonymous volume as a fallback so
# state is never silently written to the ephemeral container layer.
# RUNTIME_HOME defaults to /home/nonroot (the published image default); pass
# --build-arg RUNTIME_HOME=/root when building with RUNTIME_USER=root.
VOLUME ${RUNTIME_HOME}/.headroom
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
@ -102,12 +201,14 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
ENTRYPOINT ["headroom", "proxy"]
CMD ["--host", "0.0.0.0", "--port", "8787"]
FROM ${DISTROLESS_IMAGE}@${DISTROLESS_DIGEST} AS runtime-slim
FROM ${DISTROLESS_IMAGE} AS runtime-slim
ARG RUNTIME_USER=nonroot
ARG PYTHON_SITE_PACKAGES
COPY --from=builder ${PYTHON_SITE_PACKAGES} ${PYTHON_SITE_PACKAGES}
# Native Rust reverse proxy binary (issue #976).
COPY --from=builder /usr/local/bin/headroom-proxy /usr/local/bin/headroom-proxy
USER ${RUNTIME_USER}
WORKDIR /app

View file

@ -12,7 +12,7 @@ FIXTURES ?= tests/parity/fixtures
help:
@echo "Headroom Rust targets:"
@echo " make test - cargo test --workspace"
@echo " make test-parity - maturin develop + parity-run against fixtures"
@echo " make test-parity - parity-run against recorded fixtures"
@echo " make bench - cargo bench --workspace"
@echo " make build-proxy - release build + strip headroom-proxy, print size"
@echo " make build-wheel - release wheel for headroom-py"
@ -22,22 +22,26 @@ help:
@echo " make lint - cargo clippy --workspace -- -D warnings"
@echo " make clean - cargo clean"
@echo ""
@echo "E2e targets:"
@echo " make build-e2e-wrap - build the wrap-e2e Docker image"
@echo " make run-e2e-wrap - build + run the wrap-e2e Docker container"
@echo ""
@echo "Pre-push verification (run BEFORE git push to catch CI failures locally):"
@echo " make ci-precheck - run all CI gates (rust + python + commitlint)"
@echo " make ci-precheck-rust - cargo fmt --check + clippy + test"
@echo " make ci-precheck-python - smart_crusher-affected python tests"
@echo " make ci-precheck-commitlint - lint commits since origin/main"
@echo " make install-git-hooks - install a pre-push hook that runs ci-precheck"
@echo " make install-git-hooks - install pre-commit, commit-msg, and pre-push hooks"
test:
$(CARGO) test --workspace
# headroom-parity has no pyo3 dependency — its comparators call headroom-core
# directly, so this target needs neither a venv nor a built extension module.
# (See crates/headroom-parity/Cargo.toml: "Phase 0 does not invoke Python from
# Rust.") Dropping the `maturin develop` step keeps the harness runnable from a
# bare checkout and takes the Python toolchain off the CI parity job.
test-parity:
@if [ -z "$$VIRTUAL_ENV" ]; then \
echo "error: activate a venv first (e.g. source .venv/bin/activate)"; \
exit 1; \
fi
$(MATURIN) develop -m crates/headroom-py/Cargo.toml
$(CARGO) run -p headroom-parity -- run --fixtures $(FIXTURES)
bench:
@ -123,19 +127,31 @@ ci-precheck-python:
tests/test_toin_integration.py
# Lint commits since `origin/main`. Requires npx (Node 18+) on PATH.
# Skips silently if npx is unavailable; install nodejs to enable.
ci-precheck-commitlint:
@echo "── ci-precheck-commitlint ─────────────────────────────────────"
@if ! command -v npx >/dev/null 2>&1; then \
echo "skip: npx not on PATH (install node 18+ to enable commitlint pre-check)"; \
exit 0; \
echo "error: npx not on PATH (install Node 18+ to enable commitlint checks)"; \
exit 1; \
fi
@if ! git rev-parse --verify origin/main >/dev/null 2>&1; then \
echo "skip: origin/main not fetched (run 'git fetch origin main')"; \
exit 0; \
echo "error: origin/main not fetched (run 'git fetch origin main')"; \
exit 1; \
fi
npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional -- \
commitlint --from origin/main --to HEAD --config .commitlintrc.json
install-git-hooks:
@scripts/install-git-hooks.sh
# ─── E2e Docker targets ────────────────────────────────────────────────────
#
# The wrap-e2e Dockerfile uses manylinux_2_28_x86_64 as its builder stage,
# which only ships amd64 binaries. Pass --platform linux/amd64 explicitly
# so the build works on Apple Silicon (requires QEMU emulation). On native
# x86_64 hosts the flag is harmless and matches CI behaviour.
build-e2e-wrap:
docker build --platform linux/amd64 -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
run-e2e-wrap: build-e2e-wrap
docker run --rm headroom-wrap-e2e

14
NOTICE
View file

@ -41,3 +41,17 @@ NumPy (optional dependency)
Copyright (c) 2005-2024, NumPy Developers
Licensed under the BSD 3-Clause License
https://github.com/numpy/numpy
Vendored dashboard assets (headroom/dashboard/static/)
------------------------------------------------------
Tailwind CSS 3.4.17 (Play CDN build) — MIT License
Copyright (c) Tailwind Labs, Inc.
https://github.com/tailwindlabs/tailwindcss
htmx 1.9.10 — Zero-Clause BSD License
Copyright (c) 2020, Big Sky Software
https://github.com/bigskysoftware/htmx
Alpine.js 3.13.3 — MIT License
Copyright (c) 2019-2025 Caleb Porzio and contributors
https://github.com/alpinejs/alpine

170
PR.md
View file

@ -1,170 +0,0 @@
## Description
Implement unified CI/CD release automation with semantic versioning across all three packages:
- **Python (headroom-ai)** — pip package on PyPI
- **TypeScript SDK (headroom-ai)** — npm package on npmjs.org
- **OpenClaw plugin (headroom-openclaw)** — npm package on npmjs.org and GitHub Package Registry
Currently the three packages are independently versioned (0.5.25 / 0.1.0 / 0.1.0). This PR introduces a single-source-of-truth version in `pyproject.toml` that propagates to all packages on every release, driven by conventional commit messages.
Fixes #(issue number)
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)
## Changes Made
### New Files
**Scripts:**
- `scripts/version-sync.py` — Reads version from `pyproject.toml`, updates all 4 version files. Supports `--version X.Y.Z` and `--bump {major,minor,patch}`.
- `scripts/changelog-gen.py` — Parses conventional commits since last tag, groups by type, generates markdown changelog with breaking change detection.
- `scripts/verify-versions.py` — Pre-release sanity check that all 4 version files are in sync.
- `scripts/tests/test_version_sync.py` — 5 tests for version-sync.py
- `scripts/tests/test_changelog_gen.py` — 23 tests for changelog-gen.py
**Workflows:**
- `.github/workflows/release.yml` — Unified release pipeline: detect → build → publish-pypi → publish-npm → publish-github-packages → create-release
- `.commitlintrc.json` — Conventional commit enforcement via `@commitlint/config-conventional`
**Local Testing (act):**
- `.actrc` — Default `act` flags (Ubuntu runner, reuse, quiet)
- `.github/act/dry-run.json``act` event file for dry-run testing
- `.github/act/push-feat.json``act` event file for simulating a feat commit
- `.actrc.local.example` — Local override template for `act`
- `.env.act.example` — Secrets documentation template for `act` local testing
**Documentation:**
- `docs/content/docs/releases.mdx` — Full documentation for the release pipeline, testing guide, and configuration reference
### Modified Files
- `.github/workflows/ci.yml` — Added `commitlint` job to enforce conventional commits
- `.github/workflows/publish.yml` — Changed from `release` trigger to `workflow_dispatch` only (superseded by `release.yml`)
- `.github/workflows/release.yml`**Rewritten** with canonical+commit-height algorithm (no more commit loop)
- `.gitignore` — Added `!scripts/version-sync.py`, `!scripts/changelog-gen.py`, `!scripts/verify-versions.py`, `!scripts/tests/`, `.env.act`, `.actrc.local`
## Testing
- [x] Unit tests pass (`pytest`)
- `scripts/tests/test_version_sync.py` — 5/5 passing
- `scripts/tests/test_changelog_gen.py` — 23/23 passing
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — pre-existing issue in `headroom/cli/wrap.py:487` (unrelated)
- [x] New tests added for new functionality
- [x] Workflow tested with `act` (dry-run passes all jobs through build step — no infinite loop)
## Algorithm Validation
The canonical+commit-height algorithm was validated with test cases:
- Canonical `0.5.25`, no prior tag, `feat:` commit → git tag `v0.6.0.0`, npm `0.6.0`
- Canonical `0.5.25`, tag `v0.5.25.2`, `fix:` commit → git tag `v0.5.25.3`, npm `0.5.26`
- Canonical `0.5.25`, no prior tag, `fix:` commit → git tag `v0.5.25.0`, npm `0.5.25`
- Manual override `1.2.3` → git tag `v1.2.3`, npm `1.2.3`
## Test Output
```
scripts/tests/test_version_sync.py .....
scripts/tests/test_changelog_gen.py .......................
```
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have made corresponding changes to the documentation
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
### Version Bump Logic
**Canonical + Commit Height Algorithm** — The workflow NEVER commits back to the repo. `pyproject.toml` is the canonical source of truth, updated manually before merging.
| Commit | Bump | Git Tag | npm Version |
|--------|------|---------|-------------|
| `fix:`, `ci:`, `chore:`, `perf:`, `refactor:` | patch | `v0.5.25.3` | `0.5.26` |
| `feat:` | minor | `v0.6.0.0` | `0.6.0` |
| `feat!:` or `feat:` + `BREAKING CHANGE` body | major | `v1.0.0.0` | `1.0.0` |
The git tag uses `v{canonical}.{height}` (e.g., `v0.5.25.3` = 3 commits since canonical `0.5.25`). npm versions use 3-part semver, bumped from canonical.
### Package Publishing Targets
| Package | Target | Status |
|---------|--------|--------|
| `headroom-ai` (Python) | PyPI | ✅ via `pypa/gh-action-pypi-publish` |
| `headroom-ai` (TypeScript SDK) | npmjs.org | ✅ via `npm publish` |
| `headroom-openclaw` | npmjs.org | ✅ via `npm publish` |
| `headroom-openclaw` | GitHub Package Registry | ✅ via `npm publish --registry npm.pkg.github.com` |
### Safety Gates
Each publish job requires both `dry_run != 'true'` **and** the corresponding skip variable not set:
| Variable | Effect |
|----------|--------|
| `PYPI_SKIP=true` | Skip PyPI publish |
| `NPM_SKIP=true` | Skip both npm publishes |
| `GH_PACKAGES_SKIP=true` | Skip GitHub Package Registry publish |
Set in: **GitHub repo → Settings → Variables → Actions Variables**.
### Workflow Triggers
- **Auto:** On push to `main` — analyzes latest commit, bumps version, builds, publishes, creates GitHub Release
- **Manual:** `workflow_dispatch` with optional `version` override and `dry_run` flag
- **Paths ignore:** Skips runs when only `docs/`, `.github/workflows/ci.yml`, `.github/workflows/publish.yml`, `scripts/`, `.commitlintrc.json`, `.actrc`, `.github/act/`, or `.env.act.example` change
### Local Testing
```bash
# Install act
winget install act
# Dry-run (no publishes)
act -W .github/workflows/release.yml -e .github/act/dry-run.json
# Test feat: commit (minor bump)
act -W .github/workflows/release.yml -e .github/act/push-feat.json
```
### Required GitHub Secrets
| Secret | Purpose |
|--------|---------|
| `NPM_TOKEN` | Publishing to npmjs.org |
| `GITHUB_TOKEN` | GitHub Package Registry (auto-provided by GitHub Actions) |
PyPI uses trusted publisher OIDC — no secret required, only the `pypi` GitHub Environment must be configured.
### First Release Note
The TypeScript packages are currently at `0.1.0` while Python is at `0.5.25`. The first release will align all three to the same version. Update `pyproject.toml` to the desired canonical version before merging, then use `workflow_dispatch` with a manual `version` input to set the target explicitly.
After each release, update `pyproject.toml` to match the published version to keep the canonical current and ensure unique git tags.
### Parameterized Configuration
All package names and registries are top-level `env` constants in `release.yml`:
```yaml
env:
PYPI_PACKAGE: headroom-ai
PYPI_ENVIRONMENT: pypi
NPM_REGISTRY_URL: https://registry.npmjs.org
NPM_SDK_PACKAGE: headroom-ai
NPM_OPENCLAW_PACKAGE: headroom-openclaw
GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com
```

456
README.md
View file

@ -1,37 +1,44 @@
```
<div align="center"><pre>
██╗ ██╗███████╗ █████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███╗ ███╗
██║ ██║██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔═══██╗██╔═══██╗████╗ ████║
███████║█████╗ ███████║██║ ██║██████╔╝██║ ██║██║ ██║██╔████╔██║
██╔══██║██╔══╝ ██╔══██║██║ ██║██╔══██╗██║ ██║██║ ██║██║╚██╔╝██║
██║ ██║███████╗██║ ██║██████╔╝██║ ██║╚██████╔╝╚██████╔╝██║ ╚═╝ ██║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝
The context compression layer for AI agents
```
The context compression layer for AI agents
</pre></div>
<p align="center"><strong>6095% fewer tokens · library · proxy · MCP · 6 algorithms · local-first · reversible</strong></p>
<p align="center"><strong>6095% fewer tokens (for JSON data), 15-20% fewer tokens (for coding agents) · library · proxy · MCP · content-aware compressors · local-first · reversible</strong></p>
<p align="center">
<a href="https://github.com/chopratejas/headroom/actions/workflows/ci.yml"><img src="https://github.com/chopratejas/headroom/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://app.codecov.io/gh/chopratejas/headroom"><img src="https://codecov.io/gh/chopratejas/headroom/graph/badge.svg" alt="codecov"></a>
<a href="https://pypi.org/project/headroom-ai/"><img src="https://img.shields.io/pypi/v/headroom-ai.svg" alt="PyPI"></a>
<a href="https://www.npmjs.com/package/headroom-ai"><img src="https://img.shields.io/npm/v/headroom-ai.svg" alt="npm"></a>
<a href="https://huggingface.co/chopratejas/kompress-base"><img src="https://img.shields.io/badge/model-Kompress--base-yellow.svg" alt="Model: Kompress-base"></a>
<a href="https://headroomlabs.ai/dashboard"><img src="https://img.shields.io/badge/tokens%20saved-60B%2B-2ea44f" alt="Tokens saved: 60B+"></a>
<a href="https://huggingface.co/chopratejas/kompress-v2-base"><img src="https://img.shields.io/badge/model-Kompress--v2--base-yellow.svg" alt="Model: Kompress-v2-base"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License: Apache 2.0"></a>
<a href="https://headroom-docs.vercel.app/docs"><img src="https://img.shields.io/badge/docs-online-blue.svg" alt="Docs"></a>
</p>
<!-- mcp-name: io.github.headroomlabs-ai/headroom -->
<p align="center">
<a href="https://headroom-docs.vercel.app/docs">Docs</a> ·
<a href="#get-started-60-seconds">Install</a> ·
<a href="#proof">Proof</a> ·
<a href="#agent-compatibility-matrix">Agents</a> ·
<a href="https://discord.gg/yRmaUNpsPJ">Discord</a>
<a href="https://discord.gg/yRmaUNpsPJ">Discord</a> ·
<a href="llms.txt">llms.txt</a>
</p>
---
<p align="center"><sub>
<b>AI agents / LLMs:</b> read <a href="llms.txt"><code>/llms.txt</code></a> here, or fetch <a href="https://headroom-docs.vercel.app/llms.txt">the live index</a> / <a href="https://headroom-docs.vercel.app/llms-full.txt">full docs blob</a>.
</sub></p>
> Headroom compresses everything your AI agent reads — tool outputs, logs, RAG chunks, files, and conversation history — before it reaches the LLM. Same answers, fraction of the tokens.
---
<p align="center"><a href="https://trendshift.io/repositories/20881" target="_blank"><img src="https://trendshift.io/api/badge/repositories/20881" alt="chopratejas%2Fheadroom | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a></p>
Headroom compresses everything your AI agent reads — tool outputs, logs, RAG chunks, files, and conversation history — before it reaches the LLM. Same answers, fraction of the tokens.
<p align="center">
<img src="HeadroomDemo-Fast.gif" alt="Headroom in action" width="820">
@ -42,11 +49,12 @@
- **Library**`compress(messages)` in Python or TypeScript, inline in any app
- **Proxy**`headroom proxy --port 8787`, zero code changes, any language
- **Agent wrap**`headroom wrap claude|codex|cursor|aider|copilot` in one command
- **Agent wrap**`headroom wrap claude|codex|grok|copilot|cursor|aider|opencode|cline|continue|goose|openhands|openclaw|vibe|omp|zcode` in one command; undo with `headroom unwrap <tool>`
- **MCP server**`headroom_compress`, `headroom_retrieve`, `headroom_stats` for any MCP client
- **Cross-agent memory** — shared store across Claude, Codex, Gemini, auto-dedup
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.md` / `AGENTS.md`
- **Reversible (CCR)** — originals never deleted; LLM retrieves on demand
- **Cross-agent memory** — shared store across Claude, Codex, Gemini, Grok, auto-dedup
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored) or `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `GROK.md`
- **Output token reduction** — trims what the model *writes back* (not just what you send): drops ceremony/restated code and skips deep "thinking" on routine steps. See [Output token reduction](#output-token-reduction-cut-what-the-model-writes-back).
- **Reversible (CCR)** — originals are cached for retrieval on demand
## How it works (30 seconds)
@ -57,13 +65,13 @@
┌────────────────────────────────────────────────────┐
│ Headroom (runs locally — your data stays here) │
│ ───────────────────────────────────────────────
│ CacheAligner → ContentRouter → CCR
│ ├─ SmartCrusher (JSON)
│ ├─ CodeCompressor (AST)
│ └─ Kompress-base (text, HF)
│ Cross-agent memory · headroom learn · MCP
│ ───────────────────────────────────────────────
│ CacheAligner → ContentRouter → CCR │
│ ├─ SmartCrusher (JSON) │
│ ├─ CodeCompressor (AST) │
│ └─ Kompress-v2-base (text, HF) │
│ │
│ Cross-agent memory · headroom learn · MCP │
└────────────────────────────────────────────────────┘
│ compressed prompt + retrieval tool
@ -71,29 +79,58 @@
```
- **ContentRouter** — detects content type, selects the right compressor
- **SmartCrusher / CodeCompressor / Kompress-base** — compress JSON, AST, or prose
- **CacheAligner** — stabilizes prefixes so provider KV caches actually hit
- **SmartCrusher / CodeCompressor / Kompress-v2-base** — compress JSON, AST, or prose
- **CacheAligner** - detects and warns about volatile content that can bust provider KV cache prefixes; never rewrites prompts
- **CCR** — stores originals locally; LLM calls `headroom_retrieve` if it needs them
→ [Architecture](https://headroom-docs.vercel.app/docs/architecture) · [CCR reversible compression](https://headroom-docs.vercel.app/docs/ccr) · [Kompress-base model card](https://huggingface.co/chopratejas/kompress-base)
→ [Architecture](https://headroom-docs.vercel.app/docs/architecture) · [CCR reversible compression](https://headroom-docs.vercel.app/docs/ccr) · [Kompress-v2-base model card](https://huggingface.co/chopratejas/kompress-v2-base)
## Get started (60 seconds)
```bash
# 1 — Install
pip install "headroom-ai[all]" # Python
npm install headroom-ai # Node / TypeScript
uv tool install --python 3.13 "headroom-ai[all]" # CLI as a global tool in a self-contained virtual env
pip install "headroom-ai[all]" # Python — ships the `headroom` CLI
npm install headroom-ai # TypeScript SDK only — no `headroom` CLI
# 2 — Pick your mode
# 2 — Pick your mode (the `headroom` commands below come from the uv or pip install)
headroom deploy # turnkey local deployment + agent config
headroom wrap claude # wrap a coding agent
headroom proxy --port 8787 # drop-in proxy, zero code changes
# or: from headroom import compress # inline library
# 3 — See the savings
headroom stats
# 3 — Verify setup and see the savings
headroom doctor # health check — confirms routing is working
headroom perf
headroom dashboard # live savings dashboard (proxy must be running)
```
Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[agno]`, `[langchain]`, `[evals]`. Requires **Python 3.10+**.
To use headroom, it is recommended you launch a wrapped agent session each time so that all necessary setup is completed. When wrapping a coding agent, headroom starts a local proxy, installs **Serena** for semantic code navigation, and launches a coding agent session configured to proxy requests through headroom.
Serena is registered at **user scope** (for Claude Code, in `~/.claude.json`), so it stays available in your other projects until you run `headroom unwrap`. To skip it entirely, wrap with `--code-memory none`.
The `headroom` CLI ships **only** via the PyPI package. The npm `headroom-ai` is the TypeScript SDK — a library you import (`import { compress } from 'headroom-ai'`), not a CLI, so it provides no `headroom` command.
Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
### Codex / global install
If Codex or another MCP client cannot inherit a shell `PATH` reliably, install Headroom as a persistent uv tool and point the client at the absolute binary path:
```bash
uv tool install "headroom-ai[all]"
command -v headroom
```
Then use the returned path in MCP config:
```toml
[mcp_servers.headroom]
command = "/absolute/path/from/command-v/headroom"
args = ["mcp", "serve"]
```
`command = "headroom"` only works when the client starts with a `PATH` that already includes the uv tool directory.
## Proof
@ -117,32 +154,189 @@ Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[agno]`, `[langchain]`, `[evals]`.
Reproduce: `python -m headroom.evals suite --tier 1` · [Full benchmarks & methodology](https://headroom-docs.vercel.app/docs/benchmarks)
<p align="center">
<a href="https://headroomlabs.ai/dashboard">
<img src="headroom-savings.png" alt="60B+ tokens saved — community leaderboard" width="820">
</a>
<br/><b><a href="https://headroomlabs.ai/dashboard">60B+ tokens saved by the community — live leaderboard →</a></b>
</p>
## Output token reduction (cut what the model writes back)
Everything above shrinks the prompt you **send**. But you also pay for every
token the model **writes back** — and on Opus-class models output costs 5× input.
A lot of that output is waste: "Great, let me…" preambles, re-printing code you
just showed it, and deep "thinking" on routine steps like reading a file.
Headroom can trim that too, from the proxy, without you changing any code:
- **Verbosity steering** — appends a short "be terse, don't restate context"
note to the end of the system prompt (so your prompt cache still hits).
- **Effort routing** — when a turn is just the model resuming after a tool result
(a file read, a passing test), it dials the model's thinking effort down. New
questions and errors keep full effort.
Applies to Anthropic `/v1/messages` **and** OpenAI-compatible endpoints
(`/v1/chat/completions`, `/v1/responses`). Effort routing uses
`reasoning_effort` on OpenAI, `thinking.budget_tokens` /
`output_config.effort` on Anthropic — same clamp-only invariant on both
paths, same `output_shaper:*` label vocabulary.
Turn it on:
```bash
export HEADROOM_OUTPUT_SHAPER=1 # off by default
headroom proxy --port 8787
```
> **Already running a proxy?** These switches are read *live* on every request,
> so a proxy that `headroom wrap` **reused** (rather than started) would not see
> a value you export afterwards — its environment was snapshotted at launch.
> `headroom wrap` now hot-syncs your current settings to the running proxy via a
> loopback `POST /admin/runtime-env`, so they take effect immediately with **no
> restart** (no cold start, no dropped requests, no lost caches). Set them before
> you `wrap`. On a shared proxy these overrides are global — the last explicit
> setting wins.
**Learn the right terseness for you.** People don't *say* how terse they want
answers — they *show* it (they interrupt long replies, or move on before they
could have read them). `headroom learn --verbosity` reads your past sessions and
picks the level automatically:
```bash
headroom learn --verbosity # preview what it found (dry run)
headroom learn --verbosity --apply # save it; the proxy uses it from now on
```
**See how many output tokens you saved.** Output savings are *counterfactual*
we never see what the model *would* have written — so Headroom reports an honest
**estimate with a confidence range**, never a made-up number:
```bash
headroom output-savings
# Reduction: 31.7% (95% CI 27.7% … 35.7%) [estimated]
```
Want a *measured* number instead of an estimate? Leave 10% of conversations
unshaped as a control group: `export HEADROOM_OUTPUT_HOLDOUT=0.1`. The dashboard
shows an **Output Tokens Saved** card next to input compression, labelled
`measured` or `estimated` with the confidence band.
→ Full write-up incl. the measurement methodology: [Output token reduction](https://headroom-docs.vercel.app/docs/savings)
<a href="https://www.star-history.com/?repos=chopratejas%2Fheadroom&type=date&legend=top-left">
<picture>
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=chopratejas/headroom&type=date&legend=top-left" />
</picture>
</a>
## Agent compatibility matrix
| Agent | `headroom wrap` | Notes |
|-------------|:---------------:|----------------------------------|
| Claude Code | ● | `--memory` · `--code-graph` |
| Codex | ● | shares memory with Claude |
| Cursor | ● | prints config — paste once |
| Aider | ● | starts proxy + launches |
| Copilot CLI | ● | starts proxy + launches |
| OpenClaw | ● | installs as ContextEngine plugin |
| Agent | `headroom wrap` | Notes |
|--------------|:---------------:|----------------------------------|
| Claude Code | ✅ | `--memory` · `--code-graph` · `--1m` · `--tool-search` |
| Codex | ✅ | shares memory with Claude |
| Grok CLI | ✅ | routes via `GROK_MODELS_BASE_URL` |
| Cursor | Manual setup | starts proxy and prints base URLs for Cursor settings |
| Aider | ✅ | starts proxy + launches |
| Copilot CLI | ✅ | starts proxy + launches |
| VS Code Copilot | ✅ | transparent proxy; preserves selected model |
| OpenClaw | ✅ | installs as ContextEngine plugin |
| OpenCode | ✅ | injects config · starts proxy + launches |
| Cline | ✅ | starts proxy + injects config |
| Continue | ✅ | starts proxy + injects config |
| Goose | ✅ | starts proxy + launches |
| OpenHands | ✅ | starts proxy + launches |
| Mistral Vibe | ✅ | starts proxy + launches |
| Oh My Pi | ✅ | injects config · starts proxy + launches |
| Cortex Code | Library only | 6065% savings (library mode; no `wrap`) |
| Kimi CLI | ✅ | OAuth bearer forwarded — log in once |
| ZCode | ✅ | starts proxy and prints base URLs for ZCode settings |
Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`.
Undo durable wrapping with `headroom unwrap <tool>` (supports: `claude`, `copilot`, `codex`, `grok`, `kimi`, `omp`, `opencode`, `openclaw`, `zcode`).
Registry authors can use the canonical [`server.json`](server.json) in the repo root instead of reconstructing the `headroom mcp serve` contract from prose.
### GitHub Copilot CLI subscription mode
Headroom can route GitHub Copilot CLI subscription traffic through the local proxy:
```bash
headroom copilot-auth login
headroom wrap copilot --subscription -- --model gpt-4o
```
This lets Headroom intercept OpenAI-compatible Copilot CLI requests and apply the same proxy compression pipeline before forwarding to GitHub Copilot's hosted API. The wrapper exchanges Headroom's reusable GitHub OAuth token for Copilot's short-lived API token and prints the upstream endpoint as `COPILOT_PROVIDER_API_URL=...` during launch.
`headroom copilot-auth login` stores a Headroom-specific Copilot OAuth token.
This avoids relying on generic GitHub or Copilot CLI tokens that can read
Copilot account metadata but may still be rejected by Copilot's token-exchange
endpoint.
For GitHub Enterprise Server or custom-domain Copilot deployments, set one of
these before launching:
```bash
export GITHUB_COPILOT_ENTERPRISE_DOMAIN=ghe.example.com
# or
export GITHUB_COPILOT_ENTERPRISE_URL=https://ghe.example.com
```
Both variables are supported. If both are set,
`GITHUB_COPILOT_ENTERPRISE_URL` takes precedence.
For GitHub.com Enterprise Cloud URLs such as
`github.com/enterprises/your-enterprise`, do not set an enterprise-domain
override. Headroom uses GitHub's normal token-exchange endpoint and the Copilot
API endpoint advertised for the signed-in account.
Platform support note: macOS auth reuse via Copilot CLI Keychain storage has been smoke-tested. Windows Credential Manager, Linux Secret Service / `secret-tool`, and Docker/CI token-injection paths are implemented or planned as auth-discovery paths, but still need real OS validation before they should be considered fully vetted. For Docker and CI, prefer passing an explicit `GITHUB_COPILOT_TOKEN` or `GITHUB_COPILOT_GITHUB_TOKEN` rather than relying on host keychain access.
### GitHub Copilot in Visual Studio Code
Headroom transparently overrides Copilot's API proxy endpoint, so the normal VS
Code model picker remains authoritative. GPT-5.5, GPT-5.6 Luna/Sol/Terra, Claude
Sonnet/Opus, and other Copilot models keep their original model IDs while traffic
passes through the local compression proxy. Headroom does not patch VS Code or
change Codex settings:
```bash
headroom copilot-auth login
headroom wrap vscode
```
Keep the command running and use Copilot normally. Headroom holds the short-lived
upstream Copilot token only in the proxy process.
See the [cross-platform VS Code Copilot guide](https://headroom-docs.vercel.app/docs/vscode-copilot)
for paths, credential flow, remote-development notes, undo steps, and troubleshooting.
### Claude Code in Visual Studio Code
The official Claude Code extension embeds Claude Code and reads the same user
settings as the CLI. Install Headroom's proxy dependencies, then run the wrapper
from the project you plan to open in VS Code:
```bash
pip install "headroom-ai[proxy]"
headroom wrap vscode-claude
```
On the first run, reload the VS Code window. Keep the wrapper terminal running
while you use the Claude Code panel; inspect the dashboard or proxy log printed
at startup to see requests and savings.
Headroom preserves your Anthropic authentication and selected model.
Press `Ctrl+C` to stop the proxy. Restart the same command before using Claude
Code again, or completely restore the settings that existed before setup:
```bash
headroom unwrap vscode-claude
```
See the
[VS Code Claude Code guide](https://headroom-docs.vercel.app/docs/vscode-claude-code)
for verification, configuration paths, custom profiles, remote development, and
troubleshooting.
## When to use · When to skip
**Great fit if you…**
- run AI coding agents daily and want savings without changing your code
- work across multiple agents and want shared memory
- need reversible compression — originals always retrievable via CCR
- need reversible compression — originals are retrievable via CCR within the configured TTL
**Skip it if you…**
- only use a single provider's native compaction and don't need cross-agent memory
@ -171,11 +365,11 @@ Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom m
<summary><b>What's inside</b></summary>
- **SmartCrusher** — universal JSON: arrays of dicts, nested objects, mixed types.
- **CodeCompressor** — AST-aware for Python, JS, Go, Rust, Java, C++.
- **Kompress-base** — our HuggingFace model, trained on agentic traces.
- **CodeCompressor** — AST-aware for Python, JS/TS, Go, Rust, Java, C/C++, Perl.
- **Kompress-v2-base** — our HuggingFace model, trained on agentic traces.
- **Image compression** — 4090% reduction via trained ML router.
- **CacheAligner** — stabilizes prefixes so Anthropic/OpenAI KV caches actually hit.
- **IntelligentContext** — score-based context fitting with learned importance.
- **CacheAligner** - detects and warns about volatile content that can bust provider KV cache prefixes; never rewrites prompts.
- **Live-zone compression** — compresses only new bytes (fresh tool output, latest turn); frozen prefix stays byte-identical so provider cache is not busted. History is never dropped.
- **CCR** — reversible compression; LLM retrieves originals on demand.
- **Cross-agent memory** — shared store, agent provenance, auto-dedup.
- **SharedContext** — compressed context passing across multi-agent workflows.
@ -190,28 +384,62 @@ Headroom exposes one stable request lifecycle across `compress()`, the SDK, and
`Setup``Pre-Start``Post-Start``Input Received``Input Cached``Input Routed``Input Compressed``Input Remembered``Pre-Send``Post-Send``Response Received`
- **Transforms** do the work: CacheAligner, ContentRouter, SmartCrusher, CodeCompressor, Kompress-base, IntelligentContext / RollingWindow.
- **Transforms** do the work: CacheAligner → ContentRouter → SmartCrusher / CodeCompressor / Kompress-base (live-zone only; IntelligentContext and RollingWindow were retired in PR-B1).
- **Pipeline extensions** observe or customize lifecycle stages via `on_pipeline_event(...)`.
- **Compression hooks** sit alongside the canonical lifecycle as an additional extension seam.
- **Proxy extensions** remain the server/app integration seam for ASGI middleware, routes, and startup policy.
Provider and tool-specific behavior lives under `headroom/providers/` so core orchestration stays focused on lifecycle, sequencing, and policy.
- **CLI/tool slices**: `headroom/providers/claude`, `copilot`, `codex`, `openclaw`
- **CLI/tool slices**: `headroom/providers/claude`, `copilot`, `codex`, `grok`, `openclaw`
- **Provider runtime slices**: `headroom/providers/claude`, `gemini`, plus shared backend/runtime dispatch in `headroom/providers/registry.py`
- **Core files stay orchestration-first**: `wrap.py`, `client.py`, `cli/proxy.py`, and `proxy/server.py` delegate provider-specific env shaping, API target normalization, backend selection, and transport dispatch.
</details>
## Headroom for teams
Headroom OSS is built for **individual developers**: run `headroom proxy` or `headroom wrap` on your laptop and start cutting tokens in minutes — free, local-first, your data never leaves your machine.
Running it across a **whole engineering org** is a different job: a shared, always-on deployment; centralized config and version rollout; org-wide savings dashboards; SSO and access controls; air-gapped / VPC installs; and someone to call when it matters. That's what we help companies with — self-hosted with support, or fully managed.
**If your team is spending real money on LLM tokens** — Claude Code, Codex, Cursor, or agents running in CI — **and you want those savings across everyone, not just one laptop:**
→ Email **[hello@headroomlabs.ai](mailto:hello@headroomlabs.ai)** with your stack and rough monthly LLM spend, and we'll help you roll Headroom out across your organization.
Everything in this repo stays open source (Apache 2.0). The managed offering is simply for teams that would rather have it deployed, supported, and scaled for them.
## Install
```bash
pip install "headroom-ai[all]" # Python, everything
npm install headroom-ai # TypeScript / Node
uv tool install --python 3.13 "headroom-ai[all]" # CLI, isolated app env
pip install "headroom-ai[all]" # Python, everything — includes the `headroom` CLI
npm install headroom-ai # TypeScript SDK (library only — no `headroom` CLI)
docker pull ghcr.io/chopratejas/headroom:latest
```
Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-base), `[agno]`, `[langchain]`, `[evals]`. Requires **Python 3.10+**.
Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-v2-base), `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
> **Note**: `[all]` covers the core stack but excludes framework adapters. Install them separately: `pip install "headroom-ai[langchain]"` (also `[agno]`, `[strands]`, `[anyllm]`, `[bedrock]`).
Using `uv` for the `headroom` CLI? Prefer `uv tool install` so the command lives in an isolated app environment. On macOS, pass `--python 3.13` if your default `python3` is newer than the current wheel set:
```bash
brew install python@3.13 # if Python 3.13 is not already available
uv tool install --python 3.13 "headroom-ai[all]"
uv tool update-shell # if ~/.local/bin is not already on PATH
headroom --version
```
For MCP clients such as Codex that do not inherit your interactive shell `PATH`, configure the absolute executable path returned by `command -v headroom`:
```toml
[mcp_servers.headroom]
command = "/Users/you/.local/bin/headroom"
args = ["mcp", "serve"]
```
Current native wheels cover macOS Apple Silicon and Linux. On Intel macOS, use Docker-native install until native wheel support lands.
Using `pipx`? Choose a supported interpreter explicitly:
@ -219,15 +447,121 @@ Using `pipx`? Choose a supported interpreter explicitly:
pipx install --python python3.13 "headroom-ai[all]"
```
> **Pick 3.13 if you want dollar savings.** The dashboard's *Proxy $ Saved* tile prices compression with [LiteLLM](https://github.com/BerriAI/litellm), and LiteLLM can't be installed on Python 3.14+. On 3.14 token savings still track, but the dollar figure stays `$0.00`. If you already installed on 3.14, switch with `pipx reinstall headroom-ai --python python3.13` and restart the proxy.
→ [Installation guide](https://headroom-docs.vercel.app/docs/installation) — Docker tags, persistent service, PowerShell, devcontainers.
> **CPU requirement (x86/x86_64):** the ONNX-backed features — Magika content
> detection and embedding relevance — use a precompiled ONNX Runtime that needs
> **AVX2**. On x86 hosts without AVX2 (some Docker/QEMU setups and older cloud
> VMs) Headroom automatically falls back to its non-ONNX paths (BM25 relevance,
> heuristic detection) rather than crashing. `arm64`/Apple Silicon needs no AVX2.
### Updating
```bash
headroom update # detects pip / pipx / uv tool and upgrades in place
headroom update --check # report the latest release without upgrading
headroom update --pre # include pre-releases
```
`headroom update` figures out how Headroom was installed (pip/venv, `pip --user`,
pipx, uv tool) and runs the matching upgrade across macOS, Linux, and Windows.
For git checkouts, editable installs, Docker images, and externally-managed
system Pythons (PEP 668) it prints the correct manual step instead of guessing.
The proxy also shows a one-line "update available" notice on startup. It checks
PyPI at most once a day, in the background, and never blocks. Opt out with
`HEADROOM_UPDATE_CHECK=off` (also skipped in `--stateless` mode and CI).
### Corporate / SSL-inspection environments
If `pip install "headroom-ai[all]"` fails with `CERTIFICATE_VERIFY_FAILED`
(`unable to get local issuer certificate`), your network uses **SSL inspection** — a MITM
proxy presenting a company-issued CA. The build backend (`maturin`) downloads `rustup` over a
connection your TLS stack doesn't trust. **Install Rust first** so the build doesn't fetch it:
```bash
# macOS / Linux
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh && rustup default stable
# Windows
winget install Rustlang.Rustup && rustup default stable
```
Restart your shell, then `pip install "headroom-ai[all]"`. A prebuilt wheel avoids the Rust
build entirely where available: `pip install --only-binary headroom-ai headroom-ai`. Prebuilt
wheels are published for Windows (`win_amd64`), Linux (`x86_64` / `aarch64`), and macOS
(Apple Silicon and Intel), so installs on those platforms never need a local Rust toolchain — the
Rust-first dance above is only for the platform-independent sdist fallback when no wheel matches.
Two runtime assets are fetched over TLS; if they are blocked, trust your corporate CA via
`REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE`:
- **`cdn.pyke.io`** — the ONNX Runtime for the Rust core. Alternatively pre-provide it with
`ORT_STRATEGY=system` and `ORT_LIB_LOCATION=/path/to/onnxruntime`.
- **`huggingface.co`** — the `kompress-base` compression model. Pre-download it and run with
`HF_HUB_OFFLINE=1`, or set `HF_ENDPOINT` to a trusted mirror.
Running with compression disabled (pure gateway) requires neither asset.
#### Intel macOS (x86_64-apple-darwin): no prebuilt ONNX Runtime binary (#941)
`ort-sys` ships no prebuilt ONNX Runtime binary for Intel macOS, so a source
build fails by default even outside a corporate-proxy environment. The same
`ORT_STRATEGY=system` mechanism above fixes it — point it at a system ONNX
Runtime instead:
```bash
brew install onnxruntime
ORT_STRATEGY=system \
ORT_LIB_LOCATION="$(brew --prefix onnxruntime)/lib" \
ORT_PREFER_DYNAMIC_LINK=1 \
pip install "headroom-ai[all]"
# ORT is dlopen'd at runtime too:
export ORT_DYLIB_PATH="$(brew --prefix onnxruntime)/lib/libonnxruntime.dylib"
```
`ORT_LIB_LOCATION` must point at `lib/` (not the bare prefix) and
`ORT_PREFER_DYNAMIC_LINK=1` is required, or `ORT_STRATEGY=system` still
attempts static linking, which the Homebrew keg doesn't provide.
#### "Basic Constraints of CA cert not marked critical" (Python 3.13+ strict mode)
A **different** failure from the one above. If TLS fails with:
```
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
Basic Constraints of CA cert not marked critical
```
then the corporate CA *is* found and trusted — adding it to a CA bundle changes nothing.
Python 3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which enforces RFC 5280
§4.2.1.9: a CA cert's `basicConstraints` must be marked *critical*. Inspection roots like
Zscaler set `CA:TRUE` without the critical bit, so the chain is rejected.
Set **`HEADROOM_TLS_STRICT=0`** to clear *only* the strict flag from every TLS context
Headroom controls — the proxy's httpx upstream client **and** the urllib3/`huggingface_hub`
path used for model downloads. Chain validation, signature, expiry, and hostname checks all
stay on; this is strictly narrower than disabling verification.
```bash
HEADROOM_TLS_STRICT=0 headroom proxy --port 8787
```
The Rust core's ONNX download (`cdn.pyke.io`) uses a separate TLS stack (rustls / OS trust
store), unaffected by `HEADROOM_TLS_STRICT`. On Windows the corporate root must be in the
**machine** certificate store (browsers already trust it there); or pre-provision ONNX
Runtime with `ORT_STRATEGY=system` + `ORT_LIB_LOCATION=/path/to/onnxruntime` to skip the
download entirely.
## headroom learn
<p align="center">
<img src="headroom_learn.gif" alt="headroom learn in action" width="720">
</p>
`headroom learn` — mines failed sessions, writes corrections to `CLAUDE.md` / `AGENTS.md` / `GEMINI.md`.
`headroom learn` — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored; use `--target CLAUDE.md` for the shared team file) / `AGENTS.md` / `GEMINI.md`.
## Documentation
@ -239,6 +573,7 @@ pipx install --python python3.13 "headroom-ai[all]"
| [Memory](https://headroom-docs.vercel.app/docs/memory) | [Cache optimization](https://headroom-docs.vercel.app/docs/cache-optimization) |
| [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning) | [Benchmarks](https://headroom-docs.vercel.app/docs/benchmarks) |
| [Configuration](https://headroom-docs.vercel.app/docs/configuration) | [Limitations](https://headroom-docs.vercel.app/docs/limitations) |
| [Persistent installs](https://headroom-docs.vercel.app/docs/persistent-installs) (`headroom init` / `headroom install apply`) | [Savings analytics](https://headroom-docs.vercel.app/docs/savings) (`headroom savings` / `headroom perf` / `headroom doctor`) |
## Compared to
@ -247,27 +582,28 @@ Headroom runs **locally**, covers **every** content type, works with every major
| | Scope | Deploy | Local | Reversible |
|------------------------------------------------------------------------------|------------------------------------------------|------------------------------------|:-----:|:----------:|
| **Headroom** | All context — tools, RAG, logs, files, history | Proxy · library · middleware · MCP | Yes | Yes |
| [RTK](https://github.com/rtk-ai/rtk) | CLI command outputs | CLI wrapper | Yes | No |
| [lean-ctx](https://github.com/yvgude/lean-ctx) | CLI commands, MCP tools, editor rules | CLI wrapper · MCP | Yes | No |
| [Compresr](https://compresr.ai), [Token Co.](https://thetokencompany.ai) | Text sent to their API | Hosted API call | No | No |
| OpenAI Compaction | Conversation history | Provider-native | No | No |
> **Attribution.** Headroom ships with the excellent [RTK](https://github.com/rtk-ai/rtk) binary for shell-output rewriting — `git show --short`, scoped `ls`, summarized installers. Huge thanks to the RTK team; their tool is a first-class part of our stack, and Headroom compresses everything downstream of it. Headroom can also use [lean-ctx](https://github.com/yvgude/lean-ctx) as the selected CLI context tool; set `HEADROOM_CONTEXT_TOOL=lean-ctx` before running `headroom wrap ...`.
> **Stack & integrations.** Headroom is the **proxy** — that's what we build and offer, and it compresses everything flowing through it no matter what sits upstream. Our recommended companion is **[Serena](https://github.com/oraios/serena)** (installed by default when you wrap an agent) for semantic code navigation — plus **Ponytail** if you want leaner model output. Everything else is your call: you're free to attach your own tooling — code-memory MCP, Graphify, Caveman, or any MCP server — and Headroom compresses downstream of all of it.
## Contributing
```bash
git clone https://github.com/chopratejas/headroom.git && cd headroom
pip install -e ".[dev]" && pytest
uv sync --extra dev && uv run pytest
```
Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j). See [CONTRIBUTING.md](CONTRIBUTING.md).
## Community
- **[Live leaderboard](https://headroomlabs.ai/dashboard)** — 60B+ tokens saved and counting.
- **[Discord](https://discord.gg/yRmaUNpsPJ)** — questions, feedback, war stories.
- **[Kompress-base on HuggingFace](https://huggingface.co/chopratejas/kompress-base)** — the model behind our text compression.
- **[Kompress-v2-base on HuggingFace](https://huggingface.co/chopratejas/kompress-v2-base)** — the model behind our text compression.
### Community projects
- **[Claude Code status-line indicator](https://github.com/Ship-Wright/headroom-plugin)** — a Claude Code plugin that shows live Headroom usage in your status line: idle until `headroom_compress` fires, then the running total of tokens saved.
## License

View file

@ -1,5 +1,14 @@
# Phase G — RTK Breadth + Observability
> **SUPERSEDED.** RTK and lean-ctx were removed from Headroom entirely: the
> `headroom/rtk/` and `headroom/lean_ctx/` packages, all `--rtk` / `--context-tool`
> flags, the wrap-side hooks and hint-file injection, and the proxy-side `rtk gain`
> polling are all gone, and `headroom/context_tool_cleanup.py` uninstalls what
> earlier versions left on disk. The RTK-specific plan below is historical; the
> non-RTK observability items (cache-hit rate, compression ratio, token
> validation) were kept. `docs/rtk-architecture.md`, referenced throughout this
> document, was deleted with the feature.
**Goal:** Extend RTK coverage to more wrap-CLI agents; close the dead `tokens_saved_rtk` data plane; add per-invocation RTK metrics; add the cache-hit-rate, compression-ratio, token-validation observability surface that's missing today.
**Calendar:** 1 week.

View file

@ -92,7 +92,11 @@ No re-scoping needed; revisit after Phase D lands.
## Q9. RTK proxy-side invocation — ever revisit?
**Recommendation:** **No, document the decision in `docs/rtk-architecture.md`** (Phase G PR-G3). The argument:
**Resolved — moot.** RTK was removed from Headroom outright (see
`09-phase-G-rtk-observability.md`), so there is no proxy-side invocation to
revisit. The original recommendation was "no, document the decision in
`docs/rtk-architecture.md`" (that doc was deleted with the feature). The argument
is kept because reasons 13 apply to any future shell-output rewriter:
1. Cache hot zone risk: shell-out + buffer per tool result is correctness-fragile.
2. Parallel implementation: `crates/headroom-core/src/transforms/log_compressor.rs` covers post-hoc log/output compression; RTK rewrites *commands* (different value).
3. RTK itself is a third-party binary the team doesn't control; an upstream version change silently busts cache.

View file

@ -67,7 +67,7 @@ curl -s http://127.0.0.1:8787/healthz/upstream # => 200 if upstream reachable
```bash
# 1. Move the Python proxy to a private port (e.g. 8788)
HEADROOM_BIND=127.0.0.1:8788 python -m headroom.proxy & # or your existing launcher
HEADROOM_HOST=127.0.0.1 HEADROOM_PORT=8788 python -m headroom.proxy & # or your existing launcher
# 2. Run the Rust proxy on the previously-public port (8787) pointing at it
./target/release/headroom-proxy --listen 0.0.0.0:8787 --upstream http://127.0.0.1:8788 &
@ -288,9 +288,8 @@ doesn't rediscover them.
## Multi-worker deployment — CCR fragmentation
**Status:** PR-B7 (`REALIGNMENT/04-phase-B-live-zone.md`) introduced two
persistent CCR backends. The single-`--workers` recommendation no longer
applies once you select a persistent backend.
**Status:** two persistent CCR backends are available. The single-`--workers`
recommendation no longer applies once you select a persistent backend.
### Backend selection
@ -329,29 +328,33 @@ in-memory.
### What goes wrong with the in-memory backend on `--workers N > 1`
(Historical context — applies only when the operator explicitly
chooses `CcrBackendConfig::InMemory`.) Each uvicorn worker is a
separate Python process. Each process holds its own copies of:
Each uvicorn worker is a separate Python process. The following state is
fragmented across workers:
1. **`InMemoryCcrStore`** — sharded `DashMap` mapping
`hash → original_content` for content the compressor replaced with
`<<ccr:HASH>>` markers.
2. **`HeadroomProxy._compression_caches`** (`headroom/proxy/server.py:367`)
— per-session `CompressionCache` dict.
1. **Python `CompressionStore`** — defaults to `InMemoryBackend` (per-process)
when `HEADROOM_CCR_BACKEND` is unset. Each worker has its own singleton; CCR
markers written on worker A are invisible to worker B. Set
`HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store.
2. **`HeadroomProxy._compression_caches`** (`headroom/proxy/server.py`)
— per-session `CompressionCache` dict (instance var, always per-worker).
3. **`HeadroomProxy.session_tracker_store`** — per-session prefix-tracker
state derived from Anthropic's `cache_read_input_tokens` responses.
4. **TOIN learner state** — pattern statistics used to bias the compressor.
state derived from Anthropic's `cache_read_input_tokens` responses
(instance var, always per-worker).
4. **TOIN learner state** — writes snapshots to `~/.headroom/toin.json` but
keeps per-process in-memory state; pattern statistics on one worker are not
visible to others until the next disk flush.
When uvicorn round-robins requests across workers, a session whose
turn-1 landed on worker A may have turn-2 land on worker B. Worker B has
zero knowledge of what worker A did, the `<<ccr:HASH>>` marker resolves
to `None`, and the model sees an opaque directive it can't act on.
Switching to `SqliteCcrStore` (default) or `RedisCcrStore` resolves the
fragmentation directly.
CCR fragmentation; a sticky-session load balancer resolves all of them.
### Detecting it in the wild
The proxy emits a `WARNING`-level log line on startup if the configured
backend is `InMemoryCcrStore` AND `WEB_CONCURRENCY` / uvicorn
`--workers` is > 1, pointing operators at this section. The other two
backends never warn — they're the supported multi-worker paths.
The proxy emits a `WARNING`-level log line on startup when `--workers N > 1`.
When `HEADROOM_CCR_BACKEND` is unset (default InMemoryBackend), the warning
includes CCR retrieval failures and suggests setting `HEADROOM_CCR_BACKEND=sqlite`.
When a cross-worker backend is already configured, the warning covers only the
remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker).

View file

@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 0.2.x | :white_check_mark: |
| 0.1.x | :x: |
| 0.27.x (latest) | :white_check_mark: |
| < 0.27.x | :x: |
## Reporting a Vulnerability
@ -15,7 +15,7 @@ We take security vulnerabilities seriously. If you discover a security issue, pl
**Please DO NOT open a public GitHub issue for security vulnerabilities.**
Instead, please email us at: **security@headroom.dev**
Instead, please email us at: **security@headroomlabs.ai**
Include the following information:
- Type of vulnerability (e.g., injection, data exposure, authentication bypass)
@ -44,9 +44,9 @@ When using Headroom:
### Scope
The following are in scope for security reports:
- Headroom Python package (`pip install headroom`)
- Headroom Python package (`pip install headroom-ai`)
- Headroom proxy server
- Official integrations (LangChain, MCP)
- Official integrations (LangChain, Agno, Strands, LiteLLM, Vercel AI SDK, Anthropic/OpenAI SDK wrappers, MCP)
The following are out of scope:
- Third-party integrations not maintained by us

View file

@ -0,0 +1,153 @@
# Testing: GitHub Copilot subscription mode (`headroom wrap copilot --subscription`)
This is an **experimental** feature and we need help verifying it on **Linux and
Windows**. It already works on macOS; the cross-platform gap is small and
specific (see [Status](#status)). If you have a GitHub Copilot subscription and
10 minutes, please run one of the flows below and
[file a report](https://github.com/chopratejas/headroom/issues/new?template=copilot-subscription-test-report.md).
> ⚠️ This is experimental, and it reads your Copilot login token + routes your
> Copilot CLI traffic through a local Headroom proxy. Only run it if you're
> comfortable with that. The branch is open for inspection.
## What it does (and what "subscription" means here)
Normally `headroom wrap copilot` is **BYOK** — you bring an Anthropic/OpenAI API
key and pay that vendor. `--subscription` is different: it lets you use the
**Copilot seat you already pay GitHub for**, with **no separate API key**, while
still routing through Headroom so your context gets compressed.
Mechanically: the Copilot CLI's only interposition hook is its provider-override
(the "BYOK transport"), so Headroom uses that knob but supplies **your
subscription token** and points back at **GitHub's own Copilot API**. So the CLI
may print "BYOK" and require an explicit `--model`, but you are **not** paying a
third party — it's your subscription, just compressed. (Proof it's working: the
proxy forwards to GitHub's Copilot API — `https://api.githubcopilot.com` by
default — with your token.)
## API host & Enterprise / data-residency
Headroom routes wrapped Copilot traffic to GitHub's **generic public host**,
`https://api.githubcopilot.com`, for both `--subscription` and the implicit
OAuth path. That host serves the full model set (including newer models on the
responses API) and matches the routing that worked before 0.23.
Headroom deliberately does **not** auto-select a per-account host from
`/copilot_internal/user`. That endpoint advertises a segmented host (e.g.
`api.individual.githubcopilot.com`) that does **not** serve newer models on the
responses API and is not the host the official Copilot client routes with — using
it regressed `headroom wrap copilot` after 0.22.4
([#610](https://github.com/chopratejas/headroom/issues/610)).
**Enterprise / data-residency:** if your organization is provisioned on a
dedicated Copilot API host (GitHub Enterprise Cloud with data residency, or an
egress proxy), pin it explicitly — the override flows through both
`--subscription` and OAuth, and onward through the proxy to the upstream request:
```bash
export GITHUB_COPILOT_API_URL=https://api.<your-host>.githubcopilot.com
headroom wrap copilot --subscription -- --model gpt-5.4
```
If you operate such an environment and would like Headroom to **auto-detect** the
correct host instead of pinning it, please [open an issue](https://github.com/chopratejas/headroom/issues/new) —
the intended path is to resolve it from GitHub's token-exchange endpoint (the
source the official Copilot client uses), and we'd want to validate it against a
real enterprise tenant.
## Status
| Platform | Mechanism (compress + forward) | Token **auto-discovery** from the OS secret store |
|----------|:---:|:---:|
| macOS (Keychain) | ✅ verified | ✅ verified (`copilot-cli`) |
| Linux (`secret-tool`/libsecret) | ✅ expected | ❓ **needs testing** |
| Windows (Credential Manager) | ✅ expected | ❓ **needs testing** |
| Any OS via `GITHUB_COPILOT_TOKEN` env var | ✅ verified by tests | n/a (bypasses discovery) |
The two things we want to learn:
1. **Does it work end to end on your OS?**
2. **Does it find your Copilot token automatically**, or do you have to set
`GITHUB_COPILOT_TOKEN`? If it can't find it, we need the **storage schema**
(see each flow) so we can fix auto-discovery.
## Prerequisites (all platforms)
1. A **GitHub Copilot subscription**.
2. The **GitHub Copilot CLI**: `npm install -g @github/copilot`
3. **Log in once**: run `copilot`, complete the device-code login in your
browser, then type `/exit`.
---
## Linux — the flow we most need (tests auto-discovery)
Auto-discovery only works with a **host-native** install (a container can't read
your host secret store). Linux has prebuilt wheels, so:
```bash
pipx install --pip-args='--pre' headroom-ai # or: pip install --pre headroom-ai
# (no separate API key needed — that's the point)
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
```
- **If it prints `HEADROOM_OK`** → auto-discovery works on your Linux. 🎉 Report success.
- **If it errors with "no reusable bearer token"** → discovery missed your token. Please grab the **schema** so we can fix it (redact the secret), then confirm the mechanism works via the env var:
```bash
secret-tool search --all 2>/dev/null | sed -E 's/^secret = .*/secret = <redacted>/'
# then retry, supplying the token explicitly:
GITHUB_COPILOT_TOKEN='<your-token>' headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"
```
Report the `attribute.*` lines from `secret-tool` and whether the env-var retry worked.
---
## Windows
There is **no native Windows wheel yet**, so pick one:
**A. Mechanism test (easiest — Docker Desktop or WSL2):**
```powershell
$env:HEADROOM_DOCKER_IMAGE = "ghcr.io/chopratejas/headroom:<branch-tag>" # ask the maintainer for the tag
# run the Docker-native installer (scripts/install.ps1), then:
$env:GITHUB_COPILOT_TOKEN = "<your-token>"
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"
```
Report whether it prints `HEADROOM_OK`.
**B. Native auto-discovery schema (even without a working install):** after
`copilot` login, tell us where Windows stored the token:
```cmd
cmd /c "cmdkey /list"
```
Report the `Target:` line that looks Copilot-related (it shows the target name,
not the secret). That single fact lets us make native Windows discovery work.
> Native Windows auto-discovery becomes fully testable once we add a Windows
> wheel to the build matrix — tracked separately.
---
## macOS (already proven — a second data point still helps)
```bash
pipx install --pip-args='--pre' headroom-ai
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
```
Schema, for reference: Keychain generic password, service `copilot-cli`
(`security find-generic-password -s copilot-cli -w`).
---
## What to report
Please open a
[Copilot subscription test report](https://github.com/chopratejas/headroom/issues/new?template=copilot-subscription-test-report.md)
with:
- **OS + version** and **how you installed** (pipx/pip wheel, Docker, source).
- Was plain `copilot` logged in?
- Did `wrap copilot --subscription` print **`HEADROOM_OK`**? Paste any error.
- Did it work **without** setting `GITHUB_COPILOT_TOKEN` (auto-discovery), or
only **with** it?
- The **storage schema** if discovery failed (`secret-tool search --all` /
`cmdkey /list`), with the secret redacted.

View file

@ -16,7 +16,6 @@ Usage:
Performance Targets:
- SmartCrusher: < 10ms for 1000 items
- CacheAligner: < 1ms for date extraction
- RollingWindow: < 5ms for 200 turns
- BM25Scorer: < 1ms for 100 items
- HybridScorer: < 50ms for 100 items (with embeddings)
"""

View file

@ -252,13 +252,14 @@ def test_needle_looks_exactly_like_hay() -> AdversarialResult:
tool_name="user_search",
)
# Try to find user 456 via search
search_results = store.search(hash_key, "user_id 456")
# Recover the target user via CCR retrieval (hash-only → full content)
entry_for_search = store.retrieve(hash_key)
search_results = json.loads(entry_for_search.original_content) if entry_for_search else []
found_target = any(item.get("user_id") == target_id for item in search_results)
if found_target:
result.actual_behavior = "Found target user via CCR search"
result.actual_behavior = "Found target user via CCR retrieval"
result.passed = True
else:
# Try full retrieval as fallback
@ -718,12 +719,16 @@ def test_extremely_long_strings() -> AdversarialResult:
def test_query_injection_in_search() -> AdversarialResult:
"""
ATTACK: Malicious search query.
ATTACK: Malicious input on the retrieval surface.
Retrieval is hash-only (no query/search parameter), so the only
attacker-controlled input is the hash. A malicious string must never
crash the store or return another entry's data — it must be a clean miss.
"""
result = AdversarialResult(
name="Search Query Injection",
name="Retrieval Hash Injection",
category="injection",
expected_behavior="Should sanitize search queries",
expected_behavior="Malicious hash input is a safe cache miss, never a crash",
severity="high",
)
@ -732,35 +737,36 @@ def test_query_injection_in_search() -> AdversarialResult:
items = [{"id": i, "data": f"item {i}"} for i in range(100)]
hash_key = store.store(
store.store(
original=json.dumps(items),
compressed=json.dumps(items[:10]),
original_item_count=100,
compressed_item_count=10,
)
# Various injection attempts
malicious_queries = [
# Various injection attempts, now aimed at the hash (the only input)
malicious_hashes = [
"'; DROP TABLE items; --",
"<script>alert('xss')</script>",
"{{7*7}}", # Template injection
"${7*7}", # Expression injection
"\\x00\\x01\\x02", # Null bytes
"*" * 10000, # Long query
"*" * 10000, # Long input
".*", # Regex wildcard
"(a]", # Invalid regex
]
failures = []
for query in malicious_queries:
for bad_hash in malicious_hashes:
try:
store.search(hash_key, query)
# If it returns without error, it handled the injection
entry = store.retrieve(bad_hash)
if entry is not None:
failures.append(f"{bad_hash[:20]}: unexpected hit")
except Exception as e:
failures.append(f"{query[:20]}: {type(e).__name__}")
failures.append(f"{bad_hash[:20]}: {type(e).__name__}")
if not failures:
result.actual_behavior = "All malicious queries handled safely"
result.actual_behavior = "All malicious hashes handled safely (clean miss)"
result.passed = True
else:
result.actual_behavior = f"Failures: {failures}"
@ -1261,7 +1267,7 @@ def test_catastrophic_regex_in_search() -> AdversarialResult:
result = AdversarialResult(
name="Regex Catastrophic Backtracking",
category="extreme",
expected_behavior="Should not hang on malicious search patterns",
expected_behavior="Should not hang on malicious hash input",
severity="critical",
)
@ -1270,7 +1276,7 @@ def test_catastrophic_regex_in_search() -> AdversarialResult:
items = [{"id": i, "content": "a" * 50 + "b"} for i in range(100)]
hash_key = store.store(
store.store(
original=json.dumps(items),
compressed=json.dumps(items[:10]),
original_item_count=100,
@ -1278,7 +1284,9 @@ def test_catastrophic_regex_in_search() -> AdversarialResult:
tool_name="regex_test",
)
# These patterns could cause catastrophic backtracking in naive regex
# Retrieval is hash-only, so the only attacker input is the hash. These
# patterns could cause catastrophic backtracking in a naive matcher;
# the hash lookup must not hang on any of them.
evil_patterns = [
"(a+)+$",
"(a|aa)+$",
@ -1290,23 +1298,23 @@ def test_catastrophic_regex_in_search() -> AdversarialResult:
import signal
def timeout_handler(signum, frame):
raise TimeoutError("Search took too long")
raise TimeoutError("Retrieval took too long")
# Set 2 second timeout
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(2)
for pattern in evil_patterns:
# BM25 search doesn't use regex, so should be safe
store.search(hash_key, pattern)
# Hash lookup is a plain dict/store get — no regex, so it is safe
store.retrieve(pattern)
signal.alarm(0)
signal.signal(signal.SIGALRM, old_handler)
result.actual_behavior = "Search completed without hanging"
result.actual_behavior = "Retrieval completed without hanging"
result.passed = True
except TimeoutError:
result.actual_behavior = "Search hung on regex-like pattern"
result.actual_behavior = "Retrieval hung on regex-like input"
result.passed = False
except Exception as e:
result.actual_behavior = f"Error: {type(e).__name__}: {e}"
@ -1533,7 +1541,6 @@ def test_concurrent_reset_during_operation() -> AdversarialResult:
tool_name="reset_test",
)
store.retrieve(hash_key)
store.search(hash_key, "test")
operations_completed[0] += 1
except Exception as e:
errors.append(f"Op error: {type(e).__name__}: {e}")

View file

@ -586,7 +586,7 @@ def generate_scenarios(content_types: list[str] | None = None) -> list[Scenario]
msgs = generate_agentic_conversation(
turns=turns, tool_calls_per_turn=2, items_per_tool_response=items
)
# Set a model_limit that forces IntelligentContext to kick in
# Set a model_limit large enough to exercise compression on big agentic contexts
limit = max(50_000, turns * 2_000)
scenarios.append(
Scenario(

View file

@ -3,7 +3,6 @@
This module contains performance benchmarks for Headroom transforms:
- SmartCrusher: Statistical tool output compression
- CacheAligner: Cache-aligned prefix optimization
- RollingWindow: Token budget management
Performance Targets:
SmartCrusher:
@ -15,10 +14,6 @@ Performance Targets:
- Date extraction: < 1ms
- Hash computation: < 0.5ms
RollingWindow:
- 50 turns: < 5ms
- 200 turns: < 20ms
Run with:
pytest benchmarks/bench_transforms.py --benchmark-only -v
"""
@ -374,7 +369,7 @@ class TestTransformPipelineBenchmarks:
"""Benchmarks for full transform pipeline.
Tests the complete flow:
CacheAligner -> SmartCrusher -> RollingWindow
CacheAligner -> SmartCrusher
Expected performance:
- Simple conversation: < 5ms

View file

@ -11,12 +11,10 @@ does not cause any regression in agent behavior. Specifically:
- Anomalies and outliers
2. RETRIEVAL ACCURACY: When retrieval is needed, correct items are returned
- Full retrieval returns original content
- Search retrieval finds relevant items
- Retrieval is by hash and always returns the full original content
3. FEEDBACK LEARNING: System learns from retrieval patterns
- High retrieval rate triggers less aggressive compression
- Common queries improve future compression
Usage:
python benchmarks/ccr_regression_benchmark.py
@ -73,6 +71,23 @@ class RegressionResult:
failures: list[str] = field(default_factory=list)
def _ccr_retrieve_items(store: Any, hash_key: str) -> list[dict[str, Any]]:
"""Full CCR retrieval (hash-only) → parsed original items.
Retrieval is by hash and always returns the complete original content,
so any "needle" present at compression time is guaranteed to survive the
round-trip. Returns the parsed list, or [] on a miss / non-list payload.
"""
entry = store.retrieve(hash_key)
if not entry:
return []
try:
data = json.loads(entry.original_content)
except (json.JSONDecodeError, TypeError):
return []
return data if isinstance(data, list) else []
# =============================================================================
# TEST 1: Needle in Haystack - Error Retention
# =============================================================================
@ -204,7 +219,7 @@ def test_uuid_retrieval() -> RegressionResult:
)
# Search for the specific UUID
search_results = store.search(hash_key, target_uuid)
search_results = _ccr_retrieve_items(store, hash_key)
result.latency_ms = (time.perf_counter() - start) * 1000
# Check if target UUID was found
@ -476,10 +491,12 @@ def test_feedback_learning() -> RegressionResult:
def test_search_accuracy() -> RegressionResult:
"""
Test that BM25 search within cached content finds relevant items.
Test that hash-keyed retrieval returns the full original content (the
needle is always present in the losslessly-retrieved superset).
"""
result = RegressionResult(
name="Search Accuracy", description="Verify BM25 search finds relevant items in cache"
name="Retrieval Accuracy",
description="Verify hash retrieval returns the full original content from cache",
)
reset_compression_store()
@ -535,7 +552,7 @@ def test_search_accuracy() -> RegressionResult:
start = time.perf_counter()
# Search for authentication errors
search_results = store.search(hash_key, "authentication failed token")
search_results = _ccr_retrieve_items(store, hash_key)
result.latency_ms = (time.perf_counter() - start) * 1000
@ -640,8 +657,8 @@ def test_ccr_end_to_end() -> RegressionResult:
feedback.record_compression("alert_search", 500, 20)
# Step 4: Retrieve and search
critical_results = store.search(hash_key, "critical system overload P0")
error_results = store.search(hash_key, "Error position P1")
critical_results = _ccr_retrieve_items(store, hash_key)
error_results = _ccr_retrieve_items(store, hash_key)
# Step 5: Process feedback
store.process_pending_feedback()

View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""i18n compression-quality eval (zh/ja/ko): does extractive compression keep
the answer-bearing content in CJK? No LLM/API calls -- fully local.
Part C -- our own DETERMINISTIC needle answer-retention (zh/ja/ko): the always-
runs regression gate. A distinctive needle sentence is buried (in the middle) in
language-matched distractor sentences; compress query-aware; assert the needle
survives. No external data. TextCrusher (query-aware) vs truncate (keep-recent)
vs random baselines.
Part B -- real-transcript fidelity with CJK-aware salient: optional, anonymized.
Part A -- natural-data answer-retention on alexandrainst/multi-wiki-qa
(zh-cn/ja/ko): optional, via the [evals] datasets extra, skipped if absent.
Usage: python benchmarks/i18n_compression_eval.py [transcript.jsonl]
"""
from __future__ import annotations
import glob
import os
import random
import re
import sys
import time
from headroom.transforms.text_crusher import TextCrusher
_REDACT = [
(re.compile(r"/Users/[^/\s]+"), "/Users/USER"),
(re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"), "EMAIL"),
(re.compile(r"\b(?:sk|pk|ghp|gho|xox[baprs])-[A-Za-z0-9_-]{10,}\b"), "TOKEN"),
(re.compile(r"\b[A-Fa-f0-9]{40,}\b"), "HEX"),
]
# Split on ASCII and full-width CJK terminators so baselines segment CJK too.
_SEG = re.compile(r"(?<=[.!?。!?])\s*|\n+")
_CJK_RUN = re.compile(r"[㐀-鿿぀-ヿ가-힯]+")
def anon(t: str) -> str:
for rx, rep in _REDACT:
t = rx.sub(rep, t)
return t
def norm(s: str) -> str:
# CJK has no spaces; drop all whitespace so substring match is robust.
return re.sub(r"\s+", "", s.lower())
def _segs(text: str) -> list[str]:
return [s for s in _SEG.split(text) if s.strip()]
def truncate_keep_last(text: str, ratio: float) -> str:
segs = _segs(text)
budget = int(sum(len(s) for s in segs) * ratio)
kept: list[str] = []
c = 0
for s in reversed(segs):
if c >= budget:
break
kept.append(s)
c += len(s)
return "".join(reversed(kept))
def random_keep(text: str, ratio: float, seed: int) -> str:
segs = _segs(text)
idx = list(range(len(segs)))
random.Random(seed).shuffle(idx)
budget = int(sum(len(s) for s in segs) * ratio)
kept: set[int] = set()
c = 0
for i in idx:
if c >= budget:
break
kept.add(i)
c += len(segs[i])
return "".join(segs[i] for i in sorted(kept))
# --- Part C: deterministic needle retention (zh / ja / ko) ---------------------
# Each needle carries a distinctive verbatim KEY that must survive. Distractors
# are generated (deterministic, distinct, topic-unrelated to the query) so the
# haystack is large enough to FORCE real compression -- the needle only survives
# under TextCrusher because it is query-relevant, not because of passthrough.
_NEEDLES = {
"zh": {
"query": "认证令牌缓存淘汰策略",
"key": "最近最少使用淘汰",
"needle": "认证令牌的缓存采用最近最少使用淘汰算法来管理过期条目。",
"distractor": lambda i: f"{i}号监控服务器的日志显示子系统{i}今天运行平稳没有出现异常。",
},
"ja": {
"query": "認証トークン キャッシュ 破棄 アルゴリズム",
"key": "最長未使用",
"needle": "認証トークンのキャッシュは最長未使用アルゴリズムで管理される。",
"distractor": lambda i: (
f"{i}番目の監視サーバーのログには{i}番のサブシステムが本日も正常に稼働したと記録されている。"
),
},
"ko": {
"query": "인증 토큰 캐시 제거 알고리즘",
"key": "최근 최소 사용",
"needle": "인증 토큰 캐시는 최근 최소 사용 알고리즘으로 관리된다.",
"distractor": lambda i: (
f"{i}번 모니터링 서버의 로그에는 {i}번 하위 시스템이 오늘도 정상 작동했다고 기록되어 있다."
),
},
}
def _haystack(spec: dict, n_distract: int = 24) -> str:
half = n_distract // 2
before = [spec["distractor"](i) for i in range(half)]
after = [spec["distractor"](i) for i in range(half, n_distract)]
# needle in the MIDDLE so keep-recent (truncate) reliably misses it.
return "".join(before + [spec["needle"]] + after)
def retention_synthetic(lang: str, ratio: float = 0.3, seed: int = 0) -> dict[str, bool]:
spec = _NEEDLES[lang]
hay = _haystack(spec)
key = norm(spec["key"])
tc = TextCrusher()
out_tc = tc.compress(hay, spec["query"], ratio).compressed
return {
"text_crusher": key in norm(out_tc),
"truncate": key in norm(truncate_keep_last(hay, ratio)),
"random": key in norm(random_keep(hay, ratio, seed)),
}
def eval_synthetic(ratio: float = 0.3) -> None:
print(f"\n=== Part C: synthetic needle retention (zh/ja/ko, target_ratio={ratio}) ===")
print(f" {'lang':5} {'text_crusher':>13} {'truncate':>9} {'random':>7}")
for lang in ("zh", "ja", "ko"):
r = retention_synthetic(lang, ratio)
print(
f" {lang:5} {str(r['text_crusher']):>13} {str(r['truncate']):>9} {str(r['random']):>7}"
)
print(" (needle must survive under TextCrusher; baselines are the contrast)")
# --- Part B: real CJK transcript fidelity (CJK-aware salient) ------------------
# ASCII salient (identifiers/numbers/errors) STILL matters in CJK coding context.
_SALIENT_ASCII = re.compile(
r"\b(?:error|exception|fail(?:ed|ure)?|warning|traceback|assert|todo|fixme)\b"
r"|\b[A-Z]{2,}\b|\b[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\b|\b\d+\b"
)
def _cjk_hapax(text: str) -> set[str]:
# distinctive CJK content = char-bigrams occurring exactly once (rare = must-keep)
grams: dict[str, int] = {}
for run in _CJK_RUN.findall(text):
for i in range(len(run) - 1):
g = run[i : i + 2]
grams[g] = grams.get(g, 0) + 1
return {g for g, c in grams.items() if c == 1}
def salient_set(text: str) -> set[str]:
return set(_SALIENT_ASCII.findall(text)) | _cjk_hapax(text)
def _block_texts(jsonl_path: str, min_chars: int, limit: int) -> list[str]:
import json
out: list[str] = []
with open(jsonl_path, encoding="utf-8") as fh:
for line in fh:
try:
o = json.loads(line)
except json.JSONDecodeError:
continue
c = (o.get("message") or {}).get("content")
parts = (
[c]
if isinstance(c, str)
else [
p["text"] for p in c if isinstance(p, dict) and isinstance(p.get("text"), str)
]
if isinstance(c, list)
else []
)
for t in parts:
if len(t) >= min_chars and _CJK_RUN.search(t): # CJK-bearing only
out.append(anon(t))
if len(out) >= limit:
break
return out[:limit]
def eval_transcript(
jsonl_path: str, ratio: float = 0.4, min_chars: int = 600, limit: int = 40
) -> None:
blocks = _block_texts(jsonl_path, min_chars, limit)
if not blocks:
print(
f"\n=== Part B: no CJK blocks >= {min_chars} chars in {os.path.basename(jsonl_path)} ==="
)
return
tc = TextCrusher()
ratios: list[float] = []
times: list[float] = []
retentions: list[float] = []
for b in blocks:
sal_before = salient_set(b)
t0 = time.perf_counter()
out = tc.compress(b, "", ratio).compressed
times.append((time.perf_counter() - t0) * 1000)
retentions.append(len(sal_before & salient_set(out)) / max(1, len(sal_before)))
ratios.append(len(out) / max(1, len(b)))
n = len(blocks)
print(
f"\n=== Part B: real CJK transcript fidelity (n={n}, anonymized, target_ratio={ratio}) ==="
)
print(f" mean char-ratio kept: {sum(ratios) / n:.2f}")
print(f" mean speed: {sum(times) / n:.1f} ms/block")
print(f" CJK-aware salient retention: {sum(retentions) / n:.1%}")
# --- Part A: optional natural-data retention (multi-wiki-qa zh/ja/ko) ----------
# Schema verified: row = {id, title, context, question, answers:{text:[...]}}.
# Answers are guaranteed verbatim substrings of the (long) context; CC-BY-NC-SA.
def eval_multiwiki(
langs=("zh-cn", "ja", "ko"), n: int = 80, ratio: float = 0.3, seed: int = 0
) -> None:
try:
from datasets import load_dataset
except ImportError:
print(
"\n=== Part A: `datasets` not installed; skipping (pip install headroom-ai[evals]) ==="
)
return
tc = TextCrusher()
print(f"\n=== Part A: multi-wiki-qa answer-retention (n={n}/lang, target_ratio={ratio}) ===")
print(f" {'lang':6} {'text_crusher':>13} {'truncate':>9} {'random':>7}")
for lang in langs:
try:
ds = load_dataset("alexandrainst/multi-wiki-qa", lang, split=f"train[:{n * 2}]")
except Exception as e: # noqa: BLE001 -- optional path, fail-open
print(f" {lang}: load failed ({e}); skipping")
continue
ex = []
for r in ds:
ans = r.get("answers")
a = ans["text"][0] if isinstance(ans, dict) and ans.get("text") else None
if r.get("context") and r.get("question") and a:
ex.append((r["context"], r["question"], a))
random.Random(seed).shuffle(ex)
ex = ex[:n]
hit = {"text_crusher": 0, "truncate": 0, "random": 0}
for ctx, q, ans in ex:
a = norm(ans)
hit["text_crusher"] += a in norm(tc.compress(ctx, q, ratio).compressed)
hit["truncate"] += a in norm(truncate_keep_last(ctx, ratio))
hit["random"] += a in norm(random_keep(ctx, ratio, seed))
m = max(1, len(ex))
print(
f" {lang:6} {hit['text_crusher'] / m:>12.0%} {hit['truncate'] / m:>9.0%} {hit['random'] / m:>7.0%}"
)
if __name__ == "__main__":
eval_synthetic()
tx = sys.argv[1] if len(sys.argv) > 1 else None
if tx is None:
found = glob.glob(os.path.expanduser("~/.claude/projects/*headroom*/*.jsonl"))
tx = max(found, key=os.path.getsize) if found else None
if tx and os.path.exists(tx):
eval_transcript(tx)
else:
print("\nno transcript jsonl found; skipping Part B")
eval_multiwiki()

View file

@ -3,8 +3,8 @@
This module provides generators for realistic conversation patterns that
exercise Headroom transforms:
- Agentic conversations: Multi-turn with tool calls (SmartCrusher, RollingWindow)
- RAG conversations: Large context injection (CacheAligner, RollingWindow)
- Agentic conversations: Multi-turn with tool calls (SmartCrusher)
- RAG conversations: Large context injection (CacheAligner)
These generators produce conversations that mirror real-world usage patterns
from production agentic systems.

View file

@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Quality eval for TextCrusher (Phase 2, #1171): does extractive compression
preserve the answer-bearing content? No LLM/API calls -- fully local.
Part A -- SQuAD answer-retention (the strong, labeled metric): bury a real QA
answer in a haystack of distractor paragraphs, compress to a target ratio, and
measure whether the gold answer SURVIVES. TextCrusher (query-aware) vs truncate
(keep-recent) vs random baselines. Mirrors kompress's published
must_keep_recall (0.977 on its own labeled set).
Part B -- real-transcript fidelity: compress large text blocks from a real
Claude Code transcript (ANONYMIZED), measuring ratio, speed, and salient-token
retention (identifiers/numbers/errors -- the must-keep info in coding contexts).
Only aggregate metrics are printed; raw content is never echoed.
Usage: python benchmarks/text_crusher_quality_eval.py [squad_dev.json] [transcript.jsonl]
"""
from __future__ import annotations
import glob
import json
import os
import random
import re
import sys
import time
from headroom.transforms.text_crusher import TextCrusher
_SEG = re.compile(r"(?<=[.!?])\s+|\n+")
_SALIENT = re.compile(
r"\b(?:error|exception|fail(?:ed|ure)?|warning|traceback|assert|todo|fixme)\b"
r"|\b[A-Z]{2,}\b|\b[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\b|\b\d+\b"
)
# --- anonymization (脱敏): scrub before any processing; never echo raw content ---
_REDACT = [
(re.compile(r"/Users/[^/\s]+"), "/Users/USER"),
(re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"), "EMAIL"),
(re.compile(r"\b(?:sk|pk|ghp|gho|xox[baprs])-[A-Za-z0-9_-]{10,}\b"), "TOKEN"),
(re.compile(r"\bBearer\s+[A-Za-z0-9._-]{10,}"), "Bearer TOKEN"),
(re.compile(r"\b[A-Fa-f0-9]{40,}\b"), "HEX"),
]
def anon(t: str) -> str:
for rx, rep in _REDACT:
t = rx.sub(rep, t)
return t
def norm(s: str) -> str:
return re.sub(r"\s+", " ", s.lower()).strip()
def _segs(text: str) -> list[str]:
return [s for s in _SEG.split(text) if s.strip()]
def truncate_keep_last(text: str, ratio: float) -> str:
segs = _segs(text)
budget = int(sum(len(s) for s in segs) * ratio)
kept: list[str] = []
c = 0
for s in reversed(segs):
if c >= budget:
break
kept.append(s)
c += len(s)
return "\n".join(reversed(kept))
def random_keep(text: str, ratio: float, seed: int) -> str:
segs = _segs(text)
idx = list(range(len(segs)))
random.Random(seed).shuffle(idx)
budget = int(sum(len(s) for s in segs) * ratio)
kept: set[int] = set()
c = 0
for i in idx:
if c >= budget:
break
kept.add(i)
c += len(segs[i])
return "\n".join(segs[i] for i in sorted(kept))
def eval_squad(path: str, n: int = 200, n_distract: int = 40, ratio: float = 0.3, seed: int = 0):
data = json.load(open(path))
paras = [(p["context"], p["qas"]) for a in data["data"] for p in a["paragraphs"]]
all_ctx = [c for c, _ in paras]
examples = [
(ctx, qas[0]["question"], qas[0]["answers"][0]["text"])
for ctx, qas in paras
if qas and qas[0]["answers"]
]
rnd = random.Random(seed)
rnd.shuffle(examples)
examples = examples[:n]
tc = TextCrusher()
hit = {"text_crusher": 0, "truncate": 0, "random": 0}
tc_ratios: list[float] = []
for gold_ctx, q, ans in examples:
docs = rnd.sample(all_ctx, n_distract) + [gold_ctx]
rnd.shuffle(docs)
haystack = "\n\n".join(docs)
a = norm(ans)
out_tc = tc.compress(haystack, context=q, target_ratio=ratio).compressed
hit["text_crusher"] += a in norm(out_tc)
hit["truncate"] += a in norm(truncate_keep_last(haystack, ratio))
hit["random"] += a in norm(random_keep(haystack, ratio, seed))
tc_ratios.append(len(out_tc) / max(1, len(haystack)))
nn = len(examples)
print(
f"\n=== Part A: SQuAD answer-retention (n={nn}, distractors={n_distract}, target_ratio={ratio}) ==="
)
print(
f" TextCrusher (query-aware): {hit['text_crusher'] / nn:6.1%} answer survives compression"
)
print(f" Truncate (keep recent): {hit['truncate'] / nn:6.1%}")
print(f" Random keep: {hit['random'] / nn:6.1%}")
print(
f" TextCrusher mean char-ratio: {sum(tc_ratios) / nn:.2f} (kept ~{sum(tc_ratios) / nn:.0%} of bytes)"
)
print(" reference: kompress published must_keep_recall = 0.977 (its own labeled set)")
def _block_texts(jsonl_path: str, min_words: int, limit: int) -> list[str]:
out: list[str] = []
with open(jsonl_path) as fh:
for line in fh:
try:
o = json.loads(line)
except json.JSONDecodeError:
continue
m = o.get("message") or {}
c = m.get("content")
parts = (
[c]
if isinstance(c, str)
else [
p["text"] for p in c if isinstance(p, dict) and isinstance(p.get("text"), str)
]
if isinstance(c, list)
else []
)
for t in parts:
if len(t.split()) >= min_words:
out.append(anon(t))
if len(out) >= limit:
break
return out[:limit]
def eval_transcript(jsonl_path: str, ratio: float = 0.4, min_words: int = 1500, limit: int = 40):
blocks = _block_texts(jsonl_path, min_words, limit)
if not blocks:
print(
f"\n=== Part B: no text blocks >= {min_words} words in {os.path.basename(jsonl_path)} ==="
)
return
tc = TextCrusher()
ratios: list[float] = []
times: list[float] = []
retentions: list[float] = []
for b in blocks:
sal_before = set(_SALIENT.findall(b))
t0 = time.perf_counter()
out = tc.compress(b, target_ratio=ratio).compressed
times.append((time.perf_counter() - t0) * 1000)
sal_after = set(_SALIENT.findall(out))
retentions.append(len(sal_before & sal_after) / max(1, len(sal_before)))
ratios.append(len(out.split()) / max(1, len(b.split())))
n = len(blocks)
print(
f"\n=== Part B: real transcript fidelity (n={n} large blocks, anonymized, target_ratio={ratio}) ==="
)
print(f" mean token-ratio kept: {sum(ratios) / n:.2f}")
print(f" mean speed: {sum(times) / n:.1f} ms/block")
print(
f" salient-token retention: {sum(retentions) / n:6.1%} (identifiers/numbers/errors kept)"
)
print(
f" -> keeps salient info at {sum(retentions) / n:.0%} while dropping to {sum(ratios) / n:.0%} of tokens"
)
def eval_speed(scale_words: int = 250_000):
# Reproducible throughput on a large synthetic prose block (no external data).
text = " ".join(
f"Sentence {i} discusses subsystem {i} and its failure mode {i % 7} in detail."
for i in range(scale_words // 9)
)
nwords = len(text.split())
tc = TextCrusher()
t0 = time.perf_counter()
out = tc.compress(text, target_ratio=0.3)
ms = (time.perf_counter() - t0) * 1000
print(f"\n=== Part C: speed (synthetic, {nwords:,} words, fully reproducible) ===")
print(f" TextCrusher compress: {ms:.0f} ms ({nwords / max(ms / 1000, 1e-6):,.0f} words/sec)")
print(f" kept ratio: {out.compressed_tokens / max(1, out.original_tokens):.2f}")
print(" reference: kompress (ModernBERT ONNX) ~272s for ~1M tokens (measured, query-blind)")
print(" -> fast-vs-slow CONTRAST, not a same-input side-by-side run")
if __name__ == "__main__":
eval_speed()
squad = sys.argv[1] if len(sys.argv) > 1 else "/tmp/squad_dev.json"
tx = sys.argv[2] if len(sys.argv) > 2 else None
if os.path.exists(squad):
eval_squad(squad)
else:
print(f"SQuAD not found at {squad}; skipping Part A")
if tx is None:
found = glob.glob(os.path.expanduser("~/.claude/projects/*headroom*/*.jsonl"))
tx = max(found, key=os.path.getsize) if found else None
if tx and os.path.exists(tx):
eval_transcript(tx)
else:
print("no transcript jsonl found; skipping Part B")

338
claude_analysis_ttl.py Normal file
View file

@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Cache reconstruction cost: cache_creation on first turn after idle gap vs in-window.
Prints a pretty distribution table plus a final cost-comparison summary across three
caching strategies: current (5m default), naive flip to 1h, and conditional 1h-after-idle.
"""
import json
from collections import defaultdict
from datetime import datetime
from pathlib import Path
PROJECTS = Path.home() / ".claude" / "projects"
# $ per million tokens.
PRICING = {
"claude-sonnet-4-6": {"w5": 3.75, "w1h": 6.00, "r": 0.30, "in": 3.00},
"claude-opus-4-6": {"w5": 6.25, "w1h": 10.00, "r": 0.50, "in": 5.00},
"claude-haiku-4-5": {"w5": 1.25, "w1h": 2.00, "r": 0.10, "in": 1.00},
"claude-opus-4-7": {"w5": 18.75, "w1h": 30.00, "r": 1.50, "in": 15.00},
}
DEFAULT_PRICE = PRICING["claude-sonnet-4-6"]
unknown_models = set()
def price_for(model: str) -> dict[str, float]:
if not model:
return DEFAULT_PRICE
if model in PRICING:
return PRICING[model]
base = model.split("[")[0]
for k in PRICING:
if base.startswith(k) or k in base:
return PRICING[k]
unknown_models.add(model)
return DEFAULT_PRICE
def parse_ts(s: str) -> datetime:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)
def turns_of(path: Path, seen_ids: set[str]) -> list[tuple[datetime, int, int, int, str]]:
"""Parse one JSONL. Skip turns whose message.id was already counted globally."""
out = []
with path.open(errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
continue
msg = obj.get("message")
if not isinstance(msg, dict):
continue
usage = msg.get("usage")
if not isinstance(usage, dict):
continue
try:
ts = parse_ts(obj["timestamp"])
except Exception:
continue
mid = msg.get("id")
if mid:
if mid in seen_ids:
continue
seen_ids.add(mid)
cc = usage.get("cache_creation_input_tokens", 0) or 0
cr = usage.get("cache_read_input_tokens", 0) or 0
inp = usage.get("input_tokens", 0) or 0
model = msg.get("model") or usage.get("model") or ""
out.append((ts, cc, cr, inp, model))
out.sort(key=lambda x: x[0])
return out
BUCKET_ORDER = ["<5min", "5-15min", "15-30min", "30-60min", "1-4hr", ">4hr"]
def bucket(g: float) -> str:
if g < 5:
return "<5min"
if g < 15:
return "5-15min"
if g < 30:
return "15-30min"
if g < 60:
return "30-60min"
if g < 240:
return "1-4hr"
return ">4hr"
def main() -> None:
buckets: dict[str, list[int]] = {b: [] for b in BUCKET_ORDER}
bucket_by_model: dict[str, dict[str, int]] = {b: defaultdict(int) for b in BUCKET_ORDER}
total_sessions = 0
first_turn_tokens_by_model: dict[str, int] = defaultdict(int)
seen_ids: set[str] = set()
for path in sorted(PROJECTS.rglob("*.jsonl")): # sort for deterministic dedupe winner
try:
t = turns_of(path, seen_ids)
except Exception:
continue
if len(t) < 2:
continue
total_sessions += 1
# First turn of session: no prior, but the cache_creation IS a fresh write.
ts0, cc0, _, _, m0 = t[0]
first_turn_tokens_by_model[m0] += cc0
for i in range(1, len(t)):
gap = (t[i][0] - t[i - 1][0]).total_seconds() / 60.0
if gap < 0:
continue
cc = t[i][1]
m = t[i][4]
b = bucket(gap)
buckets[b].append(cc)
bucket_by_model[b][m] += cc
def stats(lst: list[int]) -> dict[str, int] | None:
if not lst:
return None
s = sorted(lst)
n = len(s)
return {
"n": n,
"min": s[0],
"p25": s[n // 4],
"median": s[n // 2],
"p75": s[3 * n // 4],
"p95": s[min(n - 1, int(n * 0.95))],
"max": s[-1],
"mean": sum(s) // n,
"total": sum(s),
}
# ----- Pretty distribution table -----
print()
print("=" * 88)
print(f" CACHE RECONSTRUCTION COST — {total_sessions:,} sessions analyzed")
print("=" * 88)
print()
print(" cache_creation tokens, bucketed by gap since previous turn")
print()
header = f" {'bucket':<10} {'count':>8} {'median':>12} {'mean':>12} {'p75':>12} {'p95':>12} {'total':>16}"
print(header)
print(" " + "-" * (len(header) - 2))
for b in BUCKET_ORDER:
st = stats(buckets[b])
if st:
print(
f" {b:<10} {st['n']:>8,} {st['median']:>12,} {st['mean']:>12,} "
f"{st['p75']:>12,} {st['p95']:>12,} {st['total']:>16,}"
)
print()
# ----- Smoking-gun ratios -----
in_window = buckets["<5min"]
post_idle_short = buckets["5-15min"]
post_idle_5to60 = buckets["5-15min"] + buckets["15-30min"] + buckets["30-60min"]
med_in = sorted(in_window)[len(in_window) // 2] if in_window else 0
med_5_15 = sorted(post_idle_short)[len(post_idle_short) // 2] if post_idle_short else 0
med_5_60 = sorted(post_idle_5to60)[len(post_idle_5to60) // 2] if post_idle_5to60 else 0
print("-" * 88)
print(" RECONSTRUCTION RATIO — the smoking gun")
print("-" * 88)
print(f" Median in-window write (<5min gap) : {med_in:>10,} tokens")
print(
f" Median post-idle write (5-15min gap) : {med_5_15:>10,} tokens "
f"({med_5_15 / max(med_in, 1):>5.0f}x)"
)
print(
f" Median post-idle write (5-60min gap) : {med_5_60:>10,} tokens "
f"({med_5_60 / max(med_in, 1):>5.0f}x)"
)
print()
# ----- Cost comparison across strategies -----
# Strategy A — current: all writes at 5m price.
# cost_A = sum_m (in_window_m + post_idle_5to60_m + post_idle_over60_m) * w5
# Strategy B — naive 1h: every write becomes a 1h write; 5-60min rewrites flip to reads.
# cost_B = sum_m [(in_window_m + post_idle_over60_m) * w1h + post_idle_5to60_m * r]
# Strategy C — conditional 1h-after-idle: write 5m on in-window deltas, write 1h
# only on first turn after >=5min idle. Then the 5-60min rewrites become
# reads on the *next* gap event (they already are, post-write), and the
# >60min rewrites still cost a 1h write (they expired even the 1h cache).
# cost_C = sum_m [in_window_m * w5 + post_idle_5to60_m * r + post_idle_over60_m * w1h]
#
# NOTE: Strategy C model assumes the post-idle rewrite events we measured today would
# become reads under conditional-1h. That's accurate for gaps in [5min, 60min) because
# the previous turn (now written at 1h) is still cached when the next turn arrives.
def cost(tok: int, ppm: float) -> float:
return tok * ppm / 1_000_000.0
# Aggregate per-model token totals.
by_model: dict[str, dict[str, int]] = defaultdict(
lambda: {"in": 0, "p_5to60": 0, "p_over60": 0, "first": 0}
)
for b in BUCKET_ORDER:
for m, tok in bucket_by_model[b].items():
if b == "<5min":
by_model[m]["in"] += tok
elif b in ("5-15min", "15-30min", "30-60min"):
by_model[m]["p_5to60"] += tok
else:
by_model[m]["p_over60"] += tok
for m, tok in first_turn_tokens_by_model.items():
# First turn of a session is a fresh write; treat it as a >5min "post-idle"
# since there's no prior to refresh. Conservative: bucket as p_over60 so
# conditional-1h pays 1h for it too.
by_model[m]["p_over60"] += tok
rows: list[tuple[str, dict[str, int], float, float, dict[str, float]]] = []
A_total = 0.0
B_total = 0.0
for m, agg in by_model.items():
p = price_for(m)
A = (
cost(agg["in"], p["w5"])
+ cost(agg["p_5to60"], p["w5"])
+ cost(agg["p_over60"], p["w5"])
)
B = (
cost(agg["in"], p["w1h"])
+ cost(agg["p_5to60"], p["r"])
+ cost(agg["p_over60"], p["w1h"])
)
A_total += A
B_total += B
rows.append((m, agg, A, B, p))
print("-" * 88)
print(" COST COMPARISON — two caching strategies")
print("-" * 88)
print()
print(" Strategies:")
print(" A) Current — all cache writes at 5m TTL")
print(" B) Naive 1h — flip default: all writes at 1h TTL; 5-60min rewrites become reads")
print()
# simpler totals
tot_in = sum(a["in"] for a in by_model.values())
tot_5to60 = sum(a["p_5to60"] for a in by_model.values())
tot_over60 = sum(a["p_over60"] for a in by_model.values())
grand = tot_in + tot_5to60 + tot_over60
print()
print(f" {'category':<40} {'tokens':>16} {'% of total':>12}")
print(" " + "-" * 70)
print(f" {'in-window deltas (<5min)':<40} {tot_in:>16,} {tot_in / grand * 100:>11.1f}%")
print(
f" {'avoidable rewrites (5-60min idle)':<40} {tot_5to60:>16,} {tot_5to60 / grand * 100:>11.1f}%"
)
print(
f" {'unavoidable rewrites (>60min + first)':<40} {tot_over60:>16,} {tot_over60 / grand * 100:>11.1f}%"
)
print(f" {'TOTAL cache_creation':<40} {grand:>16,} {100.0:>11.1f}%")
print()
# Per-model cost rows
print(f" {'model':<22} {'A: current 5m':>15} {'B: naive 1h':>15} {'B vs A':>10}")
print(" " + "-" * 70)
for m, _agg, A, B, _p in sorted(rows, key=lambda r: -r[2]):
name = m or "<unknown>"
if len(name) > 22:
name = name[:21] + ""
dB = B - A
print(f" {name:<22} ${A:>13,.2f} ${B:>13,.2f} ${dB:>+9,.2f}")
print(" " + "-" * 70)
dB_total = B_total - A_total
print(f" {'TOTAL':<22} ${A_total:>13,.2f} ${B_total:>13,.2f} ${dB_total:>+9,.2f}")
print()
# ----- Hypothetical: same token mix priced as if 100% Sonnet vs 100% Opus -----
print("-" * 88)
print(" HYPOTHETICAL — same token mix, all on one model")
print("-" * 88)
print()
print(" Re-prices the observed cache_creation token mix as if every token had")
print(" been written by a single model. Lets you compare TTL impact at each tier.")
print()
hypos = [
("All Sonnet 4.6", PRICING["claude-sonnet-4-6"]),
("All Opus 4.7", PRICING["claude-opus-4-7"]),
]
print(
f" {'scenario':<18} {'A: current 5m':>15} {'B: naive 1h':>15} {'B vs A':>10} {'B vs A %':>10}"
)
print(" " + "-" * 80)
for name, p in hypos:
A = cost(tot_in, p["w5"]) + cost(tot_5to60, p["w5"]) + cost(tot_over60, p["w5"])
B = cost(tot_in, p["w1h"]) + cost(tot_5to60, p["r"]) + cost(tot_over60, p["w1h"])
d = B - A
pct = (d / A * 100) if A else 0
print(f" {name:<18} ${A:>13,.2f} ${B:>13,.2f} ${d:>+9,.2f} {pct:>+9.1f}%")
print()
# ----- Bottom line -----
print("=" * 88)
print(" BOTTOM LINE")
print("=" * 88)
print()
print(f" Sample: {total_sessions:,} sessions, {grand:,} total cache_creation tokens")
print()
print(f" A) Current 5m default : ${A_total:>10,.2f} (baseline)")
sign_B = "+" if dB_total >= 0 else "-"
print(
f" B) Naive flip to 1h : ${B_total:>10,.2f} ({sign_B}${abs(dB_total):,.2f} vs current)"
)
print()
if dB_total > 0:
print(
f" Verdict: naive flip COSTS MORE because the 1.6x premium on {tot_in / grand * 100:.0f}% of tokens"
)
print(
f" (in-window deltas) exceeds savings on {tot_5to60 / grand * 100:.0f}% (post-idle rewrites)."
)
elif dB_total < 0:
print(f" Verdict: naive 1h flip saves ${abs(dB_total):,.2f} on this sample.")
else:
print(" Verdict: 1h flip is cost-neutral on this sample.")
print()
if unknown_models:
print(f" Note: unknown models defaulted to Sonnet pricing: {sorted(unknown_models)}")
print()
if __name__ == "__main__":
main()

View file

@ -1,19 +1,34 @@
codecov:
require_ci_to_pass: true
coverage:
status:
project:
default:
target: auto
patch:
default:
target: auto
ignore:
- "tests/**"
- "scripts/tests/**"
- ".github/**"
- ".claude-plugin/**"
- "plugins/headroom-agent-hooks/.claude-plugin/**"
- "plugins/headroom-agent-hooks/.github/**"
codecov:
require_ci_to_pass: true
coverage:
status:
# Gate on the comprehensive unit suite (`python` flag from ci.yml's 4 test
# shards), NOT the narrow native-e2e smoke flags (install-native /
# wrap-native). Those e2e jobs upload first and barely exercise new code,
# so an unscoped status computes patch at ~6% off the e2e flags alone and
# flaps to FAILURE before the unit shards report. Scoping to `python` makes
# the status reflect real coverage of the diff.
project:
default:
target: auto
flags:
- python
patch:
default:
target: auto
flags:
- python
flags:
python:
carryforward: false
ignore:
- "tests/**"
- "scripts/tests/**"
- ".github/**"
- ".claude-plugin/**"
- "headroom/dashboard/templates/**"
- "plugins/headroom-agent-hooks/.claude-plugin/**"
- "plugins/headroom-agent-hooks/.github/**"

View file

@ -37,6 +37,22 @@ dashmap = "6"
# `regex` is already a transitive dep of tokenizers; depend on it directly so
# our hunk-header parser and priority-pattern matcher have a stable surface.
regex = "1"
# CJK sentence + word segmentation for TextCrusher (#1171). CJK has no spaces or
# ASCII terminators, so the default ASCII splitter/tokenizer collapses a whole
# CJK paragraph into one segment/one token -> 0% compression. ICU4X's UAX#29
# sentence + dictionary word segmenters fix this. Chosen over a hand-rolled
# char-bigram (benchmarked on real CMRC2018 Chinese QA: 92.5% vs 91% answer-
# retention) and over jieba/lindera (ZH-only / tens-to-hundreds of MB dicts).
# - Why this version: 2.x is the stabilized ICU4X API (1.x used a different data
# provider model); floored at 2.2 (Cargo.lock pins the exact patch) since
# segmenter boundaries are observable in output -- bumps should be deliberate.
# - Install surface: ~13 new crates, all pure Rust, no build scripts, no native
# code, no build/runtime network. `compiled_data` bundles locale data at
# compile time (hermetic). Maintained by the official unicode-org.
# - `compiled_data` only (no `auto`/`lstm`): LSTM models cover SE-Asian scripts
# (Thai/Lao), not CJK -- CJK uses the dictionary, so `auto` would pull in libm
# for nothing. Required for CJK; pure-ASCII paths are unchanged.
icu_segmenter = { version = "2.2", features = ["compiled_data"] }
# `flate2` for `_validate_with_zlib` in `adaptive_sizer`. Python's adaptive
# sizing pipeline uses `zlib.compress(..., level=1)` to validate the chosen
# K against compression-ratio diversity. We use the default `miniz_oxide`
@ -67,7 +83,7 @@ flate2 = "1"
# crate depends on `ort`, which is already in our dep tree via
# `fastembed`, so adding it doesn't pull a new ML runtime — both
# crates share the ONNX Runtime singleton.
magika = "1"
magika = { version = "1", optional = true }
# `unidiff` is the Stage-3d Tier-2 diff detector. We use the parser
# itself as the "is this a diff?" oracle — anything that successfully
# parses to ≥1 PatchedFile is a diff. The deterministic parser
@ -94,7 +110,7 @@ rayon = "1"
# `config/pipeline.toml`. The defaults embed via `include_str!` so a
# stock binary needs no external file; production deployments override
# by loading their own TOML at startup.
toml = "0.8"
toml = "1.1"
# `blake3` powers `ccr::compute_key`. BLAKE3 is faster than SHA-256 on
# every hot path the proxy hits (large diff/log/tool_result payloads)
# and produces collision-resistant 24-char prefixes for the CCR
@ -121,26 +137,57 @@ redis = { version = "0.27", optional = true, default-features = false }
# classifier live with the other Phase B/F policy primitives without
# cycling through the proxy crate. Tiny crate (no I/O, just types).
http = "1"
# tree-sitter + per-language grammars for the CodeCompressor AST port.
# Versions are pinned to EXACTLY match the Python reference grammars
# (`tree-sitter-<lang>` PyPI wheels) so the Rust and Python parsers emit
# node-for-node identical ASTs — the precondition for byte-parity. Same
# version number on crates.io + PyPI means the same `grammar.js` source,
# hence the same generated `parser.c`. The grammar-parity canary (9
# samples × 8 languages) confirmed 100% identical node-type + line-span
# trees at these exact pins. Bumping any pin requires re-running the
# canary and re-recording the code_aware_compressor fixtures.
tree-sitter = "=0.25.2"
tree-sitter-python = "=0.25.0"
tree-sitter-javascript = "=0.25.0"
tree-sitter-typescript = "=0.23.2"
tree-sitter-go = "=0.25.0"
tree-sitter-rust = "=0.24.2"
tree-sitter-java = "=0.23.5"
tree-sitter-c = "=0.24.2"
tree-sitter-cpp = "=0.23.4"
[target.'cfg(not(windows))'.dependencies]
fastembed = { version = "5", default-features = false, features = [
"hf-hub-rustls-tls",
"ort-download-binaries-rustls-tls",
"image-models",
] }
[target.'cfg(windows)'.dependencies]
# `ort-download-binaries-*` emits DirectML link libs on Windows (`DXCORE`,
# `DXGI`, `D3D12`, `DirectML`). Users installing `headroom-ai[all]` from
# sdist often do not have those SDK libs, so load ORT dynamically instead.
fastembed = { version = "5", default-features = false, features = [
# Load ONNX Runtime dynamically on every platform. The alternative,
# `ort-download-binaries-*`, statically links Microsoft's prebuilt ORT:
# on Windows it emits DirectML link libs (`DXCORE`, `DXGI`, `D3D12`,
# `DirectML`) that sdist installs of `headroom-ai[all]` often lack, and
# on x86_64 Linux/macOS the prebuilt binary requires AVX2 — its code is
# mapped and initialized as soon as the `headroom._core` extension
# loads, so importing headroom SIGILLed on pre-AVX2 CPUs before the
# runtime AVX2 guard could run (#1278). With `ort-load-dynamic` the
# library is only dlopen'd at first use, where the AVX2 guard falls
# back to the non-ONNX detection tiers.
fastembed = { version = "5", default-features = false, optional = true, features = [
"hf-hub-rustls-tls",
"ort-load-dynamic",
"image-models",
] }
# Direct dependency for Kompress inference (`ort::session::Session` /
# `ort::value::Tensor`). Keep this pinned to the lock entry and optional so
# `default-features = false` consumers can still build without ONNX Runtime.
ort = { version = "=2.0.0-rc.12", default-features = false, optional = true, features = ["load-dynamic"] }
[features]
default = []
# `ml` is ON by default, so a stock build is byte-for-byte what it was before:
# the ONNX-backed transforms (fastembed embeddings, magika detection, the
# smart-crusher ML path) are all compiled in. Turning it OFF
# (`default-features = false`) drops `ort`/`fastembed`/`magika` from the tree
# entirely, so a consumer that only uses the lexical path (`TextCrusher` /
# BM25 relevance) builds with no ONNX Runtime at all — which is what lets that
# consumer ship a fully-static musl binary. See the source `#[cfg(feature =
# "ml")]` gates (TODO: the module + dispatch gating is the collaborative half
# of this change — flagged in the PR).
default = ["ml"]
ml = ["dep:ort", "dep:fastembed", "dep:magika"]
# Compile in the Redis CCR backend. Enable for multi-worker deployments
# that want a shared CCR store with no sticky-session at the LB. The
# SQLite backend (always compiled) is the production default for

View file

@ -149,3 +149,13 @@ lockfile_suffixes = [
# show up in formatter / linter commits and carry no signal the LLM
# needs to reason about.
drop_whitespace_only_hunks = true
# ─── Structured prose-field offload config ─────────────────────────
#
# Only detector-confirmed PlainText leaves above both floors are candidates.
# The final marker-inclusive output must still be shorter than the leaf.
[offload.prose_field]
min_bytes = 256
min_segments = 6
target_ratio = 0.5

View file

@ -93,10 +93,10 @@ const SUBSCRIPTION_UA_PREFIXES: &[&str] = &[
/// 1. **Subscription UA prefix** → [`AuthMode::Subscription`].
/// The CLI's own auth-mode wins over the bearer token shape it
/// happens to be carrying — a Claude Code session uses a
/// `sk-ant-oat-*` token but is a subscription client, not OAuth.
/// 2. **`Authorization: Bearer sk-ant-oat-*`** → [`AuthMode::OAuth`]
/// `sk-ant-oat*` token but is a subscription client, not OAuth.
/// 2. **`Authorization: Bearer sk-ant-oat*`** → [`AuthMode::OAuth`]
/// (Claude Pro / Max OAuth). Checked before the broader `sk-` PAYG
/// rule because `sk-ant-oat-` shares the `sk-` prefix.
/// rule because `sk-ant-oat` shares the `sk-` prefix.
/// 3. **`Authorization: Bearer sk-ant-api*` or `Bearer sk-*`** →
/// [`AuthMode::Payg`] (Anthropic / OpenAI API key).
/// 4. **`Authorization: Bearer <jwt>`** (3 dot-separated segments) →
@ -162,10 +162,11 @@ pub fn classify(headers: &HeaderMap) -> AuthMode {
};
if let Some(token) = auth.strip_prefix("Bearer ") {
// Order matters: the OAuth shape `sk-ant-oat-*` shares a
// Order matters: the OAuth shape `sk-ant-oat*` shares a
// prefix with `sk-ant-api*` only at `sk-ant-`, so we check
// the OAuth shape FIRST. Then the broad PAYG shapes.
if token.starts_with("sk-ant-oat-") {
// the OAuth shape FIRST. Real OAuth access tokens are
// `sk-ant-oat01-...` (version number, no dash after `oat`).
if token.starts_with("sk-ant-oat") {
return AuthMode::OAuth;
}
if token.starts_with("sk-ant-api") || token.starts_with("sk-") {

View file

@ -15,13 +15,16 @@ use std::time::{Duration, Instant};
use dashmap::DashMap;
use crate::ccr::{CcrStore, DEFAULT_CAPACITY, DEFAULT_TTL};
use crate::ccr::{max_lifetime_for, CcrStore, DEFAULT_CAPACITY, DEFAULT_TTL};
/// In-memory CCR store backed by [`DashMap`] for sharded concurrent
/// access.
///
/// - **TTL**: 5 minutes by default. Entries past their TTL are dropped
/// on the next `get` (lazy expiry — no background reaper thread).
/// - **TTL**: 30 minutes by default, treated as an **idle window** —
/// every successful `get` restarts the entry's clock (#2604), bounded
/// by an absolute max lifetime of 8x the idle TTL measured from
/// insertion. Entries past their window are dropped on the next `get`
/// (lazy expiry — no background reaper thread).
/// - **Capacity**: 1000 entries by default. When `put` would push us
/// past capacity, the oldest entry (per insertion order) is evicted.
/// - **Concurrency**: gets and puts on distinct keys do not contend.
@ -36,6 +39,7 @@ pub struct InMemoryCcrStore {
/// they actually evict a real entry.
order: Mutex<VecDeque<String>>,
ttl: Duration,
max_lifetime: Duration,
capacity: usize,
}
@ -43,19 +47,38 @@ pub struct InMemoryCcrStore {
struct Entry {
payload: String,
inserted: Instant,
last_accessed: Instant,
}
impl Entry {
/// Expired when idle past `ttl` OR older (since insertion) than
/// `max_lifetime` — the absolute ceiling that keeps constant access
/// from pinning an entry forever.
fn is_expired(&self, ttl: Duration, max_lifetime: Duration) -> bool {
self.last_accessed.elapsed() > ttl || self.inserted.elapsed() > max_lifetime
}
}
impl InMemoryCcrStore {
/// Default: 1000 entries, 5-minute TTL.
/// Default: 1000 entries, 30-minute idle TTL (8x max lifetime).
pub fn new() -> Self {
Self::with_capacity_and_ttl(DEFAULT_CAPACITY, DEFAULT_TTL)
}
/// `ttl` is the idle window; the absolute max lifetime defaults to
/// 8x that (see [`crate::ccr::DEFAULT_MAX_LIFETIME_MULTIPLIER`]).
pub fn with_capacity_and_ttl(capacity: usize, ttl: Duration) -> Self {
Self::with_capacity_and_ttls(capacity, ttl, max_lifetime_for(ttl))
}
/// Full-control constructor: idle window and absolute max lifetime
/// specified independently.
pub fn with_capacity_and_ttls(capacity: usize, ttl: Duration, max_lifetime: Duration) -> Self {
Self {
map: DashMap::with_capacity(capacity),
order: Mutex::new(VecDeque::with_capacity(capacity)),
ttl,
max_lifetime,
capacity,
}
}
@ -89,8 +112,10 @@ impl CcrStore for InMemoryCcrStore {
// in place, leave the order queue alone. Common when the same
// tool output flows through multiple times in a session.
if let Some(mut existing) = self.map.get_mut(hash) {
let now = Instant::now();
existing.payload = payload.to_string();
existing.inserted = Instant::now();
existing.inserted = now;
existing.last_accessed = now;
return;
}
@ -99,9 +124,11 @@ impl CcrStore for InMemoryCcrStore {
if self.map.len() >= self.capacity {
self.evict_until_under_capacity();
}
let now = Instant::now();
let entry = Entry {
payload: payload.to_string(),
inserted: Instant::now(),
inserted: now,
last_accessed: now,
};
let prev = self.map.insert(hash.to_string(), entry);
if prev.is_none() {
@ -117,9 +144,12 @@ impl CcrStore for InMemoryCcrStore {
}
fn get(&self, hash: &str) -> Option<String> {
// Read path: shard read-lock, check TTL, clone payload out.
// No global lock involvement at all — distinct hashes hash to
// distinct shards and never contend.
// Hit path: shard write-lock (get_mut), check the idle window +
// max-lifetime ceiling, refresh `last_accessed`, clone payload
// out. The TTL is a sliding idle window (#2604): every hit
// restarts the clock, so an entry a session keeps touching does
// not expire mid-burst. Distinct hashes hash to distinct shards
// and never contend.
//
// Lazy expiry uses DashMap's `remove_if` so the check-and-remove
// is atomic on the shard. An earlier 2-step (drop read lock,
@ -130,8 +160,9 @@ impl CcrStore for InMemoryCcrStore {
// load this manifested as "I just stored it; why is it gone?"
// `remove_if` closes the window because the shard write lock
// is held across both the predicate evaluation and the removal.
if let Some(entry) = self.map.get(hash) {
if entry.inserted.elapsed() <= self.ttl {
if let Some(mut entry) = self.map.get_mut(hash) {
if !entry.is_expired(self.ttl, self.max_lifetime) {
entry.last_accessed = Instant::now();
return Some(entry.payload.clone());
}
} else {
@ -143,7 +174,9 @@ impl CcrStore for InMemoryCcrStore {
// and re-fetch its payload.
let was_removed = self
.map
.remove_if(hash, |_, entry| entry.inserted.elapsed() > self.ttl)
.remove_if(hash, |_, entry| {
entry.is_expired(self.ttl, self.max_lifetime)
})
.is_some();
if was_removed {
None

View file

@ -42,7 +42,7 @@ pub enum CcrBackendConfig {
}
impl CcrBackendConfig {
/// Production default: SQLite at `path`, 5-minute TTL.
/// Production default: SQLite at `path`, 30-minute TTL.
pub fn sqlite_default(path: PathBuf) -> Self {
Self::Sqlite {
path,

View file

@ -10,10 +10,12 @@
//! # Storage model
//!
//! Each entry maps to a Redis key `ccr:{hash}` containing the original
//! payload bytes, with a `SETEX` TTL applied on every write. Read path
//! is a single `GET`. Redis handles purging via key expiry — no
//! application-side sweep needed (matching the SQLite backend's
//! lazy-purge but at the Redis level).
//! payload bytes, with a `SETEX` TTL applied on every write. The TTL is
//! an **idle window** (#2604): every successful `get` re-arms the key's
//! expiry, bounded by an absolute max lifetime tracked in a companion
//! `ccr:{hash}:born` key whose own expiry marks the ceiling. Redis
//! handles purging via key expiry — no application-side sweep needed
//! (matching the SQLite backend's lazy-purge but at the Redis level).
//!
//! # Concurrency
//!
@ -27,7 +29,7 @@
use redis::Commands;
use crate::ccr::CcrStore;
use crate::ccr::{max_lifetime_for, CcrStore};
/// Key prefix applied to every CCR entry. Configurable per-deployment
/// so multiple proxies sharing one Redis don't collide.
@ -38,6 +40,9 @@ pub struct RedisCcrStore {
client: redis::Client,
key_prefix: String,
default_ttl_seconds: u64,
/// Absolute max lifetime (seconds since `put`) that caps the
/// sliding idle window. Defaults to 8x the idle TTL.
max_lifetime_seconds: u64,
}
impl RedisCcrStore {
@ -59,10 +64,13 @@ impl RedisCcrStore {
// signal.
let mut conn = client.get_connection()?;
let _: String = redis::cmd("PING").query(&mut conn)?;
let max_lifetime_seconds =
max_lifetime_for(std::time::Duration::from_secs(default_ttl_seconds)).as_secs();
Ok(Self {
client,
key_prefix,
default_ttl_seconds,
max_lifetime_seconds,
})
}
@ -70,6 +78,12 @@ impl RedisCcrStore {
format!("{}:{}", self.key_prefix, hash)
}
/// Companion key whose expiry marks the entry's absolute max
/// lifetime; its remaining TTL caps every idle-window re-arm.
fn born_key_for(&self, hash: &str) -> String {
format!("{}:{}:born", self.key_prefix, hash)
}
/// Default TTL (seconds) applied on every `put`.
pub fn default_ttl_seconds(&self) -> u64 {
self.default_ttl_seconds
@ -102,6 +116,20 @@ impl CcrStore for RedisCcrStore {
error = %err,
"ccr_redis_put_failed"
);
return;
}
// Companion max-lifetime marker: its remaining TTL caps every
// idle-window re-arm in `get`, so constant access cannot pin an
// entry past `max_lifetime_seconds`.
let born: redis::RedisResult<()> =
conn.set_ex(self.born_key_for(hash), 1_u8, self.max_lifetime_seconds);
if let Err(err) = born {
tracing::warn!(
target = "ccr.redis",
hash = %hash,
error = %err,
"ccr_redis_put_born_failed"
);
}
}
@ -120,9 +148,9 @@ impl CcrStore for RedisCcrStore {
}
};
let bytes: redis::RedisResult<Option<Vec<u8>>> = conn.get(&key);
match bytes {
Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
Ok(None) => None,
let payload = match bytes {
Ok(Some(bytes)) => String::from_utf8(bytes).ok()?,
Ok(None) => return None,
Err(err) => {
tracing::warn!(
target = "ccr.redis",
@ -130,9 +158,48 @@ impl CcrStore for RedisCcrStore {
error = %err,
"ccr_redis_get_failed"
);
None
return None;
}
};
// Sliding idle window (#2604): re-arm the key's expiry on every
// hit, capped by the companion born-key's remaining lifetime.
let born_key = self.born_key_for(hash);
let born_remaining: i64 = conn.ttl(&born_key).unwrap_or(-1);
let remaining = if born_remaining >= 0 {
born_remaining as u64
} else {
// Legacy entry written by a pre-sliding build (no born key):
// backfill the ceiling from now rather than dropping data.
let backfill: redis::RedisResult<()> =
conn.set_ex(&born_key, 1_u8, self.max_lifetime_seconds);
if let Err(err) = backfill {
tracing::warn!(
target = "ccr.redis",
hash = %hash,
error = %err,
"ccr_redis_born_backfill_failed"
);
}
self.max_lifetime_seconds
};
let new_ttl = self.default_ttl_seconds.min(remaining);
if new_ttl == 0 {
// Past the max lifetime: purge rather than serve a pinned
// entry that should have died.
let _: redis::RedisResult<()> = conn.del(&key);
return None;
}
let rearm: redis::RedisResult<()> = conn.expire(&key, new_ttl as i64);
if let Err(err) = rearm {
tracing::warn!(
target = "ccr.redis",
hash = %hash,
error = %err,
"ccr_redis_ttl_rearm_failed"
);
}
Some(payload)
}
fn len(&self) -> usize {

View file

@ -5,16 +5,21 @@
//!
//! ```sql
//! CREATE TABLE IF NOT EXISTS ccr_entries (
//! hash TEXT PRIMARY KEY,
//! original BLOB NOT NULL,
//! created_at INTEGER NOT NULL, -- unix-seconds
//! ttl_seconds INTEGER NOT NULL
//! hash TEXT PRIMARY KEY,
//! original BLOB NOT NULL,
//! created_at INTEGER NOT NULL, -- unix-seconds
//! ttl_seconds INTEGER NOT NULL, -- idle window, restarted on get
//! last_accessed INTEGER NOT NULL -- unix-seconds
//! );
//! ```
//!
//! On every `get` we lazy-purge stale rows
//! (`WHERE created_at + ttl_seconds <= now`) — no background reaper
//! thread, no cron.
//! The TTL is an **idle window** (#2604): every successful `get`
//! restarts the row's clock via `last_accessed`, bounded by an absolute
//! max lifetime measured from `created_at`. On every `get` we
//! lazy-purge stale rows (`WHERE last_accessed + ttl_seconds < now OR
//! created_at + max_lifetime < now`) — no background reaper thread,
//! no cron. DBs created by pre-sliding builds are migrated in place
//! (the `last_accessed` column is added, backfilled from `created_at`).
//!
//! All hot statements are prepared once on connection setup and reused
//! per call (per realignment build constraint #5: performant). Writes
@ -43,14 +48,17 @@ use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection, OptionalExtension};
use crate::ccr::CcrStore;
use crate::ccr::{max_lifetime_for, CcrStore};
/// SQLite-backed CCR store.
pub struct SqliteCcrStore {
conn: Mutex<Connection>,
/// Default TTL applied on every `put`. Mirrors Python's
/// `compression_store` 5-minute window.
/// Default idle TTL applied on every `put`. Mirrors Python's
/// `compression_store` idle window.
default_ttl_seconds: u64,
/// Absolute max lifetime (seconds since `created_at`) that caps the
/// sliding idle window. Defaults to 8x the idle TTL.
max_lifetime_seconds: u64,
/// Path the connection was opened against — kept for diagnostics
/// and for the proxy-restart simulation test.
path: PathBuf,
@ -58,9 +66,24 @@ pub struct SqliteCcrStore {
impl SqliteCcrStore {
/// Open or create the DB file at `path` and prepare the schema.
/// `default_ttl_seconds` is the idle window; the absolute max
/// lifetime defaults to 8x that (see
/// [`crate::ccr::DEFAULT_MAX_LIFETIME_MULTIPLIER`]).
/// Errors surface to the caller (`from_config`); we never silently
/// fall back to the in-memory backend (`feedback_no_silent_fallbacks.md`).
pub fn open(path: impl AsRef<Path>, default_ttl_seconds: u64) -> rusqlite::Result<Self> {
let max_lifetime =
max_lifetime_for(std::time::Duration::from_secs(default_ttl_seconds)).as_secs();
Self::open_with_ttls(path, default_ttl_seconds, max_lifetime)
}
/// Full-control constructor: idle window and absolute max lifetime
/// specified independently.
pub fn open_with_ttls(
path: impl AsRef<Path>,
default_ttl_seconds: u64,
max_lifetime_seconds: u64,
) -> rusqlite::Result<Self> {
let path_buf = path.as_ref().to_path_buf();
let conn = Connection::open(&path_buf)?;
@ -73,25 +96,49 @@ impl SqliteCcrStore {
conn.execute(
"CREATE TABLE IF NOT EXISTS ccr_entries (
hash TEXT PRIMARY KEY,
original BLOB NOT NULL,
created_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL
hash TEXT PRIMARY KEY,
original BLOB NOT NULL,
created_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
last_accessed INTEGER NOT NULL
)",
[],
)?;
Self::migrate_legacy_schema(&conn)?;
// No secondary index — the schema is one-row-per-PK and the only
// non-PK lookup (the lazy-purge sweep) is a `WHERE` predicate on
// a small table; an index on `created_at + ttl_seconds` would
// cost more than it saves.
// a small table; an index on the expiry expressions would cost
// more than it saves.
Ok(Self {
conn: Mutex::new(conn),
default_ttl_seconds,
max_lifetime_seconds,
path: path_buf,
})
}
/// DBs created before the sliding-TTL change lack `last_accessed`.
/// Add it in place and backfill from `created_at` so legacy rows
/// keep their original expiry baseline rather than being purged or
/// artificially refreshed.
fn migrate_legacy_schema(conn: &Connection) -> rusqlite::Result<()> {
let has_last_accessed = conn
.prepare("SELECT 1 FROM pragma_table_info('ccr_entries') WHERE name = 'last_accessed'")?
.exists([])?;
if !has_last_accessed {
conn.execute(
"ALTER TABLE ccr_entries ADD COLUMN last_accessed INTEGER NOT NULL DEFAULT 0",
[],
)?;
conn.execute(
"UPDATE ccr_entries SET last_accessed = created_at WHERE last_accessed = 0",
[],
)?;
}
Ok(())
}
/// Path the connection was opened against. Test helper.
pub fn path(&self) -> &Path {
&self.path
@ -102,12 +149,18 @@ impl SqliteCcrStore {
self.default_ttl_seconds
}
/// Drop all expired rows. Lazy — invoked from `get`. Returns the
/// Drop all expired rows: idle past their window, or past the
/// absolute max lifetime. Lazy — invoked from `get`. Returns the
/// number of rows purged.
fn purge_expired(conn: &Connection, now: u64) -> rusqlite::Result<usize> {
fn purge_expired(&self, conn: &Connection, now: u64) -> rusqlite::Result<usize> {
// Timestamps have whole-second resolution. Use a strict boundary so
// truncation can extend a cache entry by less than one second but can
// never expire it before the configured idle or lifetime window.
let purged = conn.execute(
"DELETE FROM ccr_entries WHERE created_at + ttl_seconds <= ?1",
params![now as i64],
"DELETE FROM ccr_entries
WHERE last_accessed + ttl_seconds < ?1
OR created_at + ?2 < ?1",
params![now as i64, self.max_lifetime_seconds as i64],
)?;
Ok(purged)
}
@ -120,6 +173,58 @@ impl SqliteCcrStore {
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn get_at(&self, hash: &str, now: u64) -> Option<String> {
let conn = self.conn.lock().expect("ccr sqlite mutex poisoned");
// Lazy purge sweep, then the real lookup. Both happen under
// the same mutex so the row we read is guaranteed not to have
// been just-deleted by another caller.
if let Err(err) = self.purge_expired(&conn, now) {
tracing::warn!(
target = "ccr.sqlite",
error = %err,
"ccr_sqlite_purge_failed"
);
}
let row: Option<Vec<u8>> = conn
.query_row(
"SELECT original FROM ccr_entries
WHERE hash = ?1
AND last_accessed + ttl_seconds >= ?2
AND created_at + ?3 >= ?2",
params![hash, now as i64, self.max_lifetime_seconds as i64],
|r| r.get::<_, Vec<u8>>(0),
)
.optional()
.unwrap_or_else(|err| {
tracing::warn!(
target = "ccr.sqlite",
hash = %hash,
error = %err,
"ccr_sqlite_get_failed"
);
None
});
let row = row?;
// Sliding idle window (#2604): a successful hit restarts the
// row's idle clock. Still under the same mutex as the lookup.
if let Err(err) = conn.execute(
"UPDATE ccr_entries SET last_accessed = ?2 WHERE hash = ?1",
params![hash, now as i64],
) {
tracing::warn!(
target = "ccr.sqlite",
hash = %hash,
error = %err,
"ccr_sqlite_touch_failed"
);
}
String::from_utf8(row).ok()
}
}
impl CcrStore for SqliteCcrStore {
@ -129,12 +234,13 @@ impl CcrStore for SqliteCcrStore {
// Upsert by PK. ON CONFLICT REPLACE matches the in-memory
// backend's idempotent re-store semantics.
let res = conn.execute(
"INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds)
VALUES (?1, ?2, ?3, ?4)
"INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds, last_accessed)
VALUES (?1, ?2, ?3, ?4, ?3)
ON CONFLICT(hash) DO UPDATE SET
original = excluded.original,
created_at = excluded.created_at,
ttl_seconds = excluded.ttl_seconds",
original = excluded.original,
created_at = excluded.created_at,
ttl_seconds = excluded.ttl_seconds,
last_accessed = excluded.last_accessed",
params![
hash,
payload.as_bytes(),
@ -159,39 +265,7 @@ impl CcrStore for SqliteCcrStore {
}
fn get(&self, hash: &str) -> Option<String> {
let now = Self::now_unix_seconds();
let conn = self.conn.lock().expect("ccr sqlite mutex poisoned");
// Lazy purge sweep, then the real lookup. Both happen under
// the same mutex so the row we read is guaranteed not to have
// been just-deleted by another caller.
if let Err(err) = Self::purge_expired(&conn, now) {
tracing::warn!(
target = "ccr.sqlite",
error = %err,
"ccr_sqlite_purge_failed"
);
}
let row: Option<Vec<u8>> = conn
.query_row(
"SELECT original FROM ccr_entries
WHERE hash = ?1 AND created_at + ttl_seconds > ?2",
params![hash, now as i64],
|r| r.get::<_, Vec<u8>>(0),
)
.optional()
.unwrap_or_else(|err| {
tracing::warn!(
target = "ccr.sqlite",
hash = %hash,
error = %err,
"ccr_sqlite_get_failed"
);
None
});
row.and_then(|bytes| String::from_utf8(bytes).ok())
self.get_at(hash, Self::now_unix_seconds())
}
fn len(&self) -> usize {
@ -203,3 +277,56 @@ impl CcrStore for SqliteCcrStore {
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_with_row(
idle_ttl: u64,
max_lifetime: u64,
created_at: u64,
last_accessed: u64,
) -> (tempfile::TempDir, SqliteCcrStore, String) {
let dir = tempfile::tempdir().expect("tempdir");
let store =
SqliteCcrStore::open_with_ttls(dir.path().join("ccr.sqlite"), idle_ttl, max_lifetime)
.expect("open sqlite store");
let hash = "boundary-entry".to_string();
{
let conn = store.conn.lock().expect("ccr sqlite mutex poisoned");
conn.execute(
"INSERT INTO ccr_entries
(hash, original, created_at, ttl_seconds, last_accessed)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
&hash,
b"payload".as_slice(),
created_at as i64,
idle_ttl as i64,
last_accessed as i64,
],
)
.expect("insert boundary row");
}
(dir, store, hash)
}
#[test]
fn exact_idle_ttl_boundary_is_still_valid() {
let (_dir, store, hash) = store_with_row(5, 20, 100, 100);
assert_eq!(store.get_at(&hash, 105).as_deref(), Some("payload"));
assert_eq!(store.get_at(&hash, 111), None);
assert_eq!(store.len(), 0, "expired row must be purged");
}
#[test]
fn exact_max_lifetime_boundary_is_still_valid() {
let (_dir, store, hash) = store_with_row(5, 10, 100, 108);
assert_eq!(store.get_at(&hash, 110).as_deref(), Some("payload"));
assert_eq!(store.get_at(&hash, 111), None);
assert_eq!(store.len(), 0, "expired row must be purged");
}
}

View file

@ -59,8 +59,25 @@ pub trait CcrStore: Send + Sync {
/// Default capacity — matches Python's `CompressionStore` default.
pub const DEFAULT_CAPACITY: usize = 1000;
/// Default TTL — 5 minutes, matching Python.
pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
/// Default TTL — 30 minutes, matching Python
/// (`CCRConfig.store_ttl_seconds`). Session-scale: agentic sessions
/// routinely outlive the old 5-minute default, and an expired entry
/// silently converts "lossless with retrieval" into "lossy".
pub const DEFAULT_TTL: Duration = Duration::from_secs(1800);
/// The TTL is an **idle window**, not a wall clock: every successful
/// `get` restarts the entry's clock, so an entry a session keeps
/// touching survives a long multi-agent burst (#2604). To keep
/// constant access from pinning an entry forever, an absolute max
/// lifetime of `DEFAULT_MAX_LIFETIME_MULTIPLIER * ttl` (measured from
/// insertion) caps the sliding window. Mirrors the Python
/// `CompressionStore` semantics.
pub const DEFAULT_MAX_LIFETIME_MULTIPLIER: u32 = 8;
/// Absolute max lifetime for an entry with idle window `idle_ttl`.
pub fn max_lifetime_for(idle_ttl: Duration) -> Duration {
idle_ttl.saturating_mul(DEFAULT_MAX_LIFETIME_MULTIPLIER)
}
/// Compute the canonical CCR key for `payload`. BLAKE3 → first 24 hex
/// chars (96 bits — collision-resistant for the bounded LRU population

View file

@ -130,6 +130,16 @@ pub(crate) const MAX_LOSSY_RATIO_PAYG: f32 = 0.45;
/// Subscription: conservative cap at 25%. Cache stability over savings.
pub(crate) const MAX_LOSSY_RATIO_SUBSCRIPTION: f32 = 0.25;
/// Anthropic prompt-cache write multiplier: a `cache_creation` token
/// costs 1.25× a plain input token (5-minute TTL tier). Input to the
/// net-cost mutation formula (#856).
pub const CACHE_WRITE_MULTIPLIER: f32 = 1.25;
/// Anthropic prompt-cache read multiplier: a `cache_read` token costs
/// 0.1× a plain input token. Input to the net-cost mutation formula
/// (#856).
pub const CACHE_READ_MULTIPLIER: f32 = 0.1;
/// Per-auth-mode policy that downstream compression stages consult.
///
/// `Copy` because the struct is small POD (two `bool`s + a `u32` + an
@ -224,6 +234,94 @@ impl CompressionPolicy {
pub fn live_zone_compression_enabled(&self) -> bool {
true
}
/// Net gain (in plain-input-token cost units) of a mutation that
/// removes `delta_t` tokens from a message whose cached suffix is
/// `suffix_tokens` long (#856).
///
/// Mutating message K invalidates every cached token after it. When
/// the cache is warm the mutated ΔT tokens are themselves already
/// cache-written, so keeping them costs only reads (`ΔT · r · R`)
/// while mutating re-writes the suffix: alive-case saving is
/// `ΔT·r·R (wr)·S`. When the cache is dead there is no suffix
/// penalty and the full `ΔT·(w + r·(R1))` is saved. Taking the
/// expectation over `P_alive`:
///
/// gain = ΔT · (w + r·(R 1)) P_alive · (w r) · (S + ΔT)
///
/// Sanity anchors (Anthropic w=1.25, r=0.1), matching the unit
/// tests below: a 2K shave under a 50K warm suffix needs 287.5
/// remaining reads to pay off (rarely profitable); a 50K shave
/// under a 10K suffix breaks even at 2.3 reads (profitable in any
/// session with a few turns left); an edit with S = 0 is profitable
/// whenever at least one read remains. Callers gating not-yet-cached
/// content (live-zone edits) should bypass this formula — it prices
/// mutations of content the cache has already written.
///
/// Takes `&self` so a follow-up can apply per-mode margins; today
/// the arithmetic is mode-independent. Inputs are clamped:
/// `expected_reads` to `>= 0` (NaN → 0), `p_alive` to `[0, 1]`
/// (NaN → 1, the conservative full-penalty assumption).
pub fn net_mutation_gain(
&self,
delta_t: u32,
suffix_tokens: u32,
expected_reads: f32,
p_alive: f32,
) -> f32 {
let w = CACHE_WRITE_MULTIPLIER;
let r = CACHE_READ_MULTIPLIER;
// f32::max ignores NaN (returns the other operand), so NaN reads
// land on 0.0; clamp would propagate NaN, so guard alive explicitly.
let reads = expected_reads.max(0.0);
let alive = if p_alive.is_nan() {
1.0
} else {
p_alive.clamp(0.0, 1.0)
};
// Corrected warm-case penalty (#856 follow-up): when the cache is
// alive, the ΔT tokens are already cache-written, so keeping them
// costs only reads — a mutation can avoid at most ΔT·r·R, not a
// fresh write. Blending alive (ΔT·r·R (wr)·S) and dead
// (ΔT·(w + r·(R1))) cases over P_alive gives a penalty over
// S + ΔT, not S alone. The looser ·S form overstated gain by
// P_alive·(wr)·ΔT — always pro-mutation, largest for big shaves.
(delta_t as f32) * (w + r * (reads - 1.0))
- alive * (w - r) * ((suffix_tokens as f32) + (delta_t as f32))
}
/// Decision form of [`Self::net_mutation_gain`]: mutate iff the
/// gain is strictly positive.
pub fn should_mutate_deep(
&self,
delta_t: u32,
suffix_tokens: u32,
expected_reads: f32,
p_alive: f32,
) -> bool {
self.net_mutation_gain(delta_t, suffix_tokens, expected_reads, p_alive) > 0.0
}
/// Remaining-read count at which a warm-cache (P_alive = 1)
/// mutation breaks even. With the corrected penalty this is exactly
///
/// R = ((w r) / r) · S/ΔT = 11.5 · S/ΔT (Anthropic 5-min)
///
/// reproducing the #856 anchors precisely: 2K shave / 50K suffix →
/// 287.5 (~290 reads, rarely profitable); 50K shave / 10K suffix →
/// 2.3 (profitable in any session with a few turns left).
///
/// Useful for decision telemetry ("this edit pays off if the
/// session lasts N more turns"). Returns 0 when `delta_t` is 0
/// (no savings — callers gate on `delta_t > 0`).
pub fn break_even_reads(&self, delta_t: u32, suffix_tokens: u32) -> f32 {
if delta_t == 0 {
return 0.0;
}
let w = CACHE_WRITE_MULTIPLIER;
let r = CACHE_READ_MULTIPLIER;
((w - r) / r) * ((suffix_tokens as f32) / (delta_t as f32))
}
}
#[cfg(test)]
@ -322,4 +420,86 @@ mod tests {
);
}
}
// --- Net-cost mutation formula (#856). Scenario values are golden:
// tests/test_compression_policy.py asserts the identical numbers
// against the Python hand-mirror, so a drift in either side trips
// the parity pair loudly.
#[test]
fn net_gain_small_shave_deep_suffix_is_loss() {
// Shave 2K under a 50K warm suffix at R=10 remaining reads:
// 2000·(1.25 + 0.1·9) 1.0·1.15·52000 = 4300 59800 = 55500.
let p = CompressionPolicy::for_mode(AuthMode::Payg);
let gain = p.net_mutation_gain(2_000, 50_000, 10.0, 1.0);
assert!((gain - (-55_500.0)).abs() < 1.0, "gain = {gain}");
assert!(!p.should_mutate_deep(2_000, 50_000, 10.0, 1.0));
}
#[test]
fn net_gain_big_shave_shallow_suffix_is_win() {
// Shave 50K under a 10K warm suffix at R=3:
// 50000·(1.25 + 0.1·2) 1.0·1.15·60000 = 72500 69000 = 3500.
// Tight but positive — consistent with the 2.3-read break-even.
let p = CompressionPolicy::for_mode(AuthMode::Payg);
let gain = p.net_mutation_gain(50_000, 10_000, 3.0, 1.0);
assert!((gain - 3_500.0).abs() < 1.0, "gain = {gain}");
assert!(p.should_mutate_deep(50_000, 10_000, 3.0, 1.0));
}
#[test]
fn net_gain_no_suffix_edit_profitable_with_reads_remaining() {
// S = 0: nothing cached after the edit is invalidated. Warm-case
// saving is the avoided rereads, ΔT·r·R — positive whenever at
// least one read remains. At R=0 with a warm cache the gain is
// exactly 0 (already written, never read again): the boundary
// where mutating is pointless rather than harmful.
let p = CompressionPolicy::for_mode(AuthMode::Subscription);
assert!(p.should_mutate_deep(1, 0, 1.0, 1.0));
assert!(p.should_mutate_deep(2_000, 0, 1.0, 1.0));
let boundary = p.net_mutation_gain(2_000, 0, 0.0, 1.0);
assert!(boundary.abs() < f32::EPSILON, "boundary = {boundary}");
}
#[test]
fn net_gain_cold_cache_ignores_suffix() {
// P_alive = 0 (TTL lapsed): no warm suffix to lose, so even the
// worst shave/suffix ratio is profitable. This is the idle-timer
// compaction window.
let p = CompressionPolicy::for_mode(AuthMode::Payg);
assert!(p.should_mutate_deep(2_000, 50_000, 0.0, 0.0));
}
#[test]
fn net_gain_clamps_out_of_range_inputs() {
let p = CompressionPolicy::for_mode(AuthMode::Payg);
// Negative reads clamp to 0; p_alive > 1 clamps to 1.
let clamped = p.net_mutation_gain(2_000, 50_000, -5.0, 7.0);
let reference = p.net_mutation_gain(2_000, 50_000, 0.0, 1.0);
assert!((clamped - reference).abs() < f32::EPSILON);
}
#[test]
fn net_gain_guards_nan_inputs() {
// NaN reads → 0, NaN p_alive → 1: gain stays finite and matches
// the conservative reference instead of poisoning the decision.
let p = CompressionPolicy::for_mode(AuthMode::Payg);
let guarded = p.net_mutation_gain(2_000, 50_000, f32::NAN, f32::NAN);
assert!(guarded.is_finite());
let reference = p.net_mutation_gain(2_000, 50_000, 0.0, 1.0);
assert!((guarded - reference).abs() < f32::EPSILON);
}
#[test]
fn break_even_reads_matches_research_anchor() {
// R = 11.5·S/ΔT, the #856 anchors exactly: 2K shave / 50K
// suffix → 11.5·25 = 287.5 (rarely profitable); 50K shave /
// 10K suffix → 11.5·0.2 = 2.3 (profitable within a few turns).
let p = CompressionPolicy::for_mode(AuthMode::Payg);
let r = p.break_even_reads(2_000, 50_000);
assert!((r - 287.5).abs() < 0.5, "break-even = {r}");
let shallow = p.break_even_reads(50_000, 10_000);
assert!((shallow - 2.3).abs() < 0.05, "break-even = {shallow}");
assert_eq!(p.break_even_reads(0, 10_000), 0.0);
}
}

View file

@ -4,6 +4,8 @@ pub mod auth_mode;
pub mod cache_control;
pub mod ccr;
pub mod compression_policy;
#[cfg(feature = "ml")]
mod onnx_cpu;
pub mod relevance;
pub mod signals;
pub mod tokenizer;

View file

@ -0,0 +1,29 @@
//! Shared CPU-capability guard for the precompiled ONNX Runtime binary.
//!
//! Both ONNX entry points in this crate — Magika content detection
//! ([`crate::transforms::magika_detector`]) and the embedding relevance
//! scorer ([`crate::relevance::EmbeddingScorer`]) — link the same
//! precompiled ONNX Runtime shipped by `ort-sys` (pulled in via
//! fastembed's `ort-download-binaries*` feature).
//!
//! On x86/x86_64 that binary contains AVX2-family instructions. Executing
//! it on a CPU without AVX2 (common inside Docker / QEMU / older cloud VMs)
//! traps with `SIGILL` — a hardware fault that native code cannot turn into
//! a catchable exception, so the whole host process dies (issue #1723).
//!
//! Call this up front and skip the ONNX path when it returns `false`, so
//! callers fall back to non-ONNX behavior instead of crashing.
/// `true` if this CPU can run the precompiled ONNX Runtime binary.
///
/// On x86/x86_64 this requires AVX2. On non-x86 targets the AVX2 gate does
/// not apply and this always returns `true`.
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
pub(crate) fn onnx_runtime_supported_by_cpu() -> bool {
std::is_x86_feature_detected!("avx2")
}
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
pub(crate) fn onnx_runtime_supported_by_cpu() -> bool {
true
}

View file

@ -26,8 +26,10 @@
//! kernels, same weights — embeddings agree to floating-point
//! representation. Cosine similarity agrees to ~1e-6.
#[cfg(feature = "ml")]
use std::sync::Mutex;
#[cfg(feature = "ml")]
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
use super::base::{RelevanceScore, RelevanceScorer};
@ -39,6 +41,7 @@ use super::base::{RelevanceScore, RelevanceScorer};
/// for backwards compatibility but `is_available()` returns `false`
/// when the inner model failed to load (mimicking Python's
/// "sentence-transformers not installed" branch).
#[cfg(feature = "ml")]
pub struct EmbeddingScorer {
pub model_name: String,
/// `None` when model load failed — `is_available()` returns false
@ -55,6 +58,7 @@ pub struct EmbeddingScorer {
model: Option<Mutex<TextEmbedding>>,
}
#[cfg(feature = "ml")]
impl Default for EmbeddingScorer {
/// Returns an unloaded scorer (model = None, is_available = false).
///
@ -75,6 +79,7 @@ impl Default for EmbeddingScorer {
}
}
#[cfg(feature = "ml")]
impl EmbeddingScorer {
/// Construct the scorer with the default model
/// (BAAI/bge-small-en-v1.5). May trigger a one-time HF Hub
@ -93,6 +98,21 @@ impl EmbeddingScorer {
/// quality/speed tradeoff for compression-relevance scoring on
/// short snippets.
pub fn try_new_with_model(model_kind: EmbeddingModel) -> Result<Self, String> {
// fastembed links the precompiled ONNX Runtime binary, which contains
// AVX2 instructions on x86. Loading/running it on a non-AVX2 CPU traps
// with SIGILL (issue #1723) — an uncatchable native fault. Bail early so
// callers fall back to the BM25/stub path instead of killing the process.
if !crate::onnx_cpu::onnx_runtime_supported_by_cpu() {
return Err("EmbeddingScorer: ONNX Runtime backend requires AVX2 on \
this x86 CPU; embedding relevance disabled (falling back to BM25)"
.to_string());
}
// The crate loads ONNX Runtime dynamically (`ort-load-dynamic`);
// resolve and commit the dylib before fastembed touches ort — a
// failed in-ort load deadlocks instead of erroring (see
// `dynamic_ort_loader_ready`).
crate::transforms::magika_detector::dynamic_ort_loader_ready()
.map_err(|e| format!("EmbeddingScorer: ONNX Runtime unavailable: {e}"))?;
let name = format!("{:?}", model_kind);
let model = TextEmbedding::try_new(InitOptions::new(model_kind))
.map_err(|e| format!("EmbeddingScorer model load failed: {}", e))?;
@ -103,6 +123,7 @@ impl EmbeddingScorer {
}
}
#[cfg(feature = "ml")]
impl RelevanceScorer for EmbeddingScorer {
fn score(&self, item: &str, context: &str) -> RelevanceScore {
if item.is_empty() || context.is_empty() {
@ -192,8 +213,66 @@ impl RelevanceScorer for EmbeddingScorer {
}
}
/// Lexical-only build stub.
///
/// Without the `ml` feature the fastembed/ONNX backend is compiled out
/// entirely. `EmbeddingScorer` still exists so `HybridScorer` and
/// `create_scorer` compile unchanged, but it carries no model and is
/// permanently unavailable: `is_available()` is always `false` and the
/// scoring methods return the same empty scores the ml build produces
/// when its model failed to load. `HybridScorer` therefore takes its
/// BM25 fallback path exactly as it does when embeddings are stubbed.
#[cfg(not(feature = "ml"))]
pub struct EmbeddingScorer {
pub model_name: String,
}
#[cfg(not(feature = "ml"))]
impl Default for EmbeddingScorer {
fn default() -> Self {
EmbeddingScorer {
model_name: "BAAI/bge-small-en-v1.5".to_string(),
}
}
}
#[cfg(not(feature = "ml"))]
impl RelevanceScorer for EmbeddingScorer {
fn score(&self, item: &str, context: &str) -> RelevanceScore {
if item.is_empty() || context.is_empty() {
return RelevanceScore::empty("Embedding: empty input");
}
RelevanceScore::empty("Embedding: model not available")
}
fn score_batch(&self, items: &[&str], context: &str) -> Vec<RelevanceScore> {
if items.is_empty() {
return Vec::new();
}
if context.is_empty() {
return items
.iter()
.map(|_| RelevanceScore::empty("Embedding: empty context"))
.collect();
}
items
.iter()
.map(|_| RelevanceScore::empty("Embedding: model not available"))
.collect()
}
fn is_available(&self) -> bool {
false
}
}
/// Cosine similarity for two vectors. Clamped to `[0, 1]` since we
/// only care about positive similarity (mirrors Python `_cosine_similarity`).
///
/// Only the `ml` build calls this at runtime (from the fastembed-backed
/// scorer); the lexical-only build keeps it solely for the unit tests
/// that pin its numeric behavior.
#[cfg(any(feature = "ml", test))]
fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
if a.is_empty() || b.is_empty() || a.len() != b.len() {
return 0.0;
@ -224,12 +303,14 @@ mod tests {
// download). Without the env var, only the offline-safe stub
// path is exercised.
#[cfg(feature = "ml")]
fn fastembed_enabled() -> bool {
std::env::var("RUN_FASTEMBED_TESTS").is_ok()
}
/// Construct a stub scorer with `model = None` for offline-safe
/// tests of the unavailable-path behavior.
#[cfg(feature = "ml")]
fn unavailable_scorer() -> EmbeddingScorer {
EmbeddingScorer {
model_name: "test".to_string(),
@ -237,6 +318,13 @@ mod tests {
}
}
/// In the lexical-only build the scorer is always unavailable, so
/// `default()` already gives the stub we want to exercise.
#[cfg(not(feature = "ml"))]
fn unavailable_scorer() -> EmbeddingScorer {
EmbeddingScorer::default()
}
#[test]
fn cosine_similarity_orthogonal_vectors() {
let a = vec![1.0_f32, 0.0, 0.0, 0.0];
@ -310,8 +398,36 @@ mod tests {
assert!(r.is_empty());
}
// ---------- AVX2 CPU guard (issue #1723) ----------
#[cfg(feature = "ml")]
#[test]
fn onnx_guard_matches_cpu_features() {
let supported = crate::onnx_cpu::onnx_runtime_supported_by_cpu();
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
assert_eq!(supported, std::is_x86_feature_detected!("avx2"));
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
assert!(supported);
}
#[cfg(feature = "ml")]
#[test]
fn try_new_errors_on_unsupported_cpu_instead_of_sigill() {
// On a no-AVX2 host the guard must turn the SIGILL into a plain Err
// so callers fall back to BM25. On AVX2 CI runners the guard passes and
// there is nothing to assert (loading the model would need network).
if crate::onnx_cpu::onnx_runtime_supported_by_cpu() {
return;
}
match EmbeddingScorer::try_new() {
Err(err) => assert!(err.contains("AVX2"), "unexpected error: {err}"),
Ok(_) => panic!("ONNX backend must not load on a no-AVX2 CPU"),
}
}
// ---------- model-backed tests (gated on RUN_FASTEMBED_TESTS) ----------
#[cfg(feature = "ml")]
#[test]
fn fastembed_loads_default_model() {
if !fastembed_enabled() {
@ -322,6 +438,7 @@ mod tests {
assert_eq!(s.model_name, "BGESmallENV15");
}
#[cfg(feature = "ml")]
#[test]
fn fastembed_semantic_match_outranks_unrelated() {
if !fastembed_enabled() {
@ -338,6 +455,7 @@ mod tests {
);
}
#[cfg(feature = "ml")]
#[test]
fn fastembed_batch_returns_one_score_per_item() {
if !fastembed_enabled() {

View file

@ -148,12 +148,23 @@ pub fn find_knee(curve: &[usize]) -> Option<usize> {
knee_idx.map(|i| i + 1)
}
/// True for CJK ideographs, kana, and Hangul. Code-point ranges kept
/// byte-identical with the Python `_is_cjk_char` for adaptive-sizer parity.
fn is_cjk_char(c: char) -> bool {
matches!(
c as u32,
0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF
)
}
/// Cumulative unique-bigram coverage curve.
///
/// Direct port of `compute_unique_bigram_curve` (Python
/// `adaptive_sizer.py:157-182`). Each item contributes its word-level
/// bigrams; single-word items contribute `(word, "")`. The curve at
/// index `k` is the running count of unique bigrams after seeing
/// bigrams; single-word items contribute `(word, "")`. A spaceless CJK item
/// (no whitespace to split on) uses character bigrams instead, so CJK lists
/// produce a real coverage curve rather than one pseudo-bigram per item. The
/// curve at index `k` is the running count of unique bigrams after seeing
/// `items[0..=k]`.
pub fn compute_unique_bigram_curve(items: &[&str]) -> Vec<usize> {
let mut seen: HashSet<(String, String)> = HashSet::new();
@ -162,14 +173,23 @@ pub fn compute_unique_bigram_curve(items: &[&str]) -> Vec<usize> {
for item in items {
let lower = item.to_lowercase();
let words: Vec<&str> = lower.split_whitespace().collect();
if words.len() < 2 {
// Single word or empty: synthesize a unigram-bigram.
let first = words.first().copied().unwrap_or("");
seen.insert((first.to_string(), String::new()));
} else {
if words.len() >= 2 {
for j in 0..words.len() - 1 {
seen.insert((words[j].to_string(), words[j + 1].to_string()));
}
} else if let Some(w) = words.first() {
let chars: Vec<char> = w.chars().collect();
if chars.len() >= 2 && chars.iter().any(|&c| is_cjk_char(c)) {
// Spaceless CJK item: synthesize character bigrams.
for j in 0..chars.len() - 1 {
seen.insert((chars[j].to_string(), chars[j + 1].to_string()));
}
} else {
seen.insert((w.to_string(), String::new()));
}
} else {
// Empty item.
seen.insert((String::new(), String::new()));
}
curve.push(seen.len());
}
@ -469,6 +489,22 @@ mod tests {
assert_eq!(compute_unique_bigram_curve(&items), vec![1, 1]);
}
#[test]
fn bigram_curve_cjk_uses_char_bigrams() {
// Spaceless CJK: char bigrams give a real coverage curve (was 1 per item).
// "数据库连接失败" -> 数据,据库,库连,连接,接失,失败 = 6
// "数据库连接成功" -> shares 4, adds 接成,成功 -> 6+2 = 8
let items = ["数据库连接失败", "数据库连接成功"];
assert_eq!(compute_unique_bigram_curve(&items), vec![6, 8]);
}
#[test]
fn bigram_curve_cjk_single_char_is_unigram() {
// a 1-char CJK item has no bigram -> (char, "")
let items = ["", ""];
assert_eq!(compute_unique_bigram_curve(&items), vec![1, 2]);
}
// ---------- find_knee ----------
#[test]

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,14 @@
//! issue. Loud-on-error stays at the [`magika_detect`] entry point for
//! callers who care; the chain swallows the err with a log line.
//!
//! # CPU compatibility
//!
//! Precompiled ONNX Runtime binaries from `ort-sys` may contain AVX2-family
//! instructions. On x86/x86_64 CPUs where AVX2 is unavailable, the magika
//! session init returns an init error before touching ONNX. The chain
//! handles this identically to any other tier-1 error — logs it and falls
//! through to Tier 2 / Tier 3.
//!
//! # SearchResults / BuildOutput
//!
//! The retired regex detector recognized grep-style search output
@ -39,6 +47,7 @@
//! for those specifically; not preemptively.
use crate::transforms::content_detector::ContentType;
#[cfg(feature = "ml")]
use crate::transforms::magika_detector::magika_detect;
use crate::transforms::unidiff_detector::is_diff;
@ -53,6 +62,10 @@ pub fn detect(content: &str) -> ContentType {
}
// ── Tier 1: Magika ──────────────────────────────────────────
// Only present in the `ml` build. Without the ML crates the magika
// detector is compiled out; the chain skips Tier 1 and begins at
// Tier 2, exactly as it would when magika returns PlainText.
#[cfg(feature = "ml")]
match magika_detect(content) {
Ok(ContentType::PlainText) => {
// Magika says "I don't know" or "plain text". Continue
@ -84,6 +97,22 @@ pub fn detect(content: &str) -> ContentType {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "ml")]
use crate::transforms::magika_detector::magika_runtime_available_for_session_init;
#[cfg(feature = "ml")]
fn magika_available() -> bool {
magika_runtime_available_for_session_init().is_ok()
}
// Without the ML crates there is no magika session at all, so the
// detection chain always starts at Tier 2. Report "unavailable" so
// the shared assertions below exercise the same fallthrough path
// they use on a host where magika can't initialize.
#[cfg(not(feature = "ml"))]
fn magika_available() -> bool {
false
}
#[test]
fn empty_input_short_circuits_to_plain_text() {
@ -93,19 +122,31 @@ mod tests {
#[test]
fn json_array_routes_via_tier_1() {
let payload = r#"[{"id": 1}, {"id": 2}, {"id": 3}]"#;
assert_eq!(detect(payload), ContentType::JsonArray);
if magika_available() {
assert_eq!(detect(payload), ContentType::JsonArray);
} else {
assert_eq!(detect(payload), ContentType::PlainText);
}
}
#[test]
fn source_code_routes_via_tier_1() {
let py = "def hello():\n print('world')\n\nclass Foo:\n pass\n";
assert_eq!(detect(py), ContentType::SourceCode);
if magika_available() {
assert_eq!(detect(py), ContentType::SourceCode);
} else {
assert_eq!(detect(py), ContentType::PlainText);
}
}
#[test]
fn html_routes_via_tier_1() {
let html = "<!DOCTYPE html><html><body><h1>x</h1></body></html>";
assert_eq!(detect(html), ContentType::Html);
if magika_available() {
assert_eq!(detect(html), ContentType::Html);
} else {
assert_eq!(detect(html), ContentType::PlainText);
}
}
#[test]
@ -118,7 +159,8 @@ mod tests {
+ print(\"new\")\n";
// Either magika tags it `diff` (Tier 1 hit) or magika
// mis-classifies as text and unidiff catches it (Tier 2).
// Both paths produce GitDiff.
// On no-AVX2 hosts, magika is unavailable so Tier 2 still
// catches the diff. Both paths produce GitDiff.
assert_eq!(detect(diff), ContentType::GitDiff);
}
@ -127,7 +169,7 @@ mod tests {
// Magika often mis-classifies naked hunks (no `diff --git`
// wrapper) because the visible bytes look like ordinary
// patch lines mixed with code. Tier 2 (unidiff parser)
// catches these.
// catches these — even when magika is unavailable.
let diff = "--- a/foo.py\n\
+++ b/foo.py\n\
@@ -1,2 +1,2 @@\n\
@ -195,7 +237,11 @@ mod tests {
// YAML lives in magika's `code` group; the chain returns it
// as SourceCode so the router picks the code-aware compressor.
let yaml = "name: my-app\nversion: 1.0\ndependencies:\n - foo\n";
assert_eq!(detect(yaml), ContentType::SourceCode);
if magika_available() {
assert_eq!(detect(yaml), ContentType::SourceCode);
} else {
assert_eq!(detect(yaml), ContentType::PlainText);
}
}
#[test]
@ -205,13 +251,19 @@ mod tests {
impl Counter {\n \
pub fn new() -> Self { Self { counts: HashMap::new() } }\n\
}\n";
assert_eq!(detect(rs), ContentType::SourceCode);
if magika_available() {
assert_eq!(detect(rs), ContentType::SourceCode);
} else {
assert_eq!(detect(rs), ContentType::PlainText);
}
}
#[test]
fn chain_is_deterministic_across_repeated_calls() {
// Magika returns the same label for identical input on
// repeated calls; the chain wraps that determinism.
// On no-AVX2 hosts, the chain always falls through to
// PlainText — which is equally deterministic.
let payload = r#"{"users": [{"id": 1}, {"id": 2}]}"#;
let a = detect(payload);
let b = detect(payload);

View file

@ -35,7 +35,7 @@
//! method returns it alongside the parity-equal result; `compress` is the
//! parity-only API that just emits a `tracing::info_span`.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
use std::time::Instant;
@ -840,9 +840,42 @@ fn priority_patterns() -> &'static [Regex] {
})
}
/// True for CJK ideographs, kana, and Hangul.
fn is_cjk_char(c: char) -> bool {
matches!(
c as u32,
0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF
)
}
/// CJK character bigrams from the CJK runs of a (lowercased) query. A spaceless
/// CJK query rarely appears verbatim in a hunk, but its bigrams do, so these let
/// a CJK query still boost the hunks it partially overlaps.
fn cjk_bigrams(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut run: Vec<char> = Vec::new();
for c in text.chars() {
if is_cjk_char(c) {
run.push(c);
} else {
for w in run.windows(2) {
out.insert(w.iter().collect::<String>());
}
run.clear();
}
}
for w in run.windows(2) {
out.insert(w.iter().collect::<String>());
}
out
}
fn score_hunks(files: &mut [DiffFile], context: &str) {
let context_lower = context.to_lowercase();
let context_words: Vec<&str> = context_lower.split_whitespace().collect();
// Spaceless CJK queries don't survive whitespace-splitting into matchable
// words; add CJK character bigrams so they can still boost overlapping hunks.
let cjk_bg = cjk_bigrams(&context_lower);
for file in files.iter_mut() {
for hunk in file.hunks.iter_mut() {
@ -860,6 +893,11 @@ fn score_hunks(files: &mut [DiffFile], context: &str) {
score += SCORE_CONTEXT_WORD_WEIGHT;
}
}
for bg in &cjk_bg {
if hunk_content_lower.contains(bg.as_str()) {
score += SCORE_CONTEXT_WORD_WEIGHT;
}
}
for pat in priority_patterns() {
if pat.is_match(&hunk_content_lower) {
@ -1518,6 +1556,58 @@ mod tests {
);
}
#[test]
fn cjk_bigrams_from_query_runs() {
let b = cjk_bigrams("数据库连接");
assert!(
b.contains("数据") && b.contains("据库") && b.contains("库连") && b.contains("连接")
);
assert_eq!(b.len(), 4);
assert!(cjk_bigrams("hello world").is_empty()); // ASCII -> no CJK bigrams
assert!(cjk_bigrams("a数b据").is_empty()); // isolated CJK chars -> no pair
}
#[test]
fn cjk_query_boosts_matching_hunk_into_kept_set() {
// Two competing middle hunks, one middle slot. The plain hunk has the
// higher change-density base score, so WITHOUT a query it takes the
// slot; a CJK query overlapping the CJK hunk boosts it past the plain one.
let input = "diff --git a/svc.py b/svc.py\n\
--- a/svc.py\n\
+++ b/svc.py\n\
@@ -1,2 +1,2 @@\n\
-first_old\n\
+first_new\n\
@@ -10,4 +10,4 @@\n\
-plain_a\n\
+plain_b\n\
-plain_c\n\
+plain_d\n\
@@ -20,2 +20,2 @@\n\
-\n\
+\n\
@@ -30,2 +30,2 @@\n\
-last_old\n\
+last_new\n";
let mk = || DiffCompressorConfig {
max_hunks_per_file: 3,
min_lines_for_ccr: 5,
..Default::default()
};
let with_cjk = DiffCompressor::new(mk()).compress(input, "数据库连接超时排查");
let no_query = DiffCompressor::new(mk()).compress(input, "");
assert!(
with_cjk.compressed.contains("数据库连接失败重试"),
"CJK query should boost the overlapping hunk into the kept set:\n{}",
with_cjk.compressed
);
assert!(
!no_query.compressed.contains("数据库连接失败重试"),
"without a query the higher-density plain hunk should take the middle slot:\n{}",
no_query.compressed
);
}
/// Bug-fix test: combined-diff (`@@@`) hunk content must NOT be
/// silently dropped. Before the fix the hunk-header regex only
/// matched `@@`, so 3-way merge hunks had `current_hunk` never set

View file

@ -0,0 +1,672 @@
//! Kompress — Rust port of `headroom.transforms.kompress_compressor`.
//!
//! A ModernBERT token compressor for prose / plain-text tool outputs.
//! Where SmartCrusher/Log/Search/Diff are deterministic structural
//! compressors, Kompress is an **ML** compressor: it runs the trained
//! `chopratejas/kompress-v2-base` model (a fine-tune of
//! `answerdotai/ModernBERT-base` with a token keep/discard head + a
//! span-importance CNN head, exported to ONNX) and keeps only the words
//! the model scores as salient.
//!
//! # Model layering
//!
//! - **Inference weights:** `chopratejas/kompress-v2-base` — the ONNX
//! artifact (`onnx/kompress-int8-wo.onnx`, weight-only int8 via the
//! `com.microsoft` `MatMulNBits` contrib op; falls through to
//! `onnx/kompress-fp32.onnx` then `onnx/kompress-int8.onnx`). This is
//! *the model behind text compression*.
//! - **Tokenizer:** `answerdotai/ModernBERT-base`'s `tokenizer.json`.
//! Kompress is a fine-tune of ModernBERT and reuses its exact vocab,
//! so the kompress repo ships no tokenizer of its own.
//!
//! # ONNX contract
//!
//! Inputs `input_ids` + `attention_mask` (both `int64`, shape
//! `[batch, seq]`); output `final_scores` (`f32`, shape `[batch, seq]`)
//! — per-token salience in `[0, 1]` with the dual-head logic baked into
//! the graph. Keep decision is `score > 0.5`.
//!
//! # Compression path (mirrors the Python ONNX/proxy path exactly)
//!
//! 1. `words = content.split_whitespace()`. If `< 10` words → passthrough.
//! 2. For each `chunk_words`-sized (default 350) window of words:
//! tokenize with the word list as **pre-tokenized** input
//! (`is_split_into_words=True` in `transformers`), truncating to 512
//! tokens; recover `input_ids` / `attention_mask` / `word_ids`.
//! 3. Run ONNX → `final_scores`. Reduce to **max score per word**.
//! 4. Keep word `w` (global index `w + chunk_start`) when its max score
//! exceeds the threshold (default 0.5), or, when `target_ratio` is
//! set, when it is in the top-`ratio` fraction by score.
//! 5. Emit the kept words, in original order, joined by single spaces.
//!
//! # Parity
//!
//! Byte-exact against the Python reference on the ONNX path: tokenizer
//! `input_ids`/`word_ids` reproduce `transformers` exactly, ONNX scores
//! match to ~1e-6 (far below the 0.5 threshold), and the kept-word set +
//! joined output match byte-for-byte. See
//! `tests/parity/fixtures/kompress/` and `KompressComparator` in
//! `crates/headroom-parity`.
//!
//! # CCR
//!
//! This engine returns the compressed string only. CCR offload of the
//! dropped words (so the model can retrieve the original on demand) is
//! handled by the live-zone dispatcher via [`crate::ccr::CcrStore`],
//! exactly as for the Search/Log/Diff compressors — not inside this
//! engine. The Python reference's inline `[N items compressed... hash=]`
//! marker is intentionally **not** reproduced; the Rust side uses the
//! canonical `<<ccr:HASH>>` marker convention.
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use ort::session::Session;
use ort::value::Tensor;
use thiserror::Error;
use tokenizers::tokenizer::TruncationParams;
use tokenizers::{EncodeInput, InputSequence, Tokenizer};
// ─── Tunable defaults (parity-pinned to kompress-v2-base) ───────────────
/// HuggingFace repo holding the trained ONNX weights.
pub const DEFAULT_MODEL_ID: &str = "chopratejas/kompress-v2-base";
/// HuggingFace repo holding the tokenizer Kompress reuses.
pub const DEFAULT_TOKENIZER_REPO: &str = "answerdotai/ModernBERT-base";
/// Words per inference chunk. Coupled to the model's training window;
/// kompress-v2-base was trained for 350.
pub const DEFAULT_CHUNK_WORDS: usize = 350;
/// Keep a word when its max per-token score exceeds this. Matches the
/// ONNX `get_keep_mask` hard-coded `> 0.5`.
pub const DEFAULT_SCORE_THRESHOLD: f32 = 0.5;
/// Inputs shorter than this many words pass through untouched — too
/// little signal for the model and the per-call cost dominates.
pub const MIN_WORDS: usize = 10;
/// Max ModernBERT sequence length per chunk (truncation bound).
pub const MAX_SEQ_LEN: usize = 512;
/// ONNX artifact candidates, tried in order. The first is a fp32 model whose
/// input shape is frozen to a static `[1, MAX_SEQ_LEN]` — required by the
/// OpenVINO **NPU** EP, which cannot compile dynamic `seq` (it hangs during
/// graph compilation on the dynamic-shape variants). When a static model is
/// loaded, `score_chunk` right-pads each chunk to its fixed length (detected
/// via [`detect_static_seq`]); dynamic models take the chunk's natural length
/// and pay no padding cost. The static model is absent from a vanilla install
/// (it is generated separately for NPU deployments), so this entry is simply
/// skipped on CPU/GPU. The remaining variants are the dynamic fall-throughs:
/// weight-only int8 (smallest; `MatMulNBits`, unsupported on NPU), then dynamic
/// fp32 (lossless reference), then the v1-era dynamic int8. A candidate is
/// skipped on download miss or on session-load failure.
pub const ONNX_CANDIDATES: &[&str] = &[
"onnx/kompress-fp32-static512.onnx",
"onnx/kompress-int8-wo.onnx",
"onnx/kompress-fp32.onnx",
"onnx/kompress-int8.onnx",
];
// ─── Types ──────────────────────────────────────────────────────────────
/// Configuration for [`Kompress`]. Field defaults match kompress-v2-base;
/// domain-specific models override `model_id` + `chunk_words` together.
#[derive(Debug, Clone)]
pub struct KompressConfig {
pub model_id: String,
pub tokenizer_repo: String,
pub chunk_words: usize,
pub score_threshold: f32,
pub min_words: usize,
}
impl Default for KompressConfig {
fn default() -> Self {
Self {
model_id: DEFAULT_MODEL_ID.to_string(),
tokenizer_repo: DEFAULT_TOKENIZER_REPO.to_string(),
chunk_words: DEFAULT_CHUNK_WORDS,
score_threshold: DEFAULT_SCORE_THRESHOLD,
min_words: MIN_WORDS,
}
}
}
/// Result of a Kompress compression. Mirrors the Python `KompressResult`
/// fields that the proxy path populates (CCR `cache_key` is owned by the
/// dispatcher, not this engine).
#[derive(Debug, Clone, PartialEq)]
pub struct KompressResult {
pub compressed: String,
pub original: String,
/// Whitespace-split word count of the input.
pub original_tokens: usize,
/// Word count of the output.
pub compressed_tokens: usize,
/// `compressed_tokens / original_tokens`, computed in f64 to match the
/// Python reference's `float` division bit-for-bit.
pub compression_ratio: f64,
pub model_used: String,
}
impl KompressResult {
/// Words dropped (never negative).
pub fn tokens_saved(&self) -> usize {
self.original_tokens.saturating_sub(self.compressed_tokens)
}
/// True when nothing was compressed (output == input word stream).
pub fn is_passthrough(&self) -> bool {
self.compressed_tokens == self.original_tokens
}
}
#[derive(Debug, Error)]
pub enum KompressError {
#[error("failed to load tokenizer for `{repo}`: {source}")]
Tokenizer {
repo: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("failed to download `{repo}` from HuggingFace Hub: {source}")]
Hub {
repo: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("no loadable ONNX artifact in `{model_id}` (tried {tried:?}): {source}")]
Onnx {
model_id: String,
tried: Vec<String>,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
// ─── Compressor ─────────────────────────────────────────────────────────
/// A loaded Kompress model + tokenizer. Construct once (model load is
/// expensive) and share; `compress` takes `&self`.
///
/// ONNX inference is serialized behind a `Mutex` — matching the Python
/// reference, which caps ONNX execution to one concurrent call (the CPU
/// provider does not parallelize the batch dimension for this model).
pub struct Kompress {
config: KompressConfig,
tokenizer: Tokenizer,
session: Mutex<Session>,
/// `Some(n)` when the loaded ONNX has a **fixed** sequence dimension (a
/// static `[1, n]` input), in which case `score_chunk` right-pads every
/// chunk to `n`. `None` for the usual dynamic-`seq` models, which take the
/// chunk's natural length. Detected from the session's `input_ids` shape
/// (see [`detect_static_seq`]). The static path exists for execution
/// providers that cannot compile dynamic shapes (OpenVINO NPU); masked
/// padding leaves the real-token scores unchanged, so output is identical.
static_seq: Option<usize>,
}
impl std::fmt::Debug for Kompress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Kompress")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
/// Inspect a built session's `input_ids` input: return `Some(n)` if its
/// sequence dimension is a fixed `n > 0` (a static-shape model), else `None`
/// (dynamic `seq`). ONNX inputs are `[batch, seq]`; a dynamic dim is reported
/// as `-1` by ONNX Runtime.
fn detect_static_seq(session: &Session) -> Option<usize> {
let outlet = session.inputs().iter().find(|o| o.name() == "input_ids")?;
let seq = *outlet.dtype().tensor_shape()?.get(1)?;
(seq > 0).then_some(seq as usize)
}
impl Kompress {
/// Wrap built artifacts into a `Kompress`, detecting whether the loaded
/// model has a static sequence length (so `score_chunk` knows to pad).
fn assemble(config: KompressConfig, tokenizer: Tokenizer, session: Session) -> Self {
let static_seq = detect_static_seq(&session);
Self {
config,
tokenizer,
session: Mutex::new(session),
static_seq,
}
}
/// Build from local artifact paths — no network. Used by tests and
/// the parity harness against the on-disk HuggingFace cache.
pub fn from_files(
tokenizer_path: impl AsRef<Path>,
onnx_path: impl AsRef<Path>,
config: KompressConfig,
) -> Result<Self, KompressError> {
let tokenizer = load_tokenizer(tokenizer_path.as_ref(), &config.tokenizer_repo)?;
let session = build_session(onnx_path.as_ref()).map_err(|e| KompressError::Onnx {
model_id: config.model_id.clone(),
tried: vec![onnx_path.as_ref().display().to_string()],
source: e,
})?;
Ok(Self::assemble(config, tokenizer, session))
}
/// Build by resolving artifacts from the HuggingFace Hub (cache-first,
/// downloading on miss). Blocking — call off the hot path. Tries the
/// [`ONNX_CANDIDATES`] in order.
pub fn from_pretrained(config: KompressConfig) -> Result<Self, KompressError> {
let api = hf_hub::api::sync::Api::new().map_err(|e| KompressError::Hub {
repo: config.model_id.clone(),
source: Box::new(e),
})?;
let tok_path = api
.model(config.tokenizer_repo.clone())
.get("tokenizer.json")
.map_err(|e| KompressError::Hub {
repo: config.tokenizer_repo.clone(),
source: Box::new(e),
})?;
let tokenizer = load_tokenizer(&tok_path, &config.tokenizer_repo)?;
let model_api = api.model(config.model_id.clone());
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
let mut tried: Vec<String> = Vec::new();
for candidate in ONNX_CANDIDATES {
tried.push((*candidate).to_string());
let onnx_path: PathBuf = match model_api.get(candidate) {
Ok(p) => p,
Err(e) => {
last_err = Some(Box::new(e));
continue;
}
};
match build_session(&onnx_path) {
Ok(session) => {
return Ok(Self::assemble(config, tokenizer, session));
}
Err(e) => {
last_err = Some(e);
continue;
}
}
}
Err(KompressError::Onnx {
model_id: config.model_id.clone(),
tried,
source: last_err.unwrap_or_else(|| "no ONNX candidates configured".to_string().into()),
})
}
/// Cache-only construction: resolve the tokenizer + ONNX artifact from
/// the local HuggingFace cache **without ever hitting the network**, and
/// return `Ok(None)` when they are not present (or no candidate loads).
///
/// This is the Rust mirror of the Python reference's
/// `allow_download=False` path (`KompressModelNotCached` → defer): it lets
/// the live-zone dispatcher attempt a load on a hot path without risking a
/// blocking 261 MB download. When the model isn't cached the caller passes
/// plain text through untouched, exactly as Python does when Kompress is
/// unavailable.
pub fn from_cache(config: KompressConfig) -> Result<Option<Self>, KompressError> {
let Some(tok_path) = hf_cache_file(&config.tokenizer_repo, &["tokenizer.json"]) else {
// Diagnostic: a `None` here is the #1 cause of a silent
// `kompress_ready=false`. Name the repo + the roots searched so
// operators don't have to guess between "not downloaded" and
// "present but unreadable" (e.g. HF symlinks over `\\wsl$`, which
// native Windows can't follow — `path.exists()` returns false on
// the unresolved symlink). Cache-only, so this is a defer, not an
// error: the caller passes plain text through.
tracing::warn!(
event = "kompress_cache_miss",
stage = "tokenizer",
tokenizer_repo = %config.tokenizer_repo,
searched_roots = ?hf_hub_roots(),
"Kompress deferred: tokenizer.json not found in HF cache \
(not downloaded, or present but unreadable e.g. HF symlinks \
over \\\\wsl$ which native Windows cannot follow)"
);
return Ok(None);
};
let tokenizer = load_tokenizer(&tok_path, &config.tokenizer_repo)?;
let mut found_onnx = false;
for candidate in ONNX_CANDIDATES {
let rel: Vec<&str> = candidate.split('/').collect();
let Some(onnx_path) = hf_cache_file(&config.model_id, &rel) else {
continue;
};
found_onnx = true;
match build_session(&onnx_path) {
Ok(session) => {
return Ok(Some(Self::assemble(config, tokenizer, session)));
}
Err(e) => {
// The ONNX file is present but the session would not
// build — e.g. the active ORT execution provider rejects
// the graph (OpenVINO/NPU cannot compile the int8
// weight-only `MatMulNBits` op). Loudly surface it and try
// the next candidate (fp32) rather than die silently.
tracing::warn!(
event = "kompress_session_build_failed",
candidate = %candidate,
onnx_path = %onnx_path.display(),
error = %e,
"Kompress: ONNX found but session build failed; trying next candidate"
);
}
}
}
tracing::warn!(
event = "kompress_cache_miss",
stage = "onnx",
model_id = %config.model_id,
candidates = ?ONNX_CANDIDATES,
any_onnx_found = found_onnx,
searched_roots = ?hf_hub_roots(),
"Kompress deferred: no usable ONNX session \
(no candidate file in cache, or every candidate failed to build)"
);
Ok(None)
}
/// Model-decides compression (the proxy path): keep words scoring
/// above `config.score_threshold`.
pub fn compress(&self, content: &str) -> KompressResult {
self.compress_inner(content, None)
}
/// Forced-ratio compression: keep the top `target_ratio` fraction of
/// words by score (at least one). `None` defers to the threshold path.
/// The proxy never sets this — only the user-facing API does.
pub fn compress_with_ratio(&self, content: &str, target_ratio: Option<f64>) -> KompressResult {
self.compress_inner(content, target_ratio)
}
fn compress_inner(&self, content: &str, target_ratio: Option<f64>) -> KompressResult {
let words: Vec<&str> = content.split_whitespace().collect();
let n_words = words.len();
if n_words < self.config.min_words {
return self.passthrough(content, n_words);
}
let mut kept_ids: BTreeSet<usize> = BTreeSet::new();
let mut chunk_start = 0usize;
while chunk_start < n_words {
let end = (chunk_start + self.config.chunk_words).min(n_words);
match self.score_chunk(&words[chunk_start..end]) {
Ok(word_scores) => {
self.select_words(&word_scores, chunk_start, target_ratio, &mut kept_ids);
}
Err(_) => {
// A chunk that fails inference is treated as
// "nothing salient here" — matches the Python
// reference's per-call passthrough-on-error.
return self.passthrough(content, n_words);
}
}
chunk_start += self.config.chunk_words;
}
if kept_ids.is_empty() {
return self.passthrough(content, n_words);
}
let compressed_words: Vec<&str> = kept_ids
.iter()
.filter(|&&w| w < n_words)
.map(|&w| words[w])
.collect();
let compressed_tokens = compressed_words.len();
let compressed = compressed_words.join(" ");
let compression_ratio = if n_words == 0 {
1.0
} else {
compressed_tokens as f64 / n_words as f64
};
KompressResult {
compressed,
original: content.to_string(),
original_tokens: n_words,
compressed_tokens,
compression_ratio,
model_used: self.config.model_id.clone(),
}
}
/// Tokenize one chunk of words and return the **max score per word**
/// (`word_index -> score`). `word_index` is local to the chunk.
fn score_chunk(
&self,
chunk_words: &[&str],
) -> Result<HashMap<usize, f32>, Box<dyn std::error::Error + Send + Sync>> {
let seq_in: Vec<&str> = chunk_words.to_vec();
let encoding = self
.tokenizer
.encode(EncodeInput::Single(InputSequence::from(seq_in)), true)?;
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let attn: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&x| x as i64)
.collect();
let word_ids = encoding.get_word_ids();
let mut ids = ids;
let mut attn = attn;
// Static-shape models (e.g. the OpenVINO NPU build, which cannot
// compile a dynamic `seq`) require a fixed `[1, static_seq]` input, so
// right-pad every chunk to that length. Real tokens occupy
// `0..real_seq`; the tail is padding with `attention_mask = 0`, which
// masks those positions out of self-attention — the scores at real
// positions are identical to an unpadded run, so keep/discard decisions
// (hence parity) are unchanged. The tokenizer truncates to
// `MAX_SEQ_LEN`, so the chunk never exceeds a `static_seq` of that size.
// Dynamic models (`static_seq == None`) take the chunk's natural length
// and pay no padding cost — the default for CPU/GPU.
let seq = match self.static_seq {
Some(n) => {
debug_assert!(ids.len() <= n);
ids.resize(n, 0);
attn.resize(n, 0);
n
}
None => ids.len(),
};
let input_ids = Tensor::from_array(([1usize, seq], ids))?;
let attention_mask = Tensor::from_array(([1usize, seq], attn))?;
let scores: Vec<f32> = {
let mut session = self
.session
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let outputs = session.run(ort::inputs![
"input_ids" => input_ids,
"attention_mask" => attention_mask
])?;
let (_shape, data) = outputs["final_scores"].try_extract_tensor::<f32>()?;
data.to_vec()
};
let mut word_scores: HashMap<usize, f32> = HashMap::new();
for (idx, wid) in word_ids.iter().enumerate() {
let Some(w) = wid else { continue };
let Some(&s) = scores.get(idx) else { continue };
let entry = word_scores.entry(*w as usize).or_insert(f32::MIN);
if s > *entry {
*entry = s;
}
}
Ok(word_scores)
}
/// Apply the threshold or top-k rule to one chunk's per-word scores,
/// inserting kept **global** word indices into `kept_ids`.
fn select_words(
&self,
word_scores: &HashMap<usize, f32>,
chunk_start: usize,
target_ratio: Option<f64>,
kept_ids: &mut BTreeSet<usize>,
) {
if word_scores.is_empty() {
return;
}
match target_ratio {
Some(ratio) => {
// Stable top-k: iterate words in ascending index order so
// equal scores break toward the lower word index — this
// matches CPython's stable `sorted()` over the
// insertion-ordered score dict (tokens emitted in word
// order).
let mut ordered: Vec<(usize, f32)> =
word_scores.iter().map(|(&w, &s)| (w, s)).collect();
ordered.sort_by_key(|&(w, _)| w);
ordered.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let num_keep = ((ordered.len() as f64 * ratio) as usize).max(1);
for &(w, _) in ordered.iter().take(num_keep) {
kept_ids.insert(w + chunk_start);
}
}
None => {
for (&w, &s) in word_scores {
if s > self.config.score_threshold {
kept_ids.insert(w + chunk_start);
}
}
}
}
}
fn passthrough(&self, content: &str, n_words: usize) -> KompressResult {
KompressResult {
compressed: content.to_string(),
original: content.to_string(),
original_tokens: n_words,
compressed_tokens: n_words,
compression_ratio: 1.0,
model_used: self.config.model_id.clone(),
}
}
/// Expose the active config (read-only).
pub fn config(&self) -> &KompressConfig {
&self.config
}
}
// ─── Loading helpers ────────────────────────────────────────────────────
fn load_tokenizer(path: &Path, repo: &str) -> Result<Tokenizer, KompressError> {
let mut tokenizer = Tokenizer::from_file(path).map_err(|e| KompressError::Tokenizer {
repo: repo.to_string(),
source: e,
})?;
// Match the Python reference: truncation=True, max_length=512.
tokenizer
.with_truncation(Some(TruncationParams {
max_length: MAX_SEQ_LEN,
..Default::default()
}))
.map_err(|e| KompressError::Tokenizer {
repo: repo.to_string(),
source: e,
})?;
Ok(tokenizer)
}
fn build_session(path: &Path) -> Result<Session, Box<dyn std::error::Error + Send + Sync>> {
let session = Session::builder()?.commit_from_file(path)?;
Ok(session)
}
/// Resolve `rel` (e.g. `["tokenizer.json"]` or `["onnx", "kompress-int8-wo.onnx"]`)
/// inside the local HuggingFace cache for `repo` (`"owner/name"`), searching
/// every snapshot under every candidate cache root. Returns `None` if not
/// present — never touches the network.
fn hf_cache_file(repo: &str, rel: &[&str]) -> Option<PathBuf> {
let repo_dir = format!("models--{}", repo.replace('/', "--"));
for hub in hf_hub_roots() {
let snapshots = hub.join(&repo_dir).join("snapshots");
let Ok(entries) = std::fs::read_dir(&snapshots) else {
continue;
};
for snap in entries.flatten() {
let mut cand = snap.path();
for part in rel {
cand = cand.join(part);
}
if cand.exists() {
return Some(cand);
}
}
}
None
}
/// HuggingFace hub cache roots in resolution precedence. Cross-platform so
/// the cache-only loader works on Windows (native `headroom-proxy.exe`) as
/// well as Linux: `HF_HUB_CACHE` (the hub dir directly) → `HF_HOME/hub` →
/// `{HOME|USERPROFILE}/.cache/huggingface/hub`. `HOME` is the unix home; on
/// Windows the process sees `USERPROFILE` (and often no `HOME`), so both are
/// tried. Honoring `HF_HOME` also lets a Windows proxy point at a WSL cache.
fn hf_hub_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
let push_env = |roots: &mut Vec<PathBuf>, var: &str, suffix: &[&str]| {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
let mut p = PathBuf::from(v);
for s in suffix {
p = p.join(s);
}
roots.push(p);
}
}
};
push_env(&mut roots, "HF_HUB_CACHE", &[]);
push_env(&mut roots, "HF_HOME", &["hub"]);
push_env(&mut roots, "HOME", &[".cache", "huggingface", "hub"]);
push_env(&mut roots, "USERPROFILE", &[".cache", "huggingface", "hub"]);
roots
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_defaults_match_kompress_v2_base() {
let c = KompressConfig::default();
assert_eq!(c.model_id, "chopratejas/kompress-v2-base");
assert_eq!(c.tokenizer_repo, "answerdotai/ModernBERT-base");
assert_eq!(c.chunk_words, 350);
assert_eq!(c.score_threshold, 0.5);
assert_eq!(c.min_words, 10);
}
#[test]
fn result_helpers() {
let r = KompressResult {
compressed: "a b".into(),
original: "a b c d".into(),
original_tokens: 4,
compressed_tokens: 2,
compression_ratio: 0.5,
model_used: DEFAULT_MODEL_ID.into(),
};
assert_eq!(r.tokens_saved(), 2);
assert!(!r.is_passthrough());
let p = KompressResult {
compressed: "a b".into(),
original: "a b".into(),
original_tokens: 2,
compressed_tokens: 2,
compression_ratio: 1.0,
model_used: DEFAULT_MODEL_ID.into(),
};
assert_eq!(p.tokens_saved(), 0);
assert!(p.is_passthrough());
}
}

View file

@ -1009,6 +1009,23 @@ enum SlotKind {
/// latest user message. Errors out on shapes the dispatcher does not
/// support (e.g. structured-array `content` inside a tool_result —
/// rare; we degrade to NoChange in that case).
/// Whether a content block (no `type` key) carries a JSON-string `text`
/// field — the Bedrock Converse text-block shape (`{"text": "..."}`).
/// Used to route typeless Converse text through the Anthropic text path.
/// Blocks whose `text` is absent or non-string (e.g. `{"image": ...}`,
/// `{"toolUse": ...}`) return false and stay unrecognized → no-op.
fn block_has_string_text_field(block_json: &str) -> bool {
#[derive(Deserialize)]
struct Probe<'a> {
#[serde(borrow, default)]
text: Option<&'a RawValue>,
}
serde_json::from_str::<Probe<'_>>(block_json)
.ok()
.and_then(|p| p.text)
.is_some_and(|t| t.get().trim_start().starts_with('"'))
}
fn plan_block_replacements(
body_raw: &[u8],
target_msg_idx: usize,
@ -1072,7 +1089,18 @@ fn plan_block_replacements(
let header: BlockHeader<'_> =
serde_json::from_str(block_raw.get()).map_err(|_| PlanError::ParseFailed)?;
let block_type = header.r#type.unwrap_or("unknown").to_string();
// Bedrock Converse content blocks carry no `type` discriminator —
// the variant is the key itself (`{"text": ...}`, `{"image": ...}`,
// `{"toolUse": ...}`). A typeless block whose `text` field is a
// JSON string is Converse text; route it through the same surgical
// path as an Anthropic `{"type":"text","text":...}` block so
// Converse user-message text compresses too. Anthropic blocks
// always carry `type`, so this never alters the Anthropic path.
let block_type = match header.r#type {
Some(t) => t.to_string(),
None if block_has_string_text_field(block_raw.get()) => "text".to_string(),
None => "unknown".to_string(),
};
if HOT_ZONE_BLOCK_TYPES.iter().any(|t| *t == block_type) {
slots.push(PlanSlot {
@ -1611,6 +1639,61 @@ mod tests {
assert!(matches!(out, LiveZoneOutcome::NoChange { .. }));
}
#[test]
fn block_has_string_text_field_detects_converse_text_only() {
// Converse text block: typeless, string `text` → recognized.
assert!(block_has_string_text_field(r#"{"text":"hello"}"#));
// Non-text Converse blocks must NOT be mistaken for text.
assert!(!block_has_string_text_field(
r#"{"image":{"format":"png"}}"#
));
assert!(!block_has_string_text_field(r#"{"toolUse":{"name":"x"}}"#));
// `text` present but not a JSON string → not Converse text.
assert!(!block_has_string_text_field(r#"{"text":["a"]}"#));
assert!(!block_has_string_text_field(r#"{"text":{"v":1}}"#));
}
#[test]
fn converse_typeless_text_block_routes_like_anthropic_text() {
// Bedrock Converse content blocks omit the `type` discriminator —
// `{"text": "..."}` instead of `{"type":"text","text":"..."}`. The
// dispatcher must treat the two identically so Converse user-message
// text compresses like Anthropic text.
let payload = "{\"k\": \"v\", \"n\": 1}\n".repeat(200);
let converse = body(json!({
"messages": [{"role": "user", "content": [{"text": payload}]}]
}));
let anthropic = body(json!({
"messages": [{"role": "user", "content": [{"type": "text", "text": payload}]}]
}));
let c = compress_anthropic_live_zone(&converse, 0, AuthMode::Payg, DEFAULT_MODEL).unwrap();
let a = compress_anthropic_live_zone(&anthropic, 0, AuthMode::Payg, DEFAULT_MODEL).unwrap();
// Identical dispatch outcome (both Modified or both NoChange).
assert_eq!(
std::mem::discriminant(&c),
std::mem::discriminant(&a),
"converse text block must dispatch like an anthropic text block"
);
let cm = match &c {
LiveZoneOutcome::NoChange { manifest } => manifest,
LiveZoneOutcome::Modified { manifest, .. } => manifest,
};
let am = match &a {
LiveZoneOutcome::NoChange { manifest } => manifest,
LiveZoneOutcome::Modified { manifest, .. } => manifest,
};
// The Converse block is now classified the same as Anthropic text
// (before this change it was an unrecognized typeless block).
assert_eq!(cm.block_outcomes.len(), 1);
assert_eq!(am.block_outcomes.len(), 1);
assert_eq!(
cm.block_outcomes[0].block_type,
am.block_outcomes[0].block_type
);
assert_eq!(cm.block_outcomes[0].block_type, "text");
}
#[test]
fn manifest_records_messages_below_floor() {
let b = body(json!({

View file

@ -168,6 +168,14 @@ pub struct LogCompressorConfig {
/// Compression ratio threshold for CCR storage. Python defaults to
/// 0.5 inline; promoted to a config field here.
pub min_compression_ratio_for_ccr: f64,
/// When a trace exceeds `stack_trace_max_lines`, collapse runtime/stdlib
/// frames into a `[... N runtime frames collapsed]` marker instead of
/// blindly truncating the tail (which drops app frames and chain heads).
pub collapse_runtime_frames: bool,
/// First N frames always kept when collapsing (top of the trace).
pub trace_head_frames: usize,
/// App-code (non-runtime) frames kept beyond the head when collapsing.
pub trace_app_frames: usize,
}
impl Default for LogCompressorConfig {
@ -186,6 +194,9 @@ impl Default for LogCompressorConfig {
enable_ccr: true,
min_lines_for_ccr: 50,
min_compression_ratio_for_ccr: 0.5,
collapse_runtime_frames: true,
trace_head_frames: 3,
trace_app_frames: 5,
}
}
}
@ -222,6 +233,7 @@ pub struct LogCompressorStats {
pub stack_traces_kept: usize,
pub warnings_dropped_by_dedupe: usize,
pub lines_dropped_by_global_cap: usize,
pub runtime_frames_collapsed: usize,
pub ccr_emitted: bool,
pub ccr_skip_reason: Option<&'static str>,
}
@ -404,7 +416,13 @@ enum TraceFlavor {
Js,
Java,
RustError,
Go,
/// Rust panic + `RUST_BACKTRACE` dump. Frames are `N: 0x<hex>` /
/// `N: <symbol>` lines. (Previously misnamed `Go`, whose real panic
/// shape — `goroutine N [state]:` + tab-indented `.go:` frames — is
/// `GoPanic` below.)
RustBacktrace,
GoPanic,
DotNet,
}
impl StackTraceDetector {
@ -414,14 +432,23 @@ impl StackTraceDetector {
|| Self::is_python_file_frame(trimmed)
{
Some(TraceFlavor::PythonTraceback)
} else if Self::is_dotnet_opener(trimmed) {
// Before Js/Java: a .NET `at Ns.Class.Method(...) in File.cs:line N`
// frame also satisfies the Java `at <dotted>(` shape.
Some(TraceFlavor::DotNet)
} else if Self::is_js_at_frame(trimmed) {
Some(TraceFlavor::Js)
} else if Self::is_java_at_frame(trimmed) {
Some(TraceFlavor::Java)
} else if trimmed.starts_with("--> ") && Self::has_line_col_suffix(trimmed) {
Some(TraceFlavor::RustError)
} else if Self::is_go_frame(line) {
Some(TraceFlavor::Go)
} else if Self::is_rust_panic_opener(trimmed)
|| trimmed.starts_with("stack backtrace:")
|| Self::is_rust_backtrace_frame(line)
{
Some(TraceFlavor::RustBacktrace)
} else if Self::is_go_panic_opener(line) {
Some(TraceFlavor::GoPanic)
} else {
None
}
@ -440,13 +467,17 @@ impl StackTraceDetector {
}
fn is_java_at_frame(s: &str) -> bool {
// Pattern: `at <package.Class.method>(`
// Pattern: `at <package.Class.method>(`. `/` admits JPMS module
// prefixes (`at java.base/java.util.Optional.get(...)`) and lambda
// frames (`$$Lambda$17/0x...`) — without it, modern JDK frames fail
// the opener re-check at the parse cap and one trace fragments into
// several groups.
if !s.starts_with("at ") || !s.contains('(') {
return false;
}
let body = &s[3..s.find('(').unwrap_or(s.len())];
body.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '$'))
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '$' | '/'))
&& !body.is_empty()
}
@ -474,7 +505,68 @@ impl StackTraceDetector {
false
}
fn is_go_frame(s: &str) -> bool {
fn is_rust_panic_opener(s: &str) -> bool {
// Pattern: `thread '<name>' panicked at <loc>` (any rustc era).
s.starts_with("thread '") && s.contains("panicked at")
}
fn is_go_panic_opener(line: &str) -> bool {
// `panic: <msg>` / `fatal error: <msg>` (column 0) or a goroutine
// header `goroutine <N> [<state>]:`.
if line.starts_with("panic: ") || line.starts_with("fatal error: ") {
return true;
}
Self::is_goroutine_header(line)
}
fn is_goroutine_header(line: &str) -> bool {
let Some(rest) = line.strip_prefix("goroutine ") else {
return false;
};
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
digits > 0 && rest[digits..].starts_with(" [")
}
fn is_go_file_frame(line: &str) -> bool {
// Tab-indented `<path>.go:<line> +0x<hex>` (the second line of each
// goroutine frame pair).
let Some(rest) = line.strip_prefix('\t') else {
return false;
};
rest.contains(".go:") && rest.contains(" +0x")
}
fn is_go_call_frame(line: &str) -> bool {
// `pkg.func(...)` / `created by pkg.func` call lines inside a
// goroutine block (column 0, dotted symbol).
if line.starts_with("created by ") {
return true;
}
if line.starts_with([' ', '\t']) || !line.ends_with(')') {
return false;
}
let Some(open) = line.find('(') else {
return false;
};
let symbol = &line[..open];
!symbol.is_empty()
&& symbol.contains('.')
&& symbol
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '*'))
}
fn is_dotnet_opener(s: &str) -> bool {
s.starts_with("Unhandled exception.") || Self::is_dotnet_frame(s)
}
fn is_dotnet_frame(s: &str) -> bool {
// Pattern: `at <symbol>(<args>) in <file>:line <N>` — the ` in … :line`
// suffix is what distinguishes .NET from Java frames.
s.starts_with("at ") && s.contains(") in ") && s.contains(":line ")
}
fn is_rust_backtrace_frame(s: &str) -> bool {
// Pattern: `<digits>:<spaces>0x<hex>`
let trimmed = s.trim_start();
let mut chars = trimmed.chars().peekable();
@ -503,7 +595,10 @@ impl StackTraceDetector {
}
/// True if `line` should end the current trace flavor's run.
fn terminates(flavor: TraceFlavor, line: &str) -> bool {
/// `lines_so_far` is how many lines the active trace has already
/// claimed (1 = only the opener) — RustBacktrace uses it to keep the
/// free-text panic-message line that follows `panicked at <loc>:`.
fn terminates(flavor: TraceFlavor, line: &str, lines_so_far: usize) -> bool {
let trimmed = line.trim_start();
match flavor {
TraceFlavor::PythonTraceback => {
@ -524,16 +619,230 @@ impl StackTraceDetector {
!trimmed.starts_with(char::is_uppercase)
}
}
TraceFlavor::Js | TraceFlavor::Java => {
TraceFlavor::Js => {
// Terminate on the first non-`at` line.
!trimmed.starts_with("at ") && !line.is_empty()
}
TraceFlavor::Java => {
// Continue across `Caused by:` / `Suppressed:` chain heads and
// the `... N more` frame-elision summary — terminating there
// split one chained exception into several traces, and the
// later chain heads got dropped under `max_stack_traces`.
let is_chain = trimmed.starts_with("Caused by:")
|| trimmed.starts_with("Suppressed:")
|| Self::is_java_more_summary(trimmed);
!trimmed.starts_with("at ") && !is_chain && !line.is_empty()
}
TraceFlavor::DotNet => {
// Continue across frames, inner-exception heads (`--->`),
// separator lines (`--- End of inner exception stack trace`,
// `--- End of stack trace from previous location`), and
// exception-type message lines.
if line.is_empty() {
return false;
}
let continues = trimmed.starts_with("at ")
|| trimmed.starts_with("--->")
|| trimmed.starts_with("--- End of")
|| Self::is_dotnet_exception_head(trimmed);
!continues
}
TraceFlavor::RustError => !trimmed.starts_with("--> ") && !line.is_empty(),
TraceFlavor::Go => {
!trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && !line.is_empty()
TraceFlavor::RustBacktrace => {
if line.is_empty() || lines_so_far == 1 {
// The panic message is the unindented free-text line right
// after the `panicked at <loc>:` opener — keep it.
return false;
}
let is_frame = trimmed.chars().next().is_some_and(|c| c.is_ascii_digit());
let is_continuation = line.starts_with([' ', '\t'])
|| trimmed.starts_with("stack backtrace:")
|| trimmed.starts_with("note: run with");
!is_frame && !is_continuation
}
TraceFlavor::GoPanic => {
// A goroutine dump is blocks of `goroutine N [state]:` headers,
// `pkg.func(...)` call lines, and tab-indented `.go:` file
// lines, separated by blank lines. Signal lines (`[signal
// SIGSEGV...]`) and chained `panic:` lines continue it.
if line.is_empty() {
return false;
}
let continues = line.starts_with('\t')
|| Self::is_goroutine_header(line)
|| Self::is_go_call_frame(line)
|| line.starts_with("panic: ")
|| line.starts_with("fatal error: ")
|| line.starts_with("[signal ");
!continues
}
}
}
fn is_dotnet_exception_head(trimmed: &str) -> bool {
// `System.InvalidOperationException: message` (dotted type ending in
// Exception, then a colon).
let Some(colon) = trimmed.find(':') else {
return false;
};
let head = &trimmed[..colon];
head.ends_with("Exception")
&& head.contains('.')
&& head
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '`' | '+'))
}
fn is_java_more_summary(trimmed: &str) -> bool {
// `... 17 more`
let Some(rest) = trimmed.strip_prefix("... ") else {
return false;
};
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
digits > 0 && rest[digits..].trim() == "more"
}
}
// ─── Frame-collapse pass ───────────────────────────────────────────────
/// Result of collapsing runtime frames in an oversized stack trace.
struct CollapsedTrace {
kept: Vec<LogLine>,
/// Original line numbers of the dropped frames — excluded from the
/// context-line pass so they don't ride back in as neighbors.
dropped_indices: Vec<usize>,
}
/// True if `line` is a stack FRAME (vs. an exception message / chain head).
fn is_frame_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("at ")
|| (trimmed.starts_with("File \"") && trimmed.contains("\", line "))
|| StackTraceDetector::is_rust_backtrace_frame(line)
|| StackTraceDetector::is_go_file_frame(line)
|| StackTraceDetector::is_go_call_frame(line)
}
/// Chain heads and inter-trace markers that must always survive a collapse.
fn is_chain_head_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("Caused by:")
|| trimmed.starts_with("Suppressed:")
|| trimmed.starts_with("... ")
|| trimmed.starts_with("--->")
|| trimmed.starts_with("--- End of")
|| trimmed.starts_with("During handling")
|| trimmed.starts_with("The above exception")
}
/// Runtime/stdlib frame markers, split by match mode: `starts_with` on the
/// trimmed line vs. `contains` anywhere (paths and dotted symbols).
const RUNTIME_FRAME_PREFIXES: &[&str] = &[
"at java.",
"at jdk.",
"at sun.",
"at javax.",
"at scala.",
"at System.",
"at Microsoft.",
"runtime.",
"created by runtime.",
];
const RUNTIME_FRAME_MARKERS: &[&str] = &[
"site-packages/",
"/usr/lib/python",
"lib/python3.",
"node:internal/",
"node_modules/",
"(internal/",
"core::",
"std::",
"alloc::",
"rust_begin_unwind",
"__rust_",
"/rustc/",
"/usr/local/go/src/",
"/libexec/src/runtime/",
];
fn is_runtime_frame(line: &str) -> bool {
let trimmed = line.trim_start();
RUNTIME_FRAME_PREFIXES
.iter()
.any(|p| trimmed.starts_with(p))
|| RUNTIME_FRAME_MARKERS.iter().any(|m| line.contains(m))
}
/// Collapse runtime frames in an oversized trace: keep every message /
/// chain-head line, the first `head_frames` frames, and up to `app_frames`
/// app-code frames; each contiguous dropped run becomes one
/// `[... N frames collapsed]` marker occupying the run's first line slot.
/// Indented continuations of a dropped frame (Python source echo) drop
/// with it.
fn collapse_trace_frames(
stack: &[LogLine],
head_frames: usize,
app_frames: usize,
) -> CollapsedTrace {
let mut kept: Vec<LogLine> = Vec::with_capacity(stack.len().min(64));
let mut dropped_indices: Vec<usize> = Vec::new();
let mut frames_seen = 0usize;
let mut app_kept = 0usize;
let mut run_start: Option<usize> = None;
let mut run_len = 0usize;
let mut prev_dropped = false;
fn flush_run(kept: &mut Vec<LogLine>, run_start: &mut Option<usize>, run_len: &mut usize) {
if let Some(ln) = run_start.take() {
let mut marker = LogLine::new(ln, format!(" [... {run_len} frames collapsed]"));
// Survive the score-ranked global cap: the marker stands in for
// many lines and must not be the first thing dropped.
marker.score = 0.8;
marker.is_stack_trace = true;
kept.push(marker);
*run_len = 0;
}
}
for line in stack {
if is_frame_line(&line.content) && !is_chain_head_line(&line.content) {
frames_seen += 1;
let runtime = is_runtime_frame(&line.content);
let keep = frames_seen <= head_frames || (!runtime && app_kept < app_frames);
if keep {
if !runtime {
app_kept += 1;
}
flush_run(&mut kept, &mut run_start, &mut run_len);
kept.push(line.clone());
prev_dropped = false;
} else {
if run_start.is_none() {
run_start = Some(line.line_number);
}
run_len += 1;
dropped_indices.push(line.line_number);
prev_dropped = true;
}
} else if prev_dropped
&& line.content.starts_with([' ', '\t'])
&& !is_chain_head_line(&line.content)
{
// Indented continuation of a dropped frame (source echo, `at
// <path>` sub-line already caught as frame above).
run_len += 1;
dropped_indices.push(line.line_number);
} else {
flush_run(&mut kept, &mut run_start, &mut run_len);
kept.push(line.clone());
prev_dropped = false;
}
}
flush_run(&mut kept, &mut run_start, &mut run_len);
CollapsedTrace {
kept,
dropped_indices,
}
}
// ─── Summary detector ──────────────────────────────────────────────────
@ -707,8 +1016,9 @@ impl LogCompressor {
// `stack_trace_max_lines`.
if let Some(flavor) = active {
if trace_lines >= self.config.stack_trace_max_lines
|| StackTraceDetector::terminates(flavor, line)
|| StackTraceDetector::terminates(flavor, line, trace_lines)
{
let cap_hit = trace_lines >= self.config.stack_trace_max_lines;
active = None;
trace_lines = 0;
// Re-check the current line against opener — chained
@ -718,6 +1028,17 @@ impl LogCompressor {
active = Some(new_flavor);
trace_lines = 1;
entry.is_stack_trace = true;
} else if cap_hit && !StackTraceDetector::terminates(flavor, line, 2) {
// Cap hit mid-trace on a line that is not an opener
// by itself but still continues the active flavor
// (goroutine file frames, Python source echoes,
// blank separators). Keep marking so the selection
// stage sees one contiguous trace and the frame
// collapse — not arbitrary cap alignment — decides
// what survives.
active = Some(flavor);
trace_lines = 1;
entry.is_stack_trace = true;
}
} else {
entry.is_stack_trace = true;
@ -803,10 +1124,30 @@ impl LogCompressor {
selected.insert(line);
}
let mut collapsed_frame_indices: BTreeSet<usize> = BTreeSet::new();
for stack in stack_traces.iter().take(self.config.max_stack_traces) {
stats.stack_traces_kept += 1;
for line in stack.iter().take(self.config.stack_trace_max_lines) {
selected.insert(line.clone());
if self.config.collapse_runtime_frames
&& stack.len() > self.config.stack_trace_max_lines
{
let collapsed = collapse_trace_frames(
stack,
self.config.trace_head_frames,
self.config.trace_app_frames,
);
stats.runtime_frames_collapsed += collapsed.dropped_indices.len();
collapsed_frame_indices.extend(collapsed.dropped_indices);
for line in collapsed
.kept
.into_iter()
.take(self.config.stack_trace_max_lines)
{
selected.insert(line);
}
} else {
for line in stack.iter().take(self.config.stack_trace_max_lines) {
selected.insert(line.clone());
}
}
}
@ -829,7 +1170,12 @@ impl LogCompressor {
}
}
for idx in context_indices {
if !selected_indices.contains(&idx) && idx < log_lines.len() {
// Deliberately-collapsed runtime frames must not ride back in as
// "context" around the kept frames — that would undo the collapse.
if !selected_indices.contains(&idx)
&& idx < log_lines.len()
&& !collapsed_frame_indices.contains(&idx)
{
selected.insert(log_lines[idx].clone());
}
}
@ -1292,4 +1638,158 @@ mod tests {
// Third slot goes to the high-scoring middle line.
assert!(line_nums.contains(&2));
}
// ─── Language-aware stack-trace flavors ────────────────────────────
fn trace_flags(c: &LogCompressor, lines: &[&str]) -> Vec<bool> {
c.parse_lines(lines)
.iter()
.map(|l| l.is_stack_trace)
.collect()
}
#[test]
fn go_panic_and_goroutine_dump_detected() {
let c = cmp();
let lines = [
"some build output",
"panic: runtime error: index out of range [3] with length 3",
"",
"goroutine 1 [running]:",
"main.lookup(0x1, 0x2)",
"\t/app/pkg/lookup.go:42 +0x1d",
"main.main()",
"\t/app/main.go:10 +0x20",
"exit status 2",
];
let flags = trace_flags(&c, &lines);
assert!(!flags[0]);
// panic opener through both frame pairs are all one trace.
assert!(flags[1..8].iter().all(|&f| f), "flags: {:?}", flags);
assert!(!flags[8]);
}
#[test]
fn rust_panic_backtrace_detected_with_message_line() {
let c = cmp();
let lines = [
"thread 'main' panicked at src/main.rs:5:5:",
"index out of bounds: the len is 3 but the index is 99",
"stack backtrace:",
" 0: rust_begin_unwind",
" at /rustc/abc123/library/std/src/panicking.rs:645:5",
" 1: core::panicking::panic_fmt",
" 2: app::run",
"note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace",
"done",
];
let flags = trace_flags(&c, &lines);
// The free-text message line after the opener stays in the trace.
assert!(flags[..8].iter().all(|&f| f), "flags: {:?}", flags);
assert!(!flags[8]);
}
#[test]
fn dotnet_trace_continues_across_inner_exception() {
let c = cmp();
let lines = [
"Unhandled exception. System.InvalidOperationException: outer failed",
" ---> System.ArgumentNullException: inner value was null",
" at App.Data.Load(String path) in /src/App/Data.cs:line 42",
" --- End of inner exception stack trace ---",
" at App.Program.Main(String[] args) in /src/App/Program.cs:line 12",
"Build finished.",
];
let flags = trace_flags(&c, &lines);
assert!(flags[..5].iter().all(|&f| f), "flags: {:?}", flags);
assert!(!flags[5]);
}
#[test]
fn java_chain_continues_across_caused_by() {
let c = cmp();
let lines = [
"at com.example.Service.call(Service.java:10)",
"at com.example.Main.run(Main.java:5)",
"Caused by: java.io.IOException: disk gone",
"at com.example.Disk.read(Disk.java:77)",
"... 17 more",
"INFO next request",
];
let parsed = c.parse_lines(&lines);
let flags: Vec<bool> = parsed.iter().map(|l| l.is_stack_trace).collect();
assert!(flags[..5].iter().all(|&f| f), "flags: {:?}", flags);
assert!(!flags[5]);
// And selection groups it as ONE trace, not three.
let mut stats = LogCompressorStats::default();
let _ = c.select_lines(&parsed, 1.0, &mut stats);
assert_eq!(stats.stack_traces_seen, 1);
}
// ─── Frame collapse ─────────────────────────────────────────────────
fn java_chained_trace(runtime_frames: usize) -> String {
let mut lines =
vec!["Exception in thread \"main\" java.lang.IllegalStateException: boom".to_string()];
lines.push("at com.example.App.handle(App.java:10)".into());
lines.push("at com.example.App.dispatch(App.java:20)".into());
for i in 0..runtime_frames {
lines.push(format!(
"at java.base/java.util.stream.Op{}.eval(Op{}.java:{})",
i,
i,
i + 1
));
}
lines.push("Caused by: java.io.IOException: disk gone".into());
lines.push("at com.example.Disk.read(Disk.java:77)".into());
for i in 0..runtime_frames {
lines.push(format!(
"at java.base/java.lang.Thread{}.run(Thread.java:{})",
i,
i + 1
));
}
lines.push("... 17 more".into());
lines.join("\n")
}
#[test]
fn collapse_keeps_chain_heads_and_app_frames() {
let c = cmp();
let content = java_chained_trace(30); // 68 lines, way over max of 20
let (result, stats) = c.compress(&content, 1.0);
assert!(stats.runtime_frames_collapsed > 0);
// The signal lines survive:
assert!(result.compressed.contains("Caused by: java.io.IOException"));
assert!(result.compressed.contains("com.example.Disk.read"));
assert!(result.compressed.contains("... 17 more"));
// Runtime frames collapse behind a marker:
assert!(result.compressed.contains("frames collapsed]"));
// The deep runtime tail is gone (frame 25 of the second run existed
// only past the old 20-line truncation point AND is runtime).
assert!(!result.compressed.contains("Thread25.run"));
}
#[test]
fn collapse_beats_blind_truncation_on_chain_heads() {
// With collapse disabled, the old head-truncation loses the
// `Caused by:` head buried past max_lines; with it enabled, kept.
let content = java_chained_trace(30);
let mut cfg = LogCompressorConfig::default();
cfg.collapse_runtime_frames = false;
let (result_off, _) = LogCompressor::new(cfg).compress(&content, 1.0);
assert!(!result_off.compressed.contains("com.example.Disk.read"));
let (result_on, _) = cmp().compress(&content, 1.0);
assert!(result_on.compressed.contains("com.example.Disk.read"));
}
#[test]
fn small_traces_not_collapsed() {
let c = cmp();
let content = java_chained_trace(2); // 12 lines, under max of 20
let (result, stats) = c.compress(&content, 1.0);
assert_eq!(stats.runtime_frames_collapsed, 0);
assert!(!result.compressed.contains("frames collapsed]"));
}
}

View file

@ -28,14 +28,278 @@
//! - **No router rewiring here.** PR3 lands the detector + tests
//! only. PR5 flips the ContentRouter to call us instead of the
//! regex-based [`crate::transforms::content_detector`].
//!
//! - **CPU compatibility.** The precompiled ONNX Runtime binary shipped by
//! `ort-sys` may contain AVX2-family instructions on x86/x86_64. Where
//! AVX2 is unavailable on those targets, the session init returns an
//! error early instead of crashing with SIGILL; the detection chain then
//! falls through to Tier 2 and Tier 3 normally.
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use magika::Session;
use thiserror::Error;
use tracing;
use crate::transforms::content_detector::ContentType;
/// Check whether the CPU can run the precompiled ONNX Runtime binary
/// that magika depends on.
///
/// On x86/x86_64 without AVX2, the `onnxruntime` shared library shipped
/// by `ort-sys` can contain AVX2-family instructions that will SIGILL.
/// We detect this up front so the magika session init can fail gracefully
/// instead of crashing.
///
/// On non-x86 targets, this x86-specific AVX2 gate is not applied.
///
/// Delegates to the shared [`crate::onnx_cpu`] guard so magika and the
/// embedding scorer agree on a single CPU-support source of truth.
pub(crate) fn magika_onnx_runtime_supported_by_cpu() -> bool {
crate::onnx_cpu::onnx_runtime_supported_by_cpu()
}
/// Check whether this process can safely initialize Magika's ONNX session.
///
/// This is stricter than the CPU check. On dynamic-ORT platforms, the runtime
/// loader must be pinned before `Session::new()` runs; otherwise Windows can
/// resolve the OS-provided `System32\onnxruntime.dll` and hang inside ORT
/// initialization. Python callers get the pin from `headroom._ort`; direct Rust
/// binaries/tests need this fail-fast guard.
pub(crate) fn magika_runtime_available_for_session_init() -> Result<(), String> {
if !magika_onnx_runtime_supported_by_cpu() {
return Err(
"Magika ONNX Runtime backend requires AVX2 on this platform; \
falling back to non-Magika detection"
.to_string(),
);
}
dynamic_ort_loader_ready()
}
static DYNAMIC_ORT_INIT: OnceLock<Result<PathBuf, String>> = OnceLock::new();
/// Ensure the ONNX Runtime shared library is resolved and committed to
/// `ort` before any `ort` API is touched.
///
/// With `ort-load-dynamic` (every platform, see Cargo.toml) this MUST
/// run before any code path that can construct an `ort` session
/// (magika, fastembed): if the dylib cannot be loaded, `ort`
/// 2.0.0-rc.12 deadlocks inside its API-setup error path (recursive
/// `OnceLock` init), and the stuck thread then wedges process exit in
/// `ort`'s `dl_fini` environment teardown (#1715 CI hang).
pub(crate) fn dynamic_ort_loader_ready() -> Result<(), String> {
DYNAMIC_ORT_INIT
.get_or_init(initialize_dynamic_ort)
.as_ref()
.map(|_| ())
.map_err(Clone::clone)
}
fn initialize_dynamic_ort() -> Result<PathBuf, String> {
let explicit = std::env::var("ORT_DYLIB_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(path) = explicit {
let path = PathBuf::from(path);
if !path.is_file() {
return Err(format!(
"ORT_DYLIB_PATH points to a missing ONNX Runtime library: {}",
path.display()
));
}
init_ort_from_path(&path)?;
return Ok(path);
}
let mut errors = Vec::new();
let candidates = discover_onnxruntime_libraries();
for path in &candidates {
match init_ort_from_path(path) {
Ok(()) => {
tracing::info!(
ort_dylib_path = %path.display(),
"initialized ONNX Runtime for Magika from discovered onnxruntime package"
);
return Ok(path.clone());
}
Err(error) => errors.push(format!("{}: {error}", path.display())),
}
}
if candidates.is_empty() {
Err(
"no pip onnxruntime native library was found for Magika dynamic ONNX Runtime loading; \
install headroom-ai[proxy], install onnxruntime, or set ORT_DYLIB_PATH"
.to_string(),
)
} else {
Err(format!(
"failed to initialize ONNX Runtime for Magika from discovered libraries: {}",
errors.join("; ")
))
}
}
fn init_ort_from_path(path: &Path) -> Result<(), String> {
let builder = ort::init_from(path).map_err(|error| {
format!(
"failed to load ONNX Runtime from `{}`: {error}",
path.display()
)
})?;
let _committed = builder.commit();
Ok(())
}
fn discover_onnxruntime_libraries() -> Vec<PathBuf> {
let mut roots = Vec::new();
for var in ["VIRTUAL_ENV", "CONDA_PREFIX"] {
if let Some(root) = env_path(var) {
roots.push(root);
}
}
if let Ok(cwd) = std::env::current_dir() {
roots.push(cwd.join(".venv"));
roots.push(cwd.join("venv"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
roots.extend(versioned_children(
user_profile
.join(".pyenv")
.join("pyenv-win")
.join("versions"),
));
roots.extend(versioned_children(
user_profile
.join("AppData")
.join("Local")
.join("Programs")
.join("Python"),
));
roots.extend(versioned_children(
user_profile.join("AppData").join("Roaming").join("Python"),
));
}
if let Some(home) = env_path("HOME") {
roots.extend(versioned_children(home.join(".pyenv").join("versions")));
roots.push(home.join(".local"));
}
let mut candidates = Vec::new();
for root in roots {
candidates.extend(onnxruntime_candidates_under(&root));
}
dedup_existing_files(candidates)
}
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
fn versioned_children(root: PathBuf) -> Vec<PathBuf> {
let mut children = std::fs::read_dir(root)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(Result::ok))
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
children.sort_by(|a, b| b.cmp(a));
children
}
fn onnxruntime_candidates_under(root: &Path) -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
vec![
root.join("Lib")
.join("site-packages")
.join("onnxruntime")
.join("capi")
.join("onnxruntime.dll"),
root.join("site-packages")
.join("onnxruntime")
.join("capi")
.join("onnxruntime.dll"),
]
}
#[cfg(not(target_os = "windows"))]
{
let mut candidates = Vec::new();
for site_packages in python_site_packages_dirs(root) {
let capi = site_packages.join("onnxruntime").join("capi");
candidates.extend(onnxruntime_dylibs_in(&capi));
}
candidates
}
}
#[cfg(not(target_os = "windows"))]
fn python_site_packages_dirs(root: &Path) -> Vec<PathBuf> {
let mut dirs = vec![root.join("lib").join("site-packages")];
let lib = root.join("lib");
dirs.extend(
std::fs::read_dir(lib)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(Result::ok))
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("python"))
})
.map(|path| path.join("site-packages")),
);
dirs
}
#[cfg(not(target_os = "windows"))]
fn onnxruntime_dylibs_in(capi: &Path) -> Vec<PathBuf> {
let mut dylibs = std::fs::read_dir(capi)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(Result::ok))
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with("libonnxruntime")
&& (name.ends_with(".dylib") || name.contains(".so"))
})
})
.collect::<Vec<_>>();
dylibs.sort();
dylibs
}
fn dedup_existing_files(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut out = Vec::new();
for path in paths {
if path.is_file() && !out.iter().any(|seen| seen == &path) {
out.push(path);
}
}
out
}
/// Errors from the magika detector. Wraps the underlying `magika::Error`
/// so callers can match on whether init or inference broke without
/// pulling magika types into their imports.
@ -70,8 +334,80 @@ pub enum MagikaDetectorError {
/// missing or ort can't init, retrying just wastes cycles).
static MAGIKA_SESSION: OnceLock<Mutex<Result<Session, String>>> = OnceLock::new();
/// Default cap on magika ONNX session init.
///
/// On some platforms `Session::new()` can hang indefinitely instead of
/// returning an error. Root-caused on Windows: with `ort-load-dynamic`
/// (Windows-gated in `Cargo.toml`), the bare `LoadLibrary("onnxruntime.dll")`
/// search resolves to `C:\Windows\System32\onnxruntime.dll` — the Windows ML
/// OS component (1.17.x on Win11 24H2+) — and initializing an ort 2.x
/// session against it deadlocks at 0% CPU rather than erroring. A hang —
/// unlike an `Err` — is not caught by the tiered fallback in
/// [`crate::transforms::detection`], so it stalls the entire compression
/// pipeline until the proxy's own 30s+ timeout fires on every request.
///
/// The real fix is `headroom/_ort.py`, which pins `ORT_DYLIB_PATH` to the
/// pip-installed `onnxruntime` DLL before this crate can load ort. This
/// timeout remains as the safety net for unpinned embedders of the crate.
/// Override with `HEADROOM_MAGIKA_INIT_TIMEOUT_SECS`.
const MAGIKA_INIT_TIMEOUT_SECS_DEFAULT: u64 = 5;
fn magika_init_timeout() -> Duration {
let secs = std::env::var("HEADROOM_MAGIKA_INIT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(MAGIKA_INIT_TIMEOUT_SECS_DEFAULT);
Duration::from_secs(secs)
}
fn session() -> &'static Mutex<Result<Session, String>> {
MAGIKA_SESSION.get_or_init(|| Mutex::new(Session::new().map_err(|e| e.to_string())))
MAGIKA_SESSION.get_or_init(|| {
// Early-out if ORT is known to be unsafe or unavailable in this
// process. This avoids both SIGILL on unsupported CPUs and Windows
// deadlocks from unpinned dynamic ONNX Runtime loading.
if let Err(error) = magika_runtime_available_for_session_init() {
return Mutex::new(Err(error));
}
let timeout = magika_init_timeout();
let (tx, rx) = mpsc::channel();
// Run the (potentially hanging) ONNX init on a side thread so we
// can bound it. `Session: Send` (the static itself requires it),
// so moving the result across the channel is sound. On timeout we
// record an `Err` — `detection::detect` already falls through to
// the unidiff/regex tiers on `Err` — and the orphaned init thread
// is left to finish on its own; its eventual `send` lands on a
// dropped receiver (harmless) and the `Session` is then dropped.
let spawned = std::thread::Builder::new()
.name("magika-init".into())
.spawn(move || {
let _ = tx.send(Session::new().map_err(|e| e.to_string()));
});
if let Err(e) = spawned {
tracing::warn!("magika init thread spawn failed: {e}");
return Mutex::new(Err(format!("magika init thread spawn failed: {e}")));
}
match rx.recv_timeout(timeout) {
Ok(res) => Mutex::new(res),
Err(_) => {
let ort_dylib = std::env::var("ORT_DYLIB_PATH").ok();
tracing::warn!(
timeout_secs = timeout.as_secs(),
ort_dylib_path = ort_dylib.as_deref(),
"magika ONNX session init timed out; detection falls back to \
non-ML tiers for this process. On Windows an unset \
ORT_DYLIB_PATH usually means the WinML System32 \
onnxruntime.dll was picked up (deadlocks ort init)."
);
Mutex::new(Err(format!(
"magika session init exceeded {}s timeout; \
using non-ML detection tiers",
timeout.as_secs()
)))
}
}
})
}
/// Classify `content` and return the mapped Headroom [`ContentType`].
@ -166,9 +502,25 @@ mod tests {
use super::*;
fn assert_detect(content: &str, expected: ContentType, hint: &str) {
match magika_detect(content) {
Ok(got) => assert_eq!(got, expected, "{hint}: expected {expected:?}, got {got:?}"),
Err(e) => panic!("{hint}: detection failed: {e}"),
if let Err(init_reason) = magika_runtime_available_for_session_init() {
// On hosts where Magika cannot safely initialize, assert graceful
// degradation rather than panicking or hanging.
match magika_detect(content) {
Err(MagikaDetectorError::Init(msg)) => {
assert!(
msg == init_reason,
"{hint}: expected init error {init_reason:?}, got: {msg:?}"
);
}
other => panic!("{hint}: expected Magika init error, got {other:?}"),
}
} else {
match magika_detect(content) {
Ok(got) => {
assert_eq!(got, expected, "{hint}: expected {expected:?}, got {got:?}")
}
Err(e) => panic!("{hint}: detection failed: {e}"),
}
}
}
@ -300,15 +652,30 @@ index abc123..def456 100644
#[test]
fn singleton_session_is_reused_across_calls() {
// Two back-to-back calls should both succeed without re-initing
// the model (no panic, no error). We can't directly observe
// the singleton hit-rate without instrumenting the test, but
// wall-clock asymmetry between the first and second call is
// strong evidence (cold ~50 ms, warm <1 ms). For the unit
// suite, just prove neither call errors.
magika_detect("hello world").unwrap();
magika_detect("def f(): pass").unwrap();
magika_detect(r#"{"a":1}"#).unwrap();
// Two back-to-back calls should reuse the same session
// (or same cached error). When the Magika runtime is available the
// session is Ok and repeated calls succeed; otherwise the session is
// Err and repeated calls return the same Err.
if let Err(init_reason) = magika_runtime_available_for_session_init() {
let r1 = magika_detect("hello world");
let r2 = magika_detect("def f(): pass");
let r3 = magika_detect(r#"{"a":1}"#);
for r in [&r1, &r2, &r3] {
match r {
Err(MagikaDetectorError::Init(msg)) => {
assert_eq!(msg, &init_reason);
}
other => panic!("expected Magika init error, got {other:?}"),
}
}
} else {
// On available hosts the session loads once and all calls
// succeed. Wall-clock asymmetry (cold ~50 ms, warm
// <1 ms) confirms reuse.
magika_detect("hello world").unwrap();
magika_detect("def f(): pass").unwrap();
magika_detect(r#"{"a":1}"#).unwrap();
}
}
#[test]
@ -341,4 +708,29 @@ index abc123..def456 100644
assert_eq!(map_magika_label("txt"), ContentType::PlainText);
assert_eq!(map_magika_label("empty"), ContentType::PlainText);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_onnxruntime_candidate_matches_pip_layout() {
let root = std::env::temp_dir().join(format!(
"headroom-ort-discovery-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let dll = root
.join("Lib")
.join("site-packages")
.join("onnxruntime")
.join("capi")
.join("onnxruntime.dll");
std::fs::create_dir_all(dll.parent().unwrap()).unwrap();
std::fs::write(&dll, b"not a real dll").unwrap();
let candidates = dedup_existing_files(onnxruntime_candidates_under(&root));
assert_eq!(candidates, vec![dll]);
let _ = std::fs::remove_dir_all(root);
}
}

View file

@ -17,11 +17,15 @@
pub mod adaptive_sizer;
pub mod anchor_selector;
pub mod code_compressor;
pub mod content_detector;
pub mod detection;
pub mod diff_compressor;
#[cfg(feature = "ml")]
pub mod kompress;
pub mod live_zone;
pub mod log_compressor;
#[cfg(feature = "ml")]
pub mod magika_detector;
pub mod pipeline;
pub mod recommendations;
@ -29,8 +33,13 @@ pub mod safety;
pub mod search_compressor;
pub mod smart_crusher;
pub mod tag_protector;
pub mod text_crusher;
pub mod unidiff_detector;
pub use code_compressor::{
detect_language, CodeAwareCompressor, CodeCompressionResult, CodeCompressorConfig,
CodeLanguage, DocstringMode,
};
pub use content_detector::{
detect_content_type, is_json_array_of_dicts, ContentType, DetectionResult,
};
@ -38,6 +47,11 @@ pub use detection::detect;
pub use diff_compressor::{
DiffCompressionResult, DiffCompressor, DiffCompressorConfig, DiffCompressorStats,
};
#[cfg(feature = "ml")]
pub use kompress::{
Kompress, KompressConfig, KompressError, KompressResult, DEFAULT_MODEL_ID,
DEFAULT_TOKENIZER_REPO,
};
pub use live_zone::{
compress_anthropic_live_zone, compress_openai_chat_live_zone,
compress_openai_responses_live_zone, summarize_openai_responses_no_change_reason, AuthMode,
@ -48,11 +62,13 @@ pub use log_compressor::{
LogCompressionResult, LogCompressor, LogCompressorConfig, LogCompressorStats, LogFormat,
LogLevel, LogLine,
};
#[cfg(feature = "ml")]
pub use magika_detector::{magika_detect, map_magika_label, MagikaDetectorError};
pub use pipeline::{
CompressionContext, CompressionPipeline, CompressionPipelineBuilder, DiffNoise, DiffOffload,
JsonMinifier, JsonOffload, LogOffload, LogTemplate, OffloadOutput, OffloadTransform,
PipelineConfig, PipelineResult, ReformatOutput, ReformatTransform, TransformError,
PipelineConfig, PipelineResult, ProseFieldOffload, ReformatOutput, ReformatTransform,
TransformError,
};
pub use recommendations::{Recommendation, RecommendationStore, RECOMMENDATIONS_PATH_ENV_VAR};
pub use safety::{tool_pair_indices, ToolPair};
@ -61,4 +77,5 @@ pub use search_compressor::{
SearchCompressorStats, SearchMatch,
};
pub use tag_protector::{is_known_html_tag, protect_tags, restore_tags, ProtectStats};
pub use text_crusher::{TextCrusher, TextCrusherConfig, TextCrusherResult};
pub use unidiff_detector::{detect_diff, is_diff};

View file

@ -184,6 +184,7 @@ pub struct LogTemplateConfig {
pub struct OffloadConfigs {
pub json: JsonOffloadConfig,
pub diff_noise: DiffNoiseConfig,
pub prose_field: ProseFieldConfig,
}
/// Knobs for the [`crate::transforms::pipeline::offloads::JsonOffload`]
@ -196,6 +197,15 @@ pub struct JsonOffloadConfig {
pub saturation_rows: usize,
}
/// Knobs for the [`crate::transforms::pipeline::offloads::ProseFieldOffload`]
/// structured string-leaf compressor.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
pub struct ProseFieldConfig {
pub min_bytes: usize,
pub min_segments: usize,
pub target_ratio: f64,
}
/// Knobs for the [`crate::transforms::pipeline::offloads::DiffNoise`]
/// offload. Lockfile suffixes are matched against the new-file path
/// at the end of each `diff --git` header.
@ -283,6 +293,11 @@ mod tests {
min_lines = 20
lockfile_suffixes = ["custom.lock"]
drop_whitespace_only_hunks = false
[offload.prose_field]
min_bytes = 300
min_segments = 5
target_ratio = 0.4
"#;
let cfg = PipelineConfig::from_toml_str(toml).expect("override parses");
assert_eq!(cfg.pipeline.reformat_target_ratio, 0.3);
@ -293,6 +308,9 @@ mod tests {
cfg.offload.diff_noise.lockfile_suffixes,
vec!["custom.lock"]
);
assert_eq!(cfg.offload.prose_field.min_bytes, 300);
assert_eq!(cfg.offload.prose_field.min_segments, 5);
assert_eq!(cfg.offload.prose_field.target_ratio, 0.4);
}
#[test]
@ -302,6 +320,9 @@ mod tests {
assert_eq!(cfg.reformat.log_template.min_run, 3);
assert_eq!(cfg.offload.json.min_array_rows, 5);
assert_eq!(cfg.offload.json.saturation_rows, 50);
assert_eq!(cfg.offload.prose_field.min_bytes, 256);
assert_eq!(cfg.offload.prose_field.min_segments, 6);
assert_eq!(cfg.offload.prose_field.target_ratio, 0.5);
assert!(!cfg.offload.diff_noise.lockfile_suffixes.is_empty());
assert!(cfg
.offload
@ -311,6 +332,54 @@ mod tests {
.any(|s| s == "Cargo.lock"));
}
#[test]
fn prose_field_defaults_and_override() {
let defaults = PipelineConfig::default().offload.prose_field;
let override_cfg = PipelineConfig::from_toml_str(
r#"
[pipeline]
reformat_target_ratio = 0.5
bloat_threshold = 0.5
offload_fallback_ratio = 0.85
[bloat.log]
min_lines = 50
sample_size = 100
high_priority_threshold = 0.4
uniqueness_weight = 0.5
priority_dilution_weight = 0.5
[bloat.diff]
min_lines = 50
normal_context_ratio = 0.6
[bloat.search]
min_matches = 10
cluster_threshold = 10.0
[reformat.log_template]
min_lines = 20
min_run = 3
similarity_threshold = 0.8
min_constant_tokens = 2
[offload.json]
min_array_rows = 5
saturation_rows = 50
[offload.diff_noise]
min_lines = 30
lockfile_suffixes = ["Cargo.lock"]
drop_whitespace_only_hunks = true
[offload.prose_field]
min_bytes = 300
min_segments = 5
target_ratio = 0.4
"#,
)
.unwrap()
.offload
.prose_field;
assert_eq!(defaults.min_bytes, 256);
assert_eq!(override_cfg.min_bytes, 300);
assert_eq!(override_cfg.min_segments, 5);
assert_eq!(override_cfg.target_ratio, 0.4);
}
#[test]
fn malformed_toml_returns_error() {
let r = PipelineConfig::from_toml_str("this is not toml = [unterminated");

View file

@ -73,9 +73,6 @@
//! but is NOT in the default re-exports — modern agents use scoped
//! `rg`/`grep`, the marginal value didn't justify default registration.
//!
//! Deferred to later PRs:
//! - **ProseFieldCompressor** — Phase 3g PR3. Compresses prose-shaped
//! string fields inside structured payloads.
//!
//! [`estimate_bloat`]: traits::OffloadTransform::estimate_bloat
@ -87,14 +84,14 @@ pub mod traits;
pub use config::{
BloatConfigs, ConfigError, DiffBloatConfig, DiffNoiseConfig, JsonOffloadConfig, LogBloatConfig,
LogTemplateConfig, OffloadConfigs, OrchestratorConfig, PipelineConfig, ReformatConfigs,
SearchBloatConfig,
LogTemplateConfig, OffloadConfigs, OrchestratorConfig, PipelineConfig, ProseFieldConfig,
ReformatConfigs, SearchBloatConfig,
};
// `SearchOffload` is intentionally NOT in the top-level re-export
// (deprecated from default pipeline; reach via the explicit module
// path if you want to opt in). See `offloads::search_offload` head
// docs for rationale.
pub use offloads::{DiffNoise, DiffOffload, JsonOffload, LogOffload};
pub use offloads::{DiffNoise, DiffOffload, JsonOffload, LogOffload, ProseFieldOffload};
pub use orchestrator::{CompressionPipeline, CompressionPipelineBuilder, PipelineResult};
pub use reformats::{JsonMinifier, LogTemplate};
pub use traits::{

View file

@ -48,7 +48,8 @@
use md5::{Digest, Md5};
use crate::ccr::CcrStore;
use crate::transforms::pipeline::config::JsonOffloadConfig;
use crate::transforms::pipeline::config::{JsonOffloadConfig, PipelineConfig, ProseFieldConfig};
use crate::transforms::pipeline::offloads::prose_field::ProseFieldOffload;
use crate::transforms::pipeline::traits::{
CompressionContext, OffloadOutput, OffloadTransform, TransformError,
};
@ -63,6 +64,7 @@ const CONFIDENCE: f32 = 0.85;
pub struct JsonOffload {
crusher: SmartCrusher,
config: JsonOffloadConfig,
prose: ProseFieldOffload,
}
impl JsonOffload {
@ -75,13 +77,38 @@ impl JsonOffload {
Self {
crusher: SmartCrusher::new(SmartCrusherConfig::default()),
config,
prose: ProseFieldOffload::new(PipelineConfig::default().offload.prose_field),
}
}
pub fn from_pipeline(config: &PipelineConfig) -> Self {
Self {
crusher: SmartCrusher::new(SmartCrusherConfig::default()),
config: config.offload.json,
prose: ProseFieldOffload::new(config.offload.prose_field),
}
}
/// Custom constructor — used by tests that want a stubbed crusher
/// or a custom SmartCrusher config.
pub fn with_crusher(crusher: SmartCrusher, config: JsonOffloadConfig) -> Self {
Self { crusher, config }
Self {
crusher,
config,
prose: ProseFieldOffload::new(PipelineConfig::default().offload.prose_field),
}
}
pub fn with_crusher_and_prose(
crusher: SmartCrusher,
config: JsonOffloadConfig,
prose_config: ProseFieldConfig,
) -> Self {
Self {
crusher,
config,
prose: ProseFieldOffload::new(prose_config),
}
}
}
@ -121,7 +148,17 @@ impl OffloadTransform for JsonOffload {
ctx: &CompressionContext,
store: &dyn CcrStore,
) -> Result<OffloadOutput, TransformError> {
let result = self.crusher.crush(content, &ctx.query, 0.0);
let prose = &self.prose;
let prose_hook = |leaf: &str, query: &str| {
let leaf_ctx = CompressionContext::with_query(query);
prose
.apply(leaf, &leaf_ctx, store)
.ok()
.map(|output| (output.output, output.cache_key))
};
let result = self
.crusher
.crush_with_prose_hook(content, &ctx.query, 0.0, &prose_hook);
if !result.was_modified {
return Err(TransformError::skipped(
NAME,
@ -198,6 +235,94 @@ mod tests {
JsonOffload::new(cfg())
}
#[test]
fn from_pipeline_uses_overrideable_prose_config() {
let config = PipelineConfig::from_toml_str(
r#"
[pipeline]
reformat_target_ratio = 0.5
bloat_threshold = 0.5
offload_fallback_ratio = 0.85
[bloat.log]
min_lines = 50
sample_size = 100
high_priority_threshold = 0.4
uniqueness_weight = 0.5
priority_dilution_weight = 0.5
[bloat.diff]
min_lines = 50
normal_context_ratio = 0.6
[bloat.search]
min_matches = 10
cluster_threshold = 10.0
[reformat.log_template]
min_lines = 20
min_run = 3
similarity_threshold = 0.4
min_constant_tokens = 2
[offload.json]
min_array_rows = 5
saturation_rows = 50
[offload.prose_field]
min_bytes = 300
min_segments = 5
target_ratio = 0.4
[offload.diff_noise]
min_lines = 30
lockfile_suffixes = ["Cargo.lock"]
drop_whitespace_only_hunks = true
"#,
)
.expect("override parses");
let offload = JsonOffload::from_pipeline(&config);
assert_eq!(offload.prose.config(), config.offload.prose_field);
}
#[test]
fn prose_hook_ignores_diff_shaped_leaf() {
let diff = format!(
"diff --git a/foo.py b/foo.py\n--- a/foo.py\n+++ b/foo.py\n@@ -1,20 +1,20 @@\n{}",
(0..20)
.map(|i| format!("-old line {i}\n+new line {i}"))
.collect::<Vec<_>>()
.join("\n")
);
let input = serde_json::json!([{"description": diff}]).to_string();
let store = InMemoryCcrStore::new();
let output = offload()
.apply(&input, &CompressionContext::default(), &store)
.expect("diff-shaped leaf should still process through the direct offload path");
let marker = output
.output
.split("<<ccr:")
.nth(1)
.and_then(|tail| tail.split(">>").next())
.expect("direct offload should still emit a leaf marker");
assert!(
marker.contains(",string,"),
"diff leaf should stay on the opaque string route, got {marker}"
);
}
#[test]
fn prose_hook_preserves_opaque_html_leaf() {
let html = "<html><body><p>".to_string() + &"x".repeat(300) + "</p></body></html>";
let input = serde_json::json!([{"summary": html}]).to_string();
let store = InMemoryCcrStore::new();
let result = offload()
.apply(&input, &CompressionContext::with_query("recovery"), &store)
.expect("structured html should still process");
assert!(result.output.contains(",html,"));
}
/// Build a JSON array of N similar dicts with id + name + value.
/// Compact JSON (no extra whitespace) so byte counts are predictable.
fn build_tabular_array(n: usize) -> String {

Some files were not shown because too many files have changed in this diff Show more