#!/usr/bin/env python3 """echo_hook_stop.py — Stop hook: one-shot session-end reflection nudge. Reflection (/echo-reflect) only captures memory if someone remembers to run it. This hook watches the first Stop of a *substantive* session and, when nothing has been reflected or session-logged yet, blocks once with a reminder so the model runs the reflect flow (which still previews and asks the operator before writing — the safety gate lives in reflect, not here). Guards (a hook must NEVER trap a session in a loop or nag): * `stop_hook_active` -> exit immediately (loop guard); * fires at most ONCE per session (marker file under ECHO_STATE_DIR/hook-state); * skips quiet sessions (< ECHO_REFLECT_MIN_TURNS user turns, default 5); * skips when the transcript shows reflection / a session log already happened; * skips when ECHO isn't configured on this machine; * always exits 0. """ from __future__ import annotations import json import os import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) MIN_TURNS = int(os.environ.get("ECHO_REFLECT_MIN_TURNS", "5")) REASON = ( "[echo-memory] Session-end reflection has not run yet. If this session produced " "durable facts, decisions, commitments, or artifacts worth remembering: run the " "/echo-reflect flow (extract -> preview -> apply only with the operator's confirm) " "and write the session log + heartbeat per the echo-memory skill. If nothing " "durable emerged, simply finish — do not invent memories. " "(This reminder fires once per session.)" ) def state_marker(session_id: str) -> Path: base = Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory")) return base / "hook-state" / f"{session_id}.reflect-nudged" def count_user_turns(raw: str) -> int: turns = 0 for line in raw.splitlines(): try: rec = json.loads(line) except Exception: # noqa: BLE001 continue if rec.get("type") != "user": continue content = (rec.get("message") or {}).get("content") if isinstance(content, str) and content.strip(): turns += 1 elif isinstance(content, list) and any( isinstance(b, dict) and b.get("type") == "text" for b in content): turns += 1 return turns def already_reflected(raw: str) -> bool: """Transcript evidence that reflection or session logging already happened.""" return ("/echo-reflect" in raw or re.search(r'echo\.py"?\s+reflect\b', raw) is not None or "put _agent/sessions/" in raw or "put _agent/heartbeat/last-session.md" in raw) def main() -> int: try: payload = json.load(sys.stdin) except Exception: # noqa: BLE001 return 0 if payload.get("stop_hook_active"): return 0 session_id = str(payload.get("session_id") or "unknown") marker = state_marker(session_id) if marker.exists(): return 0 try: import echo_config if not echo_config.is_configured(echo_config.load()): return 0 # nothing to reflect into — stay silent except Exception: # noqa: BLE001 return 0 raw = "" tp = payload.get("transcript_path") if tp: try: raw = Path(tp).read_text(encoding="utf-8", errors="replace") except Exception: # noqa: BLE001 raw = "" if already_reflected(raw): try: # done for this session — never fire marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text("reflected\n", encoding="utf-8") except Exception: # noqa: BLE001 pass return 0 if count_user_turns(raw) < MIN_TURNS: return 0 # quiet so far; a later stop may qualify (no marker written) try: marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text("nudged\n", encoding="utf-8") except Exception: # noqa: BLE001 pass # if the marker can't be written, still nudge; stop_hook_active guards loops print(json.dumps({"decision": "block", "reason": REASON}, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())