#!/usr/bin/env bash # backup-memory.sh — PreToolUse hook for memory write tools. # Snapshots the memory graph FILE before a write. Debounced so one apply-run # produces a single backup. Never blocks the tool call (always exits 0). # # Config (env, all optional): # KG_MEMORY_FILE explicit path to the MCP memory server's JSON store # (falls back to MEMORY_FILE_PATH, the official server's var) # KG_BACKUP_DIR where backups land (default: ~/.claude/kg-backups) # KG_BACKUP_DEBOUNCE seconds; skip if a newer backup exists (default: 120) # KG_BACKUP_KEEP how many backups to retain (default: 20) # # Tip: set KG_MEMORY_FILE in your shell / Claude Code env so the hook knows # which file to copy. Without it the hook logs a notice and exits cleanly. set -uo pipefail # Drain stdin (hook receives JSON; we don't need it) so the pipe never blocks. cat >/dev/null 2>&1 || true MEM="${KG_MEMORY_FILE:-${MEMORY_FILE_PATH:-}}" BACKUP_DIR="${KG_BACKUP_DIR:-$HOME/.claude/kg-backups}" DEBOUNCE="${KG_BACKUP_DEBOUNCE:-120}" KEEP="${KG_BACKUP_KEEP:-20}" LOG="$BACKUP_DIR/backup.log" note() { mkdir -p "$BACKUP_DIR" 2>/dev/null || true; echo "[$(date -Iseconds)] $*" >>"$LOG" 2>/dev/null || true; } # No file configured or file missing -> notice + clean exit (never block writes). if [ -z "$MEM" ]; then note "no KG_MEMORY_FILE/MEMORY_FILE_PATH set; skipping file backup" exit 0 fi if [ ! -f "$MEM" ]; then note "memory file not found at '$MEM'; skipping" exit 0 fi mkdir -p "$BACKUP_DIR" 2>/dev/null || { note "cannot create $BACKUP_DIR"; exit 0; } # Debounce: if the newest existing backup is younger than DEBOUNCE seconds, skip. newest="$(ls -t "$BACKUP_DIR"/kg-backup-*.json 2>/dev/null | head -n1 || true)" if [ -n "$newest" ]; then age=$(( $(date +%s) - $(stat -c %Y "$newest" 2>/dev/null || echo 0) )) if [ "$age" -lt "$DEBOUNCE" ]; then note "debounced (last backup ${age}s ago < ${DEBOUNCE}s)" exit 0 fi fi ts="$(date +%Y%m%dT%H%M%S)" dest="$BACKUP_DIR/kg-backup-$ts.json" if cp "$MEM" "$dest" 2>/dev/null; then note "backup ok -> $dest" else note "backup FAILED copying $MEM" exit 0 fi # Prune: keep only the most recent $KEEP backups. mapfile -t old < <(ls -t "$BACKUP_DIR"/kg-backup-*.json 2>/dev/null | tail -n +"$((KEEP+1))") for f in "${old[@]:-}"; do [ -n "$f" ] && rm -f "$f" 2>/dev/null || true; done exit 0