diff --git a/dashboard/api.js b/dashboard/api.js index a54e4ad..e11c1dd 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -1,31 +1,20 @@ const api = { - async get(path) { - const r = await fetch(path); - if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); } - return r.json(); - }, - async post(path, body = {}, controller) { - const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; + async request(path, { method = 'GET', body, controller } = {}) { + const opts = { method }; + if (body !== undefined) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(body); + } if (controller) opts.signal = controller.signal; const r = await fetch(path, opts); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); } return r.json(); }, - async put(path, body = {}) { - const r = await fetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); } - return r.json(); - }, - async patch(path, body = {}) { - const r = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); } - return r.json(); - }, - async del(path) { - const r = await fetch(path, { method: 'DELETE' }); - if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); } - return r.json(); - }, + get(path) { return api.request(path); }, + post(path, body = {}, controller) { return api.request(path, { method: 'POST', body, controller }); }, + put(path, body = {}) { return api.request(path, { method: 'PUT', body }); }, + patch(path, body = {}) { return api.request(path, { method: 'PATCH', body }); }, + del(path) { return api.request(path, { method: 'DELETE' }); }, getStatus: () => api.get('/api/status'), getBrain: () => api.get('/api/brain'), getBrainFile: (name) => api.get(`/api/brain/${encodeURIComponent(name)}`), diff --git a/server.py b/server.py index 60f87bf..bfd6f12 100644 --- a/server.py +++ b/server.py @@ -28,6 +28,9 @@ from pydantic import BaseModel BASE_DIR = Path(__file__).parent.resolve() +# Agents supported across chat, routing, health, and kanban dispatch. +AGENTS = ["opencode", "hermes", "gemini"] + app = FastAPI(title="Agentic OS", version="1.1.0") # Load OpenRouter API key from Hermes .env @@ -125,43 +128,53 @@ def write_file(path: Path, content: str): path.write_text(content, encoding="utf-8") return True -_MISSING = object() - -def load_json_file(path: Path, default=_MISSING, best_effort=False): - """Read and parse a JSON file. - - Raises a descriptive HTTPException instead of leaking an opaque 500 when the - file is missing or corrupt, so callers propagate a clear error to the client. - If ``default`` is provided it is returned when the file does not exist. - - Set ``best_effort=True`` (with a ``default``) for aggregate/listing callers - that should tolerate one corrupt file rather than aborting the whole view: - the corruption is logged and ``default`` is returned instead of raising. - """ - if not path.exists(): - if default is not _MISSING: - return default - raise HTTPException(404, f"{path.name} not found") - try: - return json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: - if best_effort and default is not _MISSING: - print(f"[load] skipping corrupt {path.name}: {e}") - return default - raise HTTPException(500, f"Failed to read {path.name}: {e}") - 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 read_json(path: Path, default=None, best_effort=False): + """Load JSON from path, returning ``default`` when the file is missing. + + On corrupt or unreadable content a descriptive ``HTTPException(500)`` is + raised so the error is propagated to the client instead of surfacing as an + opaque 500. Aggregate/listing callers can set ``best_effort=True`` to + tolerate one bad file: the corruption is logged and ``default`` is returned + instead of aborting the whole view. + """ + if not path.exists(): + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: + if best_effort: + print(f"[load] skipping corrupt {path.name}: {e}") + return default + raise HTTPException(500, f"Failed to read {path.name}: {e}") + +def write_json(path: Path, data, indent: int = 2): + """Serialize data as pretty JSON to path.""" + path.write_text(json.dumps(data, indent=indent), encoding="utf-8") + +def iter_skill_dirs(): + """Yield skill directories, skipping hidden and underscore-prefixed ones.""" + skills_dir = BASE_DIR / "skills" + if not skills_dir.exists(): + return + for d in sorted(skills_dir.iterdir()): + if d.is_dir() and not d.name.startswith("_"): + yield d + +def new_id(): + return str(uuid.uuid4())[:8] + 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] + entry["id"] = new_id() try: audit_file.parent.mkdir(parents=True, exist_ok=True) with open(audit_file, "a", encoding="utf-8") as f: @@ -231,7 +244,7 @@ def check_agent(name: str) -> dict: @app.get("/api/status") def get_status(): - agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]] + agents = [check_agent(a) for a in AGENTS] skills = list_dir(BASE_DIR / "skills") return { "status": "healthy", @@ -307,19 +320,18 @@ def skill_context_file_path(name: str, filename: str) -> Path: @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 = load_json_file(d / "eval.json", default={}, best_effort=True) - score_history = load_json_file(d / "score-history.json", default=[], best_effort=True) - 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, - }) + for d in iter_skill_dirs(): + skill_md = read_file(d / "SKILL.md") + learnings = read_file(d / "learnings.md") + eval_data = read_json(d / "eval.json", {}, best_effort=True) + score_history = read_json(d / "score-history.json", [], best_effort=True) + 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}") @@ -329,8 +341,8 @@ def get_skill(name: str): "name": name, "skill": read_file(path / "SKILL.md"), "learnings": read_file(path / "learnings.md"), - "eval": load_json_file(path / "eval.json", default={}), - "score_history": load_json_file(path / "score-history.json", default=[]), + "eval": read_json(path / "eval.json", default={}), + "score_history": read_json(path / "score-history.json", default=[]), "context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [], } @@ -420,7 +432,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): if skill_input: prompt += f"## User Input\n{skill_input}" - run_id = str(uuid.uuid4())[:8] + run_id = new_id() # Execute via agent try: @@ -464,7 +476,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): path = resolve_skill_dir(name) / "score-history.json" - return {"scores": load_json_file(path, default=[])} + return {"scores": read_json(path, default=[])} # ─── Routes: Scheduler ──────────────────────────────────────────── @@ -473,7 +485,7 @@ def list_jobs(): jobs_dir = BASE_DIR / "scheduler" / "jobs" jobs = [] for f in sorted(jobs_dir.glob("*.json")): - job = load_json_file(f, default=None, best_effort=True) + job = read_json(f, default=None, best_effort=True) if job is not None: jobs.append(job) return jobs @@ -483,7 +495,7 @@ 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], + "id": new_id(), "name": job.name, "skill": job.skill, "cron": job.cron, @@ -502,7 +514,7 @@ def create_job(job: ScheduleJobRequest): def delete_job(job_id: str): jobs_dir = BASE_DIR / "scheduler" / "jobs" for f in jobs_dir.glob("*.json"): - data = load_json_file(f, default=None, best_effort=True) + data = read_json(f, default=None, best_effort=True) if data and data.get("id") == job_id: f.unlink() append_audit({"action": "job_deleted", "job_id": job_id}) @@ -533,18 +545,12 @@ def get_audit(limit: int = Query(100, le=500)): @app.get("/api/cost") def get_cost(): cost_file = BASE_DIR / "data" / "cost-history.json" - return load_json_file( - cost_file, - default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}, - ) + return read_json(cost_file, {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}) @app.post("/api/cost/record") def record_cost(data: dict): cost_file = BASE_DIR / "data" / "cost-history.json" - cost_data = load_json_file( - cost_file, - default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}, - ) + cost_data = read_json(cost_file, {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}) cost_data["entries"].append({ "timestamp": get_timestamp(), "agent": data.get("agent", "unknown"), @@ -552,7 +558,7 @@ def record_cost(data: dict): "cost": data.get("cost", 0.0), "model": data.get("model", "unknown"), }) - cost_file.write_text(json.dumps(cost_data, indent=2)) + write_json(cost_file, cost_data) return {"status": "recorded"} # ─── Routes: Registry/Plugins ───────────────────────────────────── @@ -560,7 +566,7 @@ def record_cost(data: dict): @app.get("/api/plugins") def list_plugins(): reg_file = BASE_DIR / "registry" / "plugins.json" - return load_json_file(reg_file, default={"plugins": []}) + return read_json(reg_file, {"plugins": []}) @app.post("/api/plugins/install") def install_plugin(data: dict): @@ -568,7 +574,7 @@ def install_plugin(data: dict): if not name: raise HTTPException(400, "Plugin name required") reg_file = BASE_DIR / "registry" / "plugins.json" - reg = load_json_file(reg_file, default={"plugins": []}) + reg = read_json(reg_file, {"plugins": []}) if any(p["name"] == name for p in reg["plugins"]): return {"status": "already_installed"} reg["plugins"].append({ @@ -576,7 +582,7 @@ def install_plugin(data: dict): "installed": get_timestamp(), "version": "1.0.0", }) - reg_file.write_text(json.dumps(reg, indent=2)) + write_json(reg_file, reg) append_audit({"action": "plugin_installed", "plugin": name}) return {"status": "installed", "plugin": name} @@ -633,15 +639,15 @@ def list_prompts(): @app.get("/api/settings") def get_settings(): sf = BASE_DIR / "data" / "settings.json" - return load_json_file(sf, default={}) + return read_json(sf, {}) @app.put("/api/settings") def update_settings(data: SettingsUpdate): sf = BASE_DIR / "data" / "settings.json" # Merge with existing - existing = load_json_file(sf, default={}) + existing = read_json(sf, {}) existing.update(data.settings) - sf.write_text(json.dumps(existing, indent=2)) + write_json(sf, existing) append_audit({"action": "settings_updated"}) return {"status": "ok"} @@ -673,14 +679,14 @@ def discover_standards(): CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json" def load_chat_history(): - return load_json_file(CHAT_HISTORY_FILE, default={"messages": []}) + return read_json(CHAT_HISTORY_FILE, {"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)) + write_json(CHAT_HISTORY_FILE, history) def run_cli(args: list, timeout: int = 30) -> tuple: r = subprocess.run(args, capture_output=True, text=True, timeout=timeout) @@ -787,11 +793,11 @@ def execute_agent(agent: str, message: str) -> str: @app.post("/api/chat") def chat(req: ChatRequest): agent = req.agent.lower().strip() - if agent not in ["opencode", "hermes", "gemini"]: + if agent not in AGENTS: raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini") user_msg = { - "id": str(uuid.uuid4())[:8], + "id": new_id(), "role": "user", "agent": agent, "content": req.message, @@ -802,7 +808,7 @@ def chat(req: ChatRequest): response_text = execute_agent(agent, req.message) agent_msg = { - "id": str(uuid.uuid4())[:8], + "id": new_id(), "role": "assistant", "agent": agent, "content": response_text, @@ -1001,7 +1007,7 @@ def load_kanban_tasks(): ensure_dir(KANBAN_DIR) tasks = [] for f in sorted(KANBAN_DIR.glob("*.json")): - task = load_json_file(f, default=None, best_effort=True) + task = read_json(f, default=None, best_effort=True) if task is not None: tasks.append(task) return tasks @@ -1022,7 +1028,7 @@ def save_kanban_task(task: dict): ensure_dir(KANBAN_DIR) kanban_task_path(task["id"]).write_text(json.dumps(task, indent=2)) -KANBAN_AGENTS = {"opencode", "hermes", "gemini"} +KANBAN_AGENTS = set(AGENTS) def dispatch_kanban_task(task_id: str): """Move a task to in_progress and hand it to its assignee agent in the background.""" @@ -1057,7 +1063,7 @@ def _run_kanban_agent(task_id: str): task = json.loads(path.read_text()) # reload in case it changed while the agent ran task.setdefault("comments", []).append({ - "id": str(uuid.uuid4())[:8], + "id": new_id(), "message": f"🤖 **{agent}**\n\n{response}", "timestamp": get_timestamp(), }) @@ -1085,10 +1091,10 @@ def _run_kanban_agent(task_id: str): print(f"[kanban] could not mark task {task_id} as blocked: {inner}") def load_goals(): - return load_json_file(GOALS_FILE, default=[]) + return read_json(GOALS_FILE, []) def save_goals(goals: list): - GOALS_FILE.write_text(json.dumps(goals, indent=2)) + write_json(GOALS_FILE, goals) # ─── Routes: Kanban Board (13 endpoints) ──────────────────────── @@ -1127,7 +1133,7 @@ def kanban_delete_task(task_id: str): def kanban_create_task(data: KanbanTaskCreate): try: task = { - "id": str(uuid.uuid4())[:8], + "id": new_id(), "title": data.title, "body": data.body, "status": data.status, @@ -1224,7 +1230,7 @@ def kanban_add_comment(task_id: str, data: KanbanCommentCreate): raise HTTPException(404, "Task not found") task = json.loads(path.read_text()) comment = { - "id": str(uuid.uuid4())[:8], + "id": new_id(), "message": data.message, "timestamp": get_timestamp(), } @@ -1294,7 +1300,7 @@ def kanban_decompose_task(task_id: str): subtask = subtask.strip().lstrip("-* ") if subtask: child = { - "id": str(uuid.uuid4())[:8], + "id": new_id(), "title": subtask[:80], "body": subtask, "status": "todo", @@ -1324,7 +1330,7 @@ def create_goal(data: GoalCreate): try: goals = load_goals() goal = { - "id": str(uuid.uuid4())[:8], + "id": new_id(), "title": data.title, "description": data.description, "category": data.category, @@ -1436,7 +1442,7 @@ def search_journal(q: str = Query("")): def get_agent_health(): try: agents = [] - for name in ["opencode", "hermes", "gemini"]: + for name in AGENTS: info = check_agent(name) info["uptime"] = 0 info["success_rate"] = 100 @@ -1449,7 +1455,7 @@ def get_agent_health(): @app.get("/api/agents/{name}/stats") def get_agent_stats(name: str): try: - if name not in ["opencode", "hermes", "gemini"]: + if name not in AGENTS: raise HTTPException(400, "Invalid agent") info = check_agent(name) return { @@ -1470,7 +1476,7 @@ def get_agent_stats(name: str): def refresh_agent_health(): try: agents = [] - for name in ["opencode", "hermes", "gemini"]: + for name in AGENTS: info = check_agent(name) agents.append(info) append_audit({"action": "agent_health_refreshed"}) @@ -1508,7 +1514,7 @@ def router_suggest(data: RouterSuggest): def router_route(data: RouterRoute): try: agent = data.agent.lower() - if agent not in ["opencode", "hermes", "gemini"]: + if agent not in AGENTS: return {"status": "error", "message": f"Invalid agent: {agent}"} append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]}) return { @@ -1525,22 +1531,17 @@ def router_route(data: RouterRoute): @app.get("/api/analytics/skills") def get_skill_analytics(): try: - skills_dir = BASE_DIR / "skills" analytics = [] - for d in sorted(skills_dir.iterdir()): - if d.is_dir() and not d.name.startswith("_"): - eval_path = d / "eval.json" - score_path = d / "score-history.json" - scores = json.loads(score_path.read_text()) if score_path.exists() else [] - eval_data = json.loads(eval_path.read_text()) if eval_path.exists() else {} - avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0 - analytics.append({ - "name": d.name, - "total_runs": len(scores), - "avg_score": round(avg_score, 1), - "last_score": scores[-1].get("score", 0) if scores else 0, - "trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable", - }) + for d in iter_skill_dirs(): + scores = read_json(d / "score-history.json", []) + avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0 + analytics.append({ + "name": d.name, + "total_runs": len(scores), + "avg_score": round(avg_score, 1), + "last_score": scores[-1].get("score", 0) if scores else 0, + "trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable", + }) return {"skills": sorted(analytics, key=lambda x: x["total_runs"], reverse=True)} except Exception as e: return {"skills": [], "error": str(e)} @@ -1548,18 +1549,15 @@ def get_skill_analytics(): @app.get("/api/analytics/trends") def get_trend_analytics(): try: - skills_dir = BASE_DIR / "skills" trends = [] - for d in sorted(skills_dir.iterdir()): - if d.is_dir() and not d.name.startswith("_"): - score_path = d / "score-history.json" - scores = json.loads(score_path.read_text()) if score_path.exists() else [] - if scores: - trends.append({ - "name": d.name, - "scores": [s.get("score", 0) for s in scores[-10:]], - "labels": [s.get("date", "") for s in scores[-10:]], - }) + for d in iter_skill_dirs(): + scores = read_json(d / "score-history.json", []) + if scores: + trends.append({ + "name": d.name, + "scores": [s.get("score", 0) for s in scores[-10:]], + "labels": [s.get("date", "") for s in scores[-10:]], + }) return {"trends": trends} except Exception as e: return {"trends": [], "error": str(e)}