From 5e7d805245f597135bf95b46a07702679ab23855 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 19:05:01 +0000 Subject: [PATCH 1/7] feat: scaffold Timmy Time Mission Control (v1.0.0 Genesis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/timmy/ — Agno agent wrapper (llama3.2 via Ollama, SQLite memory, TIMMY_SYSTEM_PROMPT) - src/dashboard/ — FastAPI + HTMX + Jinja2 Mission Control UI - /health + /health/status (Ollama ping, HTMX 30s poll) - /agents list + /agents/timmy/chat (HTMX form submission) - static/style.css — dark terminal mission-control aesthetic - tests/ — 27 pytest tests (prompts, agent config, dashboard routes); no Ollama required - pyproject.toml — hatchling build, pytest configured with pythonpath=src https://claude.ai/code/session_01M4L3R98N5fgXFZRvV8X9b6 --- .gitignore | 33 +++ README.md | 56 +++++ STATUS.md | 47 ++++ pyproject.toml | 45 ++++ src/dashboard/__init__.py | 0 src/dashboard/app.py | 25 ++ src/dashboard/routes/__init__.py | 0 src/dashboard/routes/agents.py | 52 ++++ src/dashboard/routes/health.py | 42 ++++ src/dashboard/templates/base.html | 38 +++ src/dashboard/templates/index.html | 81 ++++++ .../templates/partials/chat_message.html | 15 ++ .../templates/partials/health_status.html | 19 ++ src/timmy/__init__.py | 0 src/timmy/agent.py | 18 ++ src/timmy/cli.py | 30 +++ src/timmy/prompts.py | 7 + static/style.css | 238 ++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 25 ++ tests/test_agent.py | 79 ++++++ tests/test_dashboard.py | 110 ++++++++ tests/test_prompts.py | 33 +++ 23 files changed, 993 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 STATUS.md create mode 100644 pyproject.toml create mode 100644 src/dashboard/__init__.py create mode 100644 src/dashboard/app.py create mode 100644 src/dashboard/routes/__init__.py create mode 100644 src/dashboard/routes/agents.py create mode 100644 src/dashboard/routes/health.py create mode 100644 src/dashboard/templates/base.html create mode 100644 src/dashboard/templates/index.html create mode 100644 src/dashboard/templates/partials/chat_message.html create mode 100644 src/dashboard/templates/partials/health_status.html create mode 100644 src/timmy/__init__.py create mode 100644 src/timmy/agent.py create mode 100644 src/timmy/cli.py create mode 100644 src/timmy/prompts.py create mode 100644 static/style.css create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_dashboard.py create mode 100644 tests/test_prompts.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54d3807 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.Python +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual envs +.venv/ +venv/ +env/ + +# Secrets / local config +.env +.env.* + +# SQLite memory — never commit agent memory +*.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..04c0ecd --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Timmy Time — Mission Control + +Sovereign AI agent dashboard. Monitor and interact with local and cloud AI agents. + +## Stack + +| Layer | Tech | +|-----------|------------------------------| +| Agent | Agno + Ollama (llama3.2) | +| Memory | SQLite via Agno SqliteDb | +| Backend | FastAPI | +| Frontend | HTMX + Jinja2 | +| Tests | Pytest | + +## Quickstart + +```bash +pip install -e ".[dev]" + +# Ollama (separate terminal) +ollama serve && ollama pull llama3.2 + +# Dashboard +uvicorn dashboard.app:app --reload + +# Tests (no Ollama needed) +pytest +``` + +## CLI + +```bash +timmy chat "What is sovereignty?" +timmy think "Bitcoin and self-custody" +timmy status +``` + +## Project Structure + +``` +src/ + timmy/ # Agent identity — soul (prompt) + body (Agno) + dashboard/ # Mission Control UI + routes/ # FastAPI route handlers + templates/ # Jinja2 HTML (HTMX-powered) +static/ # CSS +tests/ # Pytest suite +``` + +## Roadmap + +| Version | Name | Milestone | +|---------|------------|--------------------------------------------| +| 1.0.0 | Genesis | Agno + Ollama + SQLite + Dashboard | +| 2.0.0 | Exodus | MCP tools + multi-agent | +| 3.0.0 | Revelation | Bitcoin Lightning treasury + single `.app` | diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..2e1ffef --- /dev/null +++ b/STATUS.md @@ -0,0 +1,47 @@ +# Timmy Time — Status + +## Current Version: 1.0.0 (Genesis) + +### What's Built +- `src/timmy/` — Agno-powered Timmy agent (llama3.2 via Ollama, SQLite memory) +- `src/dashboard/` — FastAPI Mission Control dashboard (HTMX + Jinja2) +- CLI: `timmy think / chat / status` +- Pytest test suite (prompts, agent config, dashboard routes) + +### System Requirements +- Python 3.11+ +- Ollama running at `http://localhost:11434` +- `llama3.2` model pulled + +### Quickstart +```bash +pip install -e ".[dev]" + +# Start Ollama (separate terminal) +ollama serve +ollama pull llama3.2 + +# Run dashboard +uvicorn dashboard.app:app --reload + +# Run tests (no Ollama required) +pytest +``` + +### Dashboard +`http://localhost:8000` — Mission Control UI with: +- Timmy agent status panel +- Ollama health indicator (auto-refreshes every 30s) +- Live chat interface + +--- + +## Roadmap + +| Tag | Name | Milestone | +|-------|------------|----------------------------------------------| +| 1.0.0 | Genesis | Agno + Ollama + SQLite + Dashboard | +| 2.0.0 | Exodus | MCP tools + multi-agent support | +| 3.0.0 | Revelation | Bitcoin Lightning treasury + single `.app` | + +_Last updated: 2026-02-19_ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b43ef5e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "timmy-time" +version = "1.0.0" +description = "Mission Control for sovereign AI agents" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [ + "agno>=1.4.0", + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "jinja2>=3.1.0", + "httpx>=0.27.0", + "python-multipart>=0.0.12", + "aiofiles>=24.0.0", + "typer>=0.12.0", + "rich>=13.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", +] + +[project.scripts] +timmy = "timmy.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/timmy", "src/dashboard"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +asyncio_mode = "auto" +addopts = "-v --tb=short" + +[tool.coverage.run] +source = ["src"] +omit = ["*/tests/*"] diff --git a/src/dashboard/__init__.py b/src/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dashboard/app.py b/src/dashboard/app.py new file mode 100644 index 0000000..b71a78b --- /dev/null +++ b/src/dashboard/app.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from dashboard.routes.agents import router as agents_router +from dashboard.routes.health import router as health_router + +BASE_DIR = Path(__file__).parent +PROJECT_ROOT = BASE_DIR.parent.parent + +app = FastAPI(title="Timmy Time — Mission Control", version="1.0.0") + +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +app.mount("/static", StaticFiles(directory=str(PROJECT_ROOT / "static")), name="static") + +app.include_router(health_router) +app.include_router(agents_router) + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse(request, "index.html") diff --git a/src/dashboard/routes/__init__.py b/src/dashboard/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py new file mode 100644 index 0000000..a8fd7fb --- /dev/null +++ b/src/dashboard/routes/agents.py @@ -0,0 +1,52 @@ +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from timmy.agent import create_timmy + +router = APIRouter(prefix="/agents", tags=["agents"]) +templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) + +AGENT_REGISTRY = { + "timmy": { + "id": "timmy", + "name": "Timmy", + "type": "sovereign", + "model": "llama3.2", + "backend": "ollama", + "version": "1.0.0", + } +} + + +@router.get("") +async def list_agents(): + return {"agents": list(AGENT_REGISTRY.values())} + + +@router.post("/timmy/chat", response_class=HTMLResponse) +async def chat_timmy(request: Request, message: str = Form(...)): + timestamp = datetime.now().strftime("%H:%M:%S") + response_text = None + error_text = None + + try: + agent = create_timmy() + run = agent.run(message, stream=False) + response_text = run.content if hasattr(run, "content") else str(run) + except Exception as exc: + error_text = f"Timmy is offline: {exc}" + + return templates.TemplateResponse( + request, + "partials/chat_message.html", + { + "user_message": message, + "response": response_text, + "error": error_text, + "timestamp": timestamp, + }, + ) diff --git a/src/dashboard/routes/health.py b/src/dashboard/routes/health.py new file mode 100644 index 0000000..4783a4c --- /dev/null +++ b/src/dashboard/routes/health.py @@ -0,0 +1,42 @@ +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path + +router = APIRouter(tags=["health"]) +templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) + +OLLAMA_URL = "http://localhost:11434" + + +async def check_ollama() -> bool: + """Ping Ollama to verify it's running.""" + try: + async with httpx.AsyncClient(timeout=2.0) as client: + r = await client.get(OLLAMA_URL) + return r.status_code == 200 + except Exception: + return False + + +@router.get("/health") +async def health(): + ollama_ok = await check_ollama() + return { + "status": "ok", + "services": { + "ollama": "up" if ollama_ok else "down", + }, + "agents": ["timmy"], + } + + +@router.get("/health/status", response_class=HTMLResponse) +async def health_status(request: Request): + ollama_ok = await check_ollama() + return templates.TemplateResponse( + request, + "partials/health_status.html", + {"ollama": ollama_ok}, + ) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html new file mode 100644 index 0000000..db0c0d2 --- /dev/null +++ b/src/dashboard/templates/base.html @@ -0,0 +1,38 @@ + + + + + + {% block title %}Timmy Time — Mission Control{% endblock %} + + + + + + + +
+
+ TIMMY TIME + MISSION CONTROL +
+
+ +
+
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html new file mode 100644 index 0000000..f98346a --- /dev/null +++ b/src/dashboard/templates/index.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% block content %} + + + + +
+
// TIMMY INTERFACE
+ +
+
+
TIMMY // SYSTEM
+
Mission Control initialized. Timmy ready — awaiting input.
+
+
+ +
+
+ + +
+
+
+ + + +{% endblock %} diff --git a/src/dashboard/templates/partials/chat_message.html b/src/dashboard/templates/partials/chat_message.html new file mode 100644 index 0000000..9620da2 --- /dev/null +++ b/src/dashboard/templates/partials/chat_message.html @@ -0,0 +1,15 @@ +
+
YOU // {{ timestamp }}
+
{{ user_message }}
+
+{% if response %} +
+
TIMMY // {{ timestamp }}
+
{{ response }}
+
+{% elif error %} +
+
SYSTEM // {{ timestamp }}
+
{{ error }}
+
+{% endif %} diff --git a/src/dashboard/templates/partials/health_status.html b/src/dashboard/templates/partials/health_status.html new file mode 100644 index 0000000..5c50849 --- /dev/null +++ b/src/dashboard/templates/partials/health_status.html @@ -0,0 +1,19 @@ +
// SYSTEM HEALTH
+
+
+ OLLAMA + {% if ollama %} + UP + {% else %} + DOWN + {% endif %} +
+
+ TIMMY + READY +
+
+ MODEL + llama3.2 +
+
diff --git a/src/timmy/__init__.py b/src/timmy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/timmy/agent.py b/src/timmy/agent.py new file mode 100644 index 0000000..af4a11f --- /dev/null +++ b/src/timmy/agent.py @@ -0,0 +1,18 @@ +from agno.agent import Agent +from agno.models.ollama import Ollama +from agno.db.sqlite import SqliteDb + +from timmy.prompts import TIMMY_SYSTEM_PROMPT + + +def create_timmy(db_file: str = "timmy.db") -> Agent: + """Instantiate Timmy with Agno + Ollama + SQLite memory.""" + return Agent( + name="Timmy", + model=Ollama(id="llama3.2"), + db=SqliteDb(db_file=db_file), + description=TIMMY_SYSTEM_PROMPT, + add_history_to_context=True, + num_history_runs=10, + markdown=True, + ) diff --git a/src/timmy/cli.py b/src/timmy/cli.py new file mode 100644 index 0000000..958325f --- /dev/null +++ b/src/timmy/cli.py @@ -0,0 +1,30 @@ +import typer + +from timmy.agent import create_timmy + +app = typer.Typer(help="Timmy — sovereign AI agent") + + +@app.command() +def think(topic: str = typer.Argument(..., help="Topic to reason about")): + """Ask Timmy to think carefully about a topic.""" + timmy = create_timmy() + timmy.print_response(f"Think carefully about: {topic}", stream=True) + + +@app.command() +def chat(message: str = typer.Argument(..., help="Message to send")): + """Send a message to Timmy.""" + timmy = create_timmy() + timmy.print_response(message, stream=True) + + +@app.command() +def status(): + """Print Timmy's operational status.""" + timmy = create_timmy() + timmy.print_response("Brief status report — one sentence.", stream=False) + + +def main(): + app() diff --git a/src/timmy/prompts.py b/src/timmy/prompts.py new file mode 100644 index 0000000..3c25a35 --- /dev/null +++ b/src/timmy/prompts.py @@ -0,0 +1,7 @@ +TIMMY_SYSTEM_PROMPT = """You are Timmy — a sovereign AI agent running locally. +No cloud dependencies. You think clearly, speak plainly, act with intention. +Grounded in Christian faith, powered by Bitcoin economics, committed to the +user's digital sovereignty.""" + +TIMMY_STATUS_PROMPT = """You are Timmy. Give a one-sentence status report confirming +you are operational and running locally.""" diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..67a5dd8 --- /dev/null +++ b/static/style.css @@ -0,0 +1,238 @@ +:root { + --bg-deep: #060d14; + --bg-panel: #0c1824; + --bg-card: #0f2030; + --border: #1a3a55; + --border-glow: #1e4d72; + --text: #b8d0e8; + --text-dim: #4a7a9a; + --text-bright: #ddeeff; + --green: #00e87a; + --green-dim: #00704a; + --amber: #ffb800; + --amber-dim: #7a5800; + --red: #ff4455; + --red-dim: #7a1a22; + --blue: #00aaff; + --font: 'JetBrains Mono', 'Courier New', monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg-deep); + color: var(--text); + font-family: var(--font); + font-size: 13px; + min-height: 100vh; + overflow-x: hidden; +} + +/* ── Header ─────────────────────────────────────── */ +.mc-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} +.mc-title { + font-size: 18px; + font-weight: 700; + color: var(--text-bright); + letter-spacing: 0.15em; +} +.mc-subtitle { + font-size: 11px; + color: var(--text-dim); + letter-spacing: 0.2em; + margin-left: 16px; +} +.mc-time { + font-size: 14px; + color: var(--blue); + letter-spacing: 0.1em; +} + +/* ── Layout ──────────────────────────────────────── */ +.mc-main { + display: grid; + grid-template-columns: 260px 1fr; + gap: 16px; + padding: 16px; + height: calc(100vh - 52px); +} + +/* ── Panels ──────────────────────────────────────── */ +.panel { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} +.panel-header { + padding: 8px 14px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + font-size: 10px; + font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.2em; + text-transform: uppercase; +} +.panel-body { padding: 14px; } + +/* ── Sidebar ─────────────────────────────────────── */ +.sidebar { + grid-column: 1; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; +} + +/* ── Agent Card ──────────────────────────────────── */ +.agent-card { + border: 1px solid var(--border); + border-radius: 3px; + padding: 12px; + background: var(--bg-card); +} +.agent-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); } +.status-dot.amber { background: var(--amber); box-shadow: 0 0 6px var(--amber); } +.status-dot.red { background: var(--red); box-shadow: 0 0 6px var(--red); } + +.agent-name { + font-size: 14px; + font-weight: 700; + color: var(--text-bright); + letter-spacing: 0.1em; +} +.agent-meta { font-size: 11px; line-height: 2; } +.meta-key { color: var(--text-dim); display: inline-block; width: 60px; } +.meta-val { color: var(--text); } + +/* ── Health ──────────────────────────────────────── */ +.health-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 7px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.health-row:last-child { border-bottom: none; } +.health-label { color: var(--text-dim); letter-spacing: 0.08em; } + +.badge { + padding: 2px 8px; + border-radius: 2px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; +} +.badge.up { background: var(--green-dim); color: var(--green); } +.badge.down { background: var(--red-dim); color: var(--red); } +.badge.ready { background: var(--amber-dim); color: var(--amber); } + +/* ── Chat Panel ──────────────────────────────────── */ +.chat-panel { + display: flex; + flex-direction: column; + grid-column: 2; +} +.chat-log { + flex: 1; + overflow-y: auto; + padding: 14px; +} +.chat-message { margin-bottom: 16px; } +.msg-meta { + font-size: 10px; + color: var(--text-dim); + margin-bottom: 4px; + letter-spacing: 0.12em; +} +.chat-message.user .msg-meta { color: var(--blue); } +.chat-message.agent .msg-meta { color: var(--green); } +.chat-message.error-msg .msg-meta { color: var(--red); } + +.msg-body { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 3px; + padding: 10px 12px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} +.chat-message.user .msg-body { border-color: var(--border-glow); } +.chat-message.agent .msg-body { border-left: 3px solid var(--green); } +.chat-message.error-msg .msg-body { border-left: 3px solid var(--red); color: var(--red); } + +/* ── Chat Input ──────────────────────────────────── */ +.chat-input-bar { + padding: 12px 14px; + background: var(--bg-card); + border-top: 1px solid var(--border); + display: flex; + gap: 8px; +} +.chat-input-bar input { + flex: 1; + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-bright); + font-family: var(--font); + font-size: 13px; + padding: 8px 12px; + outline: none; +} +.chat-input-bar input:focus { + border-color: var(--border-glow); + box-shadow: 0 0 0 1px var(--border-glow); +} +.chat-input-bar input::placeholder { color: var(--text-dim); } +.chat-input-bar button { + background: var(--border-glow); + border: none; + border-radius: 3px; + color: var(--text-bright); + font-family: var(--font); + font-size: 12px; + font-weight: 700; + padding: 8px 18px; + cursor: pointer; + letter-spacing: 0.12em; + transition: background 0.15s, color 0.15s; +} +.chat-input-bar button:hover { background: var(--blue); color: var(--bg-deep); } + +/* ── HTMX Loading ────────────────────────────────── */ +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator, +.htmx-request.htmx-indicator { display: inline-block; color: var(--amber); animation: blink 0.8s infinite; } +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } } + +/* ── Scrollbar ───────────────────────────────────── */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: var(--bg-deep); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +::-webkit-scrollbar-thumb:hover { background: var(--border-glow); } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d60d2b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +# ── Mock agno so tests run without it installed ─────────────────────────────── +# Uses setdefault: real module is used if installed, mock otherwise. +for _mod in [ + "agno", + "agno.agent", + "agno.models", + "agno.models.ollama", + "agno.db", + "agno.db.sqlite", +]: + sys.modules.setdefault(_mod, MagicMock()) + + +@pytest.fixture +def client(): + from dashboard.app import app + with TestClient(app) as c: + yield c diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..db1c370 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,79 @@ +from unittest.mock import MagicMock, patch + + +def test_create_timmy_returns_agent(): + """create_timmy should delegate to Agno Agent with correct config.""" + with patch("timmy.agent.Agent") as MockAgent, \ + patch("timmy.agent.Ollama"), \ + patch("timmy.agent.SqliteDb"): + + mock_instance = MagicMock() + MockAgent.return_value = mock_instance + + from timmy.agent import create_timmy + result = create_timmy() + + assert result is mock_instance + MockAgent.assert_called_once() + + +def test_create_timmy_agent_name(): + with patch("timmy.agent.Agent") as MockAgent, \ + patch("timmy.agent.Ollama"), \ + patch("timmy.agent.SqliteDb"): + + from timmy.agent import create_timmy + create_timmy() + + kwargs = MockAgent.call_args.kwargs + assert kwargs["name"] == "Timmy" + + +def test_create_timmy_uses_llama32(): + with patch("timmy.agent.Agent"), \ + patch("timmy.agent.Ollama") as MockOllama, \ + patch("timmy.agent.SqliteDb"): + + from timmy.agent import create_timmy + create_timmy() + + MockOllama.assert_called_once_with(id="llama3.2") + + +def test_create_timmy_history_config(): + with patch("timmy.agent.Agent") as MockAgent, \ + patch("timmy.agent.Ollama"), \ + patch("timmy.agent.SqliteDb"): + + from timmy.agent import create_timmy + create_timmy() + + kwargs = MockAgent.call_args.kwargs + assert kwargs["add_history_to_context"] is True + assert kwargs["num_history_runs"] == 10 + assert kwargs["markdown"] is True + + +def test_create_timmy_custom_db_file(): + with patch("timmy.agent.Agent"), \ + patch("timmy.agent.Ollama"), \ + patch("timmy.agent.SqliteDb") as MockDb: + + from timmy.agent import create_timmy + create_timmy(db_file="custom.db") + + MockDb.assert_called_once_with(db_file="custom.db") + + +def test_create_timmy_embeds_system_prompt(): + from timmy.prompts import TIMMY_SYSTEM_PROMPT + + with patch("timmy.agent.Agent") as MockAgent, \ + patch("timmy.agent.Ollama"), \ + patch("timmy.agent.SqliteDb"): + + from timmy.agent import create_timmy + create_timmy() + + kwargs = MockAgent.call_args.kwargs + assert kwargs["description"] == TIMMY_SYSTEM_PROMPT diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..27d17f4 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,110 @@ +from unittest.mock import AsyncMock, MagicMock, patch + + +# ── Index ───────────────────────────────────────────────────────────────────── + +def test_index_returns_200(client): + response = client.get("/") + assert response.status_code == 200 + + +def test_index_contains_title(client): + response = client.get("/") + assert "TIMMY TIME" in response.text + + +def test_index_contains_chat_interface(client): + response = client.get("/") + assert "TIMMY INTERFACE" in response.text + + +# ── Health ──────────────────────────────────────────────────────────────────── + +def test_health_endpoint_ok(client): + with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["services"]["ollama"] == "up" + assert "timmy" in data["agents"] + + +def test_health_endpoint_ollama_down(client): + with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=False): + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["services"]["ollama"] == "down" + + +def test_health_status_panel_ollama_up(client): + with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True): + response = client.get("/health/status") + assert response.status_code == 200 + assert "UP" in response.text + + +def test_health_status_panel_ollama_down(client): + with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=False): + response = client.get("/health/status") + assert response.status_code == 200 + assert "DOWN" in response.text + + +# ── Agents ──────────────────────────────────────────────────────────────────── + +def test_agents_list(client): + response = client.get("/agents") + assert response.status_code == 200 + data = response.json() + assert "agents" in data + ids = [a["id"] for a in data["agents"]] + assert "timmy" in ids + + +def test_agents_list_timmy_metadata(client): + response = client.get("/agents") + timmy = next(a for a in response.json()["agents"] if a["id"] == "timmy") + assert timmy["name"] == "Timmy" + assert timmy["model"] == "llama3.2" + assert timmy["type"] == "sovereign" + + +# ── Chat ────────────────────────────────────────────────────────────────────── + +def test_chat_timmy_success(client): + mock_agent = MagicMock() + mock_run = MagicMock() + mock_run.content = "I am Timmy, operational and sovereign." + mock_agent.run.return_value = mock_run + + with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent): + response = client.post("/agents/timmy/chat", data={"message": "status?"}) + + assert response.status_code == 200 + assert "status?" in response.text + assert "I am Timmy" in response.text + + +def test_chat_timmy_shows_user_message(client): + mock_agent = MagicMock() + mock_agent.run.return_value = MagicMock(content="Acknowledged.") + + with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent): + response = client.post("/agents/timmy/chat", data={"message": "hello there"}) + + assert "hello there" in response.text + + +def test_chat_timmy_ollama_offline(client): + with patch("dashboard.routes.agents.create_timmy", side_effect=Exception("connection refused")): + response = client.post("/agents/timmy/chat", data={"message": "ping"}) + + assert response.status_code == 200 + assert "Timmy is offline" in response.text + assert "ping" in response.text + + +def test_chat_timmy_requires_message(client): + response = client.post("/agents/timmy/chat", data={}) + assert response.status_code == 422 diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..069e472 --- /dev/null +++ b/tests/test_prompts.py @@ -0,0 +1,33 @@ +from timmy.prompts import TIMMY_SYSTEM_PROMPT, TIMMY_STATUS_PROMPT + + +def test_system_prompt_not_empty(): + assert TIMMY_SYSTEM_PROMPT.strip() + + +def test_system_prompt_has_timmy_identity(): + assert "Timmy" in TIMMY_SYSTEM_PROMPT + + +def test_system_prompt_mentions_sovereignty(): + assert "sovereignty" in TIMMY_SYSTEM_PROMPT.lower() + + +def test_system_prompt_references_local(): + assert "local" in TIMMY_SYSTEM_PROMPT.lower() + + +def test_system_prompt_is_multiline(): + assert "\n" in TIMMY_SYSTEM_PROMPT + + +def test_status_prompt_not_empty(): + assert TIMMY_STATUS_PROMPT.strip() + + +def test_status_prompt_has_timmy(): + assert "Timmy" in TIMMY_STATUS_PROMPT + + +def test_prompts_are_distinct(): + assert TIMMY_SYSTEM_PROMPT != TIMMY_STATUS_PROMPT From 03f4027f432b262d371ceea0979ab476b128a091 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 19:11:42 +0000 Subject: [PATCH 2/7] docs: rewrite README as human-friendly Mac quickstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-by-step clone → venv → install → Ollama → run sequence, what to expect in the browser, troubleshooting section for common Mac failure modes. https://claude.ai/code/session_01M4L3R98N5fgXFZRvV8X9b6 --- README.md | 126 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 04c0ecd..4ca5673 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,100 @@ # Timmy Time — Mission Control -Sovereign AI agent dashboard. Monitor and interact with local and cloud AI agents. +A local-first dashboard for your sovereign AI agents. Talk to Timmy, watch his status, verify Ollama is running — all from a browser, no cloud required. -## Stack +--- -| Layer | Tech | -|-----------|------------------------------| -| Agent | Agno + Ollama (llama3.2) | -| Memory | SQLite via Agno SqliteDb | -| Backend | FastAPI | -| Frontend | HTMX + Jinja2 | -| Tests | Pytest | +## Prerequisites -## Quickstart +You need three things on your Mac before anything else: + +**Python 3.11+** +```bash +python3 --version # should be 3.11 or higher +``` +If not: `brew install python@3.11` + +**Ollama** (runs the local LLM) +```bash +brew install ollama +``` +Or download from https://ollama.com + +**Git** — already on every Mac. + +--- + +## Quickstart (copy-paste friendly) + +### 1. Clone the branch ```bash +git clone -b claude/run-tests-IYl0F https://github.com/Alexspayne/Timmy-time-dashboard.git +cd Timmy-time-dashboard +``` + +### 2. Create a virtual environment and install + +```bash +python3 -m venv .venv +source .venv/bin/activate pip install -e ".[dev]" +``` -# Ollama (separate terminal) -ollama serve && ollama pull llama3.2 +### 3. Pull the model (one-time, ~2 GB download) -# Dashboard +Open a **new terminal tab** and run: + +```bash +ollama serve +``` + +Back in your first tab: + +```bash +ollama pull llama3.2 +``` + +### 4. Start the dashboard + +```bash uvicorn dashboard.app:app --reload +``` -# Tests (no Ollama needed) +Open your browser to **http://localhost:8000** + +--- + +## What you'll see + +The dashboard has two panels on the left and a chat window on the right: + +- **AGENTS** — Timmy's metadata (model, type, version) +- **SYSTEM HEALTH** — live Ollama status, auto-refreshes every 30 seconds +- **TIMMY INTERFACE** — type a message, hit SEND, get a response from the local LLM + +If Ollama isn't running when you send a message, the chat will show a "Timmy is offline" error instead of crashing. + +--- + +## Run the tests + +No Ollama needed — all external calls are mocked. + +```bash pytest ``` -## CLI +Expected output: +``` +27 passed in 0.67s +``` + +--- + +## Optional: CLI + +With your venv active: ```bash timmy chat "What is sovereignty?" @@ -35,18 +102,33 @@ timmy think "Bitcoin and self-custody" timmy status ``` -## Project Structure +--- + +## Project layout ``` src/ - timmy/ # Agent identity — soul (prompt) + body (Agno) - dashboard/ # Mission Control UI - routes/ # FastAPI route handlers - templates/ # Jinja2 HTML (HTMX-powered) -static/ # CSS -tests/ # Pytest suite + timmy/ # Timmy agent — wraps Agno (soul = prompt, body = Agno) + dashboard/ # FastAPI app + routes + Jinja2 templates +static/ # CSS (dark mission-control theme) +tests/ # 27 pytest tests +pyproject.toml # dependencies and build config ``` +--- + +## Troubleshooting + +**`ollama: command not found`** — Ollama isn't installed or isn't on your PATH. Install via Homebrew or the .dmg from ollama.com. + +**`connection refused` in the chat** — Ollama isn't running. Open a terminal and run `ollama serve`, then try again. + +**`ModuleNotFoundError: No module named 'dashboard'`** — You're not in the venv or forgot `pip install -e .`. Run `source .venv/bin/activate` then `pip install -e ".[dev]"`. + +**Health panel shows DOWN** — Ollama isn't running. The chat still works for testing but will return the offline error message. + +--- + ## Roadmap | Version | Name | Milestone | From f9ccfa9177f11d8e76e1f3059b5a29eabc619f7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 19:15:43 +0000 Subject: [PATCH 3/7] feat: mobile-optimized layout + phone access instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS: - Responsive grid: 2-col desktop → 1-col mobile (≤768px) - Sidebar becomes horizontal scroll strip on mobile - 100dvh for correct mobile viewport height - env(safe-area-inset-*) for iPhone notch/home bar - 16px input font-size prevents iOS auto-zoom - 44px min touch targets on input + send button - touch-action: manipulation stops double-tap zoom - overscroll-behavior: none prevents iOS bounce bleed - -webkit-overflow-scrolling: touch for smooth scroll HTML (base.html): - viewport-fit=cover for full-bleed on notched iPhones - apple-mobile-web-app-capable + status-bar-style meta - theme-color meta (#060d14) README: - "Access from your phone" section with exact steps: --host 0.0.0.0, ipconfig getifaddr en0, same-WiFi note https://claude.ai/code/session_01M4L3R98N5fgXFZRvV8X9b6 --- README.md | 32 ++++++++++ src/dashboard/templates/base.html | 5 +- static/style.css | 99 +++++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4ca5673..60662ec 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,38 @@ Open your browser to **http://localhost:8000** --- +## Access from your phone + +The dashboard is mobile-optimized. To open it on your phone: + +**Step 1 — bind to your local network** (instead of just localhost): + +```bash +uvicorn dashboard.app:app --host 0.0.0.0 --port 8000 --reload +``` + +**Step 2 — find your Mac's IP address:** + +```bash +ipconfig getifaddr en0 +``` + +This prints something like `192.168.1.42`. If you're on ethernet instead of Wi-Fi, try `en1`. + +**Step 3 — open on your phone:** + +Make sure your phone is on the **same Wi-Fi network** as your Mac, then open: + +``` +http://192.168.1.42:8000 +``` + +(replace with your actual IP) + +On mobile the layout switches to a single column — status panels become a horizontal scroll strip at the top, chat fills the rest of the screen. The input field is sized to prevent iOS from zooming in when you tap it. + +--- + ## What you'll see The dashboard has two panels on the left and a chat window on the right: diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index db0c0d2..4e84890 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -2,7 +2,10 @@ - + + + + {% block title %}Timmy Time — Mission Control{% endblock %} diff --git a/static/style.css b/static/style.css index 67a5dd8..e3e9614 100644 --- a/static/style.css +++ b/static/style.css @@ -15,6 +15,7 @@ --red-dim: #7a1a22; --blue: #00aaff; --font: 'JetBrains Mono', 'Courier New', monospace; + --header-h: 52px; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -24,8 +25,10 @@ body { color: var(--text); font-family: var(--font); font-size: 13px; - min-height: 100vh; + min-height: 100dvh; overflow-x: hidden; + /* prevent bounce-scroll from revealing background on iOS */ + overscroll-behavior: none; } /* ── Header ─────────────────────────────────────── */ @@ -34,12 +37,14 @@ body { justify-content: space-between; align-items: center; padding: 12px 24px; + padding-top: max(12px, env(safe-area-inset-top)); background: var(--bg-panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; } +.mc-header-left { display: flex; align-items: baseline; gap: 0; } .mc-title { font-size: 18px; font-weight: 700; @@ -58,13 +63,13 @@ body { letter-spacing: 0.1em; } -/* ── Layout ──────────────────────────────────────── */ +/* ── Layout — desktop ────────────────────────────── */ .mc-main { display: grid; grid-template-columns: 260px 1fr; gap: 16px; padding: 16px; - height: calc(100vh - 52px); + height: calc(100dvh - var(--header-h)); } /* ── Panels ──────────────────────────────────────── */ @@ -86,7 +91,7 @@ body { } .panel-body { padding: 14px; } -/* ── Sidebar ─────────────────────────────────────── */ +/* ── Sidebar — desktop ───────────────────────────── */ .sidebar { grid-column: 1; display: flex; @@ -156,11 +161,13 @@ body { display: flex; flex-direction: column; grid-column: 2; + min-height: 0; } .chat-log { flex: 1; overflow-y: auto; padding: 14px; + -webkit-overflow-scrolling: touch; } .chat-message { margin-bottom: 16px; } .msg-meta { @@ -169,8 +176,8 @@ body { margin-bottom: 4px; letter-spacing: 0.12em; } -.chat-message.user .msg-meta { color: var(--blue); } -.chat-message.agent .msg-meta { color: var(--green); } +.chat-message.user .msg-meta { color: var(--blue); } +.chat-message.agent .msg-meta { color: var(--green); } .chat-message.error-msg .msg-meta { color: var(--red); } .msg-body { @@ -189,10 +196,13 @@ body { /* ── Chat Input ──────────────────────────────────── */ .chat-input-bar { padding: 12px 14px; + /* safe area for iPhone home bar */ + padding-bottom: max(12px, env(safe-area-inset-bottom)); background: var(--bg-card); border-top: 1px solid var(--border); display: flex; gap: 8px; + flex-shrink: 0; } .chat-input-bar input { flex: 1; @@ -222,6 +232,8 @@ body { cursor: pointer; letter-spacing: 0.12em; transition: background 0.15s, color 0.15s; + /* prevent double-tap zoom on iOS */ + touch-action: manipulation; } .chat-input-bar button:hover { background: var(--blue); color: var(--bg-deep); } @@ -236,3 +248,78 @@ body { ::-webkit-scrollbar-track { background: var(--bg-deep); } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } ::-webkit-scrollbar-thumb:hover { background: var(--border-glow); } + + +/* ════════════════════════════════════════════════════ + MOBILE (≤ 768 px) + ════════════════════════════════════════════════════ */ +@media (max-width: 768px) { + + :root { --header-h: 44px; } + + /* Compact header */ + .mc-header { padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top)); } + .mc-title { font-size: 14px; letter-spacing: 0.1em; } + .mc-subtitle { display: none; } + .mc-time { font-size: 12px; } + + /* Single-column stack; sidebar on top, chat below */ + .mc-main { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + padding: 8px; + gap: 8px; + height: calc(100dvh - var(--header-h)); + } + + /* Sidebar becomes a horizontal scroll strip */ + .sidebar { + grid-column: 1; + grid-row: 1; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + gap: 8px; + flex-shrink: 0; + scrollbar-width: none; /* Firefox */ + -webkit-overflow-scrolling: touch; + } + .sidebar::-webkit-scrollbar { display: none; } + + /* Each panel card has a fixed width so they don't squash */ + .sidebar .panel { + min-width: 200px; + flex-shrink: 0; + } + + /* Chat fills remaining vertical space */ + .chat-panel { + grid-column: 1; + grid-row: 2; + min-height: 0; + } + + /* Tighter message padding */ + .chat-log { padding: 10px; } + .msg-body { padding: 8px 10px; font-size: 13px; } + .chat-message { margin-bottom: 12px; } + + /* Touch-friendly input bar */ + .chat-input-bar { + padding: 8px 10px; + padding-bottom: max(8px, env(safe-area-inset-bottom)); + gap: 6px; + } + .chat-input-bar input { + /* 16px prevents iOS from zooming when the field focuses */ + font-size: 16px; + min-height: 44px; + padding: 0 12px; + } + .chat-input-bar button { + min-height: 44px; + min-width: 64px; + font-size: 12px; + padding: 0 14px; + } +} From 46b848a2d777f01dbfa0e3affedcfa91b942e8fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 19:24:20 +0000 Subject: [PATCH 4/7] fix: chat input correctness and mobile UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - after-request → after-settle: scrollChat() was firing before HTMX swapped the new message into the DOM, so the chat log didn't scroll to the new message. after-settle fires post-swap, post-settle. - hx-sync="this:drop": prevents duplicate submissions if the user taps SEND a second time while a slow Ollama response is in flight. - hx-disabled-elt="find button": disables SEND button visually during a pending request; paired with hx-sync for belt-and-suspenders. - autocorrect="off" autocapitalize="none" spellcheck="false": iOS autocorrect mangles model names (llama3.2 etc.) and autocapitalize uppercases every message's first word. Both are wrong for a terminal- style chat interface. - enterkeyhint="send": tells the iOS/Android soft keyboard to label the Return key "Send" instead of the generic return arrow. https://claude.ai/code/session_01M4L3R98N5fgXFZRvV8X9b6 --- src/dashboard/templates/index.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html index f98346a..c75680b 100644 --- a/src/dashboard/templates/index.html +++ b/src/dashboard/templates/index.html @@ -55,12 +55,18 @@ hx-target="#chat-log" hx-swap="beforeend" hx-indicator="#send-indicator" - hx-on::after-request="this.reset(); scrollChat()" + hx-sync="this:drop" + hx-disabled-elt="find button" + hx-on::after-settle="this.reset(); scrollChat()" style="display:flex; flex:1; gap:8px;">