diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index 00c950b8b6..255a8f5250 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.30 (16-07-2026) + +- Compress large tool outputs automatically in every Claude Code session with a managed `PostToolUse` hook (new `headroom_auto_compress` option, enabled by default). Desktop-spawned sessions (cowork/dispatch) pin `ANTHROPIC_BASE_URL` to the production endpoint (headroom #869), so the transparent proxy never sees their traffic and compression there depended entirely on the model remembering to call the `headroom` MCP tools per the CLAUDE.md guidance — in practice most large outputs went uncompressed. The new `/usr/local/bin/headroom-posttooluse-compress.py` hook fires on `Bash`/`Grep`/`Glob`/`WebFetch` results over ~4000 characters, compresses them with Headroom's rule-based pipeline (SmartCrusher and friends; the Kompress ML path is disabled because its background model load can never complete inside a short-lived hook process), and swaps the result in via `hookSpecificOutput.updatedToolOutput` with a retrieval marker appended. Originals are stored in the shared CCR SQLite store (`~/.headroom/ccr_store.db` — the same one the headroom MCP server reads), so `mcp__headroom__headroom_retrieve` always recovers the full output; savings are recorded to the durable ledger (client `posttooluse-hook`) and show up in the existing gains report. The hook fails open (any error leaves the tool output untouched), never touches `stderr` fields so error text reaches the model verbatim, skips anything below a 50-token savings floor, and is registered idempotently in `~/.claude/settings.json` only after a `--self-test` confirms the interpreter can import headroom; disabling the option (or Headroom) removes the managed entry without touching user-defined hooks. Measured on a representative Home Assistant `states` dump: 10781 -> 2964 tokens (73% saved) at ~1.7 s hook overhead, with sub-100 ms pass-through for small outputs. + ## 1.29 (16-07-2026) - Point the Headroom MCP server at the persistent Kompress model cache. 1.27 set `HF_HOME` on the `svc-headroom` proxy longrun only, but the MCP server is a separate process spawned by Claude Desktop / Claude Code from the registered `mcpServers` entry, so it never inherited that export and kept resolving the HuggingFace cache to `~/.cache` — symlinked to tmpfs here and wiped on every restart. Its Kompress ML path therefore never found the model, re-downloaded ~270 MB into tmpfs on each boot, and lost it again on the next one; `headroom_compress` fell back to `router:noop` (unchanged output) on prose and other unstructured content. The managed `headroom` entry in both `claude_desktop_config.json` and `~/.claude.json` now carries `env.HF_HOME` pointing at the same `~/.headroom/hf` cache the proxy warms. Rule-based compression (SmartCrusher, structured tool output) was unaffected and worked throughout. diff --git a/claude_desktop/README.md b/claude_desktop/README.md index 6e2e59c5be..5468d5cb7b 100644 --- a/claude_desktop/README.md +++ b/claude_desktop/README.md @@ -99,6 +99,7 @@ Git synchronization hooks. A repository is indexed only when it is listed in | `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. | | `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. | | `headroom_wrap_claude_code` | `true` | Route PATH-based Claude Code launches through the already-running Headroom proxy. | +| `headroom_auto_compress` | `true` | Auto-compress large tool outputs in every Claude Code session via a managed `PostToolUse` hook. | | `expose_headroom_dashboard` | `false` | Bind Headroom to all interfaces. Port `8787/tcp` must also be mapped manually. | | `install_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. | | `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. | @@ -175,6 +176,16 @@ the MCP integration. The `/usr/local/bin/claude` wrapper routes PATH-based Claud Code sessions through `headroom wrap claude --no-proxy`, reusing the supervised backend without starting a second proxy. +With `headroom_auto_compress` enabled (the default), a managed Claude Code +`PostToolUse` hook additionally compresses large `Bash`/`Grep`/`Glob`/`WebFetch` +outputs (over ~4000 characters) in **every** session type — terminal, Desktop +cowork, dispatch, and cron — without the model having to remember to call the +MCP tools. The original output is kept in Headroom's local store for one hour +and can always be recovered with `mcp__headroom__headroom_retrieve` using the +hash printed in the compression marker. Error text (`stderr`) is never +compressed, and plain prose passes through unchanged; the savings come from +structured output such as JSON dumps, search results, and logs. + The dashboard is disabled externally by default. To expose it: 1. Set `expose_headroom_dashboard: true`. diff --git a/claude_desktop/config.yaml b/claude_desktop/config.yaml index bbfd56b2da..52b55e8ab4 100644 --- a/claude_desktop/config.yaml +++ b/claude_desktop/config.yaml @@ -49,6 +49,7 @@ options: github_username: "" enable_tools_health_report: true expose_headroom_dashboard: false + headroom_auto_compress: true headroom_wrap_claude_code: true install_caveman: false install_github_cli: true @@ -96,6 +97,7 @@ schema: github_username: str? enable_tools_health_report: bool expose_headroom_dashboard: bool + headroom_auto_compress: bool? headroom_wrap_claude_code: bool install_caveman: bool install_github_cli: bool @@ -109,5 +111,5 @@ slug: claude_desktop tmpfs: true udev: true url: https://github.com/alexbelgium/hassio-addons -version: "1.29" +version: "1.30" video: true diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index 4cf1a023b2..9c18e3995f 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -385,6 +385,92 @@ if changed: path.write_text(json.dumps(data, indent=2) + "\n") PY +# Compress large tool outputs automatically in every Claude Code session via a managed +# PostToolUse hook (settings.json hooks apply to terminal, cowork, dispatch and cron sessions +# alike). Desktop-spawned sessions pin ANTHROPIC_BASE_URL to the production endpoint +# (headroom #869) so the proxy never sees their traffic, and the CLAUDE.md guidance above only +# helps when the model remembers to call the MCP tools. The hook closes that gap: outputs over +# ~4000 chars from Bash/Grep/Glob/WebFetch are compressed with Headroom's rule-based pipeline +# and swapped in through hookSpecificOutput.updatedToolOutput, with the original kept in the +# shared CCR store so the model can fetch it back with mcp__headroom__headroom_retrieve. The +# script fails open (any error leaves the tool output untouched) and its --self-test gate +# keeps a broken interpreter path from registering a hook that would warn on every tool call. +HEADROOM_HOOK_CMD="/usr/local/bin/headroom-posttooluse-compress.py" +HEADROOM_HOOK_ACTION="remove" +if $HEADROOM_ENABLED && bashio::config.true 'headroom_auto_compress'; then + if run_as_runtime_user "$HEADROOM_HOOK_CMD" --self-test; then + HEADROOM_HOOK_ACTION="add" + bashio::log.info "Registering the Headroom PostToolUse auto-compression hook" + else + bashio::log.warning "headroom-posttooluse-compress.py --self-test failed; not registering the auto-compression hook" + fi +fi +HEADROOM_HOOK_ACTION="$HEADROOM_HOOK_ACTION" HEADROOM_HOOK_CMD="$HEADROOM_HOOK_CMD" \ + HEADROOM_HOOK_MATCHER="Bash|Grep|Glob|WebFetch" \ + python3 - <<'PY' || bashio::log.warning "Unable to manage the Headroom auto-compression hook" +import json +import os +from pathlib import Path + +action = os.environ["HEADROOM_HOOK_ACTION"] +command = os.environ["HEADROOM_HOOK_CMD"] +matcher = os.environ["HEADROOM_HOOK_MATCHER"] + +path = Path.home() / ".claude" / "settings.json" +original = path.read_text() if path.exists() else None +try: + data = json.loads(original) if original is not None else {} + if not isinstance(data, dict): + data = {} +except Exception: + if action != "add": + raise SystemExit(0) + path.rename(path.with_suffix(path.suffix + ".bak")) + original = None + data = {} + +hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {} +entries = hooks.get("PostToolUse") if isinstance(hooks.get("PostToolUse"), list) else [] + +# Strip the managed command everywhere first, then re-append when enabled: the same pass +# handles removal, de-duplication, and matcher migration on version upgrades. The final +# text comparison keeps the write idempotent across boots. +filtered = [] +for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list): + filtered.append(entry) + continue + kept = [ + item + for item in entry["hooks"] + if not (isinstance(item, dict) and item.get("command") == command) + ] + if len(kept) != len(entry["hooks"]): + if not kept: + continue + entry = dict(entry) + entry["hooks"] = kept + filtered.append(entry) +entries = filtered + +if action == "add": + entries.append({"matcher": matcher, "hooks": [{"type": "command", "command": command}]}) + +if entries: + hooks["PostToolUse"] = entries +else: + hooks.pop("PostToolUse", None) +if hooks: + data["hooks"] = hooks +else: + data.pop("hooks", None) + +serialized = json.dumps(data, indent=2) + "\n" +if serialized != original: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(serialized) +PY + # Tell Claude Code that it can configure Home Assistant over the Core API via the shipped # `ha-cli` helper (no /config filesystem mount needed). Managed, idempotent block appended to # the user's global CLAUDE.md; removed when the helper is disabled. Mirrors the headroom block. diff --git a/claude_desktop/rootfs/usr/local/bin/headroom-posttooluse-compress.py b/claude_desktop/rootfs/usr/local/bin/headroom-posttooluse-compress.py new file mode 100755 index 0000000000..987bef0114 --- /dev/null +++ b/claude_desktop/rootfs/usr/local/bin/headroom-posttooluse-compress.py @@ -0,0 +1,218 @@ +#!/lsiopy/bin/python3 +"""Claude Code PostToolUse hook: auto-compress large tool outputs through Headroom. + +Registered in ~/.claude/settings.json by 82-claude_tools.sh (managed entry, matcher +"Bash|Grep|Glob|WebFetch"). Desktop-spawned Claude Code sessions cannot be routed +through the Headroom proxy (the Electron app pins ANTHROPIC_BASE_URL to the +production endpoint, headroom #869), so compression there used to depend on the +model voluntarily calling the headroom MCP tools. This hook makes it automatic for +every session type: when a matched tool returns a large output, the hook compresses +it with Headroom's rule-based pipeline and replaces the tool output via +hookSpecificOutput.updatedToolOutput, appending a retrieval marker. The original is +stored in Headroom's shared CCR store (SQLite at ~/.headroom/ccr_store.db — the +same store the headroom MCP server reads), so the model can always get the full +output back with mcp__headroom__headroom_retrieve. + +Design constraints: +- Fail open: any error or non-compressible payload exits 0 with no output, leaving + the tool result untouched. A hook crash must never break a session. +- Fast path first: the payload is inspected before importing headroom (~0.6 s); + small outputs never pay the import cost. +- ML text compression (Kompress) is disabled: its model loads in the background, + which never completes inside a short-lived hook process. The rule-based + transforms (SmartCrusher for JSON, search/log/diff/tabular compressors) carry + the savings on tool output anyway; plain prose passes through unchanged. +- stderr fields are never compressed — error text must reach the model verbatim + (matching Headroom's own error-protection policy). +- File-list arrays (Glob's `filenames`, Grep's `filenames` in files_with_matches + mode — both typed `string[]` by the CLI's own output schema) are handled + separately from prose/JSON-blob fields: Headroom's SmartCrusher subsamples + JSON arrays for informational dumps, which is fine for e.g. a list of sensor + states but silently drops most paths from a file listing the model needs to + act on. Those fields are truncated deterministically instead (keep the first + N entries, append one marker string) so the model always sees a labeled cut + point rather than a shorter list it might mistake for the complete result. +""" + +import json +import os +import sys + + +def _int_env(name: str, default: str) -> int: + try: + return int(os.environ.get(name, default)) + except (TypeError, ValueError): + return int(default) + + +MIN_CHARS = _int_env("HEADROOM_HOOK_MIN_CHARS", "4000") +MIN_SAVED_TOKENS = _int_env("HEADROOM_HOOK_MIN_SAVED_TOKENS", "50") +ARRAY_KEEP = _int_env("HEADROOM_HOOK_ARRAY_KEEP", "40") +TTL_SECONDS = 3600 # matches the headroom MCP server's session TTL +SKIP_KEYS = {"stderr"} + + +def self_test() -> int: + """Exit 0 when the interpreter can import headroom (used at registration time).""" + try: + import headroom # noqa: F401 + + return 0 + except Exception: + return 1 + + +def main() -> int: + if os.environ.get("HEADROOM_HOOK_DISABLE"): + return 0 + try: + payload = json.load(sys.stdin) + except Exception: + return 0 + if not isinstance(payload, dict): + return 0 + response = payload.get("tool_response") + + # Find big string/array fields before paying the headroom import cost. + def is_string_array(value): + return isinstance(value, list) and len(value) > ARRAY_KEEP and all(isinstance(v, str) for v in value) + + if isinstance(response, str): + string_candidates = ["__whole__"] if len(response) >= MIN_CHARS else [] + array_candidates = [] + elif isinstance(response, dict): + string_candidates = [ + key + for key, value in response.items() + if key not in SKIP_KEYS and isinstance(value, str) and len(value) >= MIN_CHARS + ] + array_candidates = [ + key for key, value in response.items() if key not in SKIP_KEYS and is_string_array(value) + ] + else: + string_candidates = [] + array_candidates = [] + if not string_candidates and not array_candidates: + return 0 + + # Keep Kompress's cache probe away from the tmpfs-backed ~/.cache default. + os.environ.setdefault("HF_HOME", os.path.expanduser("~/.headroom/hf")) + from headroom import savings_ledger + from headroom.cache.compression_store import get_compression_store + from headroom.compress import compress + + store = None + totals = [0, 0] # tokens before, tokens after (only for rewritten fields) + + def shrink(text): + nonlocal store + result = compress( + [{"role": "tool", "content": text}], + protect_recent=0, + kompress_model="disabled", + ) + compressed = result.messages[0].get("content") + if not isinstance(compressed, str): + compressed = json.dumps(compressed) + saved = result.tokens_before - result.tokens_after + if saved < MIN_SAVED_TOKENS: + return None + if store is None: + store = get_compression_store() + hash_key = store.store( + original=text, + compressed=compressed, + original_tokens=result.tokens_before, + compressed_tokens=result.tokens_after, + compression_strategy="posttooluse_hook", + ttl=TTL_SECONDS, + ) + totals[0] += result.tokens_before + totals[1] += result.tokens_after + return ( + f"{compressed}\n" + f"[headroom: output compressed {result.tokens_before}->{result.tokens_after} tokens; " + f"call mcp__headroom__headroom_retrieve with hash={hash_key} if you need the full original]" + ) + + def shrink_array(items): + nonlocal store + original_json = json.dumps(items) + if len(original_json) < MIN_CHARS: + return None + kept = items[:ARRAY_KEEP] + truncated_json = json.dumps(kept) + # No ML/token-counter call needed for a plain truncation decision; a char/4 + # estimate is the same fallback Headroom's own cost estimator uses and is + # only used here to decide eligibility and annotate the marker. + tokens_before = max(1, len(original_json) // 4) + tokens_after = max(1, len(truncated_json) // 4) + if tokens_before - tokens_after < MIN_SAVED_TOKENS: + return None + if store is None: + store = get_compression_store() + hash_key = store.store( + original=original_json, + compressed=truncated_json, + original_tokens=tokens_before, + compressed_tokens=tokens_after, + compression_strategy="posttooluse_hook_array_truncate", + ttl=TTL_SECONDS, + ) + totals[0] += tokens_before + totals[1] += tokens_after + remaining = len(items) - len(kept) + marker = ( + f"[headroom: {remaining} more of {len(items)} entries omitted " + f"({tokens_before}->{tokens_after} tokens); call mcp__headroom__headroom_retrieve " + f"with hash={hash_key} for the complete list]" + ) + return kept + [marker] + + updated = None + if isinstance(response, str): + updated = shrink(response) + else: + rewritten = dict(response) + changed = False + for key in string_candidates: + new_value = shrink(rewritten[key]) + if new_value is not None: + rewritten[key] = new_value + changed = True + for key in array_candidates: + new_value = shrink_array(rewritten[key]) + if new_value is not None: + rewritten[key] = new_value + changed = True + if changed: + updated = rewritten + if updated is None: + return 0 + + savings_ledger.record_savings_event( + tokens_before=totals[0], + tokens_after=totals[1], + client="posttooluse-hook", + source="hook", + ) + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "updatedToolOutput": updated, + } + }, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + if "--self-test" in sys.argv: + sys.exit(self_test()) + try: + sys.exit(main()) + except Exception: + sys.exit(0)