feat: complete Event Log, Ledger, Memory, Cascade Router, Upgrade Queue, Activity Feed

This commit implements six major features:

1. Event Log System (src/swarm/event_log.py)
   - SQLite-based audit trail for all swarm events
   - Task lifecycle tracking (created, assigned, completed, failed)
   - Agent lifecycle tracking (joined, left, status changes)
   - Integrated with coordinator for automatic logging
   - Dashboard page at /swarm/events

2. Lightning Ledger (src/lightning/ledger.py)
   - Transaction tracking for Lightning Network payments
   - Balance calculations (incoming, outgoing, net, available)
   - Integrated with payment_handler for automatic logging
   - Dashboard page at /lightning/ledger

3. Semantic Memory / Vector Store (src/memory/vector_store.py)
   - Embedding-based similarity search for Echo agent
   - Fallback to keyword matching if sentence-transformers unavailable
   - Personal facts storage and retrieval
   - Dashboard page at /memory

4. Cascade Router Integration (src/timmy/cascade_adapter.py)
   - Automatic LLM failover between providers (Ollama → AirLLM → API)
   - Circuit breaker pattern for failing providers
   - Metrics tracking per provider (latency, error rates)
   - Dashboard status page at /router/status

5. Self-Upgrade Approval Queue (src/upgrades/)
   - State machine for self-modifications: proposed → approved/rejected → applied/failed
   - Human approval required before applying changes
   - Git integration for branch management
   - Dashboard queue at /self-modify/queue

6. Real-Time Activity Feed (src/events/broadcaster.py)
   - WebSocket-based live activity streaming
   - Bridges event_log to dashboard clients
   - Activity panel on /swarm/live

Tests:
- 101 unit tests passing
- 4 new E2E test files for Selenium testing
- Run with: SELENIUM_UI=1 pytest tests/functional/ -v --headed

Documentation:
- 6 ADRs (017-022) documenting architecture decisions
- Implementation summary in docs/IMPLEMENTATION_SUMMARY.md
- Architecture diagram in docs/architecture-v2.md
This commit is contained in:
Alexander Payne
2026-02-26 08:01:01 -05:00
parent 8d85f95ee5
commit d8d976aa60
41 changed files with 6735 additions and 254 deletions

View File

@@ -27,6 +27,11 @@ from dashboard.routes.spark import router as spark_router
from dashboard.routes.creative import router as creative_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.self_modify import router as self_modify_router
from dashboard.routes.events import router as events_router
from dashboard.routes.ledger import router as ledger_router
from dashboard.routes.memory import router as memory_router
from dashboard.routes.router import router as router_status_router
from dashboard.routes.upgrades import router as upgrades_router
from router.api import router as cascade_router
logging.basicConfig(
@@ -166,6 +171,11 @@ app.include_router(spark_router)
app.include_router(creative_router)
app.include_router(discord_router)
app.include_router(self_modify_router)
app.include_router(events_router)
app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
app.include_router(upgrades_router)
app.include_router(cascade_router)

View File

@@ -0,0 +1,91 @@
"""Event Log routes for viewing system events."""
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from swarm.event_log import (
EventType,
list_events,
get_event_summary,
get_recent_events,
)
router = APIRouter(prefix="/swarm", tags=["events"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("/events", response_class=HTMLResponse)
async def events_page(
request: Request,
event_type: Optional[str] = None,
task_id: Optional[str] = None,
agent_id: Optional[str] = None,
):
"""Event log viewer page."""
# Parse event type filter
evt_type = None
if event_type:
try:
evt_type = EventType(event_type)
except ValueError:
pass
# Get events
events = list_events(
event_type=evt_type,
task_id=task_id,
agent_id=agent_id,
limit=100,
)
# Get summary stats
summary = get_event_summary(minutes=60)
return templates.TemplateResponse(
request,
"events.html",
{
"page_title": "Event Log",
"events": events,
"summary": summary,
"filter_type": event_type,
"filter_task": task_id,
"filter_agent": agent_id,
"event_types": [e.value for e in EventType],
},
)
@router.get("/events/partial", response_class=HTMLResponse)
async def events_partial(
request: Request,
event_type: Optional[str] = None,
task_id: Optional[str] = None,
agent_id: Optional[str] = None,
):
"""Event log partial for HTMX updates."""
evt_type = None
if event_type:
try:
evt_type = EventType(event_type)
except ValueError:
pass
events = list_events(
event_type=evt_type,
task_id=task_id,
agent_id=agent_id,
limit=100,
)
return templates.TemplateResponse(
request,
"partials/events_table.html",
{
"events": events,
},
)

View File

@@ -0,0 +1,102 @@
"""Lightning Ledger routes for viewing transactions and balance."""
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from lightning.ledger import (
TransactionType,
TransactionStatus,
list_transactions,
get_balance,
get_transaction_stats,
)
router = APIRouter(prefix="/lightning", tags=["ledger"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("/ledger", response_class=HTMLResponse)
async def ledger_page(
request: Request,
tx_type: Optional[str] = None,
status: Optional[str] = None,
):
"""Lightning ledger page with balance and transactions."""
# Parse filters
filter_type = None
if tx_type:
try:
filter_type = TransactionType(tx_type)
except ValueError:
pass
filter_status = None
if status:
try:
filter_status = TransactionStatus(status)
except ValueError:
pass
# Get data
balance = get_balance()
transactions = list_transactions(
tx_type=filter_type,
status=filter_status,
limit=50,
)
stats = get_transaction_stats(days=7)
return templates.TemplateResponse(
request,
"ledger.html",
{
"page_title": "Lightning Ledger",
"balance": balance,
"transactions": transactions,
"stats": stats,
"filter_type": tx_type,
"filter_status": status,
"tx_types": [t.value for t in TransactionType],
"tx_statuses": [s.value for s in TransactionStatus],
},
)
@router.get("/ledger/partial", response_class=HTMLResponse)
async def ledger_partial(
request: Request,
tx_type: Optional[str] = None,
status: Optional[str] = None,
):
"""Ledger transactions partial for HTMX updates."""
filter_type = None
if tx_type:
try:
filter_type = TransactionType(tx_type)
except ValueError:
pass
filter_status = None
if status:
try:
filter_status = TransactionStatus(status)
except ValueError:
pass
transactions = list_transactions(
tx_type=filter_type,
status=filter_status,
limit=50,
)
return templates.TemplateResponse(
request,
"partials/ledger_table.html",
{
"transactions": transactions,
},
)

View File

@@ -0,0 +1,98 @@
"""Memory (vector store) routes for browsing and searching memories."""
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from memory.vector_store import (
store_memory,
search_memories,
get_memory_stats,
recall_personal_facts,
store_personal_fact,
)
router = APIRouter(prefix="/memory", tags=["memory"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("", response_class=HTMLResponse)
async def memory_page(
request: Request,
query: Optional[str] = None,
context_type: Optional[str] = None,
agent_id: Optional[str] = None,
):
"""Memory browser and search page."""
results = []
if query:
results = search_memories(
query=query,
context_type=context_type,
agent_id=agent_id,
limit=20,
)
stats = get_memory_stats()
facts = recall_personal_facts(limit=10)
return templates.TemplateResponse(
request,
"memory.html",
{
"page_title": "Memory Browser",
"query": query,
"results": results,
"stats": stats,
"facts": facts,
"filter_type": context_type,
"filter_agent": agent_id,
},
)
@router.post("/search", response_class=HTMLResponse)
async def memory_search(
request: Request,
query: str = Form(...),
context_type: Optional[str] = Form(None),
):
"""Search memories (form submission)."""
results = search_memories(
query=query,
context_type=context_type,
limit=20,
)
# Return partial for HTMX
return templates.TemplateResponse(
request,
"partials/memory_results.html",
{
"query": query,
"results": results,
},
)
@router.post("/fact", response_class=HTMLResponse)
async def add_fact(
request: Request,
fact: str = Form(...),
agent_id: Optional[str] = Form(None),
):
"""Add a personal fact to memory."""
store_personal_fact(fact, agent_id=agent_id)
# Return updated facts list
facts = recall_personal_facts(limit=10)
return templates.TemplateResponse(
request,
"partials/memory_facts.html",
{
"facts": facts,
},
)

View File

@@ -0,0 +1,54 @@
"""Cascade Router status routes."""
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from timmy.cascade_adapter import get_cascade_adapter
router = APIRouter(prefix="/router", tags=["router"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("/status", response_class=HTMLResponse)
async def router_status_page(request: Request):
"""Cascade Router status dashboard."""
adapter = get_cascade_adapter()
providers = adapter.get_provider_status()
preferred = adapter.get_preferred_provider()
# Calculate overall stats
total_requests = sum(p["metrics"]["total"] for p in providers)
total_success = sum(p["metrics"]["success"] for p in providers)
total_failed = sum(p["metrics"]["failed"] for p in providers)
avg_latency = 0.0
if providers:
avg_latency = sum(p["metrics"]["avg_latency_ms"] for p in providers) / len(providers)
return templates.TemplateResponse(
request,
"router_status.html",
{
"page_title": "Router Status",
"providers": providers,
"preferred_provider": preferred,
"total_requests": total_requests,
"total_success": total_success,
"total_failed": total_failed,
"avg_latency_ms": round(avg_latency, 1),
},
)
@router.get("/api/providers")
async def get_providers():
"""API endpoint for provider status (JSON)."""
adapter = get_cascade_adapter()
return {
"providers": adapter.get_provider_status(),
"preferred": adapter.get_preferred_provider(),
}

View File

@@ -0,0 +1,99 @@
"""Self-Upgrade Queue dashboard routes."""
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from upgrades.models import list_upgrades, get_upgrade, UpgradeStatus, get_pending_count
from upgrades.queue import UpgradeQueue
router = APIRouter(prefix="/self-modify", tags=["upgrades"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("/queue", response_class=HTMLResponse)
async def upgrade_queue_page(request: Request):
"""Upgrade queue dashboard."""
pending = list_upgrades(status=UpgradeStatus.PROPOSED)
approved = list_upgrades(status=UpgradeStatus.APPROVED)
history = list_upgrades(status=None)[:20] # All recent
# Separate history by status
applied = [u for u in history if u.status == UpgradeStatus.APPLIED][:10]
rejected = [u for u in history if u.status == UpgradeStatus.REJECTED][:5]
failed = [u for u in history if u.status == UpgradeStatus.FAILED][:5]
return templates.TemplateResponse(
request,
"upgrade_queue.html",
{
"page_title": "Upgrade Queue",
"pending": pending,
"approved": approved,
"applied": applied,
"rejected": rejected,
"failed": failed,
"pending_count": len(pending),
},
)
@router.post("/queue/{upgrade_id}/approve", response_class=JSONResponse)
async def approve_upgrade_endpoint(upgrade_id: str):
"""Approve an upgrade proposal."""
upgrade = UpgradeQueue.approve(upgrade_id)
if not upgrade:
raise HTTPException(404, "Upgrade not found or not in proposed state")
return {"success": True, "upgrade_id": upgrade_id, "status": upgrade.status.value}
@router.post("/queue/{upgrade_id}/reject", response_class=JSONResponse)
async def reject_upgrade_endpoint(upgrade_id: str):
"""Reject an upgrade proposal."""
upgrade = UpgradeQueue.reject(upgrade_id)
if not upgrade:
raise HTTPException(404, "Upgrade not found or not in proposed state")
return {"success": True, "upgrade_id": upgrade_id, "status": upgrade.status.value}
@router.post("/queue/{upgrade_id}/apply", response_class=JSONResponse)
async def apply_upgrade_endpoint(upgrade_id: str):
"""Apply an approved upgrade."""
success, message = UpgradeQueue.apply(upgrade_id)
if not success:
raise HTTPException(400, message)
return {"success": True, "message": message}
@router.get("/queue/{upgrade_id}/diff", response_class=HTMLResponse)
async def view_diff(request: Request, upgrade_id: str):
"""View full diff for an upgrade."""
upgrade = get_upgrade(upgrade_id)
if not upgrade:
raise HTTPException(404, "Upgrade not found")
diff = UpgradeQueue.get_full_diff(upgrade_id)
return templates.TemplateResponse(
request,
"upgrade_diff.html",
{
"upgrade": upgrade,
"diff": diff,
},
)
@router.get("/api/pending-count", response_class=JSONResponse)
async def get_pending_upgrade_count():
"""Get count of pending upgrades (for nav badge)."""
return {"count": get_pending_count()}

View File

@@ -30,6 +30,11 @@
<a href="/spark/ui" class="mc-test-link">SPARK</a>
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
<a href="/tools" class="mc-test-link">TOOLS</a>
<a href="/swarm/events" class="mc-test-link">EVENTS</a>
<a href="/lightning/ledger" class="mc-test-link">LEDGER</a>
<a href="/memory" class="mc-test-link">MEMORY</a>
<a href="/router/status" class="mc-test-link">ROUTER</a>
<a href="/self-modify/queue" class="mc-test-link">UPGRADES</a>
<a href="/creative/ui" class="mc-test-link">CREATIVE</a>
<a href="/mobile" class="mc-test-link" title="Mobile-optimized view">MOBILE</a>
<button id="enable-notifications" class="mc-test-link" style="background:none;cursor:pointer;" title="Enable notifications">&#x1F514;</button>
@@ -55,6 +60,9 @@
<a href="/spark/ui" class="mc-mobile-link">SPARK</a>
<a href="/marketplace/ui" class="mc-mobile-link">MARKET</a>
<a href="/tools" class="mc-mobile-link">TOOLS</a>
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
<a href="/memory" class="mc-mobile-link">MEMORY</a>
<a href="/creative/ui" class="mc-mobile-link">CREATIVE</a>
<a href="/voice/button" class="mc-mobile-link">VOICE</a>
<a href="/mobile" class="mc-mobile-link">MOBILE</a>

View File

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Event Log - Timmy Time{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header">
<h1 class="page-title">Event Log</h1>
<p class="mc-text-secondary">System audit trail and activity history</p>
</div>
<!-- Summary Stats -->
<div class="mc-stats-row">
{% for event_type, count in summary.items() %}
<div class="mc-stat-card">
<div class="mc-stat-value">{{ count }}</div>
<div class="mc-stat-label">{{ event_type }}</div>
</div>
{% endfor %}
{% if not summary %}
<div class="mc-stat-card">
<div class="mc-stat-value">-</div>
<div class="mc-stat-label">No events (last hour)</div>
</div>
{% endif %}
</div>
<!-- Filters -->
<div class="mc-filters">
<form method="get" action="/swarm/events" class="mc-filter-form">
<select name="event_type" class="mc-select" onchange="this.form.submit()">
<option value="">All Event Types</option>
{% for et in event_types %}
<option value="{{ et }}" {% if filter_type == et %}selected{% endif %}>{{ et }}</option>
{% endfor %}
</select>
{% if filter_task %}
<input type="hidden" name="task_id" value="{{ filter_task }}">
<span class="mc-filter-tag">Task: {{ filter_task[:8] }}... <a href="/swarm/events"></a></span>
{% endif %}
{% if filter_agent %}
<input type="hidden" name="agent_id" value="{{ filter_agent }}">
<span class="mc-filter-tag">Agent: {{ filter_agent[:8] }}... <a href="/swarm/events"></a></span>
{% endif %}
</form>
</div>
<!-- Events Table -->
<div class="mc-table-container">
{% if events %}
<table class="mc-table events-table">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Source</th>
<th>Task</th>
<th>Agent</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr class="event-row" data-type="{{ event.event_type.value }}">
<td class="event-time">{{ event.timestamp[11:19] }}</td>
<td>
<span class="mc-badge mc-badge-{{ event.event_type.value.split('.')[0] }}">
{{ event.event_type.value }}
</span>
</td>
<td>{{ event.source }}</td>
<td>
{% if event.task_id %}
<a href="/swarm/events?task_id={{ event.task_id }}">{{ event.task_id[:8] }}...</a>
{% endif %}
</td>
<td>
{% if event.agent_id %}
<a href="/swarm/events?agent_id={{ event.agent_id }}">{{ event.agent_id[:8] }}...</a>
{% endif %}
</td>
<td class="event-data">
{% if event.data %}
<code>{{ event.data[:60] }}{% if event.data|length > 60 %}...{% endif %}</code>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mc-empty-state">
<p>No events found.</p>
{% if filter_type or filter_task or filter_agent %}
<p><a href="/swarm/events" class="mc-link">Clear filters</a></p>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Lightning Ledger - Timmy Time{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header">
<h1 class="page-title">Lightning Ledger</h1>
<p class="mc-text-secondary">Bitcoin Lightning Network transaction history</p>
</div>
<!-- Balance Cards -->
<div class="mc-stats-row balance-row">
<div class="mc-stat-card sats-balance">
<div class="mc-stat-label">Available Balance</div>
<div class="mc-stat-value">{{ balance.available_sats }} <small>sats</small></div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-label">Total Received</div>
<div class="mc-stat-value">{{ balance.incoming_total_sats }} <small>sats</small></div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-label">Total Sent</div>
<div class="mc-stat-value">{{ balance.outgoing_total_sats }} <small>sats</small></div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-label">Fees Paid</div>
<div class="mc-stat-value">{{ balance.fees_paid_sats }} <small>sats</small></div>
</div>
<div class="mc-stat-card net-balance">
<div class="mc-stat-label">Net</div>
<div class="mc-stat-value {% if balance.net_sats >= 0 %}positive{% else %}negative{% endif %}">
{{ balance.net_sats }} <small>sats</small>
</div>
</div>
</div>
<!-- Pending Summary -->
{% if balance.pending_incoming_sats > 0 or balance.pending_outgoing_sats > 0 %}
<div class="mc-pending-row">
{% if balance.pending_incoming_sats > 0 %}
<span class="mc-pending-badge incoming">
Pending incoming: {{ balance.pending_incoming_sats }} sats
</span>
{% endif %}
{% if balance.pending_outgoing_sats > 0 %}
<span class="mc-pending-badge outgoing">
Pending outgoing: {{ balance.pending_outgoing_sats }} sats
</span>
{% endif %}
</div>
{% endif %}
<!-- Filters -->
<div class="mc-filters">
<form method="get" action="/lightning/ledger" class="mc-filter-form">
<select name="tx_type" class="mc-select" onchange="this.form.submit()">
<option value="">All Types</option>
{% for t in tx_types %}
<option value="{{ t }}" {% if filter_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
<select name="status" class="mc-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
{% for s in tx_statuses %}
<option value="{{ s }}" {% if filter_status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div>
<!-- Transactions Table -->
<div class="mc-table-container">
{% if transactions %}
<table class="mc-table transactions-table">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Status</th>
<th>Amount</th>
<th>Hash</th>
<th>Memo</th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr class="transaction-row" data-type="{{ tx.tx_type.value }}" data-status="{{ tx.status.value }}">
<td>{{ tx.created_at[11:19] }}</td>
<td>
<span class="mc-badge mc-badge-{{ tx.tx_type.value }}">
{{ tx.tx_type.value }}
</span>
</td>
<td>
<span class="mc-status mc-status-{{ tx.status.value }}">
{{ tx.status.value }}
</span>
</td>
<td class="amount {% if tx.tx_type.value == 'incoming' %}positive{% else %}negative{% endif %}">
{% if tx.tx_type.value == 'incoming' %}+{% endif %}{{ tx.amount_sats }} sats
</td>
<td class="mono">{{ tx.payment_hash[:16] }}...</td>
<td>{{ tx.memo }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mc-empty-state">
<p>No transactions yet.</p>
<p class="mc-text-secondary">Invoices and payments will appear here.</p>
</div>
{% endif %}
</div>
<!-- Weekly Stats -->
{% if stats %}
<div class="mc-stats-section">
<h3>Activity (Last 7 Days)</h3>
<div class="mc-mini-chart">
{% for date, day_stats in stats.items() %}
<div class="mc-chart-bar" title="{{ date }}">
<div class="bar incoming" style="height: {{ day_stats.incoming.count * 10 }}px"></div>
<div class="bar outgoing" style="height: {{ day_stats.outgoing.count * 10 }}px"></div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}Memory Browser - Timmy Time{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header">
<h1 class="page-title">Memory Browser</h1>
<p class="mc-text-secondary">Semantic search through conversation history and facts</p>
</div>
<!-- Stats -->
<div class="mc-stats-row">
<div class="mc-stat-card">
<div class="mc-stat-value">{{ stats.total_entries }}</div>
<div class="mc-stat-label">Total Memories</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-value">{{ stats.with_embeddings }}</div>
<div class="mc-stat-label">With Embeddings</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-value">{% if stats.has_embedding_model %}✓{% else %}○{% endif %}</div>
<div class="mc-stat-label">AI Search</div>
</div>
{% for type, count in stats.by_type.items() %}
<div class="mc-stat-card">
<div class="mc-stat-value">{{ count }}</div>
<div class="mc-stat-label">{{ type }}</div>
</div>
{% endfor %}
</div>
<!-- Search -->
<div class="mc-search-section">
<form method="get" action="/memory" class="mc-search-form">
<input
type="search"
name="query"
class="mc-search-input"
placeholder="Search memories..."
value="{{ query or '' }}"
autofocus
>
<button type="submit" class="mc-btn mc-btn-primary">Search</button>
</form>
{% if query %}
<p class="mc-search-info">Searching for: "{{ query }}"</p>
{% endif %}
</div>
<!-- Search Results -->
{% if query %}
<div class="mc-results-section">
<h3>Search Results</h3>
{% if results %}
<div class="memory-results">
{% for mem in results %}
<div class="memory-entry" data-relevance="{{ mem.relevance_score }}">
<div class="memory-header">
<span class="memory-source">{{ mem.source }}</span>
<span class="memory-type mc-badge">{{ mem.context_type }}</span>
{% if mem.relevance_score %}
<span class="memory-score">{{ "%.2f"|format(mem.relevance_score) }}</span>
{% endif %}
</div>
<div class="memory-content">{{ mem.content }}</div>
<div class="memory-meta">
<span class="memory-time">{{ mem.timestamp[11:16] }}</span>
{% if mem.agent_id %}
<span class="memory-agent">Agent: {{ mem.agent_id[:8] }}...</span>
{% endif %}
{% if mem.task_id %}
<span class="memory-task">Task: {{ mem.task_id[:8] }}...</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="mc-empty-state">
<p>No results found for "{{ query }}"</p>
<p class="mc-text-secondary">Try different keywords or check spelling.</p>
</div>
{% endif %}
</div>
{% endif %}
<!-- Personal Facts -->
<div class="mc-facts-section">
<div class="mc-section-header">
<h3>Personal Facts</h3>
<button class="mc-btn mc-btn-small" onclick="document.getElementById('add-fact-form').style.display='block'">
+ Add Fact
</button>
</div>
<form id="add-fact-form" class="mc-inline-form" method="post" action="/memory/fact" style="display:none;" hx-post="/memory/fact" hx-target=".mc-facts-list">
<input type="text" name="fact" class="mc-input" placeholder="Enter a fact..." required>
<button type="submit" class="mc-btn mc-btn-primary">Save</button>
<button type="button" class="mc-btn" onclick="document.getElementById('add-fact-form').style.display='none'">Cancel</button>
</form>
<div class="mc-facts-list">
{% if facts %}
<ul class="mc-fact-list">
{% for fact in facts %}
<li class="memory-fact">{{ fact }}</li>
{% endfor %}
</ul>
{% else %}
<p class="mc-text-secondary">No personal facts stored yet.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% block title %}Router Status - Timmy Time{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header">
<h1 class="page-title">Router Status</h1>
<p class="mc-text-secondary">LLM provider health and metrics</p>
</div>
<!-- Overall Stats -->
<div class="mc-stats-row">
<div class="mc-stat-card">
<div class="mc-stat-value">{{ providers|length }}</div>
<div class="mc-stat-label">Providers</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-value">{{ total_requests }}</div>
<div class="mc-stat-label">Total Requests</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-value">{{ total_success }}</div>
<div class="mc-stat-label">Successful</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-value">{{ total_failed }}</div>
<div class="mc-stat-label">Failed</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-value">{{ avg_latency_ms }}<small>ms</small></div>
<div class="mc-stat-label">Avg Latency</div>
</div>
</div>
<!-- Preferred Provider -->
{% if preferred_provider %}
<div class="mc-alert mc-alert-success">
<strong>Preferred Provider:</strong> {{ preferred_provider }}
<span class="mc-badge mc-badge-success">ACTIVE</span>
</div>
{% else %}
<div class="mc-alert mc-alert-warning">
<strong>Warning:</strong> No healthy providers available
</div>
{% endif %}
<!-- Provider Cards -->
<div class="mc-providers-grid">
{% for provider in providers %}
<div class="mc-provider-card provider-{{ provider.status }}">
<div class="provider-header">
<h3>{{ provider.name }}</h3>
<span class="mc-badge mc-badge-{{ provider.status }}">
{{ provider.status }}
</span>
</div>
<div class="provider-meta">
<span class="provider-type">{{ provider.type }}</span>
<span class="provider-priority">Priority: {{ provider.priority }}</span>
{% if not provider.enabled %}
<span class="mc-badge mc-badge-disabled">DISABLED</span>
{% endif %}
</div>
<div class="provider-circuit">
Circuit: <span class="circuit-{{ provider.circuit_state }}">{{ provider.circuit_state }}</span>
</div>
<div class="provider-metrics">
<div class="metric">
<span class="metric-value">{{ provider.metrics.total }}</span>
<span class="metric-label">Requests</span>
</div>
<div class="metric">
<span class="metric-value">{{ provider.metrics.success }}</span>
<span class="metric-label">Success</span>
</div>
<div class="metric">
<span class="metric-value">{{ provider.metrics.failed }}</span>
<span class="metric-label">Failed</span>
</div>
<div class="metric">
<span class="metric-value">{{ provider.metrics.avg_latency_ms }}ms</span>
<span class="metric-label">Latency</span>
</div>
<div class="metric">
<span class="metric-value">{{ "%.1f"|format(provider.metrics.error_rate * 100) }}%</span>
<span class="metric-label">Error Rate</span>
</div>
</div>
{% if provider.metrics.error_rate > 0.1 %}
<div class="mc-alert mc-alert-warning mc-alert-small">
High error rate detected
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if not providers %}
<div class="mc-empty-state">
<p>No providers configured.</p>
<p class="mc-text-secondary">Check config/providers.yaml</p>
</div>
{% endif %}
</div>
<style>
.mc-providers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.mc-provider-card {
background: rgba(10, 15, 30, 0.6);
border: 1px solid var(--mc-border);
border-radius: 0.5rem;
padding: 1rem;
}
.mc-provider-card.provider-healthy {
border-left: 4px solid #28a745;
}
.mc-provider-card.provider-degraded {
border-left: 4px solid #ffc107;
}
.mc-provider-card.provider-unhealthy {
border-left: 4px solid #dc3545;
}
.provider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.provider-header h3 {
margin: 0;
font-size: 1.1rem;
}
.provider-meta {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: var(--mc-text-secondary);
}
.provider-circuit {
font-size: 0.85rem;
margin-bottom: 0.75rem;
padding: 0.25rem 0.5rem;
background: rgba(0,0,0,0.3);
border-radius: 0.25rem;
}
.circuit-closed { color: #28a745; }
.circuit-open { color: #dc3545; }
.circuit-half_open { color: #ffc107; }
.provider-metrics {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
text-align: center;
}
.metric {
padding: 0.5rem;
background: rgba(0,0,0,0.2);
border-radius: 0.25rem;
}
.metric-value {
display: block;
font-size: 1.1rem;
font-weight: 600;
color: var(--mc-gold);
}
.metric-label {
display: block;
font-size: 0.75rem;
color: var(--mc-text-secondary);
}
.mc-alert-small {
margin-top: 0.75rem;
padding: 0.5rem;
font-size: 0.85rem;
}
</style>
{% endblock %}

View File

@@ -35,6 +35,89 @@
.swarm-title { font-size: 1rem; }
.swarm-log-box { height: 160px; font-size: 11px; }
}
/* Activity Feed Styles */
.activity-feed-panel {
margin-bottom: 16px;
}
.activity-feed {
max-height: 300px;
overflow-y: auto;
background: rgba(24, 10, 45, 0.6);
padding: 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
animation: fadeIn 0.3s ease;
}
.activity-item:last-child {
border-bottom: none;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.activity-icon {
font-size: 16px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.activity-content {
flex: 1;
min-width: 0;
}
.activity-label {
font-weight: 600;
color: var(--text-bright);
font-size: 12px;
}
.activity-desc {
color: var(--text-dim);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.activity-meta {
display: flex;
gap: 8px;
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
}
.activity-time {
font-family: var(--font);
color: var(--amber);
}
.activity-source {
opacity: 0.7;
}
.activity-empty {
color: var(--text-dim);
font-size: 12px;
text-align: center;
padding: 20px;
}
.activity-badge {
display: inline-block;
width: 8px;
height: 8px;
background: #28a745;
border-radius: 50%;
margin-left: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
{% endblock %}
@@ -76,6 +159,19 @@
</div>
</div>
<!-- Activity Feed Panel -->
<div class="card mc-panel activity-feed-panel">
<div class="card-header mc-panel-header">
// LIVE ACTIVITY FEED
<span class="activity-badge" id="activity-badge"></span>
</div>
<div class="card-body p-0">
<div class="activity-feed" id="activity-feed">
<div class="activity-empty">Waiting for events...</div>
</div>
</div>
</div>
<div class="card mc-panel">
<div class="card-header mc-panel-header">// SWARM LOG</div>
<div class="card-body p-0">
@@ -125,6 +221,16 @@ function connect() {
}
function handleMessage(message) {
// Handle activity feed events (from event_log broadcaster)
if (message.type === 'event' && message.payload) {
addActivityEvent(message.payload);
// Also add to log
var evt = message.payload;
var logMsg = evt.event_type + ': ' + (evt.source || '');
addLog(logMsg, 'info');
return;
}
if (message.type === 'initial_state' || message.type === 'state_update') {
var data = message.data;
document.getElementById('stat-agents').textContent = data.agents.total;
@@ -158,6 +264,87 @@ function handleMessage(message) {
}
}
// Activity Feed Functions
const EVENT_ICONS = {
'task.created': '📝',
'task.bidding': '⏳',
'task.assigned': '👤',
'task.started': '▶️',
'task.completed': '✅',
'task.failed': '❌',
'agent.joined': '🟢',
'agent.left': '🔴',
'bid.submitted': '💰',
'auction.closed': '🏁',
'tool.called': '🔧',
'system.error': '⚠️',
};
const EVENT_LABELS = {
'task.created': 'New task',
'task.assigned': 'Task assigned',
'task.completed': 'Task completed',
'task.failed': 'Task failed',
'agent.joined': 'Agent joined',
'agent.left': 'Agent left',
'bid.submitted': 'Bid submitted',
};
function addActivityEvent(evt) {
var container = document.getElementById('activity-feed');
// Remove empty message if present
var empty = container.querySelector('.activity-empty');
if (empty) empty.remove();
// Create activity item
var item = document.createElement('div');
item.className = 'activity-item';
var icon = EVENT_ICONS[evt.event_type] || '•';
var label = EVENT_LABELS[evt.event_type] || evt.event_type;
var time = evt.timestamp ? evt.timestamp.split('T')[1].slice(0, 8) : '--:--:--';
// Build description from data
var desc = '';
if (evt.data) {
try {
var data = typeof evt.data === 'string' ? JSON.parse(evt.data) : evt.data;
if (data.description) desc = data.description.slice(0, 50);
else if (data.reason) desc = data.reason.slice(0, 50);
} catch(e) {}
}
item.innerHTML = `
<div class="activity-icon">${icon}</div>
<div class="activity-content">
<div class="activity-label">${label}</div>
${desc ? `<div class="activity-desc">${desc}</div>` : ''}
<div class="activity-meta">
<span class="activity-time">${time}</span>
<span class="activity-source">${evt.source || 'system'}</span>
</div>
</div>
`;
// Add to top
container.insertBefore(item, container.firstChild);
// Keep only last 50 items
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
// Update badge
var badge = document.getElementById('activity-badge');
if (badge) {
badge.style.background = '#28a745';
setTimeout(() => {
badge.style.background = '';
}, 500);
}
}
function refreshStats() {
fetch('/swarm').then(function(r) { return r.json(); }).then(function(data) {
document.getElementById('stat-agents').textContent = data.agents || 0;

View File

@@ -0,0 +1,290 @@
{% extends "base.html" %}
{% block title %}Upgrade Queue - Timmy Time{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header">
<h1 class="page-title">Upgrade Queue</h1>
<p class="mc-text-secondary">Review and approve self-modification proposals</p>
</div>
<!-- Pending Upgrades -->
<div class="mc-section">
<h2 class="mc-section-title">
Pending Upgrades
{% if pending_count > 0 %}
<span class="mc-badge mc-badge-warning">{{ pending_count }}</span>
{% endif %}
</h2>
{% if pending %}
<div class="upgrades-list">
{% for upgrade in pending %}
<div class="upgrade-card upgrade-pending" data-id="{{ upgrade.id }}">
<div class="upgrade-header">
<h3>{{ upgrade.description }}</h3>
<span class="mc-badge mc-badge-warning">PENDING</span>
</div>
<div class="upgrade-meta">
<span class="upgrade-branch">Branch: {{ upgrade.branch_name }}</span>
<span class="upgrade-time">Proposed: {{ upgrade.proposed_at[11:16] }}</span>
</div>
<div class="upgrade-files">
Files: {{ upgrade.files_changed|join(', ') }}
</div>
<div class="upgrade-test-status">
{% if upgrade.test_passed %}
<span class="test-passed">✓ Tests passed</span>
{% else %}
<span class="test-failed">✗ Tests failed</span>
{% endif %}
</div>
<div class="upgrade-actions">
<button class="mc-btn mc-btn-primary" onclick="approveUpgrade('{{ upgrade.id }}')">
Approve
</button>
<button class="mc-btn" onclick="rejectUpgrade('{{ upgrade.id }}')">
Reject
</button>
<a href="/self-modify/queue/{{ upgrade.id }}/diff" class="mc-btn mc-btn-secondary">
View Diff
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="mc-empty-state">
<p>No pending upgrades.</p>
<p class="mc-text-secondary">Proposed modifications will appear here for review.</p>
</div>
{% endif %}
</div>
<!-- Approved (Waiting to Apply) -->
{% if approved %}
<div class="mc-section">
<h2 class="mc-section-title">Approved (Ready to Apply)</h2>
<div class="upgrades-list">
{% for upgrade in approved %}
<div class="upgrade-card upgrade-approved">
<div class="upgrade-header">
<h3>{{ upgrade.description }}</h3>
<span class="mc-badge mc-badge-success">APPROVED</span>
</div>
<div class="upgrade-actions">
<button class="mc-btn mc-btn-primary" onclick="applyUpgrade('{{ upgrade.id }}')">
Apply Now
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- History -->
<div class="mc-section">
<h2 class="mc-section-title">History</h2>
{% if applied %}
<h4>Applied</h4>
<div class="upgrades-list upgrades-history">
{% for upgrade in applied %}
<div class="upgrade-card upgrade-applied">
<span class="upgrade-desc">{{ upgrade.description }}</span>
<span class="mc-badge mc-badge-success">APPLIED</span>
<span class="upgrade-time">{{ upgrade.applied_at[11:16] if upgrade.applied_at else '' }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if rejected %}
<h4>Rejected</h4>
<div class="upgrades-list upgrades-history">
{% for upgrade in rejected %}
<div class="upgrade-card upgrade-rejected">
<span class="upgrade-desc">{{ upgrade.description }}</span>
<span class="mc-badge mc-badge-secondary">REJECTED</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if failed %}
<h4>Failed</h4>
<div class="upgrades-list upgrades-history">
{% for upgrade in failed %}
<div class="upgrade-card upgrade-failed">
<span class="upgrade-desc">{{ upgrade.description }}</span>
<span class="mc-badge mc-badge-danger">FAILED</span>
<span class="upgrade-error" title="{{ upgrade.error_message }}">⚠️</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<script>
async function approveUpgrade(id) {
if (!confirm('Approve this upgrade?')) return;
const response = await fetch(`/self-modify/queue/${id}/approve`, {
method: 'POST',
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to approve: ' + await response.text());
}
}
async function rejectUpgrade(id) {
if (!confirm('Reject this upgrade? The branch will be deleted.')) return;
const response = await fetch(`/self-modify/queue/${id}/reject`, {
method: 'POST',
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to reject: ' + await response.text());
}
}
async function applyUpgrade(id) {
if (!confirm('Apply this upgrade? This will merge to main.')) return;
const response = await fetch(`/self-modify/queue/${id}/apply`, {
method: 'POST',
});
if (response.ok) {
alert('Upgrade applied successfully!');
window.location.reload();
} else {
const error = await response.text();
alert('Failed to apply: ' + error);
}
}
</script>
<style>
.mc-section {
margin-bottom: 2rem;
}
.mc-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.upgrades-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.upgrade-card {
background: rgba(10, 15, 30, 0.6);
border: 1px solid var(--mc-border);
border-radius: 0.5rem;
padding: 1rem;
}
.upgrade-pending {
border-left: 4px solid #ffc107;
}
.upgrade-approved {
border-left: 4px solid #17a2b8;
}
.upgrade-applied {
border-left: 4px solid #28a745;
}
.upgrade-rejected {
border-left: 4px solid #6c757d;
}
.upgrade-failed {
border-left: 4px solid #dc3545;
}
.upgrade-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.upgrade-header h3 {
margin: 0;
font-size: 1.1rem;
}
.upgrade-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--mc-text-secondary);
margin-bottom: 0.5rem;
}
.upgrade-files {
font-size: 0.9rem;
margin-bottom: 0.5rem;
font-family: monospace;
}
.upgrade-test-status {
margin-bottom: 0.75rem;
}
.test-passed {
color: #28a745;
}
.test-failed {
color: #dc3545;
}
.upgrade-actions {
display: flex;
gap: 0.5rem;
}
.upgrades-history .upgrade-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
}
.upgrade-desc {
flex: 1;
}
.upgrade-time {
font-size: 0.85rem;
color: var(--mc-text-secondary);
}
.upgrade-error {
color: #dc3545;
cursor: help;
}
</style>
{% endblock %}