mempalace.hooks_cli
Source: mempalace/hooks_cli.py
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.)
Classes
class HookWriteRouting
One hook invocation's resolved routing state.
use_daemon
def use_daemon(self) -> boolblocked
def blocked(self) -> boolnotice
def notice(self) -> strFunctions
derive_wing
def derive_wing(transcript_path: str, project_dir: Optional[str] = None, entity_hint: Optional[str] = None) -> strDerive a wing from unambiguous signals, with entity as last-resort hint.
This is the formal derivation contract for #157. The priority order is:
1. cwd (from the JSONL transcript — canonical)
2. transcript path (encoded .claude/projects folder / -Projects- segment)
3. project directory hint (explicit ``project_dir`` passed by the caller)
4. entity hint (optional — only when 1-3 are all absent)
5. unfiled (``wing_sessions``)
Signals 1 and 2 are resolved together by :func:_wing_from_transcript_path (cwd first, then the encoded path). Signal 3 is the directory the caller knows it is operating in, used when the transcript carries no usable path.
The entity hint (4) is a hint, never a gate: it is consulted only when every unambiguous signal above is absent. A confident entity match can never override a cwd/transcript/project-dir signal. This is the demotion required by #157 — the entity detector informs, it does not classify.
derive_room
def derive_room(content: str = '', room_hint: Optional[str] = None, entity_hint: Optional[str] = None) -> strDerive a canonical room from unambiguous signals, entity as last resort.
Mirrors :func:derive_wing's contract for the room axis (#157):
1. explicit room hint (caller-supplied canonical room — unambiguous)
2. keyword-derived room (content scored against canonical room rules)
3. entity hint (optional — only when 1-2 yield nothing)
4. unfiled (canonical default room)
As with the wing, the entity hint never gates: a keyword-derived room always beats an entity guess. The room result is always one of the canonical rooms (FK-safe), since both the keyword path and the default come from :mod:mempalace.convo_miner's canonical rule set.
hook_stop
def hook_stop(data: dict, harness: str)Stop hook: block every N messages for auto-save.
hook_session_start
def hook_session_start(data: dict, harness: str)Session start hook: initialize session tracking state.
Also runs a best-effort pending-queue replay and, when the daemon looks unreachable or the queue has pending entries, emits a one-line warning via systemMessage so the user notices within minutes (rather than days, as happened in the 2026-05-17 power-event incident). The warning is throttled to once per session via a marker in STATE_DIR.
hook_session_end
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.
hook_precompact
def hook_precompact(data: dict, harness: str)Precompact hook: trigger transcript ingest + project mine, then allow compaction.
Respects the hooks.auto_save config toggle — when disabled, returns immediately without mining.
Two write paths fire in sequence:
_ingest_transcript(transcript_path)— local mode spawns a backgroundmempalace minePopen (best-effort, non-blocking); daemon-strict mode POSTs to/mine(the daemon serializes under its own_mine_sem, replays from a queue if a rebuild is in progress)._mine_sync()— synchronous mempalace mine of MEMPAL_DIR (project files) when set. Blocks until exit (subprocess.run).
run_hook
def run_hook(hook_name: str, harness: str)Main entry point: read stdin JSON, dispatch to hook handler.
