mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge main into realign-F3-toin-per-tenant
This commit is contained in:
commit
72b590e064
3104 changed files with 252074 additions and 292273 deletions
|
|
@ -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
16
.codegraph/.gitignore
vendored
Normal 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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
3
.env.example
Normal 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
4
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
11
.github/CODEOWNERS
vendored
Normal 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
7
.github/FUNDING.yml
vendored
|
|
@ -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"]
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
54
.github/ISSUE_TEMPLATE/copilot-subscription-test-report.md
vendored
Normal file
54
.github/ISSUE_TEMPLATE/copilot-subscription-test-report.md
vendored
Normal 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.)
|
||||
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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
19
.github/act/pr-governance-invalid.json
vendored
Normal 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
19
.github/act/pr-governance-valid.json
vendored
Normal 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
11
.github/act/release-published.json
vendored
Normal 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"
|
||||
}
|
||||
104
.github/actions/headroom-e2e-setup/action.yml
vendored
104
.github/actions/headroom-e2e-setup/action.yml
vendored
|
|
@ -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
7
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -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"
|
||||
|
|
|
|||
78
.github/pr-images/issue-1696-error-protection-fix.svg
vendored
Normal file
78
.github/pr-images/issue-1696-error-protection-fix.svg
vendored
Normal 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
79
.github/scripts/pr-health-labels.py
vendored
Normal 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
38
.github/workflows/changelog-guard.yml
vendored
Normal 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."
|
||||
663
.github/workflows/ci.yml
vendored
663
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
49
.github/workflows/devcontainers.yml
vendored
49
.github/workflows/devcontainers.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
47
.github/workflows/docker.yml
vendored
47
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
51
.github/workflows/docs.yml
vendored
51
.github/workflows/docs.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
86
.github/workflows/eval.yml
vendored
86
.github/workflows/eval.yml
vendored
|
|
@ -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/
|
||||
|
|
|
|||
68
.github/workflows/init-e2e.yml
vendored
68
.github/workflows/init-e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/init-native-e2e.yml
vendored
3
.github/workflows/init-native-e2e.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
68
.github/workflows/install-native-e2e.yml
vendored
Normal file
68
.github/workflows/install-native-e2e.yml
vendored
Normal 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
34
.github/workflows/merge-conflicts.yml
vendored
Normal 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."
|
||||
164
.github/workflows/network-diff-capture.yml
vendored
Normal file
164
.github/workflows/network-diff-capture.yml
vendored
Normal 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
53
.github/workflows/opencode-plugin.yml
vendored
Normal 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
228
.github/workflows/pr-health.yml
vendored
Normal 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
|
||||
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
87
.github/workflows/release-metadata-sync.yml
vendored
Normal file
87
.github/workflows/release-metadata-sync.yml
vendored
Normal 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
57
.github/workflows/release-please.yml
vendored
Normal 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
|
||||
1738
.github/workflows/release.yml
vendored
1738
.github/workflows/release.yml
vendored
File diff suppressed because it is too large
Load diff
212
.github/workflows/rust.yml
vendored
212
.github/workflows/rust.yml
vendored
|
|
@ -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
120
.github/workflows/security.yml
vendored
Normal 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
60
.github/workflows/stale.yml
vendored
Normal 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.
|
||||
25
.github/workflows/wrap-e2e.yml
vendored
25
.github/workflows/wrap-e2e.yml
vendored
|
|
@ -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
72
.github/workflows/wrap-native-e2e.yml
vendored
Normal 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
35
.gitignore
vendored
|
|
@ -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
27
.gitleaks.toml
Normal 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)
|
||||
]
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
44
.release-please-config.json
Normal file
44
.release-please-config.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
".": "0.34.0"
|
||||
}
|
||||
9
.releasemetadata
Normal file
9
.releasemetadata
Normal 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
2
.serena/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/cache
|
||||
/project.local.yml
|
||||
169
.serena/project.yml
Normal file
169
.serena/project.yml
Normal 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 read‑only.
|
||||
# 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
|
||||
1737
CHANGELOG.md
1737
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
294
CONTRIBUTING.md
294
CONTRIBUTING.md
|
|
@ -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
1175
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
61
Cargo.toml
61
Cargo.toml
|
|
@ -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
|
||||
|
|
|
|||
123
Dockerfile
123
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
40
Makefile
40
Makefile
|
|
@ -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
14
NOTICE
|
|
@ -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
170
PR.md
|
|
@ -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
456
README.md
|
|
@ -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>60–95% fewer tokens · library · proxy · MCP · 6 algorithms · local-first · reversible</strong></p>
|
||||
<p align="center"><strong>60–95% 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 | 60–65% 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** — 40–90% 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 1–3 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.
|
||||
|
|
|
|||
41
RUST_DEV.md
41
RUST_DEV.md
|
|
@ -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).
|
||||
|
|
|
|||
10
SECURITY.md
10
SECURITY.md
|
|
@ -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
|
||||
|
|
|
|||
153
TESTING-copilot-subscription.md
Normal file
153
TESTING-copilot-subscription.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
282
benchmarks/i18n_compression_eval.py
Normal file
282
benchmarks/i18n_compression_eval.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
221
benchmarks/text_crusher_quality_eval.py
Normal file
221
benchmarks/text_crusher_quality_eval.py
Normal 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
338
claude_analysis_ttl.py
Normal 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()
|
||||
53
codecov.yml
53
codecov.yml
|
|
@ -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/**"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 − (w−r)·S`. When the cache is dead there is no suffix
|
||||
/// penalty and the full `ΔT·(w + r·(R−1))` 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 − (w−r)·S) and dead
|
||||
// (ΔT·(w + r·(R−1))) cases over P_alive gives a penalty over
|
||||
// S + ΔT, not S alone. The looser ·S form overstated gain by
|
||||
// P_alive·(w−r)·Δ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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
29
crates/headroom-core/src/onnx_cpu.rs
Normal file
29
crates/headroom-core/src/onnx_cpu.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
2027
crates/headroom-core/src/transforms/code_compressor.rs
Normal file
2027
crates/headroom-core/src/transforms/code_compressor.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
672
crates/headroom-core/src/transforms/kompress.rs
Normal file
672
crates/headroom-core/src/transforms/kompress.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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]"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue