c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
299 lines
13 KiB
Bash
Executable File
299 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
# MEMPALACE SAVE HOOK — Auto-save every N exchanges
|
|
#
|
|
# Claude Code "Stop" hook. After every assistant response:
|
|
# 1. Counts human messages in the session transcript
|
|
# 2. Every SAVE_INTERVAL messages, BLOCKS the AI from stopping
|
|
# 3. Returns a reason telling the AI to save structured diary + palace entries
|
|
# 4. AI does the save (topics, decisions, code, quotes → organized into palace)
|
|
# 5. Next Stop fires with stop_hook_active=true → lets AI stop normally
|
|
#
|
|
# The AI does the classification — it knows what wing/hall/closet to use
|
|
# because it has context about the conversation. No regex needed.
|
|
#
|
|
# === INSTALL ===
|
|
# Add to .claude/settings.local.json:
|
|
#
|
|
# "hooks": {
|
|
# "Stop": [{
|
|
# "matcher": "*",
|
|
# "hooks": [{
|
|
# "type": "command",
|
|
# "command": "/absolute/path/to/mempal_save_hook.sh",
|
|
# "timeout": 30
|
|
# }]
|
|
# }]
|
|
# }
|
|
#
|
|
# For Codex CLI, add to .codex/hooks.json:
|
|
#
|
|
# "Stop": [{
|
|
# "type": "command",
|
|
# "command": "/absolute/path/to/mempal_save_hook.sh",
|
|
# "timeout": 30
|
|
# }]
|
|
#
|
|
# === HOW IT WORKS ===
|
|
#
|
|
# Claude Code sends JSON on stdin with these fields:
|
|
# session_id — unique session identifier
|
|
# stop_hook_active — true if AI is already in a save cycle (prevents infinite loop)
|
|
# transcript_path — path to the JSONL transcript file
|
|
#
|
|
# When we block, Claude Code shows our "reason" to the AI as a system message.
|
|
# The AI then saves to memory, and when it tries to stop again,
|
|
# stop_hook_active=true so we let it through. No infinite loop.
|
|
#
|
|
# === MEMPALACE CLI ===
|
|
# The hook ALWAYS mines the active conversation transcript automatically
|
|
# (via `mempalace mine <transcript-dir> --mode convos`). MEMPAL_DIR is an
|
|
# *additional*, optional target for project files — it does not replace
|
|
# the conversation mine.
|
|
#
|
|
# === CONFIGURATION ===
|
|
|
|
SAVE_INTERVAL=15 # Save every N human messages (adjust to taste)
|
|
STATE_DIR="$HOME/.mempalace/hook_state"
|
|
mkdir -p "$STATE_DIR"
|
|
|
|
# Optional: project directory (code / notes / docs) to also mine each
|
|
# save trigger. Mined with `--mode projects`. The conversation transcript
|
|
# is always mined regardless — this is purely additive.
|
|
# Example: MEMPAL_DIR="$HOME/projects/my_app"
|
|
MEMPAL_DIR=""
|
|
|
|
# Resolve the Python interpreter the hook should use.
|
|
#
|
|
# Why this is nontrivial: GUI-launched Claude Code on macOS (or any harness
|
|
# that doesn't inherit the user's shell PATH) may find a `python3` on PATH
|
|
# that lacks mempalace — e.g. /usr/bin/python3 while the user installed
|
|
# mempalace into a venv or pyenv. Users in that situation can point the
|
|
# hook at the right interpreter by exporting MEMPAL_PYTHON.
|
|
#
|
|
# Resolution order (first hit wins):
|
|
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
|
|
# 2. $(command -v python3) — first python3 on the hook's PATH
|
|
# 3. bare "python3" — last-resort fallback (hope the PATH has it)
|
|
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
|
|
|
|
# ── Silent mode / opt-out ──────────────────────────────────────────────
|
|
# Set MEMPALACE_HOOKS_AUTO_SAVE=false to disable auto-save blocking entirely.
|
|
# The hook stays installed but passes through without interrupting the session.
|
|
# Can also be set in ~/.mempalace/config.json: {"hooks": {"auto_save": false}}
|
|
if [ -n "$MEMPALACE_HOOKS_AUTO_SAVE" ]; then
|
|
case "$MEMPALACE_HOOKS_AUTO_SAVE" in
|
|
false|0|no) echo "{}"; exit 0 ;;
|
|
esac
|
|
else
|
|
# Check config.json if env var is not set
|
|
CONFIG_FILE="$HOME/.mempalace/config.json"
|
|
if [ -f "$CONFIG_FILE" ]; then
|
|
AUTO_SAVE=$("$MEMPAL_PYTHON_BIN" -c "
|
|
import json, sys
|
|
try:
|
|
cfg = json.load(open(sys.argv[1]))
|
|
print(str(cfg.get('hooks', {}).get('auto_save', True)).lower())
|
|
except Exception: print('true')
|
|
" "$CONFIG_FILE" 2>/dev/null)
|
|
if [ "$AUTO_SAVE" = "false" ]; then
|
|
echo "{}"
|
|
exit 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Read JSON input from stdin
|
|
INPUT=$(cat)
|
|
|
|
# Parse all fields in a single Python call (3x faster than separate invocations)
|
|
# without invoking ``eval`` on generated code: Python prints a parse-success
|
|
# sentinel followed by one sanitized value per line, the shell reads each
|
|
# line via ``sed -n 'Np'`` and does plain variable assignment. Same data,
|
|
# smaller blast radius if the sanitizer is ever bypassed (#1231 review).
|
|
#
|
|
# Why ``sed -n 'Np'`` and not ``mapfile`` / ``readarray``: macOS ships
|
|
# GNU bash 3.2.57 (frozen at Apple's GPLv3 cutoff in 2006), and both
|
|
# array-read builtins only landed in bash 4.0 (2009). On a stock macOS
|
|
# the previous ``mapfile`` form errored, every value fell back to its
|
|
# default, and the hook silently produced zero saves (#1440).
|
|
#
|
|
# The leading ``__MEMPAL_PARSE_OK__`` sentinel lets the defense-in-depth
|
|
# guard below distinguish "Python parsed cleanly, user set session_id to
|
|
# the literal string 'unknown'" or "session_id was non-ASCII and got
|
|
# sanitized to empty" from the actual failure mode ("Python crashed,
|
|
# nothing was printed"). Without the sentinel the guard false-fires
|
|
# every Stop hook for users on i18n harnesses or unusual harness configs.
|
|
# Python stderr is captured to last_python_err.log so the guard below can
|
|
# distinguish "bad user input" (JSONDecodeError) from "broken interpreter
|
|
# / future regression in this inline script" (ImportError, SyntaxError,
|
|
# ModuleNotFoundError). Without the stderr capture, last_input.log shows
|
|
# a valid payload while the actual root cause stays hidden.
|
|
#
|
|
# Two extra hardenings inside the command-substitution subshell:
|
|
#
|
|
# * ``umask 077`` so the ``2>$STATE_DIR/last_python_err.log`` redirect
|
|
# creates the file at mode 0600 atomically. Without it, the file
|
|
# appeared briefly at the parent process's umask (often 0644) before
|
|
# the explicit ``chmod 600`` below closed it — a small TOCTOU window
|
|
# where another local user on a shared box could read the traceback,
|
|
# which can echo back the user's home + project layout.
|
|
#
|
|
# * ``printf '%s'`` in place of ``echo``. ``echo`` is unreliable for
|
|
# arbitrary payloads: it interprets ``-n``/``-e``/``-E`` as flags,
|
|
# handles backslashes inconsistently across builtin vs /bin/echo,
|
|
# and depends on the ``xpg_echo`` shopt on bash. JSON typically
|
|
# starts with ``{`` so the failure mode is latent, but flipping to
|
|
# ``printf '%s'`` removes the class of bug entirely.
|
|
_mempal_parsed=$(
|
|
umask 077
|
|
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-stop \
|
|
2>"$STATE_DIR/last_python_err.log"
|
|
)
|
|
# The 2> redirect creates the file even when stderr is empty (success).
|
|
# Remove the empty file so the state directory stays clean on the happy
|
|
# path; on failure, lock the file's permissions to 600 to mirror
|
|
# last_input.log's privacy contract (the traceback can reveal absolute
|
|
# paths inside the user's home).
|
|
if [ -s "$STATE_DIR/last_python_err.log" ]; then
|
|
chmod 600 "$STATE_DIR/last_python_err.log" 2>/dev/null
|
|
else
|
|
rm -f "$STATE_DIR/last_python_err.log"
|
|
fi
|
|
_MEMPAL_PARSE_MARKER=$(printf '%s\n' "$_mempal_parsed" | sed -n '1p')
|
|
SESSION_ID=$(printf '%s\n' "$_mempal_parsed" | sed -n '2p')
|
|
STOP_HOOK_ACTIVE=$(printf '%s\n' "$_mempal_parsed" | sed -n '3p')
|
|
TRANSCRIPT_PATH=$(printf '%s\n' "$_mempal_parsed" | sed -n '4p')
|
|
SESSION_ID="${SESSION_ID:-unknown}"
|
|
STOP_HOOK_ACTIVE="${STOP_HOOK_ACTIVE:-False}"
|
|
TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-}"
|
|
|
|
# Defense-in-depth: if INPUT was non-empty but Python never reached the
|
|
# print() calls (sentinel missing), parsing silently failed. Surface the
|
|
# raw payload so the next debugger does not lose a day to hook.log lines
|
|
# that say "Session unknown". Bounded to 4 KB and overwritten on each
|
|
# failure (not appended) to keep ~/.mempalace/hook_state/ from growing
|
|
# unbounded under a repeating misconfiguration. chmod 600 so the dump,
|
|
# which mirrors the Claude Code Stop payload (includes transcript_path
|
|
# revealing the user's home + project layout), is not world-readable.
|
|
if [ -n "$INPUT" ] && [ "$_MEMPAL_PARSE_MARKER" != "__MEMPAL_PARSE_OK__" ]; then
|
|
echo "[$(date '+%H:%M:%S')] WARN: input parse failed (sentinel missing); see $STATE_DIR/last_input.log and $STATE_DIR/last_python_err.log" >> "$STATE_DIR/hook.log"
|
|
# ``head -c 4096`` caps at exactly 4096 BYTES regardless of locale.
|
|
# Bash's ``${INPUT:0:4096}`` would count characters under a UTF-8
|
|
# locale, letting a CJK/emoji payload silently exceed the cap by up
|
|
# to 4x. ``set -o pipefail`` is not enabled in this script, so the
|
|
# natural ``head``-closes-stdin / SIGPIPE-on-printf interaction is
|
|
# silently absorbed by bash (the canonical way to read N bytes from
|
|
# a string in shell). The ``umask 077`` subshell creates
|
|
# last_input.log at mode 0600 atomically — the ``chmod 600`` below
|
|
# stays as a belt-and-suspenders guard if a future edit drops the
|
|
# umask line.
|
|
( umask 077 && printf '%s' "$INPUT" | head -c 4096 > "$STATE_DIR/last_input.log" )
|
|
chmod 600 "$STATE_DIR/last_input.log" 2>/dev/null
|
|
fi
|
|
|
|
# Expand ~ in path
|
|
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
|
|
|
# Validate that TRANSCRIPT_PATH looks like a transcript file:
|
|
# - non-empty
|
|
# - .jsonl or .json suffix
|
|
# - no traversal segments (.. components)
|
|
# Mirrors mempalace.hooks_cli._validate_transcript_path so the shell hook
|
|
# rejects the same shapes the Python hook rejects (#1231 review).
|
|
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 we're already in a save cycle, let the AI stop normally
|
|
# This is the infinite-loop prevention: block once → AI saves → tries to stop again → we let it through
|
|
if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
|
echo "{}"
|
|
exit 0
|
|
fi
|
|
|
|
# Count human messages in the JSONL transcript
|
|
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
|
|
if [ -f "$TRANSCRIPT_PATH" ]; then
|
|
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell count-human-messages "$TRANSCRIPT_PATH" 2>/dev/null)
|
|
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
|
echo "[$(date '+%H:%M:%S')] WARN: transcript_path not found after normalization: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
|
|
EXCHANGE_COUNT=0
|
|
else
|
|
EXCHANGE_COUNT=0
|
|
fi
|
|
|
|
# Track last save point for this session
|
|
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")
|
|
# SECURITY: Validate as plain integer before arithmetic to prevent command injection
|
|
if [[ "$LAST_SAVE_RAW" =~ ^[0-9]+$ ]]; then
|
|
LAST_SAVE="$LAST_SAVE_RAW"
|
|
fi
|
|
fi
|
|
|
|
SINCE_LAST=$((EXCHANGE_COUNT - LAST_SAVE))
|
|
|
|
# Log for debugging (check ~/.mempalace/hook_state/hook.log)
|
|
echo "[$(date '+%H:%M:%S')] Session $SESSION_ID: $EXCHANGE_COUNT exchanges, $SINCE_LAST since last save" >> "$STATE_DIR/hook.log"
|
|
|
|
# Time to save?
|
|
if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
|
# Update last save point
|
|
echo "$EXCHANGE_COUNT" > "$LAST_SAVE_FILE"
|
|
|
|
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
|
|
|
|
# Auto-mine. Two independent targets — both run if both are set:
|
|
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
|
|
# (Claude Code session JSONL — must use the convo miner)
|
|
# 2. MEMPAL_DIR (user-configured project) → --mode projects
|
|
# (code, notes, docs)
|
|
# MEMPAL_DIR is *additive*, not an override: a user with MEMPAL_DIR
|
|
# pointed at their project still gets the active conversation mined.
|
|
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
|
>> "$STATE_DIR/hook.log" 2>&1 &
|
|
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
|
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
|
>> "$STATE_DIR/hook.log"
|
|
fi
|
|
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
|
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
|
|
>> "$STATE_DIR/hook.log" 2>&1 &
|
|
fi
|
|
|
|
# MEMPAL_VERBOSE toggle:
|
|
# true = developer mode — block and show diaries/code in chat
|
|
# false = silent mode (default) — save in background, no chat clutter
|
|
# Set via: export MEMPAL_VERBOSE=true
|
|
if [ "$MEMPAL_VERBOSE" = "true" ] || [ "$MEMPAL_VERBOSE" = "1" ]; then
|
|
cat << 'HOOKJSON'
|
|
{
|
|
"decision": "block",
|
|
"reason": "MemPalace save checkpoint. Write a brief session diary entry covering key topics, decisions, and code changes since the last save. Use verbatim quotes where possible. Continue after saving."
|
|
}
|
|
HOOKJSON
|
|
else
|
|
# Silent mode: return empty JSON to not block. "decision": "allow" is
|
|
# not a valid value — only "block" or {} are recognized.
|
|
echo '{}'
|
|
fi
|
|
else
|
|
# Not time yet — let the AI stop normally
|
|
echo "{}"
|
|
fi
|