feat: heartbeat memory hooks — pre-recall and post-update

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 <noreply@anthropic.com>
This commit is contained in:
Trip T
2026-03-11 20:54:13 -04:00
parent ea2dbdb4b5
commit 6a7875e05f
3 changed files with 233 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.