"""Morning Briefing Engine — Timmy shows up before you ask. BriefingEngine queries recent swarm activity and chat history, asks Timmy's Agno agent to summarise the period, and returns a Briefing with an embedded list of ApprovalItems the owner needs to action. Briefings are cached in SQLite so page loads are instant. A background task regenerates the briefing every 6 hours. """ import logging import sqlite3 from collections.abc import Generator from contextlib import closing, contextmanager from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path logger = logging.getLogger(__name__) _DEFAULT_DB = Path.home() / ".timmy" / "briefings.db" _CACHE_MINUTES = 30 # --------------------------------------------------------------------------- # Data structures # --------------------------------------------------------------------------- @dataclass class ApprovalItem: """Lightweight representation used inside a Briefing. The canonical mutable version (with persistence) lives in timmy.approvals. This one travels with the Briefing dataclass as a read-only snapshot. """ id: str title: str description: str proposed_action: str impact: str created_at: datetime status: str @dataclass class Briefing: generated_at: datetime summary: str # 150-300 words approval_items: list[ApprovalItem] = field(default_factory=list) period_start: datetime = field(default_factory=lambda: datetime.now(UTC) - timedelta(hours=6)) period_end: datetime = field(default_factory=lambda: datetime.now(UTC)) # --------------------------------------------------------------------------- # SQLite cache # --------------------------------------------------------------------------- @contextmanager def _get_cache_conn(db_path: Path = _DEFAULT_DB) -> Generator[sqlite3.Connection, None, None]: db_path.parent.mkdir(parents=True, exist_ok=True) with closing(sqlite3.connect(str(db_path))) as conn: conn.row_factory = sqlite3.Row conn.execute(""" CREATE TABLE IF NOT EXISTS briefings ( id INTEGER PRIMARY KEY AUTOINCREMENT, generated_at TEXT NOT NULL, period_start TEXT NOT NULL, period_end TEXT NOT NULL, summary TEXT NOT NULL ) """) conn.commit() yield conn def _save_briefing(briefing: Briefing, db_path: Path = _DEFAULT_DB) -> None: with _get_cache_conn(db_path) as conn: conn.execute( """ INSERT INTO briefings (generated_at, period_start, period_end, summary) VALUES (?, ?, ?, ?) """, ( briefing.generated_at.isoformat(), briefing.period_start.isoformat(), briefing.period_end.isoformat(), briefing.summary, ), ) conn.commit() def _load_latest(db_path: Path = _DEFAULT_DB) -> Briefing | None: """Load the most-recently cached briefing, or None if there is none.""" with _get_cache_conn(db_path) as conn: row = conn.execute("SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1").fetchone() if row is None: return None return Briefing( generated_at=datetime.fromisoformat(row["generated_at"]), period_start=datetime.fromisoformat(row["period_start"]), period_end=datetime.fromisoformat(row["period_end"]), summary=row["summary"], ) def is_fresh(briefing: Briefing, max_age_minutes: int = _CACHE_MINUTES) -> bool: """Return True if the briefing was generated within max_age_minutes.""" now = datetime.now(UTC) age = ( now - briefing.generated_at.replace(tzinfo=UTC) if briefing.generated_at.tzinfo is None else now - briefing.generated_at ) return age.total_seconds() < max_age_minutes * 60 # --------------------------------------------------------------------------- # Activity gathering helpers # --------------------------------------------------------------------------- def _gather_swarm_summary(since: datetime) -> str: """Pull recent task/agent stats from swarm.db. Graceful if DB missing.""" swarm_db = Path("data/swarm.db") if not swarm_db.exists(): return "No swarm activity recorded yet." try: with closing(sqlite3.connect(str(swarm_db))) as conn: conn.row_factory = sqlite3.Row since_iso = since.isoformat() completed = conn.execute( "SELECT COUNT(*) as c FROM tasks WHERE status = 'completed' AND created_at > ?", (since_iso,), ).fetchone()["c"] failed = conn.execute( "SELECT COUNT(*) as c FROM tasks WHERE status = 'failed' AND created_at > ?", (since_iso,), ).fetchone()["c"] agents = conn.execute( "SELECT COUNT(*) as c FROM agents WHERE registered_at > ?", (since_iso,), ).fetchone()["c"] parts = [] if completed: parts.append(f"{completed} task(s) completed") if failed: parts.append(f"{failed} task(s) failed") if agents: parts.append(f"{agents} new agent(s) joined the swarm") return "; ".join(parts) if parts else "No swarm activity in this period." except Exception as exc: logger.debug("Swarm summary error: %s", exc) return "Swarm data unavailable." def _gather_task_queue_summary() -> str: """Pull task queue stats for the briefing. Graceful if unavailable.""" try: from swarm.task_queue.models import get_task_summary_for_briefing stats = get_task_summary_for_briefing() parts = [] if stats["pending_approval"]: parts.append(f"{stats['pending_approval']} task(s) pending approval") if stats["running"]: parts.append(f"{stats['running']} task(s) running") if stats["completed"]: parts.append(f"{stats['completed']} task(s) completed") if stats["failed"]: parts.append(f"{stats['failed']} task(s) failed") for fail in stats.get("recent_failures", []): parts.append(f" - Failed: {fail['title']}") if stats["vetoed"]: parts.append(f"{stats['vetoed']} task(s) vetoed") return "; ".join(parts) if parts else "No tasks in the queue." except Exception as exc: logger.debug("Task queue summary error: %s", exc) return "Task queue data unavailable." def _gather_chat_summary(since: datetime) -> str: """Pull recent chat messages from the in-memory log.""" try: from infrastructure.chat_store import message_log messages = message_log.all() # Filter to messages in the briefing window (best-effort: no timestamps) recent = messages[-10:] if len(messages) > 10 else messages if not recent: return "No recent conversations." lines = [] for msg in recent: role = "Owner" if msg.role == "user" else "Timmy" lines.append(f"{role}: {msg.content[:120]}") return "\n".join(lines) except Exception as exc: logger.debug("Chat summary error: %s", exc) return "No recent conversations." # --------------------------------------------------------------------------- # BriefingEngine # --------------------------------------------------------------------------- class BriefingEngine: """Generates morning briefings by querying activity and asking Timmy.""" def __init__(self, db_path: Path = _DEFAULT_DB) -> None: self._db_path = db_path def get_cached(self) -> Briefing | None: """Return the cached briefing if it exists, without regenerating.""" return _load_latest(self._db_path) def needs_refresh(self) -> bool: """True if there is no fresh briefing cached.""" cached = _load_latest(self._db_path) if cached is None: return True return not is_fresh(cached) def generate(self) -> Briefing: """Generate a fresh briefing. May take a few seconds (LLM call).""" now = datetime.now(UTC) period_start = now - timedelta(hours=6) swarm_info = _gather_swarm_summary(period_start) chat_info = _gather_chat_summary(period_start) task_info = _gather_task_queue_summary() prompt = ( "You are a sovereign local AI companion.\n" "Here is what happened since the last briefing:\n\n" f"SWARM ACTIVITY:\n{swarm_info}\n\n" f"TASK QUEUE:\n{task_info}\n\n" f"RECENT CONVERSATIONS:\n{chat_info}\n\n" "Summarize the last period of activity into a 5-minute morning briefing. " "Be concise, warm, and direct. " "Use plain prose — no bullet points. " "Maximum 300 words. " "If there are tasks pending approval, mention them prominently. " "If there are failed tasks, flag them as needing attention. " "End with a short paragraph listing any items that need the owner's approval, " "or say 'No approvals needed today.' if there are none." ) try: summary = self._call_agent(prompt) except Exception as exc: logger.warning("generate(): agent call raised unexpectedly: %s", exc) summary = ( "Good morning. Timmy is offline right now, so this briefing " "could not be generated from live data. Check that Ollama is " "running and try again." ) # Attach any outstanding pending approval items approval_items = self._load_pending_items() briefing = Briefing( generated_at=now, summary=summary, approval_items=approval_items, period_start=period_start, period_end=now, ) _save_briefing(briefing, self._db_path) logger.info("Briefing generated at %s", now.isoformat()) return briefing def get_or_generate(self) -> Briefing: """Return a fresh cached briefing or generate a new one.""" cached = _load_latest(self._db_path) if cached is not None and is_fresh(cached): # Reattach live pending items (they change between page loads) cached.approval_items = self._load_pending_items() return cached return self.generate() # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ def _call_agent(self, prompt: str) -> str: """Call Timmy's Agno agent and return the response text.""" try: from timmy.agent import create_timmy agent = create_timmy() run = agent.run(prompt, stream=False) result = run.content if hasattr(run, "content") else str(run) # Ensure we always return an actual string (guards against # MagicMock objects when agno is stubbed in tests). if not isinstance(result, str): return str(result) return result except Exception as exc: logger.warning("Agent call failed during briefing generation: %s", exc) return ( "Good morning. Timmy is offline right now, so this briefing " "could not be generated from live data. Check that Ollama is " "running and try again." ) def _load_pending_items(self) -> list[ApprovalItem]: """Return pending ApprovalItems from the approvals DB.""" try: from timmy import approvals as _approvals raw_items = _approvals.list_pending() return [ ApprovalItem( id=item.id, title=item.title, description=item.description, proposed_action=item.proposed_action, impact=item.impact, created_at=item.created_at, status=item.status, ) for item in raw_items ] except Exception as exc: logger.debug("Could not load approval items: %s", exc) return [] # Module-level singleton engine = BriefingEngine()