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
1377 lines
52 KiB
Python
1377 lines
52 KiB
Python
"""
|
|
Hook logic for MemPalace — Python implementation of session-start, stop, session-end, and precompact hooks.
|
|
|
|
Reads JSON from stdin, outputs JSON to stdout.
|
|
Supported hooks: session-start, stop, session-end, precompact
|
|
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from mempalace.config import MempalaceConfig
|
|
|
|
SAVE_INTERVAL = 15
|
|
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
|
|
PALACE_ROOT = Path.home() / ".mempalace"
|
|
|
|
|
|
def _detached_popen_kwargs() -> dict:
|
|
"""Kwargs that give a Popen child a hidden console so the hook can exit.
|
|
|
|
Without these, Windows holds the parent open until the child closes the
|
|
inherited stdout/stderr handles — manifesting as "Stop hook hangs" at
|
|
session end (#1268). On POSIX the parent can already exit (orphan
|
|
reparents to init), but ``start_new_session`` makes the boundary
|
|
explicit so signals to the hook don't propagate to the background mine.
|
|
"""
|
|
kwargs: dict = {"stdin": subprocess.DEVNULL, "close_fds": True}
|
|
if os.name == "nt":
|
|
flags = 0
|
|
for name in ("CREATE_NO_WINDOW", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
|
|
flags |= getattr(subprocess, name, 0)
|
|
if flags:
|
|
kwargs["creationflags"] = flags
|
|
else:
|
|
kwargs["start_new_session"] = True
|
|
return kwargs
|
|
|
|
|
|
def _palace_root_exists() -> bool:
|
|
"""User-removable kill-switch.
|
|
|
|
If ~/.mempalace/ does not exist, the user has explicitly cleared it.
|
|
All hook side effects (logging, state dir creation, mining, ingestion)
|
|
must respect this and short-circuit BEFORE touching disk — including
|
|
before logging the short-circuit itself.
|
|
|
|
Uses ``is_dir()`` rather than ``exists()`` so a stray regular file at
|
|
``~/.mempalace`` (or a broken symlink) is treated as absent — otherwise
|
|
the kill-switch would be bypassed and ``STATE_DIR.mkdir()`` would later
|
|
crash on ``NotADirectoryError``.
|
|
"""
|
|
return PALACE_ROOT.is_dir()
|
|
|
|
|
|
def _mempalace_python() -> str:
|
|
"""Return the python interpreter that has mempalace installed.
|
|
|
|
When hooks are invoked by Claude Code, sys.executable may be the system
|
|
python which lacks chromadb and other deps. Resolution order:
|
|
1. MEMPALACE_PYTHON env var (explicit override)
|
|
2. Venv python from package install path
|
|
3. Editable install: venv/ sibling to mempalace/
|
|
4. sys.executable fallback
|
|
"""
|
|
# Honor explicit override (used by shell hook wrappers)
|
|
env_python = os.environ.get("MEMPALACE_PYTHON", "")
|
|
if env_python and os.path.isfile(env_python) and os.access(env_python, os.X_OK):
|
|
return env_python
|
|
# This file lives at <venv>/lib/pythonX.Y/site-packages/mempalace/hooks_cli.py
|
|
# or <project>/mempalace/hooks_cli.py (editable install).
|
|
#
|
|
# ``parents[3]`` / ``parents[1]`` would raise IndexError when the package
|
|
# lives at a shallow filesystem path — Docker containers mounting at
|
|
# ``/work``, ``/opt/app``, or other minimal-prefix installs don't have 4
|
|
# (or sometimes even 2) parent directories. Use ``len(parents)`` to
|
|
# check the depth before indexing; LBYL is the standard Python idiom
|
|
# for bounded-integer lookups. Per PR #1580 review (gemini-code-assist,
|
|
# medium priority).
|
|
parents = Path(__file__).resolve().parents
|
|
if len(parents) > 3:
|
|
venv_bin = parents[3] / "bin" / "python"
|
|
if venv_bin.is_file():
|
|
return str(venv_bin)
|
|
# Editable install: assumes project root has a venv/ sibling to mempalace/
|
|
if len(parents) > 1:
|
|
project_venv = parents[1] / "venv" / "bin" / "python"
|
|
if project_venv.is_file():
|
|
return str(project_venv)
|
|
return sys.executable
|
|
|
|
|
|
_RECENT_MSG_COUNT = 30 # how many recent user messages to summarize
|
|
|
|
STOP_BLOCK_REASON = (
|
|
"MemPalace auto-save checkpoint. "
|
|
"Use mempalace_diary_write (session summary) and mempalace_add_drawer "
|
|
"(quotes, decisions, code) to save session content. "
|
|
"Do NOT use native auto-memory files."
|
|
)
|
|
|
|
PRECOMPACT_BLOCK_REASON = (
|
|
"MemPalace emergency save — compaction imminent. "
|
|
"Use mempalace_diary_write (thorough summary) and mempalace_add_drawer "
|
|
"(ALL quotes, decisions, code, context) to save ALL content before context is lost. "
|
|
"Do NOT use native auto-memory files."
|
|
)
|
|
|
|
|
|
def _sanitize_session_id(session_id: str) -> str:
|
|
"""Only allow alnum, dash, underscore to prevent path traversal."""
|
|
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", session_id)
|
|
return sanitized or "unknown"
|
|
|
|
|
|
def _validate_transcript_path(transcript_path: str) -> Path:
|
|
"""Validate and resolve a transcript path, rejecting paths outside expected roots.
|
|
|
|
Returns a resolved Path if valid, or None if the path should be rejected.
|
|
Accepted paths must:
|
|
- Have a .jsonl or .json extension
|
|
- Not contain '..' after resolution (path traversal prevention)
|
|
"""
|
|
if not transcript_path:
|
|
return None
|
|
path = Path(transcript_path).expanduser().resolve()
|
|
if path.suffix not in (".jsonl", ".json"):
|
|
return None
|
|
# Reject if the original input contained '..' traversal components
|
|
if ".." in Path(transcript_path).parts:
|
|
return None
|
|
return path
|
|
|
|
|
|
def _count_human_messages(transcript_path: str) -> int:
|
|
"""Count human messages in a JSONL transcript, skipping command-messages."""
|
|
path = _validate_transcript_path(transcript_path)
|
|
if path is None:
|
|
if transcript_path:
|
|
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
|
|
return 0
|
|
if not path.is_file():
|
|
return 0
|
|
count = 0
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") 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):
|
|
if "<command-message>" in content:
|
|
continue
|
|
elif isinstance(content, list):
|
|
text = " ".join(
|
|
b.get("text", "") for b in content if isinstance(b, dict)
|
|
)
|
|
if "<command-message>" in text:
|
|
continue
|
|
count += 1
|
|
# Also handle Codex CLI transcript format
|
|
# {"type": "event_msg", "payload": {"type": "user_message", "message": "..."}}
|
|
elif entry.get("type") == "event_msg":
|
|
payload = entry.get("payload", {})
|
|
if isinstance(payload, dict) and payload.get("type") == "user_message":
|
|
msg_text = payload.get("message", "")
|
|
if isinstance(msg_text, str) and "<command-message>" not in msg_text:
|
|
count += 1
|
|
except (json.JSONDecodeError, AttributeError):
|
|
pass
|
|
except OSError:
|
|
return 0
|
|
return count
|
|
|
|
|
|
_state_dir_initialized = False
|
|
|
|
|
|
def _log(message: str):
|
|
"""Append to hook state log file."""
|
|
if not _palace_root_exists():
|
|
return # User removed the palace; do not recreate by logging
|
|
global _state_dir_initialized
|
|
try:
|
|
if not _state_dir_initialized:
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
STATE_DIR.chmod(0o700)
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
_state_dir_initialized = True
|
|
log_path = STATE_DIR / "hook.log"
|
|
is_new = not log_path.exists()
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
with open(log_path, "a") as f:
|
|
f.write(f"[{timestamp}] {message}\n")
|
|
if is_new:
|
|
try:
|
|
log_path.chmod(0o600)
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _output(data: dict):
|
|
"""Print JSON to stdout without importing modules that may redirect streams.
|
|
|
|
If mempalace.mcp_server is already loaded, reuse its saved real stdout fd.
|
|
Otherwise, write directly to fd 1 so hook responses still go to stdout even
|
|
if sys.stdout has been redirected elsewhere.
|
|
"""
|
|
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
|
|
real_stdout_fd: int | None = None
|
|
mcp_mod = sys.modules.get("mempalace.mcp_server") or sys.modules.get(
|
|
f"{__package__}.mcp_server" if __package__ else "mcp_server"
|
|
)
|
|
if mcp_mod is not None:
|
|
real_stdout_fd = getattr(mcp_mod, "_REAL_STDOUT_FD", None)
|
|
|
|
fd = real_stdout_fd if real_stdout_fd is not None else 1
|
|
offset = 0
|
|
try:
|
|
while offset < len(payload):
|
|
try:
|
|
offset += os.write(fd, payload[offset:])
|
|
except InterruptedError:
|
|
continue
|
|
return
|
|
except OSError:
|
|
pass
|
|
|
|
sys.stdout.buffer.write(payload)
|
|
sys.stdout.buffer.flush()
|
|
|
|
|
|
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:
|
|
resolved = Path(mempal_dir).expanduser().resolve()
|
|
if resolved.is_dir():
|
|
targets.append((str(resolved), "projects"))
|
|
return targets
|
|
|
|
|
|
# Per-target PID guard.
|
|
#
|
|
# Hook fires ingest mines in the background. If a previous fire's child is
|
|
# still running for the *same* target (same source dir, mode, wing), the new
|
|
# fire should skip rather than pile up — multiple concurrent mines against the
|
|
# same source corrupt the HNSW index and exhaust disk via duplicate upserts
|
|
# (#1212, #1206). But mines targeting *different* sources / modes must remain
|
|
# independent so the user can have e.g. project-mining and transcript-ingest
|
|
# running in parallel.
|
|
#
|
|
# The single ``mine.pid`` global file used previously failed both ways: the
|
|
# guard was rebuilt every spawn (so two near-simultaneous fires both passed
|
|
# the check before either wrote), and the file was unconditionally overwritten
|
|
# (so the second spawn lost the first PID, orphaning it). The replacement is
|
|
# a directory of per-target slots, claimed via ``O_CREAT | O_EXCL`` so the
|
|
# claim is atomic and per-target.
|
|
_MINE_PID_DIR = STATE_DIR / "mine_pids"
|
|
|
|
# The per-process PID file path is communicated to the mine subprocess via
|
|
# this env var so the child's cleanup hook (in miner.py) can remove its
|
|
# own slot on exit without scanning the whole directory.
|
|
_MINE_PID_FILE_ENV = "MEMPALACE_MINE_PID_FILE"
|
|
|
|
# Maximum wall-clock hours a mine subprocess is allowed to run before its
|
|
# PID slot is treated as stale (even if the process is still alive). A
|
|
# wedged mine — e.g. one that is blocking indefinitely on ChromaDB
|
|
# cold-init under concurrent Windows load (#1552) — would otherwise hold
|
|
# its slot forever. Set MEMPALACE_MINE_TIMEOUT_HOURS=0 to disable the
|
|
# timeout (slots are reclaimed only when the PID is dead).
|
|
_MINE_TIMEOUT_HOURS_ENV = "MEMPALACE_MINE_TIMEOUT_HOURS"
|
|
_MINE_TIMEOUT_HOURS_DEFAULT = 2.0
|
|
|
|
|
|
def _mine_slot_timeout_secs() -> float:
|
|
"""Return the configured mine-slot timeout in seconds.
|
|
|
|
Reads ``MEMPALACE_MINE_TIMEOUT_HOURS`` from the environment (float).
|
|
Returns 0 if the env var is set to 0 or is not parseable.
|
|
"""
|
|
raw = os.environ.get(_MINE_TIMEOUT_HOURS_ENV, "")
|
|
if raw:
|
|
try:
|
|
hours = float(raw)
|
|
return max(0.0, hours) * 3600
|
|
except ValueError:
|
|
return 0.0
|
|
return _MINE_TIMEOUT_HOURS_DEFAULT * 3600
|
|
|
|
|
|
def _pid_file_for_cmd(cmd: list[str]) -> Path:
|
|
"""Return the per-target PID file path for a mine subcommand.
|
|
|
|
The key is derived from the mine arguments (everything after ``mine``)
|
|
so different (dir, mode, wing) combinations get independent slots.
|
|
Two fires with the same arguments collapse to the same slot — which is
|
|
exactly the dedup we want.
|
|
"""
|
|
try:
|
|
idx = cmd.index("mine")
|
|
key = " ".join(cmd[idx:])
|
|
except ValueError:
|
|
key = " ".join(cmd)
|
|
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
|
return _MINE_PID_DIR / f"mine_{digest}.pid"
|
|
|
|
|
|
def _pid_alive(pid: int) -> bool:
|
|
"""Cross-platform existence check for a PID.
|
|
|
|
On POSIX, ``os.kill(pid, 0)`` is the well-known no-op existence probe.
|
|
On Windows, ``os.kill`` maps to ``TerminateProcess(handle, sig)`` and
|
|
would *terminate* the target process with exit code ``sig`` — using
|
|
it here would kill our own mine child (or worse, the caller itself).
|
|
Use ``OpenProcess`` + ``GetExitCodeProcess`` via ctypes instead.
|
|
"""
|
|
if sys.platform == "win32":
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
STILL_ACTIVE = 259
|
|
kernel32 = ctypes.windll.kernel32
|
|
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
if not handle:
|
|
return False
|
|
try:
|
|
code = wintypes.DWORD()
|
|
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
return False
|
|
return code.value == STILL_ACTIVE
|
|
finally:
|
|
kernel32.CloseHandle(handle)
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
def _mine_already_running(cmd: list[str]) -> bool:
|
|
"""Return True if a previous mine for ``cmd``'s target is still alive.
|
|
|
|
The PID file format is ``{pid} {unix_timestamp}`` (timestamp added in
|
|
#1552 to detect wedged subprocesses). Old-format files (bare ``{pid}``)
|
|
use the PID file's mtime as the approximate start time so a still-running
|
|
pre-upgrade mine is not immediately misclassified as stale.
|
|
|
|
A process is considered stale (and this function returns False) when:
|
|
- the PID is dead, OR
|
|
- the configured mine timeout is > 0 AND the process has been running
|
|
longer than the timeout.
|
|
"""
|
|
pid_file = _pid_file_for_cmd(cmd)
|
|
try:
|
|
recorded = pid_file.read_text().strip()
|
|
except OSError:
|
|
return False
|
|
if not recorded:
|
|
return False
|
|
parts = recorded.split(None, 1)
|
|
if not parts[0].isdigit():
|
|
return False
|
|
pid = int(parts[0])
|
|
if not _pid_alive(pid):
|
|
return False
|
|
timeout_secs = _mine_slot_timeout_secs()
|
|
if timeout_secs > 0:
|
|
if len(parts) > 1 and parts[1]:
|
|
try:
|
|
start_ts = float(parts[1])
|
|
except ValueError:
|
|
return False
|
|
else:
|
|
try:
|
|
start_ts = pid_file.stat().st_mtime
|
|
except OSError:
|
|
return True
|
|
if time.time() - start_ts > timeout_secs:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _create_mine_slot_with_placeholder(pid_file: Path) -> Path:
|
|
"""Atomically create a mine PID slot and write this hook PID into it.
|
|
|
|
The slot body is ``{pid} {unix_timestamp}`` so that stale-by-age
|
|
detection in ``_mine_already_running`` can determine how long the
|
|
recorded process has been running (#1552).
|
|
"""
|
|
fd = os.open(str(pid_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="ascii") as f:
|
|
f.write(f"{os.getpid()} {int(time.time())}")
|
|
except OSError:
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
pid_file.unlink()
|
|
except OSError:
|
|
pass
|
|
raise
|
|
return pid_file
|
|
|
|
|
|
def _claim_mine_slot(cmd: list[str]) -> Optional[Path]:
|
|
"""Atomically reserve the per-target PID slot for ``cmd``.
|
|
|
|
Returns the slot path on success, or ``None`` if the target is
|
|
already being mined by a live process. The reservation is done via
|
|
``O_CREAT | O_EXCL`` so two simultaneous hook fires can never both
|
|
pass the check; one wins, the other returns None.
|
|
|
|
A stale slot (file exists but the recorded PID is dead) is reclaimed
|
|
transparently — orphan miners that crashed without cleanup do not
|
|
block future hook fires forever.
|
|
"""
|
|
pid_file = _pid_file_for_cmd(cmd)
|
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
return _create_mine_slot_with_placeholder(pid_file)
|
|
except FileExistsError:
|
|
pass
|
|
|
|
# Slot exists. If the holder is alive, defer.
|
|
if _mine_already_running(cmd):
|
|
return None
|
|
|
|
# Stale entry; reclaim. The unlink+create is racy against another hook
|
|
# firing right now, but the second create's O_EXCL will fail and that
|
|
# caller will see the live PID via the next round.
|
|
try:
|
|
pid_file.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError:
|
|
return None
|
|
|
|
try:
|
|
return _create_mine_slot_with_placeholder(pid_file)
|
|
except FileExistsError:
|
|
return None
|
|
|
|
|
|
def _spawn_mine(cmd: list) -> None:
|
|
"""Spawn a mine subprocess if no live mine is already targeting it.
|
|
|
|
The PID slot is claimed atomically *before* the spawn, so two near-
|
|
simultaneous hook fires can't both proceed — the second sees the
|
|
claimed slot and silently skips. The spawned process inherits a
|
|
``MEMPALACE_MINE_PID_FILE`` env var so its cleanup hook can remove
|
|
the slot on exit without scanning the directory.
|
|
"""
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
log_path = STATE_DIR / "hook.log"
|
|
pid_file = _claim_mine_slot(cmd)
|
|
if pid_file is None:
|
|
_log(f"Skipping mine: target already running ({' '.join(cmd[-3:])})")
|
|
return
|
|
child_env = os.environ.copy()
|
|
child_env[_MINE_PID_FILE_ENV] = str(pid_file)
|
|
with open(log_path, "a") as log_f:
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
stdout=log_f,
|
|
stderr=log_f,
|
|
env=child_env,
|
|
**_detached_popen_kwargs(),
|
|
)
|
|
except OSError:
|
|
# Spawn failed; release the slot we just claimed so the next
|
|
# hook fire can try again rather than skipping forever.
|
|
try:
|
|
pid_file.unlink()
|
|
except OSError:
|
|
pass
|
|
raise
|
|
try:
|
|
pid_file.write_text(f"{proc.pid} {int(time.time())}")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _hooks_daemon_enabled() -> bool:
|
|
try:
|
|
return MempalaceConfig().hook_use_daemon is True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _daemon_mine_dedupe_key(source: str, mode: str) -> str:
|
|
try:
|
|
source_key = str(Path(source).expanduser().resolve())
|
|
except OSError:
|
|
source_key = str(Path(source).expanduser())
|
|
return f"hook:mine:{mode}:{source_key}"
|
|
|
|
|
|
def _daemon_available() -> bool:
|
|
"""True iff a daemon is already running for the configured palace.
|
|
|
|
This is a fast localhost health check, not a spawn: the 500ms hook budget
|
|
forbids auto-starting a python subprocess from a hook (cold start is
|
|
~15s). Daemon mode for hooks requires the user to have started the daemon
|
|
explicitly via `mempalace daemon start`; when it isn't up, hooks fall back
|
|
to the existing direct (in-process / spawn) path instead of blocking.
|
|
"""
|
|
from .daemon import HOOK_PROBE_TIMEOUT, get_client_if_running
|
|
|
|
try:
|
|
return (
|
|
get_client_if_running(MempalaceConfig().palace_path, health_timeout=HOOK_PROBE_TIMEOUT)
|
|
is not None
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _submit_daemon_job(
|
|
kind: str,
|
|
payload: dict,
|
|
*,
|
|
dedupe_key: str = None,
|
|
priority: int = 0,
|
|
wait: bool = False,
|
|
timeout: float = 60.0,
|
|
):
|
|
"""Submit to an already-running daemon. Never auto-starts (see _daemon_available).
|
|
|
|
Raises DaemonError on a real failure (job rejected, timeout, daemon died
|
|
mid-submit). Callers must NOT fall back to the direct path on such errors —
|
|
the daemon may already have accepted the job, and re-running it would
|
|
duplicate verbatim content. Only an absent daemon (handled by the caller's
|
|
_daemon_available() precheck) should fall back.
|
|
"""
|
|
from .daemon import submit_job
|
|
|
|
palace_path = MempalaceConfig().palace_path
|
|
return submit_job(
|
|
kind,
|
|
payload,
|
|
palace_path=palace_path,
|
|
dedupe_key=dedupe_key,
|
|
priority=priority,
|
|
wait=wait,
|
|
auto_start=False,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
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).
|
|
|
|
Per-target dedup is done by ``_spawn_mine`` itself: each (dir, mode)
|
|
target gets its own PID slot, so distinct targets never block each
|
|
other but a re-fire of the same target while the previous one is
|
|
still running is silently skipped.
|
|
"""
|
|
targets = _get_mine_targets()
|
|
if not targets:
|
|
return
|
|
for mine_dir, mode in targets:
|
|
try:
|
|
if _hooks_daemon_enabled() and _daemon_available():
|
|
try:
|
|
_submit_daemon_job(
|
|
"mine",
|
|
{"source": mine_dir, "mode": mode, "agent": "mempalace"},
|
|
dedupe_key=_daemon_mine_dedupe_key(mine_dir, mode),
|
|
wait=False,
|
|
)
|
|
except Exception as exc:
|
|
# Daemon accepted context — don't fall back (would double-mine).
|
|
_log(f"Daemon mine submission failed: {exc}")
|
|
continue
|
|
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
|
|
except OSError:
|
|
pass
|
|
except Exception as exc:
|
|
# Non-daemon spawn path failed. Hooks must never crash the user's
|
|
# shell — log and continue. Do not label this a daemon failure: the
|
|
# daemon block above handles its own errors with its own message.
|
|
_log(f"mine hook failed: {exc}")
|
|
|
|
|
|
def _mine_sync():
|
|
"""Synchronously mine MEMPAL_DIR (precompact path).
|
|
|
|
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
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
log_path = STATE_DIR / "hook.log"
|
|
for mine_dir, mode in targets:
|
|
try:
|
|
if _hooks_daemon_enabled() and _daemon_available():
|
|
try:
|
|
job = _submit_daemon_job(
|
|
"mine",
|
|
{"source": mine_dir, "mode": mode, "agent": "mempalace"},
|
|
dedupe_key=_daemon_mine_dedupe_key(mine_dir, mode),
|
|
wait=True,
|
|
timeout=60,
|
|
)
|
|
result = job.get("result") or {}
|
|
if job.get("state") != "succeeded" or not result.get("success", True):
|
|
_log(f"Daemon sync mine failed: {result.get('error', job.get('error'))}")
|
|
except Exception as exc:
|
|
# Daemon accepted context — don't fall back (would double-mine).
|
|
_log(f"Daemon sync mine submission failed: {exc}")
|
|
continue
|
|
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,
|
|
# Windows: hide the conhost window this sync mine would
|
|
# otherwise flash on every fire. Mirrors the async paths
|
|
# (_spawn_mine / _desktop_toast) via _detached_popen_kwargs().
|
|
# 0 is a no-op off-Windows.
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
except Exception as exc:
|
|
# Non-daemon sync spawn path failed. Hooks must never crash the
|
|
# user's shell — log and continue (not a daemon failure; the daemon
|
|
# block above handles its own errors).
|
|
_log(f"mine hook failed: {exc}")
|
|
|
|
|
|
def _desktop_toast(body: str, title: str = "MemPalace"):
|
|
"""Send a desktop notification via notify-send. Fails silently."""
|
|
try:
|
|
subprocess.Popen(
|
|
["notify-send", "--app-name=MemPalace", "--icon=brain", title, body],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
**_detached_popen_kwargs(),
|
|
)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _extract_recent_messages(transcript_path: str, count: int = _RECENT_MSG_COUNT) -> list[str]:
|
|
"""Extract the last N user messages from a JSONL transcript."""
|
|
path = Path(transcript_path).expanduser()
|
|
if not path.is_file():
|
|
return []
|
|
messages = []
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
try:
|
|
entry = json.loads(line)
|
|
# Claude Code format
|
|
msg = entry.get("message") or entry.get("event_message") or {}
|
|
if isinstance(msg, dict) and msg.get("role") == "user":
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
content = " ".join(
|
|
b.get("text", "") for b in content if isinstance(b, dict)
|
|
)
|
|
if not isinstance(content, str) or not content.strip():
|
|
continue
|
|
if "<command-message>" in content or "<system-reminder>" in content:
|
|
continue
|
|
messages.append(content.strip()[:200])
|
|
# Codex CLI format
|
|
elif entry.get("type") == "event_msg":
|
|
payload = entry.get("payload", {})
|
|
if isinstance(payload, dict) and payload.get("type") == "user_message":
|
|
text = payload.get("message", "")
|
|
if isinstance(text, str) and text.strip():
|
|
if "<command-message>" not in text:
|
|
messages.append(text.strip()[:200])
|
|
except (json.JSONDecodeError, AttributeError):
|
|
pass
|
|
except OSError:
|
|
return []
|
|
return messages[-count:]
|
|
|
|
|
|
_THEME_STOPWORDS = frozenset(
|
|
"the a an and or but in on at to for of is it i me my you your we our "
|
|
"this that with from by was were be been are not no yes can do did dont "
|
|
"will would should could have has had lets let just also like so if then "
|
|
"ok okay sure yeah hey hi here there what when where how why which some "
|
|
"all any each every about into out up down over after before between "
|
|
"get got make made need want use used using check look see run try "
|
|
"know think right now still already really very much more most too "
|
|
"file files code one two new first last next thing things way well".split()
|
|
)
|
|
|
|
|
|
def _extract_themes(messages: list[str], max_themes: int = 3) -> list[str]:
|
|
"""Pull 2-3 distinctive topic words from recent messages.
|
|
|
|
Note: stopword list is English-only; non-English corpora will produce noisy themes.
|
|
"""
|
|
from collections import Counter
|
|
|
|
words: Counter[str] = Counter()
|
|
for msg in messages:
|
|
for word in msg.lower().split():
|
|
# Strip punctuation, keep words 4+ chars
|
|
clean = word.strip(".,;:!?\"'`()[]{}#<>/\\-_=+@$%^&*~")
|
|
if len(clean) >= 4 and clean not in _THEME_STOPWORDS and clean.isalpha():
|
|
words[clean] += 1
|
|
return [w for w, _ in words.most_common(max_themes)]
|
|
|
|
|
|
def _save_diary_direct(
|
|
transcript_path: str,
|
|
session_id: str,
|
|
wing: str = "",
|
|
toast: bool = False,
|
|
*,
|
|
agent_name: str,
|
|
) -> dict:
|
|
"""Write a diary checkpoint by calling the tool function directly (no MCP roundtrip).
|
|
|
|
The entry is filed under `agent_name` so the agent that later calls
|
|
`mempalace_diary_read(agent_name=...)` discovers it (#1693). If `wing` is
|
|
set, the entry lands in that wing (typically the project wing derived from
|
|
the transcript path); a `diary_read` with an empty wing spans every wing
|
|
the agent wrote to, so project-derived wings stay discoverable.
|
|
|
|
Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure.
|
|
"""
|
|
messages = _extract_recent_messages(transcript_path)
|
|
if not messages:
|
|
_log("No recent messages to save")
|
|
return {"count": 0}
|
|
|
|
themes = _extract_themes(messages)
|
|
|
|
# Build a compressed diary entry from recent conversation
|
|
now = datetime.now()
|
|
topics = "|".join(m[:80] for m in messages[-10:])
|
|
entry = (
|
|
f"CHECKPOINT:{now.strftime('%Y-%m-%d')}|session:{session_id}"
|
|
f"|msgs:{len(messages)}|recent:{topics}"
|
|
)
|
|
|
|
try:
|
|
if _hooks_daemon_enabled() and _daemon_available():
|
|
try:
|
|
job = _submit_daemon_job(
|
|
"diary_write",
|
|
{
|
|
"agent_name": agent_name,
|
|
"entry": entry,
|
|
"topic": "checkpoint",
|
|
"wing": wing,
|
|
},
|
|
priority=10,
|
|
wait=True,
|
|
timeout=30,
|
|
)
|
|
except Exception as exc:
|
|
# Daemon accepted context — don't fall back (would double-write).
|
|
_log(f"Daemon diary checkpoint failed: {exc}")
|
|
return {"count": 0}
|
|
result = job.get("result") or {}
|
|
if job.get("state") == "succeeded" and result.get("success"):
|
|
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
|
|
try:
|
|
ack_file = STATE_DIR / "last_checkpoint"
|
|
ack_file.write_text(
|
|
json.dumps({"msgs": len(messages), "ts": now.isoformat()}),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError:
|
|
pass
|
|
if toast:
|
|
_desktop_toast(f"Checkpoint saved - {len(messages)} messages archived")
|
|
return {"count": len(messages), "themes": themes}
|
|
_log(f"Daemon diary checkpoint failed: {result.get('error', job.get('error'))}")
|
|
return {"count": 0}
|
|
|
|
from .mcp_server import tool_diary_write
|
|
|
|
result = tool_diary_write(
|
|
agent_name=agent_name,
|
|
entry=entry,
|
|
topic="checkpoint",
|
|
wing=wing,
|
|
)
|
|
if result.get("success"):
|
|
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
|
|
# Write state for ack tool to read
|
|
try:
|
|
ack_file = STATE_DIR / "last_checkpoint"
|
|
ack_file.write_text(
|
|
json.dumps({"msgs": len(messages), "ts": now.isoformat()}),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError:
|
|
pass
|
|
if toast:
|
|
_desktop_toast(f"Checkpoint saved \u2014 {len(messages)} messages archived")
|
|
return {"count": len(messages), "themes": themes}
|
|
else:
|
|
_log(f"Diary checkpoint failed: {result.get('error', 'unknown')}")
|
|
except Exception as e:
|
|
_log(f"Diary checkpoint error: {e}")
|
|
return {"count": 0}
|
|
|
|
|
|
def _ingest_transcript(transcript_path: str):
|
|
"""Mine a Claude Code session transcript into the palace as a conversation."""
|
|
path = _validate_transcript_path(transcript_path)
|
|
if path is None:
|
|
return
|
|
try:
|
|
if not path.is_file() or path.stat().st_size < 100:
|
|
return
|
|
except OSError:
|
|
return
|
|
|
|
try:
|
|
MempalaceConfig() # validate config loads
|
|
except Exception:
|
|
return
|
|
|
|
try:
|
|
if _hooks_daemon_enabled() and _daemon_available():
|
|
try:
|
|
_submit_daemon_job(
|
|
"mine",
|
|
{
|
|
"source": str(path.parent),
|
|
"mode": "convos",
|
|
"wing": "sessions",
|
|
"agent": "mempalace",
|
|
},
|
|
dedupe_key=_daemon_mine_dedupe_key(str(path.parent), "convos"),
|
|
wait=False,
|
|
)
|
|
_log(f"Transcript ingest submitted to daemon: {path.name}")
|
|
except Exception as exc:
|
|
# Daemon accepted context — don't fall back (would double-mine).
|
|
_log(f"Daemon transcript ingest failed: {exc}")
|
|
return
|
|
|
|
# Route through ``_spawn_mine`` so the per-target PID guard kicks
|
|
# in here too — repeated Stop/PreCompact fires for the same
|
|
# transcript should not stack up parallel ingest mines.
|
|
_spawn_mine(
|
|
[
|
|
_mempalace_python(),
|
|
"-m",
|
|
"mempalace",
|
|
"mine",
|
|
str(path.parent),
|
|
"--mode",
|
|
"convos",
|
|
"--wing",
|
|
"sessions",
|
|
]
|
|
)
|
|
_log(f"Transcript ingest started: {path.name}")
|
|
except OSError:
|
|
pass
|
|
except Exception as exc:
|
|
# Non-daemon ingest spawn path failed. Hooks must never crash the
|
|
# user's shell — log and continue (not a daemon failure; the daemon
|
|
# block above handles its own errors).
|
|
_log(f"transcript ingest hook failed: {exc}")
|
|
|
|
|
|
SUPPORTED_HARNESSES = {"claude-code", "codex"}
|
|
|
|
|
|
def _diary_agent_for_harness(harness: str) -> str:
|
|
"""Return the diary ``agent_name`` a session in ``harness`` reads under.
|
|
|
|
Stop-hook checkpoints must be filed beside the agent's own entries so
|
|
``mempalace_diary_read(agent_name=...)`` surfaces them. The old code filed
|
|
them under a fixed ``"session-hook"`` identity that no reader ever queried,
|
|
hiding every checkpoint (#1693). A ``claude-code`` session reads its diary
|
|
as ``"claude"``; every other harness already reads under its own name, so
|
|
returning the harness name keeps a newly supported harness discoverable
|
|
instead of silently invisible again.
|
|
"""
|
|
return "claude" if harness == "claude-code" else harness
|
|
|
|
|
|
def _parse_harness_input(data: dict, harness: str) -> dict:
|
|
"""Parse stdin JSON according to the harness type."""
|
|
if harness not in SUPPORTED_HARNESSES:
|
|
print(f"Unknown harness: {harness}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return {
|
|
"session_id": _sanitize_session_id(str(data.get("session_id", "unknown"))),
|
|
"stop_hook_active": data.get("stop_hook_active", False),
|
|
"transcript_path": str(data.get("transcript_path", "")),
|
|
}
|
|
|
|
|
|
# Common parent-dir tokens stripped from the encoded folder when no
|
|
# explicit ``-Projects-`` segment is present. Order matters: only the
|
|
# first match strips. These cover the bulk of Unix layouts; cwd-from-JSONL
|
|
# (the primary path) handles the long tail correctly without heuristics.
|
|
_ENCODED_PARENT_PREFIXES = (
|
|
"git-",
|
|
"dev-",
|
|
"projects-",
|
|
"Projects-",
|
|
"src-",
|
|
"code-",
|
|
"work-",
|
|
"Documents-",
|
|
)
|
|
|
|
|
|
def _safe_wing_slug(name: str) -> str:
|
|
"""Normalize a project directory name into a wing slug ``sanitize_name`` accepts.
|
|
|
|
Builds on the historical space/hyphen handling: map characters outside
|
|
``sanitize_name``'s set to ``_`` while keeping ``.`` and ``'`` so existing wings
|
|
for names like ``my.app`` are preserved (renaming a wing would orphan diary
|
|
entries already filed under it), collapse ``..`` which the validator rejects as
|
|
path traversal, trim edge separators it won't accept, and cap the length so
|
|
``wing_<slug>`` stays within ``sanitize_name``'s 128-character limit. Without
|
|
this a folder containing e.g. a leading ``+`` produced ``wing_+project``, which
|
|
``sanitize_name`` rejects — silently breaking diary auto-save. Falls back to
|
|
``sessions`` when a name reduces to nothing (e.g. ``+``).
|
|
"""
|
|
slug = name.lower().replace(" ", "_").replace("-", "_")
|
|
slug = re.sub(r"[^\w.']+", "_", slug)
|
|
slug = re.sub(r"\.{2,}", ".", slug)
|
|
slug = slug[:120].strip("_.'")
|
|
return slug or "sessions"
|
|
|
|
|
|
def _wing_from_jsonl_cwd(transcript_path: str) -> Optional[str]:
|
|
"""Read ``cwd`` from the first JSONL line that records it.
|
|
|
|
Claude Code stores the absolute working directory on most message
|
|
types (tool_use, tool_result, user/assistant turns), but not all
|
|
(e.g. queue-operation lines lack it). Scan up to 200 lines to find
|
|
the first record that includes a non-empty cwd, then derive the
|
|
wing from its leaf path segment. Returns ``None`` if the file is
|
|
unreadable, empty, or contains no cwd.
|
|
"""
|
|
try:
|
|
path = Path(transcript_path).expanduser()
|
|
if not path.is_file():
|
|
return None
|
|
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
for i, line in enumerate(f):
|
|
if i >= 200:
|
|
break
|
|
line = line.strip()
|
|
if not line or '"cwd"' not in line:
|
|
continue
|
|
try:
|
|
data = json.loads(line)
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
cwd = data.get("cwd")
|
|
if not cwd or not isinstance(cwd, str):
|
|
continue
|
|
cwd_norm = cwd.replace("\\", "/").rstrip("/")
|
|
if not cwd_norm:
|
|
continue
|
|
project = cwd_norm.rsplit("/", 1)[-1]
|
|
if project:
|
|
return f"wing_{_safe_wing_slug(project)}"
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _wing_from_transcript_path(transcript_path: str) -> str:
|
|
"""Derive a project wing name from a Claude Code transcript path.
|
|
|
|
Strategy (in priority order):
|
|
|
|
1. PRIMARY — Read ``cwd`` from the JSONL transcript. Claude Code records
|
|
the absolute working directory on most message types, so the project
|
|
name is whatever the leaf path segment of cwd is. This is the
|
|
canonical answer when present.
|
|
|
|
2. FALLBACK — Decode the encoded folder under ``.claude/projects/``.
|
|
Claude Code flattens path separators to dashes (``/Users/me/code/foo``
|
|
→ ``-Users-me-code-foo``), so the original directory boundaries are
|
|
lost. We strip the platform user-home prefix (``Users-<user>-`` or
|
|
``home-<user>-``) and one common parent-dir token (``git-``, ``dev-``,
|
|
``projects-``, etc.), then convert the remaining dashes to
|
|
underscores. Unlike the previous "last token only" heuristic, this
|
|
never silently truncates a hyphenated project folder name like
|
|
``claude-code``, ``react-native``, or ``customer-portal``.
|
|
|
|
3. LEGACY — Match an explicit ``-Projects-<name>`` segment for
|
|
transcripts not under the standard Claude Code projects dir.
|
|
|
|
4. DEFAULT — ``wing_sessions``.
|
|
|
|
Closes #1410.
|
|
"""
|
|
# 1. Primary — cwd from JSONL is the canonical source of truth
|
|
cwd_wing = _wing_from_jsonl_cwd(transcript_path)
|
|
if cwd_wing:
|
|
return cwd_wing
|
|
|
|
# Normalize path separators for cross-platform (Windows backslashes)
|
|
normalized = transcript_path.replace("\\", "/")
|
|
|
|
# 2. Fallback — encoded project folder under .claude/projects/
|
|
match = re.search(r"/\.claude/projects/-([^/]+)", normalized)
|
|
if match:
|
|
encoded = match.group(1)
|
|
# Strip platform user-home prefix so the wing isn't dominated by
|
|
# /Users/<user>/ or /home/<user>/.
|
|
m = re.match(r"(?:Users|home)-[^-]+-(.+)", encoded)
|
|
if m:
|
|
encoded = m.group(1)
|
|
# Strip one common parent-dir token if present, keeping the rest as
|
|
# the project path. Hyphens become underscores to preserve
|
|
# uniqueness for hyphenated project folder names.
|
|
for prefix in _ENCODED_PARENT_PREFIXES:
|
|
if encoded.startswith(prefix):
|
|
encoded = encoded[len(prefix) :]
|
|
break
|
|
return f"wing_{_safe_wing_slug(encoded)}"
|
|
|
|
# 3. Legacy — explicit -Projects-<name> segment
|
|
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
|
|
if match:
|
|
return f"wing_{_safe_wing_slug(match.group(1))}"
|
|
|
|
# 4. Default
|
|
return "wing_sessions"
|
|
|
|
|
|
def hook_stop(data: dict, harness: str):
|
|
"""Stop hook: block every N messages for auto-save."""
|
|
if not _palace_root_exists():
|
|
_output({})
|
|
return
|
|
parsed = _parse_harness_input(data, harness)
|
|
session_id = parsed["session_id"]
|
|
stop_hook_active = parsed["stop_hook_active"]
|
|
transcript_path = parsed["transcript_path"]
|
|
|
|
# Respect auto_save config toggle (clean opt-out)
|
|
if not MempalaceConfig().hooks_auto_save:
|
|
_output({})
|
|
return
|
|
|
|
# If already in a block-mode save cycle, let through (infinite-loop prevention).
|
|
# Silent mode saves directly without returning {"decision":"block"}, so there's
|
|
# no loop to prevent — and Claude Code's plugin dispatch sets this flag on every
|
|
# fire after the first, which would otherwise suppress all subsequent auto-saves.
|
|
if str(stop_hook_active).lower() in ("true", "1", "yes"):
|
|
# Safe default: assume silent mode on any config-read failure so saves
|
|
# proceed rather than being silently dropped. Silent mode is the default
|
|
# (v3.3.0+), so if we can't read config, behave as if it's still on.
|
|
silent_guard = True
|
|
try:
|
|
silent_guard = MempalaceConfig().hook_silent_save
|
|
except AttributeError as exc:
|
|
_log(f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode")
|
|
if not silent_guard:
|
|
_output({})
|
|
return
|
|
|
|
# Count human messages
|
|
exchange_count = _count_human_messages(transcript_path)
|
|
|
|
# Track last save point
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
last_save_file = STATE_DIR / f"{session_id}_last_save"
|
|
last_save = 0
|
|
if last_save_file.is_file():
|
|
try:
|
|
last_save = int(last_save_file.read_text().strip())
|
|
except (ValueError, OSError):
|
|
last_save = 0
|
|
|
|
since_last = exchange_count - last_save
|
|
|
|
_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")
|
|
|
|
if since_last >= SAVE_INTERVAL and exchange_count > 0:
|
|
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
|
|
|
|
# Read hook settings from config
|
|
try:
|
|
config = MempalaceConfig()
|
|
silent = config.hook_silent_save
|
|
toast = config.hook_desktop_toast
|
|
except Exception:
|
|
silent = True
|
|
toast = False
|
|
|
|
project_wing = _wing_from_transcript_path(transcript_path)
|
|
|
|
if silent:
|
|
# Save directly via Python API — systemMessage renders in terminal
|
|
result = {"count": 0}
|
|
if transcript_path:
|
|
result = _save_diary_direct(
|
|
transcript_path,
|
|
session_id,
|
|
wing=project_wing,
|
|
toast=toast,
|
|
agent_name=_diary_agent_for_harness(harness),
|
|
)
|
|
_ingest_transcript(transcript_path)
|
|
_maybe_auto_ingest()
|
|
# Only advance save marker after successful save
|
|
count = result.get("count", 0)
|
|
if count > 0:
|
|
try:
|
|
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
themes = result.get("themes", [])
|
|
if themes:
|
|
tag = " \u2014 " + ", ".join(themes)
|
|
else:
|
|
tag = ""
|
|
_output(
|
|
{
|
|
"systemMessage": f"\u2726 {count} memories woven into the palace{tag}",
|
|
}
|
|
)
|
|
else:
|
|
_output({})
|
|
else:
|
|
# Legacy: block and ask Claude to save via MCP tools.
|
|
# Marker advances before confirmed save — best-effort; if Claude
|
|
# fails to save, the checkpoint is lost but won't retry endlessly.
|
|
try:
|
|
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
if transcript_path:
|
|
_ingest_transcript(transcript_path)
|
|
_maybe_auto_ingest()
|
|
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
|
|
_output({"decision": "block", "reason": reason})
|
|
else:
|
|
_output({})
|
|
|
|
|
|
def hook_session_start(data: dict, harness: str):
|
|
"""Session start hook: initialize session tracking state."""
|
|
if not _palace_root_exists():
|
|
_output({})
|
|
return
|
|
parsed = _parse_harness_input(data, harness)
|
|
session_id = parsed["session_id"]
|
|
|
|
_log(f"SESSION START for session {session_id}")
|
|
|
|
# Initialize session state directory
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Pass through — no blocking on session start
|
|
_output({})
|
|
|
|
|
|
def _clear_session_last_save(session_id: str) -> None:
|
|
"""Drop the per-session save marker once a session has ended.
|
|
|
|
``hook_stop`` writes ``{session_id}_last_save`` but never had a clean-exit
|
|
cleanup path, so the marker lingered. The session is over by the time
|
|
``hook_session_end`` runs, so removing it here keeps ``hook_state/`` from
|
|
accumulating dead markers. OS errors (including a missing marker, since
|
|
``FileNotFoundError`` is an ``OSError``) are swallowed — this is best-effort
|
|
cleanup, never a reason to fail the hook.
|
|
"""
|
|
try:
|
|
(STATE_DIR / f"{session_id}_last_save").unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def hook_session_end(data: dict, harness: str):
|
|
"""Session end hook: one final flush when a session exits cleanly.
|
|
|
|
Closes the gap (#1341) where a session that never crosses ``SAVE_INTERVAL``
|
|
on ``Stop`` and never triggers ``PreCompact`` exits with nothing saved —
|
|
the common case for short, useful sessions.
|
|
|
|
Why background instead of mine inline: Claude Code's hooks reference
|
|
documents a default SessionEnd timeout of 1.5 seconds, and "timeouts set on
|
|
plugin-provided hooks do not raise the budget"
|
|
(https://code.claude.com/docs/en/hooks). A cold ``mempalace`` start alone
|
|
exceeds 1.5s, so this handler must never mine in the hook foreground. The
|
|
shell wrapper backgrounds it and returns immediately; the heavy capture is
|
|
spawned *detached* via ``_ingest_transcript`` / ``_maybe_auto_ingest`` (both
|
|
route through ``_spawn_mine`` / ``_detached_popen_kwargs``). On POSIX that
|
|
detached child reliably outlives the session (verified). On Windows only the
|
|
mine grandchild (spawned with detached-process flags) is designed to break
|
|
away from the session; the backgrounded hook process and the in-process
|
|
diary write are best-effort there (no Windows CI coverage yet). This
|
|
honors the "background everything / hooks under 500ms" budget. SessionEnd
|
|
has no decision control, so this only ever saves; it never emits a block
|
|
payload.
|
|
"""
|
|
if not _palace_root_exists():
|
|
_output({})
|
|
return
|
|
|
|
# Parse inside the try so a malformed payload (e.g. non-dict stdin that
|
|
# makes _parse_harness_input raise) still runs the finally cleanup below.
|
|
session_id = "unknown"
|
|
try:
|
|
parsed = _parse_harness_input(data, harness)
|
|
session_id = parsed["session_id"]
|
|
transcript_path = parsed["transcript_path"]
|
|
|
|
# Read config defensively (mirror hook_stop): a corrupt or unreadable
|
|
# config must not lose the final save, so default to auto-save on and
|
|
# toasts off rather than crashing the hook.
|
|
try:
|
|
config = MempalaceConfig()
|
|
auto_save = config.hooks_auto_save
|
|
toast = config.hook_desktop_toast
|
|
except Exception:
|
|
auto_save = True
|
|
toast = False
|
|
|
|
# Respect auto_save config toggle (clean opt-out)
|
|
if not auto_save:
|
|
_output({})
|
|
return
|
|
|
|
_log(f"SESSION END for session {session_id}")
|
|
|
|
# Validate the harness-provided transcript path before touching it
|
|
# (extension + ".." traversal check), mirroring the read path that
|
|
# already runs through _validate_transcript_path. A rejected path skips
|
|
# the transcript captures but still lets the independent MEMPAL_DIR mine
|
|
# run.
|
|
valid_transcript = ""
|
|
if transcript_path:
|
|
try:
|
|
validated = _validate_transcript_path(transcript_path)
|
|
except OSError:
|
|
validated = None
|
|
if validated is None:
|
|
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
|
|
else:
|
|
valid_transcript = str(validated)
|
|
|
|
# Flush. The diary checkpoint (in-process ChromaDB write) runs FIRST,
|
|
# before any detached mine is spawned, so it never contends for the
|
|
# palace lock; this handler is already backgrounded by the wrapper, so it
|
|
# is not under the SessionEnd budget and has time to finish. The detached
|
|
# transcript ingest follows; re-mining a transcript ``Stop`` already
|
|
# captured is a near no-op (deterministic convo IDs + ``file_already_mined``
|
|
# short-circuit + upsert). ``reason`` is intentionally not branched on:
|
|
# every clean-exit reason (incl. ``/clear`` / ``resume``) warrants the
|
|
# flush. Order matches ``hook_stop``.
|
|
if valid_transcript:
|
|
_save_diary_direct(
|
|
valid_transcript,
|
|
session_id,
|
|
wing=_wing_from_transcript_path(valid_transcript),
|
|
toast=toast,
|
|
agent_name=_diary_agent_for_harness(harness),
|
|
)
|
|
_ingest_transcript(valid_transcript)
|
|
_maybe_auto_ingest()
|
|
|
|
_output({})
|
|
finally:
|
|
_clear_session_last_save(session_id)
|
|
|
|
|
|
def hook_precompact(data: dict, harness: str):
|
|
"""Precompact hook: mine transcript synchronously, then allow compaction.
|
|
|
|
Respects the ``hooks.auto_save`` config toggle — when disabled, returns
|
|
immediately without mining.
|
|
"""
|
|
if not _palace_root_exists():
|
|
_output({})
|
|
return
|
|
parsed = _parse_harness_input(data, harness)
|
|
session_id = parsed["session_id"]
|
|
transcript_path = parsed["transcript_path"]
|
|
|
|
# Respect auto_save config toggle (clean opt-out)
|
|
if not MempalaceConfig().hooks_auto_save:
|
|
_output({})
|
|
return
|
|
|
|
_log(f"PRE-COMPACT triggered for session {session_id}")
|
|
|
|
# Capture tool output via our normalize path before compaction loses it
|
|
if transcript_path:
|
|
_ingest_transcript(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({})
|
|
|
|
|
|
def run_hook(hook_name: str, harness: str):
|
|
"""Main entry point: read stdin JSON, dispatch to hook handler."""
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
except (json.JSONDecodeError, EOFError):
|
|
_log("WARNING: Failed to parse stdin JSON, proceeding with empty data")
|
|
data = {}
|
|
|
|
hooks = {
|
|
"session-start": hook_session_start,
|
|
"stop": hook_stop,
|
|
"session-end": hook_session_end,
|
|
"precompact": hook_precompact,
|
|
}
|
|
|
|
handler = hooks.get(hook_name)
|
|
if handler is None:
|
|
print(f"Unknown hook: {hook_name}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
handler(data, harness)
|