""" SessionEnd hook - captures conversation transcript for memory extraction. When a Claude Code session ends, this hook reads the transcript path from stdin, extracts conversation context, and spawns flush.py as a background process to extract knowledge into the daily log. 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 we were spawned by flush.py (which calls Agent SDK, # which runs Claude Code, which would fire this hook again), exit immediately. if os.environ.get("CLAUDE_INVOKED_BY"): sys.exit(0) ROOT = Path(__file__).resolve().parent.parent DAILY_DIR = ROOT / "daily" SCRIPTS_DIR = ROOT / "scripts" STATE_DIR = SCRIPTS_DIR logging.basicConfig( filename=str(SCRIPTS_DIR / "flush.log"), level=logging.INFO, format="%(asctime)s %(levelname)s [hook] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) MAX_TURNS = 30 MAX_CONTEXT_CHARS = 15_000 MIN_TURNS_TO_FLUSH = 1 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 # Claude Code on Windows may pass paths with unescaped backslashes try: raw_input = sys.stdin.read() try: hook_input: dict = json.loads(raw_input) except json.JSONDecodeError: fixed_input = re.sub(r'(?