171 lines
5.9 KiB
Bash
171 lines
5.9 KiB
Bash
#!/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 '<command-message>' 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 "{}"
|