symbiont/public_api.py

212 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Cortex Public API — lightweight endpoints for external Claude sessions.
Runs on port 8112, exposed via Caddy at cortex.hydrascale.net/api/
Auth: pass API key via X-API-Key header, Authorization: Bearer, or ?key= query param.
"""
import json
import os
import subprocess
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Header, Depends, Query, Request
from fastapi.middleware.cors import CORSMiddleware
API_KEY = "cortex-pub-a7f3e9c1d4b8"
app = FastAPI(title="Cortex Public API", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
def verify_key(
request: Request,
x_api_key: Optional[str] = Header(None),
authorization: Optional[str] = Header(None),
key: Optional[str] = Query(None),
):
"""Accept key via X-API-Key header, Authorization: Bearer, or ?key= query param."""
token = x_api_key or key
if not token and authorization and authorization.startswith("Bearer "):
token = authorization[7:]
if token != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key. Pass via X-API-Key header or ?key= param.")
return token
# ──────────── /health (no auth) ────────────
@app.get("/health")
async def health():
return {"status": "ok", "timestamp": datetime.utcnow().isoformat() + "Z"}
# ──────────── /info — system specs ────────────
@app.get("/info")
async def system_info(auth: str = Depends(verify_key)):
"""Return full system specs — CPU, RAM, disk, GPU, OS, etc."""
specs = {}
# OS
try:
out = subprocess.check_output(["lsb_release", "-ds"], text=True).strip()
specs["os"] = out
except Exception:
specs["os"] = "unknown"
# Kernel
specs["kernel"] = subprocess.check_output(["uname", "-r"], text=True).strip()
# Hostname
specs["hostname"] = subprocess.check_output(["hostname"], text=True).strip()
# CPU
try:
cpuinfo = Path("/proc/cpuinfo").read_text()
model_names = [l.split(":")[1].strip() for l in cpuinfo.splitlines() if "model name" in l]
specs["cpu"] = {
"model": model_names[0] if model_names else "unknown",
"cores": len(model_names),
}
except Exception:
specs["cpu"] = "unknown"
# RAM
try:
meminfo = Path("/proc/meminfo").read_text()
for line in meminfo.splitlines():
if line.startswith("MemTotal:"):
kb = int(line.split()[1])
specs["ram_gb"] = round(kb / 1024 / 1024, 1)
break
except Exception:
specs["ram_gb"] = "unknown"
# Disk
try:
df = subprocess.check_output(["df", "-h", "/"], text=True).strip().split("\n")[1].split()
specs["disk"] = {"total": df[1], "used": df[2], "available": df[3], "use_pct": df[4]}
except Exception:
specs["disk"] = "unknown"
# GPU
try:
gpu = subprocess.check_output(["lspci"], text=True)
gpu_lines = [l for l in gpu.splitlines() if "VGA" in l or "3D" in l or "Display" in l]
specs["gpu"] = gpu_lines if gpu_lines else "none detected"
except Exception:
specs["gpu"] = "lspci not available"
# Uptime
try:
uptime = Path("/proc/uptime").read_text().split()[0]
specs["uptime_hours"] = round(float(uptime) / 3600, 1)
except Exception:
specs["uptime_hours"] = "unknown"
# Docker containers
try:
containers = subprocess.check_output(
["docker", "ps", "--format", "{{.Names}}: {{.Status}}"], text=True
).strip()
specs["docker_containers"] = containers.splitlines() if containers else []
except Exception:
specs["docker_containers"] = "docker not available"
# Services
services = {}
for svc in ["caddy", "fail2ban", "docker", "symbiont-api", "cortex-public-api"]:
try:
status = subprocess.check_output(
["systemctl", "is-active", svc], text=True
).strip()
services[svc] = status
except Exception:
services[svc] = "unknown"
specs["services"] = services
specs["timestamp"] = datetime.utcnow().isoformat() + "Z"
return specs
# ──────────── /engram/sitrep — session awareness ────────────
@app.get("/engram/sitrep")
async def engram_sitrep(auth: str = Depends(verify_key)):
"""Return situation report from engram DB."""
db_path = Path("/data/symbiont/engram.db")
if not db_path.exists():
return {"error": "engram.db not found", "sessions": []}
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Get table info first
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [r["name"] for r in cur.fetchall()]
if "sessions" not in tables:
conn.close()
return {"error": "sessions table not found", "tables": tables}
# Get active sessions
cur.execute("SELECT * FROM sessions WHERE status = 'active' ORDER BY started_at DESC")
active = [dict(r) for r in cur.fetchall()]
# Get recent completed (last 24h)
cutoff = (datetime.utcnow() - timedelta(hours=24)).isoformat()
cur.execute(
"SELECT * FROM sessions WHERE status = 'completed' AND started_at > ? ORDER BY started_at DESC",
(cutoff,)
)
recent = [dict(r) for r in cur.fetchall()]
conn.close()
return {"active_sessions": active, "recent_24h": recent}
# ──────────── /engram/register — register a session ────────────
@app.post("/engram/register")
async def engram_register(
session_type: str = Query(default="claude-chat"),
description: str = Query(default="External Claude session"),
auth: str = Depends(verify_key),
):
"""Register a new session in engram."""
import sys
sys.path.insert(0, "/data/symbiont")
from symbiont.engram import Engram
eng = Engram()
sid = eng.register(session_type, description)
return {"session_id": sid, "status": "registered"}
# ──────────── /engram/log — log to a session ────────────
@app.post("/engram/log")
async def engram_log(
session_id: str = Query(...),
message: str = Query(...),
auth: str = Depends(verify_key),
):
"""Log a message to an existing session."""
import sys
sys.path.insert(0, "/data/symbiont")
from symbiont.engram import Engram
eng = Engram()
eng.log(session_id, message)
return {"status": "logged"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8112)