#!/usr/bin/env python3 """PostToolUse hook — lightweight ticker. Responsibilities (all must complete in well under 100 ms): 1. Read AirContext/state.json. If paused, in-progress, or in cooldown, return. 2. Estimate active-chain token usage from transcript_path. 3. If usage / model_context_window >= trigger.threshold, fork compactor.py in the background and return immediately. The compactor runs detached so it never blocks Claude's main loop. """ from __future__ import annotations import json import os import subprocess import sys import time from pathlib import Path _HERE = Path(__file__).resolve().parent sys.path.insert(0, str(_HERE)) from core.config_loader import load_config # noqa: E402 from core.state import StateFile # noqa: E402 from core.token_estimator import estimate_active_chain_tokens # noqa: E402 def _project_root(payload: dict) -> Path: cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() return Path(cwd) def _spawn_compactor(project: Path, transcript: Path, session_id: str) -> None: """Detach compactor.py so the hook returns immediately.""" compactor = _HERE / "core" / "compactor.py" args = [sys.executable, str(compactor), "--project", str(project), "--transcript", str(transcript), "--session-id", session_id] log_dir = project / "AirContext" / "snapshots" log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "compactor.log" if os.name == "nt": DETACHED = 0x00000008 # DETACHED_PROCESS NEW_GROUP = 0x00000200 creationflags = DETACHED | NEW_GROUP with log_file.open("ab") as lf: subprocess.Popen(args, stdout=lf, stderr=lf, stdin=subprocess.DEVNULL, creationflags=creationflags, close_fds=True) else: with log_file.open("ab") as lf: subprocess.Popen(args, stdout=lf, stderr=lf, stdin=subprocess.DEVNULL, start_new_session=True, close_fds=True) def main() -> int: raw = sys.stdin.read() or "{}" try: payload = json.loads(raw) except json.JSONDecodeError: return 0 project = _project_root(payload) air = project / "AirContext" config_path = air / "config.yaml" if not config_path.exists(): return 0 # uninitialised — nothing to do state = StateFile(air / "state.json") snap = state.read() if snap.get("paused") or snap.get("compaction_in_progress") or snap.get("compaction_ready"): return 0 try: cfg = load_config(config_path) except Exception: return 0 # config broken — UserPromptSubmit will surface it cooldown = int(cfg.get("trigger", {}).get("cooldown_seconds", 300)) if time.time() - snap.get("last_compaction_unix", 0) < cooldown: return 0 transcript = payload.get("transcript_path") if not transcript or not Path(transcript).exists(): return 0 session_id = payload.get("session_id") or snap.get("last_session_id") if not session_id: return 0 threshold = float(cfg.get("trigger", {}).get("threshold", 0.6)) window = int(cfg.get("trigger", {}).get("model_context_window", 200000)) method = cfg.get("trigger", {}).get("estimate_method", "char_div_3_5") used = estimate_active_chain_tokens(Path(transcript), method=method) if used / max(window, 1) < threshold: return 0 # Mark in-progress immediately so successive PostToolUse calls don't double-fire. state.update(compaction_in_progress=True, last_session_id=session_id) _spawn_compactor(project, Path(transcript), session_id) return 0 if __name__ == "__main__": sys.exit(main())