cc-os/plugins/os-vault/hooks/session_start.py

169 lines
5.8 KiB
Python
Raw Permalink Normal View History

#!/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)