Files
matrix-bridge-legacy/heartbeat.py
2026-03-28 23:50:54 -04:00

380 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Heartbeat Service for Matrix-Letta Bridge
Sends periodic heartbeats to wake the agent up on a schedule.
SILENT MODE: Agent's text output is NOT auto-delivered to Matrix.
The agent must use the `matrix-send-message` MCP tool to contact the user.
Based on lettabot's heartbeat implementation.
"""
import asyncio
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Callable, Awaitable
from dataclasses import dataclass, field
from prompts import build_heartbeat_prompt, build_cron_prompt
log = logging.getLogger("meridian.heartbeat")
# Log file for heartbeat events
LOG_PATH = Path("./store/heartbeat-log.jsonl")
@dataclass
class HeartbeatConfig:
"""Heartbeat configuration"""
enabled: bool = True
interval_minutes: int = 60 # Default: every hour
# Skip heartbeat if user messaged within this many minutes
skip_if_recent_minutes: int = 5
# Custom heartbeat prompt (optional - uses default if None)
custom_prompt: Optional[str] = None
# Target room for proactive messages (optional - uses last active room if None)
target_room_id: Optional[str] = None
@dataclass
class HeartbeatState:
"""Runtime state for heartbeat service"""
last_user_message_time: Optional[datetime] = None
last_heartbeat_time: Optional[datetime] = None
last_active_room_id: Optional[str] = None
heartbeat_count: int = 0
skipped_count: int = 0
paused: bool = False
is_running: bool = False # Currently executing a heartbeat
paused_since: Optional[datetime] = None
paused_by: Optional[str] = None # Who paused it (user context)
class HeartbeatService:
"""
Heartbeat Service - Periodic agent wake-ups
SILENT MODE: Agent's response text is NOT auto-delivered.
The agent must use MCP tools to send messages proactively.
"""
def __init__(
self,
config: HeartbeatConfig,
send_to_agent: Callable[[str, str], Awaitable], # Returns LettaResponse
get_conversation_id: Callable[[str], Awaitable[Optional[str]]],
):
"""
Initialize heartbeat service.
Args:
config: Heartbeat configuration
send_to_agent: Async function to send message to Letta agent
Takes (message, conversation_id) -> LettaResponse
get_conversation_id: Async function to get conversation ID for a room
"""
self.config = config
self.send_to_agent = send_to_agent
self.get_conversation_id = get_conversation_id
self.state = HeartbeatState()
self._task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
self._pause_event = asyncio.Event() # Set when paused
# Ensure log directory exists
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
def _log_event(self, event: str, data: dict) -> None:
"""Log heartbeat event to file and console"""
import json
entry = {
"timestamp": datetime.now().isoformat(),
"event": event,
**data,
}
try:
with open(LOG_PATH, "a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
pass # Ignore log errors
log.info(f"[Heartbeat] {event}: {data}")
def update_last_user_message(self, room_id: str) -> None:
"""Call this when a user sends a message"""
self.state.last_user_message_time = datetime.now()
self.state.last_active_room_id = room_id
def start(self) -> None:
"""Start the heartbeat timer"""
if not self.config.enabled:
log.info("[Heartbeat] Disabled")
return
if self._task and not self._task.done():
log.info("[Heartbeat] Already running")
return
self._stop_event.clear()
self._task = asyncio.create_task(self._heartbeat_loop())
log.info(
f"[Heartbeat] Starting in SILENT MODE "
f"(every {self.config.interval_minutes} minutes)"
)
log.info(
f"[Heartbeat] First heartbeat in {self.config.interval_minutes} minutes"
)
self._log_event("heartbeat_started", {
"interval_minutes": self.config.interval_minutes,
"mode": "silent",
"note": "Agent must use matrix-send-message MCP tool to contact user",
})
def stop(self) -> None:
"""Stop the heartbeat timer"""
self._stop_event.set()
if self._task:
self._task.cancel()
self._task = None
log.info("[Heartbeat] Stopped")
def pause(self, by: str = "user") -> str:
"""
Pause the heartbeat service (keeps timer running but skips heartbeats).
Args:
by: Who paused it (for tracking purposes)
Returns:
Status message
"""
if self.state.paused:
return "⏸️ Heartbeat is already paused"
self.state.paused = True
self.state.paused_since = datetime.now()
self.state.paused_by = by
log.info(f"[Heartbeat] Paused by {by}")
self._log_event("heartbeat_paused", {
"by": by,
"paused_since": self.state.paused_since.isoformat(),
})
return f"⏸️ Heartbeat paused by {by}"
def resume(self, by: str = "user", trigger_immediately: bool = False) -> str:
"""
Resume the heartbeat service.
Args:
by: Who resumed it (for tracking purposes)
trigger_immediately: If True, trigger a heartbeat now
Returns:
Status message
"""
if not self.state.paused:
return "▶️ Heartbeat is not paused"
self.state.paused = False
paused_duration = datetime.now() - self.state.paused_since if self.state.paused_since else None
log.info(f"[Heartbeat] Resumed by {by} (paused for {paused_duration})")
self._pause_event.clear()
self._log_event("heartbeat_resumed", {
"by": by,
"paused_duration_minutes": paused_duration.total_seconds() / 60 if paused_duration else None,
})
# Clear pause timestamps
self.state.paused_since = None
self.state.paused_by = None
if trigger_immediately:
log.info("[Heartbeat] Triggering heartbeat immediately on resume")
asyncio.create_task(self._run_heartbeat(skip_recent_check=True))
return f"▶️ Heartbeat resumed by {by}"
async def trigger(self, by: str = "user") -> None:
"""
Manually trigger a heartbeat (or resume if paused).
Args:
by: Who triggered it (for tracking purposes)
If paused, this resumes the heartbeat and runs immediately.
"""
if self.state.paused:
log.info("[Heartbeat] Trigger on paused heartbeat - resuming...")
self.resume(by=by, trigger_immediately=True)
else:
log.info("[Heartbeat] Manual trigger requested")
await self._run_heartbeat(skip_recent_check=True)
async def _heartbeat_loop(self) -> None:
"""Main heartbeat loop"""
interval_seconds = self.config.interval_minutes * 60
while not self._stop_event.is_set():
try:
# Wait for interval (or until stopped)
await asyncio.wait_for(
self._stop_event.wait(),
timeout=interval_seconds
)
# If we get here, stop was requested
break
except asyncio.TimeoutError:
# Check if paused - if so, skip this cycle
if self.state.paused:
log.info("[Heartbeat] Skipped (paused)")
continue
# Timeout means it's time for a heartbeat
await self._run_heartbeat()
async def _run_heartbeat(self, skip_recent_check: bool = False) -> None:
"""
Run a single heartbeat.
SILENT MODE: Agent's text output is NOT auto-delivered.
The agent must use MCP tools to send messages proactively.
"""
now = datetime.now()
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
timezone = datetime.now().astimezone().tzname() or "UTC"
log.info("")
log.info("=" * 60)
log.info(f"[Heartbeat] ⏰ RUNNING at {formatted_time} [SILENT MODE]")
log.info("=" * 60)
log.info("")
try:
# Skip if user sent a message recently (unless manual trigger)
if not skip_recent_check and self.state.last_user_message_time:
time_since_last = now - self.state.last_user_message_time
skip_window = timedelta(minutes=self.config.skip_if_recent_minutes)
if time_since_last < skip_window:
minutes_ago = int(time_since_last.total_seconds() / 60)
log.info(
f"[Heartbeat] User messaged {minutes_ago}m ago - skipping heartbeat"
)
self._log_event("heartbeat_skipped_recent_user", {
"last_user_message": self.state.last_user_message_time.isoformat(),
"minutes_ago": minutes_ago,
})
self.state.skipped_count += 1
return
# Get target room and conversation
target_room = self.config.target_room_id or self.state.last_active_room_id
if not target_room:
log.warning("[Heartbeat] No target room - skipping (no user has messaged yet)")
self._log_event("heartbeat_skipped_no_room", {})
return
conversation_id = await self.get_conversation_id(target_room)
if not conversation_id:
log.warning(f"[Heartbeat] No conversation for room {target_room} - skipping")
self._log_event("heartbeat_skipped_no_conversation", {
"room_id": target_room,
})
return
log.info(f"[Heartbeat] Sending heartbeat to agent...")
log.info(f"[Heartbeat] Target room: {target_room}")
self._log_event("heartbeat_running", {
"time": now.isoformat(),
"mode": "silent",
"target_room": target_room,
})
# Build the heartbeat message
if self.config.custom_prompt:
message = self.config.custom_prompt
else:
message = build_heartbeat_prompt(
formatted_time,
timezone,
self.config.interval_minutes,
target_room,
)
log.info(f"[Heartbeat] Sending prompt (SILENT MODE):")
log.info("-" * 50)
for line in message.split("\n")[:10]: # Show first 10 lines
log.info(f" {line}")
log.info(" ...")
log.info("-" * 50)
# Send to agent - response text is NOT delivered (silent mode)
# Agent must use MCP tools to send messages
letta_response = await asyncio.to_thread(
self.send_to_agent,
message,
conversation_id,
)
# Extract from LettaResponse object
response = letta_response.assistant_text
status = letta_response.status
# Log results
log.info(f"[Heartbeat] Agent finished.")
log.info(f" - Status: {status}")
log.info(f" - Response text: {len(response) if response else 0} chars (NOT delivered - silent mode)")
if response and response.strip():
preview = response[:100] + ("..." if len(response) > 100 else "")
log.info(f" - Response preview: \"{preview}\"")
self.state.last_heartbeat_time = now
self.state.heartbeat_count += 1
self._log_event("heartbeat_completed", {
"mode": "silent",
"response_length": len(response) if response else 0,
"status": status,
"heartbeat_count": self.state.heartbeat_count,
})
except Exception as e:
log.error(f"[Heartbeat] Error: {e}")
self._log_event("heartbeat_error", {
"error": str(e),
})
finally:
# Always reset running flag when done
self.state.is_running = False
def get_status(self) -> dict:
"""Get heartbeat service status"""
return {
"enabled": self.config.enabled,
"running": self._task is not None and not self._task.done(),
"paused": self.state.paused,
"is_running": self.state.is_running, # Currently executing a heartbeat
"interval_minutes": self.config.interval_minutes,
"skip_if_recent_minutes": self.config.skip_if_recent_minutes,
"heartbeat_count": self.state.heartbeat_count,
"skipped_count": self.state.skipped_count,
"last_heartbeat": self.state.last_heartbeat_time.isoformat() if self.state.last_heartbeat_time else None,
"last_user_message": self.state.last_user_message_time.isoformat() if self.state.last_user_message_time else None,
"last_active_room": self.state.last_active_room_id,
"paused_since": self.state.paused_since.isoformat() if self.state.paused_since else None,
"paused_by": self.state.paused_by,
}