"""Session Scratchpad — ephemeral key-value notes per session. Provides fast, JSON-backed scratch storage that lives for a session and can be promoted to durable palace memory. Storage: ~/.hermes/scratchpad/{session_id}.json Refs: Epic #367, Sub-issue #372 """ from __future__ import annotations import json import os import subprocess import time from pathlib import Path from typing import Any, Optional # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- SCRATCHPAD_DIR = Path.home() / ".hermes" / "scratchpad" MEMPALACE_BIN = "/Library/Frameworks/Python.framework/Versions/3.12/bin/mempalace" # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _scratch_path(session_id: str) -> Path: """Return the JSON file path for a given session.""" # Sanitize session_id to prevent path traversal safe_id = "".join(c for c in session_id if c.isalnum() or c in "-_") if not safe_id: safe_id = "unnamed" return SCRATCHPAD_DIR / f"{safe_id}.json" def _load(session_id: str) -> dict: """Load scratchpad data, returning empty dict on failure.""" path = _scratch_path(session_id) try: if path.exists(): return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): pass return {} def _save(session_id: str, data: dict) -> None: """Persist scratchpad data to disk.""" SCRATCHPAD_DIR.mkdir(parents=True, exist_ok=True) path = _scratch_path(session_id) path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def write_scratch(session_id: str, key: str, value: Any) -> None: """Write a note to the session scratchpad. Args: session_id: Current session identifier. key: Note key (string). value: Note value (any JSON-serializable type). """ data = _load(session_id) data[key] = { "value": value, "written_at": time.strftime("%Y-%m-%d %H:%M:%S"), } _save(session_id, data) def read_scratch(session_id: str, key: Optional[str] = None) -> dict: """Read session scratchpad (all keys or one). Args: session_id: Current session identifier. key: Optional specific key. If None, returns all entries. Returns: dict — either {key: {value, written_at}} or the full scratchpad. """ data = _load(session_id) if key is not None: entry = data.get(key) return {key: entry} if entry else {} return data def delete_scratch(session_id: str, key: str) -> bool: """Remove a single key from the scratchpad. Returns True if the key existed and was removed. """ data = _load(session_id) if key in data: del data[key] _save(session_id, data) return True return False def list_sessions() -> list[str]: """List all session IDs that have scratchpad files.""" try: if SCRATCHPAD_DIR.exists(): return [ f.stem for f in SCRATCHPAD_DIR.iterdir() if f.suffix == ".json" and f.is_file() ] except OSError: pass return [] def promote_to_palace( session_id: str, key: str, room: str = "general", drawer: Optional[str] = None, ) -> bool: """Move a scratchpad note to durable palace memory. Uses the mempalace CLI to store the note in the specified room. Removes the note from the scratchpad after successful promotion. Args: session_id: Session containing the note. key: Scratchpad key to promote. room: Palace room name (default: 'general'). drawer: Optional drawer name within the room. Defaults to key. Returns: True if promotion succeeded, False otherwise. """ data = _load(session_id) entry = data.get(key) if not entry: return False value = entry.get("value", entry) if isinstance(entry, dict) else entry content = json.dumps(value, default=str) if not isinstance(value, str) else value try: bin_path = MEMPALACE_BIN if os.path.exists(MEMPALACE_BIN) else "mempalace" target_drawer = drawer or key result = subprocess.run( [bin_path, "store", room, target_drawer, content], capture_output=True, text=True, timeout=10, ) if result.returncode == 0: # Remove from scratchpad after successful promotion del data[key] _save(session_id, data) return True except (FileNotFoundError, subprocess.TimeoutExpired, OSError): # mempalace CLI not available — degrade gracefully pass return False def clear_session(session_id: str) -> bool: """Delete the entire scratchpad for a session. Returns True if the file existed and was removed. """ path = _scratch_path(session_id) try: if path.exists(): path.unlink() return True except OSError: pass return False