tankslab.com/recall/source

bin/hook.py

360 lines, exactly as they ship in the download.

#!/usr/bin/env python3 """ hook.py - the only thing Claude Code calls. Four events, all cheap. SessionStart : print the recent-context block, plus any pending handoff PreCompact : hand the summariser a precomputed brief so it reads less SessionEnd : leave a handoff behind so the next session can skip compaction Stop : ingest the finished transcript WHAT THIS DELIBERATELY DOES NOT HOOK ------------------------------------ PostToolUse "*" - the firehose the predecessor used; every tool result crossed it PreToolUse Read - every file you open UserPromptSubmit - a subprocess spawned on every keystroke-submit Those three are why the old system cost ~1.9GB resident and put 9 chat-bot tokens, a provider API key and 13 URL credentials into cleartext storage. Recall reads the transcript Claude Code already wrote, at Stop, once. A hook must never break the session. Every path exits 0; real failures are recorded and surfaced by `recall health`, which can actually fail. """ from __future__ import annotations import json import os import subprocess import sys import time from pathlib import Path BIN = Path(__file__).resolve().parent from paths import log_file LOG = log_file() TIMEOUT = 45 def _log(msg: str) -> None: try: with LOG.open("a", encoding="utf-8") as f: f.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}\n") except Exception: pass _PAYLOAD_READ = False def _payload() -> dict: """Read the hook payload from stdin. ONCE. stdin is a stream, not a file you can re-open. main() reads it, and a handler that calls this a second time gets EOF, parses "" to {}, finds no transcript path, and returns quietly - a hook that looks wired, exits 0, logs nothing and does nothing. That is exactly what PreCompact did on its first run here. So the second read is now loud instead of empty. """ global _PAYLOAD_READ if _PAYLOAD_READ: _log("BUG: _payload() called twice - stdin is already consumed. " "Pass the payload down instead of re-reading it.") return {} _PAYLOAD_READ = True try: raw = sys.stdin.read() return json.loads(raw) if raw.strip() else {} except Exception: return {} def _sid(p: dict) -> str: v = str(p.get("session_id") or "") return "".join(c for c in v if c.isalnum())[:8] def _carry(p: dict) -> Path: """Where PreCompact leaves the full brief for the SessionStart that follows it. Kept apart from the handoff queue on purpose. A handoff travels BETWEEN sessions and belongs to whoever continues the work; a carry belongs to one session crossing its own compaction boundary and to nobody else. Same file shape, two different questions about ownership - so two different drawers. """ # paths.root(), never a hand-rolled home directory. The wipe command promises # that deleting one directory leaves nothing of Recall behind, and a carry file # is verbatim conversation text. Writing it anywhere else would make that promise # false in the one place it matters most, and quietly. import paths d = paths.root() / "handoff" d.mkdir(parents=True, exist_ok=True) return d / f"carry--{_sid(p) or 'unknown'}.md" def _standing(brief: str) -> str: """Just the binding-rules section of a brief.""" out, keep = [], False for line in brief.splitlines(): if line.startswith("## STANDING INSTRUCTIONS"): keep = True elif line.startswith("## ") and keep: break if keep: out.append(line) return "\n".join(out).strip() # A session that is killed, crashes, or has its terminal closed never fires # SessionEnd, so the handoff written there is the one you do not get on exactly the # days you needed it. This keeps a current one on disk while the session is alive. # It is cheap enough to do that: local text extraction, no model call, no network. AUTO_MIN_BYTES = 400_000 # below this a fresh session costs less than a handoff AUTO_EVERY_SEC = 600.0 def _auto_handoff(p: dict, tpath: str) -> None: try: sid = _sid(p) if not sid or not tpath: return src = Path(tpath) if not src.exists() or src.stat().st_size < AUTO_MIN_BYTES: return import paths marker = paths.root() / "handoff" / f".auto--{sid}" now = time.time() if marker.exists() and (now - marker.stat().st_mtime) < AUTO_EVERY_SEC: return marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text(str(int(now)), encoding="utf-8") r = subprocess.run( [sys.executable, str(BIN / "handoff.py"), "write", tpath, "--session", sid], capture_output=True, text=True, timeout=TIMEOUT) _log(f"auto-handoff rc={r.returncode} sid={sid} src={src.stat().st_size:,}B") except Exception as e: _log(f"auto-handoff skipped: {e}") def do_session_start(p: dict) -> None: """SessionStart fires for startup, resume, clear, compact AND fork. It used to treat all five the same and swallow whatever handoff was waiting. That is how an unrelated session's 18 KB conversation arrived here on 2026-08-21 at 13:35: this session merely COMPACTED in a folder where somebody else had just left a handoff, and took it. Which door you came in by decides what you are owed, so read it. """ source = str(p.get("source") or "") try: out = subprocess.run( [sys.executable, str(BIN / "recall.py"), "context", "--days", "2", "--limit", "40", "--project", os.environ.get("RECALL_PROJECT", "home")], capture_output=True, text=True, timeout=TIMEOUT) ctx = (out.stdout or "").strip() hand = "" if source == "compact": # Coming back from a compaction. The brief PreCompact computed is sitting # on disk; putting it in here is the whole point, and this is the only # channel that carries it silently - PreCompact's own stdout is echoed # to the terminal verbatim, so the long form cannot travel that way. c = _carry(p) try: if c.exists(): hand = c.read_text(encoding="utf-8").strip() c.replace(c.with_name(c.name + ".used")) except Exception as e: _log(f"carry read failed (continuing without it): {e}") elif source in ("startup", ""): # A genuinely new session, and the only one entitled to a handoff. resume, # clear and fork already hold their own history; handing them somebody # else's would be pure loss, since a taken handoff cannot be un-taken. try: args = [sys.executable, str(BIN / "handoff.py"), "take"] if _sid(p): args += ["--session", _sid(p)] ho = subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT) hand = (ho.stdout or "").strip() except Exception as e: _log(f"handoff take failed (continuing without it): {e}") if hand: ctx = hand + ("\n\n" + ctx if ctx else "") _log(f"session-start source={source or '?'} sid={_sid(p) or '?'} " f"carried={len(hand)}B ctx={len(ctx)}B") if not ctx: return print(json.dumps({ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": ctx, } })) except Exception as e: _log(f"session-start FAILED: {e}") def do_stop(p: dict) -> None: tpath = p.get("transcript_path") if not tpath: # fall back to the newest transcript for this project sys.path.insert(0, str(BIN)) try: from recall import transcript_files files = list(reversed(transcript_files())) except Exception: files = [] if not files: _log("stop: no transcript found") return tpath = str(files[0]) if not Path(tpath).exists(): _log(f"stop: transcript missing {tpath}") return try: r = subprocess.run( [sys.executable, str(BIN / "capture.py"), tpath, "--project", os.environ.get("RECALL_PROJECT", "home")], capture_output=True, text=True, timeout=TIMEOUT) if r.returncode != 0: _log(f"stop: capture rc={r.returncode} {r.stderr.strip()[:400]}") else: try: # THESE KEY NAMES ARE THE ONES capture.py ACTUALLY RETURNS. # They were 'prompts' and 'observations', which capture.py has never # returned - so this line raised KeyError on every SUCCESSFUL capture # and fell into the bare fallback below. The counts nobody could find # in hook.log were never being written, and the log's most detailed # line was unreachable code that still looked perfectly fine. A # success path can die as quietly as a failure path. d = json.loads(r.stdout) _log(f"stop: {d['session'][:8]} prompts={d['prompts_stored']} " f"obs={d['observations_stored']} redactions={d['redactions']}") except Exception as e: _log(f"stop: captured (counts unavailable: {e})") except subprocess.TimeoutExpired: _log("stop: capture TIMEOUT") except Exception as e: _log(f"stop: FAILED {e}") # Outside the try above on purpose: a capture that timed out or blew up is # exactly the run where a current handoff is most worth having. _auto_handoff(p, tpath) def do_pre_compact(p: dict) -> None: """Compaction is about to run anyway. Make it read less. ⚠️ THIS HOOK PRINTS PLAIN TEXT. IT IS NOT A JSON HOOK. Getting that wrong is what made it useless from the day it shipped until 2026-08-21, and the failure was invisible from in here. The first version emitted the JSON shape the other hooks use: {"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": ...}} PreCompact has no additionalContext. The harness rejected the whole object with "Hook JSON output validation failed" and compaction proceeded with no help at all - every single time, for every session, while this function raised nothing, exited 0 and logged nothing, because from in here a discarded stdout and a hook that was never called look exactly the same. There was no test that asserted the harness ACCEPTED the output, only that we had produced some. What it actually does, from the shipped client (function vFe): l = hooks.filter(succeeded && !blocked && output.trim()).map(output.trim()) return { newCustomInstructions: l.join("\n\n"), userDisplayMessage: ... } Stdout, verbatim, becomes the summariser's custom instructions. ⚠️ AND IT IS ALSO ECHOED. The same stdout goes into userDisplayMessage, which the client prints to the terminal. A 12 KB brief here would wallpaper the screen on every compaction. So the long form goes to disk for SessionStart(compact) to inject silently, and only a short steer travels this way. If anything below fails it fails quietly and compaction proceeds exactly as it would have. A broken optimiser must never break the session. """ try: t = p.get("transcript_path") or "" if not t or not Path(t).exists(): return out = subprocess.run([sys.executable, str(BIN / "brief.py"), t, "--budget", "12000"], capture_output=True, text=True, timeout=TIMEOUT) b = (out.stdout or "").strip() if not b: return try: c = _carry(p) c.write_text(b, encoding="utf-8") os.chmod(c, 0o600) except Exception as e: _log(f"pre-compact: carry write failed: {e}") rules = _standing(b) heads = [l[3:].split(" (")[0].strip() for l in b.splitlines() if l.startswith("## ")] steer = [ "Recall has already extracted this conversation's spine locally - no model " "call, no network - and the full extract is being handed to the session that " "resumes after this compaction. Do not spend effort re-deriving it from tool " "output; summarise the reasoning and the decisions instead.", f"Sections already captured verbatim: {', '.join(heads)}." if heads else "", "", "PRESERVE THESE EXACTLY. They are rules the user is still bound by, not history:", rules or " (none found in this transcript)", ] text = "\n".join(x for x in steer if x != "") # Echoed to the user's terminal, so keep it to something a person can read. if len(text) > 2400: text = text[:2400].rsplit("\n", 1)[0] + "\n ... (full extract carried separately)" print(text) _log(f"pre-compact: brief={len(b)}B carried, steer={len(text)}B on stdout " f"(plain text -> newCustomInstructions)") except Exception as e: _log(f"pre-compact FAILED (compaction continues normally): {e}") def do_session_end(p: dict) -> None: """Leave a handoff so the NEXT session can start clean instead of compacting.""" try: t = p.get("transcript_path") or "" args = [sys.executable, str(BIN / "handoff.py"), "write"] if t and Path(t).exists(): args.append(t) # Stamp the writer. An anonymous handoff in a shared folder cannot be routed. if _sid(p): args += ["--session", _sid(p)] r = subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT) _log(f"session-end: handoff write rc={r.returncode} sid={_sid(p) or '?'}") except Exception as e: # lower-case for the same reason: the session still ended and the transcript # is still on disk for the Stop lane to capture. Nothing was lost. _log(f"session-end handoff failed (the session was still captured): {e}") def main() -> int: event = sys.argv[1] if len(sys.argv) > 1 else "" p = _payload() if event == "session-start": do_session_start(p) elif event == "pre-compact": do_pre_compact(p) elif event == "session-end": do_session_end(p) elif event == "stop": do_stop(p) else: # FAILED, deliberately: a hook wired to an event name this does not handle # captures nothing, forever, and used to say so in a line no check read. _log(f"FAILED unknown event {event!r} - a hook is wired to something this " f"does not handle, so it captures nothing. Rewire: python3 install.py") return 0 # never break the session if __name__ == "__main__": try: sys.exit(main()) except Exception as e: _log(f"fatal (suppressed): {e}") sys.exit(0)