feat(briefing): morning briefing + approval queue

Implements the Morning Briefing and Approval Queue feature — the first step
from tool to companion.  Timmy now shows up before the owner asks.

New modules
-----------
• src/timmy/approvals.py  — ApprovalItem dataclass, GOLDEN_TIMMY governance
  constant, full SQLite CRUD (create / list / approve / reject / expire).
  Items auto-expire after 7 days if not actioned.
• src/timmy/briefing.py   — BriefingEngine that queries swarm activity and
  chat history, calls Timmy's Agno agent for a prose summary, and caches
  the result in SQLite (~/.timmy/briefings.db).  get_or_generate() skips
  regeneration if a fresh briefing (< 30 min) already exists.

New routes (src/dashboard/routes/briefing.py)
----------------------------------------------
  GET  /briefing                        — full briefing page
  GET  /briefing/approvals              — HTMX partial: pending approval cards
  POST /briefing/approvals/{id}/approve — approve via HTMX (no page reload)
  POST /briefing/approvals/{id}/reject  — reject via HTMX (no page reload)

New templates
-------------
• briefing.html           — clean, mobile-first prose layout (max 680px)
• partials/approval_cards.html         — list of approval cards
• partials/approval_card_single.html   — single approval card with
                                          Approve/Reject HTMX buttons

App wiring (src/dashboard/app.py)
----------------------------------
• Added asynccontextmanager lifespan with _briefing_scheduler background task.
  Generates a briefing at startup and every 6 hours; skips if fresh.

Push notification hook (src/notifications/push.py)
---------------------------------------------------
• notify_briefing_ready(briefing) — logs + triggers local notifier.
  Placeholder for APNs/Pushover wiring later.

Navigation
----------
• Added BRIEFING link to the header nav in base.html.

Tests
-----
• tests/test_approvals.py  — 17 tests: GOLDEN_TIMMY, CRUD, expiry, ordering
• tests/test_briefing.py   — 22 tests: dataclass, freshness, cache round-trip,
                              generate/get_or_generate, push notification hook

354 tests, 354 passing.

https://claude.ai/code/session_01D7p5w91KX3grBeioGiiGy8
This commit is contained in:
Claude
2026-02-22 14:04:20 +00:00
parent 648305d65c
commit ce6077be0c
11 changed files with 1326 additions and 0 deletions

View File

@@ -1,4 +1,6 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
@@ -16,6 +18,7 @@ from dashboard.routes.voice import router as voice_router
from dashboard.routes.voice_enhanced import router as voice_enhanced_router
from dashboard.routes.mobile import router as mobile_router
from dashboard.routes.swarm_ws import router as swarm_ws_router
from dashboard.routes.briefing import router as briefing_router
logging.basicConfig(
level=logging.INFO,
@@ -27,9 +30,50 @@ logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent
PROJECT_ROOT = BASE_DIR.parent.parent
_BRIEFING_INTERVAL_HOURS = 6
async def _briefing_scheduler() -> None:
"""Background task: regenerate Timmy's briefing every 6 hours.
Runs once at startup (after a short delay to let the server settle),
then on a 6-hour cadence. Skips generation if a fresh briefing already
exists (< 30 min old).
"""
from timmy.briefing import engine as briefing_engine
from notifications.push import notify_briefing_ready
await asyncio.sleep(2) # Let server finish starting before first run
while True:
try:
if briefing_engine.needs_refresh():
logger.info("Generating morning briefing…")
briefing = briefing_engine.generate()
await notify_briefing_ready(briefing)
else:
logger.info("Briefing is fresh; skipping generation.")
except Exception as exc:
logger.error("Briefing scheduler error: %s", exc)
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(_briefing_scheduler())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Timmy Time — Mission Control",
version="1.0.0",
lifespan=lifespan,
# Docs disabled unless DEBUG=true in env / .env
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
@@ -47,6 +91,7 @@ app.include_router(voice_router)
app.include_router(voice_enhanced_router)
app.include_router(mobile_router)
app.include_router(swarm_ws_router)
app.include_router(briefing_router)
@app.get("/", response_class=HTMLResponse)

View File

@@ -0,0 +1,70 @@
"""Briefing routes — Morning briefing and approval queue.
GET /briefing — render the briefing page
GET /briefing/approvals — HTMX partial: pending approval cards
POST /briefing/approvals/{id}/approve — approve an item (HTMX)
POST /briefing/approvals/{id}/reject — reject an item (HTMX)
"""
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from timmy.briefing import engine as briefing_engine
from timmy import approvals as approval_store
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/briefing", tags=["briefing"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("", response_class=HTMLResponse)
async def get_briefing(request: Request):
"""Return today's briefing page (generated or cached)."""
briefing = briefing_engine.get_or_generate()
return templates.TemplateResponse(
request,
"briefing.html",
{"briefing": briefing},
)
@router.get("/approvals", response_class=HTMLResponse)
async def get_approvals(request: Request):
"""Return HTMX partial with all pending approval items."""
items = approval_store.list_pending()
return templates.TemplateResponse(
request,
"partials/approval_cards.html",
{"items": items},
)
@router.post("/approvals/{item_id}/approve", response_class=HTMLResponse)
async def approve_item(request: Request, item_id: str):
"""Approve an approval item; return the updated card via HTMX."""
item = approval_store.approve(item_id)
if item is None:
return HTMLResponse("<p class='text-danger'>Item not found.</p>", status_code=404)
return templates.TemplateResponse(
request,
"partials/approval_card_single.html",
{"item": item},
)
@router.post("/approvals/{item_id}/reject", response_class=HTMLResponse)
async def reject_item(request: Request, item_id: str):
"""Reject an approval item; return the updated card via HTMX."""
item = approval_store.reject(item_id)
if item is None:
return HTMLResponse("<p class='text-danger'>Item not found.</p>", status_code=404)
return templates.TemplateResponse(
request,
"partials/approval_card_single.html",
{"item": item},
)

View File

@@ -21,6 +21,7 @@
<span class="mc-subtitle">MISSION CONTROL</span>
</div>
<div class="mc-header-right">
<a href="/briefing" class="mc-test-link">BRIEFING</a>
<a href="/swarm/live" class="mc-test-link">SWARM</a>
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
<a href="/mobile" class="mc-test-link">MOBILE</a>

View File

@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}Timmy Time — Morning Briefing{% endblock %}
{% block content %}
<div class="container briefing-container py-4">
<!-- Header -->
<div class="briefing-header mb-4">
<div class="briefing-greeting">Good morning.</div>
<div class="briefing-timestamp">
Briefing generated
<span class="briefing-ts-val">{{ briefing.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}</span>
&mdash; covering
<span class="briefing-ts-val">{{ briefing.period_start.strftime('%H:%M') }}</span>
to
<span class="briefing-ts-val">{{ briefing.period_end.strftime('%H:%M UTC') }}</span>
</div>
</div>
<!-- Summary -->
<div class="card mc-panel briefing-summary mb-5">
<div class="card-header mc-panel-header">// TIMMY&rsquo;S REPORT</div>
<div class="card-body p-4">
<div class="briefing-prose">{{ briefing.summary }}</div>
</div>
</div>
<!-- Approval Queue -->
<div class="card mc-panel">
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
<span>// APPROVAL QUEUE</span>
<span class="badge bg-warning text-dark"
id="approval-count">{{ briefing.approval_items | length }} pending</span>
</div>
<div class="card-body p-3"
id="approval-queue"
hx-get="/briefing/approvals"
hx-trigger="load"
hx-swap="innerHTML">
<!-- HTMX fills this on load with live data -->
<div class="text-center text-muted py-3">Loading approval items&hellip;</div>
</div>
</div>
</div>
<style>
/* ------------------------------------------------------------------ */
/* Briefing-specific styles — mobile-first */
/* ------------------------------------------------------------------ */
.briefing-container {
max-width: 680px;
}
.briefing-header {
border-left: 3px solid var(--mc-amber, #ffc107);
padding-left: 1rem;
}
.briefing-greeting {
font-size: 1.6rem;
font-weight: 700;
color: var(--mc-amber, #ffc107);
letter-spacing: 0.04em;
font-family: 'JetBrains Mono', monospace;
}
.briefing-timestamp {
font-size: 0.75rem;
color: #6c757d;
margin-top: 0.25rem;
}
.briefing-ts-val {
color: #adb5bd;
}
.briefing-prose {
font-size: 1rem;
line-height: 1.75;
color: #dee2e6;
white-space: pre-wrap;
word-break: break-word;
}
/* Approval cards */
.approval-card {
border: 1px solid #2a3a4a;
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
background: #0d1b2a;
transition: border-color 0.2s;
}
.approval-card.approved {
border-color: #198754;
opacity: 0.7;
}
.approval-card.rejected {
border-color: #dc3545;
opacity: 0.7;
}
.approval-card-title {
font-weight: 600;
font-size: 0.95rem;
color: #f8f9fa;
margin-bottom: 0.25rem;
}
.approval-card-desc {
font-size: 0.85rem;
color: #adb5bd;
margin-bottom: 0.5rem;
}
.approval-card-action {
font-size: 0.8rem;
color: #6c757d;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 0.75rem;
border-left: 2px solid #495057;
padding-left: 0.5rem;
}
.impact-badge {
font-size: 0.7rem;
padding: 0.2em 0.5em;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.impact-low { background: #198754; color: #fff; }
.impact-medium { background: #fd7e14; color: #fff; }
.impact-high { background: #dc3545; color: #fff; }
.approval-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-approve {
background: #198754;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
min-height: 44px;
}
.btn-approve:hover { background: #157347; }
.btn-reject {
background: transparent;
color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
min-height: 44px;
}
.btn-reject:hover { background: #dc354520; }
.no-approvals {
text-align: center;
color: #6c757d;
padding: 2rem 0;
font-size: 0.9rem;
}
/* Refresh button */
.btn-refresh {
background: transparent;
color: #adb5bd;
border: 1px solid #495057;
border-radius: 4px;
padding: 0.3rem 0.7rem;
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-refresh:hover {
color: #f8f9fa;
border-color: #6c757d;
}
@media (max-width: 576px) {
.briefing-greeting { font-size: 1.3rem; }
.briefing-prose { font-size: 0.95rem; }
}
</style>
{% endblock %}

View File

@@ -0,0 +1,33 @@
<div class="approval-card {{ item.status }}" id="approval-{{ item.id }}">
<div class="d-flex justify-content-between align-items-start mb-1">
<div class="approval-card-title">{{ item.title }}</div>
<span class="impact-badge impact-{{ item.impact }}">{{ item.impact }}</span>
</div>
<div class="approval-card-desc">{{ item.description }}</div>
<div class="approval-card-action">&#x25B6; {{ item.proposed_action }}</div>
{% if item.status == "pending" %}
<div class="approval-actions">
<button class="btn-approve"
hx-post="/briefing/approvals/{{ item.id }}/approve"
hx-target="#approval-{{ item.id }}"
hx-swap="outerHTML">
APPROVE
</button>
<button class="btn-reject"
hx-post="/briefing/approvals/{{ item.id }}/reject"
hx-target="#approval-{{ item.id }}"
hx-swap="outerHTML">
REJECT
</button>
</div>
{% elif item.status == "approved" %}
<div class="text-success" style="font-size:0.82rem; font-family:'JetBrains Mono',monospace;">
&#x2713; Approved
</div>
{% elif item.status == "rejected" %}
<div class="text-danger" style="font-size:0.82rem; font-family:'JetBrains Mono',monospace;">
&#x2717; Rejected
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,9 @@
{% if items %}
{% for item in items %}
{% include "partials/approval_card_single.html" %}
{% endfor %}
{% else %}
<div class="no-approvals">
No pending approvals. Timmy is standing by.
</div>
{% endif %}

View File

@@ -121,3 +121,25 @@ class PushNotifier:
# Module-level singleton
notifier = PushNotifier()
async def notify_briefing_ready(briefing) -> None:
"""Placeholder: notify the owner that a new morning briefing is ready.
Logs to console now. Wire to real push (APNs/Pushover) later.
Args:
briefing: A timmy.briefing.Briefing instance.
"""
n_approvals = len(briefing.approval_items) if briefing.approval_items else 0
message = (
f"Your morning briefing is ready. "
f"{n_approvals} item(s) await your approval."
)
notifier.notify(
title="Morning Briefing Ready",
message=message,
category="briefing",
native=True,
)
logger.info("Briefing push notification dispatched (%d approval(s))", n_approvals)

185
src/timmy/approvals.py Normal file
View File

@@ -0,0 +1,185 @@
"""Approval item management — the governance layer for autonomous Timmy actions.
The GOLDEN_TIMMY constant is the single source of truth for whether Timmy
may act autonomously. All features that want to take action must:
1. Create an ApprovalItem
2. Check GOLDEN_TIMMY
3. If True → wait for owner approval before executing
4. If False → log the action and proceed (Dark Timmy mode)
Default is always True. The owner changes this intentionally.
"""
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# GOLDEN TIMMY RULE
# ---------------------------------------------------------------------------
GOLDEN_TIMMY = True
# When True: no autonomous action executes without an approved ApprovalItem.
# When False: Dark Timmy mode — Timmy may act on his own judgment.
# Default is always True. Owner changes this intentionally.
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
_DEFAULT_DB = Path.home() / ".timmy" / "approvals.db"
_EXPIRY_DAYS = 7
@dataclass
class ApprovalItem:
id: str
title: str
description: str
proposed_action: str # what Timmy wants to do
impact: str # "low" | "medium" | "high"
created_at: datetime
status: str # "pending" | "approved" | "rejected"
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS approval_items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
proposed_action TEXT NOT NULL,
impact TEXT NOT NULL DEFAULT 'low',
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
)
"""
)
conn.commit()
return conn
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
return ApprovalItem(
id=row["id"],
title=row["title"],
description=row["description"],
proposed_action=row["proposed_action"],
impact=row["impact"],
created_at=datetime.fromisoformat(row["created_at"]),
status=row["status"],
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def create_item(
title: str,
description: str,
proposed_action: str,
impact: str = "low",
db_path: Path = _DEFAULT_DB,
) -> ApprovalItem:
"""Create and persist a new approval item."""
item = ApprovalItem(
id=str(uuid.uuid4()),
title=title,
description=description,
proposed_action=proposed_action,
impact=impact,
created_at=datetime.now(timezone.utc),
status="pending",
)
conn = _get_conn(db_path)
conn.execute(
"""
INSERT INTO approval_items
(id, title, description, proposed_action, impact, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
item.id,
item.title,
item.description,
item.proposed_action,
item.impact,
item.created_at.isoformat(),
item.status,
),
)
conn.commit()
conn.close()
return item
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
"""Return all pending approval items, newest first."""
conn = _get_conn(db_path)
rows = conn.execute(
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
).fetchall()
conn.close()
return [_row_to_item(r) for r in rows]
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
"""Return all approval items regardless of status, newest first."""
conn = _get_conn(db_path)
rows = conn.execute(
"SELECT * FROM approval_items ORDER BY created_at DESC"
).fetchall()
conn.close()
return [_row_to_item(r) for r in rows]
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> Optional[ApprovalItem]:
conn = _get_conn(db_path)
row = conn.execute(
"SELECT * FROM approval_items WHERE id = ?", (item_id,)
).fetchone()
conn.close()
return _row_to_item(row) if row else None
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> Optional[ApprovalItem]:
"""Mark an approval item as approved."""
conn = _get_conn(db_path)
conn.execute(
"UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,)
)
conn.commit()
conn.close()
return get_item(item_id, db_path)
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> Optional[ApprovalItem]:
"""Mark an approval item as rejected."""
conn = _get_conn(db_path)
conn.execute(
"UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,)
)
conn.commit()
conn.close()
return get_item(item_id, db_path)
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
cutoff = (datetime.now(timezone.utc) - timedelta(days=_EXPIRY_DAYS)).isoformat()
conn = _get_conn(db_path)
cursor = conn.execute(
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
(cutoff,),
)
conn.commit()
count = cursor.rowcount
conn.close()
return count

306
src/timmy/briefing.py Normal file
View File

@@ -0,0 +1,306 @@
"""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 dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
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(timezone.utc) - timedelta(hours=6)
)
period_end: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
)
# ---------------------------------------------------------------------------
# SQLite cache
# ---------------------------------------------------------------------------
def _get_cache_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
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()
return conn
def _save_briefing(briefing: Briefing, db_path: Path = _DEFAULT_DB) -> None:
conn = _get_cache_conn(db_path)
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()
conn.close()
def _load_latest(db_path: Path = _DEFAULT_DB) -> Optional[Briefing]:
"""Load the most-recently cached briefing, or None if there is none."""
conn = _get_cache_conn(db_path)
row = conn.execute(
"SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1"
).fetchone()
conn.close()
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(timezone.utc)
age = now - briefing.generated_at.replace(tzinfo=timezone.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:
conn = sqlite3.connect(str(swarm_db))
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"]
conn.close()
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_chat_summary(since: datetime) -> str:
"""Pull recent chat messages from the in-memory log."""
try:
from dashboard.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) -> Optional[Briefing]:
"""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(timezone.utc)
period_start = now - timedelta(hours=6)
swarm_info = _gather_swarm_summary(period_start)
chat_info = _gather_chat_summary(period_start)
prompt = (
"You are Timmy, 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"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. "
"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)
return run.content if hasattr(run, "content") else str(run)
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()