538 lines
20 KiB
Python
538 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Agentic OS — FastAPI Backend
|
|
Multi-agent orchestration server for opencode, Hermes, Gemini CLI
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
app = FastAPI(title="Agentic OS", version="1.0.0")
|
|
|
|
# CORS for local dev
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
BASE_DIR = Path(__file__).parent.resolve()
|
|
|
|
# ─── Models ───────────────────────────────────────────────────────
|
|
|
|
class BrainUpdate(BaseModel):
|
|
content: str
|
|
|
|
class SkillRunRequest(BaseModel):
|
|
input: Optional[str] = ""
|
|
agent: Optional[str] = "auto"
|
|
|
|
class ScheduleJobRequest(BaseModel):
|
|
name: str
|
|
skill: str
|
|
cron: str
|
|
enabled: bool = True
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
settings: dict
|
|
|
|
class BackupRestoreRequest(BaseModel):
|
|
file: str
|
|
|
|
class ChatRequest(BaseModel):
|
|
agent: str
|
|
message: str
|
|
|
|
# ─── Helper Functions ─────────────────────────────────────────────
|
|
|
|
def read_file(path: Path):
|
|
if not path.exists():
|
|
return ""
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
def write_file(path: Path, content: str):
|
|
path.write_text(content, encoding="utf-8")
|
|
return True
|
|
|
|
def list_dir(path: Path):
|
|
if not path.exists():
|
|
return []
|
|
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".")])
|
|
|
|
def get_timestamp():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
def append_audit(entry: dict):
|
|
audit_file = BASE_DIR / "audit" / "audit.log"
|
|
entry["timestamp"] = get_timestamp()
|
|
entry["id"] = str(uuid.uuid4())[:8]
|
|
with open(audit_file, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
# ─── Agent Discovery ──────────────────────────────────────────────
|
|
|
|
def check_agent(name: str) -> dict:
|
|
try:
|
|
if name == "opencode":
|
|
r = subprocess.run(["opencode", "--version"], capture_output=True, text=True, timeout=5)
|
|
return {"name": "opencode", "status": "online" if r.returncode == 0 else "error"}
|
|
elif name == "hermes":
|
|
r = subprocess.run(["hermes", "--version"], capture_output=True, text=True, timeout=5)
|
|
return {"name": "hermes", "status": "online" if r.returncode == 0 else "error"}
|
|
elif name == "gemini":
|
|
r = subprocess.run(["gemini", "--version"], capture_output=True, text=True, timeout=5)
|
|
return {"name": "gemini", "status": "online" if r.returncode == 0 else "error"}
|
|
except Exception:
|
|
return {"name": name, "status": "offline"}
|
|
return {"name": name, "status": "offline"}
|
|
|
|
# ─── Routes: Status ───────────────────────────────────────────────
|
|
|
|
@app.get("/api/status")
|
|
def get_status():
|
|
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
|
|
skills = list_dir(BASE_DIR / "skills")
|
|
return {
|
|
"status": "healthy",
|
|
"agents": agents,
|
|
"skills_count": len(skills),
|
|
"uptime": time.time(),
|
|
}
|
|
|
|
# ─── Routes: Brain ────────────────────────────────────────────────
|
|
|
|
@app.get("/api/brain")
|
|
def list_brain():
|
|
files = list_dir(BASE_DIR / "brain")
|
|
brain_data = {}
|
|
for f in files:
|
|
path = BASE_DIR / "brain" / f
|
|
brain_data[f] = read_file(path)
|
|
return brain_data
|
|
|
|
@app.get("/api/brain/{file_name}")
|
|
def get_brain_file(file_name: str):
|
|
path = BASE_DIR / "brain" / file_name
|
|
if not path.exists() or path.is_dir():
|
|
raise HTTPException(404, "File not found")
|
|
return {"name": file_name, "content": read_file(path)}
|
|
|
|
@app.put("/api/brain/{file_name}")
|
|
def update_brain_file(file_name: str, data: BrainUpdate):
|
|
path = BASE_DIR / "brain" / file_name
|
|
write_file(path, data.content)
|
|
append_audit({"action": "brain_update", "file": file_name})
|
|
return {"status": "ok", "file": file_name}
|
|
|
|
# ─── Routes: Skills ───────────────────────────────────────────────
|
|
|
|
@app.get("/api/skills")
|
|
def list_skills():
|
|
skills = []
|
|
for d in sorted((BASE_DIR / "skills").iterdir()):
|
|
if d.is_dir() and not d.name.startswith("_"):
|
|
skill_md = read_file(d / "SKILL.md")
|
|
learnings = read_file(d / "learnings.md")
|
|
eval_data = {}
|
|
eval_path = d / "eval.json"
|
|
if eval_path.exists():
|
|
eval_data = json.loads(eval_path.read_text())
|
|
score_history = []
|
|
score_path = d / "score-history.json"
|
|
if score_path.exists():
|
|
score_history = json.loads(score_path.read_text())
|
|
skills.append({
|
|
"name": d.name,
|
|
"description": skill_md[:200] if skill_md else "",
|
|
"has_learnings": bool(learnings),
|
|
"eval_criteria": eval_data.get("criteria", []),
|
|
"scores": score_history,
|
|
})
|
|
return skills
|
|
|
|
@app.get("/api/skills/{name}")
|
|
def get_skill(name: str):
|
|
path = BASE_DIR / "skills" / name
|
|
if not path.exists():
|
|
raise HTTPException(404, "Skill not found")
|
|
return {
|
|
"name": name,
|
|
"skill": read_file(path / "SKILL.md"),
|
|
"learnings": read_file(path / "learnings.md"),
|
|
"eval": json.loads((path / "eval.json").read_text()) if (path / "eval.json").exists() else {},
|
|
"score_history": json.loads((path / "score-history.json").read_text()) if (path / "score-history.json").exists() else [],
|
|
"context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [],
|
|
}
|
|
|
|
@app.post("/api/skills/{name}/run")
|
|
def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
|
path = BASE_DIR / "skills" / name
|
|
if not path.exists():
|
|
raise HTTPException(404, "Skill not found")
|
|
|
|
agent_choice = req.agent if req else "auto"
|
|
skill_input = req.input if req else ""
|
|
|
|
# Determine which agent based on skill type
|
|
if agent_choice == "auto":
|
|
devops_keywords = ["devops", "audit", "deploy", "k8s", "gcp", "infra", "terraform"]
|
|
research_keywords = ["research", "synthesis", "analyze", "search", "compare"]
|
|
if any(k in name for k in devops_keywords):
|
|
agent_choice = "opencode"
|
|
elif any(k in name for k in research_keywords):
|
|
agent_choice = "gemini"
|
|
else:
|
|
agent_choice = "opencode"
|
|
|
|
# Log execution
|
|
run_id = str(uuid.uuid4())[:8]
|
|
append_audit({
|
|
"action": "skill_run",
|
|
"skill": name,
|
|
"agent": agent_choice,
|
|
"run_id": run_id,
|
|
})
|
|
|
|
return {
|
|
"status": "started",
|
|
"run_id": run_id,
|
|
"skill": name,
|
|
"agent": agent_choice,
|
|
"message": f"Skill '{name}' dispatched to {agent_choice}",
|
|
}
|
|
|
|
@app.get("/api/skills/{name}/eval")
|
|
def get_skill_eval(name: str):
|
|
path = BASE_DIR / "skills" / name / "score-history.json"
|
|
if not path.exists():
|
|
return {"scores": []}
|
|
return {"scores": json.loads(path.read_text())}
|
|
|
|
# ─── Routes: Scheduler ────────────────────────────────────────────
|
|
|
|
@app.get("/api/scheduler/jobs")
|
|
def list_jobs():
|
|
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
|
jobs = []
|
|
for f in sorted(jobs_dir.glob("*.json")):
|
|
jobs.append(json.loads(f.read_text()))
|
|
return jobs
|
|
|
|
@app.post("/api/scheduler/jobs")
|
|
def create_job(job: ScheduleJobRequest):
|
|
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
|
jobs_dir.mkdir(parents=True, exist_ok=True)
|
|
job_data = {
|
|
"id": str(uuid.uuid4())[:8],
|
|
"name": job.name,
|
|
"skill": job.skill,
|
|
"cron": job.cron,
|
|
"enabled": job.enabled,
|
|
"created": get_timestamp(),
|
|
"last_run": None,
|
|
"next_run": None,
|
|
}
|
|
(jobs_dir / f"{job.name.replace(' ', '_')}.json").write_text(
|
|
json.dumps(job_data, indent=2)
|
|
)
|
|
append_audit({"action": "job_created", "job": job.name})
|
|
return job_data
|
|
|
|
@app.delete("/api/scheduler/jobs/{job_id}")
|
|
def delete_job(job_id: str):
|
|
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
|
for f in jobs_dir.glob("*.json"):
|
|
data = json.loads(f.read_text())
|
|
if data.get("id") == job_id:
|
|
f.unlink()
|
|
append_audit({"action": "job_deleted", "job_id": job_id})
|
|
return {"status": "deleted"}
|
|
raise HTTPException(404, "Job not found")
|
|
|
|
# ─── Routes: Audit ────────────────────────────────────────────────
|
|
|
|
@app.get("/api/audit")
|
|
def get_audit(limit: int = Query(100, le=500)):
|
|
audit_file = BASE_DIR / "audit" / "audit.log"
|
|
if not audit_file.exists():
|
|
return {"entries": []}
|
|
lines = audit_file.read_text().strip().split("\n")
|
|
entries = [json.loads(l) for l in lines if l.strip()]
|
|
return {"entries": entries[-limit:]}
|
|
|
|
# ─── Routes: Cost Analytics ───────────────────────────────────────
|
|
|
|
@app.get("/api/cost")
|
|
def get_cost():
|
|
cost_file = BASE_DIR / "data" / "cost-history.json"
|
|
if not cost_file.exists():
|
|
return {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}
|
|
return json.loads(cost_file.read_text())
|
|
|
|
@app.post("/api/cost/record")
|
|
def record_cost(data: dict):
|
|
cost_file = BASE_DIR / "data" / "cost-history.json"
|
|
cost_data = json.loads(cost_file.read_text()) if cost_file.exists() else \
|
|
{"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}
|
|
cost_data["entries"].append({
|
|
"timestamp": get_timestamp(),
|
|
"agent": data.get("agent", "unknown"),
|
|
"tokens": data.get("tokens", 0),
|
|
"cost": data.get("cost", 0.0),
|
|
"model": data.get("model", "unknown"),
|
|
})
|
|
cost_file.write_text(json.dumps(cost_data, indent=2))
|
|
return {"status": "recorded"}
|
|
|
|
# ─── Routes: Registry/Plugins ─────────────────────────────────────
|
|
|
|
@app.get("/api/plugins")
|
|
def list_plugins():
|
|
reg_file = BASE_DIR / "registry" / "plugins.json"
|
|
if not reg_file.exists():
|
|
return {"plugins": []}
|
|
return json.loads(reg_file.read_text())
|
|
|
|
@app.post("/api/plugins/install")
|
|
def install_plugin(data: dict):
|
|
name = data.get("name", "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "Plugin name required")
|
|
reg_file = BASE_DIR / "registry" / "plugins.json"
|
|
reg = json.loads(reg_file.read_text()) if reg_file.exists() else {"plugins": []}
|
|
if any(p["name"] == name for p in reg["plugins"]):
|
|
return {"status": "already_installed"}
|
|
reg["plugins"].append({
|
|
"name": name,
|
|
"installed": get_timestamp(),
|
|
"version": "1.0.0",
|
|
})
|
|
reg_file.write_text(json.dumps(reg, indent=2))
|
|
append_audit({"action": "plugin_installed", "plugin": name})
|
|
return {"status": "installed", "plugin": name}
|
|
|
|
# ─── Routes: Backup ───────────────────────────────────────────────
|
|
|
|
@app.get("/api/backups")
|
|
def list_backups():
|
|
backup_dir = BASE_DIR / "backups"
|
|
backups = []
|
|
for f in sorted(backup_dir.glob("*.tar.gz"), reverse=True):
|
|
backups.append({
|
|
"name": f.name,
|
|
"size": f.stat().st_size,
|
|
"created": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
|
})
|
|
return backups
|
|
|
|
@app.post("/api/backup")
|
|
def create_backup():
|
|
backup_dir = BASE_DIR / "backups"
|
|
backup_dir.mkdir(exist_ok=True)
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_file = backup_dir / f"agentic-os-{ts}.tar.gz"
|
|
with tarfile.open(backup_file, "w:gz") as tar:
|
|
for dir_name in ["brain", "skills", "agents", "registry", "standards", "prompts"]:
|
|
d = BASE_DIR / dir_name
|
|
if d.exists():
|
|
tar.add(d, arcname=dir_name)
|
|
append_audit({"action": "backup_created", "file": backup_file.name})
|
|
return {"status": "ok", "file": backup_file.name, "size": backup_file.stat().st_size}
|
|
|
|
@app.post("/api/backup/restore")
|
|
def restore_backup(data: BackupRestoreRequest):
|
|
backup_file = BASE_DIR / "backups" / data.file
|
|
if not backup_file.exists():
|
|
raise HTTPException(404, "Backup file not found")
|
|
with tarfile.open(backup_file, "r:gz") as tar:
|
|
tar.extractall(path=BASE_DIR)
|
|
append_audit({"action": "backup_restored", "file": data.file})
|
|
return {"status": "restored"}
|
|
|
|
# ─── Routes: Prompts ──────────────────────────────────────────────
|
|
|
|
@app.get("/api/prompts")
|
|
def list_prompts():
|
|
prompts_dir = BASE_DIR / "prompts"
|
|
prompts = {}
|
|
for f in sorted(prompts_dir.glob("*.md")):
|
|
prompts[f.stem] = read_file(f)
|
|
return prompts
|
|
|
|
# ─── Routes: Settings ─────────────────────────────────────────────
|
|
|
|
@app.get("/api/settings")
|
|
def get_settings():
|
|
sf = BASE_DIR / "data" / "settings.json"
|
|
if not sf.exists():
|
|
return {}
|
|
return json.loads(sf.read_text())
|
|
|
|
@app.put("/api/settings")
|
|
def update_settings(data: SettingsUpdate):
|
|
sf = BASE_DIR / "data" / "settings.json"
|
|
# Merge with existing
|
|
existing = json.loads(sf.read_text()) if sf.exists() else {}
|
|
existing.update(data.settings)
|
|
sf.write_text(json.dumps(existing, indent=2))
|
|
append_audit({"action": "settings_updated"})
|
|
return {"status": "ok"}
|
|
|
|
# ─── Routes: Standards ────────────────────────────────────────────
|
|
|
|
@app.get("/api/standards")
|
|
def list_standards():
|
|
std_dir = BASE_DIR / "standards"
|
|
if not std_dir.exists():
|
|
return {"standards": []}
|
|
standards = []
|
|
index_file = std_dir / "index.yml"
|
|
index_content = read_file(index_file)
|
|
for f in std_dir.glob("*.md"):
|
|
standards.append({
|
|
"name": f.stem,
|
|
"content": read_file(f),
|
|
})
|
|
return {"standards": standards, "index": index_content}
|
|
|
|
@app.post("/api/standards/discover")
|
|
def discover_standards():
|
|
# Stub: scans codebase for patterns
|
|
append_audit({"action": "standards_discovery_run"})
|
|
return {"status": "discovery_started", "message": "Scanning codebase for patterns..."}
|
|
|
|
# ─── Routes: Chat ─────────────────────────────────────────────────
|
|
|
|
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
|
|
|
|
def load_chat_history():
|
|
if CHAT_HISTORY_FILE.exists():
|
|
return json.loads(CHAT_HISTORY_FILE.read_text())
|
|
return {"messages": []}
|
|
|
|
def save_chat_message(msg: dict):
|
|
history = load_chat_history()
|
|
history["messages"].append(msg)
|
|
if len(history["messages"]) > 200:
|
|
history["messages"] = history["messages"][-200:]
|
|
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
|
|
|
def execute_agent(agent: str, message: str) -> str:
|
|
try:
|
|
if agent == "opencode":
|
|
r = subprocess.run(
|
|
["opencode", "run", message],
|
|
capture_output=True, text=True, timeout=12
|
|
)
|
|
out = (r.stdout or "") + (r.stderr or "")
|
|
if r.returncode == 0:
|
|
return out if out.strip() else f"✓ opencode received your message.\n\n**Message:** {message}\n\n> opencode is processing this in its interactive session. For a full response, run `opencode run \"{message[:60]}\"` in your terminal."
|
|
return out or f"opencode returned exit code {r.returncode}"
|
|
elif agent == "hermes":
|
|
r = subprocess.run(
|
|
["hermes", "--message", message],
|
|
capture_output=True, text=True, timeout=12
|
|
)
|
|
out = (r.stdout or "") + (r.stderr or "")
|
|
return out if out.strip() else f"✓ Dispatched to Hermes.\n\n**Message:** {message}"
|
|
elif agent == "gemini":
|
|
r = subprocess.run(
|
|
["gemini", "ask", message],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
out = (r.stdout or "") + (r.stderr or "")
|
|
return out if out.strip() else f"✓ Sent to Gemini CLI.\n\n**Message:** {message}"
|
|
else:
|
|
return f"Unknown agent: {agent}"
|
|
except subprocess.TimeoutExpired:
|
|
return f"⏱ Agent '{agent}' took too long to respond.\n\nYour message has been dispatched. For a full response, run the agent CLI directly in your terminal.\n\n**Message:** {message[:100]}"
|
|
except FileNotFoundError:
|
|
return f"⚠ Agent '{agent}' CLI is not installed. Run `pip install {agent}` or install it manually."
|
|
except Exception as e:
|
|
return f"⚠ Error communicating with {agent}: {str(e)}"
|
|
|
|
@app.post("/api/chat")
|
|
def chat(req: ChatRequest):
|
|
agent = req.agent.lower().strip()
|
|
if agent not in ["opencode", "hermes", "gemini"]:
|
|
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
|
|
|
|
user_msg = {
|
|
"id": str(uuid.uuid4())[:8],
|
|
"role": "user",
|
|
"agent": agent,
|
|
"content": req.message,
|
|
"timestamp": get_timestamp(),
|
|
}
|
|
save_chat_message(user_msg)
|
|
|
|
response_text = execute_agent(agent, req.message)
|
|
|
|
agent_msg = {
|
|
"id": str(uuid.uuid4())[:8],
|
|
"role": "assistant",
|
|
"agent": agent,
|
|
"content": response_text,
|
|
"timestamp": get_timestamp(),
|
|
}
|
|
save_chat_message(agent_msg)
|
|
|
|
append_audit({"action": "chat_message", "agent": agent, "msg_preview": req.message[:50]})
|
|
|
|
return {"status": "ok", "response": agent_msg}
|
|
|
|
@app.get("/api/chat/history")
|
|
def get_chat_history():
|
|
return load_chat_history()
|
|
|
|
# ─── Routes: Dashboard Static Files ──────────────────────────────
|
|
|
|
dashboard_dir = BASE_DIR / "dashboard"
|
|
if dashboard_dir.exists():
|
|
app.mount("/dashboard", StaticFiles(directory=str(dashboard_dir)), name="dashboard")
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index():
|
|
html_file = BASE_DIR / "dashboard" / "index.html"
|
|
if html_file.exists():
|
|
content = html_file.read_text()
|
|
content = content.replace('href="styles.css"', 'href="/dashboard/styles.css"')
|
|
content = content.replace('src="utils.js"', 'src="/dashboard/utils.js"')
|
|
content = content.replace('src="api.js"', 'src="/dashboard/api.js"')
|
|
content = content.replace('src="app.js"', 'src="/dashboard/app.js"')
|
|
content = content.replace('pages/', '/dashboard/pages/')
|
|
return HTMLResponse(content=content)
|
|
return HTMLResponse("<h1>Agentic OS</h1><p>Dashboard not built yet. Run <code>./install.sh</code> first.</p>")
|
|
|
|
# ─── Favicon ──────────────────────────────────────────────────────
|
|
|
|
@app.get("/favicon.ico")
|
|
def favicon():
|
|
return HTMLResponse("")
|
|
|
|
# ─── Main ─────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--port", type=int, default=8080)
|
|
parser.add_argument("--host", type=str, default="127.0.0.1")
|
|
args = parser.parse_args()
|
|
uvicorn.run(app, host=args.host, port=args.port)
|