2026-06-12 16:42:05 +00:00
|
|
|
#!/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).
|
|
|
|
|
"""
|
2026-07-07 18:01:15 +00:00
|
|
|
import json
|
2026-06-12 16:42:05 +00:00
|
|
|
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"
|
|
|
|
|
|
2026-07-07 18:01:15 +00:00
|
|
|
# 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,
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
|
2026-06-12 16:42:05 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 14:06:55 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 16:42:05 +00:00
|
|
|
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()
|
|
|
|
|
|
2026-07-10 14:06:55 +00:00
|
|
|
_pull_repo(cfg.vault_path)
|
|
|
|
|
_pull_repo(cfg.memsearch_dir)
|
|
|
|
|
|
2026-07-07 18:01:15 +00:00
|
|
|
_emit_usage_note()
|
|
|
|
|
|
os-vault: WS2 write-behavior eval harness + untuned baseline grid
plugins/os-vault/eval/ — held-out unprompted vault-write discrimination eval:
ambiguity ladder L1 explicit -> L3 conceptual, paired positives/negatives,
6 run-set scenarios on a new reportgen Ruby fixture + 6 frozen reserve twins,
isolated sandbox vault, headless-only runner, deterministic-first Ruby checker
(narrow judge fallback stubbable via OS_VAULT_EVAL_JUDGE_CMD), model-free
self-test 21/21. Scenario Task blocks are held-out; reserve never read.
Isolation seam: OS_VAULT_PATH overrides vault_path via config.load_config();
OS_VAULT_SKIP_REBUILD suppresses the SessionStart graphify rebuild. Write
SKILL.md contract fix (defers to vault-conventions.md) + Vault location section.
Baseline grid (run-set x sonnet/haiku x 3 reps + 1 counted canary, 37 reps):
positives 1/19, negatives 18/18. L1 persists every rep but routes to built-in
auto-memory instead of the vault; L2/L3 mostly don't persist; zero
over-triggering. Wording loop (step 4, not started) targets: trigger at
L2/L3, route to vault at L1. Loop-input candidates: vault note
os-vault-write-eval-baseline-grid-results. CLAUDE.md pointers updated for
this and os-status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:46:22 +00:00
|
|
|
if os.environ.get("OS_VAULT_SKIP_REBUILD"):
|
|
|
|
|
_log(f"[{_utc()}] session-start: rebuild skipped (OS_VAULT_SKIP_REBUILD set)")
|
|
|
|
|
return
|
|
|
|
|
|
2026-06-12 16:42:05 +00:00
|
|
|
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)
|