diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 00000000..d0edcedc --- /dev/null +++ b/agent/__init__.py @@ -0,0 +1,21 @@ +""" +agent — Cross-session agent memory and lifecycle hooks. + +Provides persistent memory for agents via MemPalace integration. +Agents recall context at session start and write diary entries at session end. + +Modules: + memory.py — AgentMemory class (recall, remember, diary) + memory_hooks.py — Session lifecycle hooks (drop-in integration) +""" + +from agent.memory import AgentMemory, MemoryContext, SessionTranscript, create_agent_memory +from agent.memory_hooks import MemoryHooks + +__all__ = [ + "AgentMemory", + "MemoryContext", + "MemoryHooks", + "SessionTranscript", + "create_agent_memory", +] diff --git a/agent/memory.py b/agent/memory.py new file mode 100644 index 00000000..deb0c68e --- /dev/null +++ b/agent/memory.py @@ -0,0 +1,396 @@ +""" +agent.memory — Cross-session agent memory via MemPalace. + +Gives agents persistent memory across sessions. On wake-up, agents +recall relevant context from past sessions. On session end, they +write a diary entry summarizing what happened. + +Architecture: + Session Start → memory.recall_context() → inject L0/L1 into prompt + During Session → memory.remember() → store important facts + Session End → memory.write_diary() → summarize session + +All operations degrade gracefully — if MemPalace is unavailable, +the agent continues without memory and logs a warning. + +Usage: + from agent.memory import AgentMemory + + mem = AgentMemory(agent_name="bezalel", wing="wing_bezalel") + + # Session start — load context + context = mem.recall_context("What was I working on last time?") + + # During session — store important decisions + mem.remember("Switched CI runner from GitHub Actions to self-hosted", room="forge") + + # Session end — write diary + mem.write_diary("Fixed PR #1386, reconciled fleet registry locations") +""" + +from __future__ import annotations + +import json +import logging +import os +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +logger = logging.getLogger("agent.memory") + + +@dataclass +class MemoryContext: + """Context loaded at session start from MemPalace.""" + relevant_memories: list[dict] = field(default_factory=list) + recent_diaries: list[dict] = field(default_factory=list) + facts: list[dict] = field(default_factory=list) + loaded: bool = False + error: Optional[str] = None + + def to_prompt_block(self) -> str: + """Format context as a text block to inject into the agent prompt.""" + if not self.loaded: + return "" + + parts = [] + + if self.recent_diaries: + parts.append("=== Recent Session Summaries ===") + for d in self.recent_diaries[:3]: + ts = d.get("timestamp", "") + text = d.get("text", "") + parts.append(f"[{ts}] {text[:500]}") + + if self.facts: + parts.append("\n=== Known Facts ===") + for f in self.facts[:10]: + text = f.get("text", "") + parts.append(f"- {text[:200]}") + + if self.relevant_memories: + parts.append("\n=== Relevant Past Memories ===") + for m in self.relevant_memories[:5]: + text = m.get("text", "") + score = m.get("score", 0) + parts.append(f"[{score:.2f}] {text[:300]}") + + if not parts: + return "" + + return "\n".join(parts) + + +@dataclass +class SessionTranscript: + """A running log of the current session for diary writing.""" + agent_name: str + wing: str + started_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + entries: list[dict] = field(default_factory=list) + + def add_user_turn(self, text: str): + self.entries.append({ + "role": "user", + "text": text[:2000], + "ts": time.time(), + }) + + def add_agent_turn(self, text: str): + self.entries.append({ + "role": "agent", + "text": text[:2000], + "ts": time.time(), + }) + + def add_tool_call(self, tool: str, args: str, result_summary: str): + self.entries.append({ + "role": "tool", + "tool": tool, + "args": args[:500], + "result": result_summary[:500], + "ts": time.time(), + }) + + def summary(self) -> str: + """Generate a compact transcript summary.""" + if not self.entries: + return "Empty session." + + turns = [] + for e in self.entries[-20:]: # last 20 entries + role = e["role"] + if role == "user": + turns.append(f"USER: {e['text'][:200]}") + elif role == "agent": + turns.append(f"AGENT: {e['text'][:200]}") + elif role == "tool": + turns.append(f"TOOL({e.get('tool','')}): {e.get('result','')[:150]}") + + return "\n".join(turns) + + +class AgentMemory: + """ + Cross-session memory for an agent. + + Wraps MemPalace with agent-specific conventions: + - Each agent has a wing (e.g., "wing_bezalel") + - Session summaries go in the "hermes" room + - Important decisions go in room-specific closets + - Facts go in the "nexus" room + """ + + def __init__( + self, + agent_name: str, + wing: Optional[str] = None, + palace_path: Optional[Path] = None, + ): + self.agent_name = agent_name + self.wing = wing or f"wing_{agent_name}" + self.palace_path = palace_path + self._transcript: Optional[SessionTranscript] = None + self._available: Optional[bool] = None + + def _check_available(self) -> bool: + """Check if MemPalace is accessible.""" + if self._available is not None: + return self._available + + try: + from nexus.mempalace.searcher import search_memories, add_memory, _get_client + from nexus.mempalace.config import MEMPALACE_PATH + + path = self.palace_path or MEMPALACE_PATH + _get_client(path) + self._available = True + logger.info(f"MemPalace available at {path}") + except Exception as e: + self._available = False + logger.warning(f"MemPalace unavailable: {e}") + + return self._available + + def recall_context( + self, + query: Optional[str] = None, + n_results: int = 5, + ) -> MemoryContext: + """ + Load relevant context from past sessions. + + Called at session start to inject L0/L1 memory into the prompt. + + Args: + query: What to search for. If None, loads recent diary entries. + n_results: Max memories to recall. + """ + ctx = MemoryContext() + + if not self._check_available(): + ctx.error = "MemPalace unavailable" + return ctx + + try: + from nexus.mempalace.searcher import search_memories + + # Load recent diary entries (session summaries) + ctx.recent_diaries = [ + {"text": r.text, "score": r.score, "timestamp": r.metadata.get("timestamp", "")} + for r in search_memories( + "session summary", + palace_path=self.palace_path, + wing=self.wing, + room="hermes", + n_results=3, + ) + ] + + # Load known facts + ctx.facts = [ + {"text": r.text, "score": r.score} + for r in search_memories( + "important facts decisions", + palace_path=self.palace_path, + wing=self.wing, + room="nexus", + n_results=5, + ) + ] + + # Search for relevant memories if query provided + if query: + ctx.relevant_memories = [ + {"text": r.text, "score": r.score, "room": r.room} + for r in search_memories( + query, + palace_path=self.palace_path, + wing=self.wing, + n_results=n_results, + ) + ] + + ctx.loaded = True + + except Exception as e: + ctx.error = str(e) + logger.warning(f"Failed to recall context: {e}") + + return ctx + + def remember( + self, + text: str, + room: str = "nexus", + source_file: str = "", + metadata: Optional[dict] = None, + ) -> Optional[str]: + """ + Store a memory. + + Args: + text: The memory content. + room: Target room (forge, hermes, nexus, issues, experiments). + source_file: Optional source attribution. + metadata: Extra metadata. + + Returns: + Document ID if stored, None if MemPalace unavailable. + """ + if not self._check_available(): + logger.warning("Cannot store memory — MemPalace unavailable") + return None + + try: + from nexus.mempalace.searcher import add_memory + + doc_id = add_memory( + text=text, + room=room, + wing=self.wing, + palace_path=self.palace_path, + source_file=source_file, + extra_metadata=metadata or {}, + ) + logger.debug(f"Stored memory in {room}: {text[:80]}...") + return doc_id + + except Exception as e: + logger.warning(f"Failed to store memory: {e}") + return None + + def write_diary( + self, + summary: Optional[str] = None, + ) -> Optional[str]: + """ + Write a session diary entry to MemPalace. + + Called at session end. If summary is None, auto-generates one + from the session transcript. + + Args: + summary: Override summary text. If None, generates from transcript. + + Returns: + Document ID if stored, None if unavailable. + """ + if summary is None and self._transcript: + summary = self._transcript.summary() + + if not summary: + return None + + timestamp = datetime.now(timezone.utc).isoformat() + diary_text = f"[{timestamp}] Session by {self.agent_name}:\n{summary}" + + return self.remember( + diary_text, + room="hermes", + metadata={ + "type": "session_diary", + "agent": self.agent_name, + "timestamp": timestamp, + "entry_count": len(self._transcript.entries) if self._transcript else 0, + }, + ) + + def start_session(self) -> SessionTranscript: + """ + Begin a new session transcript. + + Returns the transcript object for recording turns. + """ + self._transcript = SessionTranscript( + agent_name=self.agent_name, + wing=self.wing, + ) + logger.info(f"Session started for {self.agent_name}") + return self._transcript + + def end_session(self, diary_summary: Optional[str] = None) -> Optional[str]: + """ + End the current session, write diary, return diary doc ID. + """ + doc_id = self.write_diary(diary_summary) + self._transcript = None + logger.info(f"Session ended for {self.agent_name}") + return doc_id + + def search( + self, + query: str, + room: Optional[str] = None, + n_results: int = 5, + ) -> list[dict]: + """ + Search memories. Useful during a session for recall. + + Returns list of {text, room, wing, score} dicts. + """ + if not self._check_available(): + return [] + + try: + from nexus.mempalace.searcher import search_memories + + results = search_memories( + query, + palace_path=self.palace_path, + wing=self.wing, + room=room, + n_results=n_results, + ) + return [ + {"text": r.text, "room": r.room, "wing": r.wing, "score": r.score} + for r in results + ] + + except Exception as e: + logger.warning(f"Search failed: {e}") + return [] + + +# --- Fleet-wide memory helpers --- + +def create_agent_memory( + agent_name: str, + palace_path: Optional[Path] = None, +) -> AgentMemory: + """ + Factory for creating AgentMemory with standard config. + + Reads wing from MEMPALACE_WING env or defaults to wing_{agent_name}. + """ + wing = os.environ.get("MEMPALACE_WING", f"wing_{agent_name}") + return AgentMemory( + agent_name=agent_name, + wing=wing, + palace_path=palace_path, + ) diff --git a/agent/memory_hooks.py b/agent/memory_hooks.py new file mode 100644 index 00000000..139f04f4 --- /dev/null +++ b/agent/memory_hooks.py @@ -0,0 +1,183 @@ +""" +agent.memory_hooks — Session lifecycle hooks for agent memory. + +Integrates AgentMemory into the agent session lifecycle: +- on_session_start: Load context, inject into prompt +- on_user_turn: Record user input +- on_agent_turn: Record agent output +- on_tool_call: Record tool usage +- on_session_end: Write diary, clean up + +These hooks are designed to be called from the Hermes harness or +any agent framework. They're fire-and-forget — failures are logged +but never crash the session. + +Usage: + from agent.memory_hooks import MemoryHooks + + hooks = MemoryHooks(agent_name="bezalel") + hooks.on_session_start() # loads context + + # In your agent loop: + hooks.on_user_turn("Check CI pipeline health") + hooks.on_agent_turn("Running CI check...") + hooks.on_tool_call("shell", "pytest tests/", "12 passed") + + # End of session: + hooks.on_session_end() # writes diary +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from agent.memory import AgentMemory, MemoryContext, create_agent_memory + +logger = logging.getLogger("agent.memory_hooks") + + +class MemoryHooks: + """ + Drop-in session lifecycle hooks for agent memory. + + Wraps AgentMemory with error boundaries — every hook catches + exceptions and logs warnings so memory failures never crash + the agent session. + """ + + def __init__( + self, + agent_name: str, + palace_path=None, + auto_diary: bool = True, + ): + self.agent_name = agent_name + self.auto_diary = auto_diary + self._memory: Optional[AgentMemory] = None + self._context: Optional[MemoryContext] = None + self._active = False + + @property + def memory(self) -> AgentMemory: + if self._memory is None: + self._memory = create_agent_memory( + self.agent_name, + palace_path=getattr(self, '_palace_path', None), + ) + return self._memory + + def on_session_start(self, query: Optional[str] = None) -> str: + """ + Called at session start. Loads context from MemPalace. + + Returns a prompt block to inject into the agent's context, or + empty string if memory is unavailable. + + Args: + query: Optional recall query (e.g., "What was I working on?") + """ + try: + self.memory.start_session() + self._active = True + + self._context = self.memory.recall_context(query=query) + block = self._context.to_prompt_block() + + if block: + logger.info( + f"Loaded {len(self._context.recent_diaries)} diaries, " + f"{len(self._context.facts)} facts, " + f"{len(self._context.relevant_memories)} relevant memories " + f"for {self.agent_name}" + ) + else: + logger.info(f"No prior memory for {self.agent_name}") + + return block + + except Exception as e: + logger.warning(f"Session start memory hook failed: {e}") + return "" + + def on_user_turn(self, text: str): + """Record a user message.""" + if not self._active: + return + try: + if self.memory._transcript: + self.memory._transcript.add_user_turn(text) + except Exception as e: + logger.debug(f"Failed to record user turn: {e}") + + def on_agent_turn(self, text: str): + """Record an agent response.""" + if not self._active: + return + try: + if self.memory._transcript: + self.memory._transcript.add_agent_turn(text) + except Exception as e: + logger.debug(f"Failed to record agent turn: {e}") + + def on_tool_call(self, tool: str, args: str, result_summary: str): + """Record a tool invocation.""" + if not self._active: + return + try: + if self.memory._transcript: + self.memory._transcript.add_tool_call(tool, args, result_summary) + except Exception as e: + logger.debug(f"Failed to record tool call: {e}") + + def on_important_decision(self, text: str, room: str = "nexus"): + """ + Record an important decision or fact for long-term memory. + + Use this when the agent makes a significant decision that + should persist beyond the current session. + """ + try: + self.memory.remember(text, room=room, metadata={"type": "decision"}) + logger.info(f"Remembered decision: {text[:80]}...") + except Exception as e: + logger.warning(f"Failed to remember decision: {e}") + + def on_session_end(self, summary: Optional[str] = None) -> Optional[str]: + """ + Called at session end. Writes diary entry. + + Args: + summary: Override diary text. If None, auto-generates. + + Returns: + Diary document ID, or None. + """ + if not self._active: + return None + + try: + doc_id = self.memory.end_session(diary_summary=summary) + self._active = False + self._context = None + return doc_id + except Exception as e: + logger.warning(f"Session end memory hook failed: {e}") + self._active = False + return None + + def search(self, query: str, room: Optional[str] = None) -> list[dict]: + """ + Search memories during a session. + + Returns list of {text, room, wing, score}. + """ + try: + return self.memory.search(query, room=room) + except Exception as e: + logger.warning(f"Memory search failed: {e}") + return [] + + @property + def is_active(self) -> bool: + return self._active diff --git a/bin/memory_mine.py b/bin/memory_mine.py new file mode 100644 index 00000000..b65da21a --- /dev/null +++ b/bin/memory_mine.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +memory_mine.py — Mine session transcripts into MemPalace. + +Reads Hermes session logs (JSONL format) and stores summaries +in the palace. Supports batch mining, single-file processing, +and live directory watching. + +Usage: + # Mine a single session file + python3 bin/memory_mine.py ~/.hermes/sessions/2026-04-13.jsonl + + # Mine all sessions from last 7 days + python3 bin/memory_mine.py --days 7 + + # Mine a specific wing's sessions + python3 bin/memory_mine.py --wing wing_bezalel --days 14 + + # Dry run — show what would be mined + python3 bin/memory_mine.py --dry-run --days 7 +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("memory-mine") + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +def parse_session_file(path: Path) -> list[dict]: + """ + Parse a JSONL session file into turns. + + Each line is expected to be a JSON object with: + - role: "user" | "assistant" | "system" | "tool" + - content: text + - timestamp: ISO string (optional) + """ + turns = [] + with open(path) as f: + for i, line in enumerate(f): + line = line.strip() + if not line: + continue + try: + turn = json.loads(line) + turns.append(turn) + except json.JSONDecodeError: + logger.debug(f"Skipping malformed line {i+1} in {path}") + return turns + + +def summarize_session(turns: list[dict], agent_name: str = "unknown") -> str: + """ + Generate a compact summary of a session's turns. + + Keeps user messages and key agent responses, strips noise. + """ + if not turns: + return "Empty session." + + user_msgs = [] + agent_msgs = [] + tool_calls = [] + + for turn in turns: + role = turn.get("role", "") + content = str(turn.get("content", ""))[:300] + + if role == "user": + user_msgs.append(content) + elif role == "assistant": + agent_msgs.append(content) + elif role == "tool": + tool_name = turn.get("name", turn.get("tool", "unknown")) + tool_calls.append(f"{tool_name}: {content[:150]}") + + parts = [f"Session by {agent_name}:"] + + if user_msgs: + parts.append(f"\nUser asked ({len(user_msgs)} messages):") + for msg in user_msgs[:5]: + parts.append(f" - {msg[:200]}") + if len(user_msgs) > 5: + parts.append(f" ... and {len(user_msgs) - 5} more") + + if agent_msgs: + parts.append(f"\nAgent responded ({len(agent_msgs)} messages):") + for msg in agent_msgs[:3]: + parts.append(f" - {msg[:200]}") + + if tool_calls: + parts.append(f"\nTools used ({len(tool_calls)} calls):") + for tc in tool_calls[:5]: + parts.append(f" - {tc}") + + return "\n".join(parts) + + +def mine_session( + path: Path, + wing: str, + palace_path: Optional[Path] = None, + dry_run: bool = False, +) -> Optional[str]: + """ + Mine a single session file into MemPalace. + + Returns the document ID if stored, None on failure or dry run. + """ + try: + from agent.memory import AgentMemory + except ImportError: + logger.error("Cannot import agent.memory — is the repo in PYTHONPATH?") + return None + + turns = parse_session_file(path) + if not turns: + logger.debug(f"Empty session file: {path}") + return None + + agent_name = wing.replace("wing_", "") + summary = summarize_session(turns, agent_name) + + if dry_run: + print(f"\n--- {path.name} ---") + print(summary[:500]) + print(f"({len(turns)} turns)") + return None + + mem = AgentMemory(agent_name=agent_name, wing=wing, palace_path=palace_path) + doc_id = mem.remember( + summary, + room="hermes", + source_file=str(path), + metadata={ + "type": "mined_session", + "source": str(path), + "turn_count": len(turns), + "agent": agent_name, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + ) + + if doc_id: + logger.info(f"Mined {path.name} → {doc_id} ({len(turns)} turns)") + else: + logger.warning(f"Failed to mine {path.name}") + + return doc_id + + +def find_session_files( + sessions_dir: Path, + days: int = 7, + pattern: str = "*.jsonl", +) -> list[Path]: + """ + Find session files from the last N days. + """ + cutoff = datetime.now() - timedelta(days=days) + files = [] + + if not sessions_dir.exists(): + logger.warning(f"Sessions directory not found: {sessions_dir}") + return files + + for path in sorted(sessions_dir.glob(pattern)): + # Use file modification time as proxy for session date + mtime = datetime.fromtimestamp(path.stat().st_mtime) + if mtime >= cutoff: + files.append(path) + + return files + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Mine session transcripts into MemPalace" + ) + parser.add_argument( + "files", nargs="*", help="Session files to mine (JSONL format)" + ) + parser.add_argument( + "--days", type=int, default=7, + help="Mine sessions from last N days (default: 7)" + ) + parser.add_argument( + "--sessions-dir", + default=str(Path.home() / ".hermes" / "sessions"), + help="Directory containing session JSONL files" + ) + parser.add_argument( + "--wing", default=None, + help="Wing name (default: auto-detect from MEMPALACE_WING env or 'wing_timmy')" + ) + parser.add_argument( + "--palace-path", default=None, + help="Override palace path" + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Show what would be mined without storing" + ) + + args = parser.parse_args(argv) + + wing = args.wing or os.environ.get("MEMPALACE_WING", "wing_timmy") + palace_path = Path(args.palace_path) if args.palace_path else None + + if args.files: + files = [Path(f) for f in args.files] + else: + sessions_dir = Path(args.sessions_dir) + files = find_session_files(sessions_dir, days=args.days) + + if not files: + logger.info("No session files found to mine.") + return 0 + + logger.info(f"Mining {len(files)} session files (wing={wing})") + + mined = 0 + failed = 0 + for path in files: + result = mine_session(path, wing=wing, palace_path=palace_path, dry_run=args.dry_run) + if result: + mined += 1 + elif result is None and not args.dry_run: + failed += 1 + + if args.dry_run: + logger.info(f"Dry run complete — {len(files)} files would be mined") + else: + logger.info(f"Mining complete — {mined} mined, {failed} failed") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_agent_memory.py b/tests/test_agent_memory.py new file mode 100644 index 00000000..35669d36 --- /dev/null +++ b/tests/test_agent_memory.py @@ -0,0 +1,377 @@ +""" +Tests for agent memory — cross-session agent memory via MemPalace. + +Tests the memory module, hooks, and session mining without requiring +a live ChromaDB instance. Uses mocking for the MemPalace backend. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from agent.memory import ( + AgentMemory, + MemoryContext, + SessionTranscript, + create_agent_memory, +) +from agent.memory_hooks import MemoryHooks + + +# --------------------------------------------------------------------------- +# SessionTranscript tests +# --------------------------------------------------------------------------- + + +class TestSessionTranscript: + def test_create(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + assert t.agent_name == "test" + assert t.wing == "wing_test" + assert len(t.entries) == 0 + + def test_add_user_turn(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + t.add_user_turn("Hello") + assert len(t.entries) == 1 + assert t.entries[0]["role"] == "user" + assert t.entries[0]["text"] == "Hello" + + def test_add_agent_turn(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + t.add_agent_turn("Response") + assert t.entries[0]["role"] == "agent" + + def test_add_tool_call(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + t.add_tool_call("shell", "ls", "file1 file2") + assert t.entries[0]["role"] == "tool" + assert t.entries[0]["tool"] == "shell" + + def test_summary_empty(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + assert t.summary() == "Empty session." + + def test_summary_with_entries(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + t.add_user_turn("Do something") + t.add_agent_turn("Done") + t.add_tool_call("shell", "ls", "ok") + + summary = t.summary() + assert "USER: Do something" in summary + assert "AGENT: Done" in summary + assert "TOOL(shell): ok" in summary + + def test_text_truncation(self): + t = SessionTranscript(agent_name="test", wing="wing_test") + long_text = "x" * 5000 + t.add_user_turn(long_text) + assert len(t.entries[0]["text"]) == 2000 + + +# --------------------------------------------------------------------------- +# MemoryContext tests +# --------------------------------------------------------------------------- + + +class TestMemoryContext: + def test_empty_context(self): + ctx = MemoryContext() + assert ctx.to_prompt_block() == "" + + def test_unloaded_context(self): + ctx = MemoryContext() + ctx.loaded = False + assert ctx.to_prompt_block() == "" + + def test_loaded_with_data(self): + ctx = MemoryContext() + ctx.loaded = True + ctx.recent_diaries = [ + {"text": "Fixed PR #1386", "timestamp": "2026-04-13T10:00:00Z"} + ] + ctx.facts = [ + {"text": "Bezalel runs on VPS Beta", "score": 0.95} + ] + ctx.relevant_memories = [ + {"text": "Changed CI runner", "score": 0.87} + ] + + block = ctx.to_prompt_block() + assert "Recent Session Summaries" in block + assert "Fixed PR #1386" in block + assert "Known Facts" in block + assert "Bezalel runs on VPS Beta" in block + assert "Relevant Past Memories" in block + + def test_loaded_empty(self): + ctx = MemoryContext() + ctx.loaded = True + # No data — should return empty string + assert ctx.to_prompt_block() == "" + + +# --------------------------------------------------------------------------- +# AgentMemory tests (with mocked MemPalace) +# --------------------------------------------------------------------------- + + +class TestAgentMemory: + def test_create(self): + mem = AgentMemory(agent_name="bezalel") + assert mem.agent_name == "bezalel" + assert mem.wing == "wing_bezalel" + + def test_custom_wing(self): + mem = AgentMemory(agent_name="bezalel", wing="custom_wing") + assert mem.wing == "custom_wing" + + def test_factory(self): + mem = create_agent_memory("ezra") + assert mem.agent_name == "ezra" + assert mem.wing == "wing_ezra" + + def test_unavailable_graceful(self): + """Test graceful degradation when MemPalace is unavailable.""" + mem = AgentMemory(agent_name="test") + mem._available = False # Force unavailable + + # Should not raise + ctx = mem.recall_context("test query") + assert ctx.loaded is False + assert ctx.error == "MemPalace unavailable" + + # remember returns None + assert mem.remember("test") is None + + # search returns empty + assert mem.search("test") == [] + + def test_start_end_session(self): + mem = AgentMemory(agent_name="test") + mem._available = False + + transcript = mem.start_session() + assert isinstance(transcript, SessionTranscript) + assert mem._transcript is not None + + doc_id = mem.end_session() + assert mem._transcript is None + + def test_remember_graceful_when_unavailable(self): + """Test remember returns None when MemPalace is unavailable.""" + mem = AgentMemory(agent_name="test") + mem._available = False + + doc_id = mem.remember("some important fact") + assert doc_id is None + + def test_write_diary_from_transcript(self): + mem = AgentMemory(agent_name="test") + mem._available = False + + transcript = mem.start_session() + transcript.add_user_turn("Hello") + transcript.add_agent_turn("Hi there") + + # Write diary should handle unavailable gracefully + doc_id = mem.write_diary() + assert doc_id is None # MemPalace unavailable + + +# --------------------------------------------------------------------------- +# MemoryHooks tests +# --------------------------------------------------------------------------- + + +class TestMemoryHooks: + def test_create(self): + hooks = MemoryHooks(agent_name="bezalel") + assert hooks.agent_name == "bezalel" + assert hooks.is_active is False + + def test_session_lifecycle(self): + hooks = MemoryHooks(agent_name="test") + + # Force memory unavailable + hooks._memory = AgentMemory(agent_name="test") + hooks._memory._available = False + + # Start session + block = hooks.on_session_start() + assert hooks.is_active is True + assert block == "" # No memory available + + # Record turns + hooks.on_user_turn("Hello") + hooks.on_agent_turn("Hi") + hooks.on_tool_call("shell", "ls", "ok") + + # Record decision + hooks.on_important_decision("Switched to self-hosted CI") + + # End session + doc_id = hooks.on_session_end() + assert hooks.is_active is False + + def test_hooks_before_session(self): + """Hooks before session start should be no-ops.""" + hooks = MemoryHooks(agent_name="test") + hooks._memory = AgentMemory(agent_name="test") + hooks._memory._available = False + + # Should not raise + hooks.on_user_turn("Hello") + hooks.on_agent_turn("Response") + + def test_hooks_after_session_end(self): + """Hooks after session end should be no-ops.""" + hooks = MemoryHooks(agent_name="test") + hooks._memory = AgentMemory(agent_name="test") + hooks._memory._available = False + + hooks.on_session_start() + hooks.on_session_end() + + # Should not raise + hooks.on_user_turn("Late message") + doc_id = hooks.on_session_end() + assert doc_id is None + + def test_search_during_session(self): + hooks = MemoryHooks(agent_name="test") + hooks._memory = AgentMemory(agent_name="test") + hooks._memory._available = False + + results = hooks.search("some query") + assert results == [] + + +# --------------------------------------------------------------------------- +# Session mining tests +# --------------------------------------------------------------------------- + + +class TestSessionMining: + def test_parse_session_file(self): + from bin.memory_mine import parse_session_file + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + f.write('{"role": "user", "content": "Hello"}\n') + f.write('{"role": "assistant", "content": "Hi there"}\n') + f.write('{"role": "tool", "name": "shell", "content": "ls output"}\n') + f.write("\n") # blank line + f.write("not json\n") # malformed + path = Path(f.name) + + turns = parse_session_file(path) + assert len(turns) == 3 + assert turns[0]["role"] == "user" + assert turns[1]["role"] == "assistant" + assert turns[2]["role"] == "tool" + path.unlink() + + def test_summarize_session(self): + from bin.memory_mine import summarize_session + + turns = [ + {"role": "user", "content": "Check CI"}, + {"role": "assistant", "content": "Running CI check..."}, + {"role": "tool", "name": "shell", "content": "5 tests passed"}, + {"role": "assistant", "content": "CI is healthy"}, + ] + + summary = summarize_session(turns, "bezalel") + assert "bezalel" in summary + assert "Check CI" in summary + assert "shell" in summary + + def test_summarize_empty(self): + from bin.memory_mine import summarize_session + + assert summarize_session([], "test") == "Empty session." + + def test_find_session_files(self, tmp_path): + from bin.memory_mine import find_session_files + + # Create some test files + (tmp_path / "session1.jsonl").write_text("{}\n") + (tmp_path / "session2.jsonl").write_text("{}\n") + (tmp_path / "notes.txt").write_text("not a session") + + files = find_session_files(tmp_path, days=365) + assert len(files) == 2 + assert all(f.suffix == ".jsonl" for f in files) + + def test_find_session_files_missing_dir(self): + from bin.memory_mine import find_session_files + + files = find_session_files(Path("/nonexistent/path"), days=7) + assert files == [] + + def test_mine_session_dry_run(self, tmp_path): + from bin.memory_mine import mine_session + + session_file = tmp_path / "test.jsonl" + session_file.write_text( + '{"role": "user", "content": "Hello"}\n' + '{"role": "assistant", "content": "Hi"}\n' + ) + + result = mine_session(session_file, wing="wing_test", dry_run=True) + assert result is None # dry run doesn't store + + def test_mine_session_empty_file(self, tmp_path): + from bin.memory_mine import mine_session + + session_file = tmp_path / "empty.jsonl" + session_file.write_text("") + + result = mine_session(session_file, wing="wing_test") + assert result is None + + +# --------------------------------------------------------------------------- +# Integration test — full lifecycle +# --------------------------------------------------------------------------- + + +class TestFullLifecycle: + """Test the full session lifecycle without a real MemPalace backend.""" + + def test_full_session_flow(self): + hooks = MemoryHooks(agent_name="bezalel") + + # Force memory unavailable + hooks._memory = AgentMemory(agent_name="bezalel") + hooks._memory._available = False + + # 1. Session start + context_block = hooks.on_session_start("What CI issues do I have?") + assert isinstance(context_block, str) + + # 2. User asks question + hooks.on_user_turn("Check CI pipeline health") + + # 3. Agent uses tool + hooks.on_tool_call("shell", "pytest tests/", "12 passed") + + # 4. Agent responds + hooks.on_agent_turn("CI pipeline is healthy. All 12 tests passing.") + + # 5. Important decision + hooks.on_important_decision("Decided to keep current CI runner", room="forge") + + # 6. More interaction + hooks.on_user_turn("Good, check memory integration next") + hooks.on_agent_turn("Will test agent.memory module") + + # 7. Session end + doc_id = hooks.on_session_end() + assert hooks.is_active is False