#!/usr/bin/env python3 """ Periodic Context Save Daemon Monitors Claude Code activity and saves context every 5 minutes of active time. Runs as a background process that tracks when Claude is actively working. Usage: python .claude/hooks/periodic_context_save.py start python .claude/hooks/periodic_context_save.py stop python .claude/hooks/periodic_context_save.py status """ import os import sys import time import json import signal import subprocess from datetime import datetime, timezone from pathlib import Path import requests # Configuration SCRIPT_DIR = Path(__file__).parent CLAUDE_DIR = SCRIPT_DIR.parent PID_FILE = CLAUDE_DIR / ".periodic-save.pid" STATE_FILE = CLAUDE_DIR / ".periodic-save-state.json" LOG_FILE = CLAUDE_DIR / "periodic-save.log" CONFIG_FILE = CLAUDE_DIR / "context-recall-config.env" SAVE_INTERVAL_SECONDS = 300 # 5 minutes CHECK_INTERVAL_SECONDS = 60 # Check every minute def log(message): """Write log message to file and stderr""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_message = f"[{timestamp}] {message}\n" # Write to log file with open(LOG_FILE, "a") as f: f.write(log_message) # Also print to stderr print(log_message.strip(), file=sys.stderr) def load_config(): """Load configuration from context-recall-config.env""" config = { "api_url": "http://172.16.3.30:8001", "jwt_token": None, } if CONFIG_FILE.exists(): with open(CONFIG_FILE) as f: for line in f: line = line.strip() if line.startswith("CLAUDE_API_URL="): config["api_url"] = line.split("=", 1)[1] elif line.startswith("JWT_TOKEN="): config["jwt_token"] = line.split("=", 1)[1] return config def detect_project_id(): """Detect project ID from git config""" try: # Try git config first result = subprocess.run( ["git", "config", "--local", "claude.projectid"], capture_output=True, text=True, check=False, ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() # Try to derive from git remote URL result = subprocess.run( ["git", "config", "--get", "remote.origin.url"], capture_output=True, text=True, check=False, ) if result.returncode == 0 and result.stdout.strip(): import hashlib return hashlib.md5(result.stdout.strip().encode()).hexdigest() except Exception: pass return "unknown" def is_claude_active(): """ Check if Claude Code is actively running. Returns True if: - Claude Code process is running - Recent file modifications in project directory - Not waiting for user input (heuristic) """ try: # Check for Claude process on Windows if sys.platform == "win32": result = subprocess.run( ["tasklist.exe"], capture_output=True, text=True, check=False, ) if "claude" in result.stdout.lower() or "node" in result.stdout.lower(): return True # Check for recent file modifications (within last 2 minutes) cwd = Path.cwd() two_minutes_ago = time.time() - 120 for file in cwd.rglob("*"): if file.is_file() and file.stat().st_mtime > two_minutes_ago: # Recent activity detected return True except Exception as e: log(f"Error checking activity: {e}") # Default to inactive if we can't detect return False def load_state(): """Load state from state file""" if STATE_FILE.exists(): try: with open(STATE_FILE) as f: return json.load(f) except Exception: pass return { "active_seconds": 0, "last_update": None, "last_save": None, } def save_state(state): """Save state to state file""" state["last_update"] = datetime.now(timezone.utc).isoformat() with open(STATE_FILE, "w") as f: json.dump(state, f, indent=2) def save_periodic_context(config, project_id): """Save context to database via API""" if not config["jwt_token"]: log("No JWT token - cannot save context") return False title = f"Periodic Save - {datetime.now().strftime('%Y-%m-%d %H:%M')}" summary = f"Auto-saved context after 5 minutes of active work. Session in progress on project: {project_id}" payload = { "context_type": "session_summary", "title": title, "dense_summary": summary, "relevance_score": 5.0, "tags": json.dumps(["auto-save", "periodic", "active-session"]), } try: url = f"{config['api_url']}/api/conversation-contexts" headers = { "Authorization": f"Bearer {config['jwt_token']}", "Content-Type": "application/json", } response = requests.post(url, json=payload, headers=headers, timeout=10) if response.status_code in [200, 201]: log(f"✓ Context saved successfully (ID: {response.json().get('id', 'unknown')})") return True else: log(f"✗ Failed to save context: HTTP {response.status_code}") return False except Exception as e: log(f"✗ Error saving context: {e}") return False def monitor_loop(): """Main monitoring loop""" log("Periodic context save daemon started") log(f"Will save context every {SAVE_INTERVAL_SECONDS}s of active time") config = load_config() state = load_state() # Reset state on startup state["active_seconds"] = 0 save_state(state) while True: try: # Check if Claude is active if is_claude_active(): # Increment active time state["active_seconds"] += CHECK_INTERVAL_SECONDS save_state(state) log(f"Active: {state['active_seconds']}s / {SAVE_INTERVAL_SECONDS}s") # Check if we've reached the save interval if state["active_seconds"] >= SAVE_INTERVAL_SECONDS: log(f"{SAVE_INTERVAL_SECONDS}s of active time reached - saving context") project_id = detect_project_id() if save_periodic_context(config, project_id): state["last_save"] = datetime.now(timezone.utc).isoformat() # Reset timer state["active_seconds"] = 0 save_state(state) else: log("Claude Code inactive - not counting time") # Wait before next check time.sleep(CHECK_INTERVAL_SECONDS) except KeyboardInterrupt: log("Daemon stopped by user") break except Exception as e: log(f"Error in monitor loop: {e}") time.sleep(CHECK_INTERVAL_SECONDS) def start_daemon(): """Start the daemon as a background process""" if PID_FILE.exists(): with open(PID_FILE) as f: pid = int(f.read().strip()) # Check if process is running try: os.kill(pid, 0) # Signal 0 checks if process exists print(f"Periodic context save daemon already running (PID: {pid})") return 1 except OSError: # Process not running, remove stale PID file PID_FILE.unlink() # Start daemon process if sys.platform == "win32": # On Windows, use subprocess.Popen with DETACHED_PROCESS import subprocess CREATE_NO_WINDOW = 0x08000000 process = subprocess.Popen( [sys.executable, __file__, "_monitor"], creationflags=subprocess.DETACHED_PROCESS | CREATE_NO_WINDOW, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) else: # On Unix, fork import subprocess process = subprocess.Popen( [sys.executable, __file__, "_monitor"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Save PID with open(PID_FILE, "w") as f: f.write(str(process.pid)) print(f"Started periodic context save daemon (PID: {process.pid})") print(f"Logs: {LOG_FILE}") return 0 def stop_daemon(): """Stop the daemon""" if not PID_FILE.exists(): print("Periodic context save daemon not running") return 1 with open(PID_FILE) as f: pid = int(f.read().strip()) try: if sys.platform == "win32": # On Windows, use taskkill subprocess.run(["taskkill", "/F", "/PID", str(pid)], check=True) else: # On Unix, use kill os.kill(pid, signal.SIGTERM) print(f"Stopped periodic context save daemon (PID: {pid})") PID_FILE.unlink() if STATE_FILE.exists(): STATE_FILE.unlink() return 0 except Exception as e: print(f"Failed to stop daemon (PID: {pid}): {e}") PID_FILE.unlink() return 1 def check_status(): """Check daemon status""" if not PID_FILE.exists(): print("Periodic context save daemon not running") return 1 with open(PID_FILE) as f: pid = int(f.read().strip()) # Check if process is running try: os.kill(pid, 0) except OSError: print("Daemon PID file exists but process not running") PID_FILE.unlink() return 1 state = load_state() active_seconds = state.get("active_seconds", 0) print(f"Periodic context save daemon is running (PID: {pid})") print(f"Active time: {active_seconds}s / {SAVE_INTERVAL_SECONDS}s") if state.get("last_save"): print(f"Last save: {state['last_save']}") return 0 def main(): """Main entry point""" if len(sys.argv) < 2: print("Usage: python periodic_context_save.py {start|stop|status}") print() print("Periodic context save daemon - saves context every 5 minutes of active time") print() print("Commands:") print(" start - Start the background daemon") print(" stop - Stop the daemon") print(" status - Check daemon status") return 1 command = sys.argv[1] if command == "start": return start_daemon() elif command == "stop": return stop_daemon() elif command == "status": return check_status() elif command == "_monitor": # Internal command - run monitor loop monitor_loop() return 0 else: print(f"Unknown command: {command}") return 1 if __name__ == "__main__": sys.exit(main())