Compare commits

..

2 Commits

Author SHA1 Message Date
Claude Opus 4.6
337ce5e9f8 Fix statement leaks, error handling, UTC timestamps in Engram 2026-03-23 12:32:09 +00:00
Claude Opus 4.6
1ee2976765 Add Engram module — Elixir port of Python session memory system 2026-03-23 12:27:13 +00:00
28 changed files with 790 additions and 13 deletions

View File

@ -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',[]}}]}.
{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',[]}}]}.

View File

@ -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',[]}}]}.
{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',[]}}]}.

View File

@ -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

View File

@ -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}
]

618
lib/symbiont/engram.ex Normal file
View File

@ -0,0 +1,618 @@
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 = DateTime.utc_now()
hex = :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
now
|> DateTime.to_iso8601()
|> String.replace(~r/[T:\-]/, "")
|> String.slice(0, 14)
|> Kernel.<>("-#{hex}")
end
defp now_iso do
DateTime.utc_now() |> DateTime.to_iso8601()
end
defp is_stale?(last_heartbeat) do
# Parse timestamp — handles both UTC DateTime (new) and naive (legacy Python)
with {:error, _} <- parse_as_datetime(last_heartbeat),
{:error, _} <- parse_as_naive(last_heartbeat) do
false
else
{:ok, dt} -> DateTime.diff(DateTime.utc_now(), dt, :minute) > @stale_threshold_minutes
end
end
defp parse_as_datetime(str) do
case DateTime.from_iso8601(str) do
{:ok, dt, _offset} -> {:ok, dt}
_ -> {:error, :invalid}
end
end
defp parse_as_naive(str) do
case NaiveDateTime.from_iso8601(str) do
{:ok, ndt} -> {:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
_ -> {:error, :invalid}
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)
try do
if params != [] do
:ok = Exqlite.Sqlite3.bind(stmt, params)
end
case Exqlite.Sqlite3.step(conn, stmt) do
:done -> :ok
{:row, _} -> :ok
{:error, reason} -> raise "SQLite exec error: #{inspect(reason)}"
end
after
Exqlite.Sqlite3.release(conn, stmt)
end
end
defp query_one(conn, sql, params \\ []) do
{:ok, stmt} = Exqlite.Sqlite3.prepare(conn, sql)
try do
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} -> raise "SQLite query error: #{inspect(reason)}"
end
after
Exqlite.Sqlite3.release(conn, stmt)
end
end
defp query_all(conn, sql, params \\ []) do
{:ok, stmt} = Exqlite.Sqlite3.prepare(conn, sql)
try do
if params != [] do
:ok = Exqlite.Sqlite3.bind(stmt, params)
end
rows = collect_rows(conn, stmt, [])
{:ok, rows}
after
Exqlite.Sqlite3.release(conn, stmt)
end
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} -> raise "SQLite step error mid-collection: #{inspect(reason)}"
end
end
end

View File

@ -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

View File

@ -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"},