""" PreCompact hook - captures conversation transcript before auto-compaction. When Claude Code's context window fills up, it auto-compacts (summarizes and discards detail). This hook fires BEFORE that happens, extracting conversation context and spawning flush.py to extract knowledge that would otherwise be lost to summarization. The hook itself does NO API calls - only local file I/O for speed (<10s). """ from __future__ import annotations import json import logging import os import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path # Recursion guard if os.environ.get("CLAUDE_INVOKED_BY"): sys.exit(0) ROOT = Path(__file__).resolve().parent.parent SCRIPTS_DIR = ROOT / "scripts" STATE_DIR = SCRIPTS_DIR logging.basicConfig( filename=str(SCRIPTS_DIR / "flush.log"), level=logging.INFO, format="%(asctime)s %(levelname)s [pre-compact] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) MAX_TURNS = 30 MAX_CONTEXT_CHARS = 15_000 MIN_TURNS_TO_FLUSH = 5 def extract_conversation_context(transcript_path: Path) -> tuple[str, int]: """Read JSONL transcript and extract last ~N conversation turns as markdown.""" turns: list[str] = [] with open(transcript_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) except json.JSONDecodeError: continue msg = entry.get("message", {}) if isinstance(msg, dict): role = msg.get("role", "") content = msg.get("content", "") else: role = entry.get("role", "") content = entry.get("content", "") if role not in ("user", "assistant"): continue if isinstance(content, list): text_parts = [] for block in content: if isinstance(block, dict) and block.get("type") == "text": text_parts.append(block.get("text", "")) elif isinstance(block, str): text_parts.append(block) content = "\n".join(text_parts) if isinstance(content, str) and content.strip(): label = "User" if role == "user" else "Assistant" turns.append(f"**{label}:** {content.strip()}\n") recent = turns[-MAX_TURNS:] context = "\n".join(recent) if len(context) > MAX_CONTEXT_CHARS: context = context[-MAX_CONTEXT_CHARS:] boundary = context.find("\n**") if boundary > 0: context = context[boundary + 1 :] return context, len(recent) def main() -> None: # Read hook input from stdin try: raw_input = sys.stdin.read() try: hook_input: dict = json.loads(raw_input) except json.JSONDecodeError: fixed_input = re.sub(r'(?