Skip to content
Open
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
135 changes: 120 additions & 15 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,25 @@
from datetime import datetime
from pathlib import Path

from .config import MempalaceConfig

SAVE_INTERVAL = 15
STATE_DIR = Path.home() / ".mempalace" / "hook_state"

STOP_BLOCK_REASON = (
"AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n"
"1. mempalace_diary_write — AAAK-compressed session summary\n"
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n"
"3. mempalace_kg_add — entity relationships (optional)\n"
"Do NOT write to Claude Code's native auto-memory (.md files). "
"Continue conversation after saving."
"AUTO-SAVE checkpoint (MemPalace). First check whether MemPalace MCP tools are available in this session.\n"
"If tools like mempalace_diary_write, mempalace_add_drawer, or mempalace_kg_add are available, use them to save this session's key content.\n"
"If those MCP tools are NOT available, do not invent CLI substitutes and do not run shell commands like mempalace_diary_write or mempalace_add_drawer. Those are MCP tool names, not CLI commands.\n"
"In that case, briefly note that MemPalace auto-save is unavailable in this session and continue normally.\n"
"Do NOT write to Claude Code's native auto-memory (.md files)."
)

PRECOMPACT_BLOCK_REASON = (
"COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n"
"1. mempalace_diary_write — thorough AAAK-compressed session summary\n"
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n"
"3. mempalace_kg_add — entity relationships (optional)\n"
"Be thorough \u2014 after compaction, detailed context will be lost. "
"Do NOT write to Claude Code's native auto-memory (.md files). "
"Save everything to MemPalace, then allow compaction to proceed."
"COMPACTION IMMINENT (MemPalace). First check whether MemPalace MCP tools are available in this session.\n"
"If tools like mempalace_diary_write, mempalace_add_drawer, or mempalace_kg_add are available, use them to save ALL important session content before context is lost.\n"
"If those MCP tools are NOT available, do not invent CLI substitutes and do not run shell commands like mempalace_diary_write or mempalace_add_drawer. Those are MCP tool names, not CLI commands.\n"
"In that case, briefly note that MemPalace auto-save is unavailable in this session, avoid fake saves, and let compaction proceed.\n"
"Do NOT write to Claude Code's native auto-memory (.md files)."
)


Expand Down Expand Up @@ -94,6 +93,98 @@ def _log(message: str):
pass


def _silent_save_enabled() -> bool:
"""Whether hooks should save directly instead of blocking for MCP calls."""
try:
return MempalaceConfig().hook_silent_save
except Exception:
return True


def _transcript_excerpt(transcript_path: str, max_chars: int = 6000) -> str:
"""Return a normalized tail excerpt from the transcript for direct checkpointing."""
if not transcript_path:
return ""

try:
from .normalize import normalize

text = normalize(transcript_path)
except Exception:
try:
text = Path(transcript_path).expanduser().read_text(encoding="utf-8", errors="replace")
except OSError:
return ""

text = text.strip()
if not text:
return ""
if len(text) <= max_chars:
return text
return "...\n" + text[-max_chars:]


def _write_checkpoint_ack(exchange_count: int, hook_name: str, result: dict):
"""Write a short ack file so clients can notice a silent save happened."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
ack_file = STATE_DIR / "last_checkpoint"
payload = {
"ts": datetime.now().isoformat(),
"msgs": exchange_count,
"hook": hook_name,
"entry_id": result.get("entry_id"),
}
ack_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except OSError:
pass


def _silent_checkpoint(
session_id: str,
transcript_path: str,
exchange_count: int,
hook_name: str,
harness: str,
) -> bool:
"""Persist a lightweight diary checkpoint directly from the hook runtime."""
excerpt = _transcript_excerpt(transcript_path)
if not excerpt:
excerpt = "(No transcript excerpt available.)"

agent_name = harness.replace("-", "_")
topic = f"{agent_name}_autosave"

entry = (
f"Harness: {harness}\n"
f"Session: {session_id}\n"
f"Hook: {hook_name}\n"
f"Human messages: {exchange_count}\n"
f"Timestamp: {datetime.now().isoformat()}\n\n"
f"Transcript excerpt:\n{excerpt}"
)

try:
from .mcp_server import tool_diary_write

result = tool_diary_write(
agent_name=agent_name,
entry=entry,
topic=topic,
)
except Exception as e:
_log(f"silent checkpoint failed during {hook_name}: {e}")
return False

if result.get("success"):
_write_checkpoint_ack(exchange_count, hook_name, result)
_log(f"silent checkpoint saved during {hook_name}: {result.get('entry_id', 'unknown')}")
return True

_log(f"silent checkpoint failed during {hook_name}: {result}")
return False


def _output(data: dict):
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
print(json.dumps(data, indent=2, ensure_ascii=False))
Expand Down Expand Up @@ -171,6 +262,12 @@ def hook_stop(data: dict, harness: str):
# Optional: auto-ingest if MEMPAL_DIR is set
_maybe_auto_ingest()

if _silent_save_enabled():
if _silent_checkpoint(session_id, transcript_path, exchange_count, "stop", harness):
_output({})
return
_log("silent save failed, falling back to legacy MCP block")

_output({"decision": "block", "reason": STOP_BLOCK_REASON})
else:
_output({})
Expand All @@ -191,9 +288,11 @@ def hook_session_start(data: dict, harness: str):


def hook_precompact(data: dict, harness: str):
"""Precompact hook: always block with comprehensive save instruction."""
"""Precompact hook: save directly when silent-save is enabled, else block for MCP."""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
transcript_path = parsed["transcript_path"]
exchange_count = _count_human_messages(transcript_path)

_log(f"PRE-COMPACT triggered for session {session_id}")

Expand All @@ -212,7 +311,13 @@ def hook_precompact(data: dict, harness: str):
except OSError:
pass

# Always block -- compaction = save everything
if _silent_save_enabled():
if _silent_checkpoint(session_id, transcript_path, exchange_count, "precompact", harness):
_output({})
return
_log("silent precompact save failed, falling back to legacy MCP block")

# Legacy fallback: block so Claude can attempt MCP-based persistence
_output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})


Expand Down