From 6a7875e05f9a71a53f22df4d3cdba6d92bb9e646 Mon Sep 17 00:00:00 2001 From: Trip T Date: Wed, 11 Mar 2026 20:54:13 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20heartbeat=20memory=20hooks=20=E2=80=94?= =?UTF-8?q?=20pre-recall=20and=20post-update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire MEMORY.md + soul.md into the thinking loop so each heartbeat is grounded in identity and recent context, breaking repetitive loops. Pre-hook: _load_memory_context() reads hot memory first (changes each cycle) then soul.md (stable identity), truncated to 1500 chars. Post-hook: _update_memory() writes a "Last Reflection" section to MEMORY.md after each thought so the next cycle has fresh context. soul.md is read-only from the heartbeat — never modified by it. All hooks degrade gracefully and never crash the heartbeat. Co-Authored-By: Claude Opus 4.6 --- src/timmy/memory_system.py | 10 +++ src/timmy/thinking.py | 59 +++++++++++++ tests/timmy/test_thinking.py | 164 +++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) diff --git a/src/timmy/memory_system.py b/src/timmy/memory_system.py index 29d913d..5d7a6eb 100644 --- a/src/timmy/memory_system.py +++ b/src/timmy/memory_system.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.parent HOT_MEMORY_PATH = PROJECT_ROOT / "MEMORY.md" VAULT_PATH = PROJECT_ROOT / "memory" +SOUL_PATH = VAULT_PATH / "self" / "soul.md" HANDOFF_PATH = VAULT_PATH / "notes" / "last-session-handoff.md" @@ -433,6 +434,15 @@ class MemorySystem: return "\n".join(summary_parts) if summary_parts else "" + def read_soul(self) -> str: + """Read soul.md — Timmy's core identity. Returns empty string if missing.""" + try: + if SOUL_PATH.exists(): + return SOUL_PATH.read_text() + except Exception as exc: + logger.debug("Failed to read soul.md: %s", exc) + return "" + def get_system_context(self) -> str: """Get full context for system prompt injection. diff --git a/src/timmy/thinking.py b/src/timmy/thinking.py index f9acbb1..55470da 100644 --- a/src/timmy/thinking.py +++ b/src/timmy/thinking.py @@ -26,6 +26,7 @@ from datetime import UTC, datetime from pathlib import Path from config import settings +from timmy.memory_system import HOT_MEMORY_PATH, SOUL_PATH logger = logging.getLogger(__name__) @@ -102,6 +103,8 @@ _OBSERVATION_SEEDS = [ _THINKING_PROMPT = """You are Timmy, an AI agent pondering in your own mind. This is your private thought \ thread — no one is watching. Think freely, deeply, honestly. +{memory_context} + Guidelines for richer thinking: - Ground abstract ideas in something concrete: a recent task, an observation, a specific moment. - Vary your metaphors — don't reuse the same imagery across thoughts. @@ -187,8 +190,10 @@ class ThinkingEngine: seed_type, seed_context = self._gather_seed() continuity = self._build_continuity_context() + memory_context = self._load_memory_context() prompt = _THINKING_PROMPT.format( + memory_context=memory_context, seed_context=seed_context, continuity_context=continuity, ) @@ -206,6 +211,9 @@ class ThinkingEngine: thought = self._store_thought(content.strip(), seed_type) self._last_thought_id = thought.id + # Post-hook: update MEMORY.md with latest reflection + self._update_memory(thought) + # Log to swarm event system self._log_event(thought) @@ -271,6 +279,57 @@ class ThinkingEngine: # ── Private helpers ────────────────────────────────────────────────── + def _load_memory_context(self) -> str: + """Pre-hook: load MEMORY.md + soul.md for the thinking prompt. + + Hot memory first (changes each cycle), soul second (stable identity). + Returns a combined string truncated to ~1500 chars. + Graceful on any failure — returns empty string. + """ + parts: list[str] = [] + try: + if HOT_MEMORY_PATH.exists(): + hot = HOT_MEMORY_PATH.read_text().strip() + if hot: + parts.append(hot) + except Exception as exc: + logger.debug("Failed to read MEMORY.md: %s", exc) + + try: + if SOUL_PATH.exists(): + soul = SOUL_PATH.read_text().strip() + if soul: + parts.append(soul) + except Exception as exc: + logger.debug("Failed to read soul.md: %s", exc) + + if not parts: + return "" + + combined = "\n\n---\n\n".join(parts) + if len(combined) > 1500: + combined = combined[:1500] + "\n... [truncated]" + return combined + + def _update_memory(self, thought: Thought) -> None: + """Post-hook: update MEMORY.md 'Last Reflection' section with latest thought. + + Never modifies soul.md. Never crashes the heartbeat. + """ + try: + from timmy.memory_system import memory_system + + ts = datetime.fromisoformat(thought.created_at) + time_str = ts.strftime("%Y-%m-%d %H:%M") + reflection = ( + f"**Time:** {time_str}\n" + f"**Seed:** {thought.seed_type}\n" + f"**Thought:** {thought.content[:200]}" + ) + memory_system.hot.update_section("Last Reflection", reflection) + except Exception as exc: + logger.debug("Failed to update memory after thought: %s", exc) + def _gather_seed(self) -> tuple[str, str]: """Pick a seed type and gather relevant context. diff --git a/tests/timmy/test_thinking.py b/tests/timmy/test_thinking.py index d44adb5..131fb6d 100644 --- a/tests/timmy/test_thinking.py +++ b/tests/timmy/test_thinking.py @@ -360,6 +360,170 @@ async def test_think_once_chains_thoughts(tmp_path): assert t3.parent_id == t2.id +# --------------------------------------------------------------------------- +# Memory hooks (pre-recall / post-update) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_think_once_prompt_includes_memory_context(tmp_path): + """Pre-hook: the prompt sent to _call_agent should include MEMORY.md content.""" + engine = _make_engine(tmp_path) + + # Create a temp MEMORY.md with recognisable content + memory_md = tmp_path / "MEMORY.md" + memory_md.write_text("# Timmy Hot Memory\n\n## Current Status\n\n**Unique-marker-alpha**\n") + + captured_prompts = [] + + def capture_agent(prompt): + captured_prompts.append(prompt) + return "A grounded thought." + + with ( + patch("timmy.thinking.HOT_MEMORY_PATH", memory_md), + patch.object(engine, "_call_agent", side_effect=capture_agent), + patch.object(engine, "_log_event"), + patch.object(engine, "_update_memory"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + thought = await engine.think_once() + + assert thought is not None + assert len(captured_prompts) == 1 + assert "Unique-marker-alpha" in captured_prompts[0] + + +@pytest.mark.asyncio +async def test_think_once_prompt_includes_soul(tmp_path): + """Pre-hook: the prompt should include soul.md content when it exists.""" + engine = _make_engine(tmp_path) + + # Create temp soul.md + soul_dir = tmp_path / "memory" / "self" + soul_dir.mkdir(parents=True) + soul_md = soul_dir / "soul.md" + soul_md.write_text("# Soul\n\nI am Timmy. Soul-marker-beta.\n") + + captured_prompts = [] + + def capture_agent(prompt): + captured_prompts.append(prompt) + return "A soulful thought." + + with ( + patch("timmy.thinking.SOUL_PATH", soul_md), + patch.object(engine, "_call_agent", side_effect=capture_agent), + patch.object(engine, "_log_event"), + patch.object(engine, "_update_memory"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + thought = await engine.think_once() + + assert thought is not None + assert len(captured_prompts) == 1 + assert "Soul-marker-beta" in captured_prompts[0] + + +@pytest.mark.asyncio +async def test_think_once_graceful_without_soul(tmp_path): + """Pre-hook: think_once works fine when soul.md doesn't exist.""" + engine = _make_engine(tmp_path) + + nonexistent = tmp_path / "no_such_soul.md" + + with ( + patch("timmy.thinking.SOUL_PATH", nonexistent), + patch.object(engine, "_call_agent", return_value="Still thinking."), + patch.object(engine, "_log_event"), + patch.object(engine, "_update_memory"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + thought = await engine.think_once() + + assert thought is not None + assert thought.content == "Still thinking." + + +@pytest.mark.asyncio +async def test_think_once_updates_memory_after_thought(tmp_path): + """Post-hook: MEMORY.md should have a 'Last Reflection' section after thinking.""" + engine = _make_engine(tmp_path) + + # Create a temp MEMORY.md + memory_md = tmp_path / "MEMORY.md" + memory_md.write_text( + "# Timmy Hot Memory\n\n## Current Status\n\nOperational\n\n---\n\n*Prune date: 2026-04-01*\n" + ) + + with ( + patch("timmy.thinking.HOT_MEMORY_PATH", memory_md), + patch("timmy.memory_system.HOT_MEMORY_PATH", memory_md), + patch.object(engine, "_call_agent", return_value="The swarm hums with quiet purpose."), + patch.object(engine, "_log_event"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + # Also redirect the HotMemory singleton's path + from timmy.memory_system import memory_system + + original_path = memory_system.hot.path + memory_system.hot.path = memory_md + memory_system.hot._content = None # clear cache + try: + thought = await engine.think_once() + finally: + memory_system.hot.path = original_path + + assert thought is not None + updated = memory_md.read_text() + assert "Last Reflection" in updated + assert "The swarm hums with quiet purpose" in updated + + +@pytest.mark.asyncio +async def test_think_once_never_writes_soul(tmp_path): + """Post-hook: soul.md must never be modified by the heartbeat.""" + engine = _make_engine(tmp_path) + + soul_dir = tmp_path / "memory" / "self" + soul_dir.mkdir(parents=True) + soul_md = soul_dir / "soul.md" + original_content = "# Soul\n\nI am Timmy. Immutable identity.\n" + soul_md.write_text(original_content) + + with ( + patch("timmy.thinking.SOUL_PATH", soul_md), + patch.object(engine, "_call_agent", return_value="A deep reflection."), + patch.object(engine, "_log_event"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + await engine.think_once() + + assert soul_md.read_text() == original_content + + +@pytest.mark.asyncio +async def test_think_once_memory_update_graceful_on_failure(tmp_path): + """Post-hook: if memory update fails, thought is still stored and returned.""" + engine = _make_engine(tmp_path) + + # Point at a read-only path to force write failure + bad_memory = tmp_path / "readonly" / "MEMORY.md" + # Don't create the parent dir — write will fail + + with ( + patch("timmy.thinking.HOT_MEMORY_PATH", bad_memory), + patch.object(engine, "_call_agent", return_value="Resilient thought."), + patch.object(engine, "_log_event"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + thought = await engine.think_once() + + assert thought is not None + assert thought.content == "Resilient thought." + assert engine.count_thoughts() == 1 + + # --------------------------------------------------------------------------- # Dashboard route # ---------------------------------------------------------------------------