#!/usr/bin/env python3 """SessionStart hook — staleness check + detached graph rebuild. Fail-open. Must return sub-second; heavy work is detached in the background. Context injection is handled by session_context.py (UserPromptSubmit hook). """ import json import os import sys import time import subprocess from datetime import datetime, timezone sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import config as config_mod import hook_io HOOK_LOG = "/tmp/memory-hook.log" # Trigger-conditioned destination ladder (WS2 wording loop, 2026-07-07). # Injected as additionalContext every session; keep it short — it is a # per-session token cost in every project. USAGE_NOTE = ( "[os-vault] Where knowledge goes — ask in order: " "(1) the repo already records it (code, ADR, CLAUDE.md)? -> write it there or " "nowhere; never duplicate into memory. " "(2) would it change how you act in a DIFFERENT repo next year (tool/API " "behavior discovered, client conventions, methodology that worked)? -> the " "SecondBrain vault via /os-vault:write — NOT auto-memory; the repo is where " "you learned it, not what it's about. " "(3) only useful in future sessions of THIS repo? -> auto-memory. " "(4) task status / in-flight state -> nothing. " "Mechanical triggers — treat these as part of the task itself: " "WHEN you discover a tool/API/service behaves differently from what its " "docs or config claim (advertised X, actually Y) -> that discrepancy is " "vault knowledge, /os-vault:write it. " "WHEN the user states a client-specific requirement or standing preference " "-> vault note, scope: client. " "BEFORE your final reply on any task -> check: did either occur this " "session? If yes, the task is NOT COMPLETE until /os-vault:write has " "captured it. Ephemeral status and in-progress state never go to the vault." ) def _emit_usage_note(): print(json.dumps({ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": USAGE_NOTE, } })) def _utc(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _log(line): try: with open(HOOK_LOG, "a") as f: f.write(line + "\n") except Exception: pass def _pull_repo(repo_dir): """Fast-forward-only pull for a sync'd repo. Silent on success; at most a one-line stderr note on failure (offline machine must never block or error out session start). Mirrors vault_sync.py / memsearch_sync.py push style but in the opposite direction (Step 5b multi-machine freshness). """ if not os.path.isdir(os.path.join(repo_dir, ".git")): return try: r = subprocess.run( ["timeout", "10", "git", "-C", repo_dir, "pull", "--ff-only", "--quiet", "origin", "main"], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, ) if r.returncode != 0: print(f"[session-start] pull skipped for {repo_dir} (offline or diverged)", file=sys.stderr) except Exception: print(f"[session-start] pull skipped for {repo_dir}", file=sys.stderr) def _needs_rebuild(stamp, stale_days): if not os.path.exists(stamp): return True try: age_days = (time.time() - os.path.getmtime(stamp)) / 86400 return age_days > stale_days except Exception: return True def main(): inp = hook_io.read_input() source = inp.source or "unknown" _log(f"[{_utc()}] session-start fired: source={source} cwd={os.getcwd()}") cfg = config_mod.load_config() _pull_repo(cfg.vault_path) _pull_repo(cfg.memsearch_dir) _emit_usage_note() if os.environ.get("OS_VAULT_SKIP_REBUILD"): _log(f"[{_utc()}] session-start: rebuild skipped (OS_VAULT_SKIP_REBUILD set)") return cache_dir = os.path.expanduser("~/.cache/graphify") stamp = os.path.join(cache_dir, "vault-rebuild.stamp") lock = os.path.join(cache_dir, "vault-rebuild.lock") logfile = os.path.join(cache_dir, "vault-rebuild.log") try: os.makedirs(cache_dir, exist_ok=True) except Exception: pass if _needs_rebuild(stamp, cfg.stale_days): if os.path.exists(lock): try: with open(logfile, "a") as f: f.write("[memory/session-start] rebuild already running (lock present), skipping\n") except Exception: pass _log(f"[{_utc()}] session-start: rebuild skipped (lock present)") else: try: open(lock, "a").close() except Exception: pass env = dict(os.environ) env.update(cfg.env) env["VAULT_PATH"] = cfg.vault_path env["MODEL"] = cfg.model env["STAMP"] = stamp env["LOCK"] = lock env["LOGFILE"] = logfile cmd = ( 'graphify extract "$VAULT_PATH" --backend ollama --model "$MODEL" --force ' '>> "$LOGFILE" 2>&1 ' '&& date > "$STAMP" && rm -f "$LOCK" ' '|| (rm -f "$LOCK"; echo "rebuild failed" >> "$LOGFILE")' ) try: devnull = open(os.devnull, "wb") subprocess.Popen( ["bash", "-c", cmd], stdin=subprocess.DEVNULL, stdout=devnull, stderr=devnull, env=env, start_new_session=True, ) except Exception: pass _log(f"[{_utc()}] session-start: rebuild triggered") else: _log(f"[{_utc()}] session-start: rebuild not needed") if __name__ == "__main__": try: main() except Exception as e: print(f"[session-start] error: {e}", file=sys.stderr) sys.exit(0)