## Description
The torch/sentence-transformers `LocalEmbedder` ran encodes on the
shared default executor with **no BLAS/OpenMP thread cap**. Under
concurrent load each `encode()` fans out to ~`os.cpu_count()`
BLAS/OpenMP threads, so N in-flight encodes spawn ~`N × cpu_count` OS
threads — oversubscribing the CPU, slowing the `memory_context` stage
and (on smaller boxes) starving the asyncio event loop. The ONNX
embedder already bounds its threads
(`create_cpu_session_options(intra_op_num_threads=1,
inter_op_num_threads=1)`); this brings the torch path to parity.
Supersedes #691 by @oxura — closed only for the open-PR cap, with an
explicit invitation to resubmit; no technical objection was raised, and
its CI was fully green. Credit to @oxura for the original diagnosis and
fix. That PR capped threads by setting BLAS/OpenMP env vars at import
time plus `torch.set_num_threads`; this PR instead runs CPU encodes on a
dedicated, size-limited executor whose workers each pin their thread
pool — which additionally bounds in-flight encode concurrency (the
issue's Fix B/C) and keeps the cap contained to the embedder rather than
mutating process-global env at import.
Closes#198
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Performance improvement
## Changes Made
- CPU encodes now run on a **dedicated, size-limited executor** whose
worker `initializer` pins each worker's torch intra-op pool (and sets
BLAS/OpenMP env defaults). torch's OpenMP thread count is per-thread, so
a one-shot cap misses pooled executor workers — the per-worker
initializer caps every worker deterministically.
- Total embedding threads are bounded by `HEADROOM_EMBED_CONCURRENCY`
(default `min(4, os.cpu_count())`) × `HEADROOM_EMBED_NUM_THREADS`
(default `1`); invalid/non-positive values fall back safely (≥1).
- Mirrors the existing MPS dedicated-single-worker-executor pattern;
CUDA keeps the shared default executor (GPU compute is off-CPU).
`setdefault` never overrides an operator's explicit `OMP_NUM_THREADS`.
## 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_memory/test_embedder_thread_cap.py tests/test_memory/test_embedder_mps_serialization.py -q
13 passed
$ uv run pytest tests/test_memory/ tests/test_cli_proxy_embedding_server.py -q
533 passed # no regressions from the executor change
$ uv run ruff check . && uv run ruff format --check .
All checks passed! / 1016 files already formatted
$ uv run mypy headroom --ignore-missing-imports
Success: no issues found in 404 source files
```
New `tests/test_memory/test_embedder_thread_cap.py`: env resolution for
both knobs (default / positive / invalid / clamped), worker-init env
application + operator-override safety, and a behavioral test that loads
the real CPU embedder and asserts every executor worker is pinned to the
configured intra-op thread count. Updated
`test_embedder_mps_serialization.py` to the new CPU contract.
## Real Behavior Proof
- Environment: built this branch into a CPU-only Linux container,
removed `onnxruntime` so the proxy falls back to the torch
`LocalEmbedder`; a container has no MPS/CUDA, so it resolves to
`device=cpu` — the deployment where #198 occurs. Python 3.12, torch
2.12.1, `all-MiniLM-L6-v2`, container capped to 4 CPUs, 32 concurrent
clients.
- Exact command / steps: `headroom proxy --host 0.0.0.0 --memory`
in-container; a concurrent `/v1/messages` driver from the host (invalid
key — `memory_context` runs before the upstream call); measured the
`memory_context` stage from `/metrics` before vs after the cap.
- Observed result: the embedder stage this PR targets improved —
`memory_context` avg 73.5 ms → 58.7 ms and max 279 ms → 242 ms (uncapped
12×8 = 96 threads vs fix 4×1): ~20% faster and steadier inside the real
proxy. Isolated component benchmarks (heavy concurrent `embed_batch`;
`LocalBackend.search_memories`) show a larger effect — tail event-loop
stall ~16–24 ms → ~3 ms, and search throughput +57%. Unit/regression: 13
new tests + 533 memory-suite tests pass; `ruff` + `mypy` clean.
- Not tested: the issue's absolute multi-second `/livez` spike. On my
hardware/synthetic load, `/livez` stalls were dominated by the
upstream-connection path (invalid-key DNS/TLS), not the ~250 ms
`memory_context` stage, so I can't attribute the multi-second figure to
the embedder here — the original report was on an 8-core box with real
Claude Code transcripts that drove `memory_context` itself to several
seconds. Linux/CUDA hardware not exercised; no live LLM provider used;
ONNX path unchanged. This PR removes the documented thread
oversubscription and brings the torch path to ONNX parity; it does not
claim to single-handedly resolve the 4 s figure.
Measured `memory_context` stage timing (real containerized proxy, torch
CPU embedder, 4 CPUs, 32 concurrent clients):
| `memory_context` | avg | max |
|---|---|---|
| Before (uncapped, 12×8 = 96 threads) | 73.5 ms | 279 ms |
| After (fix, 4×1) | 58.7 ms | 242 ms |
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Additional Notes
Default-behavior change: CPU encodes use a dedicated bounded pool
instead of the shared default executor (`close()` tears it down). Both
knobs are opt-in overrides with safe defaults. No new dependencies.
Signed-off-by: Krishnachaitanyakc <krishnabkc15@gmail.com>
## Description
Anthropic request-side CCR can still compress a turn into retrieval
markers after the frozen-prefix cache guard suppresses
`headroom_retrieve` registration. That leaves the model with marker-only
context it cannot redeem, so the proxy silently drops recoverable data
on exactly the turns where cache preservation deferred tool injection.
This change couples the Anthropic request-side CCR path to tool
availability so a turn never emits retrieve-only markers without the
retrieval tool, even when token mode or cache-mode prefix replay could
otherwise reuse already-compressed marker text. Closes#1006
After a collaborator merged current `main` into this branch, CI also
picked up unrelated offline-memory failures from the merged base. Those
follow-up changes are test-only: they keep the offline Hugging Face
cache lanes skipping cleanly instead of failing in memory tests that are
outside the CCR runtime path.
## 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
- Couple Anthropic request-side CCR compression to the same
frozen-prefix guard that already defers `headroom_retrieve`
registration.
- Keep the existing cache-preservation behavior: frozen-prefix turns
stop emitting CCR retrieval markers instead of forcing tool injection
into the cached prefix.
- Make the skip decision use the effective frozen prefix after
token-mode reclamping, so turns that genuinely reclamp to zero still
keep normal reversible CCR behavior.
- Bypass cached marker reuse in both token mode and cache-mode prefix
replay when tool injection is deferred.
- Add focused regressions for the Anthropic request-path seams under
this bug:
- frozen-prefix turns do not emit marker-only payloads
- unfrozen turns still keep normal reversible CCR behavior
- token-mode reclamp back to zero still compresses normally
- existing `headroom_retrieve` tools keep reversible CCR on frozen turns
- cache-mode delta reuse and exact-prefix replay both forward original
content when retrieval is unavailable
- Add a `CHANGELOG.md` entry because the proxy's user-visible Anthropic
CCR behavior changes.
- Add a shared test skip helper for offline Hugging Face cache misses
and apply it to the merged `main` memory tests that were failing only in
the offline CI shards after the branch picked up current `main`.
## Testing
- [x] Unit tests pass (`uv run pytest
tests/test_proxy/test_anthropic_ccr_deferred_injection.py`)
- [x] Linting passes (`uv run ruff check . && uv run ruff format .
--check`)
- [ ] 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_proxy/test_anthropic_ccr_deferred_injection.py
14 passed, 1 warning in 34.19s
uv run pytest tests/test_memory/test_skip_helpers.py tests/test_memory/test_embedder_mps_serialization.py::test_cpu_uses_shared_executor tests/test_memory/test_hierarchical.py::TestLocalEmbedder::test_embed_single tests/test_memory_bridge.py::TestMemoryBridgeImport::test_import_claude_code_memory tests/test_memory_handler_concurrent_init.py::test_real_localbackend_initializes_via_public_entrypoint tests/test_memory_system.py::TestLocalBackend::test_save_memory_basic -q
4 passed, 5 skipped, 11 warnings in 7.65s
uv run ruff check .
All checks passed!
uv run ruff format . --check
968 files already formatted
```
## Real Behavior Proof
- Environment: local FastAPI `TestClient` for the Anthropic request
path, plus Windows Python 3.12 offline-memory repros with
`TRANSFORMERS_OFFLINE=1`
- Exact command / steps: Run the focused CCR regression command and the
offline-memory repro subset below on the merged branch state.
- `uv run pytest
tests/test_proxy/test_anthropic_ccr_deferred_injection.py`
- `uv run pytest tests/test_memory/test_skip_helpers.py
tests/test_memory/test_embedder_mps_serialization.py::test_cpu_uses_shared_executor
tests/test_memory/test_hierarchical.py::TestLocalEmbedder::test_embed_single
tests/test_memory_bridge.py::TestMemoryBridgeImport::test_import_claude_code_memory
tests/test_memory_handler_concurrent_init.py::test_real_localbackend_initializes_via_public_entrypoint
tests/test_memory_system.py::TestLocalBackend::test_save_memory_basic
-q`
- Scenario coverage from the CCR pytest command:
- frozen prefix with deferred tool injection and cached marker text
available in token mode
- unfrozen turn with normal CCR marker emission
- token mode where the tracked frozen prefix reclamps back to zero
- frozen prefix where the client already supplied `headroom_retrieve`
- cache-mode append-only delta reuse with a previously forwarded
compressed prefix
- cache-mode exact-prefix replay where the previous forwarded prefix
already contained a marker
- Scenario coverage from the offline-memory repro command:
- direct local embedder startup on CPU with no cached HF model
- hierarchical memory embedder startup with offline model cache missing
- bridge import through `LocalBackend`
- `MemoryHandler` public init warmup path
- `LocalBackend` save path under the offline lane
- Observed result: The CCR regression keeps marker-free forwarding on
frozen turns without tool availability, and the merged-`main`
offline-memory lanes now skip cleanly instead of failing unrelated CI
shards.
- frozen-prefix Anthropic turns without tool availability forward the
original long transcript across both token-mode and cache-mode reuse
paths, while unfrozen turns, reclamped token-mode turns, and frozen
turns that already advertise `headroom_retrieve` keep the reversible CCR
marker path
- the merged-`main` offline-memory regressions now skip cleanly when the
Hugging Face cache is unavailable instead of failing unrelated CI shards
- Not tested: live Zed session cache-hit behavior, provider latency
under real Anthropic upstreams, and online Hugging Face download lanes
## 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
- Runtime scope is still intentionally narrow to Anthropic request-side
CCR. OpenAI, Gemini, streaming, and response-side CCR behavior are
unchanged.
- The only non-CCR diff is the test-only offline-memory follow-up
required after current `main` was merged into the branch.
- The focused proxy pytest run still emits the Windows-local
`StarletteDeprecationWarning` from `fastapi.testclient`'s `httpx`
bridge. The offline-memory repro command also emits existing datetime
deprecation warnings and a pytest teardown warning around skipped
offline lanes; none of those warnings were introduced by the CCR runtime
change.
## Description
Stop `headroom wrap codex --memory` from pinning the global
`headroom_memory` MCP server to one absolute SQLite path. Today the
wrapper writes `--db <wrap-cwd>/.headroom/memory.db` into
`~/.codex/config.toml`, which makes later Codex sessions either reopen a
stale project-local DB or fail with `unable to open database file` when
that original path disappears. This change lets the MCP server use its
existing per-cwd default again, so each Codex session resolves
`.headroom/memory.db` from the active project instead of a serialized
past cwd. Closes#1147
The current Codex-memory config surface was shaped by
https://github.com/chopratejas/headroom/issues/462 and
https://github.com/chopratejas/headroom/issues/730; this PR keeps that
surface project-scoped again instead of globally pinning one DB.
## 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
- remove the injected `--db` argument from the global `headroom_memory`
Codex MCP block while keeping `--user` intact
- preserve the wrap-time local `.headroom/memory.db` setup and
Claude-memory import path for the current project
- treat only wrap-owned Codex markers as snapshot-suppression and
unwrap-cleanup signals, so pre-existing named MCP blocks still back up
and restore
- log a startup diagnostic from `headroom.memory.mcp_server` that
records the configured DB path, config source, cwd/project root,
resolved storage scope, path existence/readability, and whether the path
was static or cwd-derived
- add a shared MCP SDK test stub so both the memory MCP and CCR MCP test
surfaces still run in CI when `mcp` is absent
- make the shared MCP stub re-import target modules under the stubbed
dependency set and restore any pre-existing target module object plus
dotted parent-package attribute state after cleanup
- add focused regressions and guard coverage for the persisted Codex
config shape, named-MCP marker backup and restore, the no-backup
memory-only unwrap path, the wrap-memory-then-unwrap cleanup path, the
failed-wrap memory-only cleanup path, the startup-diagnostic path
classification, the shared-store CCR retrieval path, and the shared MCP
stub import lifecycle
- add a `CHANGELOG.md` entry for the user-visible Codex memory scoping
fix
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
uv run pytest tests/test_ccr_mcp_server.py tests/test_memory/test_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py
======================== 78 passed, 1 warning in 5.96s ========================
Pytest warning:
PytestConfigWarning: Unknown config option: asyncio_mode
Pytest post-success atexit noise:
PermissionError: [WinError 5] Access is denied: 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-current'
uv run ruff check headroom/cli/wrap.py headroom/memory/mcp_server.py tests/_mcp_stub.py tests/test_ccr_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py tests/test_memory/test_mcp_server.py
All checks passed!
uv run ruff format headroom/cli/wrap.py headroom/memory/mcp_server.py tests/_mcp_stub.py tests/test_ccr_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py tests/test_memory/test_mcp_server.py --check
7 files already formatted
```
## Real Behavior Proof
- Environment: isolated temp project directories, a temp Codex home, the
real `wrap codex` and `unwrap codex` CLI commands under pytest, a mocked
missing-`codex` launch path for the failed-wrap cleanup case, and shared
MCP-SDK stubs for the memory MCP and CCR MCP test modules so CI still
exercises those paths without a real `mcp` install.
- Exact command / steps: run `uv run pytest tests/test_ccr_mcp_server.py
tests/test_memory/test_mcp_server.py tests/test_cli/test_wrap_codex.py
tests/test_mcp_stub.py`; prove the persisted config shape with
`TestCodexMemoryMcpConfig::test_inject_omits_db_and_replaces_existing_memory_block`;
prove prepare-only wrap cleanup with
`test_wrap_codex_memory_prepare_only_unwrap_removes_memory_mcp_without_prior_config`;
prove failed-wrap cleanup with
`test_wrap_codex_memory_launch_failure_unwrap_cleans_memory_only_config`;
guard pre-existing named Codex MCP preservation with
`test_memory_only_wrap_restores_preexisting_named_mcp_block` and
`test_memory_only_wrap_without_backup_preserves_named_mcp_block`; prove
the startup diagnostic classifications with
`test_memory_mcp_startup_context_reports_dynamic_project_db` and
`test_memory_mcp_startup_context_reports_static_external_db`; prove the
shared-store CCR retrieval path with
`test_mcp_uses_shared_singleton_store` and
`test_mcp_retrieves_proxy_stored_content`; prove stub import cleanup
with `test_import_module_with_mcp_stub_imports_target_and_cleans_up`,
`test_import_module_with_mcp_stub_reimports_target_and_restores_originals`,
and
`test_import_module_with_mcp_stub_cleans_up_dotted_target_attribute`.
- Observed result: the persisted global `headroom_memory` block now
keeps `--user` but omits `--db`; prepare-only memory setup still
bootstraps the current project's `.headroom/memory.db`; `headroom unwrap
codex --no-stop-proxy` now removes both the prepare-only generated
config and the failed-wrap memory-only config instead of leaving
`[mcp_servers.headroom_memory]` behind; pre-existing named Codex MCP
blocks remain restorable across both normal and no-backup memory-only
unwrap paths because only wrap-owned markers suppress backups or trigger
named-block cleanup; the memory MCP server now logs whether its DB path
came from the cwd default or an explicit static path, along with the
resolved path and scope it will open; CI can exercise both MCP test
modules even when the `mcp` package is absent from the shard
environment, and the shared stub now re-imports target modules under the
stubbed SDK while restoring both dependency and dotted parent-package
target-module import state after cleanup.
- Not tested: full end-to-end interactive Codex CLI launch.
## 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
- [ ] 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 code change stays narrowly scoped to Codex memory config
persistence, cleanup, and startup observability. It does not widen into
larger memory-routing redesign or startup-failure recovery logic.
## Summary
This PR implements transparent `headroom wrap opencode` support without
asking users to edit OpenCode provider URLs, choose an extra CLI flag,
or maintain a static provider list.
The wrapper now lives at the runtime transport boundary: OpenCode keeps
its user/provider config, while Headroom intercepts outbound provider
traffic in-process and routes it through the local Headroom proxy.
## What changed
### Transparent OpenCode wrapping
- `headroom wrap opencode` injects the `headroom-opencode` plugin
through `OPENCODE_CONFIG_CONTENT`.
- Existing OpenCode provider URLs are preserved. We do not rewrite user
config URLs to point at Headroom.
- Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are
preserved.
- Local OpenCode traffic, localhost traffic, and Headroom proxy traffic
bypass the shim to avoid loops.
### Runtime transport interception
- Added an OpenCode plugin transport shim that wraps:
- `globalThis.fetch`
- `http.request` / `http.get`
- `https.request` / `https.get`
- External provider calls are routed to the local Headroom proxy.
- The original upstream origin is passed through `x-headroom-base-url`,
so the proxy can forward to the real provider without changing OpenCode
config.
- External `http2.connect` is blocked loudly instead of allowing direct
provider traffic to leak outside Headroom.
### Live provider additions
Provider coverage is no longer based on a static config scan. Because
routing happens at outbound request time, providers added mid-session
are routed through Headroom automatically as long as they use the
covered Node transport paths.
### Subagent and child-process coverage
- The parent OpenCode plugin sets a packaged Node preload shim through
`NODE_OPTIONS=--import=.../hook-shim/handler.js`.
- The transport shim patches `child_process.spawn`, `exec`, `execFile`,
and `fork` so child Node processes receive the Headroom preload even
when OpenCode passes a custom `env`.
- The child-process shim fails closed if it loads without
`HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`.
- This closes the subagent leak path where a child Node process could
otherwise start without Headroom transport interception.
## Why this goes beyond PR #1089
PR #1089 improves OpenCode provider registration, but it still focuses
on provider config shape. This PR moves the enforcement boundary to
runtime transport interception.
This PR goes further because:
- No provider URL rewriting is required.
- New providers added mid-session are covered automatically.
- Subagents and child Node processes inherit the Headroom transport
shim.
- Direct external HTTP/2 paths fail loudly instead of leaking.
- The wrap remains transparent to the user's OpenCode provider config.
- The wrapper is fail-closed for unsupported child-process preload
state.
## Additional robustness fixes
While validating the change in Docker, the full Python suite exposed
unrelated Linux/container robustness issues. These are fixed in this PR
so the suite is green:
- Binary cache handling now treats cache paths under a non-writable
existing parent as unavailable, including when tests run as root in
Docker.
- `release_version.py` honors `MANUAL_VER` before git calls so direct
script execution works outside a `.git` checkout.
- Test logger isolation now resets relevant Headroom child loggers so
proxy logging setup cannot poison later `caplog` tests.
- The scanner missing-path test now uses a guaranteed missing `tmp_path`
child instead of relying on `/nonexistent/path`.
## Validation
All implementation validation was run inside Docker.
- Full Python suite from a fresh Docker copy: `6605 passed, 523
skipped`.
- Ruff on changed Python/OpenCode paths: passed.
- OpenCode plugin typecheck: passed.
- OpenCode plugin tests: `9 passed`.
- OpenCode plugin build: passed.
- Hook shim preload smoke test: passed.
## Notes
This PR intentionally does not add a CLI option. `headroom wrap
opencode` means full wrap. Either Headroom wraps OpenCode transparently,
or the path fails loudly instead of silently leaking provider traffic.
---------
Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
## Description
On Apple-Silicon Macs — especially fanless models like the MacBook Air
(M5) — running the proxy with memory context injection can pin the CPU
while embedding. The embedding work runs an uncapped session on the CPU,
saturating multiple cores, which starves the proxy's asyncio loop and
leads to request timeouts.
This PR adds an **opt-in** runtime that offloads the memory embedder to
the Apple GPU (MPS). Setting `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`
routes embedding through the torch `sentence-transformers` backend on
MPS instead of the default ONNX CPU embedder, moving the work off the
CPU and keeping the proxy responsive.
The default behavior is unchanged — the feature is strictly opt-in,
env-var only, and falls through to the existing default embedder
selection (with a warning) whenever MPS or the torch dependencies are
unavailable.
Fixes: N/A — no tracking issue (surfaced while running codex auto-review
through
the proxy on a fanless MacBook Air (M5)).
## 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
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **Runtime selection** (`headroom/proxy/memory_handler.py`): read
`HEADROOM_EMBEDDER_RUNTIME`; when set to `pytorch_mps` **and** MPS is
actually available, route the memory embedder to the torch
`sentence-transformers` backend (Apple GPU).
- If MPS is unavailable or torch/sentence-transformers is not installed,
log a warning and fall through to the existing default embedder
selection (ONNX when available, else the pre-existing local
sentence-transformers fallback) — no crash. The default (env var unset)
is unchanged. Env-var only
- **MPS serialization** (`headroom/memory/adapters/embedders.py`):
`LocalEmbedder` now funnels every `encode()` through a dedicated
single-worker `ThreadPoolExecutor` when the resolved device is MPS.
torch-MPS is not thread-safe, and the existing `run_in_executor(None,
...)` dispatch would otherwise let concurrent proxy requests call MPS
from multiple threads. CPU/CUDA keep the shared default executor
(behavior unchanged). `close()` also drops the cached model so re-use
after close re-initializes cleanly.
- **Packaging** (`pyproject.toml`): new `pytorch-mps` extra (`torch` +
`sentence-transformers`), **platform-gated to macOS** (`; sys_platform
== 'darwin'`) since MPS is Apple-Silicon-only. Deliberately left out of
`[all]` (its deps already arrive via `[ml]`/`[memory]`).
- **Tests** (`tests/test_memory/test_embedder_mps_serialization.py`):
regression coverage for the serialized executor, concurrency safety (no
SIGABRT), CPU-path default behavior, and close/re-use re-initialization.
- **Docs**: `wiki/{configuration,memory,macos-deployment}.md`,
`docs/content/docs/{configuration,installation,memory}.mdx`,
`README.md`, `CHANGELOG.md`.
## 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 (CPU-offload + concurrency profiling on
Apple Silicon)
## Test Output
```
$ pytest -v tests/test_memory/test_embedder_mps_serialization.py
collected 4 items
tests/test_memory/test_embedder_mps_serialization.py::test_cpu_uses_shared_executor PASSED [ 25%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_creates_single_worker_executor PASSED [ 50%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_concurrent_embeds_do_not_crash PASSED [ 75%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_reembed_after_close_recreates_executor PASSED [100%]
============================== 4 passed in 6.92s ===============================
$ ruff check headroom/memory/adapters/embedders.py headroom/proxy/memory_handler.py tests/test_memory/test_embedder_mps_serialization.py
All checks passed!
$ mypy headroom/memory/adapters/embedders.py headroom/proxy/memory_handler.py
Success: no issues found in 2 source files
$ pytest -q tests/test_memory/ tests/test_memory_handler_concurrent_init.py tests/test_memory_handler_native_ops.py
553 passed, 1 skipped in 13.51s
```
## 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
**Why MPS (and not CoreML or a thread cap):** measured on an
Apple-Silicon Mac, the default uncapped CPU embedding session saturates
the cores; the same model on MPS runs at a fraction of the CPU (≈8x
lower sustained CPU utilization in profiling) while producing
**byte-identical embeddings** (cosine distance ≈ 0 across runtimes), so
relevance/ranking is unchanged. A CoreML execution-provider path was
evaluated and rejected: the default optimized ONNX model uses fused ops
that fall back to CPU under CoreML (no offload), and a full-precision
re-export was impractical (very low throughput + multi-GB memory). MPS
via `sentence-transformers` was the only practical GPU offload.
**Why serialization is mandatory:** torch-MPS is not thread-safe —
concurrent encode calls from a multi-worker executor abort with
`-[IOGPUMetalCommandBuffer validate]: failed assertion 'commit an
already committed command buffer'` (reproduced deterministically; a
single-worker executor resolves it).
Under concurrent load the serialized single-GPU-stream throughput meets
or exceeds the parallel CPU path while using a fraction of the cores.
**Scope / boundary:** this targets the Python **memory** embedder, which
is live on the proxy request path (memory context injection). The
Rust-backed SmartCrusher compression path is unaffected and remains
non-configurable from Python by design.
**Safety:** default behavior is unchanged (ONNX, no torch). The feature
is opt-in, env-var only, macOS-gated at the packaging layer, and
degrades gracefully (warn + the existing default embedder selection)
when MPS or the dependencies are unavailable.
`TrafficLearner._extract_preferences` ran three regex patterns over raw
user-message text and saved any match as a `User preference: <captured>`
memory. Two compounding bugs made ~10% of the reporter's saved memories
(187 of 1796) garbage:
1. **System-reminder content was matched.** Claude Code injects
`<system-reminder>…</system-reminder>` blocks into user-role
messages — scaffolding ("don't mention this reminder", "use colgrep
instead of Grep", "never bypass signing") that hits every correction
trigger. The learner happily persisted scaffolding as authoritative
user preferences.
2. **Capture groups were fixed-length windows.** `(.{10,100})` grabbed
the next 10–100 chars with no boundary awareness, producing
mid-word truncations like `User preference: of Grep, Glob. When
spawning agents, mention colgrep features a`.
This change rewrites `_extract_preferences` to be **regex-free** and
adds two layered defences:
- `_strip_system_reminders` (literal `str.find` scan, no regex)
removes `<system-reminder>…</system-reminder>` blocks from user
text before any pattern matching. Unclosed reminders drop to
end-of-string. Case-insensitive on the tag name only. ~95% of the
reporter's noise sample comes from this single layer.
- A token-based correction scanner replaces the three `re.compile`
patterns. It tokenises on whitespace (lowercasing once, up front),
matches trigger sequences as ordered token lists (`don't`, `do not`,
`stop`, `never`, `avoid`, `no use`, `no try`, `no do`, `instead`),
and captures the trailing content until a sentence terminator
(`.!?\n`) or end-of-input. Captures shorter than 10 chars are
rejected (stray triggers), and captures that hit the 78/98-char cap
without finding a terminator are rejected (rambling fragments). The
former noise — `colgrep instead of Grep, Glob. When spawning…` —
fails this gate; short complete user utterances
(`don't use git push, I'll push manually`) still pass because
end-of-input counts as a boundary.
Net regex count in this file: -3, +0.
`_hydrate_persisted_state` already runs in `start()` and seeds
`_saved_hashes`/`_persisted_ids` from prior rows, so cross-restart
dedup is already wired up — the reporter's "doesn't survive restarts"
note was partially outdated. The narrow remaining edge (in-process
`_dedup_window=100` eviction within a single very-long-running
process) self-heals on next restart and is left as a separate
follow-up.
Tests: 17 new across `TestStripSystemReminders`,
`TestExtractPreferencesSystemReminderFiltering`,
`TestExtractPreferencesRealCorrections`, and
`TestExtractPreferencesSentenceBoundary`. Full traffic_learner suite:
139 passing. ci-precheck green.
CI flagged two issues on the rebased branch:
1. ruff format --check failed on server.py and test_traffic_learner.py
after the rebase; line-collapse / trailing-whitespace nits.
2. Codecov reported 80% patch coverage with 20 lines missing in the
matcher helpers — mostly branches not exercised by the high-level
tests (empty Levenshtein inputs, source-prefix Bash parsing, env-var
skip, equal-string short-circuit in binary match, the substantive-
token path that beats the edit-distance gate, error_recovery patterns
with non-canonical content in _drop_contradictions).
Adds 16 targeted unit tests for those branches and applies ruff format.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recovery matchers paired any failed and successful tool call within
a 5-call window with no semantic check that the pair was actually a
retry. This produced confidently-wrong rules like:
File `state.rs` does not exist. The correct path is `lib.rs`.
…where the user simply read two unrelated files in the same directory.
Across sessions the same user can also typo in opposite directions,
producing directly contradictory rules side by side.
This commit adds three structural checks:
1. Read recovery: require the failed and successful basenames to be
identical or close in Levenshtein distance. Rejects the "same dir,
different file" case that was the most common noise source.
2. Bash recovery: require both commands to share a binary (allowing
path-prefixed variants and short prefix-versions like
`python` ↔ `python3`) AND either have low normalized edit distance
or share a substantive non-flag token. Rejects pairs that share only
the binary name but differ in every meaningful argument.
3. Contradiction filter on flush: detect A→B and B→A pairs in
error_recovery patterns and drop both. They almost always indicate
opposite-direction typos in different sessions, not stable advice.
Also: stash failed_path in metadata so the contradiction filter and
downstream consumers can reason about pairs without parsing content.
Tests: 13 new tests covering the heuristics directly. Existing tests
exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`,
`pip install`→success) continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Asserts that stop()'s final flush_to_file does not bypass the evidence
threshold. Earlier behavior collapsed the gate to 1 at shutdown,
persisting every singleton pattern. This guards against that change
sneaking back in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The traffic learner was emitting one-shot error_recovery patterns that
contradicted each other and bloated MEMORY.md with low-signal noise. Two
issues drove this:
1. The shutdown flush bypassed the evidence gate: the in-memory
_min_evidence was set to 2, but on stop() the gate dropped to 1, so
every singleton pattern got persisted at session end. This is the
opposite of how evidence thresholding should work — singletons are
the least trustworthy patterns, not the most.
2. The default min_evidence of 2 is too low to filter noise from the
matchers, which pair up failed/successful tool calls within a small
sliding window without a strong semantic check that the calls are
actually related.
Changes:
- Raise default min_evidence from 2 to 5 in TrafficLearner.
- Remove the shutdown-relaxation in flush_to_files; require
self._min_evidence at all times, including on stop().
- Add traffic_learning_min_evidence to ProxyConfig (default 5).
- Add --min-evidence CLI flag with HEADROOM_MIN_EVIDENCE envvar so
users and embedded clients (desktop apps, plugins) can tune the
threshold without source changes.
- Thread the config value through HeadroomProxy into TrafficLearner.
- Tests: cover default propagation and custom value flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this change, every persisted traffic_learner row in memory.db
landed with evidence_count=1, causing two user-visible problems:
1. The live flush gate (evidence_count >= 2) filtered out every row, so
CLAUDE.md / MEMORY.md never received the patterns the learner saw
repeatedly.
2. _saved_hashes is in-memory only and reset on each proxy restart, so
a pattern seen once in session A then twice in session B would insert
a *duplicate* DB row instead of bumping the existing one. Users
accumulated many rows stuck at 1 instead of a few rows with high
evidence.
Root cause chain:
- _accumulate tracks a running count in the _pattern_counts tuple but
enqueues the ExtractedPattern dataclass with its default
evidence_count=1 intact.
- _save_worker writes pattern.evidence_count into metadata verbatim.
- After save, the hash goes into _saved_hashes and further sightings
are early-returned — never bumped.
- Next process start has empty _saved_hashes, so the same content goes
through the accumulator as fresh and gets re-saved.
Fix:
- _accumulate now sets pattern.evidence_count = count before enqueuing,
so DB rows reflect the real number of sightings at save time.
- _save_worker captures the Memory.id returned by save_memory and
records content_hash → id in a new _persisted_ids map.
- _accumulate's saved-hash branch now awaits
_bump_persisted_evidence(memory_id), which runs an atomic
json_set('$.evidence_count', existing + 1) UPDATE via
asyncio.to_thread to keep the proxy hot path non-blocking.
- start() calls a new _hydrate_persisted_state() that reads existing
traffic_learner rows' (id, content) pairs from the DB and pre-seeds
_saved_hashes + _persisted_ids. Cross-session re-sightings bump the
seeded row instead of inserting a duplicate.
- _load_persisted_patterns_from_sqlite and _hydrate_persisted_state
query by json_extract(metadata, '$.source') = 'traffic_learner'
instead of the prior LIKE on raw JSON — the bump path uses json_set,
which rewrites the metadata string without the default ": " spacing,
which would otherwise make the LIKE blind to bumped rows.
Adds TestEvidencePersistence with three cases:
- save persists the actual accumulated count (not the default 1)
- re-sightings bump the persisted row instead of creating duplicates
- a fresh learner hydrates _saved_hashes from DB, so cross-session
re-sightings bump the pre-existing row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous shutdown-only flush with a debounced, near-real-time
dirty-flag flush worker that writes patterns into the correct CLAUDE.md /
MEMORY.md bucket as traffic accumulates.
- New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on
bursty traffic while keeping updates "live" from the user's perspective.
- TrafficLearner.start() now spawns a _flush_worker alongside the save
worker; _accumulate() sets a dirty flag; _flush_worker() calls
flush_to_file() when dirty and past the debounce window.
- flush_to_file() now reads *both* persisted rows (memory.db) and the
in-memory accumulator via _load_persisted_patterns_from_sqlite and
_collect_all_patterns, so patterns survive proxy restarts and the
agent-native files converge toward the full learned set.
- Patterns are bucketed per-project via the learn plugin registry
(plugin.discover_projects()) and anchored to project roots through
longest-matching-path on content or entity_refs
(_project_for_pattern). Un-anchored patterns are dropped.
- Patterns are routed by PatternCategory to either CONTEXT_FILE
(CLAUDE.md) or MEMORY_FILE (MEMORY.md) via
_patterns_to_recommendations + _CATEGORY_TO_TARGET.
- Live flushes require evidence_count >= 2; shutdown flushes accept
single-evidence rows to avoid losing last-session signal.
Adds tests for project routing, persisted-pattern loading, category
routing, and the debounced flush worker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the pattern used by headroom.ccr_backend so memory store,
vector index, and text index backends can be registered via setuptools
entry points.
- EXTERNAL enum value on StoreBackend, VectorBackend, TextBackend
- Optional *_backend_name fields on MemoryConfig
- entry_points(group=...) lookup in _create_{store,vector_index,text_index}
- New test_factory_external.py (7 tests) covering load / missing-name /
unknown-name paths
Default behavior (SQLITE + AUTO + FTS5) unchanged.
Extension groups:
headroom.memory_store
headroom.memory_vector
headroom.memory_text
Enable ReadLifecycle by default so stale/superseded Read outputs are
automatically replaced with compact CCR markers — these are provably safe
to compress (file was edited or re-read).
Replace static compression thresholds with adaptive parameters that scale
with conversation length and context pressure:
- protect_recent_reads_fraction: protects the most-recent 50% of messages
from Read exclusion. Old Reads beyond this window become compressible,
preventing the "28 excluded Read/Glob, 0 tokens saved" problem.
- min_ratio_relaxed / min_ratio_aggressive: compression acceptance
threshold interpolates linearly with context pressure (tokens / model
limit). Low pressure → 0.85 (picky), high pressure → 0.65 (accept
anything helpful). Eliminates the fixed 0.9 gate that was rejecting
20+ messages per request.
Also adds --no-read-lifecycle CLI flag, and fixes a missing
pytest.importorskip guard for sentence-transformers in memory tests.
- Create headroom/models/config.py as single source of truth for all ML model defaults
- Support environment variable overrides (HEADROOM_SENTENCE_TRANSFORMER, etc.)
- Update all components to use ML_MODEL_DEFAULTS instead of hardcoded values
- Switch LLMLingua default to smaller bert-base model (~350MB vs 1GB)
- Total memory footprint reduced from ~1.6GB to ~980MB
Updated files:
- MLModelRegistry now resolves defaults from config
- EmbeddingScorer, LocalEmbedder, TrainedRouter use config
- All dataclass configs use field(default_factory=...) for consistency
- Tests updated to handle auto-selected vector backends
- Add MemoryToolAdapter for unified memory across providers
- Anthropic: Uses native memory tool (memory_20250818) for subscription safety
- OpenAI/Gemini/Others: Uses function calling format
- All providers share the same semantic vector store backend
- Simplify CLI to single --memory flag with auto-detection
- Add proper resource cleanup (close methods) to fix test isolation
- Update README with memory documentation
Add pytestmark skip conditions to memory test modules that depend on
hnswlib (core_operations, factory, easy). The subprocess probe for
hnswlib correctly detects unavailability on some platforms (like
Python 3.13 CI runners), but these tests were still trying to run
and failing with ImportError.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
DiffCompressor:
- Parse unified diff format and compress by reducing context lines
- Preserve file headers and all +/- change lines
- Score hunks by relevance (error keywords, query matches)
- Add summary line: [N files, +X -Y lines]
- Expected 30-50% savings on typical git diffs
- Wire into content router for CompressionStrategy.DIFF
- 30 tests covering parsing, compression, edge cases
hnswlib SIGILL fix:
- Move hnswlib import from module level to lazy loading
- hnswlib crashes with SIGILL (Illegal Instruction) on CPUs
without AVX support, before Python can catch the error
- Now imports only when HNSWVectorIndex is actually used
- HNSW_AVAILABLE is checked lazily via __getattr__
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use pytest hook to catch httpx.ReadTimeout and skip tests instead of
failing. This handles flaky network timeouts from HuggingFace Hub
during sentence-transformers model downloads in CI.
The hook covers all tests in tests/test_memory/ directory.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement comprehensive memory system supporting:
- Local backend (SQLite + FTS5 + HNSW) for zero-dependency operation
- Mem0 backends (Neo4j + Qdrant) for production graph memory
- DirectMem0Adapter for optimized pre-extracted data (bypasses LLM)
- Memory extraction with facts, entities, and relationships
- Proxy integration with --memory flag for automatic memory injection
Key components:
- headroom/memory/backends/: LocalBackend, Mem0Backend, DirectMem0Adapter
- headroom/memory/system.py: MemorySystem with tool-based interface
- headroom/memory/extraction.py: Entity and relationship extraction
- headroom/proxy/memory_handler.py: Proxy integration layer
- headroom/prediction/feature_extractor.py: Content analysis features
Testing:
- 217 new memory system tests covering all backends
- LoCoMo evaluation framework for memory quality assessment
- Integration tests for proxy memory functionality
Also removes deprecated example files in favor of focused test coverage.
- Wrap hnswlib import in try/except in hnsw.py
- Export HNSW_AVAILABLE flag from adapters module
- Add helpful error message when HNSWVectorIndex is used without hnswlib
- Add @pytest.mark.skipif to HNSW test classes
hnswlib requires C++ compilation and may not be available on all
platforms or Python versions in CI environments.
Features:
- with_fast_memory(): Zero-latency inline extraction (Letta-style)
- Memory extracted as part of LLM response, no extra API calls
- Semantic retrieval with local embeddings (sub-50ms)
- with_memory(): Background extraction for non-blocking memory
- SQLite + FTS5 storage with vector similarity search
- Multi-user isolation by user_id
Memory enables temporal compression - extract key facts instead of
carrying full conversation history (4000 tokens → 50 tokens).
Includes:
- Comprehensive test suite (71 new tests)
- Documentation (docs/memory.md)
- Benchmark examples comparing approaches
- E2E test with LLM-as-judge evaluation