diff --git a/_build/dev/lib/symbiont/.mix/compile.app_cache b/_build/dev/lib/symbiont/.mix/compile.app_cache index cdfe73e..f2af339 100644 Binary files a/_build/dev/lib/symbiont/.mix/compile.app_cache and b/_build/dev/lib/symbiont/.mix/compile.app_cache differ diff --git a/_build/dev/lib/symbiont/.mix/compile.elixir b/_build/dev/lib/symbiont/.mix/compile.elixir index aabeeb2..d9a2c6a 100644 Binary files a/_build/dev/lib/symbiont/.mix/compile.elixir and b/_build/dev/lib/symbiont/.mix/compile.elixir differ diff --git a/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam b/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam index 9a186be..adc09ce 100644 Binary files a/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam and b/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam differ diff --git a/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam b/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam index 6113b1a..515c9da 100644 Binary files a/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam and b/_build/dev/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam differ diff --git a/_build/dev/lib/symbiont/consolidated/Elixir.Enumerable.beam b/_build/dev/lib/symbiont/consolidated/Elixir.Enumerable.beam index 79cb005..17a06c1 100644 Binary files a/_build/dev/lib/symbiont/consolidated/Elixir.Enumerable.beam and b/_build/dev/lib/symbiont/consolidated/Elixir.Enumerable.beam differ diff --git a/_build/dev/lib/symbiont/consolidated/Elixir.String.Chars.beam b/_build/dev/lib/symbiont/consolidated/Elixir.String.Chars.beam index 0b8fd00..1d65575 100644 Binary files a/_build/dev/lib/symbiont/consolidated/Elixir.String.Chars.beam and b/_build/dev/lib/symbiont/consolidated/Elixir.String.Chars.beam differ diff --git a/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.API.beam b/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.API.beam index 87e0de0..407ce2c 100644 Binary files a/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.API.beam and b/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.API.beam differ diff --git a/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.Application.beam b/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.Application.beam index 472c187..2ef9df6 100644 Binary files a/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.Application.beam and b/_build/dev/lib/symbiont/ebin/Elixir.Symbiont.Application.beam differ diff --git a/_build/dev/lib/symbiont/ebin/symbiont.app b/_build/dev/lib/symbiont/ebin/symbiont.app index 3f2fae4..4e22c01 100644 --- a/_build/dev/lib/symbiont/ebin/symbiont.app +++ b/_build/dev/lib/symbiont/ebin/symbiont.app @@ -1 +1 @@ -{application,symbiont,[{modules,['Elixir.Symbiont','Elixir.Symbiont.API','Elixir.Symbiont.Application','Elixir.Symbiont.Dispatcher','Elixir.Symbiont.Heartbeat','Elixir.Symbiont.Ledger','Elixir.Symbiont.Queue','Elixir.Symbiont.Router']},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,bandit,plug,jason]},{description,"symbiont"},{registered,[]},{vsn,"0.1.0"},{mod,{'Elixir.Symbiont.Application',[]}}]}. \ No newline at end of file +{application,symbiont,[{modules,['Elixir.Symbiont','Elixir.Symbiont.API','Elixir.Symbiont.Application','Elixir.Symbiont.Dispatcher','Elixir.Symbiont.Engram','Elixir.Symbiont.Heartbeat','Elixir.Symbiont.Ledger','Elixir.Symbiont.Queue','Elixir.Symbiont.Router']},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,crypto,bandit,plug,jason,exqlite]},{description,"symbiont"},{registered,[]},{vsn,"0.2.0"},{mod,{'Elixir.Symbiont.Application',[]}}]}. \ No newline at end of file diff --git a/_build/prod/lib/symbiont/.mix/compile.app_cache b/_build/prod/lib/symbiont/.mix/compile.app_cache index cdfe73e..f2af339 100644 Binary files a/_build/prod/lib/symbiont/.mix/compile.app_cache and b/_build/prod/lib/symbiont/.mix/compile.app_cache differ diff --git a/_build/prod/lib/symbiont/.mix/compile.elixir b/_build/prod/lib/symbiont/.mix/compile.elixir index 6911054..ac458b0 100644 Binary files a/_build/prod/lib/symbiont/.mix/compile.elixir and b/_build/prod/lib/symbiont/.mix/compile.elixir differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam b/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam index ed072fa..adc09ce 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.HTTP2.Frame.Serializable.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam b/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam index 6113b1a..515c9da 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.Bandit.WebSocket.Frame.Serializable.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.Collectable.beam b/_build/prod/lib/symbiont/consolidated/Elixir.Collectable.beam index 41d1339..8e5c284 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.Collectable.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.Collectable.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.Enumerable.beam b/_build/prod/lib/symbiont/consolidated/Elixir.Enumerable.beam index 27b5af7..5ec861c 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.Enumerable.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.Enumerable.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.IEx.Info.beam b/_build/prod/lib/symbiont/consolidated/Elixir.IEx.Info.beam index 7e4425c..b353a23 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.IEx.Info.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.IEx.Info.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.Inspect.beam b/_build/prod/lib/symbiont/consolidated/Elixir.Inspect.beam index 823563a..1d7a158 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.Inspect.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.Inspect.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.JSON.Encoder.beam b/_build/prod/lib/symbiont/consolidated/Elixir.JSON.Encoder.beam index 4fc0a6d..54aed03 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.JSON.Encoder.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.JSON.Encoder.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.Jason.Encoder.beam b/_build/prod/lib/symbiont/consolidated/Elixir.Jason.Encoder.beam index c2c1731..eb2b370 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.Jason.Encoder.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.Jason.Encoder.beam differ diff --git a/_build/prod/lib/symbiont/consolidated/Elixir.String.Chars.beam b/_build/prod/lib/symbiont/consolidated/Elixir.String.Chars.beam index 0b8fd00..1d65575 100644 Binary files a/_build/prod/lib/symbiont/consolidated/Elixir.String.Chars.beam and b/_build/prod/lib/symbiont/consolidated/Elixir.String.Chars.beam differ diff --git a/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.API.beam b/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.API.beam index 40306da..407ce2c 100644 Binary files a/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.API.beam and b/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.API.beam differ diff --git a/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.Application.beam b/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.Application.beam index 472c187..2ef9df6 100644 Binary files a/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.Application.beam and b/_build/prod/lib/symbiont/ebin/Elixir.Symbiont.Application.beam differ diff --git a/_build/prod/lib/symbiont/ebin/symbiont.app b/_build/prod/lib/symbiont/ebin/symbiont.app index 3f2fae4..4e22c01 100644 --- a/_build/prod/lib/symbiont/ebin/symbiont.app +++ b/_build/prod/lib/symbiont/ebin/symbiont.app @@ -1 +1 @@ -{application,symbiont,[{modules,['Elixir.Symbiont','Elixir.Symbiont.API','Elixir.Symbiont.Application','Elixir.Symbiont.Dispatcher','Elixir.Symbiont.Heartbeat','Elixir.Symbiont.Ledger','Elixir.Symbiont.Queue','Elixir.Symbiont.Router']},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,bandit,plug,jason]},{description,"symbiont"},{registered,[]},{vsn,"0.1.0"},{mod,{'Elixir.Symbiont.Application',[]}}]}. \ No newline at end of file +{application,symbiont,[{modules,['Elixir.Symbiont','Elixir.Symbiont.API','Elixir.Symbiont.Application','Elixir.Symbiont.Dispatcher','Elixir.Symbiont.Engram','Elixir.Symbiont.Heartbeat','Elixir.Symbiont.Ledger','Elixir.Symbiont.Queue','Elixir.Symbiont.Router']},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,crypto,bandit,plug,jason,exqlite]},{description,"symbiont"},{registered,[]},{vsn,"0.2.0"},{mod,{'Elixir.Symbiont.Application',[]}}]}. \ No newline at end of file diff --git a/lib/symbiont/api.ex b/lib/symbiont/api.ex index 54b1105..b61ca79 100644 --- a/lib/symbiont/api.ex +++ b/lib/symbiont/api.ex @@ -9,6 +9,23 @@ defmodule Symbiont.API do 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 @@ -17,7 +34,8 @@ defmodule Symbiont.API do plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) plug(:dispatch) - # -- POST /task -- + # ── Task Endpoints ────────────────────────────────────────────────────── + post "/task" do task = conn.body_params["task"] force_tier = conn.body_params["force_tier"] @@ -49,7 +67,8 @@ defmodule Symbiont.API do end end - # -- POST /queue -- + # ── Queue Endpoints ───────────────────────────────────────────────────── + post "/queue" do task = conn.body_params["task"] priority = conn.body_params["priority"] || "normal" @@ -68,7 +87,8 @@ defmodule Symbiont.API do end end - # -- GET /status -- + # ── Status Endpoints ──────────────────────────────────────────────────── + get "/status" do ledger_stats = Symbiont.Ledger.stats() queue_size = Symbiont.Queue.size() @@ -87,33 +107,164 @@ defmodule Symbiont.API do send_json(conn, 200, response) end - # -- GET /ledger -- get "/ledger" do entries = Symbiont.Ledger.recent(50) send_json(conn, 200, %{entries: entries, count: length(entries)}) end - # -- GET /ledger/stats -- get "/ledger/stats" do stats = Symbiont.Ledger.stats() send_json(conn, 200, stats) end - # -- GET /health -- get "/health" do send_json(conn, 200, %{status: "ok", runtime: "elixir/otp"}) end - # -- Fallback -- + # ── 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 -- + # ── 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 diff --git a/lib/symbiont/application.ex b/lib/symbiont/application.ex index 29ae86f..77369a8 100644 --- a/lib/symbiont/application.ex +++ b/lib/symbiont/application.ex @@ -7,6 +7,7 @@ defmodule Symbiont.Application do ├── Task.Supervisor (Symbiont.TaskSupervisor) ├── Symbiont.Ledger — append-only cost log ├── Symbiont.Queue — persistent task queue + ├── Symbiont.Engram — cross-session memory (SQLite) ├── Symbiont.Heartbeat — periodic health checks + queue processing └── Bandit (Symbiont.API) — HTTP API @@ -29,11 +30,13 @@ defmodule Symbiont.Application do File.mkdir_p!(data_dir) port = Application.get_env(:symbiont, :port, 8111) + engram_db = Application.get_env(:symbiont, :engram_db, "/data/symbiont/engram.db") children = [ {Task.Supervisor, name: Symbiont.TaskSupervisor}, {Symbiont.Ledger, data_dir: data_dir}, {Symbiont.Queue, data_dir: data_dir}, + {Symbiont.Engram, db_path: engram_db}, {Symbiont.Heartbeat, []}, {Bandit, plug: Symbiont.API, port: port, scheme: :http} ] diff --git a/lib/symbiont/engram.ex b/lib/symbiont/engram.ex new file mode 100644 index 0000000..3e58518 --- /dev/null +++ b/lib/symbiont/engram.ex @@ -0,0 +1,594 @@ +defmodule Symbiont.Engram do + @moduledoc """ + Engram: Persistent memory across Claude instances. + + An engram is the physical trace a memory leaves in neural tissue. Every Claude + session (Cowork, Claude Code, Desktop) writes its engrams here on startup, + building a shared memory of what's being worked on across the ecosystem. + + Two-tier memory model: + - world_state: A singleton markdown document updated at the end of any session + that changes things. Read at the start of every session. + - session_logs: Detailed logs available via get_session_logs/2 only when needed. + + SQLite with WAL mode handles concurrent readers cleanly. Each session writes + only its own rows, so writer contention is minimal. + + ## Usage + + # Get situation report (call at start of every session) + Symbiont.Engram.sitrep() + + # Register a session + {:ok, sid} = Symbiont.Engram.register("cowork", "Building the Elixir port") + + # Log progress + :ok = Symbiont.Engram.log(sid, "Finished router module") + + # Claim a resource + :ok = Symbiont.Engram.lock_resource(sid, "/data/symbiont_ex/lib/symbiont/router.ex") + + # Check locks before modifying a file + locks = Symbiont.Engram.check_locks("/data/symbiont_ex/lib/symbiont/router.ex") + + # Heartbeat (call periodically on long sessions) + :ok = Symbiont.Engram.heartbeat(sid, "Still working, 60% done") + + # Update world state before completing + :ok = Symbiont.Engram.set_world_state("Updated content", sid) + + # Done + :ok = Symbiont.Engram.complete(sid, "Finished Elixir port. Tests passing.") + """ + + use GenServer + require Logger + + @default_db_path "/data/symbiont/engram.db" + @stale_threshold_minutes 30 + + # ── Client API ────────────────────────────────────────────────────────── + + def start_link(opts \\ []) do + db_path = Keyword.get(opts, :db_path, @default_db_path) + GenServer.start_link(__MODULE__, db_path, name: Keyword.get(opts, :name, __MODULE__)) + end + + @doc "Get the current world state markdown." + def get_world_state(server \\ __MODULE__) do + GenServer.call(server, :get_world_state) + end + + @doc "Replace the world state. Called at the end of any session that changes things." + def set_world_state(content, updated_by \\ nil, server \\ __MODULE__) do + GenServer.call(server, {:set_world_state, content, updated_by}) + end + + @doc "Register a new session. Returns `{:ok, session_id}`." + def register(session_type, summary, metadata \\ nil, server \\ __MODULE__) do + GenServer.call(server, {:register, session_type, summary, metadata}) + end + + @doc "Update heartbeat timestamp and optionally update summary." + def heartbeat(session_id, summary \\ nil, server \\ __MODULE__) do + GenServer.call(server, {:heartbeat, session_id, summary}) + end + + @doc "Mark session as completed with a summary of what was accomplished." + def complete(session_id, completion_summary, server \\ __MODULE__) do + GenServer.call(server, {:complete, session_id, completion_summary}) + end + + @doc "Log a progress entry for a session." + def log(session_id, entry, server \\ __MODULE__) do + GenServer.call(server, {:log, session_id, entry}) + end + + @doc "Get all currently active sessions." + def get_active_sessions(server \\ __MODULE__) do + GenServer.call(server, :get_active_sessions) + end + + @doc "Get recently completed sessions for context." + def get_recent_sessions(hours \\ 24, server \\ __MODULE__) do + GenServer.call(server, {:get_recent_sessions, hours}) + end + + @doc "Get log entries for a specific session." + def get_session_logs(session_id, limit \\ 20, server \\ __MODULE__) do + GenServer.call(server, {:get_session_logs, session_id, limit}) + end + + @doc "Claim a resource lock. Warns if already locked by another session." + def lock_resource(session_id, resource, note \\ nil, server \\ __MODULE__) do + GenServer.call(server, {:lock_resource, session_id, resource, note}) + end + + @doc "Release a resource lock." + def release_resource(session_id, resource, server \\ __MODULE__) do + GenServer.call(server, {:release_resource, session_id, resource}) + end + + @doc "Check who has locks on a resource." + def check_locks(resource, server \\ __MODULE__) do + GenServer.call(server, {:check_locks, resource}) + end + + @doc """ + Context-safe situation report. Designed to fit in any Claude context window. + Returns world state + active session one-liners + locked resources. + """ + def sitrep(server \\ __MODULE__) do + GenServer.call(server, :sitrep) + end + + @doc "Get a single session by ID." + def get_session(session_id, server \\ __MODULE__) do + GenServer.call(server, {:get_session, session_id}) + end + + @doc "Get summary stats: total sessions, active count, log count." + def stats(server \\ __MODULE__) do + GenServer.call(server, :stats) + end + + # ── GenServer Callbacks ───────────────────────────────────────────────── + + @impl true + def init(db_path) do + db_path + |> Path.dirname() + |> File.mkdir_p!() + + {:ok, conn} = Exqlite.Sqlite3.open(db_path) + :ok = exec(conn, "PRAGMA journal_mode=WAL") + :ok = exec(conn, "PRAGMA busy_timeout=5000") + init_tables(conn) + + Logger.info("Engram started — db: #{db_path}") + {:ok, %{conn: conn, db_path: db_path}} + end + + @impl true + def handle_call(:get_world_state, _from, %{conn: conn} = state) do + result = + case query_one(conn, "SELECT content FROM world_state WHERE id=1") do + {:ok, [content]} -> content + _ -> "" + end + + {:reply, result, state} + end + + def handle_call({:set_world_state, content, updated_by}, _from, %{conn: conn} = state) do + now = now_iso() + + :ok = + exec( + conn, + "INSERT INTO world_state (id, updated_at, updated_by, content) VALUES (1, ?1, ?2, ?3) " <> + "ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, " <> + "updated_by=excluded.updated_by, content=excluded.content", + [now, updated_by, content] + ) + + {:reply, :ok, state} + end + + def handle_call({:register, session_type, summary, metadata}, _from, %{conn: conn} = state) do + sid = generate_session_id() + now = now_iso() + + :ok = + exec( + conn, + "INSERT INTO sessions (id, session_type, summary, status, started_at, last_heartbeat, metadata) " <> + "VALUES (?1, ?2, ?3, 'active', ?4, ?5, ?6)", + [sid, session_type, summary, now, now, metadata] + ) + + Logger.info("Engram session registered: #{sid} (#{session_type}) — #{summary}") + {:reply, {:ok, sid}, state} + end + + def handle_call({:heartbeat, session_id, summary}, _from, %{conn: conn} = state) do + now = now_iso() + + if summary do + exec( + conn, + "UPDATE sessions SET last_heartbeat=?1, summary=?2, status='active' WHERE id=?3", + [now, summary, session_id] + ) + else + exec( + conn, + "UPDATE sessions SET last_heartbeat=?1, status='active' WHERE id=?2", + [now, session_id] + ) + end + + {:reply, :ok, state} + end + + def handle_call({:complete, session_id, completion_summary}, _from, %{conn: conn} = state) do + now = now_iso() + + exec( + conn, + "UPDATE sessions SET status='completed', completed_at=?1, completion_summary=?2 WHERE id=?3", + [now, completion_summary, session_id] + ) + + exec(conn, "DELETE FROM resource_locks WHERE session_id=?1", [session_id]) + + Logger.info("Engram session completed: #{session_id}") + {:reply, :ok, state} + end + + def handle_call({:log, session_id, entry}, _from, %{conn: conn} = state) do + now = now_iso() + + exec( + conn, + "INSERT INTO session_logs (session_id, timestamp, entry) VALUES (?1, ?2, ?3)", + [session_id, now, entry] + ) + + {:reply, :ok, state} + end + + def handle_call(:get_active_sessions, _from, %{conn: conn} = state) do + {:ok, rows} = + query_all( + conn, + "SELECT id, session_type, summary, status, started_at, last_heartbeat, completed_at, completion_summary, metadata " <> + "FROM sessions WHERE status='active' ORDER BY last_heartbeat DESC" + ) + + sessions = Enum.map(rows, &row_to_session/1) + {:reply, sessions, state} + end + + def handle_call({:get_recent_sessions, hours}, _from, %{conn: conn} = state) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-hours * 3600, :second) + |> DateTime.to_iso8601() + + {:ok, rows} = + query_all( + conn, + "SELECT id, session_type, summary, status, started_at, last_heartbeat, completed_at, completion_summary, metadata " <> + "FROM sessions WHERE status='completed' AND completed_at > ?1 ORDER BY completed_at DESC", + [cutoff] + ) + + sessions = Enum.map(rows, &row_to_session/1) + {:reply, sessions, state} + end + + def handle_call({:get_session_logs, session_id, limit}, _from, %{conn: conn} = state) do + {:ok, rows} = + query_all( + conn, + "SELECT id, session_id, timestamp, entry FROM session_logs " <> + "WHERE session_id=?1 ORDER BY timestamp DESC LIMIT ?2", + [session_id, limit] + ) + + logs = + Enum.map(rows, fn [id, sid, ts, entry] -> + %{id: id, session_id: sid, timestamp: ts, entry: entry} + end) + + {:reply, logs, state} + end + + def handle_call({:lock_resource, session_id, resource, note}, _from, %{conn: conn} = state) do + # Check for existing locks by other sessions + {:ok, existing} = + query_all( + conn, + "SELECT rl.session_id FROM resource_locks rl " <> + "JOIN sessions s ON rl.session_id = s.id " <> + "WHERE rl.resource=?1 AND rl.session_id != ?2 AND s.status='active'", + [resource, session_id] + ) + + if existing != [] do + holders = Enum.map(existing, fn [sid] -> sid end) + Logger.warning("Engram: resource '#{resource}' already locked by: #{Enum.join(holders, ", ")}") + end + + now = now_iso() + + exec( + conn, + "INSERT OR REPLACE INTO resource_locks (resource, session_id, locked_at, note) " <> + "VALUES (?1, ?2, ?3, ?4)", + [resource, session_id, now, note] + ) + + {:reply, :ok, state} + end + + def handle_call({:release_resource, session_id, resource}, _from, %{conn: conn} = state) do + exec( + conn, + "DELETE FROM resource_locks WHERE resource=?1 AND session_id=?2", + [resource, session_id] + ) + + {:reply, :ok, state} + end + + def handle_call({:check_locks, resource}, _from, %{conn: conn} = state) do + {:ok, rows} = + query_all( + conn, + "SELECT rl.resource, rl.session_id, rl.locked_at, rl.note, s.summary, s.session_type " <> + "FROM resource_locks rl JOIN sessions s ON rl.session_id = s.id WHERE rl.resource=?1", + [resource] + ) + + locks = + Enum.map(rows, fn [resource, sid, locked_at, note, summary, stype] -> + %{ + resource: resource, + session_id: sid, + locked_at: locked_at, + note: note, + summary: summary, + session_type: stype + } + end) + + {:reply, locks, state} + end + + def handle_call(:sitrep, _from, %{conn: conn} = state) do + lines = [] + + # World state + world = + case query_one(conn, "SELECT content FROM world_state WHERE id=1") do + {:ok, [content]} -> content + _ -> nil + end + + lines = if world, do: lines ++ [world, ""], else: lines + + # Active sessions + {:ok, active_rows} = + query_all( + conn, + "SELECT id, session_type, summary, last_heartbeat FROM sessions " <> + "WHERE status='active' ORDER BY last_heartbeat DESC" + ) + + lines = + if active_rows != [] do + header = "**Active sessions (#{length(active_rows)}):**" + + session_lines = + Enum.map(active_rows, fn [id, stype, summary, last_hb] -> + stale = is_stale?(last_hb) + marker = if stale, do: "⚠ stale", else: "●" + truncated = String.slice(summary || "", 0, 100) + "- #{marker} `#{id}` (#{stype}): #{truncated}" + end) + + lines ++ [header | session_lines] ++ [""] + else + lines + end + + # Resource locks + {:ok, lock_rows} = + query_all( + conn, + "SELECT rl.resource, rl.session_id FROM resource_locks rl " <> + "JOIN sessions s ON rl.session_id = s.id WHERE s.status='active'" + ) + + lines = + if lock_rows != [] do + lock_lines = + Enum.map(lock_rows, fn [resource, sid] -> + "- `#{resource}` by `#{sid}`" + end) + + lines ++ ["**Locked resources:**" | lock_lines] ++ [""] + else + lines + end + + report = + if lines == [] do + "No world state set yet. Call `Symbiont.Engram.set_world_state/2` to initialize." + else + Enum.join(lines, "\n") + end + + {:reply, report, state} + end + + def handle_call({:get_session, session_id}, _from, %{conn: conn} = state) do + result = + case query_one( + conn, + "SELECT id, session_type, summary, status, started_at, last_heartbeat, completed_at, completion_summary, metadata " <> + "FROM sessions WHERE id=?1", + [session_id] + ) do + {:ok, row} -> {:ok, row_to_session(row)} + :empty -> {:error, :not_found} + end + + {:reply, result, state} + end + + def handle_call(:stats, _from, %{conn: conn} = state) do + {:ok, [total]} = query_one(conn, "SELECT COUNT(*) FROM sessions") + {:ok, [active]} = query_one(conn, "SELECT COUNT(*) FROM sessions WHERE status='active'") + {:ok, [completed]} = query_one(conn, "SELECT COUNT(*) FROM sessions WHERE status='completed'") + {:ok, [logs]} = query_one(conn, "SELECT COUNT(*) FROM session_logs") + {:ok, [locks]} = query_one(conn, "SELECT COUNT(*) FROM resource_locks") + + {:reply, + %{ + total_sessions: total, + active_sessions: active, + completed_sessions: completed, + total_logs: logs, + active_locks: locks + }, state} + end + + @impl true + def terminate(_reason, %{conn: conn}) do + Exqlite.Sqlite3.close(conn) + :ok + end + + # ── Private Helpers ───────────────────────────────────────────────────── + + defp init_tables(conn) do + exec(conn, """ + CREATE TABLE IF NOT EXISTS world_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + updated_at TEXT NOT NULL, + updated_by TEXT, + content TEXT NOT NULL + ) + """) + + exec(conn, """ + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + session_type TEXT NOT NULL, + summary TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + started_at TEXT NOT NULL, + last_heartbeat TEXT NOT NULL, + completed_at TEXT, + completion_summary TEXT, + metadata TEXT + ) + """) + + exec(conn, """ + CREATE TABLE IF NOT EXISTS session_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + timestamp TEXT NOT NULL, + entry TEXT NOT NULL + ) + """) + + exec(conn, """ + CREATE TABLE IF NOT EXISTS resource_locks ( + resource TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id), + locked_at TEXT NOT NULL, + note TEXT, + PRIMARY KEY (resource, session_id) + ) + """) + + exec(conn, "CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status)") + exec(conn, "CREATE INDEX IF NOT EXISTS idx_logs_session ON session_logs(session_id)") + exec(conn, "CREATE INDEX IF NOT EXISTS idx_locks_resource ON resource_locks(resource)") + end + + defp generate_session_id do + now = NaiveDateTime.local_now() + hex = :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) + + now + |> NaiveDateTime.to_iso8601() + |> String.replace(~r/[T:\-]/, "") + |> String.slice(0, 14) + |> Kernel.<>("-#{hex}") + end + + defp now_iso do + NaiveDateTime.local_now() |> NaiveDateTime.to_iso8601() + end + + defp is_stale?(last_heartbeat) do + case NaiveDateTime.from_iso8601(last_heartbeat) do + {:ok, dt} -> + NaiveDateTime.diff(NaiveDateTime.local_now(), dt, :minute) > @stale_threshold_minutes + + _ -> + false + end + end + + defp row_to_session([id, stype, summary, status, started, last_hb, completed, comp_summary, metadata]) do + %{ + id: id, + session_type: stype, + summary: summary, + status: status, + started_at: started, + last_heartbeat: last_hb, + completed_at: completed, + completion_summary: comp_summary, + metadata: metadata + } + end + + # ── SQLite Helpers ────────────────────────────────────────────────────── + + defp exec(conn, sql, params \\ []) do + {:ok, stmt} = Exqlite.Sqlite3.prepare(conn, sql) + + if params != [] do + :ok = Exqlite.Sqlite3.bind(stmt, params) + end + + case Exqlite.Sqlite3.step(conn, stmt) do + :done -> :ok + {:row, _} -> :ok + {:error, reason} -> {:error, reason} + end + after + :ok + end + + defp query_one(conn, sql, params \\ []) do + {:ok, stmt} = Exqlite.Sqlite3.prepare(conn, sql) + + if params != [] do + :ok = Exqlite.Sqlite3.bind(stmt, params) + end + + case Exqlite.Sqlite3.step(conn, stmt) do + {:row, row} -> {:ok, row} + :done -> :empty + {:error, reason} -> {:error, reason} + end + end + + defp query_all(conn, sql, params \\ []) do + {:ok, stmt} = Exqlite.Sqlite3.prepare(conn, sql) + + if params != [] do + :ok = Exqlite.Sqlite3.bind(stmt, params) + end + + rows = collect_rows(conn, stmt, []) + {:ok, rows} + end + + defp collect_rows(conn, stmt, acc) do + case Exqlite.Sqlite3.step(conn, stmt) do + {:row, row} -> collect_rows(conn, stmt, [row | acc]) + :done -> Enum.reverse(acc) + {:error, _reason} -> Enum.reverse(acc) + end + end +end diff --git a/mix.exs b/mix.exs index 47d6e89..f53fe11 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Symbiont.MixProject do def project do [ app: :symbiont, - version: "0.1.0", + version: "0.2.0", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, deps: deps(), @@ -15,7 +15,7 @@ defmodule Symbiont.MixProject do def application do [ - extra_applications: [:logger], + extra_applications: [:logger, :crypto], mod: {Symbiont.Application, []} ] end @@ -24,7 +24,8 @@ defmodule Symbiont.MixProject do [ {:bandit, "~> 1.0"}, {:plug, "~> 1.15"}, - {:jason, "~> 1.4"} + {:jason, "~> 1.4"}, + {:exqlite, "~> 0.27"} ] end diff --git a/mix.lock b/mix.lock index 37f600d..5847aa9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,9 @@ %{ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},