Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ system prompt:
Two Claude Code hooks save periodically and before context compression:
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).

For per-message recall on top of the file-level chunks the hooks produce,
run `mempalace sweep <transcript-dir>` periodically — it stores one
verbatim drawer per user/assistant message, idempotent and resume-safe.

---

## Requirements
Expand Down
2 changes: 1 addition & 1 deletion hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Edit `mempal_save_hook.sh` to change:

- **`SAVE_INTERVAL=15`** — How many human messages between saves. Lower = more frequent saves, higher = less interruption.
- **`STATE_DIR`** — Where hook state is stored (defaults to `~/.mempalace/hook_state/`)
- **`MEMPAL_DIR`** — Optional. Set to a conversations directory to auto-run `mempalace mine <dir>` on each save trigger. Leave blank (default) to let the AI handle saving via the block reason message.
- **`MEMPAL_DIR`** — Optional **project directory** (code, notes, docs) to also mine on each save trigger, with `--mode projects`. The hook ALWAYS mines the active conversation transcript automatically with `--mode convos` — `MEMPAL_DIR` is purely additive, never an override. Leave blank if you don't want to ingest project files.
- **`MEMPALACE_PYTHON`** — Optional env var. Python interpreter with mempalace + chromadb installed. Auto-detects: `MEMPALACE_PYTHON` env var → repo `venv/bin/python3` → system `python3`. Set this if your venv is in a non-standard location.

### mempalace CLI
Expand Down
67 changes: 55 additions & 12 deletions hooks/mempal_precompact_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,18 @@
# to save everything. After the AI saves, compaction proceeds normally.
#
# === MEMPALACE CLI ===
# This repo uses: mempalace mine <dir>
# or: mempalace mine <dir> --mode convos
# Set MEMPAL_DIR below if you want the hook to auto-ingest before compaction.
# Leave blank to rely on the AI's own save instructions.
# 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: set to the directory you want auto-ingested before compaction.
# Example: MEMPAL_DIR="$HOME/conversations"
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
# 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:
Expand All @@ -64,15 +65,57 @@ fi
# Read JSON input from stdin
INPUT=$(cat)

SESSION_ID=$(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null)
# 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). Same contract as
# mempal_save_hook.sh.
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')
tp = data.get('transcript_path', '')
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
print(safe(sid))
print(safe(tp))
" 2>/dev/null)
SESSION_ID="${_mempal_parsed[0]:-unknown}"
TRANSCRIPT_PATH="${_mempal_parsed[1]:-}"

# 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"

# Optional: run mempalace ingest synchronously so memories land before compaction
# 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
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
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$STATE_DIR/hook.log" 2>&1
fi

# Silent: return empty JSON to not block. "decision": "allow" is invalid —
Expand Down
77 changes: 53 additions & 24 deletions hooks/mempal_save_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,21 @@
# stop_hook_active=true so we let it through. No infinite loop.
#
# === MEMPALACE CLI ===
# This repo uses: mempalace mine <dir>
# or: mempalace mine <dir> --mode convos
# Set MEMPAL_DIR below if you want the hook to auto-ingest after blocking.
# Leave blank to rely on the AI's own save instructions.
# 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: set to the directory you want auto-ingested on each save trigger.
# Example: MEMPAL_DIR="$HOME/conversations"
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
# 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.
Expand All @@ -82,9 +83,11 @@ fi
INPUT=$(cat)

# Parse all fields in a single Python call (3x faster than separate invocations)
# SECURITY: All values are sanitized before being interpolated into shell assignments.
# stop_hook_active is coerced to a strict True/False to prevent command injection via eval.
eval $(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
# without invoking ``eval`` on generated code: Python prints one sanitized
# value per line, the shell reads them via ``mapfile`` and does plain
# variable assignment — same data, smaller blast radius if the sanitizer
# is ever bypassed (#1231 review).
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')
Expand All @@ -94,14 +97,36 @@ tp = data.get('transcript_path', '')
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
# Coerce stop_hook_active to strict boolean string
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
print(f'SESSION_ID=\"{safe(sid)}\"')
print(f'STOP_HOOK_ACTIVE=\"{sha}\"')
print(f'TRANSCRIPT_PATH=\"{safe(tp)}\"')
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]:-}"

# 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
Expand Down Expand Up @@ -157,19 +182,23 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then

echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"

# Auto-mine the transcript. Two paths:
# 1. TRANSCRIPT_PATH (from Claude Code) — mine the directory it lives in
# 2. MEMPAL_DIR (user-configured) — mine that directory
# At least one should work. If neither is set, nothing mines.
MINE_DIR=""
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
MINE_DIR="$(dirname "$TRANSCRIPT_PATH")"
# 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
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
>> "$STATE_DIR/hook.log" 2>&1 &
Comment on lines +193 to +194

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transcript-derived mine target only checks -f "$TRANSCRIPT_PATH" before running, so any existing file path (including ones with .. segments) would be accepted and its parent directory mined. Consider adding lightweight validation (e.g., require .jsonl/.json and reject .. path components) before invoking mempalace mine "$(dirname ...)" --mode convos, to align with the Python hook’s transcript_path validation.

Suggested change
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
>> "$STATE_DIR/hook.log" 2>&1 &
case "$TRANSCRIPT_PATH" in
*.json|*.jsonl)
case "$TRANSCRIPT_PATH" in
..|../*|*/../*|*/..)
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
;;
*)
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
>> "$STATE_DIR/hook.log" 2>&1 &
;;
esac
;;
*)
echo "[$(date '+%H:%M:%S')] Skipping non-transcript path: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
;;
esac

Copilot uses AI. Check for mistakes.
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
MINE_DIR="$MEMPAL_DIR"
fi
if [ -n "$MINE_DIR" ]; then
mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$STATE_DIR/hook.log" 2>&1 &
fi

# MEMPAL_VERBOSE toggle:
Expand Down
104 changes: 67 additions & 37 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,16 +197,23 @@ def _output(data: dict):
sys.stdout.buffer.flush()


def _get_mine_dir(transcript_path: str = "") -> str:
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
def _get_mine_targets() -> list[tuple[str, str]]:
"""Return the list of ``(dir, mode)`` targets for auto-ingest.

MEMPAL_DIR (when set and resolvable) contributes a ``"projects"``
target. Transcript ingestion is handled separately by
``_ingest_transcript`` — emitting it here too would double-mine the
same JSONL into a different wing on every hook fire (#1231 review).

An empty list means no MEMPAL_DIR ingest should run.
"""
targets: list[tuple[str, str]] = []
mempal_dir = os.environ.get("MEMPAL_DIR", "")
if mempal_dir and os.path.isdir(mempal_dir):
return mempal_dir
if transcript_path:
path = Path(transcript_path).expanduser()
if path.is_file():
return str(path.parent)
return ""
if mempal_dir:
resolved = Path(mempal_dir).expanduser().resolve()
if resolved.is_dir():
targets.append((str(resolved), "projects"))
return targets
Comment on lines +212 to +216

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_mine_targets() adds a convos target for the transcript directory, but hook_stop()/hook_precompact() already call _ingest_transcript(transcript_path), which spawns a convos mine as well (with a different --wing). This can result in duplicate conversation ingestion (and potentially into different wings), plus extra load on every save/precompact. Consider consolidating to a single transcript-ingest path (either remove the transcript-derived target here and leave transcript mining to _ingest_transcript, or remove/adjust _ingest_transcript and ensure the spawned mine uses the intended --wing consistently).

Copilot uses AI. Check for mistakes.


_MINE_PID_FILE = STATE_DIR / "mine.pid"
Expand Down Expand Up @@ -263,37 +270,58 @@ def _spawn_mine(cmd: list) -> None:
_MINE_PID_FILE.write_text(str(proc.pid))


def _maybe_auto_ingest(transcript_path: str = ""):
"""Run mempalace mine in background if a mine directory is available."""
mine_dir = _get_mine_dir(transcript_path)
if not mine_dir:
def _maybe_auto_ingest():
"""Background-mine MEMPAL_DIR (project files) if set.

Transcript convos are ingested separately via ``_ingest_transcript``
in the hook handlers — this function does not handle them, to avoid
asymmetric interpreter handling and PID-file overwrite when both
targets fire from a single hook call (#1231 review).
"""
targets = _get_mine_targets()
if not targets:
return
if _mine_already_running():
_log("Skipping auto-ingest: mine already running")
return
try:
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
except OSError:
pass
for mine_dir, mode in targets:
try:
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
except OSError:
Comment on lines +287 to +290

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subprocesses here are spawned via sys.executable, but the module-level _mempalace_python() docstring explicitly calls out that sys.executable may be a system Python without chromadb when invoked by hooks. To avoid background/sync mines failing silently in those environments, consider using _mempalace_python() for the interpreter in these spawned mempalace mine commands.

Copilot uses AI. Check for mistakes.
pass


def _mine_sync():
"""Synchronously mine MEMPAL_DIR (precompact path).

def _mine_sync(transcript_path: str = ""):
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
mine_dir = _get_mine_dir(transcript_path)
if not mine_dir:
Transcript convos are ingested separately via ``_ingest_transcript``
in ``hook_precompact`` — keeping them out of this function avoids
timeout stacking against the harness 30s ceiling (#1231 review).
"""
targets = _get_mine_targets()
if not targets:
return
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
pass
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
for mine_dir, mode in targets:
try:
with open(log_path, "a") as log_f:
subprocess.run(
[
_mempalace_python(),
"-m",
"mempalace",
"mine",
mine_dir,
"--mode",
mode,
],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
pass


def _desktop_toast(body: str, title: str = "MemPalace"):
Expand Down Expand Up @@ -592,7 +620,7 @@ def hook_stop(data: dict, harness: str):
transcript_path, session_id, wing=project_wing, toast=toast
)
_ingest_transcript(transcript_path)
_maybe_auto_ingest(transcript_path)
_maybe_auto_ingest()
# Only advance save marker after successful save
count = result.get("count", 0)
if count > 0:
Expand Down Expand Up @@ -622,7 +650,7 @@ def hook_stop(data: dict, harness: str):
pass
if transcript_path:
_ingest_transcript(transcript_path)
_maybe_auto_ingest(transcript_path)
_maybe_auto_ingest()
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
_output({"decision": "block", "reason": reason})
else:
Expand Down Expand Up @@ -655,8 +683,10 @@ def hook_precompact(data: dict, harness: str):
if transcript_path:
_ingest_transcript(transcript_path)

# Mine synchronously so data lands before compaction proceeds
_mine_sync(transcript_path)
# Mine MEMPAL_DIR synchronously so project data lands before
# compaction proceeds. Transcript convos were already kicked off
# above via _ingest_transcript.
_mine_sync()

_output({})

Expand Down
Loading