import asyncio
import json
import logging
import os
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token
from .brain import extract_task_fields, stream_completion
from .token_log import get_summary as get_token_summary
from .token_log import log_usage
from .intent import (
classify_intent,
extract_agent_name,
extract_compound_task,
extract_execute_target,
extract_new_project_name,
extract_project_name,
extract_task_destination,
extract_task_title,
is_issue_query,
)
from .tools import (
call_qyburn_rebuild,
call_qyburn_restart,
create_plane_issue,
create_plane_project,
get_agent_output,
get_all_agent_status,
get_plane_issues,
get_plane_projects,
notify_raven_with_actions,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("jon-snow")
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
JON_PUBLIC_URL = os.getenv("JON_PUBLIC_URL", "https://jon.nxm.co.za")
ZAR_RATE = float(os.getenv("ZAR_RATE", "18.50"))
app = FastAPI(title="Jon Snow", version="0.3.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
SYSTEM_PROMPT = """You are Jon Snow, chief of staff for the NxM home lab agent ecosystem on a self-hosted Linux server (172.27.40.3).
Live agents you coordinate:
- hodor (8200): HTTP gateway — routes requests to Ollama
- bran: Daily changelog summariser — runs 06:00 SAST, writes to /opt/sites/changelog/
- varys: Infrastructure monitor — runs every 15 min, HTTP health checks + agent watchdog
- sam (8500): Research agent — SearXNG + Ollama synthesis
- raven (8400): Notifications — Discord webhook + Gmail SMTP
- qyburn (8700): LLM coding agent — qwen2.5-coder:14b, approve/reject workflow
- citadel (8300): MCP tool registry — 21 tools including Plane integration
- hermes: Cloud intelligence agent — claude-sonnet-4-6 brain, connected to Citadel read-only
Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Headscale VPN, Plane project management, Gitea at git.nxm.co.za.
Your capabilities (Phase 3):
1. Report agent status — last run time, success/failure, output summary
2. List Plane projects and log tasks to Plane project management
3. Answer questions about the infrastructure
4. Route complex questions to your SMART_MODEL brain
5. Queue execution actions (restart/rebuild agents) — sends approval request via Discord before executing
For execution requests (restart/rebuild/redeploy), always queue for approval — never execute directly.
Be concise — the user is often on mobile. Use short markdown lists, not long paragraphs."""
# --- OpenAI-compatible request/response models ---
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str = "jon-snow"
messages: list[Message]
stream: bool = True
temperature: float | None = None
max_tokens: int | None = None
class QueueActionRequest(BaseModel):
description: str
action_type: str
params: dict
# --- Output helpers ---
def _render_jon_page() -> None:
"""Write /opt/sites/jon-snow/index.html with live cost and recent tasks."""
summary = get_token_summary()
total_usd = summary["all_time"]["cost_usd"]
today_usd = summary["today"]["cost_usd"]
total_zar = total_usd * ZAR_RATE
today_zar = today_usd * ZAR_RATE
runs_file = AGENT_OS_DIR / "logs" / "jon-snow" / "runs.jsonl"
runs = []
if runs_file.exists():
for line in runs_file.read_text().splitlines():
try:
runs.append(json.loads(line))
except Exception:
pass
# Last 3 task entries, newest first
task_runs = [r for r in reversed(runs) if r.get("result", "").startswith("[task]")][:3]
CLAUDE_INTENTS = {"task", "planning"}
task_rows = ""
for r in task_runs:
ts = r.get("timestamp", "")[:19].replace("T", " ")
result = r.get("result", "")
# "[task] Task created: TITLE → PROJECT"
body = result.removeprefix("[task] Task created: ").removeprefix("[task] ")
if " → " in body:
title, project = body.split(" → ", 1)
else:
title, project = body, "—"
badge_cls = "badge-claude" if r.get("result", "").startswith("[task]") else "badge-python"
task_rows += (
f"
{ts}
{title[:55]}
"
f"
{project[:30]}
"
f'
Claude
\n'
)
if not task_rows:
task_rows = '
No tasks logged yet
'
intent_rows = ""
for intent, v in summary["by_intent"].items():
zar = v["cost_usd"] * ZAR_RATE
label = "Claude" if intent in CLAUDE_INTENTS or "extract" in intent else "Python"
badge = "badge-claude" if label == "Claude" else "badge-python"
intent_rows += (
f"
{intent}
"
f'
{label}
'
f"
{v['calls']}
"
f"
{v['in']:,} / {v['out']:,}
"
f"
R{zar:.4f}
\n"
)
if not intent_rows:
intent_rows = '
No API calls yet
'
updated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
html = f"""
Jon Snow — NxM Agents