#!/bin/bash # MEMPALACE SAVE HOOK (REMOTE) — Auto-save every N exchanges to a remote palace. # # Drop-in replacement for mempal_save_hook.sh when MemPalace runs on a # server (e.g. Unraid) instead of the dev machine. Same trigger logic # (count human messages, fire every SAVE_INTERVAL), but instead of running # `mempalace mine` locally it POSTs the active transcript to the server's # /ingest/transcript endpoint. # # Required env vars: # MEMPAL_REMOTE_URL Base URL of the MemPalace server, e.g. # https://unraid.local:8443 # MEMPAL_REMOTE_TOKEN Bearer token (same one configured in the server's # .env / MEMPAL_TOKEN). # # Optional env vars: # MEMPAL_REMOTE_WING Wing name to file under (defaults to the # session-id-derived inbox name on the server). # MEMPAL_REMOTE_INSECURE "1" to skip TLS verification — needed when # the server uses Caddy's self-signed `tls # internal` cert and the client hasn't trusted # the Caddy root CA. # SAVE_INTERVAL Override the default of 15 messages. # # === INSTALL === # Add to .claude/settings.local.json (Claude Code): # # "hooks": { # "Stop": [{ # "matcher": "*", # "hooks": [{ # "type": "command", # "command": "/abs/path/to/mempal_save_hook_remote.sh", # "timeout": 30 # }] # }] # } # # For Codex CLI, add the same shape to .codex/hooks.json. set -u SAVE_INTERVAL="${SAVE_INTERVAL:-15}" STATE_DIR="$HOME/.mempalace/hook_state" mkdir -p "$STATE_DIR" # Resolve Python — used only for parsing the hook's stdin JSON. MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}" if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)" fi # Pre-flight: bail with a clean no-op if config is missing. Returning {} # lets Claude Code stop normally; we log the reason for the user to find. if [ -z "${MEMPAL_REMOTE_URL:-}" ] || [ -z "${MEMPAL_REMOTE_TOKEN:-}" ]; then echo "[$(date '+%H:%M:%S')] MEMPAL_REMOTE_URL/TOKEN not set — skipping" \ >> "$STATE_DIR/hook.log" echo "{}" exit 0 fi INPUT=$(cat) # Parse session_id, stop_hook_active, transcript_path in one Python call — # same sanitization shape as the local hook. mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c " import sys, json, re data = json.load(sys.stdin) sid = data.get('session_id', 'unknown') sha_raw = data.get('stop_hook_active', False) tp = data.get('transcript_path', '') safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s)) sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False' print(safe(sid)) print(sha) print(safe(tp)) " 2>/dev/null) SESSION_ID="${_mempal_parsed[0]:-unknown}" STOP_HOOK_ACTIVE="${_mempal_parsed[1]:-False}" TRANSCRIPT_PATH="${_mempal_parsed[2]:-}" TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}" is_valid_transcript_path() { local path="$1" [ -n "$path" ] || return 1 case "$path" in *.json|*.jsonl) ;; *) return 1 ;; esac case "/$path/" in */../*) return 1 ;; esac return 0 } if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then echo "{}" exit 0 fi # Count human messages (same logic as local hook). if [ -f "$TRANSCRIPT_PATH" ]; then EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF' import json, sys count = 0 with open(sys.argv[1]) as f: for line in f: try: entry = json.loads(line) msg = entry.get('message', {}) if isinstance(msg, dict) and msg.get('role') == 'user': content = msg.get('content', '') if isinstance(content, str) and '' in content: continue count += 1 except Exception: pass print(count) PYEOF 2>/dev/null) else EXCHANGE_COUNT=0 fi LAST_SAVE_FILE="$STATE_DIR/${SESSION_ID}_last_save" LAST_SAVE=0 if [ -f "$LAST_SAVE_FILE" ]; then LAST_SAVE_RAW=$(cat "$LAST_SAVE_FILE") if [[ "$LAST_SAVE_RAW" =~ ^[0-9]+$ ]]; then LAST_SAVE="$LAST_SAVE_RAW" fi fi SINCE_LAST=$((EXCHANGE_COUNT - LAST_SAVE)) echo "[$(date '+%H:%M:%S')] Session $SESSION_ID: $EXCHANGE_COUNT exchanges, $SINCE_LAST since last save" \ >> "$STATE_DIR/hook.log" if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then echo "$EXCHANGE_COUNT" > "$LAST_SAVE_FILE" CURL_OPTS=("-sS" "--max-time" "30" "-X" "POST") [ "${MEMPAL_REMOTE_INSECURE:-0}" = "1" ] && CURL_OPTS+=("-k") WING_HEADER=() [ -n "${MEMPAL_REMOTE_WING:-}" ] && WING_HEADER=(-H "X-Wing: $MEMPAL_REMOTE_WING") # Background the upload so we don't block the AI's stop. The hook # exits immediately with {} — the next save retry will catch any # transient failure (the miner is idempotent server-side). ( curl "${CURL_OPTS[@]}" \ -H "Authorization: Bearer $MEMPAL_REMOTE_TOKEN" \ -H "X-Session-Id: $SESSION_ID" \ -H "Content-Type: application/octet-stream" \ "${WING_HEADER[@]}" \ --data-binary "@$TRANSCRIPT_PATH" \ "$MEMPAL_REMOTE_URL/ingest/transcript" \ >> "$STATE_DIR/hook.log" 2>&1 \ && echo "[$(date '+%H:%M:%S')] ingest ok" >> "$STATE_DIR/hook.log" \ || echo "[$(date '+%H:%M:%S')] ingest failed (will retry next save)" \ >> "$STATE_DIR/hook.log" ) & disown elif [ -n "$TRANSCRIPT_PATH" ]; then echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \ >> "$STATE_DIR/hook.log" fi fi echo "{}"