Commit graph

2232 commits

Author SHA1 Message Date
Ashish
6469fcd018
feat(transforms): language-aware stack-trace collapse for Go/Rust/.NET/Java/Node (#1791)
## Description

Stack-trace handling covered only Python tracebacks and a generic ` at
symbol(` pattern. Go panics and Rust panics flowed to prose compression;
.NET traces were unrecognized; Java chained exceptions split into
separate traces at every `Caused by:` (so later chain heads fell off the
`max_stack_traces` cliff); and oversized traces were blindly
head-truncated — keeping runtime scheduler noise while dropping the app
frames and chain heads an agent actually needs.

This PR adds language-aware trace flavors (Go, Rust, .NET, Java chains,
Node async) to the Rust core and both Python mirrors, and replaces blind
truncation with a runtime-frame collapse: message lines, chain heads,
the trace head, and app-code frames survive; contiguous runtime/stdlib
frames fold into `[... N frames collapsed]` markers. A 147-line Go panic
dump compresses to 19 lines with the panic message, signal line, and app
frame intact.

Note one intentional behavior change: now that Go/Rust panics are
*detected*, panics ≤8KB in tool outputs gain the existing error-output
protection (`protect_error_outputs`) they previously missed — small
panics stay verbatim, exactly like small Python tracebacks already do.

## Type of Change

- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring

## Changes Made

- `crates/headroom-core/src/transforms/log_compressor.rs`:
- New `TraceFlavor::GoPanic` (`panic:` / `fatal error:` / `goroutine N
[state]:` openers, tab-indented `.go:` frame lines, `created by` /
call-line continuation, blank-separated goroutine blocks) and
`TraceFlavor::DotNet` (`Unhandled exception.`, `at … ) in file:line N`
frames — checked before Java since those frames also satisfy the Java
shape — plus `--->` inner-exception heads and `--- End of` separators).
- Renamed the misnamed `Go` flavor to `RustBacktrace` (its `is_go_frame`
matched `N: 0x<hex>` — the Rust backtrace shape) and gave it real
openers (`thread '…' panicked at`, `stack backtrace:`); the free-text
panic-message line after the opener stays in the trace (`terminates` now
receives `lines_so_far`).
- Java: continues across `Caused by:` / `Suppressed:` / `... N more`,
and `is_java_at_frame` admits `/` so JPMS module frames (`at
java.base/…`) pass the opener re-check — without this, modern JDK traces
fragmented at the parse cap into ≤20-line groups.
- Frame collapse (`collapse_trace_frames`): for traces over
`stack_trace_max_lines`, keeps message/chain-head lines, first
`trace_head_frames` frames, up to `trace_app_frames` app frames; runtime
frames (prefix + path marker tables per language) fold into `[... N
frames collapsed]` markers that occupy the run's first line slot and
carry score 0.8 so the global cap doesn't drop them first. Collapsed
frame indices are excluded from the context-line pass (otherwise ±3
context re-added them), and the parse cap re-opens on continuation lines
so selection sees one contiguous trace. New config:
`collapse_runtime_frames=true`, `trace_head_frames=3`,
`trace_app_frames=5`; new sidecar stat `runtime_frames_collapsed`.
- `crates/headroom-py/src/lib.rs`: the three new knobs on the
`LogCompressorConfig` PyO3 signature.
- `headroom/transforms/log_compressor.py`: dataclass fields +
constructor pass-through; `_parse_lines` opener patterns mirrored per
the documented contract.
- `headroom/transforms/content_detector.py`: `_LOG_PATTERNS` additions
(Go panic/goroutine/frame lines, Rust panic/backtrace/numbered frames,
.NET, Java chain heads, Node `at async`); JS/Java `at` pattern admits
JPMS module paths.
- `tests/test_transforms_stack_traces.py` (new, 10 tests) + 7 new Rust
unit tests (flavor open/continue/terminate, chain grouping, collapse
keeps chain heads/app frames, collapse-off comparison, small traces
untouched).

## Testing

- [x] Added new tests for the changes
- [x] All existing tests pass

### Test Output

```
$ cargo test -p headroom-core
928 passed; 3 ignored

$ python -m pytest tests/test_transforms_stack_traces.py tests/test_log_compressor.py \
    tests/test_transforms_log_compressor.py tests/test_transforms_content_detection.py \
    tests/test_transforms_content_router.py tests/test_transforms/test_content_router.py \
    tests/test_lossless_mode.py tests/test_compression_fidelity_regression.py -q
191 passed
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.13, repo main @ e8151f05,
`headroom._core` rebuilt via scripts/build_rust_extension.sh
- Exact command / steps: fed a 147-line Go panic + 24-goroutine dump and
a 66-line Java chained exception through
`LogCompressor(LogCompressorConfig(enable_ccr=False)).compress(...)`
- Observed result: Go dump 147 → 19 lines with `panic: runtime error…`,
`[signal SIGSEGV…]`, and the `main.handler` app frame kept, scheduler
frames as `[... 4 frames collapsed]` per goroutine; Java output keeps
`Caused by: java.io.IOException`, `com.example.Disk.read`, and `... 17
more` — all three were lost under blind truncation (verified by the
collapse-off comparison test)
- Not tested: Windows; PHP/Ruby traces (out of scope); interplay with
Kompress relevance-split on mixed log+trace payloads beyond the existing
suite

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:58:30 +00:00
Ashish
737b332129
feat(config): fold TOML array-of-tables to csv-schema via SmartCrusher (#1799)
## Description

Adds a schema-fold tier to the structured-config compressor (introduced
in #1784). TOML files containing an `[[array-of-tables]]` are parsed
with the stdlib `tomllib` reference parser and bridged to SmartCrusher's
lossless `csv-schema` renderer, which folds the repeated per-record keys
into a single schema over the rows. On lockfiles and override-lists —
where the repeated keys dominate the byte count — this is a large win.

**Stacked on #1784 — review only the top commit** (`feat(config): fold
TOML array-of-tables to csv-schema via SmartCrusher`). The base commit
is #1784's config-compressor PR; this PR will collapse to the single new
commit once #1784 merges.

Faithfulness is guaranteed by construction, not by a heuristic:
- `tomllib` is the reference TOML parser, so the extracted records are
ground-truth.
- `csv-schema` is a lossless JSON renderer (`smart_crusher.py` documents
it as such), so the model reads a faithful, reformatted view of the
exact parsed data.
- Byte-exact recovery rides the existing CCR path — the original is
persisted to the `CompressionStore` and a `Retrieve original: hash=…`
marker is emitted. The fold is only emitted when that store write
succeeds, so nothing is ever unrecoverable.

Scope is deliberately **TOML-only**: `tomllib` is stdlib, whereas PyYAML
is only a *transitive* dependency (not declared in `pyproject.toml`),
and INI record-sections would need a bespoke dict-of-dicts→records
transform. Those flavors can follow in a separate PR with an explicit
dependency decision.

## Type of Change

- [x] New feature (non-breaking change which adds functionality)

## Changes Made

- `headroom/transforms/config_compressor.py`: added Tier 3
(`_schema_fold`) — TOML `[[array-of-tables]]` → `tomllib` → JSON →
`SmartCrusher(csv-schema)`. New `enable_schema_fold` config flag
(default on; auto-off in lossless mode since it rides `enable_ccr`). The
fold competes with the reversible text tiers and is adopted only when
strictly smaller. Added `_load_toml` (stdlib parser with tomli backport)
and `_json_default` (TOML date/time → ISO; bail on any other
non-serializable value).
- Recovery reuses the existing `CompressionStore` + `Retrieve original:
hash=` marker; no new CCR plumbing.
- `tests/test_transforms_config_compressor.py`: 14 new tests covering
the fold, big-win assertion, byte-exact CCR round-trip, lossless-mode
disable, flag-off, non-TOML skip, no-array skip, small-array
`passthrough` decline, store-failure fallback, savings-floor rejection,
unparseable/non-serializable bails, `_load_toml`/`_json_default` units,
and a datetime-valued fold.

## Testing

- [x] New and existing unit tests pass locally
- [x] New tests added for the new behavior

### Test Output

```
tests/test_transforms_config_compressor.py ............................. [ 59%]
headroom/transforms/config_compressor.py     127      0     36      0   100%
============================== 49 passed in 0.61s ==============================
```

Must-stay-green suites (`test_lossless_mode`,
`test_lossless_excluded_compaction`,
`test_transforms_content_detection`,
`test_compression_fidelity_regression`) — 48 passed.
Router/tabular/smart_crusher regression — 73 + 82 passed. `mypy
--strict` clean on the changed module.

## Real Behavior Proof

- Environment: local, Python 3.11.0, macOS (darwin), `HF_HUB_OFFLINE=1`
- Exact command / steps: parsed a 25-record `[[tool.mypy.overrides]]`
TOML through
`ConfigCompressor(ConfigCompressorConfig(enable_ccr=True)).compress()`,
then retrieved the CCR hash from the `CompressionStore`.
- Observed result: `strategy=config_schema_fold`, 2765 → 840 chars (30%
of original); the marker hash resolved to the byte-exact original
(`recovered == original` True); with `enable_ccr=False` (lossless mode)
the fold did not run and no marker was emitted; a 3-record long-valued
`[[package]]` array correctly declined (SmartCrusher `passthrough`).
- Not tested: the live proxy end-to-end path and non-TOML flavors
(YAML/INI schema folding is intentionally out of scope for this PR).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:58:27 +00:00
gglucass
12a9710665
feat(stats): per-bucket output-shaping savings in /stats-history (#1819)
## Description

Adds per-bucket **output-shaping savings** to `/stats-history`. Today
output-shaping savings exist only as a single global aggregate
(`savings.by_layer.output_shaping`), so downstream consumers can't chart
them over time. This threads a per-request output-savings estimate into
the existing rollup so every `series` bucket carries
`output_tokens_saved_delta` + `output_savings_usd_delta`, symmetric with
the existing `compression_savings_usd_delta`.

Motivation: on Claude Code subscription traffic, input is ~99%
cache-discounted (the compressible live zone is a fraction of a
percent), while output shaping is a ~36% reduction on full-price output
tokens — so it's the dominant, honestly-attributable saving, and
currently the only one a dashboard can't render per day.

Closes #1816

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Changes Made

- `output_savings.py`: new read-only
`SavingsRecorder.estimate_request_savings(labels, output_tokens)` →
per-request synthetic-control estimate `max(0, baseline_mean(stratum) -
output_tokens)` for treatment requests; 0 for control / unknown stratum
/ no label. Does **not** mutate the ledger, so it composes with
`record_from_labels` without double-counting. `record_from_labels`'s
`bool` contract is unchanged.
- `outcome.py`: in the funnel, capture that estimate and pass it to
`record_request(output_tokens_saved=...)`.
- `savings_tracker.py`: `record_request` gains `output_tokens_saved`;
accumulates lifetime cumulative `output_tokens_saved` /
`output_savings_usd` (priced via new `_estimate_output_savings_usd`,
output-rate), writes them into each checkpoint, and now checkpoints when
**either** compression **or** output savings occurred (so output-only
requests aren't dropped). `_build_rollup` diffs the cumulative into
`output_tokens_saved_delta` / `output_savings_usd_delta` per bucket;
`_normalize_history_entry` and the CSV export carry the fields.
- Additive + backward-compatible: checkpoints predating the feature
default the new fields to 0.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run --extra dev pytest tests/test_output_shaping_rollup.py tests/test_output_savings.py \
    tests/test_output_savings_cli.py tests/test_proxy_savings_history.py tests/test_request_outcome.py -q
... 103 passed

$ uv run --extra dev ruff check headroom/proxy/savings_tracker.py headroom/proxy/output_savings.py \
    headroom/proxy/prometheus_metrics.py headroom/proxy/outcome.py tests/test_output_shaping_rollup.py
All checks passed!

$ uv run --extra dev mypy headroom/proxy/savings_tracker.py headroom/proxy/output_savings.py
Success: no issues found in 2 source files
```

New tests (`tests/test_output_shaping_rollup.py`): output savings bucket
into the daily series; an output-only request (no compression) still
checkpoints; pre-feature requests default to 0;
`estimate_request_savings` returns the baseline-relative saving for
treatment and 0 for control / unknown / over-baseline.

## Real Behavior Proof

- Environment: macOS, CPython 3.10.18, this branch (rebased on latest
`main`), litellm pricing available.
- Exact command / steps: seed a baseline (as `learn --verbosity` would),
then drive 3 requests through the real, unmocked chain
`SavingsRecorder.estimate_request_savings` →
`SavingsTracker.record_request` → `history_response()`, and print
`series.daily`. Full script + raw output:

```text
$ uv run python proof.py   # seeds baseline ~1000 out-tok; 3 treatment requests (out=600/550/700), one with no compression
[
  { "timestamp": "2026-07-05T00:00:00Z", "tokens_saved": 120,
    "compression_savings_usd_delta": 0.0006,
    "output_tokens_saved_delta": 850, "output_savings_usd_delta": 0.02125 },
  { "timestamp": "2026-07-06T00:00:00Z", "tokens_saved": 80,
    "compression_savings_usd_delta": 0.0004,
    "output_tokens_saved_delta": 300, "output_savings_usd_delta": 0.0075 }
]
```

- Observed result: output-shaping savings appear per day and independent
of the compression axis. 2026-07-05 = 850 (400+450 saved by two
treatment requests vs the ~1000-token baseline, including one request
with zero compression — proving the output-only checkpoint path),
2026-07-06 = 300, each priced at the model's output rate. Matches
expectations.
- Not tested: the full live proxy over HTTP with a real learned baseline
and organic traffic — I exercised the same code path minus the
HTTP/streaming layer. The measured-vs-estimated `method` gating is
unchanged by this PR.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — backend-only change (no UI surface in this repo). The runtime
effect is the `/stats-history` `series.daily` JSON with the new
`output_tokens_saved_delta` / `output_savings_usd_delta` fields, shown
under **Real Behavior Proof** above. The downstream chart that renders
them lives in the separate Headroom desktop app.

## Additional Notes

- Per CONTRIBUTING's issue-first policy for features, I opened #1816
first with the spec; happy to adjust the API surface (field names /
gating) to whatever you prefer. A downstream consumer (Headroom desktop
chart) is already implemented against this exact contract and stacks the
segment only when `output_reduction.method == "measured"`.
- Docs checkbox left unchecked: I didn't find a `/stats-history` schema
doc to update; point me at one if it exists.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:24 +00:00
Rudimar Ronsoni
dec60de976
fix(codex): preserve wrapped sessions and recover state (#2160)
## Description

Closes #2159.

Codex wrappers currently launch against a disposable `CODEX_HOME`, so
session state created during a wrapped run can disappear when that
temporary directory is removed. This change launches Codex against its
durable home, keeps proxy routing process-local, and adds recovery for
retained temporary homes and pinned recovery sources.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] 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
- [ ] Code refactoring (no functional changes)

## Changes Made

- Launch Codex against its durable `CODEX_HOME` and apply routing
through process-local config overrides after the actual proxy port is
resolved.
- Preserve custom provider identity and reject providers that cannot be
redirected safely.
- Detect dangling temporary Codex homes before interactive wraps and
offer recovery.
- Add `headroom recover codex` with automatic discovery, repeatable
`--source`, preview, confirmation, retained backups, and rollback on
failure.
- Search Python's temp root, `$TMPDIR`, `/tmp`, `/private/tmp`, and
macOS `/private/var/folders/*/*/T` for retained `headroom-codex-home-*`
directories.
- Reuse `source-pinned/` copies left by interrupted or failed recovery
attempts after the original temporary home has disappeared.
- Report deleted temporary homes still referenced by SQLite rollout
paths without treating paths pasted into prompts or errors as filesystem
evidence.
- Audit the durable thread index, rollout files, and history when no
source remains, including indexed chat counts and history-only orphan
records.
- Normalize legacy localhost `headroom` providers in both SQLite thread
rows and rollout `session_meta`, including retries after an earlier
broken recovery, while preserving user-defined remote providers named
`headroom`.
- Merge compatible config, JSONL, rollout, SQLite, credential, and
regular-file state without propagating deletions or runtime artifacts.
- Rewrite recovered thread rollout paths to the durable home and restore
legacy Headroom thread providers to the active provider.
- Validate SQLite schemas, SQLx migration checksums, integrity, and
foreign keys, and quarantine malformed JSONL.
- Preserve failed targets with an atomic rename before rollback,
avoiding recursive-deletion races with live SQLite runtime files.
- Document discovery, migration, retained backups, rollback behavior,
and the limits of deleted-source recovery.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed in isolated Docker containers

### Test Output

```text
$ uv run pytest tests/test_cli/test_wrap_codex.py tests/test_cli/test_recover_codex.py -q
122 passed

$ uv run ruff check headroom/cli/recover.py headroom/providers/codex/recovery.py tests/test_cli/test_recover_codex.py
All checks passed!

$ uv run ruff format --check headroom/cli/recover.py headroom/providers/codex/recovery.py tests/test_cli/test_recover_codex.py
3 files already formatted

$ uv run mypy headroom/cli/recover.py headroom/providers/codex/recovery.py
Success: no issues found in 2 source files
```

All validation ran in `ghcr.io/astral-sh/uv:python3.12-bookworm` against
a writable disposable copy of a read-only source mount. Codex was not
installed or launched, and no real user Codex state was read or
modified.

The tests cover multi-root discovery, deleted-reference reporting,
retained pinned-source recovery, durable SQLite path relocation, SQLite
and rollout provider normalization, idempotent repair after an earlier
broken recovery, remote provider preservation, unrelated dangling target
rows, backup retention, atomic rollback, malformed-state quarantine,
SQLite validation, and Windows-safe handle closure.

The repository shim E2E was not launched locally because this recovery
work intentionally avoids launching Codex. Upstream CI exercises wrapper
E2E in isolated environments.

## Real Behavior Proof

- Environment: `ghcr.io/astral-sh/uv:python3.12-bookworm`, Python 3.12,
a writable disposable checkout copied from a read-only source mount, at
head `2d89ecec`.
- Exact command / steps: Run `pytest -q
tests/test_cli/test_wrap_codex.py tests/test_cli/test_recover_codex.py`,
then run `ruff check` and `ruff format --check` against
`headroom/cli/wrap.py`, `headroom/cli/recover.py`,
`headroom/providers/codex/recovery.py`,
`tests/test_cli/test_wrap_codex.py`, and
`tests/test_cli/test_recover_codex.py`.
- Observed result: `122 passed in 10.08s`; Ruff reported `All checks
passed!` and `5 files already formatted`.
- Not tested: Launching a real Codex process or modifying a real user
`CODEX_HOME`; these were intentionally excluded to protect live user
state.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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 where the behavior is hard to understand
- [x] I have made corresponding documentation changes
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and existing focused unit tests pass with my changes
- [x] I have updated `CHANGELOG.md` if applicable

## Additional Notes

The temporary-home behavior was introduced by #1507 in
`ad9d086f43`. Related context: #730, #731,
#961, #1034, #1050, #1349, #1853, #1889, #2103, and #2104.

A temporary home that macOS or `TemporaryDirectory` already deleted
cannot be reconstructed unless a retained `source-pinned/` copy exists.
Recovery identifies genuine dangling SQLite paths, audits surviving
durable history, and recovers any retained pinned source it can find.
Prompt text without a rollout cannot reconstruct a full transcript.

The unchecked changelog item is not applicable because this repository
does not require a changelog entry for this fix.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:21 +00:00
Krishna Chaitanya
57e8dcb425
feat(proxy): add opt-in cost-aware model router (#1706) (#2205)
## Description

Adds an optional, configuration driven model router (closes #1706). With
`HEADROOM_MODEL_ROUTER_ENABLED` set, ordered rules in
`HEADROOM_MODEL_ROUTES` rewrite the upstream model by estimated input
size and tool presence, complementary to content compression, for
example sending small, tool-free requests to a cheaper model. First
matching rule wins and every decision is logged with a reason. Off by
default so behavior is unchanged, skipped under
`x-headroom-bypass`/passthrough, and wired on the Anthropic
`/v1/messages` path. Malformed rules fail open, so a bad rule is skipped
rather than silently widened.

Closes #1706

## 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
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/model_router.py`: new `ModelRouter` component (ordered
rules, first-match decision with reason, fail-open env parsing,
tokenizer-free input estimate).
- `headroom/proxy/models.py` + `headroom/proxy/server.py`:
`ProxyConfig.model_router` field, env loader
(`HEADROOM_MODEL_ROUTER_ENABLED` / `HEADROOM_MODEL_ROUTES`), and proxy
wiring.
- `headroom/proxy/handlers/anthropic.py`: apply routing on
`/v1/messages` after the bypass gate, tracked as a body mutation.
- Tests, docs (`configuration.mdx`), and a CHANGELOG entry.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest -q tests/test_proxy/test_model_router.py tests/test_proxy/test_model_router_wiring.py
36 passed, 1 warning

$ ruff check .
All checks passed!

$ mypy headroom --ignore-missing-imports
Success: no issues found in 477 source files
```

## Real Behavior Proof

- Environment: local, macOS, Python 3.12, headroom `.venv`, upstream
mocked (no live provider call).
- Exact command / steps: enable the router via
`ProxyConfig(model_router=...)`, POST `/v1/messages` through
`TestClient` with a rule routing low-risk requests to a cheaper model;
repeat with header `x-headroom-bypass: true`.
- Observed result: the forwarded upstream body model is rewritten from
`claude-sonnet-4-6` to `claude-haiku-4-5` when the router is enabled,
and is left unchanged under bypass (see
`tests/test_proxy/test_model_router_wiring.py`).
- Not tested: the OpenAI and Gemini handler paths (this PR wires the
Anthropic path only); no live provider request (upstream is mocked).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Additional Notes

Happy to adjust the interface or scope (for example OpenAI and Gemini
parity) if you'd prefer a different shape.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: reneleonhardt <65483435+reneleonhardt@users.noreply.github.com>
2026-07-15 19:58:17 +00:00
Chester
aa4515cf7a
fix(memory): filter inactive graph-expanded results (#2210)
## DescriptionKeep graph-expanded local-memory results consistent with
the current-only contract already applied by vector search.Closes
#2209## Type of Change- [x] Bug fix (non-breaking change that fixes an
issue)- [ ] New feature (non-breaking change that adds functionality)- [
] Breaking change (fix or feature that would cause existing
functionality to change)- [ ] Documentation update- [ ] Performance
improvement- [ ] Code refactoring (no functional changes)## Changes
Made- Reject graph-expanded memories whose `valid_until` is set.- Reject
graph-expanded memories whose `superseded_by` is set.- Add focused
coverage for active, expired, and superseded related memories.##
Testing- [x] Unit tests pass (`pytest`)- [x] Linting passes (`ruff check
.`)- [ ] Type checking passes (`mypy headroom`)- [x] New tests added for
new functionality- [ ] Manual testing performed### Test Output```text$
uv run --with pytest --with pytest-asyncio --with numpy pytest
tests/test_memory/test_local_backend_search.py -q3 passed$ uv run --with
ruff ruff check headroom/memory/backends/local.py
tests/test_memory/test_local_backend_search.pyAll checks passed!$ uv run
--with ruff ruff format --check headroom/memory/backends/local.py
tests/test_memory/test_local_backend_search.py2 files already
formatted```## Real Behavior Proof- Environment: Python 3.13, synthetic
in-memory test doubles- Exact command / steps: run the focused test file
above- Observed result: active graph-linked memory is returned; records
with `valid_until` or `superseded_by` are excluded- Not tested: full
repository suite, external vector/graph implementations## Review
Readiness- [x] I have performed a self-review- [x] This PR is ready for
human review## Checklist- [x] My code follows the project's style
guidelines- [x] I have performed a self-review of my code- [ ] I have
commented my code, particularly in hard-to-understand areas- [ ] I have
made corresponding changes to the documentation- [ ] 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- [ ] I have updated the CHANGELOG.md if applicable##
Screenshots (if applicable)N/A## Additional NotesDocumentation and
changelog changes are not needed for this narrow internal behavior fix.
The existing temporal-history APIs remain unchanged.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:13 +00:00
Zhenjia ZHOU
8537e2cf60
fix(wrap): self-heal a stale ANTHROPIC_BASE_URL left by a dead proxy (#2223)
## Description

`headroom wrap claude` persists `ANTHROPIC_BASE_URL=<proxy>` into
project-local `.claude/settings.local.json`. This is required: Claude
Code's cc-daemon spawn-forks conversation workers that read settings
fresh rather than inherit env, so the URL cannot just live in the child
process env.

When the proxy then dies via a **hard reboot / SIGKILL**, no
signal/atexit cleanup fires, so the stale URL lingers and bricks a later
**bare `claude`** with ConnectionRefused (#2221). #1768's mitigations
(SIGHUP, next-`wrap` self-heal, doctor WARN) don't cover "reboot → bare
`claude`", and — the key gap — `wrap` installed no hook of its own, so
for a user who only ever ran `wrap claude` (never `init claude`) there
was nothing to clean it up.

`wrap claude` now installs a **SessionStart-only** self-heal hook
(removed again on `unwrap`) that clears the persisted base URL **iff the
recorded proxy port fails a retry-hardened liveness probe**. A
responding proxy is never cleared, and the retry (3 attempts ~250 ms
apart, alive on first success) keeps a transient blip from clearing a
live session mid-run. Because workers read settings fresh per
conversation, clearing at session start unblocks the current session
too, not only the next.

## Design note / assumption (for maintainer confirmation)

This relies on **the SessionStart hook completing before the first
cc-daemon conversation worker reads `settings.local.json`**. That
ordering lives in Claude Code, not this repo; it is grounded in the
documented spawn-fresh-read model (the same reason the URL must be
persisted at all). Raised on the issue for confirmation. The truly
launcher-agnostic fix would be upstream — Claude Code falling back to
the real upstream when its configured base URL is unreachable — which
would make any stale local URL harmless.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cli/wrap.py`:
- `_wrap_proxy_alive(port, attempts=3, delay=0.25)` — retry-hardened
liveness (alive on first success, dead only if all fail).
- `_check_and_clear_dead_wrap_marker` — port is authoritative (survives
PID reuse after reboot); a single probe decides; a responding proxy is
never cleared; falls back to PID staleness only for port-less markers.
- `_ensure_claude_wrap_selfheal_hook` /
`_remove_claude_wrap_selfheal_hook` — install on `wrap claude`, remove
on `unwrap`; **SessionStart-only** (never PreToolUse), idempotent,
preserves the `env` block and unrelated/user hooks.
  - hidden `wrap selfheal` command the hook invokes.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality

### Test Output

```text
$ pytest tests/test_cli/test_wrap_dead_marker_selfheal.py
22 passed

$ pytest <related wrap/unwrap suites>
59 passed, 1 failed   # the 1 failure (test_wrap_marker_is_stale_when_pid_reused)
                      # is PRE-EXISTING + unrelated — fails identically on clean main
                      # (macOS _proc_identity returns None); this PR touches neither
                      # _wrap_marker_is_stale nor _identity_mismatch.

$ ruff check / mypy headroom/cli/wrap.py   # clean
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python in a uv venv, branch
`feat/wrap-stale-url-selfheal` off `main`.
- Exact command / steps: `pytest
tests/test_cli/test_wrap_dead_marker_selfheal.py` exercises: `wrap
claude` writes a SessionStart-only self-heal hook into
`settings.local.json` (idempotent, not on PreToolUse); `unwrap` removes
it (keeping unrelated hooks); the `wrap selfheal` command clears a
dead-port marker's base URL; and — bound to a REAL listening socket — a
live proxy's marker is never cleared, including when a single probe
transiently fails but the retry succeeds.
- Observed result: dead-proxy marker → base URL restored to its prior
value; live-proxy marker (real socket) → preserved; no marker / no
settings file / port-less marker → no-op, no exception. All 22 pass.
- Not tested: the actual Claude Code hook-vs-worker execution ordering
(upstream, not in this repo) — see the Design note; the fix is correct
given that documented model.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation — N/A
(internal wrap behavior)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — happy to add an entry if
preferred.

## Additional Notes

- Scoped to the `wrap claude` project-local path (the reported
scenario). The opt-in cc-switch reconciler writes `ANTHROPIC_BASE_URL`
into the *global* `~/.claude/settings.json` with no restore today — a
separate, lower-frequency gap I can follow up on if wanted.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:10 +00:00
Ingmar Krusch
ea0115cbdb
fix(backend/bedrock): preserve system-prompt cache_control breakpoint (list form) (#2225)
## Description

The LiteLLM backend flattened the Anthropic top-level `system` field to
a joined string whenever it arrived as a **list of content blocks**,
discarding each block's `cache_control`. LiteLLM's Bedrock Converse
transform (`AmazonConverseConfig._transform_system_message`) only emits
a `cachePoint` for content blocks that carry `cache_control`, never for
a plain string. So on any `--backend bedrock` deployment the **system
prefix was never cached**: every turn re-sent the full system prompt
(typically 5k-25k tokens with Claude Code) at full input price.

#1390 fixed the analogous case for `tool_result` blocks in
`_convert_messages_for_litellm`, but the top-level `system` field
handling in `send_message` / `stream_message` was out of scope there and
still flattened. The cache hits observed on live Bedrock traffic came
only from the tool-result / message-tail breakpoint, masking that the
largest, most stable block was uncached.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/backends/litellm.py`: factor the top-level `system` field
conversion into a single `_system_field_to_message` helper. `str` stays
string-content (unchanged behavior); a `list` maps to text blocks
retaining each block's `cache_control`; non-dict entries coerce to a
plain text block. Both call sites (`send_message` non-streaming,
`stream_message` streaming) now call the helper, so they stay
byte-identical.
- `tests/test_bedrock_tool_result_cache_and_streaming_stats.py`: add
`TestSystemFieldCacheControl` — list-with-`cache_control` retains it,
plain-string is unchanged, list-without-`cache_control` produces list
content with no marker, plus two end-to-end checks that drive the
emitted message through `AmazonConverseConfig._transform_system_message`
and assert a `cachePoint` is present for the cache_control case and
absent otherwise.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_bedrock_tool_result_cache_and_streaming_stats.py -q
collected 13 items
tests/test_bedrock_tool_result_cache_and_streaming_stats.py .............  [100%]
13 passed in 1.18s

$ uv run ruff check headroom/backends/litellm.py tests/test_bedrock_tool_result_cache_and_streaming_stats.py
All checks passed!
```

## Real Behavior Proof

- Environment: personal fork deployed as a real proxy (macOS launchd
service, `headroom install apply`) with `--backend bedrock --mode
cache`, fronting a live Claude Code session. Model
`global.anthropic.claude-sonnet-5`, region eu-west-1.
- Exact command / steps: ran a purpose-built probe that POSTs
Anthropic-shape `/v1/messages` to the running proxy with a 7,692-token
STABLE system prompt carrying a single `cache_control: {type:
ephemeral}` breakpoint (and no other cache_control anywhere), a pinned
`x-headroom-session-id`, across 5 sequential turns, reading the raw
response `usage` each turn.
- Observed result: **before the fix**, the response `usage` had no cache
fields at all — `cache_creation_input_tokens` and
`cache_read_input_tokens` both absent, nothing cached. **After the
fix**, turn 1 shows `cache_creation_input_tokens=10164` (write) and
turns 2-5 each show `cache_read_input_tokens=10164` (read) with
`cache_creation=0` — write-once, then read the system prefix from
Bedrock's cache on every subsequent turn. The proxy's `/stats`
`prefix_cache` tracker registered all four later turns as hits
(`hit_requests += 1` per turn, `bust_count = 0`).
- Not tested: no change to the tool_result / message-tail breakpoint
path (already handled by #1390 / #2144); this fix is scoped to the
top-level `system` field only. The in-`messages` text-block flatten in
`_convert_messages_for_litellm` is intentionally left untouched.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- No linked issue number: found via independent investigation of a
personal `--backend bedrock` deployment.
- Companion to #2196 (`fix(proxy/bedrock): wire PrefixCacheTracker
updates into Bedrock backend paths`) from the same investigation. #2196
wires the tracker; this fixes the system-prompt breakpoint that #1390
left flattened on the top-level `system` field.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:58:06 +00:00
Abhay Singh
842d7e1ad1
fix(ccr): lowercase a retrieved hash so an uppercase echo still hits the store (#2236)
## Description

A CCR retrieval fails whenever the model echoes the content hash in
uppercase, even though the content is present in the store.

`parse_tool_call` extracts and validates the hash from a
`headroom_retrieve` tool call:

```python
# Validate hex characters only
if not all(c in "0123456789abcdef" for c in hash_key.lower()):
    return None

return hash_key
```

The hex check is deliberately case-insensitive (`hash_key.lower()`), so
an uppercase hash passes validation — but the value is then returned
**verbatim**. The compression store, however, keys every entry by a
lowercase hash: writes use either a sha256 hexdigest
(`hashlib.sha256(...).hexdigest()[:24]`, always lowercase) or
`explicit_hash.lower()`, and `retrieve` / `get_entry_status` look the
key up as-is with no normalization.

So when a model reproduces the marker hash in uppercase (LLMs routinely
normalize hex casing when they copy tokens), the retrieve endpoint
validates it, calls `store.retrieve("ABC…")` against a store that only
holds `"abc…"`, and reports a miss — the original content is unreachable
even though it is right there. The case-insensitive validation shows the
intent was to accept either casing; only the return value was left
un-normalized.

## Fix

Return the canonical lowercase form so the whole pipeline is
consistently lowercase:

```python
return hash_key.lower()
```

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/ccr/tool_injection.py`: `parse_tool_call` returns
`hash_key.lower()`.
- `tests/test_ccr_tool_injection.py`: new test asserting an uppercase
hash is normalized to lowercase.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/ccr/tool_injection.py tests/test_ccr_tool_injection.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/ccr/tool_injection.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the validate/return + a lowercase-keyed store with a
dependency-free script and left the full pytest to CI.
- Exact command / steps: put `"abc123def456abc123def456" -> content` in
a store, then looked it up with the uppercase echo
`"ABC123DEF456ABC123DEF456"` through the OLD (return verbatim) and NEW
(return `.lower()`) paths.
- Observed result: OLD returns the uppercase hash → store miss; NEW
returns the lowercase hash → store hit (original content recovered). A
lowercase hash resolves under both.
- Not tested: a live model round-trip that uppercases the marker; full
local `pytest` deferred to CI (OOM).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test lives
alongside the existing `parse_tool_call` tests in
`tests/test_ccr_tool_injection.py`, so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:57:58 +00:00
Shubham Srivastava
17d60dce1f
docs(troubleshooting): document Windows Defender ast-grep-cli false positive + workarounds (#2200) (#2237)
## Description

On Windows, `uv tool install "headroom-ai[all]"` (and `pip install`)
fails while installing the `ast-grep-cli` wheel because Windows Defender
quarantines the bundled `sg.exe` as `Trojan:Win64/Lazy!MTB` (`os error
225`). This is a **known upstream false positive** in the `ast-grep-cli`
wheel
([ast-grep/ast-grep#2799](https://github.com/ast-grep/ast-grep/issues/2799)),
not a Headroom-introduced problem — but because `ast-grep-cli` is a base
dependency, the local install path is blocked on affected Windows
machines.

The issue (#2200) explicitly asks: "At minimum, please document a
supported workaround." This adds a troubleshooting entry with
safest-first workarounds. `ast-grep` is used only for optional AST-based
Read-output outlining and Headroom degrades gracefully without it, so
the impact is purely the install-time quarantine.

Closes #2200

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] 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
- [ ] Code refactoring (no functional changes)

## Changes Made

`docs/content/docs/troubleshooting.mdx` only — a new `### Windows:
Defender blocks ast-grep-cli (sg.exe) during install` subsection under
the existing `## Installation Issues` section, following the file's
`**Symptom**` / `**Cause**` / workarounds pattern:

- **Symptom** — the exact `uv tool install` failure text (`os error
225`, `Trojan:Win64/Lazy!MTB`, `sg.exe`) so users match it by search.
- **Cause** — known upstream `ast-grep-cli` wheel false positive
(linked); base dependency so it hits `[proxy]` too; `ast-grep` is
optional at runtime and Headroom runs without it.
- **Workarounds, safest first:** (1) run the proxy in Docker (no local
wheel → no AV trigger); (2) restore `sg.exe` from Defender quarantine
and retry (no persistent change); (3) a temporary, *scoped* Defender
exclusion for `uv tool dir` during install, framed as a known false
positive with a caution not to disable Defender wholesale; (4) report
the false positive to Microsoft for a durable signature fix.

Explicitly out of scope: making `ast-grep-cli` optional (a
dependency-policy change requiring maintainer justification per
CONTRIBUTING). No code change.

## Testing

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

Docs-only; verification is fact cross-check + MDX sanity:

```text
$ grep -n "ast-grep-cli>=" pyproject.toml
60:    "ast-grep-cli>=0.30.0",       # AST-aware code slicing (CodeCompressor); binary wheel
# → confirms ast-grep-cli is a base dependency (affects [proxy] too)

$ sed -n '6,7p' headroom/proxy/interceptors/astgrep.py
followed by an elided body marker. Falls back to the original text if
ast-grep isn't available, the extension isn't supported, or there are fewer
# → confirms graceful degradation: Headroom runs without a working sg.exe

$ uv tool dir
C:\Users\<user>\AppData\Roaming\uv\tools
# → the directory the scoped-exclusion workaround targets (via `uv tool dir`, not a hardcoded path)

# MDX sanity: balanced code fences (even count), well-formed headings, links close.
```

## Real Behavior Proof

- **Environment:** Windows 11 (the affected platform), the docs source
inspected against the current `main` base.
- **Exact command / steps:** Issue #2200 contains a complete, exact
reproduction (command `uv tool install "headroom-ai[all]"`, the `os
error 225` / `Trojan:Win64/Lazy!MTB` failure on `sg.exe`, `ast-grep-cli
0.44.1`, `uv 0.11.16`, Windows 11). The documented facts are verified
against the tree: base-dependency declaration (`pyproject.toml:60`) and
graceful degradation (`headroom/proxy/interceptors/astgrep.py:6-7`). The
`uv tool dir` command used in the exclusion workaround resolves
correctly on this machine.
- **Observed result:** The troubleshooting note accurately describes the
failure and gives valid Windows/Defender workarounds, ordered
safest-first.
- **Not tested:** I deliberately did **not** run `uv tool install
"headroom-ai[all]"` to force a live Defender quarantine — doing so is
disruptive (it can quarantine real files and pulls the full dependency
set) and machine-specific. The reproduction in the issue is complete and
corroborated by the upstream ast-grep report.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A (troubleshooting prose addition).

## Additional Notes

- Test/tests-added and CHANGELOG checklist items are N/A —
documentation-only change (kept to a single file, matching the merged
#2031 precedent).
- The durable fix for the underlying false positive belongs upstream
(ast-grep) and/or with Microsoft's signature update; this PR documents
supported workarounds in the meantime, as the issue requested.
- Making `ast-grep-cli` an optional dependency would remove the install
blocker at the source, but that's a dependency-policy change for
maintainers to weigh (the interceptor already tolerates its absence) —
intentionally not attempted here.
2026-07-15 19:57:55 +00:00
Jervis
09c66ac212
fix(proxy): batch small Codex Responses tool outputs (#2239)
## Description

Batches small Codex/OpenAI Responses tool-output units through the
existing ContentRouter instead of skipping each unit individually below
the 512-byte floor. This fixes sessions where many small tool outputs
are collectively worth compressing, but no single output clears the
per-unit threshold.

The change keeps larger units on the existing independent compression
path, preserves CCR retrieval markers and protected tags across the
batch envelope, rejects structurally invalid batch output, and leaves
under-floor tails as size-floor passthroughs.

Fixes #2234

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added `headroom/transforms/compression_batches.py` for bounded
compatible-unit batching, batch envelope parsing, tag/CCR marker
preservation, and per-entry result splitting.
- Updated the OpenAI Responses compression adapter to batch small
tool-output text slots while keeping larger units on the existing cached
per-unit path.
- Switched the unit size floor to UTF-8 bytes so CJK and other multibyte
text are measured consistently with the byte threshold.
- Added regression coverage for batching, CJK byte floors, CCR marker
preservation, malformed batch rejection, array output parts, and
under-floor tails.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run --with pytest --with fastapi --with httpx --with anyio --with uvicorn --with h2 pytest tests/test_compression_batches.py tests/test_compression_units.py tests/test_openai_responses_compression_units.py -q
47 passed, 1 warning

$ uvx ruff==0.15.17 check headroom/proxy/handlers/openai.py headroom/transforms/compression_batches.py headroom/transforms/compression_units.py tests/test_compression_batches.py tests/test_compression_units.py tests/test_openai_responses_compression_units.py --output-format concise
All checks passed!

$ uvx ruff==0.15.17 format --check headroom/proxy/handlers/openai.py headroom/transforms/compression_batches.py headroom/transforms/compression_units.py tests/test_compression_batches.py tests/test_compression_units.py tests/test_openai_responses_compression_units.py
6 files already formatted

$ uv run --with mypy mypy headroom/transforms/compression_batches.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.3, local checkout of this PR
branch.
- Exact command / steps: ran the focused batching/unit/OpenAI Responses
test suites above, including cases where four individually-small tool
outputs collectively exceed the shared floor and where output arrays
contain multiple text parts plus non-text parts.
- Observed result: small outputs are sent through one router call and
applied back to their original slots; under-floor tails remain
unmodified; non-text parts are preserved; CCR markers are retained or
the entire batch is rejected if moved/corrupted.
- Not tested: a live Codex Responses proxy session against an upstream
model; full-suite collection was not run locally.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:57:50 +00:00
Vinay Gupta
3f241e472b
fix(ccr): skip compact summaries for proactive expansion (#2242)
## Description

Fixes #2186.

Claude Code `/compact` continuation summaries are already session
context. When Headroom tracks those summaries for CCR proactive
expansion, later fresh sessions can receive stale compacted history
again inside `<headroom_proactive_expansion>` blocks, increasing token
usage and busting cache stability.

This PR keeps CCR storage/retrieval intact but excludes probable Claude
Code compact-summary payloads from the proactive-expansion tracker.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added a narrow Claude Code compact-summary detector to the CCR context
tracker.
- Skipped tracking compact summaries when feeding Anthropic CCR metadata
into proactive expansion.
- Added an original-content preview to CCR metadata so the Anthropic
feed point can classify compact summaries even when compressed text
loses the distinctive header.
- Added regression coverage proving compact summaries are not tracked
and ordinary summaries are still eligible.

## Testing

- [x] Unit tests pass
- [x] Linting passes
- [x] Formatting check passes
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ pytest tests/test_ccr_context_tracker.py -q
37 passed

$ uvx ruff==0.15.17 check headroom/ccr/context_tracker.py headroom/cache/compression_store.py headroom/proxy/handlers/anthropic.py tests/test_ccr_context_tracker.py --output-format concise
All checks passed!

$ uvx ruff==0.15.17 format --check headroom/ccr/context_tracker.py headroom/cache/compression_store.py headroom/proxy/handlers/anthropic.py tests/test_ccr_context_tracker.py
4 files already formatted
```

## Real Behavior Proof

- Environment: local checkout on macOS, Python test environment used by
the repository.
- Exact command / steps: ran the focused CCR context tracker suite after
adding compact-summary detection and tracker-feed filtering.
- Observed result: compact-summary payloads are not tracked for
proactive expansion, ordinary summary-like tool output is still
eligible, and the existing tracker behavior remains covered by the full
focused suite.
- Not tested: live Claude Code `/compact` session through a running
proxy.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review
- [x] I have added tests that prove the fix is effective
- [ ] I have updated the CHANGELOG.md if applicable
2026-07-15 19:57:46 +00:00
LunarECL
fcf455a7eb
feat(wrap): add omp target (Oh My Pi) with models.yml override and unwrap (#1811)
## Description

Adds `headroom wrap omp` / `headroom unwrap omp` — a one-command wrap
for [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
(`omp`), the pi-mono-lineage coding agent, as proposed in #1149.

One honest correction to the issue: #1149 proposed reusing the
`ANTHROPIC_BASE_URL` redirect from `wrap claude`. During implementation
I probed that empirically and it turned out to be wrong — omp only reads
`ANTHROPIC_BASE_URL` in its web-search helper; its **chat** endpoint
comes from the model registry (`providers.anthropic.baseUrl` in
`~/.omp/agent/models.yml`). With the env var pointed at a local probe
server, omp's chat traffic still went straight to the real endpoint (0
probe hits); with a `models.yml` same-ID override, every request arrived
at the probe (9/9 hits on `/v1/messages`). A same-ID override keeps
omp's bundled Anthropic model catalog and stored credentials (both keyed
by provider id `anthropic`), so only the endpoint moves.

The wrap therefore injects a marker-fenced `providers.anthropic.baseUrl`
override into `models.yml`, snapshotting the pre-wrap file
**byte-for-byte** first, and `headroom unwrap omp` restores it exactly
(or removes the file when the wrap created it) — the same durable-wrap +
backup + unwrap contract `wrap codex` uses for `config.toml`.

Closes #1149

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/providers/omp/` (new provider slice): `models_yml_path()`
(honors `PI_CODING_AGENT_DIR`), `inject_models_override()` (yaml-merge
preserving user providers; pristine byte-for-byte backup, never
re-snapshotted while managed), `restore_models_override()` (`restored` /
`removed` / `noop`; never touches an unmanaged file),
`build_launch_env()`
- `headroom/cli/wrap.py`: `wrap omp` (mirrors the aider/vibe
`_launch_tool` shape; rtk instructions into the project's `AGENTS.md`,
which omp reads natively) and `unwrap omp` (restore models.yml + scrub
rtk block + stop proxy)
- `headroom/telemetry/context.py`: `omp` added to `_KNOWN_WRAP_AGENTS`
so the stack slug reports `wrap_omp` instead of `unknown`
- `README.md` (agent matrix row + unwrap list), `llms.txt`,
`CHANGELOG.md`
- `tests/test_cli/test_wrap_omp.py`: 16 tests (injection
fresh/merge/re-inject, restore statuses incl. unmanaged-file safety, env
passthrough, CLI wiring, unwrap flows)

## Testing

- [ ] Unit tests pass (`pytest`) — all new + `test_cli` tests pass; the
full suite carries **3 pre-existing failures** that reproduce
identically on unmodified `origin/main` (same set, same asserts — see
Test Output and the rebase-validation comment)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest -q                    # post-rebase, base 4f22cbb0
3 failed, 7723 passed, 515 skipped in 262.64s
  FAILED tests/test_cli/test_wrap_claude_base_url.py::test_wrap_marker_is_stale_when_pid_reused
  FAILED tests/test_rtk_session_savings.py::test_rtk_reader_returns_none_on_nonzero_exit
  FAILED tests/test_rtk_session_savings.py::test_lean_ctx_reader_returns_none_on_failure_and_logs
  → all three reproduce identically on unmodified origin/main (4f22cbb0), run the
    same way (same worktree + venv, sources switched): 3 failed, 7707 passed —
    this branch = baseline + the 16 new tests, nothing else changes.
    (The pre-rebase run against e8151f05 showed the same shape: one order-dependent
    flake that also reproduced on its baseline; these are env/order-dependent.)

$ uv run pytest tests/test_cli/ -q     # post-rebase
542 passed + 1 of the pre-existing failures above   # includes the 16 new test_wrap_omp.py tests

$ uv run ruff check . ; echo ruff-check-exit:$?
All checks passed!
ruff-check-exit:0

$ uv run ruff format --check .         # post-rebase
1 pre-existing violation: headroom/proxy/handlers/anthropic.py — flagged identically
on unmodified origin/main (not touched by this PR); every file this PR touches is clean

$ uv run mypy headroom               # post-rebase; output redirected to file; exit captured
Success: no issues found in 409 source files
mypy-exit:0
```

## Real Behavior Proof

- Environment: macOS 15 (arm64, M1 Pro), Python 3.12.13 (uv venv,
editable install incl. Rust `_core`), headroom @ this branch, base
extras only (no `[ml]`), Anthropic account signed into omp. Initial
proof ran on base e8151f05 with omp 16.3.6 (`@oh-my-pi/pi-coding-agent`
via bun); re-validated after the rebase onto 4f22cbb0 with omp 16.3.11 —
fresh numbers in the rebase-validation comment.

- Exact command / steps: four scenarios, run in this order —
1. Mechanism probe (why models.yml, not env): local HTTP probe server on
`127.0.0.1:18999`; ran `omp -p "say ok" --model claude-fable-5
--no-session --no-tools` once with
`ANTHROPIC_BASE_URL=http://127.0.0.1:18999`, once with
`~/.omp/agent/models.yml` containing `providers.anthropic.baseUrl:
http://127.0.0.1:18999`.
2. One-command path: `headroom wrap omp --no-rtk --port 8790 -- -p "Read
CHANGELOG.md and count how many '### Fixed' headings it contains. Answer
with just the number." --model claude-fable-5 --no-session --max-time
180`
3. Routing stats: separate proxy on :8788, wrap with `--no-proxy`, then
`GET /stats`.
4. Restore: `headroom unwrap omp`, plus an isolated
`PI_CODING_AGENT_DIR=/tmp/omp-agent-test` run with a pre-existing user
`models.yml`, then `cmp` against the original.

- Observed result: end-to-end routing through the proxy proven for every
scenario —
- Probe: env-var run → **0 probe hits**, omp answered normally
(bypassed). models.yml run → **9 hits on `/v1/messages?beta=true`** with
real Messages bodies. This is the routing mechanism the wrap uses.
- One-command run: wrap started the proxy ("Proxy ready on
http://127.0.0.1:8790"), wrote the override (`models.yml:
providers.anthropic.baseUrl=http://127.0.0.1:8790/p/headroom-wrap-omp`),
launched omp, and omp answered **"7"** (correct — real `read` tool work
through the proxy). Proxy log for the session (3 requests,
`anthropic_messages` path):
    ```
PERF model=claude-fable-5 msgs=1 tok_before=36 cache_read=0
cache_write=61939 cache_hit_pct=0
PERF model=claude-fable-5 msgs=3 tok_before=796 cache_read=0
cache_write=63308 cache_hit_pct=0
PERF model=claude-fable-5 msgs=5 tok_before=935 cache_read=63308
cache_write=215 cache_hit_pct=100
    ```
    Prompt caching survives the proxy (100% hit on the follow-up turn).
- Routing stats (:8788 session): `requests.total: 2, by_provider:
{"anthropic": 2}, by_model: {"claude-fable-5": 2}`, per-project prefix
`/p/headroom-wrap-omp` attributed.
- Unwrap: `Removed wrap-created models.yml` (file gone); isolated
pre-existing-file run: backup created, user's `my-gw` provider preserved
in the managed file, and after `unwrap omp` the restored file is
**byte-identical** (`cmp` clean).
- Compression: **not observed in this environment** — `tok_saved=0`,
`transforms=router:noop` / `too_small`. Honest reading: omp minimizes
its own tool outputs client-side (a 300-item JSON tool result reached
the proxy at only ~657 tokens) and the `[ml]` text compressor wasn't
installed; small print-mode payloads sit below crush thresholds, and
passthrough-by-default is the documented safety contract. The wrap's
value here is proven at the routing/lifecycle/cache layer; compression
numbers will match whatever the proxy does for a given content mix.

- Not tested: Windows / Linux; lean-ctx mode with omp
(`HEADROOM_CONTEXT_TOOL=lean-ctx` — `lean-ctx init --agent omp` depends
on lean-ctx recognizing the agent; failure degrades with a warning by
design); long interactive (non `-p`) sessions; `--memory` / `--learn` /
`--code-graph` flags combined with omp; OAuth-vs-API-key matrix beyond
my local account.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] 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 — all
except the 3 documented pre-existing failures, which fail identically on
unmodified origin/main
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — terminal evidence inline above.

## Additional Notes

- The models.yml override is regenerated from the pristine backup on
every wrap, so re-running with a different `--port` updates the endpoint
idempotently and the backup is never clobbered.
- Scope note from #1149 stands: this routes omp's **Anthropic** provider
family. omp's other providers (OpenAI-direct, Gemini, ...) resolve their
endpoints from their own registry entries; users can already point those
at Headroom with their own custom provider in `models.yml`.
- `headroom/providers/omp/` deliberately contains no install-time / MCP
pieces — this is the thin wrap + unwrap slice only.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-15 19:30:19 +00:00
Manmit Singh
996c1174a8
feat(proxy): show real upstream provider on dashboard for OpenAI-compatible endpoints (#1594)
## Description

When the proxy runs against a custom OpenAI-compatible endpoint via
`--openai-api-url` (OpenRouter, Groq, Together, Azure OpenAI, …), the
dashboard always showed the provider as **OpenAI**, because the OpenAI
handler records every request with `provider="openai"`.

This detects well-known upstreams from the `--openai-api-url` host and
adds a `--provider-name` override that takes precedence (the issue's
option 3). The label is resolved only where the dashboard/stats payload
is built — the internal provider key stays `openai`, so pricing and
request formatting are unaffected.

| Upstream URL | Provider shown |
|--------------|----------------|
| `https://api.openai.com/v1` | OpenAI |
| `https://openrouter.ai/api/v1` | OpenRouter |
| `https://api.groq.com/openai/v1` | Groq |
| `https://api.together.xyz/v1` | Together AI |
| `https://<resource>.openai.azure.com/` | Azure OpenAI |

Unknown hosts keep the `openai` label unless `--provider-name` is set.

Closes #1533

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Changes Made

- `helpers.py`: `classify_openai_upstream()` (host → display name) +
`resolve_display_provider()` (precedence: `--provider-name` > host
detection > raw provider; only relabels `openai`).
- `models.py`: `ProxyConfig.provider_name`.
- `cli/proxy.py`: `--provider-name` flag, threaded into `ProxyConfig`.
- `server.py`: relabel at the four dashboard/stats display sites (recent
requests, transformations feed, `requests.by_provider`, agent-usage
breakdown) via the resolver / `_remap_provider_counts`. Stored logs and
metrics keys are untouched.
- `docs/content/docs/proxy.mdx`: document `--provider-name`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] New tests added

### Test Output

```text
$ pytest tests/test_provider_display_classification.py tests/test_dashboard_agent_usage.py -q
16 passed
13 passed

$ ruff check headroom/proxy/helpers.py headroom/proxy/server.py headroom/proxy/models.py headroom/cli/proxy.py
All checks passed!
```

## Real Behavior Proof

- Environment: repo branch `feat/1533-upstream-provider-classify` @
HEAD, local `.venv` (Python 3)
- Exact command / steps: ran the helpers directly from the venv —
`python -c "from headroom.proxy.helpers import classify_openai_upstream,
resolve_display_provider;
print(classify_openai_upstream('https://openrouter.ai/api/v1'));
print(resolve_display_provider('openai',
openai_api_url='https://openrouter.ai/api/v1'));
print(resolve_display_provider('openai',
openai_api_url='https://openrouter.ai/api/v1', provider_name='Groq'));
print(resolve_display_provider('anthropic'))"`
- Observed result: host detection relabels `openai` → `OpenRouter`,
`--provider-name` overrides detection (`Groq`), and the `anthropic`
label (plus the `openai` pricing key) is unchanged. Full output below:
  ```text
  classify openrouter           -> OpenRouter
  resolve openai+openrouter url -> OpenRouter
  override provider-name        -> Groq
  anthropic untouched           -> anthropic
  ```
- Not tested: live dashboard render against a real OpenRouter key (the
payload-builder logic is covered by the unit tests above).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:09:56 +00:00
Vsevolod Rychkov
7a5d8a7ace
fix(mcp): reap orphaned mcp serve on client death (#2226)
## Description

`headroom mcp serve` processes survive after the launching MCP client
(e.g. Claude Code) exits, get reparented to init/launchd (`ppid == 1`),
and never terminate — piling up one pinned Python interpreter +
tree-sitter grammars per dead session (observed 3+ simultaneously).

An MCP stdio server is supposed to shut down on stdin EOF, but an abrupt
client `SIGKILL` leaves the MCP SDK's blocking stdin-reader thread
wedged, so `await self.server.run(...)` in `run_stdio()` never returns
and the process orphans.

Refs #2185 (its secondary "orphaned `mcp serve` pileup", left out of
#2204's `Refs`-only Perl fix), #1761 (same symptom: "orphaned `headroom
mcp serve` processes accumulate … even after quitting").

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

`headroom/ccr/mcp_server.py`:
- Added `PARENT_DEATH_POLL_INTERVAL = 5.0` module constant.
- Added `HeadroomMCPServer._await_parent_death(interval)`: captures the
launch ppid and resolves once it changes. Watching for a *change* (not a
hard `== 1`) is portable to Linux PID subreapers, which adopt the orphan
with their own pid.
- Reworked `run_stdio()` to run that watchdog concurrently with
`server.run()`. On parent death it `os._exit(0)`s **from inside** the
`stdio_server()` context manager — the wedged stdin reader would also
hang the context-manager teardown and a cooperative `server.run` cancel,
so a hard exit is the only reliable reaper. The normal stdin-EOF path is
unchanged: `server.run` wins the race, the watchdog is cancelled, and
the context manager unwinds cleanly.

`tests/test_ccr_mcp_server.py`: 3 regression tests (below).
`CHANGELOG.md`: entry under Unreleased → Fixed.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py -q`)
- [x] Linting passes (`uv run ruff check headroom/ccr/mcp_server.py
tests/test_ccr_mcp_server.py`)
- [x] Type checking passes (`uv run mypy headroom/ccr/mcp_server.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

New tests:
- `test_parent_death_watchdog_fires_when_reparented` — ppid change
resolves the watchdog.
- `test_parent_death_watchdog_stays_quiet_with_live_parent` — a stable
ppid never trips it.
- `test_run_stdio_reaps_process_on_parent_death` — on reparent,
`run_stdio` cleans up and hits `os._exit(0)` even though the (stubbed)
`server.run` never returns.

### Test Output

```text
$ uv run pytest tests/test_ccr_mcp_server.py -q
collected 21 items
tests/test_ccr_mcp_server.py .....................                       [100%]
============================== 21 passed in 0.57s ==============================

$ uv run ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py
All checks passed!

$ uv run mypy headroom/ccr/mcp_server.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS 26.5 arm64, Python 3.14.6, headroom built from this
branch via `uv sync --all-extras` (Rust extension compiled). No provider
call.
- Exact command / steps: launch a real `HeadroomMCPServer.run_stdio()`
as a child of a throwaway parent, with stdin wired to a FIFO whose write
end is held open by a separate process (so stdin **never** reaches EOF —
this isolates the watchdog as the only possible reaper). Then `kill -9`
the parent to reparent the server to `pid 1`, and watch. The watchdog
poll interval is passed via `run_stdio(parent_death_poll_interval=…)` to
A/B the exact same shipped code path:

```text
### interval=9999s  (watchdog effectively OFF — reproduces the bug) ###  ppid(pre-kill)=43438
  -> STILL ALIVE after 8s (orphan lingers)

### interval=0.5s   (watchdog ON — the fix) ###  ppid(pre-kill)=43461
  -> REAPED at ~2s
```

And with the default flow (`headroom mcp serve`, default 5s interval),
the watchdog logs before the process exits:

```text
headroom.ccr.mcp - INFO - Headroom MCP Server starting (proxy: http://127.0.0.1:8787)
headroom.ccr.mcp - WARNING - parent process gone (ppid 41956 -> 1); shutting down MCP server
```

- Observed result: with the watchdog disabled the orphaned server
lingers indefinitely (reproduces the reported pileup); with it enabled
the orphan is reaped within one poll interval of the parent dying.
- Not tested: Linux/systemd and Windows spawn paths (the change is
POSIX-portable via ppid-change detection, but I only exercised macOS);
the reporters' desktop-app menu-bar quit path.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md

## Additional Notes

- Deliberately `os._exit(0)`, not a cooperative shutdown: the failure
mode is a wedged native stdin-reader thread, so both `server.run`
cancellation and the `stdio_server` context-manager exit can block
forever. Exiting from inside the context manager is the only path that
reliably reaps the orphan; the normal EOF path never reaches it.
- A Linux-only `prctl(PR_SET_PDEATHSIG)` fast-path could cut reap
latency to ~0, but it is racy (must re-check `getppid()` after arming)
and non-portable, so the portable poll is the primary mechanism. Happy
to add prctl as a follow-up optimization if wanted.
- Watchdog latency is bounded by `PARENT_DEATH_POLL_INTERVAL` (5s
default); trivial to make env-configurable if a tighter bound is
preferred.



---

🤖 This PR was created with [Claude Code](https://claude.com/claude-code)
but checked by the author

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:57:32 +00:00
roman-t3a
cb388f6af2
feat(wrap): add first-class Grok CLI support (#1823)
## Description

Adds first-class Grok CLI integration so Headroom can wrap, compress,
and learn from Grok sessions the same way it does for Claude Code and
Codex.

Grok routes inference through `GROK_CLI_CHAT_PROXY_BASE_URL`; Headroom
sets that to the local proxy so chat traffic is compressed before
forwarding to xAI. MCP retrieval and session learning follow the same
patterns as Codex.

Closes #

## 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
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add `headroom/providers/grok/` provider slice (`runtime.py`,
`install.py`) with `GROK_CLI_CHAT_PROXY_BASE_URL` routing and project
attribution prefix
- Add `headroom wrap grok` and `headroom unwrap grok` CLI commands (MCP
registration, RTK/context-tool setup, proxy launch)
- Add `GrokRegistrar` for marker-delimited `[mcp_servers.headroom]`
injection in `~/.grok/config.toml`
- Add `headroom learn --agent grok` plugin parsing
`~/.grok/sessions/*/updates.jsonl` and `GrokWriter` targeting `GROK.md`
- Register Grok in install planner, install registry, MCP install list,
and agent savings tracking
- Update README agent compatibility matrix and unwrap support list
- Add unit tests for provider, wrap CLI, MCP registrar, and learn plugin

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_mcp_registry_grok.py tests/test_learn_grok_plugin.py -q
============================== 10 passed in 0.15s ==============================

$ uv run ruff check headroom/providers/grok headroom/mcp_registry/grok.py headroom/learn/plugins/grok.py headroom/cli/wrap.py headroom/learn/writer.py
All checks passed!

$ uv run mypy headroom/providers/grok headroom/mcp_registry/grok.py headroom/learn/plugins/grok.py
Success: no issues found in 5 source files
```

## Real Behavior Proof

- Environment: macOS (darwin), Python 3.13.14 via `uv`, repo at
`/Users/s/dev/headroom`, Grok CLI at `/Users/s/.grok/bin/grok`
- Exact command / steps: `cd /Users/s/dev/headroom && uv run pytest
tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py
tests/test_mcp_registry_grok.py tests/test_learn_grok_plugin.py -q`; `uv
run ruff check headroom/providers/grok headroom/mcp_registry/grok.py
headroom/learn/plugins/grok.py`; `uv run python -c "from
headroom.providers.grok import build_launch_env; env, display =
build_launch_env(8787, environ={}); print(display[0])"`; `uv run python
-c "from pathlib import Path; import tempfile; from
headroom.mcp_registry.grok import GrokRegistrar; from
headroom.mcp_registry.install import build_headroom_spec;
td=tempfile.mkdtemp(); reg=GrokRegistrar(home_dir=Path(td));
print(reg.register_server(build_headroom_spec('http://127.0.0.1:8787'),
force=True).status.value)"`
- Observed result: pytest reported `10 passed`; ruff reported `All
checks passed!`; `build_launch_env` printed
`GROK_CLI_CHAT_PROXY_BASE_URL=http://127.0.0.1:8787/v1`; MCP registrar
returned `registered` and wrote the Headroom marker block to
`config.toml`
- Not tested: live `headroom wrap grok` session with authenticated Grok
API traffic through a running Headroom proxy (requires maintainer
environment with active `grok login`)

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — CLI/integration change only.

## Additional Notes

- Follows the provider-slice pattern from `b17c6d81` / `93a1f211`
(Codex/Cursor/Aider extraction).
- Routing uses the session env var only (not `config.toml` endpoint
override) so `grok login` session auth continues to work.
- Manual E2E wrap/unwrap with real Grok sessions is left for maintainer
verification.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-15 18:51:38 +00:00
Andrei Boldyrev
7bfb1d7f38
fix(cache): stable session identity and per-conversation prefix trackers under agentic clients (#2193)
## Description

Running headroom as the proxy for Claude Code destroys Anthropic
prompt-cache
reuse (#2085: ~4.4x cache-creation inflation, 2.5–3x net cost). Tracing
live
Claude Code traffic through the proxy shows **two independent
session-identity
defects**, both of which orphan or thrash the frozen-prefix state; this
PR
fixes both.

### Defect 1: `<system-reminder>` turns rotate the fallback session id
mid-conversation

Claude Code interleaves reminder turns into the history as actual
`role:"system"` messages (hook output, skills lists, file-truncation
notices).
`compute_session_id` hashed **every** system message, so the id rotated
each
time a reminder landed. Live trace (subagent reading two 80KB files; sid
changes exactly when the truncation reminder appears, and the tracker
restarts
at turn 0):

```
REQ#2 sid=68d4ee666990 nmsg=3   [0]SYSTEM<<top-level system>> [1]user [2]SYSTEM<<skills reminder>>
REQ#3 sid=6944948c9fb2 nmsg=6   ... [5]SYSTEM<<Truncated: PARTIAL view ...>>   <- id rotated
```

Everything keyed on the session id is orphaned at that moment: the
prefix
tracker (freeze never survives past a reminder-bearing turn),
beta-header
stickiness, the CCR and memory-tool registries, and the compression
cache.

**Fix:** hash only the **leading run** of system messages (everything
before
the first non-system turn) — the top-level system prompt on the
Anthropic path
(folded in as the synthetic first message), the conventional leading
system
message(s) on the OpenAI path. Stable for the life of a conversation;
mid-history system turns are content, not identity.

### Defect 2: conversations sharing a (now stable) id thrash one tracker

With ids stable, the fallback tuple `model + system prompt` is identical
across every same-type parallel subagent (and any sessions reusing one
system
prompt) — all of them collapse onto one `PrefixCacheTracker`, and their
interleaved histories cross-contaminate the freeze state: the forwarded
prefix
is byte-unstable on nearly every turn and the provider cache is
re-written
instead of read. Reproduced against the real code paths (script below):

```
1) fallback session ids: A=3dc639aaf4f48fa1 B=3dc639aaf4f48fa1 -> COLLIDE=True
2) single conversation, legacy       : stable prefix on 4/4 later turns, trackers=1
2) interleaved (subagents), legacy   : stable prefix on 0/9 later turns, trackers=1
2) interleaved, lineage resolution   : stable prefix on 8/8 later turns, trackers=2
```

**Fix:** `SessionTrackerStore.resolve_tracker` — within a session id,
reuse
the tracker whose previous request messages are a prefix of the incoming
history (client histories are append-only, so a conversation's next
request
always extends its previous one); a diverging or rewritten history
(client-side compaction) starts a fresh lineage. Matching uses the
repo's
existing canonical cross-turn equivalence
(`_canonicalize_for_prefix_compare`,
the same one the cache-stable delta path uses) on the **original client
bytes**, so moved cache breakpoints, string<->block sugar, transport
annotations, or a tail-mutating `pre_compress` hook never read as a
rewrite.
Byte-identical histories (templated fan-outs before they diverge)
intentionally share a tracker — their provider cache line is identical
too.

### Both fixes together, on live Claude Code traffic (sonnet, 2 parallel
Explore agents)

```
main conversation: sid=5b7e245a...  one tracker, turns 0->4, id stable across reminders
agents (collide):  sid=2bdffc9e...  -> lineage bare  (alpha) turns 0->1->2
                                    -> lineage "~1"  (beta)  turns 0->1->2
```

Before: the agents' ids rotated per reminder (every tracker stuck at
turn 0),
and whenever they did share an id they thrashed one tracker (`0/9`
stable
prefixes in the repro).

### Why not key the session id on conversation content?

Draft #1912 folds the first user turn into the fallback id; this change
composes with it, but identity-level keying alone can't close #2085:
identical
first turns (templated fan-outs) still collide, and everything keyed on
the
session id rotates with it when the client rewrites history. The
"session"
(client/workspace grouping) and the "conversation" (positional cache
lineage)
are different identities; only the tracker holds positional per-turn
state
that thrashes under collision — beta stickiness is a monotone union and
the
compression cache is content-addressed — so lineage resolution lives one
level below the session id and leaves the id semantics (and every other
consumer) untouched.

## Changes Made

- `headroom/cache/prefix_tracker.py`:
- `compute_session_id`: harvest only the leading system run (defect 1).
- `SessionTrackerStore.resolve_tracker`: conversation-lineage resolution
    (defect 2). First lineage lives under the bare session id —
single-conversation sessions behave byte-identically to before; degrades
to `get_or_create` when messages are absent or prefix freeze is
disabled.
- Lineages are capped per session id
(`PrefixFreezeConfig.max_lineages_per_session`, default 32). **Over-cap
  conversations share one overflow tracker instead of evicting an
established lineage** — any eviction policy degrades every conversation
  once the working set exceeds the cap (under round-robin the victim is
always the conversation about to arrive), while overflow sharing
degrades
only the over-cap tail, to exactly the pre-lineage shared behavior; `0`
  disables lineage splitting. Chains are stored as structural snapshots
  that normalize `NaN` (`json.loads` accepts bare NaN, and `NaN != NaN`
would read a byte-identical resend as a rewrite). Synthetic lineage keys
use a `\x00` separator, which cannot appear in an HTTP header value, so
  they can never collide with a client-supplied `x-headroom-session-id`.
- `headroom/proxy/handlers/anthropic.py`, `openai.py`: the session id
and
  the lineage both derive from the **same original client bytes** (a
turn-dependent hook rewrite can no longer rotate one without the other);
anthropic folds in its synthetic system message so explicit-header
clients
with different system prompts stay separate. Plus a docstring correction
in `streaming.py` that falsely claimed its coarse mid-turn key "mirrors"
  `compute_session_id`.
- `tests/test_cache/test_prefix_tracker.py`: 24 new test cases —
  reminder-rotation regression; interleaved isolation + per-conversation
turn state; identical-first-turn share-then-split; cache_control
movement
(3 cases); representation churn (string<->block sugar / streaming
`index`
  / Bedrock cachePoint); rewritten history → fresh lineage (compacted /
  middle-edited / truncated); legacy no-messages / freeze-disabled /
  empty-canonical fallbacks; NaN-in-tool-payload stability; overflow
  sharing, established-lineages-survive-cap, and a cap+1 round-robin
no-cliff guard; TTL cleanup; session-id-not-rotated-by-lineage guard.
One
  existing test renamed (`uses_all_system_messages` →
  `distinguishes_leading_system_run`) to match the new contract.
- Three SimpleNamespace stub stores in existing tests gained a
  `resolve_tracker` field (handlers call it unconditionally — a silent
`hasattr` fallback would degrade to the pre-fix behavior with no
signal).
One of them is the cold-start fast-pass suite (#2073), which landed
while
  this branch was in review.
- `CHANGELOG.md` entry.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Testing

- [x] Unit tests pass (`pytest`) — 11 failed, 8652 passed, 528 skipped
in 4:37 (the 11 are pre-existing on unmodified `main` — verified by
rerunning the same node ids on a clean checkout:
gh-CLI/onnx/PID-reuse/deadline flakes and order-dependent cases, none
touching session/cache/proxy paths)
- [x] Linting passes (`ruff check .`) — All checks passed (ruff 0.15.17,
CI-pinned; `ruff format --check .` clean)
- [x] Type checking passes (`mypy headroom`) — Success: no issues found
in 471 source files
- [x] New tests added for new functionality — 24 test cases; the
rotation/isolation/no-cliff ones fail on `main`
- [x] Manual testing performed — live Claude Code end-to-end, below

### Test Output

```text
$ python -m pytest tests/ -q
11 failed, 8652 passed, 528 skipped, 5857 warnings in 276.68s (0:04:36)
# same 11 fail on unmodified main (env/order-dependent: test_wrap_claude_base_url pid-reuse,
# copilot_auth gh-cli fallback, image_compression onnx, content_router deadline, rtk/output-shaper/dedup order flakes)

$ python -m pytest tests/test_cache/test_prefix_tracker.py -q
63 passed

$ uvx ruff@0.15.17 check . && uvx ruff@0.15.17 format --check .
All checks passed! / 1208 files already formatted

$ mypy headroom
Success: no issues found in 471 source files

$ python repro_2085.py
1) fallback session ids: A=3dc639aaf4f48fa1 B=3dc639aaf4f48fa1 -> COLLIDE=True
2) single conversation, legacy       : stable prefix on 4/4 later turns, trackers=1
2) interleaved (subagents), legacy   : stable prefix on 0/9 later turns, trackers=1
2) interleaved, lineage resolution   : stable prefix on 8/8 later turns, trackers=2
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.13, `uv sync --extra dev --extra
proxy`;
  real Claude Code CLI pointed at the proxy via
  `ANTHROPIC_BASE_URL=http://127.0.0.1:8790`, real Anthropic backend.
- Exact command / steps: ran Claude Code sessions that launch 2–3
parallel Explore subagents
(each reading multi-KB JSON files, several tool-loop turns each), with
an
observability wrapper printing each request's resolved session id,
tracker
  identity, and turn counter inside the proxy.
- Observed result: on `main`, subagent session ids rotate on
reminder-bearing turns
(trackers permanently stuck at turn 0); when conversations do share an
id
  they share one tracker whose turn counter interleaves all of them.
  On this branch: ids stable for the life of each conversation;
colliding subagents resolve to separate lineages (`bare`, `~1`) with
clean
per-conversation turn progressions (trace above). Unit-level repro shows
  forwarded-prefix stability going 0/9 → 8/8 for the interleaved shape.
- Not tested: reporter-scale cache-economics (his 4.4x needs his
long-session
workload against a paid backend); happy to coordinate with
@RomanAlexanderW
on a before/after — the number to watch is the cache-read ratio in
Claude
  Code transcripts recovering toward ~96%.

<details>
<summary>repro_2085.py</summary>

```python
"""Repro for #2085: concurrent conversations sharing a fallback session id
(same model + system prompt — e.g. a Claude Code session and its parallel
subagents) collapse onto one PrefixCacheTracker and thrash its frozen-prefix
state -> byte-unstable forwarded prefixes -> the provider prompt cache is
re-written on nearly every call. Uses headroom's real code paths.

Run from the repo root: python ../repro_2085.py
"""

from headroom.cache.prefix_tracker import PrefixFreezeConfig, SessionTrackerStore

MODEL = "claude-sonnet-5"
# Claude Code system prompt: long, static, identical across the main session
# and every parallel subagent of the same type.
SYSTEM = ("You are Claude Code, Anthropic's official CLI for Claude. " * 40)[:2000]


def convo(name: str, turns: int) -> list[dict]:
    msgs = [{"role": "system", "content": SYSTEM}]
    for t in range(turns):
        msgs.append({"role": "user", "content": f"[{name}] user turn {t}: " + ("x" * 800)})
        msgs.append(
            {"role": "assistant", "content": f"[{name}] tool_result {t}: " + ('{"data": 1}' * 200)}
        )
    return msgs


class _Req:  # request stub: no x-headroom-session-id header
    headers: dict = {}


# --- Part 1: identity collision (real derivation) ----------------------------
store = SessionTrackerStore(PrefixFreezeConfig())
id_a = store.compute_session_id(_Req(), MODEL, convo("A", 3))
id_b = store.compute_session_id(_Req(), MODEL, convo("B", 5))
print(f"1) fallback session ids: A={id_a} B={id_b} -> COLLIDE={id_a == id_b}")

# --- Part 2: interleaved conversations thrash the freeze state ---------------


def run(interleave: bool, lineage_resolution: bool) -> tuple[int, int, int]:
    store = SessionTrackerStore(PrefixFreezeConfig())
    stable_turns = 0
    later_turns = 0
    seq = []
    for t in range(1, 6):
        seq.append(("A", convo("A", t)))
        if interleave:
            seq.append(("B", convo("B", t)))
    for _name, msgs in seq:
        sid = store.compute_session_id(_Req(), MODEL, msgs)
        if lineage_resolution:
            tracker = store.resolve_tracker(sid, "anthropic", messages=msgs)
        else:
            tracker = store.get_or_create(sid, "anthropic")
        if tracker._turn_number > 0:
            later_turns += 1
            if tracker._forwarded_prefix_stable(msgs):
                stable_turns += 1
        tracker.update_from_response(
            cache_read_tokens=5000 * len(msgs),
            cache_write_tokens=2000,
            messages=msgs,
        )
    return stable_turns, later_turns, store.active_sessions


for label, interleave, fixed in (
    ("single conversation, legacy       ", False, False),
    ("interleaved (subagents), legacy   ", True, False),
    ("interleaved, lineage resolution   ", True, True),
):
    stable, later, sessions = run(interleave, fixed)
    print(f"2) {label}: stable prefix on {stable}/{later} later turns, trackers={sessions}")
```
</details>

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation (CHANGELOG
only — no docs describe the tracker store)
- [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 updated the CHANGELOG.md if applicable

## Additional Notes

- Addresses the session-identity mechanisms of #2085; intentionally does
not
`Closes` it — the reporter should confirm the cache-read ratio recovers
on
  live traffic first.
- Composes with draft #1912 (first-user-turn fallback id).
- Known bounded tradeoffs (all strictly milder than the per-turn thrash
this
fixes): a fork-style branch that resends a parent's full history adopts
the
parent's lineage, costing the parent one cold restart at its next turn;
a
request that aborts before the response and is retried with different
bytes
starts a fresh lineage; history truncation/tail-edit starts a fresh
lineage
  even though the shorter provider prefix may still be warm.
- Hot-path cost, measured on a 199-message/2.1MB agentic history:
canonical
projection 0.21ms + structural snapshot 0.92ms + match loop 0.06ms with
  32 candidate lineages (2.27ms absolute worst case) ≈ **1.3ms per
  request** — same order as the handler's existing request deepcopy
(0.80ms) and below one `json.dumps` of the body (2.9ms). Chain memory is
structure-only (~180-330KB per lineage; message strings are shared with
  state the tracker already retains).
- Known semantic shift to flag: hashing only the leading system run
means
  conversations distinguished ONLY by mid-list system messages (e.g.
clients injecting a per-conversation system context late in the list)
now
share a fallback id. The tracker is protected by lineage resolution; the
  residual sharing concentrates in the CCR sticky-tool registry and the
monotone beta union — the same pre-existing class as same-system-prompt
  conversations today. Happy to file the CCR-stickiness scoping as a
  follow-up.
- Out of scope, observed while tracing: `SessionCcrTracker.has_done_ccr`
  mildly cross-contaminates conversations sharing an id (monotone, no
  thrash) — can file separately if useful.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:42:20 +00:00
JD Davis
560ffae103
feat(deploy): Add turnkey deploy command (#1404)
## Description

Adds `headroom deploy` as the turnkey, zero-config local deployment
entrypoint. The command chooses the most capable deployment path it can
verify on the current host, configures detected tools through the
existing persistent-install machinery, starts the proxy, and preserves
the existing rollback behavior if an update fails.

The selection order favors performance first: NVIDIA Docker GPU
passthrough when `nvidia-smi` and Docker's NVIDIA runtime are available,
then plain Docker, then native scheduled recovery, then a detached
Python runtime fallback.

## 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
- [x] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

- Added the top-level `headroom deploy` command and reused the existing
install manifest/apply/start/rollback path.
- Added conservative runtime selection for GPU Docker, plain Docker,
native schedulers, and detached Python fallback.
- Added Docker runtime support for manifest-driven `--gpus all`
passthrough.
- Added tests for Docker selection, GPU Docker selection, detached
fallback, GPU command rendering, and subprocess wrapper compliance.
- Updated README and persistent-install docs to present the turnkey
deployment flow and performance-first GPU behavior.
- Allowed documented `opencode` targets through `headroom install apply
--target`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Formatting passes (`ruff format --check`)
- [x] Type checking passes in local pre-commit and CI
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run --with pytest --with pytest-asyncio python -m pytest tests/test_cli/test_subprocess_utf8_encoding.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_includes_gpu_passthrough tests/test_install/test_planner.py -q
47 passed in 1.57s

uvx --from ruff==0.15.17 ruff check headroom/cli/install.py headroom/install/runtime.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py --output-format concise
All checks passed!

uvx --from ruff==0.15.17 ruff format --check headroom/cli/install.py headroom/install/runtime.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py
4 files already formatted
```

GitHub checks are green on the current head.

## Real Behavior Proof

- Environment: Windows local worktree `C:\git\headroom`, Python 3.13 via
`uv`; GitHub Actions Ubuntu/macOS/Windows runners for full PR CI.
- Exact command / steps: Ran the focused deploy/install tests above,
checked the touched Python files with the CI-pinned Ruff version, and
confirmed the current PR head is mergeable with green GitHub checks.
- Observed result: The deploy command, runtime selection, Docker GPU
command rendering, install CLI behavior, and subprocess encoding
coverage all pass locally; the branch is no longer conflicted.
- Not tested: Actual RTX 4090 hardware passthrough on a physical NVIDIA
workstation; the PR tests conservative detection and Docker command
rendering without requiring GPU hardware in CI.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A - CLI/runtime behavior only.

## Additional Notes

CHANGELOG update is not included because this is an unreleased feature
PR and the repository's release tooling owns release notes from
conventional commits.
2026-07-15 18:37:20 +00:00
JD Davis
ad6ab48cbb
refactor(proxy): extract tool definition serialization (#1998)
## Description

Extracts canonical memory-tool definition byte serialization from
`headroom.proxy.helpers` into a focused pure module. The existing helper
function remains as a compatibility wrapper for sticky memory tool and
CCR replay code.

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

- Added `headroom.proxy.tool_definition_serialization` for deterministic
compact UTF-8 tool definition serialization.
- Kept `helpers.serialize_tool_definition_canonical()` as a
compatibility wrapper.
- Added direct unit tests for compact separators, Unicode preservation,
insertion-order byte stability, and parity with the existing body
canonicalizer.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Formatting passes (`ruff format --check`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_tool_definition_serialization.py tests/test_ccr_tool_always_on.py tests/test_memory_tool_session_sticky.py tests/test_proxy_byte_faithful_forwarding.py -q
85 passed, 1 warning in 2.47s

uvx --from ruff==0.15.17 ruff check headroom/proxy/helpers.py headroom/proxy/tool_definition_serialization.py tests/test_tool_definition_serialization.py --output-format concise
All checks passed!

uvx --from ruff==0.15.17 ruff format --check headroom/proxy/helpers.py headroom/proxy/tool_definition_serialization.py tests/test_tool_definition_serialization.py
3 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.x
- Exact command / steps: Ran direct serializer tests plus CCR always-on,
sticky memory tool, and proxy byte-faithful forwarding regression
coverage; then checked the touched files with the CI-pinned Ruff
version.
- Observed result: Serializer byte contract remains directly covered
while existing sticky replay and byte-faithful proxy behavior stay
green.
- Not tested: Full repository pytest suite locally; GitHub CI is green
for the current head.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. The current head is mergeable and GitHub checks are green.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-15 18:36:52 +00:00
nangsontay
96bc4cd128
feat(dashboard): add settings dashboard for proxy configuration (#2101)
## Description

Adds a loopback-only dashboard settings panel at `/dashboard/settings`
for a curated, safe subset of Headroom runtime knobs. Settings persist
to `settings.json`, are applied before Click resolves `envvar=` options,
and keep precedence predictable: explicit shell export > stored settings
> code default.

The panel also adds an Endpoints group for custom Anthropic/OpenAI
upstream base URLs and optional extra forwarded headers for gateway
deployments. Mutating routes are loopback-gated and same-origin guarded;
secret header values are masked and admin audit records only changed key
names.

## Changes Made

- Added `headroom/settings_store.py` with validation, masking, atomic
save, partial-update merge semantics, and env application.
- Added `/settings/schema`, `/settings`, `/settings/apply`, and
`/dashboard/settings` routes.
- Added same-origin protection for mutating local settings routes.
- Added custom Anthropic/OpenAI endpoint and extra-header plumbing
through CLI, provider registry, proxy config, and handlers.
- Added deployment-aware apply/restart behavior for
service/docker/foreground modes.
- Added docs for the settings GUI and endpoint/header configuration.
- Merged current `main`, added missing retry-delay settings registry
entries, fixed the UI so no-op saves do not persist every default, and
removed unrelated dependency/Cargo churn from the PR diff.

## Testing

```text
uvx ruff@0.15.17 check headroom/settings_store.py headroom/proxy/server.py headroom/proxy/loopback_guard.py headroom/providers/registry.py headroom/proxy/helpers.py headroom/cli/main.py headroom/cli/proxy.py tests/test_proxy/test_settings_store.py tests/test_proxy_settings_endpoints.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_header_isolation.py tests/test_install/test_runtime.py tests/test_proxy/test_settings_fresh_process_precedence.py
All checks passed!

uvx ruff@0.15.17 format --check headroom/settings_store.py headroom/proxy/server.py headroom/proxy/loopback_guard.py headroom/providers/registry.py headroom/proxy/helpers.py headroom/cli/main.py headroom/cli/proxy.py tests/test_proxy/test_settings_store.py tests/test_proxy_settings_endpoints.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_header_isolation.py tests/test_install/test_runtime.py tests/test_proxy/test_settings_fresh_process_precedence.py
14 files already formatted

uv run --extra dev python -m pytest tests/test_proxy/test_settings_store.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_proxy_settings_endpoints.py tests/test_header_isolation.py tests/test_install/test_runtime.py tests/test_proxy/test_settings_fresh_process_precedence.py -q
192 passed, 1 skipped, 1 warning

git diff --check headroomlabs/main...HEAD
# no output
```

The pushed cleanup commits also passed local pre-commit hooks.

## Review Readiness

- [x] Ready for review
- [x] Regression tests added
- [x] Documentation updated

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:19:09 +00:00
Matthew Jackson
cdba2eccdd
feat(core): gate ONNX transforms behind a default-on ml feature (static/lexical builds) (#2165)
## Description

`TextCrusher` and the BM25 relevance path can run without the
ONNX-backed ML stack, but `headroom-core` previously compiled `ort`,
`fastembed`, and `magika` unconditionally. This made lexical-only
downstream consumers carry the ONNX Runtime dependency even when they
never used embedding relevance or Magika detection.

This PR makes those ML crates optional behind a new default-on `ml`
Cargo feature. Default builds keep the existing ML-backed behavior.
Consumers that only need lexical compression can opt out with
`default-features = false`; in that mode the ML modules are compiled out
and the relevance path falls back to BM25.

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `crates/headroom-core/Cargo.toml`: marks `ort`, `fastembed`, and
`magika` optional; adds default-on `ml = ["dep:ort", "dep:fastembed",
"dep:magika"]`.
- `crates/headroom-core/src/lib.rs`: gates the shared ONNX CPU helper
behind `ml`.
- `crates/headroom-core/src/relevance/embedding.rs`: gates the fastembed
implementation behind `ml` and provides a no-ml stub with the same
scorer surface so `HybridScorer` naturally falls back to BM25.
- `crates/headroom-core/src/transforms/detection.rs`: gates the Magika
tier behind `ml`; no-ml builds start at the existing unidiff/plain-text
fallback tiers.
- `crates/headroom-core/src/transforms/mod.rs`: gates the Magika module
and re-exports behind `ml`.

## Testing

- [x] Default build compiles (`cargo build -p headroom-core`)
- [x] Lexical-only build compiles (`cargo build -p headroom-core
--no-default-features`)
- [x] Default tests pass (`cargo test -p headroom-core`)
- [x] Lexical-only tests pass (`cargo test -p headroom-core
--no-default-features`)
- [x] Dependency tree checked for no-ml build (`cargo tree -p
headroom-core --no-default-features` contains no `fastembed`, `magika`,
or `ort` packages)
- [ ] Manual testing performed

## Real Behavior Proof

- Environment: Windows 11 review worktree, Rust/Cargo workspace.
- Exact command / steps:
  - `cargo build -p headroom-core`
  - `cargo build -p headroom-core --no-default-features`
  - `cargo test -p headroom-core`
  - `cargo test -p headroom-core --no-default-features`
  - `cargo tree -p headroom-core --no-default-features`
- Observed result: both feature configurations build and test cleanly.
The no-default dependency tree does not include `fastembed`, `magika`,
or `ort`, while the default build still compiles the ML path.
- Not tested: model-backed `RUN_FASTEMBED_TESTS=1` cases that require
downloading the embedding model; those remain env-gated as before.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

The no-ml build intentionally degrades embedding relevance to the
existing unavailable-model behavior, so `HybridScorer` takes its BM25
fallback path. Magika detection is skipped when `ml` is disabled;
detection then proceeds through unidiff and plain-text fallback tiers.

---------

Co-authored-by: Matthew Jackson <mattjackson86@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:52 +00:00
Chester
6d897e8eaa
fix(memory): require explicit updates for supersession (#2188)
## Description

The standalone Memory MCP `memory_save` handler currently treats vector
similarity as update identity. A score of `0.70` can therefore supersede
a valid but distinct memory that merely shares domain vocabulary.

This change makes `memory_save` append-only. Supersession remains
available through explicit update paths that receive an existing memory
ID.

Closes #2187.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that causes existing functionality
to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Remove vector-similarity-based auto-supersession from the standalone
MCP `memory_save` handler.
- Clarify in the tool description that corrections require an explicit
update path with the existing memory ID.
- Add a regression test proving that a high-scoring but distinct memory
is neither searched for replacement nor updated.
- Preserve the existing save result summary shape for compatibility.

## Testing

- [x] Focused unit tests pass
- [x] Linting passes (`ruff check` on changed files)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual focused test execution performed

### Test Output

```text
uv run --with pytest --with numpy pytest tests/test_memory/test_mcp_server.py -q
9 passed, 21 warnings in 0.70s

uvx ruff check headroom/memory/mcp_server.py tests/test_memory/test_mcp_server.py
All checks passed!

uvx ruff format --check headroom/memory/mcp_server.py tests/test_memory/test_mcp_server.py
2 files already formatted
```

The warnings are pre-existing pytest configuration and
`datetime.utcnow()` deprecation warnings in the test environment.

## Real Behavior Proof

- Environment: Python 3.13 with the MCP module stub and an async
recording backend.
- Exact command / steps: run `tests/test_memory/test_mcp_server.py`; the
new regression supplies a search result with similarity `0.91`, then
saves a distinct fact.
- Observed result: `search_memories` and `update_memory` are not called;
`save_memory` is called once with the new fact and requested importance.
- Not tested: live embedding backends or migration of supersession
chains created by earlier versions.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented the non-obvious identity boundary
- [ ] Documentation changes are limited to the MCP tool description
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [ ] New and existing unit tests pass locally; the focused MCP suite
passes and full CI is pending
- [ ] CHANGELOG update is not included because release notes are
generated from conventional commits

## Screenshots (if applicable)

Not applicable.

## Additional Notes

This patch intentionally does not infer replacement identity from
category, entity references, or a higher vector threshold: none of those
alone proves that two statements are versions of the same fact. Exposing
an explicit update tool from the standalone MCP server can be considered
separately without retaining the unsafe automatic behavior.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:41 +00:00
nangsontay
e5b3a634df
fix(proxy): dedupe Codex WS request logging for accurate mixed-provider dashboards (#2189)
## Description

Running Claude Code (Anthropic) and Codex (OpenAI) against the **same**
Headroom proxy instance on one port produced incorrect, unstable
dashboard data. The proxy core is provider-isolated and
multi-provider-safe by design; the defect was in the observability
layer. The Codex `/v1/responses` **WebSocket** handler was the only path
in the proxy that wrote to the request logger by hand instead of through
the unified `emit_request_outcome` funnel, and it did so twice per
session close: the per-turn funnel record plus an unconditional
cumulative session-summary `RequestLog`. This PR removes the duplicate
summary log so Codex WS emits exactly one request log per turn, matching
the HTTP provider paths.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Dropped the duplicate cumulative session-summary `RequestLog` in the
Codex WS handler while preserving the per-turn `emit_request_outcome`
path.
- Preserved gated `request_messages` and `turn_id` on residual outcomes
so dashboard telemetry keeps the useful attribution without
double-counting tokens.
- Ensured explicit `--anyllm-provider` wins over a leaked
`HEADROOM_ANYLLM_PROVIDER` environment variable.
- Registered retry delay settings that had drifted out of the settings
registry.
- Hardened tests against developer-shell `HEADROOM_*` /
`ANTHROPIC_CUSTOM_HEADERS` leakage and stabilized several focused
proxy/wrap test fixtures.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ .venv/bin/pytest tests/ -q -p no:cacheprovider
8529 passed, 537 skipped, 5831 warnings in 276.25s (0:04:36)

$ .venv/bin/ruff check <touched files>
All checks passed!
```

## Real Behavior Proof

- Environment: macOS (Darwin 25.4.0), Python 3.13.14, pytest 9.0.3, ruff
via project venv, branch `fix/multi-provider-runtime`.
- Exact command / steps: Ran the full test suite without pytest cache
provider and Ruff on all touched files; used `git stash` to confirm the
stale fake-config failures pre-existed this change.
- Observed result: Full suite passed with no failures; Ruff passed;
Codex WS now routes end-of-session logging through
`emit_request_outcome`, emitting one request log per turn with the same
accounting model as Anthropic HTTP turns.
- Not tested: Live simultaneous Claude + Codex dashboard run. `mypy
headroom` was not run to completion; a scoped run reported one
pre-existing `settings_store.py:470` coercion error outside this diff.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A - server-side observability fix; no UI markup changed.

## Additional Notes

- The proxy's multi-provider routing, header/auth isolation, and
per-model cache keying are already correct and unchanged here; only the
WS observability write path was double-counting.
- Architectural assessment:
`plans/reports/research-260714-0004-multi-provider-upstream-compression-report.md`;
root-cause + resolution trail:
`plans/reports/debug-assessment-260714-0011-dashboard-instability-mixed-claude-codex-report.md`.
- No live simultaneous Claude + Codex dashboard run was performed;
validation is from test coverage and code review of the WS logging path.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:34 +00:00
Ingmar Krusch
a352fa0168
fix(proxy/bedrock): wire PrefixCacheTracker updates into Bedrock backend paths (#2196)
## Description

`update_from_response()` was only called from the direct-Anthropic-API
branch of `handle_anthropic_messages`. Both Bedrock backend branches
(streaming and non-streaming) returned before ever reaching it, so
`PrefixCacheTracker` state stayed permanently empty for the life of a
session on any `--backend bedrock` deployment:
`extract_cache_stable_delta()` always saw no previous turn, and `--mode
cache` fell back to full unmodified passthrough on every turn instead of
compressing the append-only delta.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: non-streaming Bedrock branch
now mirrors the direct-API branch — builds
`next_original_messages`/`next_forwarded_messages` from the response,
runs cache-miss attribution, and calls
`prefix_tracker.update_from_response()` before returning.
- `headroom/proxy/handlers/streaming.py`: `_stream_response_bedrock`
gains `prefix_tracker`/`optimized_messages` parameters (previously
absent entirely), accumulates raw SSE bytes only when a tracker is
present, reconstructs the assistant message via the existing
`_parse_sse_to_response` helper in the `finally:` block, then updates
the tracker. Mirrors `_finalize_stream_response` and the
OpenAI-via-backend sibling (`_stream_openai_via_backend`), which already
had this wiring.
- `tests/test_bedrock_prefix_tracker_wiring.py` (new): drives real
`PrefixCacheTracker` instances (via `session_tracker_store`, not a fake)
through both the non-streaming and streaming Bedrock paths using
`TestClient`, and asserts the tracker's turn counter and
last-forwarded/-original messages actually advance after a Bedrock call.
A second non-streaming test drives two turns and asserts turn 2 sees a
nonzero `frozen_message_count` once the cached total clears
`min_cached_tokens`. Verified these tests fail against the pre-fix
`anthropic.py`/`streaming.py` (turn counter stuck at 0) and pass against
the fix.
- `CHANGELOG.md`: added a `### Fixed` entry under `Unreleased`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_bedrock_prefix_tracker_wiring.py tests/test_backend_nonstreaming_cache_metrics.py tests/test_backend_streaming_cache_metrics.py tests/test_bedrock_streaming_input_tokens.py tests/test_cache/test_prefix_tracker.py tests/test_cache_prefix_overlay.py tests/test_cross_turn_cache_safety.py tests/test_proxy_anthropic_cache_stability.py -q
collected 91 items

tests/test_bedrock_prefix_tracker_wiring.py ...                          [  3%]
tests/test_backend_nonstreaming_cache_metrics.py ....                    [  7%]
tests/test_backend_streaming_cache_metrics.py ....                       [ 12%]
tests/test_bedrock_streaming_input_tokens.py ..                          [ 14%]
tests/test_cache/test_prefix_tracker.py .................................. [ 49%]
tests/test_cache_prefix_overlay.py .........                             [ 69%]
tests/test_cross_turn_cache_safety.py ...                                [ 72%]
tests/test_proxy_anthropic_cache_stability.py .........................  [100%]

======================== 91 passed, 1 warning in 9.15s =========================

$ uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/streaming.py tests/test_bedrock_prefix_tracker_wiring.py
All checks passed!

$ uv run mypy headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/streaming.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: personal fork deployed as a real proxy (macOS launchd
service, `headroom install apply`) with `--backend bedrock --mode
cache`, fronting a live Claude Code session.
- Exact command / steps: ran a two-turn streaming conversation against
the running Bedrock-backed proxy, then a third append-only turn, while
temporarily adding debug logging around
`prefix_tracker.get_frozen_message_count()` /
`get_last_original_messages()` (removed before this commit; the
automated tests above are the permanent record).
- Observed result: before the fix, `prev_orig_len`/`prev_fwd_len` were
always 0 on every turn including turn 2+ — the tracker never advanced
past its cold-start state. After the fix, turn 2 shows
`prev_orig_len`/`prev_fwd_len` populated from turn 1's response, and the
append-only turn 3 correctly triggers the delta-compression path
(`router:noop` transform, pipeline actually runs) instead of falling to
the router-never-called passthrough. In a separate live session captured
while validating this fix, one turn showed `cache_write=98242` in the
PERF log, and the immediately following turn showed `cache_read=98242
cache_hit_pct=94` — direct proof that the Bedrock path is now feeding
real cache-read/write data back into the tracker end-to-end on live
traffic, not just synthetic test fixtures.
- Not tested: the live full-suite run during development surfaced one
pre-existing unrelated failure in `test_provider_model_fallback.py`,
confirmed independently failing on the commit prior to this fix (i.e.,
not introduced by this change, not fixed by it either).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — backend logic change, no UI surface.

## Additional Notes

- No linked issue number: found via independent investigation of a
personal deployment, not filed as a `headroomlabs-ai/headroom` issue
first.
- This is the more consequential of two related fixes from the same
investigation; the sibling PR (`fix(proxy/savings): append history point
on cache-only savings too`) fixes a savings-history reporting gap that
this same `--mode cache` + Bedrock deployment surfaced.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:20 +00:00
Jervis
0537cbfde4
feat(dashboard): persist lifetime proxy metrics (#2198)
## Description

Persist bounded, aggregate-only Lifetime dashboard metrics across proxy
restarts and expose them through a new `/stats-lifetime` endpoint. The
change keeps session/runtime stats separate from durable lifetime stats,
gates sensitive dashboard metadata for loopback or explicitly trusted
dashboard clients, and updates the dashboard Lifetime view to consume
the new endpoint.

Closes #2137

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

- Added bounded persistent lifetime metrics state and wired proxy metric
events into it.
- Added `/stats-lifetime` with sensitive project/persistence details
gated behind dashboard metadata access checks.
- Extended loopback/dashboard metadata access policy for trusted
dashboard client CIDRs without widening admin/debug endpoints.
- Reorganized dashboard session/lifetime presentation around runtime
counters versus durable aggregates.
- Added focused tests for persistent aggregation, persistence, endpoint
registration, loopback gating, trusted dashboard CIDRs, and recent
request ordering.
- Fixed current Ruff/mypy issues in the lifetime metrics normalization
code.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run --extra proxy --with pytest --with pytest-asyncio pytest tests/test_persistent_metrics.py tests/test_persistent_metrics_integration.py tests/test_persistent_metrics_persistence.py tests/test_proxy_loopback_gating.py tests/test_proxy_stats_recent_requests.py -q
53 passed, 1 warning

uvx ruff==0.15.17 check headroom/proxy/forwarded_headers.py headroom/proxy/loopback_guard.py headroom/proxy/persistent_metrics.py headroom/proxy/savings_tracker.py headroom/proxy/server.py tests/test_forwarded_headers.py tests/test_persistent_metrics.py tests/test_persistent_metrics_integration.py tests/test_persistent_metrics_persistence.py tests/test_proxy_loopback_gating.py tests/test_proxy_project_savings.py tests/test_proxy_stats_recent_requests.py
All checks passed!

uv run --extra proxy --with mypy mypy headroom/proxy/persistent_metrics.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows review worktree, Python 3.13.3 via uv.
- Exact command / steps: Ran the focused persistent metrics,
persistence, loopback gating, and recent request tests; ran CI-matching
Ruff on touched files; ran mypy on the new persistent metrics module.
- Observed result: `/stats-lifetime` is registered, non-loopback callers
receive only non-sensitive aggregate data, loopback/trusted dashboard
clients receive the full lifetime payload, admin/debug endpoints remain
loopback-only, and persistent metrics normalize malformed stored state
without type/lint errors.
- Not tested: Full repository pytest suite, full dashboard browser
screenshot pass, or live long-running proxy traffic.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

No changelog entry is required for this dashboard/internal metrics
iteration. The endpoint intentionally exposes only aggregate lifetime
data to ordinary network callers and strips project/persistence details
unless the caller passes the dashboard metadata access policy.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:13 +00:00
Rod Boev
551f473e04
fix(proxy): accept Codex websocket before upstream retries (#2203)
## Description

Codex Desktop could abandon Headroom's local `/v1/responses` WebSocket
handshake before Headroom's upstream retry strategy had a chance to
recover. The ChatGPT-auth path waited for an upstream opening handshake
with a minimum 30-second timeout before sending the local 101, while the
reported Codex Desktop handshake expired after about 34 seconds.

This change accepts validated ChatGPT-auth Codex WebSockets before
opening the upstream connection, then keeps the existing upstream
retries and HTTP fallback behind the established local session. API-key
sessions retain connect-before-accept behavior so upstream `x-codex-*`
headers can still be attached to their client-facing 101. The change is
scoped to the pre-101 timing failure and does not address the separate
large-context streaming investigation in #1944.

Closes #2184

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Accept ChatGPT-auth Codex WebSocket clients before the upstream
connect and retry loop.
- Preserve API-key connect-before-accept ordering and upstream
`x-codex-*` handshake-header forwarding.
- Keep upstream retry, relay, usage-state refresh, and WebSocket-to-HTTP
fallback behavior after the local 101.
- Add a deterministic regression that blocks the first upstream opening
handshake and proves the local acceptance deadline is independent of it.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_openai_codex_ws_lifecycle.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/openai.py
tests/test_openai_codex_ws_lifecycle.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_openai_codex_ws_lifecycle.py -q
28 passed in 2.02s
uv run ruff check headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py
All checks passed!
uv run ruff format headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py --check
2 files already formatted
```

## Real Behavior Proof

- Environment: Python 3.12, synced development worktree, local fake
Codex client and upstream WebSocket, no live provider
- Exact command / steps: Run `uv run pytest
tests/test_openai_codex_ws_lifecycle.py::test_chatgpt_ws_accepts_before_stalled_upstream_connect
-q`; the fake upstream blocks its first opening handshake while the
client enforces a bounded local-accept deadline.
- Observed result: `1 passed in 0.46s`; the ChatGPT-auth client receives
its local 101 before the blocked upstream connect is released, and the
handler continues into its existing upstream recovery path.
- Not tested: live Codex Desktop pre-turn compaction against ChatGPT
subscription infrastructure

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Additional Notes

`CHANGELOG.md` is unchanged because the release pipeline generates it
from conventional commits. No user documentation changes are required;
the handler comments and ordered-flow docstring are updated with the
auth-mode-specific behavior. The broader #1944 large-context disconnect
surface remains out of scope.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:06 +00:00
Chester
2de07db281
fix(memory): audit passive context injection (#2212)
## Description

Close the passive-memory observability loop by recording access for
context rows that survive the final injection budget and tagging
requests where context is actually appended.

Closes #2211

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Track only memory IDs retained after ranking, similarity filtering,
entry limits, and final text truncation.
- Call optional backend `record_access` with stable de-duplication and
fail-open error handling.
- Extend structured injection logging to stamp `memory_injected=true`
when injected bytes are positive.
- Thread request tags through successful Anthropic, OpenAI Chat, OpenAI
Responses, Gemini, and Codex WebSocket injection sites.
- Add a static contract test that all current successful handler
injection logs pass tags.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \
    tests/test_memory_handler_native_ops.py \
    tests/test_memory_auto_tail.py \
    tests/test_memory_handler_project_isolation.py \
    tests/test_memory_injection_logging.py -q
51 passed

$ uv run --with ruff ruff check <touched Python files>
All checks passed!

$ git diff --check
(no output)
```

## Real Behavior Proof

- Environment: Python 3.13 with synthetic backend and handler fixtures
- Exact command / steps: run the focused test set above
- Observed result: only IDs present after the final text budget are
access-recorded; access-write failures remain fail-open; positive
injection logs stamp `memory_injected=true`; all six current successful
injection call sites pass tags
- Not tested: live provider requests, full repository suite, third-party
backends without `record_access`

## Review Readiness

- [x] I have performed a self-review
- [ ] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

Access accounting is intentionally best-effort: unsupported backends and
write failures do not delay or fail the upstream model request.
Documentation and changelog changes are not needed for this internal
observability fix.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:17:31 +00:00
Chester
1c9585d42e
fix(stats): tag streamed output token source (#2214)
## Description

Preserve the existing SSE output-token fallback while making its
provenance visible to request logs and downstream statistics.

Closes #2213

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Tag provider-reported streaming output tokens with
`output_tokens_source=provider`.
- Tag the existing `total_bytes // 40` fallback with
`output_tokens_source=estimated_bytes`.
- Copy incoming tags before adding provenance so caller-owned
dictionaries are not mutated.
- Add focused coverage for both source values and the unchanged fallback
estimate.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run --with pytest --with pytest-asyncio --with fastapi --with httpx --with numpy pytest \
    tests/test_proxy_streaming_request_logger.py \
    tests/test_request_outcome.py \
    tests/test_proxy_handler_helpers.py -q
77 passed

$ uv run --with ruff ruff check headroom/proxy/handlers/streaming.py tests/test_proxy_streaming_request_logger.py
All checks passed!

$ uv run --with ruff ruff format --check headroom/proxy/handlers/streaming.py tests/test_proxy_streaming_request_logger.py
2 files already formatted
```

## Real Behavior Proof

- Environment: Python 3.13 with the real request logger and synthetic
stream state
- Exact command / steps: run the focused test set above
- Observed result: parsed usage records `provider`; a 200-byte no-usage
stream still records 5 output tokens and tags it `estimated_bytes`
- Not tested: live provider stream, full repository suite

## Review Readiness

- [x] I have performed a self-review
- [ ] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

This PR does not change the fallback formula or token totals.
Documentation and changelog changes are not needed for the new internal
outcome tag.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:17:24 +00:00
Chester
ce52b30c8f
feat(memory): add explicit supersession repair (#2217)
## Description

Add an explicit, reviewable way to detach one incorrect supersession
edge while preserving both memories and all neighboring version history.

Closes #2216

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add an atomic SQLite `detach_supersession(old_id, new_id)` primitive
that requires reciprocal direct lineage.
- Restore only the old memory's validity and clear only the selected
edge.
- Re-index both affected memories and refresh cache state through
`HierarchicalMemory`.
- Expose the operation through `LocalBackend`.
- Add `headroom memory repair-supersession OLD_ID NEW_ID`, dry-run by
default with explicit `--apply`.
- Resolve unambiguous partial IDs for preview but pass full IDs to the
mutation.
- Add chain-locality, rejection, index/cache, dry-run, and apply-path
tests.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi --with httpx pytest \
    tests/test_memory/test_supersession_repair.py \
    tests/test_memory/test_hierarchical.py::TestSQLiteMemoryStore \
    tests/test_cli/test_main_help_version.py -q
22 passed

$ uv run --with ruff ruff check <touched Python files>
All checks passed!

$ uv run --with ruff ruff format --check <touched Python files>
6 files already formatted
```

## Real Behavior Proof

- Environment: Python 3.13, temporary SQLite databases, synthetic two-
and three-version chains
- Exact command / steps: run the focused test set above
- Observed result: detaching `v1 -> v2` restores `v1` as current, leaves
`v2 -> v3` intact, re-indexes both records, refreshes cache, and keeps
CLI preview read-only until `--apply`
- Not tested: live proxy process, external MemoryStore/VectorIndex
plugins, full repository suite

## Review Readiness

- [x] I have performed a self-review
- [ ] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

This is intentionally separate from #2188: that PR prevents new false
edges, while this PR repairs historical data.

The CLI help requires stopping any proxy that is actively using the same
database before `--apply`, because another process can retain an old
in-memory index snapshot. External backend semantics and stronger
cross-store rollback behavior are left visible for maintainer review
before this Draft is marked ready.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:17:17 +00:00
Zhenjia ZHOU
528517cff8
fix(diff-compressor): CJK-aware relevance scoring for hunk selection (#2220)
## Description

`score_hunks` boosts diff hunks whose content overlaps the query/context
(+`SCORE_CONTEXT_WORD_WEIGHT` per match); the resulting score decides
which hunks survive when `max_hunks_per_file` fires. It split the
context on whitespace, so a spaceless CJK query became one blob that
only matched a hunk containing the whole query verbatim — relevant hunks
weren't boosted and got dropped.

This adds CJK character bigrams to the query match set so a CJK query
boosts the hunks it overlaps. Rust-only (`diff_compressor.py` is a thin
shim over Rust; hunk scoring lives only in Rust). CJK-gated: for a
pure-ASCII query `cjk_bigrams` returns an empty set and the new loop is
a no-op, so non-CJK scoring is byte-identical and the 20 diff parity
fixtures stay green.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `crates/headroom-core/src/transforms/diff_compressor.rs`: add
`is_cjk_char` + `cjk_bigrams`, and a separate loop in `score_hunks` that
boosts hunks containing each CJK query bigram. The existing ASCII word
loop is untouched.
- Rust unit test (`cjk_bigrams` extraction) + an end-to-end test (a CJK
query promotes the overlapping hunk into the kept set; the no-query
baseline drops it).

## Testing

- [x] Unit tests pass (`cargo test`)
- [x] Linting passes (`cargo clippy` / `cargo fmt`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib diff_compressor
test result: ok. 23 passed; 0 failed

$ cargo clippy -p headroom-core   # clean
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo, branch
`feat/diff-compressor-cjk` off `main`.
- Exact command / steps: `cargo test -p headroom-core --lib
diff_compressor` — the `cjk_query_boosts_matching_hunk_into_kept_set`
test builds a diff with 4 hunks (first / plain / cjk / last),
`max_hunks_per_file = 3` (one contested middle slot between the plain
hunk at change-density `0.12` and the CJK hunk at `0.06`), and
compresses it once with the CJK context `数据库连接超时排查` and once with no
query.
- Observed result: with no query the higher-density plain hunk takes the
slot (the CJK hunk `数据库连接失败重试` is dropped); with the CJK context its
bigrams (`数据` / `据库` / `库连` / `连接`) match → score `0.06 + 4×0.2 = 0.86`
beats the plain hunk's `0.12` → the CJK hunk survives. Both directions
are asserted; before this change the spaceless CJK query matched neither
hunk and the CJK hunk was always dropped.
- Not tested: the Python side — `diff_compressor.py` is a thin shim that
delegates `compress()` straight to Rust, so hunk scoring has no Python
twin; and no new parity fixtures were recorded, since the 20 existing
diff fixtures contain no CJK and therefore stay byte-identical.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation — N/A
(internal scoring)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A: internal relevance-scoring
fix, no user-facing surface change

## Additional Notes

- Completes the relevance-scorer CJK sweep across the compressors
(search, adaptive sizer, the shared BM25 tokenizer, and now diff).
Rust-only — no Python parity mirror is needed because diff hunk scoring
has no Python twin (the shim delegates `compress()` straight to Rust).
2026-07-15 18:16:55 +00:00
Zhenjia ZHOU
5de12f75e3
docs(ccr): correct stale 5-minute TTL hints to 30 minutes (#2224)
## Description

The CCR store default TTL is `DEFAULT_TTL = 1800s` (30 minutes — see
`crates/headroom-core/src/ccr/mod.rs` and `config.py
store_ttl_seconds=1800`), but several user-facing hints and docstrings
still said "5 minutes", the old default. The opencode/openclaw retrieve
tools surfaced `(default TTL: 5 minutes)` in their expiry hint — exactly
the misleading message reported in #1023. (The CCR cache itself works;
the row-drop store bridge that populates the retrieve store landed for
#389.)

This corrects the two plugin hints, the `InMemoryCcrStore` docstrings,
the SQLite/backend default TTL comments, and the `smart_crusher` mirror
comment. The `mod.rs` comment that references "the *old* 5-minute
default" is intentionally left unchanged — it correctly describes
history.

## Type of Change

- [x] Documentation update

## Changes Made

- `plugins/openclaw/src/tools/headroom-retrieve.ts` +
`plugins/opencode/src/retrieve.ts`: retrieve-failure hint `5 minutes` →
`30 minutes`.
- `crates/headroom-core/src/ccr/backends/in_memory.rs`: two docstrings
(`5 minutes by default`, `5-minute TTL`) → `30 minutes` / `30-minute`.
- `crates/headroom-core/src/ccr/backends/mod.rs` + `sqlite.rs`:
SQLite/default backend TTL comments `5-minute` → `30-minute`.
- `headroom/transforms/smart_crusher.py`: mirror comment `defaults to 5
minutes` → `30 minutes`.

## Testing

- [x] Linting passes (`ruff` / `cargo check`)
- [x] Manual verification (see Real Behavior Proof)

### Test Output

```text
$ ruff format --check headroom/transforms/smart_crusher.py   # clean
$ cargo check -p headroom-core                                # Finished, no errors
```

## Real Behavior Proof

- Environment: macOS (Darwin), branch `feat/ccr-ttl-hint-fix` off
`main`.
- Exact command / steps: grepped every `5 minutes` / `5-minute` TTL
reference across the repo; confirmed the real default is `DEFAULT_TTL =
Duration::from_secs(1800)` (`ccr/mod.rs:66`), that
`InMemoryCcrStore::new()` uses `DEFAULT_TTL` (not a local 300s), and
that `config.py` sets `store_ttl_seconds = 1800 # 30 minutes`.
- Observed result: all stale CCR default-TTL "5 minutes" references now
read "30 minutes"; the one historical reference (`mod.rs`: "the old
5-minute default") is left as-is because it is accurate.
- Not tested: nothing runtime changed — these are docstring/comment/hint
string edits only, so there is no behavior to exercise.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code — N/A (this PR is comments/strings)
- [x] I have made corresponding changes to the documentation (this *is*
the doc change)
- [x] My changes generate no new warnings
- [ ] I have added tests — N/A (no behavior change)
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A: user-facing hint/docstring
correction, no functional change

## Additional Notes

- Surfaced while root-causing #1023: the "cache permanently empty / TTL:
5 minutes" report is resolved on `main` (the store-bridge for #389
populates the retrieve store), but the stale "5 minutes" strings the
reporter actually saw were still in the tree. This PR fixes those.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:15:53 +00:00
Abhay Singh
daca1dd756
fix(cli/init): fail clearly on a target settings file with invalid JSON (#2227)
## Description

`headroom init` crashes with a raw traceback when a target's settings
file contains invalid JSON.

`_json_file` reads the JSON config files that init read-merge-writes
(Claude's `settings.json`, Codex's `hooks.json`, etc.):

```python
def _json_file(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    content = path.read_text(encoding="utf-8").strip()
    if not content:
        return {}
    payload = json.loads(content)          # unguarded
    return payload if isinstance(payload, dict) else {}
```

These are user-owned files that people hand-edit, so a stray trailing
comma or an unquoted key is entirely plausible. When that happens
`json.loads` raises `json.JSONDecodeError` and it propagates all the way
out, so `headroom init` dies with a Python traceback instead of a usable
message.

Returning `{}` on the error would be worse, not better: every caller
does `payload = _json_file(path)` then `_write_json(path, payload)`, so
an empty dict would make init overwrite the user's real settings with
just the hooks/env block — silent data loss.

## Fix

Guard the parse and convert it into an actionable `ClickException` that
names the file and the parse error, leaving the file untouched:

```python
try:
    payload = json.loads(content)
except json.JSONDecodeError as e:
    raise click.ClickException(
        f"{path} contains invalid JSON ({e}); fix it and re-run, or move it aside."
    ) from e
```

`click.ClickException` is already the project's convention for
user-facing init failures (e.g. the `'claude' not found in PATH`
messages). The user now gets a clear instruction, and their file is
never clobbered.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cli/init.py`: wrap the `json.loads` in `_json_file` and
raise a `ClickException` on `JSONDecodeError`.
- `tests/test_cli/test_init_cli.py`: new test asserting a malformed file
raises `ClickException` (matching "invalid JSON") and is left
byte-for-byte untouched.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/init.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the behavior with a dependency-free script mirroring
`_json_file` and left the full pytest to CI.
- Exact command / steps: wrote a settings file containing `{"env": {"A":
"B",}}` (trailing comma), then called the OLD unguarded reader and the
NEW guarded reader; also re-checked a valid file round-trips.
- Observed result: OLD raises a raw `json.JSONDecodeError` (the init
traceback); NEW raises a `ClickException` containing "invalid JSON" and
leaves the file byte-for-byte unchanged; valid JSON still parses to the
same dict.
- Not tested: a full `headroom init` end-to-end run; full local `pytest`
deferred to CI (OOM).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `_load_init_module` harness (the same one the neighbouring
`test_json_file_*` tests use), so it runs under the normal CI pytest
job; behaviour is additionally verified by the standalone proof above.
2026-07-15 18:15:45 +00:00
Ayush Kumar Jha
f71fef1ca6
fix(claude): treat non-zero claude --version exit as version-unknown … (#2233)
## Description

Treat a non-zero `claude --version` exit as an unknown Claude Code
version, even if the failing command prints a version-shaped string to
stdout or stderr.

This is a follow-up to the Remote Control gate work for #1779/#1883. The
callers rely on `None` to use the self-qualified "2.1.196+ / unknown"
warning path; accepting a version from a failed command can produce a
false exact-version warning.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/providers/claude/runtime.py`: return `None` from
`detect_claude_code_version` when the `claude --version` subprocess has
a non-zero return code.
- `tests/test_issue_1779_remote_control_gate.py`: add a regression test
where a failing process still prints `2.1.196 (Claude Code)` and must be
treated as unknown.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_issue_1779_remote_control_gate.py -q
50 passed

$ uvx ruff==0.15.17 check headroom/providers/claude/runtime.py tests/test_issue_1779_remote_control_gate.py --output-format concise
All checks passed!

$ uvx ruff==0.15.17 format --check headroom/providers/claude/runtime.py tests/test_issue_1779_remote_control_gate.py
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12/3.13 test environment, local
checkout of this PR branch.
- Exact command / steps: ran the focused Remote Control gate test file,
including the new regression that stubs `claude --version` as
`returncode=1` with version-shaped stdout.
- Observed result: `detect_claude_code_version("claude")` returns `None`
for the failed command, preserving the unknown-version path; existing
parser/gate tests still pass.
- Not tested: an actual failing Claude Code binary invocation on a user
machine; the subprocess behavior is covered by the regression stub.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:14:58 +00:00
JD Davis
cbfa267c5f
fix(deps): enforce transformers security floor (#2201)
## Description

Raise the production `transformers` dependency floor so the security
workflow cannot resolve the CVE-2026-5241 vulnerable range reported by
`pip-audit`, and refresh the small current-main test fixtures needed for
the PR matrix to run green.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Raised direct optional `transformers` declarations for `proxy`, `ml`,
and `voice` extras to `>=5.5.0,<6.0`.
- Refreshed `uv.lock` metadata so `uv export --extra all` resolves a
patched `transformers` version for the production audit set.
- Kept the current-main test fixture fixes for the ZCode setup printer
and deferred compression fallback metrics.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv lock --check
Resolved 238 packages in 1ms

$ uv export --frozen --no-dev --no-emit-project --no-hashes --extra all --format requirements-txt | rg "^transformers==|^huggingface-hub=="
huggingface-hub==1.16.1
transformers==5.13.1

$ uv export --frozen --no-dev --no-emit-project --no-hashes --extra all --format requirements-txt > requirements-prod.txt
$ uvx pip-audit -r requirements-prod.txt
No known vulnerabilities found

$ uv run --with pytest --with pytest-asyncio --with fastapi --with httpx --with numpy pytest tests/test_cli/test_wrap_zcode.py::test_wrap_prints_proxy_urls tests/test_cold_start_fast_pass.py::test_fast_pass_failure_falls_back_to_full_deferral -q
2 passed

$ uvx ruff==0.15.17 check headroom/cli/wrap.py headroom/proxy/handlers/anthropic.py --output-format concise
All checks passed!

$ git diff --check
passed
```

## Real Behavior Proof

- Environment: Windows checkout plus the same frozen production
dependency export shape used by the GitHub Actions security workflow.
- Exact command / steps: raised the `transformers` floor, refreshed
`uv.lock`, exported `--extra all` production requirements, ran
`pip-audit`, then reproduced the focused ZCode and deferred-compression
tests.
- Observed result: the export resolves `transformers==5.13.1`;
`pip-audit` reported no known vulnerabilities; the focused tests pass
locally; CI is rerunning on the updated head.
- Not tested: full GitHub Actions matrix locally; CI is running the
complete suite on this PR.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A - dependency metadata and CI fixture fix.

## Additional Notes

This PR is intentionally scoped to clearing the current red mainline
security gate while keeping the small fixture updates needed by the
branch test matrix.
2026-07-15 11:50:53 -05:00
jichaowang02-lang
6413cc75a2
fix(wrap): read/write instruction files as UTF-8 on Windows (#1245)
## Description

Fixes #1126. On a Windows (cp1252) locale, `headroom wrap` crashes with
`UnicodeDecodeError` the first time it injects guidance into a user
instruction
file that contains non-ASCII prose (e.g. typographic quotes `“happy
places”` or
an em-dash). `_inject_rtk_instructions` and `_inject_memory_agents_md`
both read
the existing file and append/create it with a bare `read_text()` /
`open()` /
`write_text()`, so the default codec (cp1252, not UTF-8) chokes on the
multi-byte characters.

This is the same bug class already fixed for the `learn` pipeline
(#1202) and
earlier for other wrap paths — here it's the instruction-file injectors.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cli/wrap.py`: in `_inject_rtk_instructions` and
  `_inject_memory_agents_md`, read the existing instruction file as
  `encoding="utf-8", errors="replace"` and append/create with
`encoding="utf-8"`. The read only feeds the marker-existence check and
the
append doesn't rewrite existing bytes, so replacement can't corrupt the
file.
- `tests/test_cli/test_wrap_encoding.py`: new regression tests.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_cli/test_wrap_encoding.py tests/test_cli/test_wrap_hintfile_agents.py -q
16 passed

$ ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_encoding.py
All checks passed!
```

The new tests are **red on the old code, green with the fix**: injecting
into a
file with a typographic quote plus a stray `0x9d` byte (undefined in
cp1252 and
invalid UTF-8, so a bare `open()` fails on any locale) — the append and
idempotent paths fail before the fix (4 failed) and pass after (6
passed).

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, against the real
`headroom.cli.wrap`
injectors (no live agent launch; the decode failure is at file read
time).
- Exact command / steps: `write_bytes` an `AGENTS.md` containing
`"Be in “happy places” — really.\n"` plus a stray `0x9d` byte, then call
  `_inject_rtk_instructions(path)` / `_inject_memory_agents_md(path)`.
- Observed result: **before** the fix → `UnicodeDecodeError: 'utf-8'
codec
  can't decode byte 0x9d` (and on a real cp1252 locale, the same on the
typographic quotes alone); **after** → both return `True`, the marker is
present, the pre-existing prose is preserved, and re-running is
idempotent.
- Not tested: a full end-to-end `headroom wrap copilot` against a live
Copilot
CLI (verified at the injector level, which is where the decode crash
lives).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 09:26:34 -05:00
Eyal Mizrachi
9e376afabe
fix(mcp): mcp status checks ~/.claude.json, not only ~/.claude/mcp.json (#990)
## Description

`headroom mcp status` only inspected `~/.claude/mcp.json`, but servers
registered via `claude mcp add` (user scope) live in `~/.claude.json`.
So `status` printed `✗ No config file` even when headroom was registered
and `claude mcp list` reported it Connected. This detects the
registration across every location Claude Code uses.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Add `find_headroom_registration()` that checks `~/.claude.json`,
`~/.claude/mcp.json`, then `./.mcp.json` (first match wins).
- Use it in `mcp status` for both the "Configured" check and the
proxy-URL lookup.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_cli/test_mcp_status.py -q
5 passed in 0.13s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, editable build of this branch
- Exact command / steps: registered headroom in ~/.claude.json, then ran
`headroom mcp status`
- Observed result: prints `✓ Configured` with the ~/.claude.json path
(previously `✗ No config file`)
- Not tested: project-scoped ./.mcp.json discovery in a real multi-repo
workflow

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 09:25:52 -05:00
yiihao
0c7087539d
fix: add DeepSeek V4/V3.2/R1 tokenizer mappings and context limits (#912)
The tokenizer registry routed deepseek-v4-pro, deepseek-v4-flash,
deepseek-chat, deepseek-reasoner, and other modern DeepSeek models
to the 2023-era deepseek-llm-7b-base tokenizer via prefix fallback.

This caused token counts off by 30-50%, broken context-limit detection
(V4-Pro supports 1M but got 32K), and inaccurate savings reports.

## Fix

3 files, +43/-2:

- **huggingface.py**: 16 new MODEL_TO_TOKENIZER entries with verified
  HuggingFace IDs (deepseek-ai/DeepSeek-V4-Pro, V4-Flash, V3.2,
  V3-0324, R1, R1-0528, Reasoner, Chat, Coder-V2, etc.)

- **openai_compatible.py**: 17 new _DEFAULT_CONTEXT_LIMITS entries
  (V4-Pro/Flash -> 1M, R1/Reasoner -> 131K, V3 -> 128K, etc.)

- **openai.py**: 8 new _CONTEXT_LIMITS entries for LiteLLM-fallback.

Existing mappings untouched (backward compatible).

## Real behavior proof

- **Setup**: Windows 11, Python 3.13.14, headroom-ai 0.2.15 wheel +
  source checkout at v0.24.0. No Rust extension built (headroom._core
  unavailable). Touched files are at parity with v0.24.0.

- **Steps after patch**:
  ```
  python3 -c "
  from headroom.tokenizers.huggingface import get_tokenizer_name
for m in
['deepseek-v4-pro','deepseek-chat','deepseek-reasoner','deepseek-v4-flash']:
      print(f'{m} -> {get_tokenizer_name(m)}')
  from headroom.tokenizers.registry import get_tokenizer
  for m in ['deepseek-v4-pro','deepseek-chat','deepseek-reasoner']:
      print(f'{m}: {get_tokenizer(m)}')
  "
  ```

- **Observed result**:
  ```
  deepseek-v4-pro  -> deepseek-ai/DeepSeek-V4-Pro
  deepseek-v4-flash -> deepseek-ai/DeepSeek-V4-Flash
  deepseek-chat    -> deepseek-ai/DeepSeek-V3
  deepseek-reasoner -> deepseek-ai/DeepSeek-R1
  ```
  Previously ALL resolved to deepseek-ai/deepseek-llm-7b-base.
  TokenizerRegistry routes correctly. Context limits verified
  (1M / 131K / 128K). compress() import smoke-tested OK.

- **Not tested**: full proxy e2e with a live DeepSeek API key
  (no available key). HuggingFace AutoTokenizer download confirmed
  for V4-Pro/V3/R1 but produced GBK decode errors from hf_hub on
  this zh-CN Windows locale during config fetch -- a separate
  huggingface_hub issue unrelated to this change.


<!-- headroom-maintainer-template-completion:start -->

## Description

This PR prepares `fix: add DeepSeek V4/V3.2/R1 tokenizer mappings and
context limits` for review by documenting the intended change,
validation evidence, and remaining merge-readiness context.

Linked issues: None declared.

## Type of Change

- [x] Bug fix
- [ ] New feature
- [ ] Documentation
- [ ] Refactor
- [ ] Tests only

## Changes Made

- Commit: fix: add DeepSeek V4/V3.2/R1 tokenizer mappings and context
limits
- Touches `headroom/providers/openai.py`
- Touches `headroom/providers/openai_compatible.py`
- Touches `headroom/tokenizers/huggingface.py`

## Testing

- [x] GitHub checks reviewed
- [x] Metadata/template validation
- [ ] Local functional testing

### Test Output

```text
gh pr view 912 --repo chopratejas/headroom --json statusCheckRollup
- PR Governance / label: SUCCESS
- external / GitGuardian Security Checks: SUCCESS
```

## Real Behavior Proof

- Environment: GitHub PR metadata and checks for `chopratejas/headroom`
PR #912.
- Exact command / steps: Reviewed PR title, commits, changed files,
linked issues, labels, and check rollup; appended this maintainer
template completion block without replacing the author's original
description.
- Observed result: PR body now contains all required governance
sections, checked readiness fields, and a non-placeholder validation
evidence block.
- Not tested: This pass updated PR metadata only; code validation
remains represented by the linked GitHub checks and any author-provided
evidence above.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

<!-- headroom-maintainer-template-completion:end -->

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 09:24:44 -05:00
Tejas Chopra
36202f4d0b
fix(windows): unwedge compression on degraded ONNX runtimes (every request timing out at 30s, 0% savings) (#822)
## Summary

Multiple Windows users reported (via Discord, on v0.23.0, `pip install
"headroom-ai[all]"`) that the proxy delivers **zero compression** and
adds **+30s latency to every request**: `Optimization failed:
TimeoutError:` with `compression_first_stage ≈ 30000ms` on every
optimization attempt, for the lifetime of the process. Log analysis
showed the wedge starts at the **first message eligible for real
compression** (earlier requests succeed because everything is
skipped/excluded) — and never recovers, even though the Kompress model
loaded successfully at startup.

### Root cause chain

1. `create_cpu_session_options` disabled ONNX Runtime's CPU memory arena
on **all** platforms. On Windows this is catastrophic: every `Run()`
falls back to per-node `VirtualAlloc`/free, slowing ModernBERT inference
by 2–3 orders of magnitude (onnxruntime#11627). One reporter's perf
summary showed max optimization overhead of **200,369ms** (~13 chunks ×
~15s) — slow, not deadlocked.
2. The first slow inference outlives the proxy's 30s compression-stage
timeout. `asyncio.wait_for` abandons the future but **cannot kill the
executor thread**, which keeps holding the Kompress
`BoundedSemaphore(1)`.
3. Every later compression blocks on an **unbounded**
`semaphore.acquire()`, times out at exactly 30s, and leaks another
thread — permanently wedging the proxy until restart.

Two adjacent Windows bugs found in the same logs are fixed too:
`subprocess.run(text=True)` without `encoding=` decodes child output
with cp1252, so rtk's emoji output killed reader threads
(`UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f`); and the
OpenAI handler logged `Optimization failed: ` with an empty message
because `str(asyncio.TimeoutError())` is empty.

### Fixes

- **`onnx_runtime.py`** — keep the CPU arena at ORT's default on
Windows; Linux/macOS keep the legacy low-RSS behavior (arena disabled)
bit-for-bit. New `HEADROOM_ONNX_CPU_ARENA` env overrides either way. All
ONNX sessions (Kompress, image router, memory embedders) share this
helper, so one fix covers them all.
- **`kompress_compressor.py`** — three layers of wedge-proofing, each
fail-safing to passthrough instead of blocking:
- bounded semaphore acquire
(`HEADROOM_KOMPRESS_ACQUIRE_TIMEOUT_SECONDS`, default 5s)
- wall-clock budget per compress/compress_batch call
(`HEADROOM_KOMPRESS_TIME_BUDGET_SECONDS`, default 20s — under the 30s
stage timeout, so Kompress gives up before the request is abandoned).
Batch bail never emits a partially-covered text.
- preload canary (`HEADROOM_KOMPRESS_CANARY_SECONDS`, default 5s, one
retry to forgive cold-start warmup): machines that can never finish
inference inside the stage timeout get ML compression disabled up front
with one actionable warning, instead of a guaranteed 30s timeout per
request.
- Setting any knob `<= 0` disables that guard (restores legacy
behavior). First give-up logs at WARNING with remediation hints; repeats
drop to DEBUG.
- **`proxy/helpers.py`, `interceptors/astgrep.py`** — `encoding="utf-8",
errors="replace"` on rtk/lean-ctx/ast-grep subprocess calls.
- **`handlers/openai.py`** — failure log now includes request id +
exception type, matching the Anthropic handler.

### Non-Windows perf

- Session options on Linux/macOS are unchanged (pinned by tests).
- The only new hot-path cost is one `time.monotonic()` + a bounded
acquire per chunk: micro-benchmarked at sub-microsecond (bounded acquire
measured marginally *faster* than the old context-manager acquire), vs
50–500ms of inference per chunk.
- Real-model smoke run on macOS: identical compression output (ratio
0.262 on a 1020-word sample), canary passes, budget/acquire give-up
paths verified against the real ONNX stack by forcing tiny env values.

Related (same symptom, different root cause — **not** addressed here):
#810 tracks the blocked-tiktoken-download hang, which produces the same
per-request 30s `TimeoutError` signature. The bounded-acquire/budget
changes in this PR limit the blast radius of Kompress-side slowness
only.

## Validation

- `.venv/bin/ruff check headroom/ tests/...` — clean
- `.venv/bin/ruff format --check` — clean (355 files)
- `.venv/bin/mypy` on all five changed source files — no issues
- `python -m pytest tests/test_onnx_runtime.py
tests/test_kompress_failsafe.py tests/test_subprocess_encoding.py` — 25
passed (new coverage: arena platform matrix + env overrides,
stuck-semaphore passthrough for compress and batch, budget bail incl.
mid-batch no-data-loss, canary trip/pass/retry/disable/error-safety,
UTF-8 subprocess kwargs)
- `python -m pytest tests/test_transforms_content_router.py
tests/test_proxy_handler_helpers.py
tests/test_codex_ws_compression_scheduler.py tests/test_proxy_warmup.py
tests/test_proxy_pipeline_lifecycle.py` — 52 passed (existing suites for
touched areas)


<!-- headroom-maintainer-template-completion:start -->

## Description

This PR prepares `fix(windows): unwedge compression on degraded ONNX
runtimes (every request timing out at 30s, 0% savings)` for review by
documenting the intended change, validation evidence, and remaining
merge-readiness context.

Linked issues: None declared.

## Type of Change

- [x] Bug fix
- [ ] New feature
- [ ] Documentation
- [ ] Refactor
- [ ] Tests only

## Changes Made

- Commit: fix(windows): unwedge compression on degraded ONNX runtimes
- Commit: fix(kompress): run preload canary off the startup path
- Touches `headroom/onnx_runtime.py`
- Touches `headroom/proxy/handlers/openai.py`
- Touches `headroom/proxy/helpers.py`
- Touches `headroom/proxy/interceptors/astgrep.py`
- Touches `headroom/transforms/kompress_compressor.py`
- Touches `tests/test_kompress_failsafe.py`

## Testing

- [x] GitHub checks reviewed
- [x] Metadata/template validation
- [ ] Local functional testing

### Test Output

```text
gh pr view 822 --repo chopratejas/headroom --json statusCheckRollup
- CI / changes: SUCCESS
- CodeQL / Analyze (actions): SUCCESS
- Evaluation Suite / smoke-test: SUCCESS
- Init E2E / docker-init-e2e: SUCCESS
- PR Governance / label: SUCCESS
- Wrap E2E / docker-wrap-e2e: SUCCESS
- CodeQL / Analyze (c-cpp): SUCCESS
- CodeQL / Analyze (javascript-typescript): SUCCESS
- CodeQL / Analyze (python): SUCCESS
- CodeQL / Analyze (rust): SUCCESS
- Evaluation Suite / weekly-suite: SKIPPED
- CI / commitlint: SUCCESS
```

## Real Behavior Proof

- Environment: GitHub PR metadata and checks for `chopratejas/headroom`
PR #822.
- Exact command / steps: Reviewed PR title, commits, changed files,
linked issues, labels, and check rollup; appended this maintainer
template completion block without replacing the author's original
description.
- Observed result: PR body now contains all required governance
sections, checked readiness fields, and a non-placeholder validation
evidence block.
- Not tested: This pass updated PR metadata only; code validation
remains represented by the linked GitHub checks and any author-provided
evidence above.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

<!-- headroom-maintainer-template-completion:end -->

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 09:23:44 -05:00
Tejas Chopra
cb6c828457
fix(proxy): one bad extension no longer aborts proxy startup (#2215)
## What

`install_all()` (the `headroom.proxy_extension` loader) previously let
any exception from an extension's `install()` **propagate and abort
proxy startup** — one broken or version-incompatible third-party
extension took the whole proxy down, and every other extension with it.

This makes extension loading resilient:
- catch a failing `install()`, log it (with traceback), record it as
**skipped**
- continue installing the rest — a failure disables that one extension,
not the proxy
- print a `SKIPPED` line to the console (the startup banner lists
*enabled* extensions before install runs, so a skip would otherwise be
logging-config dependent)

## Why

Found while testing several proxy extensions together in a clean venv: a
plugin built against a newer core API raised `ModuleNotFoundError` from
`install()` and crashed the proxy at startup. An extension that fails
its own environment/auth check should disable itself — it should not
take the whole proxy down.

## Real behavior proof

Before — one extension failing in `install()`:
```
... proxy did NOT come up (/livez never answered)
```

After — same setup, one extension deliberately broken:
```
[headroom] proxy extensions SKIPPED: myorg_ext (install failed — running without them; see logs)
/livez: 200 healthy      # proxy up; the other extensions installed
```

Loader unit check (fake failing extension):
```
returned installed: ['good_ext']         # bad one excluded
bad_ext skipped (not in installed): True
good_ext survived: True
warning logged for bad_ext: True
```

## Tests

- `mypy headroom/proxy/extensions.py` → `Success: no issues found`
- `ruff check headroom/proxy/extensions.py` → `All checks passed!`
- Verified in-process (catch/skip/continue + logging) and end-to-end
against a running proxy (`/livez` 200 with a deliberately failing
extension).

## Maintainer Follow-up
- Added `tests/test_proxy_extensions.py` covering skip-and-continue
behavior for a failed extension and the missing-extension warning path.
- Removed an informal implementation comment from
`headroom/proxy/extensions.py`.
- Validation on `fe5176db`: `uv run --frozen --extra dev python -m
pytest tests/test_proxy_extensions.py -q`, `uvx ruff==0.15.17 check
headroom/proxy/extensions.py tests/test_proxy_extensions.py
--output-format concise`, `git diff --check`, and commit hooks all
passed.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 22:50:03 -07:00
Tejas Chopra
79d8056fd7
fix(mcp): regenerate stale server.json (0.27.0 -> 0.32.0) (#2218)
## Description

The committed `server.json` pinned version `0.27.0` while
`pyproject.toml` is at `0.32.0`.
`tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder`
asserts the committed artifact equals `render_server_json()`, so it
fails on `main`. This regenerates `server.json` from the current
metadata.

Found while getting the security PR (#2207) CI green. The two other
pre-existing failures it was grouped with were **already fixed on
`main`** by recent commits — `test_cold_start_fast_pass`
(`record_compression_failed` added to the metrics double) and
`test_cli/test_wrap_zcode` (watcher mock now passes the port) — so this
PR only needs the `server.json` regen.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Regenerated `server.json` from `render_server_json()` so the committed
artifact matches the current package version (`0.32.0`).

## Testing

- [x] Unit tests pass (`pytest`) — the previously-failing tests
- [x] Linting passes (`ruff check`)

### Test Output

```text
$ pytest tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder \
         tests/test_cold_start_fast_pass.py::test_fast_pass_failure_falls_back_to_full_deferral \
         tests/test_cli/test_wrap_zcode.py::test_wrap_prints_proxy_urls -q
3 passed
```

## Real Behavior Proof

- Environment: branch off current `main` (`ea3d5a86`), Python 3.12,
project `.venv`.
- Steps: `python -c "from headroom.mcp_registry import
render_server_json;
open('server.json','w').write(render_server_json())"`, then ran the MCP
registry test.
- Observed: `server.json` `version` → `0.32.0`;
`test_root_server_json_matches_builder` passes.
- Not tested: full suite (single generated-artifact change).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] Documentation changes (N/A)
- [x] My changes generate no new warnings
- [ ] Tests added (N/A — regenerates an artifact an existing test
already guards)
- [x] New and existing unit tests pass locally
- [ ] CHANGELOG (N/A)

## Additional Notes

`server.json` is a generated artifact
(`headroom/mcp_registry/server_json.py`) — regenerate with
`render_server_json()` after any version bump.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-14 22:49:39 -07:00
JD Davis
ea3d5a86b7
fix(deps): clear Dependabot lockfile alerts (#2175)
## Description

Clears the current dependency/security-audit blockers that are making
unrelated PRs red:

- `transformers 5.3.0` / `CVE-2026-5241`, fixed by requiring
`transformers>=5.5.0` in the locked optional dependency set.
- `sqlitedict <=2.1.0` via the optional `benchmark` extra's
`lm-eval[api]` dependency. There is no patched `sqlitedict` release, so
this PR removes the published/locked `benchmark` extra instead of
shipping a known-vulnerable transitive dependency.
- `esbuild >=0.27.3,<0.28.1` in the OpenCode plugin lockfile, fixed by
forcing `esbuild@0.28.1` through the OpenCode npm override and
regenerated lockfile.

The benchmark code still invokes `python -m lm_eval`; researchers who
need that harness should install `lm-eval[api]` in their benchmark
environment until its transitive vulnerability has a patched release.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `pyproject.toml`: remove the `benchmark` optional extra, document
external `lm-eval[api]` installation guidance, and require
`transformers>=5.5.0`.
- `uv.lock`: regenerate without the `benchmark` extra, removing
`lm-eval` and `sqlitedict` lock entries and locking the patched
transformers floor.
- `plugins/opencode/package.json`: add an `overrides` entry for
`esbuild@0.28.1`.
- `plugins/opencode/package-lock.json`: regenerate the OpenCode lockfile
with `esbuild@0.28.1`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv lock --check
rg -n -F 'sqlitedict' uv.lock        # no matches
rg -n -F 'name = "lm-eval"' uv.lock  # no matches
rg -n -F "extra == 'benchmark'" uv.lock # no matches
rg -n -F '0.27.7' plugins/opencode/package-lock.json plugins/opencode/package.json # no matches
npm ls esbuild --package-lock-only
npm audit --package-lock-only        # found 0 vulnerabilities
git diff --check
```

Previous GitHub checks were green. After merging current `main`, fresh
GitHub checks are running again; local targeted validation still passes.

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.3, uv, npm in `plugins/opencode`,
Dependabot/pip-audit alert metadata from the failing PR jobs.
- Exact command / steps: inspected the regenerated Python and npm
lockfiles with `rg`, checked the uv lock with `uv lock --check`, checked
OpenCode's dependency tree with `npm ls esbuild --package-lock-only`,
and ran `npm audit --package-lock-only`.
- Observed result: `uv.lock` no longer contains `sqlitedict`, `lm-eval`,
or a `benchmark` extra marker; `transformers` resolves at the patched
`>=5.5.0` floor; OpenCode's lock resolves `esbuild@0.28.1`; `npm audit
--package-lock-only` reports 0 vulnerabilities; GitHub `Dependency audit
(pip-audit)` passes.
- Not tested: running the external `lm-eval` harness after installing it
separately.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A - dependency and lockfile security fix.

## Additional Notes

The `benchmark` extra can be restored once the upstream `lm-eval[api]`
dependency chain stops pulling a vulnerable `sqlitedict` release.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 20:40:28 -07:00
Rod Boev
dbbef4bd41
fix(code-compressor): recover valid Python rewrites after local syntax rejection (#2202)
## Description

A compile-invalid Python definition rewrite currently makes
`CodeAwareCompressor` discard every otherwise valid rewrite in the file
and return the original source at 0 percent reduction. The existing
whole-file safety guard stays in place, while a Python-only recovery
replay now preserves the rejected definition and keeps independent valid
compression.

The recovery reuses the current Python validation authority in
`ast.parse()` plus `compile()`, runs only after the first assembled
module already fails `_verify_syntax()`, and stays out of non-Python
paths.

Closes #1233

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added a Python-only recovery replay after the first assembled module
fails syntax validation.
- Preserved only the invalid function or class rewrite while allowing
independent valid definitions to remain compressed.
- Kept the existing whole-file syntax guard and original-source fallback
as the terminal safety check.
- Added focused invalid-node, valid-modern-syntax, and fail-safe
coverage.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_invalid_node_falls_back_locally
tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_does_not_block_valid_modern_syntax
tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_still_returns_original_when_all_candidates_invalid
tests/test_transforms/test_code_compressor.py::TestEdgeCases::test_syntax_errors_in_input
tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_ast_preserves_structure_for_python
-v`)
- [x] Linting passes (`uv run ruff check
headroom/transforms/code_compressor.py
tests/test_transforms/test_code_compressor.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_invalid_node_falls_back_locally tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_does_not_block_valid_modern_syntax tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_still_returns_original_when_all_candidates_invalid tests/test_transforms/test_code_compressor.py::TestEdgeCases::test_syntax_errors_in_input tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_ast_preserves_structure_for_python -v
5 passed in 0.34s
uv run ruff check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py
All checks passed!
uv run ruff format headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py --check
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows, synced `uv` environment with `dev` and `code`
extras installed
- Exact command / steps: run the focused invalid-node regression through
public `compress(..., language="python")`
- Observed result: `1 passed in 0.19s`; the invalid candidate stays
original, the neighboring valid candidate remains compressed, and
`headroom-PR-TARGET-1233-PROOF.md` records the base `ratio=1.0`
whole-file rollback against the fixed head behavior.
- Not tested: the stale future-import mismatch discussed in the old
issue comment, already covered on current main

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Additional Notes

- The stale future-import comment on #1233 is not the live slice here;
current main already validates Python with `compile()` and already
covers that ordering case.
- This fix keeps the existing whole-file fail-safe and does not broaden
into cross-language recovery or new syntax models.
- `CHANGELOG.md` remains unchanged because Headroom generates release
notes from conventional commits.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 20:19:46 -07:00
Rod Boev
8522fcbc40
fix(code): quarantine Perl parser from code-aware compression (#2204)
## Description

`headroom proxy` can wedge when code-aware compression enters the
tree-sitter Perl external scanner and the native scan keeps the GIL
indefinitely. Current main still has two routes into that scanner:
explicit `perl` or `pl` hints flow through `CodeAwareCompressor`, and
`detect_language()` can nominate Perl on non-Perl code because its
prefilter matches generic sigils such as decorators, JSDoc tags, and
shell variables before phase 2 parses every surviving candidate grammar.

This change quarantines Perl at the code-aware compression funnel
without widening scope. Perl remains recognized at the input boundary,
but live proxy compression no longer requests a Perl parser. Non-Perl
code keeps its existing code-aware behavior. Real Perl falls back
through the existing safe Kompress or passthrough contract instead of
entering tree-sitter. The diff stays inside
`headroom/transforms/code_compressor.py` plus focused parser-safety
regressions.

Refs #2185

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added a Perl quarantine list in
`headroom/transforms/code_compressor.py` and used it to stop Perl
candidate parsing during language detection.
- Added a hard `_get_parser()` guard so no live code-aware path can
construct a Perl parser.
- Routed resolved explicit Perl hints through the existing safe fallback
or passthrough contract before AST compression.
- Added focused parser-safety regressions for non-Perl candidate bleed,
explicit `perl` and `pl`, inferred real Perl, mixed fenced Perl content,
fallback-disabled passthrough, and non-Perl negative space.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_perl_scanner_safety.py
-q`)
- [x] Linting passes (`uv run ruff check
headroom/transforms/code_compressor.py
tests/test_perl_scanner_safety.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_perl_scanner_safety.py -q
8 passed in 1.93s
uv run ruff check headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py
All checks passed!
uv run ruff format headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py --check
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python from the synced `uv` environment, `dev`
and `code` extras installed, no provider call
- Exact command / steps: Run `uv run pytest
tests/test_perl_scanner_safety.py -q`.
- Observed result: `8 passed in 1.93s`; the suite proves explicit `perl`
and `pl`, inferred real Perl, mixed fenced Perl, and non-Perl
negative-space routes all avoid Perl parser entry.
- Not tested: the reporter's macOS payload and long-running concurrent
workload

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Additional Notes

- Use `Refs #2185`, not `Closes #2185`. The wedge surface is this PR's
scope, but #2185 also carries a separate orphaned `headroom mcp serve`
report that this slice does not address.
- This is a reachability fix. It does not repair the upstream Perl
scanner and it does not harden other grammars against the same class of
native wedge.
- `CHANGELOG.md` remains unchanged because Headroom generates release
notes from conventional commits.
- Merged PR https://github.com/headroomlabs-ai/headroom/pull/2114
addresses a different cooperative compression stall and stays separate
from this native parser-entry slice.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 20:18:51 -07:00
David Wells
2a954b69b4
feat(wrap): add ZCode desktop app support (#1845)
## Description

Add `headroom wrap zcode` and `headroom unwrap zcode` commands for the
ZCode desktop app (zcode.z.ai). ZCode is a desktop Electron IDE built by
Z.AI, optimized for GLM-5.2 models. It has no CLI binary, so this
follows the Pattern-B (proxy-only, print instructions) approach — same
as Cursor, Cline, and Continue.

**Upstream auto-detection:** `headroom wrap zcode` now reads
`~/.zcode/v2/config.json` to detect the enabled provider and
automatically configures the proxy upstream — no manual flags needed.

Closes #1844

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- New module: `headroom/providers/zcode/__init__.py` and `runtime.py`
(ZCodeProxyTargets, ZCodeUpstream, build_proxy_targets, detect_upstream,
upstream_to_proxy_urls, render_setup_lines)
- New CLI command: `headroom wrap zcode` in `headroom/cli/wrap.py:4815`
— starts proxy, injects RTK into AGENTS.md, prints Base URL setup
instructions
- New CLI command: `headroom unwrap zcode` in
`headroom/cli/wrap.py:5960` — removes RTK markers, stops proxy
- New helper: `zcode_config_dir()` in `headroom/install/paths.py`
- Updated `_run_proxy_only_watcher` to accept
`anthropic_api_url`/`openai_api_url` params
- Updated README.md: ZCode row in compatibility matrix, unwrap list,
wrap command list
- Updated CHANGELOG.md: entry under [Unreleased] > Added

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — pre-existing numpy type
stubs issue prevents full mypy run
- [x] New tests added for new functionality (24 tests in
`tests/test_cli/test_wrap_zcode.py`)
- [x] Manual testing performed

### Test Output

```text
tests/test_cli/test_wrap_zcode.py ........................               [100%]

24 passed, 1 warning in 0.22s
```

## Real Behavior Proof

- Environment: macOS 15.5, Python 3.12.13, headroom installed via `pip
install -e .[dev]`
- Exact command / steps: `headroom wrap zcode --port 9000` then
`headroom unwrap zcode --port 9000`
- Observed result: Wrap detects provider from `~/.zcode/v2/config.json`
(e.g. "Z.ai Coding"), starts proxy with correct upstream on port 9000,
injects RTK into AGENTS.md, prints detected provider + upstream + Base
URL setup instructions. Unwrap removes RTK markers, deletes empty
AGENTS.md, stops proxy.
- Not tested: Actual ZCode app integration (ZCode is a desktop Electron
app; Base URL configuration is manual in Settings > Model Settings)

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
— N/A: code follows existing patterns
- [x] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — CLI-only changes

## Additional Notes

- **Pattern-B approach:** ZCode is a desktop Electron app with no CLI
binary. Same pattern as Cursor, Cline, and Continue — proxy-only, print
instructions.
- **Upstream auto-detection:** Reads `~/.zcode/v2/config.json`, finds
the enabled provider, and passes its `baseURL` to the proxy. Falls back
to Z.ai Anthropic endpoint if no config found.
- **httpProxy investigation:** ZCode has an `httpProxy` setting in
`~/.zcode/v2/setting.json`, but it is an Electron-level forward proxy
(CONNECT tunneling), incompatible with headroom reverse proxy. The Base
URL approach in Model Settings is the correct integration point.
- **No dependencies added:** This PR adds zero new dependencies.

---------

Co-authored-by: Epicism <epicism@Epiphanie.local>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:39 -04:00
ninosat00
5709291914
chore(release): harden local artifact smokes (#1824)
## Description

Harden the release artifact workflow so maintainers can reproduce npm
and Python release checks locally and so OpenClaw packaging does not
depend on a not-yet-published SDK version. This follow-up also keeps the
OpenClaw loader export contract explicit and updates the PR after the
branch was merged with current `headroomlabs/main`.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [x] Documentation update

## Changes Made

- Added reusable local release smoke scripts for npm assets, Python
wheel/sdist artifacts, and the combined release gate.
- Switched the release workflow npm asset build to the reusable npm
builder.
- Made OpenClaw release asset building install against the just-built
local SDK tarball before rewriting packed metadata to the release
dependency range.
- Kept the OpenClaw source dependency registry-installable at
`headroom-ai@^0.22.3` while the packed artifact still ships
`^<release-version>`.
- Added `registerHeadroomPlugin` as a named export while preserving the
default `{ register }` OpenClaw loader contract.
- Added Windows development bootstrap docs/script and regression tests
for version sync, npm asset ordering, OpenClaw source installability,
and Python wheel smoke import isolation.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run --with pytest python -m pytest tests/test_release_workflows.py scripts/tests/test_version_sync.py -q
44 passed, 1 warning

node --check scripts/build_npm_release_assets.mjs
node --check scripts/verify_npm_release_assets.mjs

python -m py_compile scripts/build_python_release_smoke.py scripts/release_smoke_all.py scripts/version-sync.py
python scripts/verify-versions.py
All versions aligned at 0.31.0

npm ci (plugins/openclaw)
added 88 packages, audited 89 packages, found 0 vulnerabilities

python scripts/release_smoke_all.py --out release-assets-local/all-pr1824-postmerge-fixed-20260710-104739
Verified npm release assets for 0.31.0
wheel metadata OK: headroom_ai-0.31.0-cp310-abi3-win_amd64.whl contains headroom/_core.pyd
sdist License-File metadata OK: ['LICENSE', 'NOTICE']
smoke-import OK: version=0.31.0 hello=headroom-core
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.14.3, Node v24.14.0, npm 11.9.0, uv
0.11.15, Rust/Cargo already installed in the local development
environment.
- Exact command / steps: Ran `python scripts/release_smoke_all.py --out
release-assets-local/all-pr1824-postmerge-fixed-20260710-104739` after
syncing this branch with current `headroomlabs/main`.
- Observed result: The command built `headroom-ai-0.31.0.tgz`,
`headroom-openclaw-0.31.0.tgz`,
`headroom_ai-0.31.0-cp310-abi3-win_amd64.whl`, and
`headroom_ai-0.31.0.tar.gz`; npm verifier passed; the wheel installed
into a fresh venv and imported `headroom._core`.
- Not tested: Full GitHub Actions release matrix and publish jobs with
real PyPI/npm/GitHub Packages credentials.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

The post-merge full smoke initially failed because the OpenClaw source
package depended on `headroom-ai@^0.31.0`, which is not available on
public npm yet. The builder now installs OpenClaw against the just-built
local SDK tarball before build/pack, and then rewrites packed metadata
to `^0.31.0`.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:34 -04:00
Doyeon Baek
36577d9547
fix(search_compressor): don't let a date in a path hijack the line-number parse (#2084)
## Description

`SearchCompressor::parse_match_line` splits a grep/ripgrep line into
`(file, line_number, content)` by finding the **leftmost**
`<sep><digits><sep>` triplet, where `<sep>` is `:` or `-`. A path
segment that itself contains such a triplet hijacks the parse — and that
shape is everyday, not exotic:

| real ripgrep line | parsed as |
|---|---|
| `logs/2026-05-03/app.log:12:ERROR boom` | `("logs/2026", 5,
"03/app.log:12:ERROR boom")` |
| `advisories/CVE-2021-44228.md:8:Log4Shell` | `("advisories/CVE", 2021,
"44228.md:8:Log4Shell")` |
| `src/v1-2-beta/mod.rs:3:fn x()` | `("src/v1", 2, "beta/mod.rs:3:fn
x()")` |
| `migrations/20240101-002-add_users.sql-9-…` | `("migrations/20240101",
2, "add_users.sql-9-…")` |

**This is silent corruption, not a drop.** The parse *succeeds*, so the
line is never counted in `stats.lines_unparsed` and never falls back to
passthrough. The bogus path becomes the **grouping key** in
`parse_search_results`, so unrelated files collapse into one bucket, and
the bogus path + line number + mangled body are what get scored, capped,
and rendered into the compressed output handed to the model. **The LLM
is shown a file and a line that do not exist.**

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

One file, one function:
`crates/headroom-core/src/transforms/search_compressor.rs`.
`parse_match_line` becomes a 3-tier scan:

- **Colon tier** — leftmost `:\d+:` whose path part contains no
whitespace. `:` is grep's *match* separator and a path practically never
contains one (the Windows drive colon is already skipped by the existing
`scan_start` logic), so leftmost is right. The whitespace bound stops a
`foo.rs:12:` reference *inside the body* of a `-` context line from
hijacking the parse.
- **Dash tier** — **last** `-\d+-` whose path part contains no
whitespace. `-` is grep's *context* separator, and unlike `:` it
genuinely appears inside real paths (`2026-05-03`, `CVE-2021-44228`,
`20240101-002-…`), so the marker is the *last* triplet in the path
token, not the first.
- **Permissive tier** — the original leftmost-any rule, byte-for-byte
unchanged. Only reached when neither typed tier matched (e.g. a path
containing a space), so those lines behave exactly as before.
- Also tightened in the typed tiers: the closing separator must equal
the opening one — grep emits `file:12:body` or `file-12-body`, never a
mix.
- Added 4 tests: 2 reproducing the bug, 2 regression guards against the
naive fixes.

**Safety argument (verified by execution):** with `parse_match_line`
temporarily forced to the Permissive tier alone, all 18 pre-existing
`search_compressor` tests still pass — i.e. the fallback is a faithful
reproduction of today's rule, so the change can only *add* correct
parses on lines a typed tier claims, never remove one.

This is the next bug in a family the module already tracks: the doc has
a "Bug fixes vs Python" section and three `fixed_in_3e2_*` tests
hardening this same parser against Windows drive colons and dashes in
filenames. `pre-commit-config.yaml-42-…` (dash before a *non*-digit) is
covered; `2026-05-03` (dash before a digit run followed by another dash)
was not.

## Testing

- [x] Unit tests pass
- [x] Linting passes (`cargo clippy -p headroom-core --all-targets` → 0
warnings)
- [x] Formatting passes (`cargo fmt --all -- --check`)
- [x] New tests added (4: 2 reproducing the bug, 2 regression guards
against naive fixes)
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

**Before the fix** (new tests run against the unmodified scan rule):

```text
$ cargo test -p headroom-core --lib search_compressor

---- transforms::search_compressor::tests::date_stamped_path_is_not_misread_as_line_number_marker stdout ----
assertion `left == right` failed
  left: Some(("logs/2026", 5, "03/app.log:12:ERROR boom"))
 right: Some(("logs/2026-05-03/app.log", 12, "ERROR boom"))

---- transforms::search_compressor::tests::date_stamped_paths_are_not_collapsed_into_one_bogus_file stdout ----
assertion `left == right` failed
  left: ["logs/2026"]
 right: ["logs/2026-05-03/app.log", "logs/2026-05-04/app.log"]

test result: FAILED. 18 passed; 2 failed; 0 ignored
```

**After the fix:**

```text
$ cargo test -p headroom-core --lib search_compressor
test result: ok. 20 passed; 0 failed; 0 ignored; 835 filtered out

$ cargo test -p headroom-core --lib          # whole crate — no regressions
test result: ok. 854 passed; 0 failed; 1 ignored

$ cargo test -p headroom-parity
test result: ok. 4 passed; 0 failed

$ cargo fmt --all -- --check                            -> OK
$ cargo clippy -p headroom-core --all-targets           -> 0 warnings, 0 errors
```

Regression guards added for the two ways a naive fix breaks:
- `digit_terminated_path_still_parses_ripgrep_context_line` —
`logs/app.log.1-42-rotated line` (path ends in a digit, so the context
separator is digit-preceded).
- `body_line_reference_does_not_hijack_a_context_line` —
`src/main.py-44-see foo.rs:12:bar` (body quotes a `file:line:`
reference).

## Real Behavior Proof

Per CONTRIBUTING — unit tests alone don't prove user-visible behavior,
so this was reproduced against the **released build** (`headroom-ai`
0.26.0 from PyPI, the compiled `_core.abi3.so`), driving the **public
`SearchCompressor.compress()` API** on **real `rg` output over real
files on disk** — not fixtures or mocks.

- Environment: macOS (Darwin 25.5.0, arm64), Python 3.13, released
`headroom-ai` 0.26.0 (`site-packages/headroom/_core.abi3.so`); patched
build = this branch compiled with `cargo build --release -p
headroom-py`, rustc 1.96.0.
- Exact command / steps: created 20 real log files at
`logs/2026-05-01/app.log` … `logs/2026-05-20/app.log` (12 real `ERROR`
lines each); ran `rg -n ERROR logs > rg_big.txt` (240 real match lines);
then called
`SearchCompressor(SearchCompressorConfig()).compress(open("rg_big.txt").read())`
on the shipped 0.26.0 build and on the patched build, comparing
`files_affected`, the rendered output, and whether each referenced path
exists on disk.
- Observed result: on shipped 0.26.0, the 20 distinct real files
collapse into **1 bogus bucket** `logs/2026` (a path that does **not**
exist on disk), per-line paths are mangled to
`logs/2026:5:01/app.log:10:`, 19 of 20 files effectively vanish from the
output, and `lines_unparsed: 0` means **nothing signals the
corruption**. On the patched build, same input and same API:
`files_affected: 20` (matches reality), every path in the compressed
output exists on disk (`all_exist=True`), and per-file match counts and
line numbers are correct.
- Not tested: the end-to-end proxy path (`headroom-proxy` against a live
LLM provider) — I exercised the `SearchCompressor` public API directly,
which is the surface `SearchOffload` and the MCP `headroom_compress`
tool wrap. I also did not test Windows path behavior on an actual
Windows host (the existing `scan_start` drive-letter logic is untouched,
and its tests still pass).

**Observed on the SHIPPED 0.26.0 build (the bug, in the released
product):**

```text
SHIPPED headroom 0.26.0 | real `rg -n ERROR logs` output, 240 lines
lines_unparsed      : 0     <-- corruption is SILENT: nothing reported as unparsed
original_match_count: 240
files_affected      : 1     <-- 20 distinct real files collapsed into ONE bucket

=== compressed output actually handed to the model ===
   logs/2026:5:01/app.log:10:ERROR failure 1 connection refused upstream timeout on 2026-05-01 ...
   logs/2026:5:20/app.log:21:ERROR failure 12 connection refused upstream timeout on 2026-05-20 ...
   logs/2026:5:01/app.log:11:ERROR failure 2 connection refused upstream timeout on 2026-05-01 ...
   [... and 235 more matches in logs/2026]
   [240 matches compressed to 5. Retrieve more: hash=39c894009014d42b856ddd8a]

=== do the file paths in that output exist on disk? ===
   logs/2026                          exists_on_disk=False
```

**Observed on the PATCHED build (same input, same API, only the patch
differs):**

```text
PATCHED headroom-core | same real `rg` output, 240 lines
lines_unparsed      : 0
original_match_count: 240
files_affected      : 20    <-- was 1 (bogus) on the shipped build

=== compressed output handed to the model ===
   logs/2026-05-01/app.log:10:ERROR failure 1 connection refused upstream timeout on 2026-05-01 ...
   logs/2026-05-01/app.log:11:ERROR failure 2 connection refused upstream timeout on 2026-05-01 ...
   [... and 7 more matches in logs/2026-05-01/app.log]
   logs/2026-05-02/app.log:10:ERROR failure 1 connection refused upstream timeout on 2026-05-02 ...

=== do the file paths in that output exist on disk? ===
   logs/2026-05-01/app.log            exists_on_disk=True
   logs/2026-05-02/app.log            exists_on_disk=True
   ...all distinct paths referenced, all_exist=True
```

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [x] New and existing unit tests pass locally with my changes

## Additional Notes

**Known residual ambiguity (stating it rather than hiding it).** grep
output is inherently ambiguous — `logs/2026-05-03/x:12:y` *could*
legitimately be a file literally named `logs/2026` with context line 5.
The tiers pick the overwhelmingly more likely reading. Two contrived
cases still parse the old way, both preserved deliberately:

1. a path containing a whitespace character;
2. a `-`-context line whose body is a whitespace-free token containing
its own `-N-` triplet.

If you'd prefer a different disambiguation policy (e.g. only trusting
`:` and treating all `-` context lines as unparseable, or gating on
filesystem existence), I'm happy to rework — the tiering is deliberately
isolated to one function so the policy is easy to swap.

N/A checklist items: no documentation or CHANGELOG change (internal
parser fix, no public API or behavior contract change); no screenshots
(no UI surface).

---------

Signed-off-by: dosthcpp <drakedog19@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:29 -04:00
Matthew Jackson
6bdc8c44a3
docs(metrics): ship an importable Grafana dashboard (#2168)
## Description

<!-- Briefly explain the change and why it is needed. -->

The metrics docs describe the `headroom_*` Prometheus metric family and
suggest example Grafana panels, but ship no importable dashboard — users
have to build one by hand. This adds a ready-to-import Grafana dashboard
built **only** on documented metric names (`headroom_requests_total`,
`headroom_tokens_saved_total`, `headroom_tokens_input_total`, and the
`headroom_overhead_ms_*` millisecond summary), and links it from the
**Grafana Dashboard** section of `docs/content/docs/metrics.mdx`.

This is a docs/examples-only addition — no source code changes.

Closes #

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] 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
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added `examples/grafana/headroom-dashboard.json` — a ready-to-import
Grafana dashboard (7 panels, uid `headroom-compression`) built entirely
on Headroom's documented `/metrics` names. Panels cover tokens saved,
input tokens, request rate, average processing overhead
(`headroom_overhead_ms_sum` / `headroom_overhead_ms_count` with
min/max), tokens-saved/sec, and request rate by pool. It uses **no
histograms** (the proxy emits none). The `pool`/`source` template
variables use regex matchers (`=~`) so they are optional and match
series without those labels.
- Updated `docs/content/docs/metrics.mdx` — linked the new dashboard
from the **Grafana Dashboard** section with import instructions, keeping
the existing ad-hoc PromQL query table alongside it.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

Docs/examples-only change, manually verified: the dashboard JSON is
well-formed and every PromQL query references only the documented
`headroom_*` metric names from `docs/content/docs/metrics.mdx`.

### Test Output

```text
$ python3 -c "import json; d=json.load(open('examples/grafana/headroom-dashboard.json')); print('valid JSON,', len(d['panels']), 'panels, uid', d['uid'])"
valid JSON, 7 panels, uid headroom-compression
```

PromQL queries used by the panels (all against documented `headroom_*`
metrics):

```text
sum(headroom_tokens_saved_total{pool=~"$pool", hook=~"$hook"})
sum(headroom_tokens_input_total{pool=~"$pool", hook=~"$hook"})
sum(rate(headroom_requests_total{pool=~"$pool", hook=~"$hook"}[$__rate_interval]))
sum(rate(headroom_overhead_ms_sum{pool=~"$pool", hook=~"$hook"}[$__rate_interval])) / clamp_min(sum(rate(headroom_overhead_ms_count{pool=~"$pool", hook=~"$hook"}[$__rate_interval])), 1)
sum(rate(headroom_tokens_saved_total{pool=~"$pool", hook=~"$hook"}[$__rate_interval])) by (pool)
max(headroom_overhead_ms_max{pool=~"$pool", hook=~"$hook"})
min(headroom_overhead_ms_min{pool=~"$pool", hook=~"$hook"})
sum(rate(headroom_requests_total{pool=~"$pool", hook=~"$hook"}[$__rate_interval])) by (pool)
```

## Real Behavior Proof

- Environment: local checkout of the PR branch; Python 3 for JSON
validation.
- Exact command / steps: ran the JSON-validation command above (see Test
Output) — parses cleanly, reports 7 panels and uid
`headroom-compression`; then read every panel target and confirmed each
PromQL query references only metric names documented in
`docs/content/docs/metrics.mdx` (`headroom_requests_total`,
`headroom_tokens_saved_total`, `headroom_tokens_input_total`,
`headroom_overhead_ms_{sum,count,min,max}`). No histogram metrics are
referenced.
- Observed result: JSON is valid and importable via Grafana's
**Dashboards → New → Import → Upload**; no datasource UID is hard-coded,
so the importer prompts for a Prometheus datasource. Queries match the
documented metric family.
- Not tested: a full live Grafana import against a running proxy
scraping real `/metrics` was not performed in CI. Verification was
limited to JSON validity and query/metric-name correctness against the
documented metrics.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Additive docs/examples only — no source code, tests, or runtime behavior
changed.

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] 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

## Screenshots (if applicable)

N/A — dashboard is imported from JSON; see the PromQL and panel list
above.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

Test-related checklist items are N/A: this is an additive docs/examples
change with no application code, so `pytest`/`mypy`/`ruff` and new unit
tests do not apply. The dashboard JSON was validated and its queries
checked against the documented metric names instead.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:25 -04:00
Connor Campbell
021a762bf8
feat(compress): expose frozen_message_count in library-mode compress() (#2178)
## Description

`read_lifecycle.apply()` already supports a frozen message prefix
(`frozen_message_count`) — stale-Read replacements inside the prefix are
skipped so compression never rewrites messages the provider's prompt
cache has anchored. But only the proxy handlers can pass it:
`ContentRouter` reads it from transform kwargs, `CompressConfig` has no
such field, and the public `compress()` never forwards it.

Library-mode callers that manage their own conversation loop (SDK
integrations, offline evaluation, sidecar scoring) therefore can't stop
transforms from rewriting already-sent history. On cached Anthropic
traffic that's expensive: every byte after the first rewritten one stops
billing as a 0.1× cache read and re-bills as a cache write (1.25× at the
5-minute TTL, 2× at the 1-hour TTL) — measured on live coding-agent
traffic, retroactive stale-Read rewrites were the dominant cache-bust
source once tool injection went session-sticky (PR-B7).

Relates to #809 (cache-bust economics discussion); does not close it.

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `CompressConfig.frozen_message_count: int = 0` — documented field;
default `0` preserves existing behavior exactly.
- `compress()` forwards it through `pipeline.apply()` to the transforms,
matching what the proxy handlers already do.
- `compress()` docstring: added to the kwargs shorthand list.
- CHANGELOG entry under Unreleased → Features.
- Four tests in `tests/test_compress_api.py` (`TestFrozenMessageCount`).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_compress_api.py tests/test_transforms/test_read_lifecycle.py \
    tests/test_compression_safety_rails.py tests/test_compress_failure.py -q
59 passed, 1 warning in 3.05s

$ uv run ruff check headroom/compress.py tests/test_compress_api.py
All checks passed!

$ uv run mypy headroom
Success: no issues found in 471 source files
```

## Real Behavior Proof

- Environment: Linux, Python 3.12.3, this branch installed via `uv sync
--extra dev`
- Exact command / steps: build an Anthropic-format conversation with a
stale Read (file read at message 2, edited at message 3), then:

  ```python
  r0 = compress(msgs, model="claude-sonnet-4-5-20250929")
r5 = compress(msgs, model="claude-sonnet-4-5-20250929",
frozen_message_count=5)
  ```

- Observed result: without frozen prefix the stale Read is rewritten;
with frozen_message_count=5 the Read remains byte-identical.

  ```text
  without frozen prefix: stale Read rewritten: True
    transforms: ['read_lifecycle:stale:/app/config.py']
  with frozen_message_count=5: Read byte-identical: True
    transforms: []
  ```

- Not tested: proxy-mode code paths (untouched — they already pass
`frozen_message_count` their own way); Rust crates (untouched).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — library API change, no UI.

## Additional Notes

Default `0` makes this a strict superset of current behavior — no caller
sees any change without opting in. The motivation data comes from a
proxy-side measurement tool that prices compression's cache effects on
live Anthropic agent traffic (per-request cache-adjusted dollars); happy
to share methodology in #809 if useful.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:21 -04:00
Noam Asor
5dbe3314a1
fix(auth): support GitHub Enterprise Copilot OAuth domain (#2192)
## Description

Adds GitHub Enterprise OAuth domain support for Copilot auth. When
`GITHUB_COPILOT_ENTERPRISE_URL` is set, the default OAuth domain
resolves to that enterprise host; explicit `--domain` values still take
precedence.

Closes #1152

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] 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
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/copilot_auth.py`: derive the default OAuth domain from
`GITHUB_COPILOT_ENTERPRISE_URL` when present, falling back to
`github.com` for unset or blank values.
- `headroom/cli/copilot_auth.py`: keep explicit CLI domain overrides
authoritative even when the enterprise env var is set.
- `README.md`: document the enterprise OAuth environment setting and
precedence.
- Added regression tests for enterprise URL handling, blank/unset
fallback, and explicit CLI override precedence.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py -q
69 passed in 1.05s

uvx ruff@0.15.17 check headroom/cli/copilot_auth.py headroom/copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_copilot_auth.py
All checks passed!

uvx ruff@0.15.17 format --check headroom/cli/copilot_auth.py headroom/copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_copilot_auth.py
4 files already formatted
```

## Real Behavior Proof

- Environment: Windows 11 development checkout, Python 3.13.3, with
focused Copilot auth tests using monkeypatched enterprise env vars.
- Exact command / steps: ran the Copilot auth unit/CLI tests plus ruff
check and format-check against the touched auth files and tests.
- Observed result:
`GITHUB_COPILOT_ENTERPRISE_URL=https://ghe.example.com` resolves
`default_oauth_domain()` to `ghe.example.com`; unset/blank enterprise
env vars fall back to `github.com`; and `headroom copilot-auth login
--domain github.com` still honors the explicit override when enterprise
env vars are set.
- Not tested: live OAuth against a real GitHub Enterprise Server
instance.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [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 updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A - CLI/auth behavior and README update.

## Additional Notes

`GITHUB_COPILOT_ENTERPRISE_URL` takes precedence over
`GITHUB_COPILOT_ENTERPRISE_DOMAIN`; explicit `--domain` remains
authoritative for the login command.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:17 -04:00
Waldo Zins
a61f534426
fix(ccr): store pre-protection original, not tag placeholder, in CCR (#1208)
## Description

When `ContentRouter` protects custom tags (e.g. `<system-reminder>`)
into `{{HEADROOM_TAG_N}}` placeholders before invoking Kompress, CCR can
persist the protected **placeholder intermediate** as the entry's
`original_content` instead of the pre-protection source text. A later
**full retrieve** (or proactive expansion / model-initiated retrieve) of
such an entry then returns `{{HEADROOM_TAG_0}}` and the real protected
block is lost from the retrieval path. The immediate upstream request is
unaffected — `restore_tags` correctly restores the compressed output
before it goes upstream; the confirmed corruption is in CCR storage and
only surfaces on later retrieval/expansion.

This threads the pre-protection `content` through as `ccr_original` so
CCR stores the real source text while the model still sees the
placeholdered text.

Closes #1209

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/transforms/content_router.py`: `_try_ml_compressor` passes
`ccr_original=content` to `compressor.compress(...)` **only when tags
were actually protected** (untagged callers keep the historic call shape
— backward compatible).
- `headroom/transforms/kompress_compressor.py`: `compress()` gains a
`ccr_original` kwarg; `compress_batch()` gains a per-item
`ccr_originals` list (validated against `len(contents)`).
- All four CCR store sites store `ccr_original` when present, else
`content`: inline `compress()`, single-content
`compress()`→`compress_batch` delegation, `compress_batch` sequential
fallback, and `compress_batch` batched/GPU path. The stored original's
token count is recomputed from the stored text.
- `tests/test_ccr_tag_placeholder_regression.py` (new, 5 tests): router
boundary forwarding, untagged backward-compat, `ccr_originals` length
validation, and two store-site tests driving the real `compress()` /
batched `compress_batch()` all the way to `_store_in_ccr` (a tiny fake
model stands in for the 274MB ModernBERT).

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ .venv/bin/python -m pytest tests/test_ccr_tag_placeholder_regression.py -q
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/.../headroom.worktrees/ccr-tag-placeholder
configfile: pyproject.toml
plugins: anyio-4.14.0
collected 5 items

tests/test_ccr_tag_placeholder_regression.py .....                       [100%]

========================= 5 passed, 1 warning in 0.15s =========================
```

Fail-before / pass-after was confirmed against a freshly built Rust
`_core`: with the fix reverted the new tests fail (router forwards no
`ccr_original` → `None`/placeholder reaches the store; `compress_batch`
rejects the unknown `ccr_originals` kwarg with `TypeError`); with the
fix applied all 5 pass. The surrounding kompress/ccr/router suites stay
green (8 unrelated failures are pre-existing — identical with the patch
stashed — from missing optional test deps such as `pytest-asyncio`, not
caused by this change).

## Real Behavior Proof

- Environment: macOS (darwin), Python 3.12.12, locally built Rust
`_core` via `maturin develop`, pytest 9.1.1.
- Exact command / steps: `maturin develop` to build `_core`, then
`python -m pytest tests/test_ccr_tag_placeholder_regression.py -q`.
- Observed result: 5 passed with the fix applied; the same suite fails
before the fix (placeholder/`None` reaches `_store_in_ccr`;
`compress_batch` rejects `ccr_originals`).
- Not tested: end-to-end live proxy full-retrieve against a 274MB
ModernBERT model (tests use a fake model to keep them deterministic and
offline); `ruff`/`mypy` not run locally.

> Note: this fixes new CCR writes. Pre-existing entries written before
the fix keep their placeholder `original_content` until they expire.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## 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
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Docs/CHANGELOG unchanged: this is an internal CCR correctness fix with
no public API or user-facing behavior change beyond correct
full-retrieve content. `ruff`/`mypy` were not run in the local build
environment.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:08 -04:00