Commit graph

929 commits

Author SHA1 Message Date
chopratejas
7fd5b2196c ci: use maturin build + pip install instead of maturin develop
`maturin develop` requires a virtualenv (it errors with "Couldn't find a
virtualenv or conda environment"). CI's setup-python provides a bare
system Python without a venv, so the dev script's build path doesn't
work there.

This switches CI to build a release wheel via `maturin build` and
install it with `pip install --force-reinstall --no-deps`. Then symlink
the installed `.so` into the in-tree `headroom/` package so the
editable install resolves `import headroom._core` past the source-dir
shadowing of site-packages.

`scripts/build_rust_extension.sh` is unchanged — it stays optimized for
local dev (where there IS a venv).
2026-04-26 10:04:06 -07:00
chopratejas
7c3516aad5 ci: build rust extension via maturin before pytest
The python `DiffCompressor` was retired in this PR's stage-3b commit;
the public class now delegates to `headroom._core` (built from
`crates/headroom-py`). Without the wheel installed in CI, every test
that constructs a `DiffCompressor` fails with `ModuleNotFoundError:
No module named 'headroom._core'`.

This adds a build step to the main `test` job in `ci.yml` that:
1. Installs the stable Rust toolchain (`dtolnay/rust-toolchain@stable`).
2. Caches the cargo registry and build output (`Swatinem/rust-cache@v2`).
3. Installs maturin.
4. Runs `scripts/build_rust_extension.sh`, which calls `maturin develop`
   and symlinks the built `.so` into the in-tree `headroom/` package
   so the editable install resolves `import headroom._core`.

Only the main `test` job needs this — `test-extras` and `test-agno`
run narrow subsets that don't construct `DiffCompressor`. The existing
`rust.yml` workflow continues to handle wheel builds for distribution
and `cargo test` for the Rust workspace.
2026-04-26 09:58:42 -07:00
chopratejas
d5ca50cd03 fix(tests): stop module-level dotenv loaders from polluting os.environ during pytest collection
# The bug

Several test modules and two production modules loaded the project `.env`
at *import time*. During pytest collection (where every test module is
imported once), this populated `os.environ` with API keys from `.env`.

The skipif guards in `test_proxy_passthrough_integration.py` (and
others) evaluate at collection time:

    @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="...")

If the polluter module was collected *before* the guard, the guard saw
the leaked key, decided not to skip, and the integration tests ran
live against a fake key and failed. In a fresh local-dev venv with
`.env` + full `[dev]` extras, this manifested as ~16 spurious test
failures plus a misleading test runtime of 6+ minutes (live HTTP).

# Why now

CI does not see this (no `.env`). It only manifests when:
1. `litellm` (and friends) are installed — they run `dotenv.load_dotenv()`
   on import, populating `os.environ` from `.env`.
2. A `.env` file with real API keys exists locally.

Until the venv was provisioned with the full `[dev]` extras during
recent test work, `pytest.importorskip("litellm")` and
`from headroom.pricing import litellm_pricing` both silently no-op'd
(via try/except ImportError → `LITELLM_AVAILABLE=False`), so the leak
never triggered. With litellm now installed, the latent bug surfaced.

# The fix — three patterns

1. **Production modules** (`headroom/pricing/litellm_pricing.py`,
   `headroom/backends/litellm.py`): wrap the eager `import litellm` with
   a snapshot/restore of `os.environ`. Any keys litellm's bundled
   `python-dotenv` adds during import are deleted immediately. The
   module is fully imported and cached in `sys.modules` so subsequent
   imports hit the cache without re-running the side effect.

2. **Test modules using `pytest.importorskip("litellm")`**
   (`test_backend_bugs.py`, `test_bedrock_region.py`,
   `test_cost_tracker_counterfactual.py`): replace with
   `tests._dotenv.importorskip_no_env_leak("litellm")`, which does the
   same snapshot/restore around `importlib.import_module`.

3. **Test modules that intentionally need `.env` values for skipif
   guards** (`test_compression_summary_*.py`, `test_query_echo.py`,
   `test_cost_tracker_counterfactual.py`, `test_memory_usage_integration.py`,
   `test_bundled_tools_savings.py`): replace module-level
   `os.environ.setdefault(...)` / `dotenv.load_dotenv()` with
   `tests._dotenv.load_env_overrides()` (returns a local dict — does
   NOT mutate `os.environ`) plus `autouse_apply_env(...)` (function-
   scoped fixture that applies via `monkeypatch.setenv`, auto-cleaned
   at teardown). The skipif still works because
   `ANTHROPIC_KEY = os.environ.get(...) or _env_overrides.get(...)`
   reads from the local dict as fallback.

# Helper module

New `tests/_dotenv.py` exposes:
- `load_env_overrides() -> dict[str, str]` — read `.env` into a dict.
- `autouse_apply_env(overrides) -> fixture` — function-scoped autouse
  fixture that applies via `monkeypatch.setenv`.
- `importorskip_no_env_leak(module) -> module` — drop-in
  `pytest.importorskip` substitute that quarantines env mutations.

# Results

Local full-suite (excluding live-LLM and live-feed tests):
- Before: 46 failed, 4830 passed, 387s
- After:   2 failed, 4672 passed, 134s

The remaining 2 failures are unrelated environment-dependent tests
(missing `PIL` / Docker daemon).
2026-04-26 09:15:37 -07:00
chopratejas
f5f465418b feat(rust): retire python diff_compressor, ship rust-only via pyo3
The python `DiffCompressor` is replaced by a thin pyo3-backed shim that
delegates to `headroom._core.DiffCompressor`. There is no python
implementation and no env-var fallback — the wheel is a hard import.

Why now: opt-in defaults don't drive python retirement. Byte-equal
parity was already proven across 27 fixtures (stage 3a); keeping a
shadow python impl behind a flag is a permanent maintenance cost with
no operational benefit. Stage 3b deletes ~700 lines of python parser /
scorer / formatter code; the rust crate has its own coverage.

Surface preserved:
- `headroom.transforms.diff_compressor.DiffCompressor` — same class
  name, same `__init__`, same `compress(content, context)` shape.
  Returns python `DiffCompressionResult` dataclasses so call sites that
  destructure with `asdict()` work unchanged.
- `DiffCompressorConfig` and `DiffCompressionResult` dataclasses kept.
- Sidecar `compress_with_stats(...)` exposes the rust-only
  `DiffCompressorStats` (per-file hunk drops, context lines trimmed,
  file_mode normalizations) for observability.

Removed:
- Python parser / scorer / formatter (~700 lines).
- Internal `DiffHunk` / `DiffFile` parser dataclasses (rust crate has
  parallel coverage).
- 12 tests that probed `_parse_diff` / `_score_hunks` or the deleted
  parser dataclasses. The 29 public-API tests in `test_diff_compressor.py`
  remain and now exercise the rust backend through the same import path.
- `HEADROOM_DIFF_COMPRESSOR_BACKEND` env var — no longer meaningful.

Build:
- `scripts/build_rust_extension.sh` runs `maturin develop` and symlinks
  the built `.so` into `headroom/` so `import headroom._core` resolves
  past the in-tree package shadowing the maturin overlay.
- `.gitignore` excludes the symlinks and allowlists the build script.

Tests:
- 544 transforms pass; 33 rust-vs-fixture parity tests guard the pyo3
  bridge against regressions (`tests/test_transforms/test_diff_compressor_rust_parity.py`).
- Mypy clean.
2026-04-26 09:15:37 -07:00
chopratejas
48c13245c5 fix(diff): close ContentRouter routing gaps for merge diffs and long preambles
User audit caught three gaps that prevented DiffCompressor from being
invoked even when the input was a real diff. These complement the four
emit-time bugs fixed in the previous commit — those fixes only kick in
once DiffCompressor receives the input. Without these gap fixes, real
merge-commit diffs and `git log -p` outputs with long commit messages
were misrouted away from DiffCompressor entirely.

# The three gaps (each fixed in Python; gap 3 also fixed in Rust)

1. Detector scan window was hardcoded to first 50 lines.
   `_try_detect_diff` in content_detector.py only inspected
   `content.split("\n")[:50]`. `git log -p` outputs commonly have
   commit messages longer than 50 lines (releases, squashed commits,
   bots), pushing the `diff --git` header out of the detection window.
   Result: input was returned with `content_type=PLAIN_TEXT` and routed
   to the text compressor, never reaching DiffCompressor. Fix: window
   widened to 500 lines.

2. Detector regex didn't recognize merge-commit headers.
   `_DIFF_HEADER_PATTERN` matched `diff --git`, `--- a/`, and the
   regular `@@ -A,B +C,D @@` hunk header. Merge-commit diffs from
   `git log -p` use `diff --combined <path>`, `diff --cc <path>`, and
   combined-diff hunk headers `@@@+`. The shared `--- a/` line still
   triggered the detector with low confidence, but only barely. Fix:
   extended the regex to recognize all four merge-shaped header forms.

3. DiffCompressor parser only matched `^diff --git`.
   Even after fixing detection, the parser's `_DIFF_GIT_PATTERN`
   wouldn't match `diff --combined` or `diff --cc`, so merge diffs
   reached DiffCompressor and were treated as one giant pre-diff blob —
   passed through unchanged after the previous PR's pre-diff
   preservation fix. Fix: added `_DIFF_COMBINED_PATTERN` and
   `_DIFF_CC_PATTERN`; `_parse_diff` starts a new file section on any
   of the three header forms. Mirrored in Rust as `is_diff_header`
   helper that checks all three regexes.

# Why this matters end-to-end

DiffCompressor's value comes from being routed to. Detection +
parser-level coverage are upstream of the compressor — without them,
the compressor never sees the input. The previous PR's four bug fixes
(rename, combined-diff hunks, no-newline marker, pre-diff content) are
correct and necessary, but for merge commits and long-preamble diffs,
they were only firing on the rare cases where the detector misclicked
into DiffCompressor anyway. With these three gaps closed, the
ContentRouter→DiffCompressor pipeline actually engages on:
- `git log -p` outputs of any commit-message length
- Merge-commit diffs (`diff --combined`, `diff --cc`)
- Combined-diff snippets (`@@@`+ hunk-only inputs)

# New fixtures (3 added to the existing 24)

- `066bc82…` — `diff --combined` merge diff (3-way)
- `5d950a94…` — `diff --cc` merge diff (alternate form)
- `66c86f64…` — long pre-diff content (60-line commit message)
  followed by a rename diff (exercises detector scan widening +
  pre-diff preservation in tandem)

Parity: total=27 matched=27 skipped=0 diffed=0.

# Tests

- Python: 4 new tests across 2 new test classes —
  `TestRoutingGapMergeDiffs` (combined / cc parser) and
  `TestRoutingGapDetectorScanWindow` (long preamble detection +
  combined-diff regex recognition).
- Rust: 2 new tests covering combined / cc parser sections.

# Verification

- 27/27 parity fixtures byte-equal.
- Python: 41/41 tests pass (was 37).
- Rust: 18/18 transforms tests; 62/62 workspace; 5/5 proptests.
- `cargo fmt --check` clean; `cargo clippy --workspace --all-targets
  -- -D warnings` clean.
2026-04-26 09:13:08 -07:00
chopratejas
6d47a0cd00 fix(diff_compressor): four silent information-loss paths in Python AND Rust
Audit caught four bugs that the byte-equal parity harness can't catch on
its own — both Python and Rust were faithfully emitting the buggy output.
Fixed in lockstep so parity is maintained while the underlying behavior
is now correct on inputs the existing 20 fixtures didn't exercise.

# The four bugs (each fixed in both Python and Rust)

1. Renames silently dropped from output. Parser captured `is_renamed=True`
   but the emitter never emitted ANY rename markers. Output of a rename
   looked exactly like a plain modification of the old path. Fix: capture
   `rename from` / `rename to` / `similarity index N%` / `dissimilarity
   index N%` / `copy from` / `copy to` lines in a new `rename_lines` field
   on `DiffFile`; emit them after `diff --git` in canonical git ordering.

2. Combined diff hunks (`@@@`) silently dropped. Hunk-header regex only
   matched `@@`, so 3-way merge hunks had `current_hunk` never set and
   ALL their content fell through to the no-op branch. Fix in Python:
   regex switched to `^(@@+) ... \1` (backreferences match any number of
   `@`s on each side). Fix in Rust: alternation over `@@`, `@@@`, `@@@@`
   since `regex` is RE2-based and rejects backreferences. n>3 octopus
   merges still fall through; rare in practice.

3. `\ No newline at end of file` markers can be context-trimmed away.
   Treated as ordinary "other" lines — if more than `max_context_lines`
   from a `+`/`-` change, dropped. Round-trip-breaking for patches; can
   change whether the trailing line has a newline. Fix: in
   `_reduce_context`, force-add any line starting with `\` to the keep
   set regardless of distance.

4. Pre-diff content silently dropped. Anything before the first `diff
   --git` — commit messages from `git log -p`, email headers from `git
   format-patch`, fork-and-rebase metadata — was discarded. Fix:
   `_parse_diff` now returns `(pre_diff_lines, files)`; `format_output`
   prepends pre-diff content verbatim when present.

# Hidden parity bug found during the work

`_compress_files` constructed a fresh `DiffFile` from the parsed one but
only copied a subset of the fields by name. The new `rename_lines` and
`original_*_line` fields were silently dropped here, so the parser
populated them correctly but the emitter saw an empty `rename_lines`
list. Caught by writing a real test instead of a smoke test — the smoke
test passed because it hit the no-diff-found short-circuit, not the
parser/emitter pipeline. Constructor now copies all fields explicitly.

# Parity status

- Existing 20 fixtures: still byte-equal between fixed Python and fixed
  Rust. None of them exercised the buggy paths.
- 4 NEW fixtures recorded against fixed Python, exercising each bug-fix
  path: rename, 3-way combined diff, `\ No newline` marker far from
  changes, pre-diff commit headers. All 4 byte-equal between Python and
  Rust.
- Parity harness: total=24 matched=24 skipped=0 diffed=0.

# Observability

Some normalizations remain parity-bound (file mode `100644` hardcode,
`Binary files differ` simplification). Those are surfaced in
`DiffCompressorStats::file_mode_normalizations` /
`binary_files_simplified` (Rust) and via `logger.warning` (Python's new
`_log_loss_signals` helper, called once per compress).

# Tests

- Python: 4 new test classes (11 tests) covering rename markers,
  combined diffs, no-newline preservation, pre-diff content. Edge case:
  no pre-diff content must NOT add a leading blank line.
- Rust: 4 new `bugfix_*` unit tests with the same scenarios.
- Existing Python tests calling `_parse_diff` directly were updated for
  the new `(pre_diff, files)` tuple return.

# Verification

- Python: 37/37 tests pass (was 26).
- Rust: 16/16 transforms tests; 60/60 workspace unit tests; 5/5
  proptests; 1/1 doctest.
- Parity: 24/24 byte-equal.
- `cargo fmt --check` clean; `cargo clippy --workspace --all-targets
  -- -D warnings` clean.
2026-04-26 09:13:08 -07:00
chopratejas
255b4295dc refactor(rust): diff_compressor — surface hidden cutoffs + lossy-emit stats
Audit follow-up to the diff_compressor port. The parity-bound port had
several issues that the byte-equal harness can't catch: hardcoded magic
numbers, score-weight literals scattered through the scorer, and silent
information-loss paths (file mode + binary detail) with no observability
trail. Parity is preserved (still 20/20 byte-equal); these are quality
and observability fixes only.

# Changes

1. **Hardcoded `0.8` savings threshold → config knob.** New
   `DiffCompressorConfig::min_compression_ratio_for_ccr: f64` (default
   0.8) replaces the literal. Rust-only — Python hardcodes 0.8 too;
   fixtures don't carry the field, parity comparator defaults to 0.8.

2. **Score-weight magic numbers → documented `pub const`.** The scorer's
   `0.03`, `0.3`, `0.2`, `0.3`, `1.0`, and `len > 2` filter are now
   `SCORE_CHANGE_DENSITY_WEIGHT`, `SCORE_CHANGE_DENSITY_CAP`,
   `SCORE_CONTEXT_WORD_WEIGHT`, `SCORE_CONTEXT_MIN_WORD_LEN`,
   `SCORE_PRIORITY_PATTERN_BOOST`, `SCORE_TOTAL_CAP`. Each carries a doc
   comment explaining what it biases toward. A pinning test fails if
   anyone changes the value without updating the test.

3. **File mode normalization is now visible.** Parity forces emit to
   hardcode `100644` regardless of input mode (`100755` executable,
   `120000` symlink, `160000` submodule). The parser now captures the
   ORIGINAL mode line; if it's not `100644`, the loss is recorded in
   `DiffCompressorStats::file_mode_normalizations: Vec<(label,
   original_mode_line)>`. Empty when no normalization happened.

4. **Binary file detail loss is now visible.** Same pattern: parity
   forces `Binary files differ` on emit, dropping the original `Binary
   files X and Y differ` filenames. Captured in
   `DiffCompressorStats::binary_files_simplified: Vec<String>` whenever
   the original line carried richer detail.

5. **`min_lines_for_ccr` doc clarified.** Despite the name, it gates the
   ENTIRE compression path, not just the CCR marker. A 49-line diff is
   returned unchanged regardless of how compressible it is. Doc comment
   now flags this misnomer prominently.

6. **6 new unit tests cover the lossy paths.** Previously the test
   suite only exercised pass-through and the in-cap synthetic. New
   tests:
     - `max_hunks_per_file_cap_drops_excess_and_records_stats` (15
       hunks, cap 10, verify 5 dropped + per-file accounting)
     - `max_files_cap_drops_files_and_records_names_in_stats` (25
       files, cap 20, verify 5 dropped names recorded)
     - `file_mode_normalization_is_recorded_for_executable_bit` (input
       `new file mode 100755`, verify stats capture original)
     - `binary_files_simplification_is_recorded` (input `Binary files
       a/x and b/x differ`, verify original captured)
     - `min_compression_ratio_for_ccr_is_configurable` (default 0.8 vs
       custom 0.5; same input, different CCR emit decision)
     - `score_constants_match_inline_values` (pins constants so future
       changes are intentional)

Stats fields are also reflected in the existing `tracing::info!` event
so OTel scrapers see the loss counts in production.

# Why these matter — the user-stated principle

"Information preservation > aggressive compression." When parity forces
us to drop bytes (file mode, binary detail, fully-dropped files,
trimmed context), the loss must be observable. Adding it to the
compressed output bytes would break parity; surfacing it in
`DiffCompressorStats` + tracing spans is the right escape hatch.

# Verification

- 20/20 parity fixtures still byte-equal
- 12/12 unit tests pass (6 new + 6 prior)
- 56 total tests in headroom-core, all green
- `cargo fmt --check` clean
- `cargo clippy --workspace --all-targets -- -D warnings` clean
2026-04-26 09:13:08 -07:00
chopratejas
5c3c9c49f2 feat(rust): diff_compressor port — byte-equal parity + sidecar stats
Stage 3a: first real transform port. Faithful Rust port of
`headroom.transforms.diff_compressor` with byte-equal parity against all
20 recorded fixtures.

# Algorithm (matching Python)

1. Hand-rolled unified-diff parser (state machine over `diff --git`,
   `index`, `--- a/`, `+++ b/`, `@@`, mode/binary/rename markers, +/- /
   space lines, "other" lines like `\ No newline at end of file`).
2. File cap (`max_files=20`): when fired, sort by total changes (most
   first) and keep top N.
3. Per-file hunk cap (`max_hunks_per_file=10`): keep first + last + top
   relevance-scored middle, then resort by hunk-header start line to
   restore appearance order.
4. Relevance scoring: change-density base + user-query word overlap
   + priority patterns (ERROR / IMPORTANCE / SECURITY regexes —
   matches `error_detection.PRIORITY_PATTERNS_DIFF`).
5. Per-hunk context trim: keep `max_context_lines=2` lines either side
   of each `+`/`-` line.
6. CCR cache_key: `md5(original)[:24]` (matches
   `compression_store.CompressionStore.store`). Emitted only when
   compression saved >20% of lines.

Parity result: `[diff_compressor ] total=20 matched=20 skipped=0 diffed=0`.

# Information preservation hardening

Three pass-through paths inherited from Python that we keep deliberate
(would lose info if we changed them):
- Below `min_lines_for_ccr` (50): return input unchanged.
- No diff sections parsed: return input unchanged.
- Below 20% compression savings: emit compressed output but no CCR
  marker (the original is the cheaper representation anyway).

Plus a parity-bound subtlety: `compressed_line_count` is captured BEFORE
the CCR retrieval marker is appended, both for the marker text
(`compressed to N`) and the result field. The output string therefore
ends up with one more line than the field reports — by design, matching
Python exactly. An off-by-one bug from recounting after appending the
CCR marker was caught and pinned by a synthetic 8-file diff test.

# Observability — the Rust escape hatch

Python's `DiffCompressionResult` has thin observability: input/output
line counts, additions/deletions, hunks_kept/removed, files_affected,
cache_key. The Rust port adds a sidecar `DiffCompressorStats` struct
with metrics Python doesn't emit:

- `files_dropped: Vec<String>` — names (old → new path) of files
  silently discarded by the `max_files` cap. Python loses these.
- `hunks_dropped_per_file: BTreeMap<String, usize>` — per-file hunk
  drops, stable iteration via `BTreeMap`.
- `context_lines_input` / `context_lines_kept` / `context_lines_trimmed`
  — directly proxies info loss from the context trim.
- `largest_hunk_kept_lines` / `largest_hunk_dropped_lines` — outlier
  detection (a single huge dropped hunk is much worse than many small).
- `parse_warnings: Vec<String>` — surfaces malformed input rather than
  dropping silently.
- `processing_duration_us` — latency budget.
- `cache_key_emitted` + `ccr_skipped_reason: Option<String>` — explicit
  signal for "we chose not to emit CCR and this is why".

A `tracing::info!(target: "diff_compressor", ...)` event is emitted on
every call, carrying these fields for OTel scraping in prod. The
sidecar struct is returned alongside via `compress_with_stats`; the
parity-only `compress` API discards it.

# Module layout

- `crates/headroom-core/src/transforms/mod.rs` — namespace, doc comment
  with the guiding principle ("information preservation > aggressive
  compression") so future ports inherit the philosophy.
- `crates/headroom-core/src/transforms/diff_compressor.rs` — full port
  (parser, scorer, hunk selector, context trimmer, formatter, CCR layer,
  stats, tracing).

# Dependencies added to headroom-core

- `md-5 = "0.10"` — for the CCR cache_key (matches Python MD5[:24]).
- `regex = "1"` — was a transitive dep via tokenizers; now a direct
  dependency for the hunk-header parser and priority patterns.

# Tests

6 unit tests covering pass-through paths, MD5 hex truncation, the
Python `split("\n")` line-count semantics, sidecar stats emission,
and a synthetic 8-file diff that locks the byte-equal behavior found
in the parity fixtures.
2026-04-26 09:13:08 -07:00
Tejas Chopra
ab7b1914e3
Merge pull request #275 from chopratejas/rust-stage-2-tokenizer
Rust stage 2 tokenizer
2026-04-25 21:47:27 -07:00
Tejas Chopra
c275567d94
Merge pull request #272 from chopratejas/rust-stage-2.1-hf-hub
rust(stage 2.1): HfTokenizer::from_pretrained via hf-hub
2026-04-25 16:15:24 -07:00
Tejas Chopra
342a6a47ed
Merge pull request #271 from chopratejas/rust-stage-2-tokenizer
Rust stage 2 tokenizer
2026-04-25 15:18:19 -07:00
chopratejas
a23ee8e70b feat(rust): HfTokenizer::from_pretrained — HuggingFace Hub auto-download
Stage 2.1: closes the loop on the HuggingFace tokenizer story. Stage 2
shipped `HfTokenizer::from_bytes`/`from_file`, which required callers to
manage their own tokenizer.json files. This adds the third constructor:

    let t = HfTokenizer::from_pretrained("CohereForAI/c4ai-command-r-v01")?;
    register_hf("command-", t);

`from_pretrained` is a thin wrapper around the `hf-hub` crate's blocking
`ureq` API. First call downloads `tokenizer.json` to `~/.cache/huggingface/
hub` (or `$HF_HOME` if set); subsequent calls reuse the on-disk cache. Uses
the `main` revision; gated repos (Llama, Mistral) require `HF_TOKEN` in env
or `~/.cache/huggingface/token`.

Also adds `try_register_hf(prefix, repo)` as the obvious one-liner for
proxy startup code:

    let _ = try_register_hf("command-", "CohereForAI/c4ai-command-r-v01");
    let _ = try_register_hf("mistral-", "mistralai/Mistral-7B-v0.1");

Each call is independent — a download failure for one model (e.g. gated
without a token) does not affect others.

`HfTokenizerError` gains a new `Hub` variant so callers can distinguish
"couldn't fetch" from "fetched but malformed" — relevant when deciding
whether to retry, surface to the user, or fall back to the estimator.

Why blocking, not async: `from_pretrained` is called once at startup. A
sync API works from `main()`, from a `OnceLock` initializer, or from
`tokio::task::spawn_blocking` if a tokio caller needs it later. The async
hf-hub backend would force callers to await at startup, which doesn't fit
the `register_hf` registry pattern.

Why rustls, not native-tls: keeps the binary statically linkable for AWS
deploys (no system OpenSSL dependency).

Tests: a network-dependent integration test (`#[ignore]`d in CI; hits HF
for `gpt2`, ~1.4 MB) verifies the real download + load + count path. A
non-network negative test verifies that an invalid repo name surfaces as
`HfTokenizerError::Hub`, not a panic. 44 unit tests + 5 proptests +
1 doctest pass; parity stays 40/40 byte-equal.
2026-04-25 15:05:09 -07:00
chopratejas
3957288229 test(memory): add qdrant_url/qdrant_api_key to expected config dict
The `qdrant-env-vars` change in d3c37d7 (PR #266) added `qdrant_url` and
`qdrant_api_key` keys to the kwargs that `MemoryHandler` passes into
`DirectMem0Adapter.__init__`. The corresponding assertion in
`test_ensure_initialized_fast_paths_and_qdrant_variants` was missed in that
PR and has been failing on `main` ever since. Surfacing here because it
fails on every PR's CI; not caused by the Rust tokenizer work this branch
adds.

The two new keys are both `None` when the corresponding `HEADROOM_QDRANT_*`
env vars are unset, which is the case in this test.
2026-04-25 14:56:55 -07:00
chopratejas
cb80bf69fe chore: sync plugin versions to 0.11.0 2026-04-25 14:55:51 -07:00
chopratejas
9ce1c01b87 feat(rust): tokenizer crate with tiktoken-rs + HuggingFace + estimator
Stage 2 of the Rust port: a `headroom_core::tokenizer` module mirroring the
Python `headroom.tokenizers` surface, with three backends behind a single
`Tokenizer` trait.

Backends, in dispatch order:

1. HuggingFace (`HfTokenizer`) — pure-Rust `tokenizers` crate loading any
   public `tokenizer.json`. Covers the gap between OpenAI (tiktoken) and the
   Anthropic/Gemini estimator: Cohere `command-*`, Llama-3.x, Mistral, Qwen,
   BERT, T5, etc. Construct from bytes or a file path; register against a
   model-name prefix via `register_hf` for automatic dispatch. No `hf-hub`
   auto-download yet — keeps networking, auth, and `~/.cache/huggingface` out
   of core. Longest-prefix wins; lookups are RwLock-protected.
2. Tiktoken (`TiktokenCounter`) — `tiktoken-rs` 0.11 BPE for OpenAI / o-series
   families. Byte-identical to Python `tiktoken` for ordinary text. Lazy
   shared `Arc<CoreBPE>` per encoding (o200k_base, cl100k_base, p50k_base,
   r50k_base).
3. Estimation (`EstimatingCounter`) — `chars / cpt` last-resort fallback.
   Matches Python's `max(1, int(len(text) / cpt + 0.5))` round-half-up
   formula (a self-review caught and fixed an earlier `ceil`-based version
   that diverged in the middle of the range, e.g. 5 chars at 4.0 cpt).

Tests: 43 unit tests + 5 proptests; parity 40/40 byte-equal.
Bench: criterion baseline on small/medium/large inputs.
Workspace MSRV bumped 1.78 → 1.80 for `LazyLock`/`OnceLock`.

No proxy wiring. Library-only; production behavior unchanged.
2026-04-25 14:22:09 -07:00
chopratejas
a22a7277da chore: sync plugin versions to 0.10.13 2026-04-25 14:21:48 -07:00
Tejas Chopra
7c1cff226f
Merge pull request #266 from ipapapa/fix/31-qdrant-env-vars
fix(memory): resolve Qdrant connection from HEADROOM_QDRANT_* env vars (#31)
2026-04-25 13:33:48 -07:00
Tejas Chopra
b2527a03b8
Merge pull request #269 from pratikbin/main
ci(docker): clean up image tags, signatures, and Latest indicator
2026-04-25 13:30:37 -07:00
Tejas Chopra
68c506d591
Merge pull request #268 from chopratejas/rust-rewrite
phase-0: rust workspace scaffolding + parity harness
2026-04-25 13:30:19 -07:00
chopratejas
3447dd6378 ci: replace deprecated macos-13 runner with macos-15-intel
GitHub Actions deprecated the macos-13 runner label. The validate-workflows
actionlint step in CI fails because macos-13 is no longer in the available
labels list. macos-15-intel is the current x86_64 macOS runner.

(Bumped from macos-14 to macos-15 for arm64 was unnecessary; macos-14 is
still valid and we keep it for cache-warmth.)
2026-04-25 13:06:12 -07:00
chopratejas
4429a11166 Merge remote-tracking branch 'origin/main' into rust-rewrite
# Conflicts:
#	headroom/proxy/server.py
2026-04-25 13:01:37 -07:00
chopratejas
0144cfba51 ci: fix cargo fmt + maturin action invocation
cargo fmt --check failed in CI: import order in proxy.rs (cfg(test)
attributes before/after non-attr imports) and a few line-wrapping
nits in e2e_real.rs. Ran cargo fmt --all to fix.

maturin-action@v1 does not have a 'manifest-path' input — the action
warned 'Unexpected input(s) manifest-path' and proceeded to invoke
maturin from the repo root, which sees the workspace Cargo.toml with
no [package] section and bails. Move -m crates/headroom-py/Cargo.toml
back inside the 'args' string.
2026-04-25 12:52:55 -07:00
chopratejas
c2749c0fb6 docs(rust): lockfile + RUST_DEV.md for proxy CLI
Cargo.lock: pick up tokio-util added in the WS half-close fix.
RUST_DEV.md: document how to run headroom-proxy in passthrough mode
(listen + upstream flags, e2e test gate, env vars).
2026-04-25 12:49:36 -07:00
chopratejas
f28f697310 fix(proxy): make third-party extensions opt-in
Previously, any package registered under the headroom.proxy_extension
entry-point group auto-loaded at proxy startup. A user pip-installing a
plugin (or pulling one in transitively) would get its middleware running
in front of all their LLM traffic with zero opt-in or visibility — the
same mechanism that masked the Shield Enterprise streaming bug.

Change: install_all() now takes an explicit enabled set (or reads
HEADROOM_PROXY_EXTENSIONS). Discovery still runs to enumerate what's
available, but only names the operator opted into actually install.
The literal '*' is a wildcard for trusted environments.

  CLI:  headroom proxy --proxy-extension shield_enterprise
        headroom proxy --proxy-extension shield_enterprise,mypkg
        headroom proxy --proxy-extension '*'
  Env:  HEADROOM_PROXY_EXTENSIONS=shield_enterprise

The startup banner now shows discovered + enabled extensions:
  Extensions:   discovered=shield_enterprise (opt-in: --proxy-extension ...)
  Extensions:   ENABLED shield_enterprise (available: shield_enterprise)
  Extensions:   ENABLED (wildcard) shield_enterprise

Names that were requested but not found are logged as warnings.

Adds proxy_extensions: list[str] | None to ProxyConfig. Plumbs it
through CLI -> ProxyConfig -> install_all(enabled=...).

This is a behavior change for users who relied on auto-loading.
Existing Shield/extension users must add --proxy-extension or set
HEADROOM_PROXY_EXTENSIONS to keep their middleware running.
2026-04-25 12:48:23 -07:00
pratikbin
a297b8fea6 ci(docker): add <version>-<short-sha> tag for each variant
Adds 0.10.7-ab46594 (root) and 0.10.7-<variant>-<sha> (variants) so
images can be referenced by an exact version+commit pair without
relying on the moving variant or :latest tags.
2026-04-26 01:10:06 +05:30
chopratejas
15877fb63f ci: workflow permissions + comprehensive e2e tests (phase-1)
CodeQL alert #61 (CWE-275, actions/missing-workflow-permissions):
add explicit `permissions: contents: read` to the rust workflow root.
Defaults the GITHUB_TOKEN to read-only across all jobs, so even if the
repo policy changes, this workflow stays at least-privilege. No job in
this workflow needs write — wheels/audit/parity all read-only.

Add real end-to-end test suite at tests/e2e_real.rs gated behind
HEADROOM_E2E=1. Spawns the actual Python Headroom proxy as a subprocess,
runs the Rust proxy in-process in front of it, and exercises:
  - health endpoints across the full chain
  - Anthropic non-streaming (real API call)
  - Anthropic streaming SSE (real API call) with chunk-level validation
  - OpenAI non-streaming (real API call)
  - X-Request-Id generation and pass-through

Adds tokio-process feature for Command/Child usage. Loads .env at the
repo root for API keys (does not log values). Tests skip cleanly when
HEADROOM_E2E is unset, so cargo test stays fast.
2026-04-25 12:35:27 -07:00
pratikbin
ab465948f0 fix(ci): tolerate null enable_ref_tags on direct release events
When the docker workflow is triggered directly by release.published
(rather than via workflow_call from the Release parent), inputs.enable_ref_tags
is null and produced an empty enable= attribute that the metadata-action
rejected. Default to true on non-release triggers and skip ref/pr tags
on release events where they don't apply anyway.
2026-04-26 00:59:52 +05:30
pratikbin
46781d368c ci(docker): clean up image tags, signatures, and Latest indicator
- Replace full-sha image tags with type=sha,format=short (7-char) so the
  primary package versions list stops accumulating long sha-only entries.
- Route cosign signatures into a sibling GHCR package via
  COSIGN_REPOSITORY=<image>-signatures, so the main image's package
  version list stays clean. GHCR does not yet implement the OCI 1.1
  Distribution Referrers API (community discussion #163029, June 2025),
  so legacy signature mode is used here -- OCI 1.1 mode would force the
  signature manifest's subject into the same repo as the image and
  override COSIGN_REPOSITORY. Verifiers must export the same
  COSIGN_REPOSITORY value when running 'cosign verify'.
- Add a promote-latest job that runs after the variant matrix and
  re-pushes the :latest tag pointing at the root image with a unique
  index annotation. This forces a fresh manifest digest, generating a
  new GHCR package version with current timestamp so :latest sits at
  the top of the version listing instead of whichever variant happened
  to finish last.
2026-04-26 00:45:01 +05:30
chopratejas
56f679f247 fix(rust): 7 proxy bugs found in review (phase-1)
Bug 1 (HIGH) health.rs: Url::join('healthz') used relative resolution,
stripping non-trailing-slash base paths. Fixed with set_path('/healthz').

Bug 2 (HIGH) main.rs: graceful_shutdown_timeout was configured and logged
but never enforced. Now sleeps for the configured duration after signal
before axum exits, giving in-flight LLM streams time to drain.

Bug 3 (MEDIUM) websocket.rs: WS pump half-close could hang forever if
close() on one side failed. Replaced tokio::join! on async blocks with
spawned tasks + CancellationToken so either direction cancels the other.

Bug 4 (MEDIUM) proxy.rs/websocket.rs: URL path-join logic was copy-pasted
verbatim in two places. Extracted to join_upstream_path() helper; websocket
now calls it instead of duplicating the 15-line block.

Bug 5 (MEDIUM) proxy.rs: mid-stream upstream errors were silently swallowed
by Body::from_stream. Added a .map() wrapper that logs before re-raising.

Bug 6 (LOW) websocket.rs: WS session log was missing the request path,
making it hard to correlate logs with client sessions. Added path field.

Bug 7 (LOW) websocket.rs: scheme match arm 'ws'|'wss' borrowed joined
immutably while set_scheme needed a mutable borrow. Fixed by using literal
'ws' (set_scheme on an already-ws URL is a no-op for the ws case).
2026-04-25 12:05:01 -07:00
chopratejas
1bbbf96700 fix(ci): maturin manifest-path is Cargo.toml not pyproject.toml
maturin>=1.5 requires -m to point to Cargo.toml, not pyproject.toml.
Fixes wheel build job failure in CI (all three matrix targets).
Also switches to manifest-path: action param for cleaner workflow syntax.
Applies same fix to Makefile build-wheel and develop targets.
2026-04-25 11:56:44 -07:00
ipapapa
d3c37d7098 feat(memory): resolve Qdrant connection from HEADROOM_QDRANT_* env vars (#31)
Adds `HEADROOM_QDRANT_URL`, `_HOST`, `_PORT`, `_API_KEY`, `_HTTPS`,
`_PREFER_GRPC`, `_GRPC_PORT` support across the memory stack:

- `headroom/memory/qdrant_env.py`: shared resolver helper with
  explicit-arg > env > default precedence (URL wins over host/port;
  booleans parsed via standard truthy set).
- `memory/easy.py`, `backends/{mem0,direct_mem0}.py`,
  `proxy/memory_handler.py`: call the resolver so
  `Memory(backend="qdrant-neo4j")`, `Mem0Config`, and the proxy's
  `MemoryConfig` all honor the same env keys.
- `proxy/models.py` + `proxy/server.py`: `ProxyConfig` picks up the
  same keys so hosted Qdrant (e.g. Qdrant Cloud) works without code
  changes.
- `cli/proxy.py`: adds `--memory-qdrant-{url,host,port,api-key}`
  flags that override the env when present.
- `tests/test_memory/test_qdrant_env.py`: unit coverage for
  precedence, URL-vs-host/port, boolean parsing, and unset defaults.
- `CHANGELOG.md`: documented under [Unreleased] / Added.

Explicit constructor arguments still win; unset env keeps the existing
localhost:6333 defaults, so this is backwards-compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:16:16 -07:00
Tejas Chopra
6dede0c2b4
Merge pull request #262 from gglucass/fix/traffic-learner-error-recovery
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
2026-04-24 20:17:41 -07:00
Tejas Chopra
e635bab336
Merge pull request #263 from gglucass/chore/renormalize-line-endings
chore: renormalize line endings to LF
2026-04-24 20:17:09 -07:00
Tejas Chopra
62753e42bb
Merge pull request #264 from chopratejas/dependabot/npm_and_yarn/sdk/typescript/npm_and_yarn-d3e0e43246
chore(deps): bump the npm_and_yarn group across 3 directories with 4 updates
2026-04-24 20:16:48 -07:00
chopratejas
bcc2ad810a test(rust): integration tests + wiremock harness (phase-1)
15 integration tests across five suites that spin up the proxy on an
ephemeral port pointed at a per-test mock upstream:

- integration_http: all 7 methods round-trip with body, status passthrough
  for 404/500/502, query strings preserved, 1MB POST streams through.
- integration_sse: a 10-event in-process hyper SSE upstream emits at 50ms
  cadence; chunks reach the client with max gap < 500ms (loose CI bound)
  and a client disconnect propagates to the upstream within 2s.
- integration_ws: 5 text + 5 binary messages echo through a tungstenite
  upstream byte-equal; client-initiated close propagates.
- integration_headers: hop-by-hop strip both directions, X-Forwarded-*
  injection, X-Forwarded-For appends to existing value, multi-valued
  response headers preserved.
- integration_body: 5MB POST round-trips byte-equal; streaming response
  yields first byte before the upstream finishes sending.
- integration_health: own /healthz always 200; /healthz/upstream is 200
  when upstream healthy and 503 when down.

The Sec-WebSocket-Protocol forwarding is exercised implicitly by the WS
tests via tungstenite handshake. The harness lives at tests/common/mod.rs
and is shared by every integration suite.
2026-04-24 15:52:31 -07:00
chopratejas
e3b71b949f feat(rust): upstream health check endpoint (phase-1)
/healthz returns 200 unconditionally (own health). /healthz/upstream
proxies a GET to the upstream's /healthz and returns 200 when reachable
+ 2xx, 503 otherwise. Both endpoints are intercepted in axum and never
forwarded; documented in RUST_DEV.md as reserved paths.
2026-04-24 15:52:06 -07:00
chopratejas
dd6ebef8cf feat(rust): websocket upgrade + bidirectional pump (phase-1)
When the catch-all sees an Upgrade: websocket request, hand it to the ws
module: axum upgrades the client side, tokio-tungstenite connects to the
upstream (rewriting http->ws / https->wss while preserving path + query),
and two pumps shovel messages until either side closes. Forwarded headers
exclude what tungstenite manages (Host, Upgrade, Connection, Sec-*) but
preserve Authorization, Sec-WebSocket-Protocol, etc. Supports text,
binary, ping, pong, and close frames in both directions.
2026-04-24 15:49:46 -07:00
chopratejas
0493ebf1fe feat(rust): hop-by-hop header filtering + X-Forwarded-* (phase-1)
Implements RFC 7230 6.1 hop-by-hop filtering on both request and response
sides (Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization,
TE, Trailers, Transfer-Encoding, Upgrade), plus the additional headers
listed inside any incoming Connection: header. Injects X-Forwarded-For
(appending to existing value if any), X-Forwarded-Proto, X-Forwarded-Host,
and X-Request-Id. The proxy module wires these in for both HTTP and WS.
2026-04-24 15:47:19 -07:00
chopratejas
128a910ebb feat(rust): axum reverse proxy skeleton + http catch-all (phase-1)
Builds out crates/headroom-proxy from a /healthz stub into a transparent
reverse proxy: catch-all router that forwards every method/path/query to
--upstream verbatim, streaming both request and response bodies through
reqwest without buffering. Adds clap-based config (CLI + env), thiserror
error type with sane upstream-status mapping, JSON tracing-subscriber
logging, and graceful shutdown. The library surface (build_app, AppState,
Config) is reused by the integration tests.
2026-04-24 15:47:07 -07:00
dependabot[bot]
2f659535d2
chore(deps): bump the npm_and_yarn group across 3 directories with 4 updates
Bumps the npm_and_yarn group with 1 update in the /sdk/typescript directory: [esbuild](https://github.com/evanw/esbuild).
Bumps the npm_and_yarn group with 1 update in the /plugins/openclaw directory: [esbuild](https://github.com/evanw/esbuild).
Bumps the npm_and_yarn group with 2 updates in the /docs directory: [postcss](https://github.com/postcss/postcss) and [next](https://github.com/vercel/next.js).


Updates `esbuild` from 0.21.5 to 0.27.4
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.4)

Updates `postcss` from 8.5.8 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

Updates `vite` from 5.4.21 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

Updates `esbuild` from 0.21.5 to 0.27.4
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.4)

Updates `postcss` from 8.5.8 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

Updates `vite` from 5.4.21 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

Updates `postcss` from 8.5.8 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

Updates `next` from 16.2.2 to 16.2.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.4)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 0.27.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 16.2.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 22:15:53 +00:00
Tejas Chopra
9aa6487d83
Merge pull request #252 from chopratejas/dependabot/uv/uv-399e5958fa
chore(deps): bump nltk from 3.9.2 to 3.9.4 in the uv group across 1 directory
2026-04-24 15:09:54 -07:00
chopratejas
0414cb70e4 feat(rust): scaffold workspace + parity harness (phase-0)
Bootstrap the Rust port of Headroom. Additive only — no existing Python
code modified. Ships the workshop, not the widgets.

Layout
  Cargo.toml (workspace) + rust-toolchain.toml
  crates/headroom-core    — transform library, stub only
  crates/headroom-proxy   — axum binary, /healthz only
  crates/headroom-py      — PyO3 cdylib, exposes headroom._core.hello()
  crates/headroom-parity  — Rust-vs-Python oracle harness + parity-run CLI

Tooling
  Makefile: test, test-parity, bench, build-proxy, build-wheel, fmt, lint
  .github/workflows/rust.yml: test, wheels (linux/mac), audit, parity-nightly
  deny.toml for cargo-deny

Parity corpus
  tests/parity/recorder.py + scripts/record_fixtures.py
  125 recorded fixtures across 5 leaf transforms (ccr, tokenizer,
  log_compressor, diff_compressor, cache_aligner)

Docs
  RUST_DEV.md — developer setup and workspace reference
  docs/spec/022-rust-migration.md — migration plan and stage breakdown

.gitignore: whitelist scripts/record_fixtures.py; ignore target/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:39:48 -07:00
Tejas Chopra
32152f5202
Merge pull request #246 from Kayzo/fix/memory-batch-onnx-sqlitevec
fix(memory): batch onnx embeddings and sqlite-vec ops
2026-04-24 07:56:59 -07:00
Garm
ac493cba1e test(memory): raise patch coverage from 83% to 98% on error_recovery fixes
26 new tests covering:

- TestNormalizeBashForHash — empty string, no-suffix, head/tail strip,
  trailing context flags, stderr redirect, chain-boundary truncation
- TestParseIsoTimestamp — None, empty, non-string, invalid format,
  naive (assumed UTC), tz-aware preserved
- TestLoadPersistedPatternsTimestamps — reads first_seen_at/last_seen_at
  from metadata, falls back to created_at, collision-merges timestamps
  and bumps importance to max, handles malformed JSON and non-numeric
  importance cells gracefully
- TestBumpPersistsLastSeenAt — verifies _bump_persisted_evidence writes
  $.last_seen_at into metadata JSON
- TestHydrateLegacyRow — legacy rows without category, rows with
  unknown/invalid category, rows with empty content
- TestCollectAllPatternsTimestamps — in-session re-sighting bumps
  last_seen_at past stale persisted timestamp
- TestRefineErrorRecovery (additions) — refine-empties-section skips
  recommendation entirely, OSError during re-validation keeps the row,
  Read patterns without success_path skip re-validation cleanly

Remaining uncovered lines in patch (4): defensive exception handlers
in _hydrate_persisted_state (sqlite connect OperationalError, asyncio
thread exception, JSONDecodeError on metadata) that require heavy
mocking for marginal value.

91 tests pass, ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:53:51 +02:00
Garm
570a365dc4 chore: add .git-blame-ignore-revs
Lists the line-ending renormalization commit so `git blame` and GitHub's
blame UI skip it. Contributors can opt in locally with:

    git config blame.ignoreRevsFile .git-blame-ignore-revs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:35:29 +02:00
Garm
efd2ac1ca4 chore: renormalize line endings to LF
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.

Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.

Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:30 +02:00
Garm
879064fea5 fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.

- Normalize the hash key for error_recovery patterns. Read recoveries key
  on (basename(error_path), basename(success_path)); Bash recoveries strip
  volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
  command before the first | or &&. Non-error-recovery categories keep
  literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
  _bump_persisted_evidence via json_set. Stored in metadata JSON — no
  schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
  in 21 days, re-validate Read success paths against the filesystem,
  collapse same-error_path-with-multiple-targets into one "use Glob/Grep
  first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
  bullets.

15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
Kayzo
f5cea7c51e fix(memory): batch onnx embeddings and sqlite-vec ops
Make the ONNX + sqlite-vec memory path truly batched.

Batch ONNX embed_batch calls, batch sqlite-vec index/remove work under a single cached connection, and update MCP warm-up to use batch embed/save/index flows.

Add focused regression tests for ONNX batching, sqlite-vec single-connection batch behavior, and MCP warm-up batching.

Skip the MCP-specific test when optional MCP dependencies are not installed.

Refs #240
2026-04-24 09:49:28 +00:00
Tejas Chopra
7b99b05e0d
Merge pull request #256 from JerrettDavis/fix/init-g-regression
fix(init): guide users when no agents are auto-detected + expand e2e
2026-04-23 15:27:24 -07:00
JerrettDavis
bc7a95a7c7 ci(init-native): install [proxy] extras and use pwsh for Windows shim check
Two fixes for the init-native-e2e matrix surfaced on PR #256:

1. Composite action installed `headroom` without extras, but
   `headroom/cli/__init__.py` eagerly imports `proxy.server` (via
   `cli/proxy.py`), which requires `fastapi`. All 6 POSIX jobs hit
   `ModuleNotFoundError: No module named 'fastapi'` before `init` ran.
   Fix: install `-e .[proxy]` to match the Docker e2e image.

2. On Windows, shims are `.cmd` files and Git Bash's `which` cannot
   resolve them (exact-match only). Python's `shutil.which` (used by
   `headroom init`) honors PATHEXT and finds the shim fine, but the
   pre-flight `which` step failed first. Fix: use `Get-Command` via
   `pwsh` for the Windows verification step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:30:30 -05:00