Compare commits
2 Commits
b8a7644557
...
337ce5e9f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
337ce5e9f8 | ||
|
|
1ee2976765 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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',[]}}]}.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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',[]}}]}.
|
||||||
@ -9,6 +9,23 @@ defmodule Symbiont.API do
|
|||||||
GET /ledger — Recent inference calls
|
GET /ledger — Recent inference calls
|
||||||
GET /ledger/stats — Aggregate cost & usage
|
GET /ledger/stats — Aggregate cost & usage
|
||||||
GET /health — Simple liveness probe
|
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
|
use Plug.Router
|
||||||
|
|
||||||
@ -17,7 +34,8 @@ defmodule Symbiont.API do
|
|||||||
plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
|
plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
|
||||||
plug(:dispatch)
|
plug(:dispatch)
|
||||||
|
|
||||||
# -- POST /task --
|
# ── Task Endpoints ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
post "/task" do
|
post "/task" do
|
||||||
task = conn.body_params["task"]
|
task = conn.body_params["task"]
|
||||||
force_tier = conn.body_params["force_tier"]
|
force_tier = conn.body_params["force_tier"]
|
||||||
@ -49,7 +67,8 @@ defmodule Symbiont.API do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- POST /queue --
|
# ── Queue Endpoints ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
post "/queue" do
|
post "/queue" do
|
||||||
task = conn.body_params["task"]
|
task = conn.body_params["task"]
|
||||||
priority = conn.body_params["priority"] || "normal"
|
priority = conn.body_params["priority"] || "normal"
|
||||||
@ -68,7 +87,8 @@ defmodule Symbiont.API do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- GET /status --
|
# ── Status Endpoints ────────────────────────────────────────────────────
|
||||||
|
|
||||||
get "/status" do
|
get "/status" do
|
||||||
ledger_stats = Symbiont.Ledger.stats()
|
ledger_stats = Symbiont.Ledger.stats()
|
||||||
queue_size = Symbiont.Queue.size()
|
queue_size = Symbiont.Queue.size()
|
||||||
@ -87,33 +107,164 @@ defmodule Symbiont.API do
|
|||||||
send_json(conn, 200, response)
|
send_json(conn, 200, response)
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- GET /ledger --
|
|
||||||
get "/ledger" do
|
get "/ledger" do
|
||||||
entries = Symbiont.Ledger.recent(50)
|
entries = Symbiont.Ledger.recent(50)
|
||||||
send_json(conn, 200, %{entries: entries, count: length(entries)})
|
send_json(conn, 200, %{entries: entries, count: length(entries)})
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- GET /ledger/stats --
|
|
||||||
get "/ledger/stats" do
|
get "/ledger/stats" do
|
||||||
stats = Symbiont.Ledger.stats()
|
stats = Symbiont.Ledger.stats()
|
||||||
send_json(conn, 200, stats)
|
send_json(conn, 200, stats)
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- GET /health --
|
|
||||||
get "/health" do
|
get "/health" do
|
||||||
send_json(conn, 200, %{status: "ok", runtime: "elixir/otp"})
|
send_json(conn, 200, %{status: "ok", runtime: "elixir/otp"})
|
||||||
end
|
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
|
match _ do
|
||||||
send_json(conn, 404, %{error: "not found"})
|
send_json(conn, 404, %{error: "not found"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- Helpers --
|
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp send_json(conn, status, body) do
|
defp send_json(conn, status, body) do
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("application/json")
|
|> put_resp_content_type("application/json")
|
||||||
|> send_resp(status, Jason.encode!(body))
|
|> send_resp(status, Jason.encode!(body))
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -7,6 +7,7 @@ defmodule Symbiont.Application do
|
|||||||
├── Task.Supervisor (Symbiont.TaskSupervisor)
|
├── Task.Supervisor (Symbiont.TaskSupervisor)
|
||||||
├── Symbiont.Ledger — append-only cost log
|
├── Symbiont.Ledger — append-only cost log
|
||||||
├── Symbiont.Queue — persistent task queue
|
├── Symbiont.Queue — persistent task queue
|
||||||
|
├── Symbiont.Engram — cross-session memory (SQLite)
|
||||||
├── Symbiont.Heartbeat — periodic health checks + queue processing
|
├── Symbiont.Heartbeat — periodic health checks + queue processing
|
||||||
└── Bandit (Symbiont.API) — HTTP API
|
└── Bandit (Symbiont.API) — HTTP API
|
||||||
|
|
||||||
@ -29,11 +30,13 @@ defmodule Symbiont.Application do
|
|||||||
File.mkdir_p!(data_dir)
|
File.mkdir_p!(data_dir)
|
||||||
|
|
||||||
port = Application.get_env(:symbiont, :port, 8111)
|
port = Application.get_env(:symbiont, :port, 8111)
|
||||||
|
engram_db = Application.get_env(:symbiont, :engram_db, "/data/symbiont/engram.db")
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
{Task.Supervisor, name: Symbiont.TaskSupervisor},
|
{Task.Supervisor, name: Symbiont.TaskSupervisor},
|
||||||
{Symbiont.Ledger, data_dir: data_dir},
|
{Symbiont.Ledger, data_dir: data_dir},
|
||||||
{Symbiont.Queue, data_dir: data_dir},
|
{Symbiont.Queue, data_dir: data_dir},
|
||||||
|
{Symbiont.Engram, db_path: engram_db},
|
||||||
{Symbiont.Heartbeat, []},
|
{Symbiont.Heartbeat, []},
|
||||||
{Bandit, plug: Symbiont.API, port: port, scheme: :http}
|
{Bandit, plug: Symbiont.API, port: port, scheme: :http}
|
||||||
]
|
]
|
||||||
|
|||||||
618
lib/symbiont/engram.ex
Normal file
618
lib/symbiont/engram.ex
Normal 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
|
||||||
7
mix.exs
7
mix.exs
@ -4,7 +4,7 @@ defmodule Symbiont.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :symbiont,
|
app: :symbiont,
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
elixir: "~> 1.19",
|
elixir: "~> 1.19",
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
deps: deps(),
|
deps: deps(),
|
||||||
@ -15,7 +15,7 @@ defmodule Symbiont.MixProject do
|
|||||||
|
|
||||||
def application do
|
def application do
|
||||||
[
|
[
|
||||||
extra_applications: [:logger],
|
extra_applications: [:logger, :crypto],
|
||||||
mod: {Symbiont.Application, []}
|
mod: {Symbiont.Application, []}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
@ -24,7 +24,8 @@ defmodule Symbiont.MixProject do
|
|||||||
[
|
[
|
||||||
{:bandit, "~> 1.0"},
|
{:bandit, "~> 1.0"},
|
||||||
{:plug, "~> 1.15"},
|
{:plug, "~> 1.15"},
|
||||||
{:jason, "~> 1.4"}
|
{:jason, "~> 1.4"},
|
||||||
|
{:exqlite, "~> 0.27"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
4
mix.lock
4
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"},
|
"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"},
|
"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"},
|
"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"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user