defmodule Symbiont.API do @moduledoc """ HTTP API for Symbiont — drop-in replacement for the Python FastAPI server. Endpoints: POST /task — Submit and execute a task immediately POST /queue — Add a task to the persistent queue GET /status — Health check + system overview GET /ledger — Recent inference calls GET /ledger/stats — Aggregate cost & usage GET /health — Simple liveness probe Engram endpoints: GET /engram/sitrep — Situation report (world state + active sessions) GET /engram/world — Raw world state markdown PUT /engram/world — Update world state POST /engram/sessions — Register a new session GET /engram/sessions — List active sessions GET /engram/sessions/recent — Recently completed sessions GET /engram/sessions/:id — Get a single session POST /engram/sessions/:id/heartbeat — Update heartbeat POST /engram/sessions/:id/complete — Mark session completed POST /engram/sessions/:id/log — Add a log entry GET /engram/sessions/:id/logs — Get session logs POST /engram/locks — Lock a resource DELETE /engram/locks — Release a resource lock GET /engram/locks/:resource — Check locks on a resource GET /engram/stats — Engram stats """ use Plug.Router plug(Plug.Logger) plug(:match) plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) plug(:dispatch) # ── Task Endpoints ────────────────────────────────────────────────────── post "/task" do task = conn.body_params["task"] force_tier = conn.body_params["force_tier"] if is_nil(task) or task == "" do send_json(conn, 400, %{error: "missing 'task' field"}) else opts = if force_tier, do: [force_tier: force_tier], else: [] case Symbiont.Router.route_and_execute(task, opts) do {:ok, result} -> response = %{ id: "task-#{System.system_time(:second)}", task: task, model: result.model, result: result.result, elapsed_seconds: result.elapsed_seconds, input_tokens: result.input_tokens, output_tokens: result.output_tokens, estimated_cost_usd: result.estimated_cost_usd, timestamp: DateTime.utc_now() |> DateTime.to_iso8601() } send_json(conn, 200, response) {:error, reason} -> send_json(conn, 500, %{error: inspect(reason)}) end end end # ── Queue Endpoints ───────────────────────────────────────────────────── post "/queue" do task = conn.body_params["task"] priority = conn.body_params["priority"] || "normal" if is_nil(task) or task == "" do send_json(conn, 400, %{error: "missing 'task' field"}) else {:ok, task_id} = Symbiont.Queue.enqueue(task, priority) queue_size = Symbiont.Queue.size() send_json(conn, 200, %{ id: task_id, status: "queued", position: queue_size }) end end # ── Status Endpoints ──────────────────────────────────────────────────── get "/status" do ledger_stats = Symbiont.Ledger.stats() queue_size = Symbiont.Queue.size() last_heartbeat = Symbiont.Heartbeat.last_snapshot() response = %{ status: "healthy", runtime: "elixir/otp", queue_size: queue_size, last_heartbeat: last_heartbeat && last_heartbeat["timestamp"], total_calls: ledger_stats["total_calls"], total_cost_estimated_usd: ledger_stats["total_cost_estimated_usd"], by_model: ledger_stats["by_model"] } send_json(conn, 200, response) end get "/ledger" do entries = Symbiont.Ledger.recent(50) send_json(conn, 200, %{entries: entries, count: length(entries)}) end get "/ledger/stats" do stats = Symbiont.Ledger.stats() send_json(conn, 200, stats) end get "/health" do send_json(conn, 200, %{status: "ok", runtime: "elixir/otp"}) end # ── Engram Endpoints ──────────────────────────────────────────────────── get "/engram/sitrep" do report = Symbiont.Engram.sitrep() send_json(conn, 200, %{sitrep: report}) end get "/engram/world" do content = Symbiont.Engram.get_world_state() send_json(conn, 200, %{content: content}) end put "/engram/world" do content = conn.body_params["content"] updated_by = conn.body_params["updated_by"] if is_nil(content) do send_json(conn, 400, %{error: "missing 'content' field"}) else :ok = Symbiont.Engram.set_world_state(content, updated_by) send_json(conn, 200, %{status: "ok"}) end end post "/engram/sessions" do session_type = conn.body_params["session_type"] summary = conn.body_params["summary"] metadata = conn.body_params["metadata"] if is_nil(session_type) or is_nil(summary) do send_json(conn, 400, %{error: "missing 'session_type' and/or 'summary'"}) else {:ok, sid} = Symbiont.Engram.register(session_type, summary, metadata) send_json(conn, 200, %{session_id: sid, status: "active"}) end end get "/engram/sessions" do sessions = Symbiont.Engram.get_active_sessions() send_json(conn, 200, %{sessions: sessions, count: length(sessions)}) end get "/engram/sessions/recent" do hours = parse_int(conn.query_params["hours"], 24) sessions = Symbiont.Engram.get_recent_sessions(hours) send_json(conn, 200, %{sessions: sessions, count: length(sessions)}) end # Note: this must come AFTER /engram/sessions/recent to avoid matching "recent" as :id get "/engram/sessions/:id" do case Symbiont.Engram.get_session(id) do {:ok, session} -> send_json(conn, 200, session) {:error, :not_found} -> send_json(conn, 404, %{error: "session not found"}) end end post "/engram/sessions/:id/heartbeat" do summary = conn.body_params["summary"] :ok = Symbiont.Engram.heartbeat(id, summary) send_json(conn, 200, %{status: "ok"}) end post "/engram/sessions/:id/complete" do completion_summary = conn.body_params["summary"] if is_nil(completion_summary) do send_json(conn, 400, %{error: "missing 'summary' field"}) else :ok = Symbiont.Engram.complete(id, completion_summary) send_json(conn, 200, %{status: "completed"}) end end post "/engram/sessions/:id/log" do entry = conn.body_params["entry"] if is_nil(entry) do send_json(conn, 400, %{error: "missing 'entry' field"}) else :ok = Symbiont.Engram.log(id, entry) send_json(conn, 200, %{status: "ok"}) end end get "/engram/sessions/:id/logs" do limit = parse_int(conn.query_params["limit"], 20) logs = Symbiont.Engram.get_session_logs(id, limit) send_json(conn, 200, %{logs: logs, count: length(logs)}) end post "/engram/locks" do session_id = conn.body_params["session_id"] resource = conn.body_params["resource"] note = conn.body_params["note"] if is_nil(session_id) or is_nil(resource) do send_json(conn, 400, %{error: "missing 'session_id' and/or 'resource'"}) else :ok = Symbiont.Engram.lock_resource(session_id, resource, note) send_json(conn, 200, %{status: "locked"}) end end delete "/engram/locks" do session_id = conn.body_params["session_id"] resource = conn.body_params["resource"] if is_nil(session_id) or is_nil(resource) do send_json(conn, 400, %{error: "missing 'session_id' and/or 'resource'"}) else :ok = Symbiont.Engram.release_resource(session_id, resource) send_json(conn, 200, %{status: "released"}) end end get "/engram/locks/:resource" do locks = Symbiont.Engram.check_locks(URI.decode(resource)) send_json(conn, 200, %{locks: locks, count: length(locks)}) end get "/engram/stats" do stats = Symbiont.Engram.stats() send_json(conn, 200, stats) end # ── Fallback ──────────────────────────────────────────────────────────── match _ do send_json(conn, 404, %{error: "not found"}) end # ── Helpers ───────────────────────────────────────────────────────────── defp send_json(conn, status, body) do conn |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(body)) end defp parse_int(nil, default), do: default defp parse_int(str, default) do case Integer.parse(str) do {n, _} -> n :error -> default end end end