Files
mempalace--mempalace/hooks/mempal_precompact_hook.sh
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

199 lines
8.3 KiB
Bash
Executable File

#!/bin/bash
# MEMPALACE PRE-COMPACT HOOK — Emergency save before compaction
#
# Claude Code "PreCompact" hook. Fires RIGHT BEFORE the conversation
# gets compressed to free up context window space.
#
# This is the safety net. When compaction happens, the AI loses detailed
# context about what was discussed. This hook forces one final save of
# EVERYTHING before that happens.
#
# Unlike the save hook (which gates on a message-count threshold and on
# MEMPAL_VERBOSE), this runs the mine synchronously on every PreCompact
# event — compaction is always worth saving before. The hook itself
# returns ``{}`` so it does not emit a ``decision: block`` to Claude
# Code; the "always run" semantics live in the mine call, not in the
# Stop-hook block protocol.
#
# === INSTALL ===
# Add to .claude/settings.local.json:
#
# "hooks": {
# "PreCompact": [{
# "hooks": [{
# "type": "command",
# "command": "/absolute/path/to/mempal_precompact_hook.sh",
# "timeout": 30
# }]
# }]
# }
#
# For Codex CLI, add to .codex/hooks.json:
#
# "PreCompact": [{
# "type": "command",
# "command": "/absolute/path/to/mempal_precompact_hook.sh",
# "timeout": 30
# }]
#
# === HOW IT WORKS ===
#
# Claude Code sends JSON on stdin with:
# session_id — unique session identifier
# transcript_path — path to the JSONL transcript file
#
# The hook runs the transcript mine synchronously (the foreground
# ``mempalace mine`` call below blocks until it returns), then prints
# ``{}`` to stdout so Claude Code proceeds with the compaction. We do
# not emit a ``decision: block`` to the hook protocol — the
# "always save before compaction" guarantee is provided by the
# synchronous mine, not by the Stop-hook block contract.
#
# === MEMPALACE CLI ===
# The hook ALWAYS mines the active conversation transcript synchronously
# before compaction (via `mempalace mine <transcript-dir> --mode convos`).
# MEMPAL_DIR is an *additional*, optional target for project files — it
# does not replace the conversation mine.
STATE_DIR="$HOME/.mempalace/hook_state"
mkdir -p "$STATE_DIR"
# Optional: project directory (code / notes / docs) to also mine before
# compaction. 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. Same contract as mempal_save_hook.sh:
# MEMPAL_PYTHON (explicit override) → $(command -v python3) → bare python3.
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.
if [ -n "$MEMPALACE_HOOKS_AUTO_SAVE" ]; then
case "$MEMPALACE_HOOKS_AUTO_SAVE" in
false|0|no) echo "{}"; exit 0 ;;
esac
else
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 session_id and transcript_path in one call. Sanitize both, then
# read sanitized values from one-per-line stdout into shell variables
# (avoids ``eval`` on generated code, #1231 review). Uses ``sed -n 'Np'``
# rather than the bash 4-only ``mapfile`` so the script also runs on
# macOS /bin/bash 3.2.57 (Apple GPLv3 freeze, 2006), where ``mapfile``
# silently caused every parsed value to fall back to its default (#1440).
#
# The leading ``__MEMPAL_PARSE_OK__`` sentinel lets the defense-in-depth
# guard below distinguish "Python parsed cleanly" from "Python crashed
# and printed nothing". Same parsing contract as mempal_save_hook.sh.
# Python stderr is captured to last_python_err.log so the guard below can
# distinguish "bad user input" from "broken interpreter / future regression
# in this inline script". Same diagnostic contract as mempal_save_hook.sh.
#
# ``umask 077`` inside the command-substitution subshell makes the
# ``2>$STATE_DIR/last_python_err.log`` redirect create the file at mode
# 0600 atomically, closing the TOCTOU window between creation at
# umask-default and the ``chmod 600`` below. ``printf '%s'`` replaces
# ``echo`` so payloads beginning with ``-n``/``-e``/``-E`` or containing
# backslashes are not mangled by echo flag parsing.
_mempal_parsed=$(
umask 077
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-precompact \
2>"$STATE_DIR/last_python_err.log"
)
# Drop the empty file on success; chmod 600 on failure to mirror
# last_input.log's privacy contract.
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')
TRANSCRIPT_PATH=$(printf '%s\n' "$_mempal_parsed" | sed -n '3p')
SESSION_ID="${SESSION_ID:-unknown}"
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 PreCompact 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`` is a byte cap, locale-independent; ``${INPUT:0:4096}``
# would count characters under UTF-8 and slip a multibyte payload past
# the bound. ``set -o pipefail`` is not enabled in this script so the
# natural SIGPIPE-on-printf from ``head`` closing stdin is absorbed.
# ``umask 077`` in the subshell creates last_input.log at mode 0600
# atomically — the ``chmod 600`` below stays as belt-and-suspenders.
( 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. 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
}
echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$STATE_DIR/hook.log"
# Run ingest synchronously so memories land before compaction. Two
# independent targets — both run if both are set:
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
# 2. MEMPAL_DIR → --mode projects
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 missing or invalid transcript path after normalization: $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
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
# only "block" or {} are recognized.
echo '{}'