| #!/usr/bin/env python3 | | | """ | | | temporal.py — zero-dep time awareness for Claude Code. | | | Injects a throttled [⏱ time] block on UserPromptSubmit, and re-injects | | | right after a compaction via SessionStart(source=compact). | | | State: ~/.claude/.temporal/<session_id>.json | | | Env: TEMPORAL_INTERVAL=300 (min secs between injects, 0=always) | | | Wire both UserPromptSubmit and SessionStart in settings.json to: | | | python3 ~/.claude/hooks/temporal.py | | | In ~/.claude/settings.json point these two events at it: | |
| {"hooks": { | |
| "UserPromptSubmit": [{"matcher":"","hooks":[{"type":"command","command":"python3 ~/.claude/hooks/temporal.py"}]}], | |
| "SessionStart": [{"matcher":"","hooks":[{"type":"command","command":"python3 ~/.claude/hooks/temporal.py"}]}] | |
| }} | |
| """ | | | import json, os, sys, time | | | from datetime import datetime, timezone | | | from pathlib import Path | |
| INTERVAL_S = int(os.environ.get("TEMPORAL_INTERVAL", 300)) | |
| TTL_S = int(os.environ.get("TEMPORAL_TTL_DAYS", 7)) * 86400 | |
| DIR = Path.home() / ".claude" / ".temporal" | |
| def fmt(s): | |
| if s < 60: return f"{s}s" | |
| if s < 3600: return f"{s // 60}m{s % 60:02d}s" | |
| return f"{s // 3600}h{(s % 3600) // 60:02d}m" | |
| def emit(event, context): | |
| # Claude Code reads context from this nested hookSpecificOutput shape | |
| print(json.dumps({"hookSpecificOutput": | |
| {"hookEventName": event, "additionalContext": context}})) | |
| def main(): | |
| data = json.loads(sys.stdin.read().strip() or "{}") | |
| event = data.get("hook_event_name", "") | |
| DIR.mkdir(parents=True, exist_ok=True) | |
| for old in DIR.glob("*.json"): # sweep stale sessions | |
| if time.time() - old.stat().st_mtime > TTL_S: | |
| old.unlink(missing_ok=True) | |
| f = DIR / f"{data.get('session_id', 'nosession')}.json" | |
| s = json.loads(f.read_text()) if f.exists() else {} | |
| now_ms = int(time.time() * 1000) | |
| utc, local = datetime.now(timezone.utc), datetime.now().astimezone() | |
| # setdefault stamps start_ms on first sight so session age survives restarts | |
| session_s = (now_ms - s.setdefault("start_ms", now_ms)) // 1000 | |
| stamp = (f"now={local:%H:%M} {local:%Z} | utc={utc:%Y-%m-%dT%H:%M:%SZ} | " | |
| f"unix_ms={now_ms} | session={fmt(session_s)}") | |
| if event == "UserPromptSubmit": | |
| # only inject once per INTERVAL_S window | |
| if (now_ms - s.get("last_inject_ms", 0)) // 1000 >= INTERVAL_S: | |
| s["last_inject_ms"] = now_ms | |
| emit(event, f"[⏱ {stamp}]") | |
| elif event == "SessionStart" and data.get("source") == "compact": | |
| emit(event, f"[⏱ post-compaction time check — {stamp}]") | |
| f.write_text(json.dumps(s)) | |
| if __name__ == "__main__": | |
| main() |