Files
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

491 lines
19 KiB
Bash

# shellcheck shell=bash
# MEMPALACE CURSOR HOOK — shared helpers
#
# Sourced by the three Cursor hooks (stop / preCompact / sessionStart).
# Mirrors the conventions of the existing Claude Code hook scripts
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh) so a
# user who already debugs one knows how to debug the other:
#
# * STATE_DIR layout under ~/.mempalace/hook_state/
# * MEMPAL_PYTHON resolution order (override → $PATH → bare python3)
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
# * fail-open on internal errors: emit `{}` and log, never crash the
# hook host
#
# Cursor-specific additions on top of that contract:
#
# * MEMPAL_DISABLE_HOOK=1 as an additional kill-switch alias
# * MEMPAL_STATE_DIR env override for the state directory
# * conversation_id (Cursor's stable per-conversation ID) replaces
# Claude Code's session_id in the counter file names — Cursor `stop`
# events do not carry a session_id, only conversation_id
# * loop_count is the loop-prevention signal in place of Claude Code's
# stop_hook_active flag (Cursor docs, "stop" event)
#
# This file is sourced, not executed, so it intentionally has no
# shebang. The `# shellcheck shell=bash` directive above tells
# shellcheck to treat it as bash when run standalone.
# ── State directory + log path ────────────────────────────────────────
#
# Honour MEMPAL_STATE_DIR (additive override introduced for Cursor)
# while keeping the default identical to the Claude Code hooks so a
# user running both keeps a single state directory.
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
MEMPAL_CURSOR_LOG="$MEMPAL_STATE_DIR/cursor_hook.log"
# ── Python interpreter resolution ─────────────────────────────────────
#
# Same contract as the Claude Code hooks:
# 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
mempal_resolve_python() {
local p="${MEMPAL_PYTHON:-}"
if [ -n "$p" ] && [ -x "$p" ]; then
printf '%s' "$p"
return 0
fi
p="$(command -v python3 2>/dev/null || true)"
if [ -n "$p" ]; then
printf '%s' "$p"
return 0
fi
printf '%s' "python3"
}
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
# ── Logging ───────────────────────────────────────────────────────────
#
# Lines are `[ISO8601Z] [event=...] [conv=...] message`. ISO8601 keeps
# the format greppable across timezones (the Claude Code log uses
# %H:%M:%S which loses the date — we improve on that here without
# changing the existing log file).
mempal_log() {
local event="${1:-?}"
local conv="${2:-unknown}"
local msg="${3:-}"
local ts
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
>> "$MEMPAL_CURSOR_LOG" 2>/dev/null
}
# ── Kill switch ───────────────────────────────────────────────────────
#
# Disabled if ANY of:
# * MEMPAL_DISABLE_HOOK is a truthy string (Cursor-prompt addition)
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no (Claude Code convention)
# * ~/.mempalace/config.json has hooks.auto_save == false
#
# Returns 0 (true in shell) when disabled, 1 when enabled.
mempal_is_disabled() {
case "${MEMPAL_DISABLE_HOOK:-}" in
1|true|yes|on) return 0 ;;
esac
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
false|0|no|off) return 0 ;;
esac
local cfg="$HOME/.mempalace/config.json"
if [ -f "$cfg" ]; then
local result
# Use python -c '...' with the config path as argv[1] rather
# than a heredoc. A heredoc body that contains parens inside a
# $(...) command substitution trips the bash 3.2.57 parser
# bug (macOS /bin/bash default) — gh-PR review caught this.
# The -c form is also consistent with mempal_parse_stdin below.
result="$("$MEMPAL_PYTHON_BIN" -c '
import json, sys
try:
with open(sys.argv[1]) as f:
cfg = json.load(f)
print(str(cfg.get("hooks", {}).get("auto_save", True)).lower())
except Exception:
print("true")
' "$cfg" 2>/dev/null)"
if [ "$result" = "false" ]; then
return 0
fi
fi
return 1
}
# ── Stdin parser ──────────────────────────────────────────────────────
#
# Reads Cursor's hook JSON from $1 and exports:
# MEMPAL_CONV_ID — conversation_id, falls back to "unknown"
# MEMPAL_LOOP_COUNT — integer (0 if absent / non-numeric)
# MEMPAL_TRANSCRIPT — transcript_path, may be empty
# MEMPAL_WORKSPACE — first workspace_roots entry, falls back to
# CURSOR_PROJECT_DIR env var, then $PWD
# MEMPAL_TRIGGER — preCompact trigger ("auto" | "manual"), empty otherwise
# MEMPAL_STATUS — stop status ("completed" | "aborted" | "error"),
# empty otherwise
# MEMPAL_PARSE_OK — "1" if parser ran cleanly, "0" otherwise
#
# Uses the same sentinel + `sed -n 'Np'` extraction as the Claude Code
# hooks for bash 3.2 compatibility (mapfile/readarray are unavailable
# on macOS /bin/bash 3.2.57; #1440 regression). Each line of output is
# pre-sanitised by the Python side to a shell-safe character set.
mempal_parse_stdin() {
local input="${1:-}"
local parsed
# We invoke Python via -c with a single-quoted multi-line string
# rather than ``python3 - <<'PYEOF'`` because the heredoc form
# would shadow Python's stdin with the heredoc body, leaving
# ``json.load(sys.stdin)`` to read nothing and silently fail. The
# parser body deliberately uses only double-quoted Python strings
# so the surrounding bash single-quote is safe verbatim, and uses
# only the shell-safe character set (alphanumeric, underscore,
# dash, slash, dot, tilde) matching the Claude Code hook's
# sanitiser so a hostile transcript_path cannot splice
# metacharacters into the parsed output.
parsed="$(
umask 077
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c '
import json, re, sys
def safe_str(value):
return re.sub(r"[^a-zA-Z0-9_/.\-~]", "", str(value or ""))
def safe_int(value):
try:
return str(int(value))
except (TypeError, ValueError):
return "0"
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(1)
if not isinstance(data, dict):
sys.exit(1)
conv = safe_str(data.get("conversation_id") or data.get("session_id"))
loop_count = safe_int(data.get("loop_count", 0))
transcript = safe_str(data.get("transcript_path", ""))
trigger = safe_str(data.get("trigger", ""))
status = safe_str(data.get("status", ""))
roots = data.get("workspace_roots") or []
workspace = ""
if isinstance(roots, list) and roots:
workspace = safe_str(roots[0])
print("__MEMPAL_PARSE_OK__")
print(conv)
print(loop_count)
print(transcript)
print(workspace)
print(trigger)
print(status)
' 2>"$MEMPAL_STATE_DIR/cursor_last_python_err.log"
)"
# Drop empty stderr capture on success; lock it to 0600 on failure
# (mirrors the privacy contract in the Claude Code hooks — the
# traceback can echo back transcript_path / home layout).
if [ -s "$MEMPAL_STATE_DIR/cursor_last_python_err.log" ]; then
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
else
rm -f "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
fi
local marker
marker="$(printf '%s\n' "$parsed" | sed -n '1p')"
if [ "$marker" = "__MEMPAL_PARSE_OK__" ]; then
MEMPAL_PARSE_OK="1"
MEMPAL_CONV_ID="$(printf '%s\n' "$parsed" | sed -n '2p')"
MEMPAL_LOOP_COUNT="$(printf '%s\n' "$parsed" | sed -n '3p')"
MEMPAL_TRANSCRIPT="$(printf '%s\n' "$parsed" | sed -n '4p')"
MEMPAL_WORKSPACE="$(printf '%s\n' "$parsed" | sed -n '5p')"
MEMPAL_TRIGGER="$(printf '%s\n' "$parsed" | sed -n '6p')"
MEMPAL_STATUS="$(printf '%s\n' "$parsed" | sed -n '7p')"
else
MEMPAL_PARSE_OK="0"
MEMPAL_CONV_ID=""
MEMPAL_LOOP_COUNT="0"
MEMPAL_TRANSCRIPT=""
MEMPAL_WORKSPACE=""
MEMPAL_TRIGGER=""
MEMPAL_STATUS=""
fi
# Defaults and environment fallbacks. The Cursor docs guarantee
# CURSOR_TRANSCRIPT_PATH and CURSOR_PROJECT_DIR env vars are set
# for every hook execution; if JSON parsing failed for any reason
# (sentinel missing, malformed payload, missing interpreter) we
# still have a usable workspace.
MEMPAL_CONV_ID="${MEMPAL_CONV_ID:-unknown}"
case "$MEMPAL_LOOP_COUNT" in
''|*[!0-9]*) MEMPAL_LOOP_COUNT="0" ;;
esac
if [ -z "$MEMPAL_TRANSCRIPT" ] && [ -n "${CURSOR_TRANSCRIPT_PATH:-}" ]; then
MEMPAL_TRANSCRIPT="${CURSOR_TRANSCRIPT_PATH}"
fi
if [ -z "$MEMPAL_WORKSPACE" ]; then
if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then
MEMPAL_WORKSPACE="${CURSOR_PROJECT_DIR}"
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
MEMPAL_WORKSPACE="${CLAUDE_PROJECT_DIR}"
else
MEMPAL_WORKSPACE="${PWD:-/}"
fi
fi
# Expand a leading ~ in the transcript path so downstream
# ``[ -f "$path" ]`` checks resolve correctly.
case "$MEMPAL_TRANSCRIPT" in
'~/'*) MEMPAL_TRANSCRIPT="$HOME/${MEMPAL_TRANSCRIPT#~/}" ;;
esac
}
# ── Defense-in-depth: dump unparseable stdin ──────────────────────────
#
# Same shape as the Claude Code hooks' last_input.log: bounded to 4096
# bytes, overwritten (never appended) so a misconfiguration loop cannot
# grow disk usage, 0600 perms because the dump mirrors the raw hook
# payload (transcript_path reveals the user's home + project layout).
mempal_dump_bad_input() {
local input="${1:-}"
if [ -z "$input" ]; then
return 0
fi
mempal_log "${2:-?}" "${MEMPAL_CONV_ID:-unknown}" \
"WARN: input parse failed (sentinel missing); see $MEMPAL_STATE_DIR/cursor_last_input.log + cursor_last_python_err.log"
(
umask 077
printf '%s' "$input" | head -c 4096 > "$MEMPAL_STATE_DIR/cursor_last_input.log"
)
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_input.log" 2>/dev/null
}
# ── Counter helpers ───────────────────────────────────────────────────
#
# One counter file per conversation_id. Atomic write via temp file
# inside the same directory (rename is atomic on POSIX) so concurrent
# hook invocations cannot half-write the file. Read tolerates a
# corrupted or empty file by returning 0, never crashing.
_mempal_counter_path() {
local conv="${1:-unknown}"
# Sanitise the conv id one more time: it has already been through
# the Python sanitiser, but be defensive in case a caller passes a
# raw string. Strip any character outside [a-zA-Z0-9_.-].
local safe_conv
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
if [ -z "$safe_conv" ]; then
safe_conv="unknown"
fi
printf '%s/cursor_%s.count' "$MEMPAL_STATE_DIR" "$safe_conv"
}
mempal_read_counter() {
local path="$1"
if [ ! -f "$path" ]; then
printf '0'
return 0
fi
local raw
raw="$(cat "$path" 2>/dev/null)"
case "$raw" in
''|*[!0-9]*) printf '0' ;;
*) printf '%s' "$raw" ;;
esac
}
mempal_write_counter_atomic() {
local path="$1"
local value="$2"
case "$value" in
''|*[!0-9]*) value="0" ;;
esac
local tmp="${path}.tmp.$$"
printf '%s' "$value" > "$tmp" 2>/dev/null || return 1
mv "$tmp" "$path" 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
return 1
}
}
# ── Pending-save marker ───────────────────────────────────────────────
#
# Dropped by the preCompact hook (which cannot itself emit a
# followup_message — Cursor's preCompact is observational-only) and
# consumed by the next stop invocation so the LLM still gets a diary
# nudge after compaction.
_mempal_pending_path() {
local conv="${1:-unknown}"
local safe_conv
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
if [ -z "$safe_conv" ]; then
safe_conv="unknown"
fi
printf '%s/cursor_%s.pending' "$MEMPAL_STATE_DIR" "$safe_conv"
}
mempal_set_pending() {
local conv="${1:-unknown}"
local path
path="$(_mempal_pending_path "$conv")"
: > "$path" 2>/dev/null || return 1
chmod 600 "$path" 2>/dev/null
}
mempal_consume_pending() {
local conv="${1:-unknown}"
local path
path="$(_mempal_pending_path "$conv")"
if [ -f "$path" ]; then
rm -f "$path" 2>/dev/null
return 0
fi
return 1
}
# ── State-file TTL ────────────────────────────────────────────────────
#
# Per-conversation state artifacts (cursor_<conv>.count and
# cursor_<conv>.pending) accumulate one set per conversation and are
# never otherwise removed (igorls review, PR #1632 — unbounded state
# growth). Reads MEMPAL_STATE_TTL_DAYS (default 30), validated
# digits-only and leading-zero-stripped (mirrors the SAVE_INTERVAL
# sanitiser) so `find -mtime` never sees a bad or octal token. Empty or
# non-numeric floors to 30; a value of 0 means "sweep everything older
# than today".
mempal_state_ttl_days() {
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
case "$raw" in
''|*[!0-9]*) printf '30'; return 0 ;;
esac
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
raw="${raw#0}"
done
printf '%s' "$raw"
}
# ── Stale state GC ────────────────────────────────────────────────────
#
# Opportunistic sweep of per-conversation Cursor state older than the
# TTL. Throttled to at most once per 24h via the cursor_last_sweep
# marker, so it costs a single mtime comparison on the vast majority of
# fires. When it does run, two `find` passes remove the stale counter
# files and pending markers.
#
# The globs are Cursor-specific and suffix-anchored (cursor_*.count,
# cursor_*.pending), so the shared logs (cursor_hook.log,
# cursor_last_input.log, cursor_last_python_err.log), the
# cursor_last_sweep marker itself, and any antigravity_*/Claude state
# sharing the same directory are never touched. BSD find (macOS default)
# and GNU find both accept -maxdepth, -mtime +N, and -exec ... +.
#
# Fail-open: every step is best-effort; a missing state dir, a find that
# errors, or a permission problem must never abort the caller.
mempal_gc_stale_state() {
[ -d "$MEMPAL_STATE_DIR" ] || return 0
local marker="$MEMPAL_STATE_DIR/cursor_last_sweep"
if [ -f "$marker" ]; then
local mtime now
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
&& now=$(date '+%s' 2>/dev/null) \
&& [ -n "$mtime" ] \
&& [ "$((now - mtime))" -lt 86400 ]; then
return 0
fi
fi
# Touch the marker first so a crash mid-sweep still throttles the
# next fire (better to skip a sweep than to hammer the disk).
: > "$marker" 2>/dev/null
local ttl
ttl=$(mempal_state_ttl_days)
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
-name 'cursor_*.count' -mtime +"$ttl" \
-exec rm -f {} + 2>/dev/null
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
-name 'cursor_*.pending' -mtime +"$ttl" \
-exec rm -f {} + 2>/dev/null
return 0
}
# ── Workspace → wing inference ────────────────────────────────────────
#
# basename(workspace_root), normalised to [a-z0-9_-]. Edge cases:
# / → "root"
# /path/ → trailing slash stripped, then basename
# "/foo bar/" → "foo_bar" (spaces collapsed to underscores)
# "" → "cursor_session"
# "C:\\proj" → "proj" (Windows-style path; basename via tr fallback)
#
# We intentionally keep this in pure bash + POSIX tools so the
# inference is identical across the hook scripts and the test suite
# can target it as a function via `bash -c 'source ...; ...'`.
mempal_infer_wing() {
local raw="${1:-}"
if [ -z "$raw" ]; then
printf 'cursor_session'
return 0
fi
# Strip trailing slashes (but preserve the lone "/" case).
while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do
raw="${raw%/}"
done
if [ "$raw" = "/" ]; then
printf 'root'
return 0
fi
local base="${raw##*/}"
# On Windows-style paths with backslashes, fall back to splitting
# on backslash too so we don't return the whole path verbatim.
case "$base" in
*\\*) base="${base##*\\}" ;;
esac
# Lowercase + replace anything outside [a-z0-9_-] with underscore.
# Collapse runs of underscores so "foo bar" doesn't become
# "foo__bar".
base="$(printf '%s' "$base" \
| tr '[:upper:]' '[:lower:]' \
| tr -c 'a-z0-9_-' '_' \
| tr -s '_' \
| sed 's/^_//; s/_$//')"
if [ -z "$base" ]; then
printf 'cursor_session'
return 0
fi
printf '%s' "$base"
}
# ── Transcript path validation ────────────────────────────────────────
#
# Mirrors hooks/mempal_save_hook.sh::is_valid_transcript_path so the
# Cursor and Claude Code hooks reject the same shapes:
# * non-empty
# * .json or .jsonl suffix
# * no .. traversal segments
mempal_is_valid_transcript() {
local path="${1:-}"
[ -n "$path" ] || return 1
case "$path" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$path/" in
*/../*) return 1 ;;
esac
return 0
}
# ── JSON emit ─────────────────────────────────────────────────────────
#
# Final stdout write. Uses ``printf '%s'`` instead of ``echo`` because
# echo interprets ``-n``/``-e``/``-E`` as flags and varies in backslash
# handling between builtin and /bin/echo (xpg_echo shopt). Matches the
# Claude Code hook's documented rationale.
mempal_emit() {
printf '%s\n' "${1:-{\}}"
}