fix(cli): harden all CLI surfaces + fix docs accuracy (#1491)

## Summary

Full CLI audit + documentation accuracy pass. All 5 commits on this
branch:

### CLI Hardening (4 commits)
- **Clean errors instead of tracebacks**: corrupt manifests, missing
Docker, malformed JSONL, bad `--profile`, invalid env-var values all now
raise `click.ClickException` with helpful messages
- **Range validation**: ~25 numeric flags across 10 files now use
`click.IntRange`/`FloatRange` — `--port 0`, `--hours -1`, `--limit 0`
etc. produce clean usage errors instead of silent wrong behavior
- **Flag combination warnings**: conflicting combos (`--no-rate-limit` +
`--rpm`, `--no-optimize` + `--target-ratio`, `--telemetry` +
`--no-telemetry`) emit yellow warnings on stderr
- **`memory --db-path` default fixed**: was resolving to
`headroom_memory.db` (wrong bare file); now uses project store
`./.headroom/memory.db` if present, else `~/.headroom/memory.db`
- **`memory list --search` + filters**: `--scope`/`--session`/`--since`
were silently ignored when `--search` was also set; now filters are
applied to search results
- **`learn --verbosity --apply` now works**: the output shaper is off by
default (`HEADROOM_OUTPUT_SHAPER`); `--apply` now hot-enables it via
`POST /admin/runtime-env` on a running proxy, or prints explicit `export
HEADROOM_OUTPUT_SHAPER=1` instructions when no proxy is running
- **`perf --hours` overflow**: `1e9` hours no longer raises
`OverflowError`; treated as "all data"
- **`evals memory --categories` invalid input**: `abc,1,2` now raises
`BadParameter` instead of a raw `ValueError` traceback

### Documentation (1 commit, 20 files)

Corrected factual errors found by 3 parallel audit agents across root
docs, wiki, and the published Fumadocs site:

**Critical (caused runtime errors or wrong behavior if followed):**
- `simulation.mdx`: `plan.transforms_applied` -> `plan.transforms`;
`plan.savings_percent` -> computed from available fields (both raised
`AttributeError`)
- `shared-context.mdx`: `import { SharedContext } from "headroom"` ->
`"headroom-ai"` (5x `ImportError`)
- `claude-code-azure-foundry.mdx`: `pip install headroom` -> `pip
install headroom-ai`
- `api-reference.mdx` + `configuration.mdx`: `from headroom import
GoogleProvider` -> `from headroom.providers import GoogleProvider`
- `ccr.mdx`: CCR TTL default 300s -> 1800s (30 min)

**Fabricated flags removed:**
- `wiki/proxy.md` + `wiki/cli.md`: `--no-intelligent-context`,
`--no-intelligent-scoring`, `--no-compress-first` (none exist); replaced
with real CCR flags
- `wiki/configuration.md`: `--no-ccr-responses`, `--no-ccr-expansion`
(none exist); replaced with real flags
- `wiki/troubleshooting.md`, `wiki/metrics.md`,
`docs/troubleshooting.mdx`: `headroom proxy --log-level debug` (flag
doesn't exist)

**Stale content corrected:**
- `llms.txt`: telemetry stated as enabled-by-default (it's opt-in); wrap
list had 5 tools (now 11)
- `README.md`: compatibility matrix added 5 missing `wrap` targets;
`unwrap`, `doctor`, `init`/`install`, savings-analytics now mentioned
- `SECURITY.md`: supported version table showed 0.2.x (current: 0.27.x)
- `wiki/learn.md`: 5 missing flags added; verbosity shaper-off behavior
documented
- `wiki/quickstart.md`: "Configuration Reference" linked to `api.md`
(wrong) -> `configuration.md`
- `CacheAlignerConfig.enabled` default corrected: `True` -> `False`
- `opencode.mdx`: `--port` default wrong ("random") -> 8787; `openai`
backend removed
- `CONTRIBUTING.md`: broken Markdown table cell fixed
- `docs/meta.json`: `claude-code-azure-foundry` added to nav (was
unreachable orphan page)
- `configuration.mdx`: SDK modes vs proxy `--mode` now clearly
distinguished

## Test plan

- [x] `python -m pytest tests/ -x -q` — 857 passed, 0 failures
- [x] 41-combination CLI smoke test (all flag combos across 8 commands)
— 0 tracebacks
- [x] `ruff check` on all modified Python files — clean
- [x] Docs changes are removals/corrections of fabricated or stale
content; no new claims introduced
This commit is contained in:
Tejas Chopra 2026-06-27 14:48:43 -07:00 committed by GitHub
parent 06eb42005f
commit bd76235f5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 539 additions and 151 deletions

View file

@ -13,7 +13,7 @@ By participating, you agree to our [Code of Conduct](CODE_OF_CONDUCT.md).
| 🧹 Refactor-only | **Don't.** Only if a maintainer asked, as part of a concrete fix. |
| 🧪 Test/CI-only PR chasing a known `main` failure | **Don't.** We're tracking it. |
| 📦 New dep or version bump | **PR with written justification.** |
| ❓ Question | **Discord `#help` |
| ❓ Question | Ask in **Discord `#help`** |
**Open PR cap: 10 per author.** Get existing ones merged before opening more.

View file

@ -48,10 +48,10 @@ Headroom compresses everything your AI agent reads — tool outputs, logs, RAG c
- **Library**`compress(messages)` in Python or TypeScript, inline in any app
- **Proxy**`headroom proxy --port 8787`, zero code changes, any language
- **Agent wrap**`headroom wrap claude|codex|aider|copilot|opencode` in one command; Cursor prints manual proxy settings to paste into the app
- **Agent wrap**`headroom wrap claude|codex|copilot|cursor|aider|opencode|cline|continue|goose|openhands|openclaw|vibe` in one command; undo with `headroom unwrap <tool>`
- **MCP server**`headroom_compress`, `headroom_retrieve`, `headroom_stats` for any MCP client
- **Cross-agent memory** — shared store across Claude, Codex, Gemini, auto-dedup
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.md` / `AGENTS.md`
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored) or `CLAUDE.md` / `AGENTS.md` / `GEMINI.md`
- **Output token reduction** — trims what the model *writes back* (not just what you send): drops ceremony/restated code and skips deep "thinking" on routine steps. See [Output token reduction](#output-token-reduction-cut-what-the-model-writes-back).
- **Reversible (CCR)** — originals are cached for retrieval on demand
@ -96,7 +96,8 @@ headroom wrap claude # wrap a coding agent
headroom proxy --port 8787 # drop-in proxy, zero code changes
# or: from headroom import compress # inline library
# 3 — See the savings
# 3 — Verify setup and see the savings
headroom doctor # health check — confirms routing is working
headroom perf
headroom dashboard # live savings dashboard (proxy must be running)
```
@ -192,16 +193,22 @@ shows an **Output Tokens Saved** card next to input compression, labelled
| Agent | `headroom wrap` | Notes |
|--------------|:---------------:|----------------------------------|
| Claude Code | ✅ | `--memory` · `--code-graph` · `--1m` |
| Claude Code | ✅ | `--memory` · `--code-graph` · `--1m` · `--tool-search` |
| Codex | ✅ | shares memory with Claude |
| Cursor | Manual setup | starts proxy and prints base URLs for Cursor settings |
| Aider | ✅ | starts proxy + launches |
| Copilot CLI | ✅ | starts proxy + launches |
| OpenClaw | ✅ | installs as ContextEngine plugin |
| OpenCode | ✅ | injects config · starts proxy + launches |
| Cline | ✅ | starts proxy + injects config |
| Continue | ✅ | starts proxy + injects config |
| Goose | ✅ | starts proxy + launches |
| OpenHands | ✅ | starts proxy + launches |
| Mistral Vibe | ✅ | starts proxy + launches |
| Cortex Code | ✅ | 6065% savings · library mode |
Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`.
Undo durable wrapping with `headroom unwrap <tool>` (supports: `claude`, `copilot`, `codex`, `opencode`, `openclaw`).
### GitHub Copilot CLI subscription mode
@ -309,6 +316,8 @@ docker pull ghcr.io/chopratejas/headroom:latest
Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-base), `[code]`, `[memory]`, `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
> **Note**: `[all]` covers the core stack but excludes framework adapters. Install them separately: `pip install "headroom-ai[langchain]"` (also `[agno]`, `[strands]`, `[anyllm]`, `[bedrock]`).
Using `pipx`? Choose a supported interpreter explicitly:
```bash
@ -401,7 +410,7 @@ download entirely.
<img src="headroom_learn.gif" alt="headroom learn in action" width="720">
</p>
`headroom learn` — mines failed sessions, writes corrections to `CLAUDE.md` / `AGENTS.md` / `GEMINI.md`.
`headroom learn` — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored; use `--target CLAUDE.md` for the shared team file) / `AGENTS.md` / `GEMINI.md`.
## Documentation
@ -413,6 +422,7 @@ download entirely.
| [Memory](https://headroom-docs.vercel.app/docs/memory) | [Cache optimization](https://headroom-docs.vercel.app/docs/cache-optimization) |
| [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning) | [Benchmarks](https://headroom-docs.vercel.app/docs/benchmarks) |
| [Configuration](https://headroom-docs.vercel.app/docs/configuration) | [Limitations](https://headroom-docs.vercel.app/docs/limitations) |
| [Persistent installs](https://headroom-docs.vercel.app/docs/persistent-installs) (`headroom init` / `headroom install apply`) | [Savings analytics](https://headroom-docs.vercel.app/docs/savings) (`headroom savings` / `headroom perf` / `headroom doctor`) |
## Compared to

View file

@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 0.2.x | :white_check_mark: |
| 0.1.x | :x: |
| 0.27.x (latest) | :white_check_mark: |
| < 0.27.x | :x: |
## Reporting a Vulnerability
@ -46,7 +46,7 @@ When using Headroom:
The following are in scope for security reports:
- Headroom Python package (`pip install headroom-ai`)
- Headroom proxy server
- Official integrations (LangChain, MCP)
- Official integrations (LangChain, Agno, Strands, LiteLLM, Vercel AI SDK, Anthropic/OpenAI SDK wrappers, MCP)
The following are out of scope:
- Third-party integrations not maintained by us

View file

@ -281,7 +281,7 @@ config = SmartCrusherConfig(
<Tab value="Python">
<TypeTable type={{
enabled: { type: 'bool', description: 'Enable/disable cache alignment', default: 'True' },
enabled: { type: 'bool', description: 'Enable/disable cache alignment (off by default)', default: 'False' },
extract_dates: { type: 'bool', description: 'Extract date patterns from system prompt', default: 'True' },
normalize_whitespace: { type: 'bool', description: 'Normalize whitespace for stable prefix', default: 'True' },
stable_prefix_min_tokens: { type: 'int', description: 'Minimum prefix tokens for caching', default: '100' },
@ -475,7 +475,7 @@ tokens = counter.count_messages(messages) # Accurate count via API
### GoogleProvider
```python
from headroom import GoogleProvider
from headroom.providers import GoogleProvider
provider = GoogleProvider(
enable_context_caching=True,

View file

@ -164,7 +164,7 @@ response = client.chat.completions.create(
## Retention
Proxy CCR originals are kept for 300 seconds by default. For longer autonomous
Proxy CCR originals are kept for 1800 seconds (30 minutes) by default. For longer autonomous
agent runs, set `HEADROOM_CCR_TTL_SECONDS` before starting the proxy:
```bash

View file

@ -35,7 +35,7 @@ No `ANTHROPIC_API_KEY` is needed — Foundry mode uses your Azure credentials.
## Run it (one command)
```bash
pip install headroom
pip install headroom-ai
headroom wrap claude
```

View file

@ -24,7 +24,9 @@ daemon reports savings across the operator's projects. Set
`HEADROOM_RTK_GAIN_SCOPE=project` to query `rtk gain --project` from the
proxy process working directory.
## Modes
## SDK Modes (`default_mode` / `headroom_mode`)
These modes apply to SDK usage via `HeadroomClient(default_mode=...)` or per-request `headroom_mode=...`. They are **not** the same as the proxy `--mode` flag.
| Mode | Behavior | Use Case |
|------|----------|----------|
@ -32,6 +34,8 @@ proxy process working directory.
| `optimize` | Applies safe, deterministic transforms | Production optimization |
| `simulate` | Returns plan without API call | Testing, cost estimation |
> **Proxy `--mode` is a separate axis**: `headroom proxy --mode token` (maximize compression) or `--mode cache` (freeze prior turns for prefix-cache stability). The proxy does not accept `audit`, `optimize`, or `simulate`.
## SDK Configuration
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
@ -354,7 +358,7 @@ provider = AnthropicProvider(
</Tab>
<Tab value="Google">
```python
from headroom import GoogleProvider
from headroom.providers import GoogleProvider
provider = GoogleProvider(
enable_context_caching=True,

View file

@ -33,6 +33,7 @@
"strands",
"litellm",
"claude-code-vertex",
"claude-code-azure-foundry",
"opencode",
"mcp",
"---Configuration---",

View file

@ -37,7 +37,7 @@ headroom unwrap opencode
```bash
headroom wrap opencode \
--port 8787 \ # Proxy port. Defaults to a random available port
--port 8787 \ # Proxy port (default: 8787)
--no-rtk \ # Skip RTK context tool injection
--no-mcp \ # Skip Headroom MCP registration
--no-serena \ # Skip Serena code graph MCP
@ -45,7 +45,7 @@ headroom wrap opencode \
--no-proxy \ # Use an existing proxy instead of starting one
--learn \ # Enable memory and live learning
--memory \ # Enable persistent memory
--backend anthropic \ # Backend: anthropic, openai, anyllm
--backend anthropic \ # Backend: anthropic, anyllm, litellm-<provider>
--anyllm-provider ... \ # AnyLLM provider selection
--region ... \ # Provider region
-- <opencode args> # Arguments passed to the opencode binary

View file

@ -10,7 +10,7 @@ When agents hand off to each other, context gets replayed in full. SharedContext
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
<Tab value="TypeScript">
```ts twoslash
import { SharedContext } from "headroom";
import { SharedContext } from "headroom-ai";
const ctx = new SharedContext();
@ -53,7 +53,7 @@ Store content under a key. Compresses automatically using Headroom's full pipeli
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
<Tab value="TypeScript">
```ts twoslash
import { SharedContext } from "headroom";
import { SharedContext } from "headroom-ai";
const ctx = new SharedContext();
// ---cut---
const entry = await ctx.put("findings", bigJsonOutput, {
@ -85,7 +85,7 @@ Retrieve content. Returns the compressed version by default, or the original wit
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
<Tab value="TypeScript">
```ts twoslash
import { SharedContext } from "headroom";
import { SharedContext } from "headroom-ai";
const ctx = new SharedContext();
// ---cut---
const compressed = ctx.get("findings"); // 4K tokens
@ -109,7 +109,7 @@ Aggregated statistics across all entries.
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
<Tab value="TypeScript">
```ts twoslash
import { SharedContext } from "headroom";
import { SharedContext } from "headroom-ai";
const ctx = new SharedContext();
// ---cut---
const stats = ctx.stats();
@ -141,7 +141,7 @@ stats.savings_percent # 80.0
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
<Tab value="TypeScript">
```ts twoslash
import { SharedContext } from "headroom";
import { SharedContext } from "headroom-ai";
// ---cut---
const ctx = new SharedContext({
model: "claude-sonnet-4-5-20250929", // For token counting

View file

@ -29,8 +29,8 @@ plan = client.chat.completions.simulate(
print(f"Tokens before: {plan.tokens_before}")
print(f"Tokens after: {plan.tokens_after}")
print(f"Would save: {plan.tokens_saved} tokens ({plan.savings_percent:.1f}%)")
print(f"Transforms: {plan.transforms_applied}")
print(f"Would save: {plan.tokens_saved} tokens ({plan.tokens_saved/plan.tokens_before*100:.1f}%)")
print(f"Transforms: {plan.transforms}")
```
</Tab>
</Tabs>
@ -114,7 +114,7 @@ if plan.tokens_saved == 0:
print("- No tool outputs with compressible JSON arrays")
print("- Content is already compact (code, grep results)")
else:
print(f"Transforms applied: {plan.transforms_applied}")
print(f"Transforms applied: {plan.transforms}")
# See the optimized messages
print(json.dumps(plan.messages_optimized, indent=2))
```
@ -141,7 +141,7 @@ for config in configs:
)
plan = client.chat.completions.simulate(model="gpt-4o", messages=messages)
print(f"max_items={config.max_items_after_crush}: "
f"{plan.tokens_saved} tokens saved ({plan.savings_percent:.1f}%)")
f"{plan.tokens_saved} tokens saved ({plan.tokens_saved/plan.tokens_before*100:.1f}%)")
```
<Callout type="info" title="No API call">

View file

@ -22,7 +22,7 @@ headroom proxy --port 8788
pip install "headroom-ai[proxy]"
# Run with debug logging
headroom proxy --log-level debug
headroom proxy --log-file ~/.headroom/logs/proxy.jsonl --log-messages
```
### Connection refused when calling proxy
@ -43,7 +43,7 @@ ps aux | grep headroom
```bash
# Check proxy logs for the actual error
headroom proxy --log-level debug
headroom proxy --log-file ~/.headroom/logs/proxy.jsonl --log-messages
# Verify API key is set
echo $OPENAI_API_KEY # or ANTHROPIC_API_KEY

View file

@ -10,12 +10,15 @@ from __future__ import annotations
import base64
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
logger = logging.getLogger(__name__)
SENSITIVE_HEADER_PARTS = ("authorization", "api-key", "apikey", "token", "secret", "cookie")
SENSITIVE_QUERY_PARTS = ("key", "token", "secret", "signature", "code")
MAX_BODY_PREVIEW_CHARS = 1200
@ -168,13 +171,22 @@ def load_capture_file(path: str | Path, *, fallback_lane: str) -> list[CapturedE
exchanges: list[CapturedExchange] = []
capture_path = Path(path)
skipped = 0
for line_number, line in enumerate(capture_path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
record = json.loads(line)
# mitmproxy captures can be truncated mid-write; skip a corrupt line
# rather than aborting the whole diff with a raw JSONDecodeError.
try:
record = json.loads(line)
except json.JSONDecodeError:
skipped += 1
continue
exchanges.append(
exchange_from_record(record, fallback_lane=fallback_lane, sequence=line_number)
)
if skipped:
logger.warning("Skipped %d malformed line(s) in capture file %s", skipped, capture_path)
return exchanges

View file

@ -35,10 +35,10 @@ from .main import main
)
@click.option(
"--hours",
type=float,
type=click.FloatRange(min=0),
default=24.0,
show_default=True,
help="Hours of proxy logs to inspect with --check-perf.",
help="Hours of proxy logs to inspect with --check-perf (0 = all data).",
)
@click.option(
"--accuracy-report",
@ -76,7 +76,10 @@ def agent_savings(
) -> None:
"""Render or verify Codex/Claude/Cursor token-savings settings."""
savings_profile = get_agent_savings_profile(profile)
try:
savings_profile = get_agent_savings_profile(profile)
except ValueError as exc:
raise click.BadParameter(str(exc), param_hint="--profile") from None
if write_smoke_fixture is not None:
eval_path = _write_smoke_fixture(write_smoke_fixture)
click.echo(f"Wrote agent-90 smoke fixture to {write_smoke_fixture}")

View file

@ -356,7 +356,7 @@ def _render(checks: list[CheckResult], port: int, installed: str) -> None:
"--port",
"-p",
default=8787,
type=int,
type=click.IntRange(1, 65535),
envvar="HEADROOM_PORT",
help="Proxy port to check (default: 8787, env: HEADROOM_PORT)",
)

View file

@ -10,14 +10,45 @@ import click
from .main import main
def _parse_categories(categories: str | None) -> list[int] | None:
"""Parse a comma-separated ``--categories`` value into validated ints.
Raises ``click.BadParameter`` (clean usage error, exit 2) instead of
letting a non-numeric or out-of-range token surface as a raw traceback.
"""
if not categories:
return None
parsed: list[int] = []
for token in categories.split(","):
token = token.strip()
if not token:
continue
try:
value = int(token)
except ValueError:
raise click.BadParameter(
f"{token!r} is not an integer; expected comma-separated values 1-5, e.g. 1,2,3",
param_hint="--categories",
) from None
if not 1 <= value <= 5:
raise click.BadParameter(
f"{value} is out of range; categories must be 1-5",
param_hint="--categories",
)
parsed.append(value)
return parsed or None
@main.group()
def evals() -> None:
"""Memory evaluation commands.
"""Evaluation commands (memory, compression robustness, retention).
\b
Examples:
headroom evals memory Run LoCoMo memory evaluation
headroom evals memory-v2 Run V2 evaluation with LLM-controlled tools
headroom evals adversarial Compression-robustness adversarial grid
headroom evals probes Retention probes over recorded sessions
"""
pass
@ -425,9 +456,7 @@ def _run_memory_eval(
import asyncio
# Build configuration
parsed_categories = None
if categories:
parsed_categories = [int(c) for c in categories.split(",")]
parsed_categories = _parse_categories(categories)
memory_config = MemoryConfig()
@ -599,9 +628,7 @@ def _run_memory_eval_v2(
import asyncio
# Build configuration
parsed_categories = None
if categories:
parsed_categories = [int(c) for c in categories.split(",")]
parsed_categories = _parse_categories(categories)
eval_config = MemoryEvalConfigV2(
n_conversations=n_conversations,

View file

@ -38,7 +38,7 @@ from headroom.install.runtime import (
stop_runtime,
wait_ready,
)
from headroom.install.state import load_manifest, save_manifest
from headroom.install.state import ManifestError, load_manifest, save_manifest
from headroom.install.supervisors import start_supervisor
from headroom.providers.claude import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV
from headroom.providers.codex.install import codex_uses_chatgpt_auth
@ -520,7 +520,12 @@ def _ensure_runtime_manifest(
memory: bool,
) -> str:
profile = _runtime_profile(global_scope)
existing = load_manifest(profile)
try:
existing = load_manifest(profile)
except ManifestError as e:
# Recover from a corrupt manifest by overwriting it rather than crashing.
click.echo(f"Warning: {e}; overwriting.")
existing = None
merged_targets = sorted(set(existing.targets if existing else []).union(targets))
manifest = build_manifest(
profile=profile,
@ -688,7 +693,11 @@ def _suppress_hook_output() -> Iterator[None]:
def _ensure_profile_running(profile: str) -> None:
manifest = load_manifest(profile)
# Best-effort hook path: a corrupt manifest must not crash the session.
try:
manifest = load_manifest(profile)
except ManifestError:
return
if manifest is None:
return
with _suppress_hook_output():
@ -901,7 +910,13 @@ def _install_headroom_mcp_for_targets(*, targets: list[str], port: int) -> None:
@main.group(invoke_without_command=True)
@click.option("-g", "--global", "global_scope", is_flag=True, help="Install for the current user.")
@click.option("--port", default=8787, type=int, show_default=True, help="Headroom proxy port.")
@click.option(
"--port",
default=8787,
type=click.IntRange(1, 65535),
show_default=True,
help="Headroom proxy port.",
)
@click.option("--backend", default="anthropic", show_default=True, help="Proxy backend.")
@click.option("--anyllm-provider", default=None, help="Provider for any-llm backends.")
@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
@ -938,6 +953,11 @@ def init(
memory,
ctx.invoked_subcommand,
)
if anyllm_provider and backend != "anyllm":
click.echo(
f"Warning: --anyllm-provider is ignored unless --backend anyllm "
f"(got --backend {backend})."
)
if ctx.invoked_subcommand is not None:
ctx.obj = {
"global_scope": global_scope,
@ -1041,14 +1061,22 @@ def init_hook() -> None:
def init_hook_ensure(profile: str | None, marker: str | None) -> None:
"""Best-effort ensure used by installed agent hooks."""
del marker
def _has_manifest(name: str) -> bool:
# Best-effort: a corrupt manifest must not crash the session-start hook.
try:
return load_manifest(name) is not None
except ManifestError:
return False
profiles: list[str] = []
if profile:
profiles.append(profile)
else:
local_profile = _local_profile()
if load_manifest(local_profile) is not None:
if _has_manifest(local_profile):
profiles.append(local_profile)
elif load_manifest(_GLOBAL_PROFILE) is not None:
elif _has_manifest(_GLOBAL_PROFILE):
profiles.append(_GLOBAL_PROFILE)
for name in profiles:
_ensure_profile_running(name)

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import shutil
import subprocess
from copy import deepcopy
import click
@ -26,7 +28,12 @@ from headroom.install.runtime import (
stop_runtime,
wait_ready,
)
from headroom.install.state import delete_manifest, load_manifest, save_manifest
from headroom.install.state import (
ManifestError,
delete_manifest,
load_manifest,
save_manifest,
)
from headroom.install.supervisors import (
install_supervisor,
remove_supervisor,
@ -43,19 +50,35 @@ def install() -> None:
def _require_manifest(profile: str) -> DeploymentManifest:
manifest = load_manifest(profile)
try:
manifest = load_manifest(profile)
except ManifestError as e:
raise click.ClickException(str(e)) from None
if manifest is None:
raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
return manifest
def _start_deployment(manifest: DeploymentManifest) -> None:
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
start_persistent_docker(manifest)
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
start_supervisor(manifest)
else:
start_detached_agent(manifest.profile)
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value and shutil.which("docker") is None:
raise click.ClickException(
"Docker is required for this deployment but 'docker' was not found on PATH."
)
try:
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
start_persistent_docker(manifest)
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
start_supervisor(manifest)
else:
start_detached_agent(manifest.profile)
except FileNotFoundError as e:
# A required external binary (docker, launchctl, systemctl) is missing.
raise click.ClickException(f"Cannot start deployment '{manifest.profile}': {e}") from None
except subprocess.CalledProcessError as e:
raise click.ClickException(
f"Cannot start deployment '{manifest.profile}': command failed "
f"({' '.join(map(str, e.cmd)) if isinstance(e.cmd, (list, tuple)) else e.cmd})"
) from None
if not wait_ready(manifest, timeout_seconds=45):
raise click.ClickException(
@ -140,7 +163,12 @@ def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
)
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
@click.option(
"--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
"--port",
"-p",
default=8787,
type=click.IntRange(1, 65535),
show_default=True,
help="Persistent proxy port.",
)
@click.option(
"--backend",
@ -193,6 +221,12 @@ def install_apply(
) -> None:
"""Install a persistent Headroom deployment."""
if anyllm_provider and backend != "anyllm":
click.echo(
f"Warning: --anyllm-provider is ignored unless --backend anyllm "
f"(got --backend {backend})."
)
if preset == InstallPreset.PERSISTENT_DOCKER.value:
runtime = RuntimeKind.DOCKER.value
@ -213,7 +247,12 @@ def install_apply(
image=image,
)
existing = load_manifest(profile)
try:
existing = load_manifest(profile)
except ManifestError as e:
# A corrupt existing manifest shouldn't block a fresh apply; overwrite it.
click.echo(f"Warning: {e}; overwriting.")
existing = None
if existing is not None:
click.echo(f"Updating existing deployment profile '{profile}'...")
_remove_deployment(existing)
@ -223,12 +262,16 @@ def install_apply(
manifest.artifacts = install_supervisor(manifest)
save_manifest(manifest)
_start_deployment(manifest)
except Exception:
except Exception as exc:
_remove_deployment(manifest)
if existing is not None:
click.echo(f"Restoring previous deployment '{profile}'...")
_restore_deployment(existing)
raise
# Surface non-Click errors (OSError, CalledProcessError, …) as a clean
# message rather than a raw traceback; Click errors pass through as-is.
if isinstance(exc, (click.ClickException, click.Abort)):
raise
raise click.ClickException(f"Failed to install deployment '{profile}': {exc}") from exc
click.echo(
f"Installed persistent deployment '{profile}' "

View file

@ -101,7 +101,7 @@ Use 'auto' (default) to scan all detected agents."""
@click.option(
"--workers",
"-j",
type=int,
type=click.IntRange(min=1),
default=None,
help="Parallel workers for session scanning. "
"Default: auto (min of CPU count, 8). Use 1 for serial.",
@ -164,11 +164,37 @@ def learn(
from ..learn.analyzer import SessionAnalyzer, _detect_default_model
from ..learn.registry import auto_detect_plugins, get_plugin
# Flag-combination validation — reject contradictory/no-op combinations up
# front rather than letting one flag silently win or be ignored.
if analyze_all and project is not None:
raise click.UsageError("--all and --project are mutually exclusive.")
if llm_judge and not verbosity_mode:
raise click.UsageError("--llm-judge only applies with --verbosity.")
if verbosity_mode and analyze_all and apply:
raise click.UsageError(
"--verbosity persists a single global level, so --all --apply would keep "
"only the last project's level. Re-run with one --project (or drop --apply "
"to preview every project)."
)
max_workers = workers if workers is not None else min(os.cpu_count() or 4, 8)
# Verbosity learning is a distinct flow: it mines behavioral signals (no
# failure analysis) and needs no LLM unless --llm-judge is set.
if verbosity_mode:
ignored = [
flag
for flag, is_set in (
("--target", target is not None),
("--main-only", main_only),
("--workers", workers is not None),
("--model", model is not None and not llm_judge),
)
if is_set
]
if ignored:
verb = "is" if len(ignored) == 1 else "are"
click.echo(f"Note: {', '.join(ignored)} {verb} ignored with --verbosity.")
_run_verbosity(
project=project,
analyze_all=analyze_all,
@ -217,6 +243,10 @@ def learn(
click.echo(f"Note: --target is not supported for {agent_name}; ignoring.")
all_projects = plugin.discover_projects()
if not all_projects:
# An explicitly-selected agent with no data should say so rather than
# exiting silently (the auto path aggregates across agents instead).
if agent != "auto":
click.echo(f"No {plugin.display_name} project data found.")
continue
available_projects.extend((agent_name, proj.project_path) for proj in all_projects)
@ -367,6 +397,41 @@ def _make_llm_judge(model: str) -> Any:
return judge
def _activate_output_shaper(port: int | None = None) -> tuple[str, int]:
"""Best-effort: turn the output shaper ON for a running local proxy.
Writing ``verbosity.json`` is inert on its own the shaper is a live,
off-by-default knob, so the learned level does nothing until
``HEADROOM_OUTPUT_SHAPER`` is enabled in the proxy that serves traffic.
When a proxy is already running locally we hot-enable it via
``/admin/runtime-env`` (no restart, the same channel ``wrap`` uses), so
``--apply`` actually takes effect. Returns ``(status, port)`` where status is
``"live"`` (enabled on a running proxy), ``"absent"`` (no reachable proxy),
or ``"error"``.
"""
import json as _json
import os as _os
import urllib.error
import urllib.request
resolved_port = port if port is not None else int(_os.environ.get("HEADROOM_PORT", "8787"))
request = urllib.request.Request(
f"http://127.0.0.1:{resolved_port}/admin/runtime-env",
data=_json.dumps({"HEADROOM_OUTPUT_SHAPER": "1"}).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=2) as response:
response.read()
return "live", resolved_port
except (urllib.error.URLError, OSError):
# ConnectionRefused (no proxy) or 404 (proxy predates the endpoint).
return "absent", resolved_port
except ValueError:
return "error", resolved_port
def _run_verbosity(
*,
project: Path | None,
@ -468,9 +533,28 @@ def _run_verbosity(
f" [WROTE] {ledger_path} (baseline: {baseline.total_samples} samples, "
f"{len(baseline.strata)} strata)"
)
click.echo(
"\n The output shaper now uses this level when "
"HEADROOM_OUTPUT_SHAPER=1 and HEADROOM_VERBOSITY_LEVEL is unset."
)
# Writing the level is not enough — the shaper is off by default.
# Make --apply actually take effect: hot-enable a running proxy, and
# otherwise tell the user exactly how to turn it on.
status, shaper_port = _activate_output_shaper()
if status == "live":
click.echo(
f"\n ✓ Output shaper enabled on the running proxy (port {shaper_port}); "
f"level {profile.level} is live now (while HEADROOM_VERBOSITY_LEVEL is unset)."
)
click.echo(
" To keep it on across restarts: export HEADROOM_OUTPUT_SHAPER=1 "
"before `headroom wrap ...` (wrap pushes it to the proxy)."
)
else:
click.echo(
"\n ⚠ Level written, but the output shaper is OFF by default — it is "
"NOT shaping output yet."
)
click.echo(
" Enable it: export HEADROOM_OUTPUT_SHAPER=1 then `headroom wrap ...` "
"(or start `headroom proxy` with it set). The learned level is then used "
"automatically while HEADROOM_VERBOSITY_LEVEL is unset."
)
else:
click.echo("\n Dry run — use --apply to persist the level and baseline.")

View file

@ -287,6 +287,10 @@ def mcp_status() -> None:
click.echo(" Run: headroom proxy")
except httpx.TimeoutException:
click.echo("Proxy Status: ✗ Timeout")
except httpx.HTTPError as e:
# Catch the rest (InvalidURL, UnsupportedProtocol, ProtocolError, …)
# so a malformed configured HEADROOM_PROXY_URL can't crash `status`.
click.echo(f"Proxy Status: ✗ Unreachable ({proxy_url}: {e})")
except ImportError:
click.echo("Proxy Status: ? (httpx not installed)")

View file

@ -30,14 +30,33 @@ from ._utils.parsers import parse_duration
from .main import main
def _default_db_path() -> str:
"""Resolve the memory DB the proxy/install actually use.
Prefer the project-scoped store the proxy writes when run inside a project
(``./.headroom/memory.db``); otherwise fall back to the global workspace
store (``~/.headroom/memory.db``). The previous bare ``headroom_memory.db``
default pointed at neither, so ``headroom memory list`` silently read an
empty/legacy DB.
"""
project_db = Path.cwd() / ".headroom" / "memory.db"
if project_db.exists():
return str(project_db)
from ..paths import memory_db_path
return str(memory_db_path())
def db_path_option(fn: Any) -> Any:
"""Shared --db-path option for memory commands."""
return click.option(
"--db-path",
type=click.Path(),
default="headroom_memory.db",
help="Path to the memory database file.",
show_default=True,
default=_default_db_path,
help="Path to the memory database file. Defaults to the project store "
"(./.headroom/memory.db) if present, else the global store "
"(~/.headroom/memory.db).",
show_default="project store if present, else global store",
)(fn)
@ -245,7 +264,13 @@ def memory(ctx: click.Context) -> None:
@memory.command("list")
@db_path_option
@click.option("--limit", "-n", type=int, default=50, help="Maximum number of memories to show.")
@click.option(
"--limit",
"-n",
type=click.IntRange(min=1),
default=50,
help="Maximum number of memories to show.",
)
@click.option("--session", "-s", "session_id", type=str, help="Filter by session ID.")
@click.option(
"--scope",
@ -283,8 +308,29 @@ def list_memories(
try:
if search_query:
# Use text search
memories = _search_content(store, search_query, limit=limit)
# Content search is a SQL LIKE that ignores structured filters; apply
# --scope/--session/--since to the results so combining them with
# --search isn't a silent no-op. Fetch a generous set before
# filtering (so the limit isn't consumed by rows we'll drop), then
# truncate to the requested limit.
has_filters = bool(scope or session_id or since_duration)
memories = _search_content(
store, search_query, limit=max(limit, 1000) if has_filters else limit
)
if scope:
memories = [m for m in memories if get_scope_label(m) == scope.upper()]
if session_id:
memories = [m for m in memories if m.session_id == session_id]
if since_duration:
duration = parse_duration(since_duration)
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - duration
memories = [
m
for m in memories
if (m.created_at.replace(tzinfo=None) if m.created_at.tzinfo else m.created_at)
>= cutoff
]
memories = memories[:limit]
else:
# Build filter
filter_kwargs: dict[str, Any] = {

View file

@ -12,9 +12,9 @@ from .main import main
@main.command()
@click.option(
"--hours",
type=float,
type=click.FloatRange(min=0),
default=168.0,
help="Analyze logs from the last N hours (default: 168 = 7 days)",
help="Analyze logs from the last N hours (default: 168 = 7 days; 0 = all data)",
)
@click.option("--raw", is_flag=True, help="Show raw PERF records instead of report")
@click.option(

View file

@ -83,12 +83,22 @@ def _get_env_bool_optional(name: str) -> bool | None:
def _get_env_int_optional(name: str) -> int | None:
val = os.environ.get(name)
return int(val) if val is not None and val != "" else None
if val is None or val == "":
return None
try:
return int(val)
except ValueError:
raise click.ClickException(f"{name} must be an integer, got {val!r}") from None
def _get_env_float_optional(name: str) -> float | None:
val = os.environ.get(name)
return float(val) if val is not None and val != "" else None
if val is None or val == "":
return None
try:
return float(val)
except ValueError:
raise click.ClickException(f"{name} must be a number, got {val!r}") from None
def _selected_context_tool() -> str:
@ -109,7 +119,7 @@ def _selected_context_tool() -> str:
"--port",
"-p",
default=8787,
type=int,
type=click.IntRange(1, 65535),
envvar="HEADROOM_PORT",
help="Proxy port (default: 8787, env: HEADROOM_PORT)",
)
@ -141,7 +151,7 @@ def dashboard(port: int, no_open: bool) -> None:
"--port",
"-p",
default=8787,
type=int,
type=click.IntRange(1, 65535),
envvar="HEADROOM_PORT",
help="Port to bind to (default: 8787, env: HEADROOM_PORT)",
)
@ -547,7 +557,7 @@ def dashboard(port: int, no_open: bool) -> None:
)
@click.option(
"--read-maturation-quiesce-turns",
type=int,
type=click.IntRange(min=1),
default=5,
show_default=True,
envvar="HEADROOM_READ_MATURATION_QUIESCE_TURNS",
@ -555,7 +565,7 @@ def dashboard(port: int, no_open: bool) -> None:
)
@click.option(
"--read-maturation-max-hold-turns",
type=int,
type=click.IntRange(min=1),
default=25,
show_default=True,
envvar="HEADROOM_READ_MATURATION_MAX_HOLD_TURNS",
@ -563,7 +573,7 @@ def dashboard(port: int, no_open: bool) -> None:
)
@click.option(
"--read-maturation-min-size-bytes",
type=int,
type=click.IntRange(min=0),
default=2048,
show_default=True,
envvar="HEADROOM_READ_MATURATION_MIN_SIZE_BYTES",
@ -662,7 +672,7 @@ def dashboard(port: int, no_open: bool) -> None:
)
@click.option(
"--memory-qdrant-port",
type=int,
type=click.IntRange(1, 65535),
default=None,
help=(
"Qdrant port for the qdrant-neo4j backend (default: 6333, also reads HEADROOM_QDRANT_PORT)"
@ -688,7 +698,7 @@ def dashboard(port: int, no_open: bool) -> None:
)
@click.option(
"--min-evidence",
type=int,
type=click.IntRange(min=1),
default=None,
envvar="HEADROOM_MIN_EVIDENCE",
help=(
@ -924,6 +934,28 @@ def proxy(
err=True,
)
# Warn on contradictory / no-op flag combinations. The resolved value still
# applies; the warning just prevents a silently-ignored flag.
if no_rate_limit and (rpm is not None or tpm is not None):
click.secho(
"Warning: --rpm/--tpm have no effect because --no-rate-limit disables rate limiting.",
fg="yellow",
err=True,
)
if no_optimize and target_ratio is not None:
click.secho(
"Warning: --target-ratio has no effect because --no-optimize disables compression.",
fg="yellow",
err=True,
)
if telemetry and no_telemetry:
click.secho(
"Warning: both --telemetry and --no-telemetry were specified; --no-telemetry "
"takes precedence and telemetry will be disabled.",
fg="yellow",
err=True,
)
# Opt-in: turn on tool_result interceptors (ast-grep Read outline, etc.).
# Only fetch the bundled CLI tool binaries when the feature is enabled —
# otherwise we'd pay a network round-trip and risk a readonly-FS failure
@ -1258,8 +1290,8 @@ Memory (Multi-Provider):
context_tool_line = f" Context Tool: {_selected_context_tool()}"
# Performance tuning section — only shown when at least one tuning var is active.
_stable_turn = int(os.environ.get("HEADROOM_COMPRESSION_STABLE_AFTER_TURN", "0"))
_stale_turns = int(os.environ.get("HEADROOM_STALE_READ_COMPRESS_AFTER_TURNS", "0"))
_stable_turn = _get_env_int_optional("HEADROOM_COMPRESSION_STABLE_AFTER_TURN") or 0
_stale_turns = _get_env_int_optional("HEADROOM_STALE_READ_COMPRESS_AFTER_TURNS") or 0
_embed_socket = os.environ.get("HEADROOM_EMBEDDING_SERVER_SOCKET") or (
embedding_server and (embedding_server_socket or f"/tmp/headroom-embed-{port}.sock")
)

View file

@ -49,7 +49,7 @@ def _window_line(label: str, window: dict[str, Any]) -> str:
@click.option("--json", "as_json", is_flag=True, help="Emit the raw report as JSON.")
@click.option(
"--days",
type=int,
type=click.IntRange(min=1),
default=savings_ledger.DEFAULT_RETENTION_DAYS,
show_default=True,
help="Retention/lookback window for the ledger, in days.",

View file

@ -2569,6 +2569,13 @@ def _ensure_proxy(
) -> subprocess.Popen | None:
"""Start or verify proxy. Returns process handle if we started it."""
helpers = _live_wrap_module()
# --no-proxy reuses an already-running proxy, so backend/region/provider
# flags (which only apply when we start one) would be silently dropped.
if no_proxy and (backend or anyllm_provider or region):
click.echo(
" Warning: --backend/--region/--anyllm-provider have no effect with --no-proxy "
"(reusing the existing proxy)."
)
if not no_proxy:
manifest = helpers._find_persistent_manifest(port)
if manifest is not None:
@ -3244,7 +3251,9 @@ def unwrap() -> None:
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -3588,7 +3597,9 @@ def claude(
@unwrap.command("claude")
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
@click.option("--keep-mcp", is_flag=True, help="Keep Headroom MCP registrations")
@click.option("--keep-rtk", is_flag=True, help="Keep rtk Claude hooks")
@ -3659,7 +3670,9 @@ def unwrap_claude(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -3946,7 +3959,9 @@ def copilot(
@unwrap.command("copilot")
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
def unwrap_copilot(port: int, no_stop_proxy: bool) -> None:
"""Undo durable setup from ``headroom wrap copilot``."""
@ -3966,7 +3981,9 @@ def unwrap_copilot(port: int, no_stop_proxy: bool) -> None:
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4197,7 +4214,9 @@ def codex(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4299,7 +4318,9 @@ def aider(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4376,7 +4397,9 @@ def vibe(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4460,7 +4483,9 @@ def cursor(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4560,7 +4585,9 @@ def cline(
@wrap.command("continue", context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4682,7 +4709,9 @@ def continue_dev(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4805,7 +4834,9 @@ def goose(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -4968,7 +4999,9 @@ def openhands(
is_flag=True,
help="Install by copying plugin path instead of using --link",
)
@click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
@click.option(
"--proxy-port", default=8787, type=click.IntRange(1, 65535), help="Headroom proxy port"
)
@click.option("--startup-timeout-ms", default=20000, type=int, help="Proxy startup timeout")
@click.option(
"--gateway-provider-id",
@ -5182,7 +5215,9 @@ def openclaw(
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option(
"--no-context-tool",
"--no-rtk",
@ -5344,7 +5379,9 @@ def _opencode_home_dir() -> Path:
@unwrap.command("opencode")
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
def unwrap_opencode(port: int, no_stop_proxy: bool) -> None:
"""Undo ``headroom wrap opencode`` edits to the active OpenCode config file.
@ -5420,7 +5457,9 @@ def unwrap_opencode(port: int, no_stop_proxy: bool) -> None:
@unwrap.command("openclaw")
@click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
@click.option(
"--proxy-port", default=8787, type=click.IntRange(1, 65535), help="Headroom proxy port"
)
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
@click.option("--no-restart", is_flag=True, help="Do not restart OpenClaw gateway at the end")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@ -5499,7 +5538,9 @@ def unwrap_openclaw(
@unwrap.command("codex")
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
)
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
"""Undo ``headroom wrap codex`` edits to the active Codex config file.

View file

@ -13,6 +13,10 @@ from .paths import deploy_root, manifest_path, profile_root
logger = logging.getLogger(__name__)
class ManifestError(Exception):
"""A deployment manifest exists on disk but could not be parsed."""
def save_manifest(manifest: DeploymentManifest) -> None:
"""Persist a deployment manifest to disk.
@ -35,10 +39,17 @@ def load_manifest(profile: str = "default") -> DeploymentManifest | None:
path = manifest_path(profile)
if not path.exists():
return None
payload = json.loads(path.read_text(encoding="utf-8"))
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
return DeploymentManifest(**payload)
# A present-but-corrupt manifest (partial write, hand-edit, schema drift)
# must not crash callers with a raw traceback — every install lifecycle
# command and the auto-run `init hook ensure` route through here. Raise a
# typed error so callers can report cleanly or degrade gracefully.
try:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
return DeploymentManifest(**payload)
except (json.JSONDecodeError, ValueError, TypeError, OSError) as e:
raise ManifestError(f"deployment profile '{profile}' is corrupt ({path}): {e}") from e
def list_manifests() -> list[DeploymentManifest]:

View file

@ -210,6 +210,10 @@ class PerfReport:
oldest_kept_ts: str | None = None
newest_kept_ts: str | None = None
records_filtered_out: int = 0
# True when no time cutoff was applied (--hours 0, or a value so large it
# overflows datetime arithmetic). The header says "all data" instead of a
# misleading "last 0h".
window_all_data: bool = False
# Log timestamps are emitted by Python's `logging` formatter as
@ -249,7 +253,17 @@ def parse_log_files(last_n_hours: float = 168.0) -> PerfReport:
if not log_dir.exists():
return report
cutoff = datetime.now() - timedelta(hours=last_n_hours) if last_n_hours > 0 else None
# A huge --hours value (e.g. 1e9) overflows datetime arithmetic. Since
# "look back a billion hours" is effectively "all data", treat overflow as
# no cutoff rather than crashing with a raw OverflowError traceback.
if last_n_hours > 0:
try:
cutoff: datetime | None = datetime.now() - timedelta(hours=last_n_hours)
except OverflowError:
cutoff = None
else:
cutoff = None
report.window_all_data = cutoff is None
def _within_window(ts_str: str | None) -> bool:
# Fail-open: records without a parseable timestamp are kept. The
@ -498,14 +512,15 @@ def format_report(report: PerfReport) -> str:
lines.append("Headroom Performance Report")
lines.append("=" * 60)
if report.requested_hours is not None:
window_label = "all data" if report.window_all_data else f"last {report.requested_hours:g}h"
if report.oldest_kept_ts and report.newest_kept_ts:
window_str = (
f"Window: last {report.requested_hours:g}h "
f"Window: {window_label} "
f"(actual data: {report.oldest_kept_ts[:19]}"
f"{report.newest_kept_ts[:19]})"
)
else:
window_str = f"Window: last {report.requested_hours:g}h (no records found in window)"
window_str = f"Window: {window_label} (no records found in window)"
lines.append(window_str)
if report.records_filtered_out > 0:
lines.append(

View file

@ -21,7 +21,7 @@ The canonical, always-current documentation index lives at the docs site below.
- TypeScript / Node: `npm install headroom-ai` (or `pnpm add headroom-ai`, `bun add headroom-ai`)
- Docker: `docker run -p 8787:8787 ghcr.io/chopratejas/headroom:latest`
- Run the proxy: `headroom proxy --port 8787` then point any client at `http://127.0.0.1:8787`
- Wrap an agent in one command: `headroom wrap claude` (also: `codex`, `cursor`, `aider`, `copilot`, `opencode`)
- Wrap an agent in one command: `headroom wrap claude` (also: `codex`, `copilot`, `cursor`, `aider`, `opencode`, `cline`, `continue`, `goose`, `openhands`, `openclaw`, `vibe`)
## Entry points
@ -53,7 +53,7 @@ The canonical, always-current documentation index lives at the docs site below.
- [Persistent memory](https://headroom-docs.vercel.app/docs/memory): Per-project SQLite + HNSW vector store. No cross-project bleed (GH #462).
- [SharedContext](https://headroom-docs.vercel.app/docs/shared-context): Compressed inter-agent context handoffs.
- [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning): Offline analysis writes corrections to `CLAUDE.md` / `AGENTS.md`.
- [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning): Offline analysis writes corrections to `CLAUDE.local.md` (default, gitignored) or `CLAUDE.md` (shared) / `AGENTS.md` / `GEMINI.md`.
## Operations
@ -64,4 +64,4 @@ The canonical, always-current documentation index lives at the docs site below.
## Licensing
Apache 2.0. Use commercially, modify, redistribute. Data stays on the user's machine when running the library, proxy, or MCP server locally. Anonymous telemetry enabled by default; set `HEADROOM_TELEMETRY=off` to disable.
Apache 2.0. Use commercially, modify, redistribute. Data stays on the user's machine when running the library, proxy, or MCP server locally. Anonymous telemetry is **off by default** (opt-in); enable with `HEADROOM_TELEMETRY=on` or `headroom proxy --telemetry`.

View file

@ -319,6 +319,15 @@ def test_install_apply_uses_docker_runtime_for_persistent_docker(monkeypatch) ->
monkeypatch.setattr(
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
)
# _start_deployment guards the persistent-docker preset with
# `shutil.which("docker")`. Fake docker as present so the test exercises the
# runtime-selection path itself rather than the host's docker install —
# otherwise it passes on dev machines with Docker but fails on CI runners
# (e.g. macos-latest) that have no docker on PATH.
monkeypatch.setattr(
"headroom.cli.install.shutil.which",
lambda name, *args, **kwargs: "/usr/local/bin/docker" if name == "docker" else None,
)
result = runner.invoke(main, ["install", "apply", "--preset", "persistent-docker"])

View file

@ -255,9 +255,9 @@ headroom proxy --mode cache
| `--no-code-aware` | off | Disable AST-aware code compression |
| `--code-aware` | off | Enable code-aware compression in the proxy (env: HEADROOM_CODE_AWARE_ENABLED) |
| `--no-read-lifecycle` | off | Disable stale/superseded read compression |
| `--no-intelligent-context` | off | Disable intelligent context manager |
| `--no-intelligent-scoring` | off | Disable multi-factor importance scoring |
| `--no-compress-first` | off | Disable deep compression before dropping messages |
| `--no-ccr-inject-tool` | off | Disable injecting the `headroom_retrieve` tool |
| `--no-ccr-marker` | off | Disable adding retrieval markers to compressed output |
| `--no-ccr-proactive-expansion` | off | Disable proactive CCR context expansion |
| `--memory` | off | Enable persistent user memory |
| `--memory-db-path` | `""` | Override memory DB path (help text: `{cwd}/.headroom/memory.db`) |
| `--no-memory-tools` | off | Disable automatic memory tool injection |
@ -406,7 +406,7 @@ headroom memory list -q "budget"
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--limit`, `-n` | `50` | Maximum memories to show |
| `--session`, `-s` | unset | Filter by session ID |
| `--scope` | unset | `USER`, `SESSION`, `AGENT`, or `TURN` |
@ -423,7 +423,7 @@ headroom memory show 1234abcd --json
| Argument / option | Default | Meaning |
|---|---|---|
| `memory_id` | required | Full or partial memory ID |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--json` | off | Emit raw JSON |
### `headroom memory stats`
@ -434,7 +434,7 @@ headroom memory stats
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
### `headroom memory edit <memory_id>`
@ -446,7 +446,7 @@ headroom memory edit 1234abcd --importance 0.9
| Argument / option | Default | Meaning |
|---|---|---|
| `memory_id` | required | Full or partial memory ID |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--content`, `-c` | unset | New memory content |
| `--importance`, `-i` | unset | New importance score (`0.0` to `1.0`) |
@ -462,7 +462,7 @@ headroom memory delete 1234abcd --force
| Argument / option | Default | Meaning |
|---|---|---|
| `memory_ids...` | required | One or more memory IDs |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--force`, `-f` | off | Skip confirmation |
### `headroom memory prune`
@ -474,7 +474,7 @@ headroom memory prune --scope SESSION --force
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--older-than` | unset | Age threshold |
| `--scope` | unset | Scope filter: `USER`, `SESSION`, `AGENT`, `TURN` |
| `--low-importance` | unset | Importance cutoff |
@ -492,7 +492,7 @@ headroom memory purge --confirm
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--confirm` | off | Required confirmation flag |
### `headroom memory export`
@ -504,7 +504,7 @@ headroom memory export --output export.json
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--output`, `-o` | stdout | Output path |
### `headroom memory import <file>`
@ -517,7 +517,7 @@ headroom memory import export.json --force
| Argument / option | Default | Meaning |
|---|---|---|
| `file` | required | JSON file containing exported memories |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--db-path` | `./.headroom/memory.db` if present, else `~/.headroom/memory.db` | Memory database path |
| `--force`, `-f` | off | Skip confirmation |
The import expects a JSON array. Malformed entries are skipped.

View file

@ -53,11 +53,14 @@ headroom proxy --no-optimize
# Disable semantic caching
headroom proxy --no-cache
# Disable CCR response handling
headroom proxy --no-ccr-responses
# Disable CCR tool injection
headroom proxy --no-ccr-inject-tool
# Disable proactive expansion
headroom proxy --no-ccr-expansion
# Disable CCR retrieval markers
headroom proxy --no-ccr-marker
# Disable proactive CCR expansion
headroom proxy --no-ccr-proactive-expansion
# (The earlier --llmlingua flag was retired in 0.9.x and replaced by
# Kompress (ModernBERT). See `wiki/transforms.md` for the current

View file

@ -8,9 +8,12 @@ Offline failure learning for coding agents. Analyzes past conversations, finds w
# See recommendations for current project (dry-run, no changes)
headroom learn
# Write recommendations to CLAUDE.md and MEMORY.md
# Write recommendations to CLAUDE.local.md (gitignored, personal default)
headroom learn --apply
# Write to the shared team file instead
headroom learn --apply --target CLAUDE.md
# Analyze a specific project
headroom learn --project ~/my-project --apply
@ -99,10 +102,10 @@ Commands repeatedly rejected — model should suggest them to the user instead.
| Pattern | Claude Code | Codex | Gemini CLI |
|---------|-------------|-------|-----------|
| Environment, paths, commands | **CLAUDE.md** | **AGENTS.md** | **GEMINI.md** |
| Environment, paths, commands | **CLAUDE.local.md** (default) or `CLAUDE.md` (with `--target CLAUDE.md`) | **AGENTS.md** | **GEMINI.md** |
| Retry patterns, permissions | **MEMORY.md** | **instructions.md** | **GEMINI.md** |
Output files are agent-native: Claude Code uses CLAUDE.md/MEMORY.md, Codex uses AGENTS.md, Gemini uses GEMINI.md. The same learnings, written to the format each agent reads.
Output files are agent-native: Claude Code writes to `CLAUDE.local.md` by default (gitignored, personal); pass `--target CLAUDE.md` for the shared team file. Codex uses `AGENTS.md`, Gemini uses `GEMINI.md`. The same learnings, written to the format each agent reads.
## Marker-Based Updates
@ -150,13 +153,33 @@ headroom learn [OPTIONS]
Options:
--project PATH Project directory (default: current directory)
--all Analyze all discovered projects
--all Analyze all discovered projects (mutually exclusive with --project)
--apply Write recommendations (default: dry-run)
--target TEXT Context file to write (default: CLAUDE.local.md for Claude Code)
--main-only Write only to the main context file, skip MEMORY.md
--agent [auto|claude|codex|gemini]
Which agent to analyze (default: auto-detect)
--model TEXT LLM for analysis (default: auto from API keys or CLI)
--workers / -j INTEGER Parallel analysis workers (min 1, default: auto)
--verbosity Analyze verbosity level instead of failure patterns
--llm-judge Use an LLM to score verbosity quality (requires --verbosity)
```
### Verbosity learning (`--verbosity`)
`headroom learn --verbosity` analyzes past sessions to infer the ideal output verbosity level for your project and writes a `verbosity.json` profile.
**Important**: the output shaper is **off by default**. Running `--verbosity --apply` will either:
- Hot-enable the output shaper on a running proxy (`POST /admin/runtime-env`), OR
- Print instructions to set `HEADROOM_OUTPUT_SHAPER=1` before `headroom wrap ...`
To keep the shaper on across proxy restarts, add `export HEADROOM_OUTPUT_SHAPER=1` to your shell profile before starting the proxy.
**Flag interactions**:
- `--all` and `--project` are mutually exclusive
- `--llm-judge` requires `--verbosity`
- `--verbosity --all --apply` is rejected (verbosity persists a single global level)
### Supported Agents
| Agent | Scanner | Writer | Output Files |

View file

@ -410,8 +410,8 @@ DEBUG:headroom.transforms.smart_crusher:Kept items: [0,1,2,42,77,97,98,99] (erro
# Log to file
headroom proxy --log-file headroom.jsonl
# Increase verbosity
headroom proxy --log-level debug
# Enable request logging
headroom proxy --log-messages
```
## Grafana Dashboard

View file

@ -102,23 +102,15 @@ Legacy values (`token_headroom`, `cost_savings`) are still accepted as aliases.
### Context Management Options
| Option | Default | Description |
|--------|---------|-------------|
| `--no-intelligent-context` | `false` | Disable IntelligentContextManager (fall back to RollingWindow) |
| `--no-intelligent-scoring` | `false` | Disable multi-factor importance scoring (use position-based) |
| `--no-compress-first` | `false` | Disable trying deeper compression before dropping messages |
Context management in the proxy is handled automatically by the compression pipeline. CCR (Compress-Cache-Retrieve) ensures that when content is compressed or messages are dropped, the original data remains accessible for the LLM to retrieve on demand. See [CCR documentation](ccr.md) for details.
By default, the proxy uses **IntelligentContextManager** which scores messages by multiple factors (recency, semantic similarity, TOIN-learned patterns, error indicators, forward references) and drops lowest-scored messages first. This is smarter than simple age-based truncation.
Key CCR-related proxy flags:
**CCR Integration:** When messages are dropped, they're stored in CCR so the LLM can retrieve them if needed. The inserted marker includes the CCR reference. Drops are also recorded to TOIN, so the system learns which message patterns are important across all users.
```bash
# Use legacy RollingWindow (drops oldest first)
headroom proxy --no-intelligent-context
# Disable semantic scoring (faster, but less intelligent)
headroom proxy --no-intelligent-scoring
```
| Option | Description |
|--------|-------------|
| `--no-ccr-inject-tool` | Do not inject the `headroom_retrieve` tool into the LLM's available tools |
| `--no-ccr-marker` | Do not add retrieval markers to compressed output |
| `--no-ccr-proactive-expansion` | Disable proactive context expansion before the LLM asks |
### ML Compression — RETIRED `--llmlingua` flag

View file

@ -319,7 +319,7 @@ response = client.chat.completions.create(
## Next Steps
- **[Configuration Reference](api.md)** - All configuration options
- **[Configuration Reference](configuration.md)** - All configuration options
- **[Transform Reference](transforms.md)** - How each transform works
- **[Troubleshooting](troubleshooting.md)** - Common issues and solutions
- **[Examples](../examples/)** - More complete examples

View file

@ -23,8 +23,8 @@ headroom proxy --port 8788
# 3. Check for missing dependencies
pip install "headroom-ai[proxy]"
# 4. Run with debug logging
headroom proxy --log-level debug
# 4. Run with request logging
headroom proxy --log-file ~/.headroom/logs/proxy.jsonl --log-messages
```
### "Connection refused" when calling proxy
@ -69,7 +69,7 @@ Alternatively, restarting the proxy process clears the in-memory tracker. See [S
```bash
# 1. Check proxy logs for the actual error
headroom proxy --log-level debug
headroom proxy --log-file ~/.headroom/logs/proxy.jsonl --log-messages
# 2. Verify API key is set
echo $OPENAI_API_KEY # or ANTHROPIC_API_KEY