diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..42effa9
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,13 @@
+# Timmy Time — Mission Control
+# Copy this file to .env and uncomment lines you want to override.
+# .env is gitignored and never committed.
+
+# Ollama host (default: http://localhost:11434)
+# Override if Ollama is running on another machine or port.
+# OLLAMA_URL=http://localhost:11434
+
+# LLM model to use via Ollama (default: llama3.2)
+# OLLAMA_MODEL=llama3.2
+
+# Enable FastAPI interactive docs at /docs and /redoc (default: false)
+# DEBUG=true
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..094447f
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,57 @@
+name: Tests
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+ branches: ["**"]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ # Required for publish-unit-test-result-action to post check runs and PR comments
+ permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: "pip"
+
+ - name: Install dependencies
+ run: pip install -e ".[dev]"
+
+ - name: Run tests
+ run: |
+ pytest \
+ --tb=short \
+ --cov=src \
+ --cov-report=term-missing \
+ --cov-report=xml:reports/coverage.xml \
+ --junitxml=reports/junit.xml
+
+ # Posts a check annotation + PR comment showing pass/fail counts.
+ # Visible in the GitHub mobile app under Checks and in PR conversations.
+ - name: Publish test results
+ uses: EnricoMi/publish-unit-test-result-action@v2
+ if: always()
+ with:
+ files: reports/junit.xml
+ check_name: "pytest results"
+ comment_title: "Test Results"
+ report_individual_runs: true
+
+ # Coverage report available as a downloadable artifact in the Actions tab
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: coverage-report
+ path: reports/coverage.xml
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2474c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+.Python
+build/
+dist/
+*.egg-info/
+.eggs/
+
+# Virtual envs
+.venv/
+venv/
+env/
+
+# Secrets / local config — commit only .env.example (the template)
+.env
+.env.*
+!.env.example
+
+# SQLite memory — never commit agent memory
+*.db
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+reports/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ce078a7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,216 @@
+# Timmy Time — Mission Control
+
+[](https://github.com/Alexspayne/Timmy-time-dashboard/actions/workflows/tests.yml)
+
+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.
+
+---
+
+## Prerequisites
+
+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]"
+```
+
+### 3. Pull the model (one-time, ~2 GB download)
+
+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
+```
+
+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:
+
+- **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
+```
+
+Expected output:
+```
+27 passed in 0.67s
+```
+
+---
+
+## Optional: CLI
+
+With your venv active:
+
+```bash
+timmy chat "What is sovereignty?"
+timmy think "Bitcoin and self-custody"
+timmy status
+```
+
+---
+
+## Architecture
+
+```mermaid
+graph TD
+ Phone["📱 Phone / Browser"]
+ Browser["💻 Browser"]
+
+ Phone -->|HTTP + HTMX| FastAPI
+ Browser -->|HTTP + HTMX| FastAPI
+
+ subgraph "Local Machine"
+ FastAPI["FastAPI\n(dashboard.app)"]
+ Jinja["Jinja2 Templates\n+ static CSS"]
+ Timmy["Timmy Agent\n(Agno wrapper)"]
+ Ollama["Ollama\n:11434"]
+ SQLite[("SQLite\ntimmy.db")]
+
+ FastAPI -->|renders| Jinja
+ FastAPI -->|/agents/timmy/chat| Timmy
+ FastAPI -->|/health/status ping| Ollama
+ Timmy -->|LLM call| Ollama
+ Timmy -->|conversation memory| SQLite
+ end
+```
+
+All traffic stays on your local network. No cloud, no telemetry.
+
+## Configuration
+
+Override defaults without touching code — create a `.env` file (see `.env.example`):
+
+```bash
+cp .env.example .env
+# then edit .env
+```
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `OLLAMA_URL` | `http://localhost:11434` | Ollama host (useful if Ollama runs on another machine) |
+| `OLLAMA_MODEL` | `llama3.2` | LLM model served by Ollama |
+| `DEBUG` | `false` | Set `true` to enable `/docs` and `/redoc` |
+
+## Project layout
+
+```
+src/
+ config.py # pydantic-settings (reads .env)
+ timmy/ # Timmy agent — wraps Agno (soul = prompt, body = Agno)
+ dashboard/ # FastAPI app + routes + Jinja2 templates
+static/ # CSS (dark mission-control theme)
+tests/ # pytest suite (27 tests, no Ollama required)
+.env.example # environment variable reference
+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 |
+|---------|------------|--------------------------------------------|
+| 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..f659f1b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,47 @@
+[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",
+ "pydantic-settings>=2.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]
+sources = {"src" = ""}
+include = ["src/timmy", "src/dashboard", "src/config.py"]
+
+[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/config.py b/src/config.py
new file mode 100644
index 0000000..ec387d3
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,21 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ # Ollama host — override with OLLAMA_URL env var or .env file
+ ollama_url: str = "http://localhost:11434"
+
+ # LLM model passed to Agno/Ollama — override with OLLAMA_MODEL
+ ollama_model: str = "llama3.2"
+
+ # Set DEBUG=true to enable /docs and /redoc (disabled by default)
+ debug: bool = False
+
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+
+settings = Settings()
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..91312b5
--- /dev/null
+++ b/src/dashboard/app.py
@@ -0,0 +1,40 @@
+import logging
+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 config import settings
+from dashboard.routes.agents import router as agents_router
+from dashboard.routes.health import router as health_router
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+BASE_DIR = Path(__file__).parent
+PROJECT_ROOT = BASE_DIR.parent.parent
+
+app = FastAPI(
+ title="Timmy Time — Mission Control",
+ version="1.0.0",
+ # Docs disabled unless DEBUG=true in env / .env
+ docs_url="/docs" if settings.debug else None,
+ redoc_url="/redoc" if settings.debug else None,
+)
+
+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..05968e7
--- /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
+
+from config import settings
+
+router = APIRouter(tags=["health"])
+templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
+
+
+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(settings.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..4e84890
--- /dev/null
+++ b/src/dashboard/templates/base.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+ {% block title %}Timmy Time — Mission Control{% endblock %}
+
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html
new file mode 100644
index 0000000..c75680b
--- /dev/null
+++ b/src/dashboard/templates/index.html
@@ -0,0 +1,87 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ 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..cc1aab3
--- /dev/null
+++ b/src/timmy/agent.py
@@ -0,0 +1,19 @@
+from agno.agent import Agent
+from agno.models.ollama import Ollama
+from agno.db.sqlite import SqliteDb
+
+from timmy.prompts import TIMMY_SYSTEM_PROMPT
+from config import settings
+
+
+def create_timmy(db_file: str = "timmy.db") -> Agent:
+ """Instantiate Timmy with Agno + Ollama + SQLite memory."""
+ return Agent(
+ name="Timmy",
+ model=Ollama(id=settings.ollama_model),
+ 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..e3e9614
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,325 @@
+: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;
+ --header-h: 52px;
+}
+
+* { 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: 100dvh;
+ overflow-x: hidden;
+ /* prevent bounce-scroll from revealing background on iOS */
+ overscroll-behavior: none;
+}
+
+/* ── Header ─────────────────────────────────────── */
+.mc-header {
+ display: flex;
+ 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;
+ 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 — desktop ────────────────────────────── */
+.mc-main {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ gap: 16px;
+ padding: 16px;
+ height: calc(100dvh - var(--header-h));
+}
+
+/* ── 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 — desktop ───────────────────────────── */
+.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;
+ min-height: 0;
+}
+.chat-log {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px;
+ -webkit-overflow-scrolling: touch;
+}
+.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;
+ /* 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;
+ 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;
+ /* prevent double-tap zoom on iOS */
+ touch-action: manipulation;
+}
+.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); }
+
+
+/* ════════════════════════════════════════════════════
+ 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;
+ }
+}
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